C言語の「Hello World!」プログラムで使われる、「printf()」「main()」関数の中身を、デバッガによる解析と逆アセンブル、ソースコード読解などのさまざまな側面から探る連載。前回から、printf()内のwrite()やint $0x80の呼び出しについてLinuxカーネルのソースコード側から探ってきた。今回は、さらにシステムコールについて学ぶ。
書籍の中から有用な技術情報をピックアップして紹介する本シリーズ。今回は、秀和システム発行の書籍『ハロー“Hello, World” OSと標準ライブラリのシゴトとしくみ(2015年9月11日発行)』からの抜粋です。
ご注意:本稿は、著者及び出版社の許可を得て、そのまま転載したものです。このため用字用語の統一ルールなどは@ITのそれとは一致しません。あらかじめご了承ください。
※編集部注:前回記事「Linuxカーネルのソースコードを読んで、システムコールを探る」はこちら
システムコールのパラメータとしてLinuxカーネルに渡されるものには、どのシステムコールが呼ばれているかの番号と、そのシステムコールの引数の2種類がある。
まず、システムコール番号について考えてみよう。
もう一度、Linuxカーネルに話を戻そう。システムコールの処理関数はsys_call_tableというシステムコール・テーブル(言い替えると関数へのポインタの配列)から、EAXレジスタをインデックスにして取得されていた。このインデックスがシステムコール番号ということになる。
前回の表3.1によれば、int $0x80の呼び出し時のEAXレジスタの値は4になっている。
syscall_table_32.Sのsys_call_tableを、もう一度見てみよう。
ENTRY(sys_call_table) .long sys_restart_syscall /* 0 - old "setup()" system call, used for restarting */ .long sys_exit .long ptregs_fork .long sys_read .long sys_write .long sys_open /* 5 */ .long sys_close ...
先頭をゼロとして順に数えて4番目は、sys_writeという関数だ。これはおそらくwriteシステムコールの処理関数だろう。つまり呼ばれているのはwriteシステムコールであり、そのシステムコール番号は4である、ということができるわけだ。
実はシステムコール番号は、arch/x86/include/asm/unistd_32.hというヘッダファイルで定義されている。
見てみると、writeシステムコールは以下のようにして定義されている。やはり4という値で合っているようだ。
12:#define __NR_write 4
次にシステムコールの引数について考えてみよう。システムコールの引数はどのように渡されているのだろうか。
Linuxカーネルのシステムコール処理では、system_callからsyscall_callに入り、さらにcall命令によってシステムコールの処理関数が呼ばれている。
その先はC言語の関数であるから、そこまでに関数呼び出しの引数の設定が行われているはずだ。そして連載第1回で説明したように、x86では関数の引数はスタック渡しだ。つまりスタック上に値を保存している箇所があれば、そこが処理関数用に引数の準備をしている場所だ。
そのような視点で探すとSAVE_ALLというマクロがentry_32.Sで以下のように定義されており、system_callの内部で使われている。
194:.macro SAVE_ALL 195: cld 196: PUSH_GS 197: pushl %fs ... 218: pushl %edx 219: CFI_ADJUST_CFA_OFFSET 4 220: CFI_REL_OFFSET edx, 0 221: pushl %ecx 222: CFI_ADJUST_CFA_OFFSET 4 223: CFI_REL_OFFSET ecx, 0 224: pushl %ebx 225: CFI_ADJUST_CFA_OFFSET 4 226: CFI_REL_OFFSET ebx, 0 ...
push命令により、EDX、ECX、EBXレジスタの値をスタックに格納している。これらがスタック上に配置された状態でcall命令によりシステムコールの処理関数が呼ばれるため、EBX〜EDXに格納されていた値がシステムコールの処理関数に、引数として渡されることになる。
つまりLinux/x86では、EAXでシステムコール番号を、EBX以降で引数を渡しているということになる。前回の表3.1を拡張した形でまとめると、表3.2のようになる。
レジスタ | 値 | 意味 | |
---|---|---|---|
EAX | 4 | システムコール番号(write()は4) | |
EBX | 1 | write()の第1引数(出力先ファイルディスクリプタ。標準出力は1) | |
ECX | 0xb7fff000 | write()の第2引数(出力データのアドレス) | |
EDX | 38 | write()の第3引数(出力データのサイズ) | |
これはアプリケーション・プログラム側からすると、EAXにシステムコール番号、EBX以降に引数を設定してint $0x80を実行することでシステムコールが発行され、あとはLinuxカーネルが当該の処理を行ってくれるということになる。
つまりLinux/x86は、システムコール番号をEAXに、システムコールの引数をEBX以降に設定してint $0x80を呼ぶというシステムコール体系だといえる。
ただしこれはLinux/x86特有の話であって、Linux以外のOSならば異なるかもしれない。たとえばFreeBSDでは同じx86用でも、引数の渡しかたは異なる。そしてx86以外のアーキテクチャならばそもそもレジスタ構成が違うので、また異なることになる。
つまりこれはLinux/x86のシステムコール仕様、ということができる。
Linux/x86ではEAXレジスタにシステムコール番号、EBX以降のレジスタに引数を設定してint $0x80を実行することでシステムコールを呼び出すことができる、ということはわかった。
しかしそれらの作業は、C言語では記述できない。アセンブラで記述する必要がある。
そうしたコードをプログラマがすべて書くことは面倒だ。アセンブラで記述はするが、C言語から呼び出すことができる関数の形にライブラリ化して、システム側から提供してもらいたい。
そして多くの環境では実際にそのようなライブラリが用意され提供されているため、一般のプログラマがこうしたことを気にする必要は無い。write()を呼び出せば、あとはライブラリ側で適切な処理をしてくれる。実際にはライブラリの呼び出しの先で、レジスタ設定とint $0x80の実行が行われているわけだ。
そのような役割のアセンブラで書かれた関数は、システムコール・ラッパー(SystemCall Wrapper)と呼ばれる。
プログラミングの世界でラッパーというと、何らかの処理を覆っているような処理のことになる。一枚かぶせる、といった言いかたをしたりもする。プログラミングの他にも例えば連載第2回で説明したGDBのGUIインターフェースなどは、GDBのGUIラッパーである、などと言ったりする。これはユーザ・インターフェースのラッパーの例だ。
ではシステムコール・ラッパーの実体は、どこにあるのだろうか。
int $0x80実行前のスタックの状態をもう一度見てみよう。
(gdb) x/16x $esp 0xbffff4d8: 0x08053d92 0x00000026 0x08067671 0x00000001 0xbffff4e8: 0xb7fff000 0x00000026 0x080d68c0 0x00000026 0xbffff4f8: 0xb7fff000 0xbffff524 0x0806819b 0x080d68c0 0xbffff508: 0xb7fff000 0x00000026 0xbffff544 0x08069732 (gdb)
x86では関数呼び出しの際には、スタックの先頭に戻り先アドレスが格納される。スタックのダンプの上のほうにある0x08053d92や0x08067671といった値は、連載第1回で見た機械語コードのアドレス値に似ていて、戻り先アドレスのように思える。
ここで連載第1回のバックトレースでは、以下のように関数が呼び出されていたことを思い出してほしい。
(gdb) where #0 0x00110416 in __kernel_vsyscall () #1 0x08053d92 in __write_nocancel () #2 0x08067671 in _IO_new_file_write () #3 0x0806819b in _IO_new_do_write () #4 0x080683ea in _IO_new_file_overflow () #5 0x080673f4 in _IO_new_file_xsputn () #6 0x08059738 in vfprintf () #7 0x08049381 in printf () #8 0x080482e2 in main (argc=1, argv=0xbffffc14) at hello.c:5 (gdb)
__write_nocancel()と_IO_new_file_write()のアドレスに注目してほしい。これらは0x08053d92と0x08067671になっており、まさにスタックダンプの中に現れている。
x86では関数呼び出し時にはスタックに引数が積まれ、関数呼び出しによってスタックにはさらに戻り先アドレスが詰まれる。つまりスタック上には、戻り先アドレスの次に引数があることになる。
ということは、_IO_new_file_write()→__write_nocancel()のように呼ばれたときの引数が0x00000001、0xb7fff000、0x00000026になっているのだと推測できる。ちなみにスタックを見ると__write_nocancel()→__kernel_vsyscall()のように呼ばれたときの引数が0x00000026であるようにも見えるが、これはアセンブラの処理を見ると単にEBXレジスタの値をスタック上に保存しているだけのもので、関数呼び出しの引数としての意味は無いようだ。
ここでこの3つの値に注目すると、それらはint $0x80が呼ばれるときのEBX、ECX、EDXの値になっている。つまり__write_nocancel()という関数が、関数が呼び出された際にスタック経由で渡された引数をレジスタに設定しているのでは、と思えるわけだ。これがwriteシステムコールのシステムコール・ラッパーだ。
逆アセンブル結果から、__write_nocancel()を探して見てみよう。
[user@localhost hello]$ objdump -d hello | less
__write_nocancelで検索すると、以下のような部分があった。
08053d70 <__libc_write>: 8053d70: 65 83 3d 0c 00 00 00 cmpl $0x0,%gs:0xc 8053d77: 00 8053d78: 75 25 jne 8053d9f <__write_nocancel+0x25> 08053d7a <__write_nocancel>: 8053d7a: 53 push %ebx 8053d7b: 8b 54 24 10 mov 0x10(%esp),%edx 8053d7f: 8b 4c 24 0c mov 0xc(%esp),%ecx 8053d83: 8b 5c 24 08 mov 0x8(%esp),%ebx 8053d87: b8 04 00 00 00 mov $0x4,%eax 8053d8c: ff 15 50 67 0d 08 call *0x80d6750
前回説明したが、ソースコードを読むときのコツとして、付近も見てみるようにすると、実際には__libc_write()という関数があり、その直後の__write_nocancelという処理が継続実行されているようで、_IO_new_file_write()からも__libc_write()が呼び出されていることに気がつく。
連載第4回で調べた結果では、__write_nocancelの直前にはwrite()があったはずだ。そしてreadelfの結果を見ると、__libc_write()と同じアドレスにwrite()が配置されているため、実質はwrite()が呼び出されているようだ(前回でwrite()にブレークポイントを張っていたことを思い出してほしい)。GDBのバックトレースでは「__write_nocancel()」のように表示されているが、これはスタック上に保存されている戻り先アドレスから、直前のシンボルを単純に検索して表示しているためだろう。
そして__write_nocancelの内部では、mov命令によりスタック上の引数をEDX、ECX、EBXレジスタに設定し、システムコール番号の4をEAXレジスタに設定している。さらにその後のcall命令により、int $0x80を行う__kernel_vsyscall()が呼ばれているのだろう。
call命令によるジャンプ先は、0x80d6750というアドレスに格納されているアドレスになっている。つまりC言語風に書くと、以下のような関数呼び出しが行われている。
int (*f)(); f = (int (*)())0x80d6750; f();
これが__kernel_vsyscall()の呼び出しになっているはずだ。
確認してみよう。前述の手順でwriteシステムコールの呼び出しのためのint $0x80の実行位置でブレークする。
[user@localhost hello]$ gdb -q hello Reading symbols from /home/user/hello/hello...done. (gdb) break write Breakpoint 1 at 0x8053d70 (gdb) run Starting program: /home/user/hello/hello Breakpoint 1, 0x08053d70 in write () (gdb) break *0x110414 Breakpoint 2 at 0x110414 (gdb) continue Continuing. Breakpoint 2, 0x00110414 in __kernel_vsyscall () (gdb)
0x80d6750の指す先を、GDB上で逆アセンブルしてみよう。
(gdb) print/x *0x80d6750 $1 = 0x110414 (gdb) disassemble *0x80d6750 Dump of assembler code for function __kernel_vsyscall: => 0x00110414 <+0>: int $0x80 0x00110416 <+2>: ret End of assembler dump. (gdb)
確かにint $0x80の処理が呼ばれているようだ。
Copyright © ITmedia, Inc. All Rights Reserved.