Linuxカーネルに見る、システムコール番号と引数、システムコール・ラッパーとは:main()関数の前には何があるのか(7)(2/2 ページ)
C言語の「Hello World!」プログラムで使われる、「printf()」「main()」関数の中身を、デバッガによる解析と逆アセンブル、ソースコード読解などのさまざまな側面から探る連載。前回から、printf()内のwrite()やint $0x80の呼び出しについてLinuxカーネルのソースコード側から探ってきた。今回は、さらにシステムコールについて学ぶ。
戻り値の返しかたを見る
Linux/x86のwriteシステムコールはシステムコール・ラッパーが呼び出されることでレジスタの準備がされ、レジスタ経由でパラメータが渡されていることはわかった。
ではシステムコールの戻り値は、どのようにして返されるのだろうか?
システムコールの戻り値
Linuxカーネルの処理に戻ろう。entry_32.Sでのシステムコール処理関数の呼び出しは、以下のようになっていた。
529:syscall_call: 530: call *sys_call_table(,%eax,4) 531:syscall_after_call: 532: movl %eax,PT_EAX(%esp) # store the return value 533:syscall_exit: ...
sys_call_table[]に登録されたシステムコール処理関数を呼び出した後、EAXレジスタの値をスタック上のEAX格納位置に格納している。
システムコールの先頭ではSAVE_ALLというマクロによってレジスタの値がスタックに退避されていたが、その先を読むと以下のような部分があり、システムコールの終了時にはRESTORE_REGSというマクロでその逆が、つまりスタック上に退避された値がレジスタに書き戻されていることがわかる。
これはシステムコール処理の完了後に、レジスタの値を復帰してアプリケーションに戻るようにするためだ。
558:restore_nocheck: 559: RESTORE_REGS 4 # skip orig_eax/error_code 560: CFI_ADJUST_CFA_OFFSET -4 561:irq_return: 562: INTERRUPT_RETURN
よってシステムコール処理関数の戻り値がスタック上のEAX退避位置に書き込まれ、復帰時にはそれがEAXレジスタに戻されてアプリケーションに戻ることになる。
つまりアプリケーション側から見れば、int $0x80からの復帰時にはシステムコール処理関数の戻り値がEAXレジスタに格納されて戻ってくることになる。__write_nocancelの逆アセンブル結果を見てみると以下のようになっており、int $0x80を実行するためのcall命令の後に、ret命令がある。
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 8053d92: 5b pop %ebx 8053d93: 3d 01 f0 ff ff cmp $0xfffff001,%eax 8053d98: 0f 83 b2 28 00 00 jae 8056650 <__syscall_error> 8053d9e: c3 ret
連載第4回で説明したように、x86アーキテクチャでは、関数からの戻り値はEAXレジスタによって返される。よってシステムコール処理関数の戻り値が、__write_nocancel()の戻り値として返されることになる。
疑問なのはret命令の前に、jaeというジャンプ処理があることだ。
条件によってはこのジャンプ命令によって、ret命令によるリターン前に__syscall_errorという処理にジャンプすることになる。これは、いったい何が行われているのだろうか。
errnoを設定するのは誰か?
ここでシステムコールがエラーを返したときのことを考えてみたい。UNIXライクなOSでは、システムコールのエラー時には負の値を返し、グローバル変数のerrnoにエラー番号が設定される、というのが通例だ。
しかしこのerrnoという変数に、カーネルが値を設定することはできるのだろうか?
errnoはアプリケーションのメモリ空間にあるものだ。まあ頑張れば不可能ではないが、やるべきではない。カーネルから見れば、errnoはアプリケーションが持っているメモリ空間の一部分に過ぎないもので、なんら特別なものではないからだ。
ではerrnoはどこで設定されるのだろうか?システムコールの呼び出し後には、条件によって__syscall_errorという処理にジャンプしている。名前からして、これはエラー時の処理に思える。もう一度、よく見てみよう。
8053d93: 3d 01 f0 ff ff cmp $0xfffff001,%eax 8053d98: 0f 83 b2 28 00 00 jae 8056650 <__syscall_error>
システムコールの戻り値はEAXレジスタによって返される。これをcmp命令でチェックし、特定の条件のときに__syscall_errorが呼ばれる。
jae命令はcmp命令と組み合わせて、値の大小の条件判断を行う。具体的には上のような場合、「EAX >= 0xfffff001」という条件判断になる。
実際に以下のような簡単なプログラムを書いてGDBのステップ実行により試すと、EAXが0xfffff001〜0xffffffffの間の値のときのみ、jae命令によるジャンプが行われていた。アセンブラの命令の動作をちょっと確認したいような場合には、このようなプログラムを即席で書いてGDBで実行してみるのが手軽だ。
.global main main: mov $-1, %eax cmp $0xfffff001, %eax jae match jmp unmatch match: nop unmatch: ret
これは符号付き10進数にすると、−4095〜−1の値になる。ということはシステムコールの戻り値が−4095〜−1の場合に、__syscall_errorが呼ばれるということだ。
errnoの設定処理
__syscall_errorでは何が行われているのだろうか。helloを逆アセンブルした出力を確認すると、以下のようになっていた。
08056650 <__syscall_error>: 8056650: f7 d8 neg %eax 08056652 <__syscall_error_1>: 8056652: 65 8b 0d 00 00 00 00 mov %gs:0x0,%ecx 8056659: 89 81 e8 ff ff ff mov %eax,0xffffffe8(%ecx) 805665f: b8 ff ff ff ff mov $0xffffffff,%eax 8056664: c3 ret
negは正負の反転命令だ。つまりEAXが−1ならば1に、1ならば−1になる。
さらにEAXの値をどこかのメモリ領域に書き込んでいるようだ。そしてEAXに−1を設定して返っている。
ということはEAXが−4095〜−1の値の場合には、正負反転させた値がメモリ上のどこかに書き込まれ、EAXは−1に置き換えられるということだ。これがerrnoの設定になるのではないだろうか?
UNIXライクなOSでのC言語のシステムコールAPIの多くは、エラー発生時には−1が返されてerrnoにエラー番号が設定される、ということになっている。しかしLinux/x86は、エラー番号を負の値で返すのではないだろうか。errnoをカーネルが直接書き換えるのは不適切なので、errnoの設定はシステムコール・ラッパー側に任せる、という仕組みになっているようだ。
つまりC言語風に書くと、システムコール呼び出しの後には以下のような処理を行っていることになる。
int ret = int0x80(); if ((ret < 0) && (ret > -4096)) { errno = -ret; ret = -1; } return ret;
Linuxカーネルのエラーの返しかた
Linuxカーネル側を確認してみよう。
「システムコール番号」によれば、writeシステムコールの処理はsys_writeによって行われる、という話になっていた。syscall_table_32.Sで定義されている以下のシステムコールのテーブルの、ゼロから数えて4番目のエントリだ。
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 ...
sys_writeの処理を実際に見てみよう。これはアーキテクチャに非依存の処理になるので、arch/x86の下には無いだろう。
探してみると、fs/read_write.cというファイルで以下のように定義されていた。なお定義にはSYSCALL_DEFINE3()というマクロが利用されており、これがSYSCALL_DEFINEx()、__SYSCALL_DEFINEx()というマクロを経由して、最終的にsys_write()に展開されるようだ。#defineの展開について詳しくは、include/linux/syscalls.hを参照してほしい。
389:SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, 390: size_t, count) 391:{ 392: struct file *file; 393: ssize_t ret = -EBADF; 394: int fput_needed; 395: 396: file = fget_light(fd, &fput_needed); 397: if (file) { 398: loff_t pos = file_pos_read(file); 399: ret = vfs_write(file, buf, count, &pos); 400: file_pos_write(file, pos); 401: fput_light(file, fput_needed); 402: } 403: 404: return ret; 405:}
戻り値を格納している変数retの初期値が-EBADFのようにして、負の値が設定されている点に注目だ。
またretにはvfs_write()という関数の戻り値が格納されている箇所があるが、vfs_write()は同じfs/read_write.cの中で定義されている。先頭部分を見ると以下のようになっていて、やはりエラー番号を負の値で返している。
332:ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_t*pos) 333:{ 334: ssize_t ret; 335: 336: if (!(file->f_mode & FMODE_WRITE)) 337: return -EBADF; 338: if (!file->f_op || (!file->f_op->write && !file->f_op->aio_write)) 339: return -EINVAL; 340: if (unlikely(!access_ok(VERIFY_READ, buf, count))) 341: return -EFAULT; ...
つまりLinuxカーネル内では、システムコール処理関数はエラー時にはエラー番号を負の値で返すという約束のようだ。これはLinuxカーネル内の仕様だ。
そしてシステムコール自体も、その値をそのまま返す。つまりエラーは負の値で返ってくる。これはx86依存部で行われているためLinux/x86の仕様になるが、他のアーキテクチャ向けのLinuxでも同様のようだ。
さらにアプリケーション側のシステムコール・ラッパーではこれを受け取り、負の値ならばerrnoに設定して−1を返す、という動作をしていることになる。ここでようやくAPIの仕様として吸収され、移植性が確保される。
errnoはエラー番号が保存される変数だが、その実体は標準Cライブラリ側で用意されたグローバル変数だ。よってエラーの際にLinuxカーネル側でそれを設定するのは適切でないしそもそもerrnoのアドレスはアプリケーションによって変化する前提がある。変数のアドレスはリンカによるリンク時に決定されるためだ。
このためLinux/x86の仕様としてはエラーは負の値で返し、それをerrnoに設定するのはシステムコール・ラッパー側の役割、ということになる。もちろんその処理じたいは標準Cライブラリの中で実装されているため、一般プログラマが意識する必要はないわけだ。
連載第6〜7回のまとめ
連載第6〜7回ではOSカーネルとアプリケーション・プログラムの接点を見てみた。
複数のモジュールの接点となる部分の実装は筆者が興味深く感じる点だが、これがアプリケーション・プログラムの場合には、システムコールになる。そしてそれを片側からだけでなく、両側から見てみることで理解は深まる。システムコールの実装ならば、アプリケーション側とカーネル側の両方から見てみるといいだろう。
そしてこのような部分は理論的に理解することも大切だが、それだけで終わらせずに現物ベースで実装を見てみることも必要だ。そこで連載第6〜7回では、現物を両側から見る、というやりかたを実践してみた。
書籍紹介
ハロー“Hello, World” OSと標準ライブラリのシゴトとしくみ
坂井弘亮著
秀和システム 3,200円
C言語の入門書では、"Hello, World"と出力するプログラムを最初に作るのが定番です。"Hello, World"は、たった7行の単純なプログラムですが、printf()の先では何が行われているのか、main()の前にはいったい何があるのか、考えてみると謎だらけです。本書は、基礎中の基礎である"Hello, World"プログラムを元に、OSと標準ライブラリの仕組みをあらゆる角度からとことん解析します。資料に頼らず、自分の手で調べる方法がわかります。
Copyright © ITmedia, Inc. All Rights Reserved.
関連記事
- プログラミング言語Cについて知ろう
プログラミング言語の基本となる「C」。正しい文法や作法を身に付けよう。Cには確かに学ぶだけの価値がある(編集部) - シェルコード解析に必携の「5つ道具」
コンピュータウイルスの解析などに欠かせないリバースエンジニアリング技術ですが、何だか難しそうだな、という印象を抱いている人も多いのではないでしょうか。この連載では、「シェルコード」を例に、実践形式でその基礎を紹介していきます。(編集部) - 【 od 】コマンド――ファイルを8進数や16進数でダンプする
本連載は、Linuxのコマンドについて、基本書式からオプション、具体的な実行例までを紹介していきます。今回は、「od」コマンドです。