引き続き「エキスパートCプログラミング」ネタ。setjmp()とlongjmp()が出てきたので、早速実験。
ちょうど「デーモン君のソース探検」ようにCVSからソースを落としてきたNetBSD1.6があったので、setjmp()/longjmp()の中身を調べ、動作原理をgdbで追いつめていきたい。
まずは「エキスパートCプログラミング」に載っていたサンプルプログラム。
test7.c:
#include <stdio.h> #include <setjmp.h> jmp_buf buf; void banana() { printf("in banana()\n"); /* (2) */ longjmp(buf, 1); /* NOTREACHED */ printf("you'll never see this, because I longjmp'd.\n"); } int main() { if (setjmp(buf)) { printf("back in main\n"); /* (3) */ } else { printf("first time through...\n"); /* (1) */ banana(); } return 0; }
そのまま動かすと、上の"(1)","(2)","(3)"の順で処理が進むことが確認出来る。
$ ./test7 first time through... in banana() back in main
では早速gdbで追っていきたい。
gdbの前に、nmコマンドで主要なシンボルのアドレスを押さえておく。
$ nm test7 (主要シンボルだけ抽出) U __longjmp14 U __setjmp14 08048974 T banana 08049d60 B buf 080489b0 T main U printf
実際に使われるのが__setjmp14, __longjmp14になっている。なぜそうなってしまうのかも謎だが、一旦そちらの謎は置いておいて、とりあえず素直に __setjmp14, __longjmp14のソースコードがどこにあるか確認する。
$ locate setjmp
あらかじめソースをCVSからチェックアウト後、"/etc/weekly"でlocateのデータベースを構築しておいた。アーキテクチャ依存などかなり大量のファイルがヒットするが、i386用のソースファイルは以下に見つかった。
/usr/src/lib/libc/arch/i386/gen/__setjmp14.S
このファイルは後々、すこしだけちら見することになる。実際は、動作原理を把握する分であればgdb上での逆アセンブルだけで大凡分かる。
ではgdbを始める。mainにブレークポイントを置き実行する。なお今回は "*main" という風にアスタリスクを頭に置き、インストラクション単位でmainに入った段階で停まるようにした。単に"main"とすると、アセンブリ言語レベルでの関数の先頭ではなく、ソースコードに対応する少し後のアドレスになってしまうからだ。
$ gdb test7 GNU gdb 5.0nb1 ... This GDB was configured as "i386--netbsdelf"... (gdb) b *main Breakpoint 1 at 0x80489b0: file test7.c, line 11. (gdb) run Starting program: /home/msakamoto/lang.c/test7 Breakpoint 1, 0x80489b0 in main () at test7.c:11 11 }
ここで逆アセンブルしてみる。
(gdb) disas Dump of assembler code for function main: 0x80489b0 <main>: push %ebp 0x80489b1 <main+1>: mov %esp,%ebp 0x80489b3 <main+3>: sub $0x8,%esp 0x80489b6 <main+6>: add $0xfffffff4,%esp 0x80489b9 <main+9>: push $0x8049d60 0x80489be <main+14>: call 0x8048634 <__setjmp14> 0x80489c3 <main+19>: add $0x10,%esp 0x80489c6 <main+22>: mov %eax,%eax 0x80489c8 <main+24>: test %eax,%eax 0x80489ca <main+26>: je 0x80489e0 <main+48> 0x80489cc <main+28>: add $0xfffffff4,%esp 0x80489cf <main+31>: push $0x8048bcd 0x80489d4 <main+36>: call 0x8048644 <printf> 0x80489d9 <main+41>: add $0x10,%esp 0x80489dc <main+44>: jmp 0x80489f5 <main+69> 0x80489de <main+46>: mov %esi,%esi 0x80489e0 <main+48>: add $0xfffffff4,%esp 0x80489e3 <main+51>: push $0x8048bdb 0x80489e8 <main+56>: call 0x8048644 <printf> 0x80489ed <main+61>: add $0x10,%esp 0x80489f0 <main+64>: call 0x8048974 <banana> 0x80489f5 <main+69>: xor %eax,%eax ---Type <return> to continue, or q <return> to quit--- 0x80489f7 <main+71>: jmp 0x80489fc <main+76> 0x80489f9 <main+73>: lea 0x0(%esi),%esi 0x80489fc <main+76>: leave 0x80489fd <main+77>: ret End of assembler dump. (gdb) p/x $pc $1 = 0x80489b0
ここで"__setjmp14"にブレークポイントを設定する。
(gdb) b *__setjmp14 Breakpoint 2 at 0x4808e24c
0x4808e24cと、上の逆アセンブルでのcall先アドレスとは異なる場所に設定されてしまった。これはダイナミックリンクによるPLTの影響なので、今は無視しても問題ないはず。
(gdb) b *0x80489be Breakpoint 3 at 0x80489be: file test7.c, line 14.
→アセンブラレベルでの "__setjmp14" のcall命令にもブレークポイントを設置する。
"info break"コマンドでブレークポイントの設置状況を確認後、"c"でcontinueし、"__setjmp14"のcall命令まで一気に実行する。
(gdb) info break Num Type Disp Enb Address What 1 breakpoint keep y 0x080489b0 in main at test7.c:11 breakpoint already hit 1 time 2 breakpoint keep y 0x4808e24c <__setjmp14> 3 breakpoint keep y 0x080489be in main at test7.c:14 (gdb) c Continuing. Breakpoint 3, 0x80489be in main () at test7.c:14 14 if (setjmp(buf)) { (gdb) c Continuing. Breakpoint 2, 0x4808e24c in __setjmp14 () from /usr/lib/libc.so.12
__setjmp14の中に到達したので、逆アセンブルする。
(gdb) disas Dump of assembler code for function __setjmp14: 0x4808e24c <__setjmp14>: mov 0x4(%esp,1),%ecx 0x4808e250 <__setjmp14+4>: mov 0x0(%esp,1),%edx 0x4808e254 <__setjmp14+8>: mov %edx,0x0(%ecx) 0x4808e257 <__setjmp14+11>: mov %ebx,0x4(%ecx) 0x4808e25a <__setjmp14+14>: mov %esp,0x8(%ecx) 0x4808e25d <__setjmp14+17>: mov %ebp,0xc(%ecx) 0x4808e260 <__setjmp14+20>: mov %esi,0x10(%ecx) 0x4808e263 <__setjmp14+23>: mov %edi,0x14(%ecx) 0x4808e266 <__setjmp14+26>: lea 0x18(%ecx),%edx 0x4808e269 <__setjmp14+29>: push %ebx 0x4808e26a <__setjmp14+30>: call 0x4808e26f <__setjmp14+35> 0x4808e26f <__setjmp14+35>: pop %ebx 0x4808e270 <__setjmp14+36>: add $0x5e305,%ebx 0x4808e276 <__setjmp14+42>: push %edx 0x4808e277 <__setjmp14+43>: push $0x0 0x4808e279 <__setjmp14+45>: push $0x0 0x4808e27b <__setjmp14+47>: call 0x48072aa4 <_init+3016> 0x4808e280 <__setjmp14+52>: add $0xc,%esp 0x4808e283 <__setjmp14+55>: pop %ebx 0x4808e284 <__setjmp14+56>: xor %eax,%eax 0x4808e286 <__setjmp14+58>: ret 0x4808e287 <__setjmp14+59>: nop ---Type <return> to continue, or q <return> to quit---q Quit
実際のアセンブラソースも載せておく。(BSDライセンスだから多丈夫なはず。)
ENTRY(__setjmp14) movl 4(%esp),%ecx movl 0(%esp),%edx movl %edx, 0(%ecx) movl %ebx, 4(%ecx) movl %esp, 8(%ecx) movl %ebp,12(%ecx) movl %esi,16(%ecx) movl %edi,20(%ecx) /* Get the signal mask. */ leal 24(%ecx),%edx PIC_PROLOGUE pushl %edx pushl $0 pushl $0 #ifdef PIC call PIC_PLT(_C_LABEL(__sigprocmask14)) #else call _C_LABEL(__sigprocmask14) #endif addl $12,%esp PIC_EPILOGUE xorl %eax,%eax ret
この時点でESP/EBP, そしてスタックの状況を確認してみる。
(gdb) p/x $esp $1 = 0xbfbfdb40 (gdb) p/x $ebp $2 = 0xbfbfdb5c (gdb) x/40w $esp 0xbfbfdb40: 0x080489c3 0x08049d60 0xbfbfdbcc 0x0804001f 0xbfbfdb50: 0x4804001f 0xbfbfdbcc 0xbfbfdb78 0xbfbfdba8 0xbfbfdb60: 0x08048780 0x00000001 0xbfbfdbcc 0xbfbfdbd4 0xbfbfdb70: 0x00000246 0x48056200 0xbfbfdba8 0x08048769 0xbfbfdb80: 0x08048a34 0x48056200 0x480534a8 0x080486ea 0xbfbfdb90: 0xbfbfdff0 0x00000000 0x00000000 0xbfbfdc24 0xbfbfdba0: 0xbfbfdc1c 0x00000000 0x00000000 0x080486db 0xbfbfdbb0: 0x00000001 0xbfbfdbcc 0xbfbfdbd4 0x4804abc0 0xbfbfdbc0: 0x48056200 0xbfbfdff0 0x00000001 0xbfbfdc5c 0xbfbfdbd0: 0x00000000 0xbfbfdc79 0xbfbfdc94 0xbfbfdc9f
x86ではスタックはアドレスの小さい方に伸びていくので、0xbfbfdb40というのが間違いなくスタックのTOP。スタックフレームのTOP近くを解析してみる。
0xbfbfdb40: 0x080489c3 <= ESP, 戻り先アドレス→setjmp()の直後の命令を指す。 0xbfbfdb44: 0x08049d60 <= 引数, これはソースコードでの "jmp_buf buf"へのアドレス。(nmの結果を復習)
0xbfbfdb48以降は、ここに至るまでのスタックフレームになり、本題から外れるので解析は省略する。
この2点が把握できれば、__setjmp14の次のアセンブラコードの意味も明らかになる。
ENTRY(__setjmp14) movl 4(%esp),%ecx movl 0(%esp),%edx movl %edx, 0(%ecx) movl %ebx, 4(%ecx) movl %esp, 8(%ecx) movl %ebp,12(%ecx) movl %esi,16(%ecx) movl %edi,20(%ecx)
まずECXに jmp_buf型へのポインタを格納する。
movl 4(%esp),%ecx
続いて ESP の値 = longjmpでの戻り先アドレスをEDXに格納する。
movl 0(%esp),%edx
EDX(戻り先アドレス)に加えて、EBX, ESI, EDI, ESP, EBPを格納している。なお処理の流れ上、元のEAX, ECX, EDXはjmp_bufには保存されないらしい。
ちなみにjmp_buf型は long[JBLEN] というlong型配列がtypedefされたもので、NetBSD1.6のi386ではJBLEN=13となっている。
$ cd /usr/include $ grep -r jmp_buf * i386/setjmp.h:#define _JBLEN 13 /* size, in longs, of a jmp_buf */ machine/setjmp.h:#define _JBLEN 13 /* size, in longs, of a jmp_buf */ setjmp.h:typedef long sigjmp_buf[_JBLEN + 1] _JB_ATTRIBUTES; setjmp.h:typedef long jmp_buf[_JBLEN] _JB_ATTRIBUTES;
まとめると、setjmp()ではjmp_bufの中に、longjmpで必要な戻り先アドレスや戻った時点で復元したいレジスタ値を保存していることがわかった。
setjmp()の原理が分かったところで、longjmp()をデバッグする為、引き続きgdbに戻る。
(gdb) b *__longjmp14 Breakpoint 4 at 0x4808e288 (gdb) c Continuing. first time through... in banana() Breakpoint 4, 0x4808e288 in __longjmp14 () from /usr/lib/libc.so.12
"__longjmp14()"の中で停まったので、逆アセンブルする。
(gdb) disas Dump of assembler code for function __longjmp14: 0x4808e288 <__longjmp14>: mov 0x4(%esp,1),%ecx 0x4808e28c <__longjmp14+4>: lea 0x18(%ecx),%edx 0x4808e28f <__longjmp14+7>: push %ebx 0x4808e290 <__longjmp14+8>: call 0x4808e295 <__longjmp14+13> 0x4808e295 <__longjmp14+13>: pop %ebx 0x4808e296 <__longjmp14+14>: add $0x5e2df,%ebx 0x4808e29c <__longjmp14+20>: push $0x0 0x4808e29e <__longjmp14+22>: push %edx 0x4808e29f <__longjmp14+23>: push $0x3 0x4808e2a1 <__longjmp14+25>: call 0x48072aa4 <_init+3016> 0x4808e2a6 <__longjmp14+30>: add $0xc,%esp 0x4808e2a9 <__longjmp14+33>: pop %ebx 0x4808e2aa <__longjmp14+34>: mov 0x4(%esp,1),%edx 0x4808e2ae <__longjmp14+38>: mov 0x8(%esp,1),%eax 0x4808e2b2 <__longjmp14+42>: mov 0x0(%edx),%ecx 0x4808e2b5 <__longjmp14+45>: mov 0x4(%edx),%ebx 0x4808e2b8 <__longjmp14+48>: mov 0x8(%edx),%esp 0x4808e2bb <__longjmp14+51>: mov 0xc(%edx),%ebp 0x4808e2be <__longjmp14+54>: mov 0x10(%edx),%esi 0x4808e2c1 <__longjmp14+57>: mov 0x14(%edx),%edi 0x4808e2c4 <__longjmp14+60>: test %eax,%eax 0x4808e2c6 <__longjmp14+62>: jne 0x4808e2c9 <__longjmp14+65> ---Type <return> to continue, or q <return> to quit--- 0x4808e2c8 <__longjmp14+64>: inc %eax 0x4808e2c9 <__longjmp14+65>: mov %ecx,0x0(%esp,1) 0x4808e2cd <__longjmp14+69>: ret 0x4808e2ce <__longjmp14+70>: mov %esi,%esi End of assembler dump.
対応する実際のアセンブラソースも載せておく。
ENTRY(__longjmp14) /* Restore the signal mask. */ movl 4(%esp),%ecx leal 24(%ecx),%edx PIC_PROLOGUE pushl $0 pushl %edx pushl $3 /* SIG_SETMASK */ #ifdef PIC call PIC_PLT(_C_LABEL(__sigprocmask14)) #else call _C_LABEL(__sigprocmask14) #endif addl $12,%esp PIC_EPILOGUE movl 4(%esp),%edx movl 8(%esp),%eax movl 0(%edx),%ecx movl 4(%edx),%ebx movl 8(%edx),%esp movl 12(%edx),%ebp movl 16(%edx),%esi movl 20(%edx),%edi testl %eax,%eax jnz 1f incl %eax 1: movl %ecx,0(%esp) ret
この時点でのESP/EBP/スタックフレーム/バックトレースを確認してみる。
(gdb) p/x $esp $3 = 0xbfbfdb30 (gdb) p/x $ebp $4 = 0xbfbfdb4c (gdb) x/8w $esp 0xbfbfdb30: 0x08048999 0x08049d60 0x00000001 0xbfbfdb5c 0xbfbfdb40: 0x080489ed 0x08048bdb 0xbfbfdbcc 0xbfbfdb5c (gdb) bt #0 0x4808e288 in __longjmp14 () from /usr/lib/libc.so.12 #1 0x8048999 in banana () at test7.c:8 #2 0x80489f5 in main () at test7.c:18 #3 0x8048780 in ___start ()
元のC言語のソースを思い出すと、
longjmp(buf, 1);
なので、
(gdb) x/8w $esp 0xbfbfdb30: 0x08048999 0x08049d60 0x00000001 0xbfbfdb5c
のスタックフレームを逆算すると
戻り先(ESP) : 0x08048999 引数1 (ESP + 4) : 0x08049d60 = "jmp_buf buf" 引数2 (ESP + 8) : 1
となり、C言語のソースと対応していることが分かる。ここまで解析出来れば、本体のアセンブラソースの意味も明らかになる。
movl 4(%esp),%edx movl 8(%esp),%eax movl 0(%edx),%ecx movl 4(%edx),%ebx movl 8(%edx),%esp movl 12(%edx),%ebp movl 16(%edx),%esi movl 20(%edx),%edi testl %eax,%eax jnz 1f incl %eax 1: movl %ecx,0(%esp) ret
まず引数1で渡されたjmp_buf型のポインタをEDXに格納。
movl 4(%esp),%edx
続いて、引数2をEAXに格納。
movl 8(%esp),%eax
setjmp()の中でjmp_bufの中に格納された各レジスタ値を復元。
movl 0(%edx),%ecx movl 4(%edx),%ebx movl 8(%edx),%esp movl 12(%edx),%ebp movl 16(%edx),%esi movl 20(%edx),%edi
次は、元のソースではなくgdb上で逆アセンブルしたソースの方が分かりやすい。
testl %eax,%eax jnz 1f incl %eax
↓
0x4808e2c4 <__longjmp14+60>: test %eax,%eax 0x4808e2c6 <__longjmp14+62>: jne 0x4808e2c9 <__longjmp14+65> 0x4808e2c8 <__longjmp14+64>: inc %eax 0x4808e2c9 <__longjmp14+65>: mov %ecx,0x0(%esp,1) 0x4808e2cd <__longjmp14+69>: ret
まずEAX、つまり引数2のANDを行う(test命令)。
0x4808e2c4 <__longjmp14+60>: test %eax,%eax
その結果、もし引数2が0以外であればZF=0になるので、次のJNZ命令が実行される。
0x4808e2c6 <__longjmp14+62>: jne 0x4808e2c9 <__longjmp14+65>
つまり、次の命令に進む。
0x4808e2c9 <__longjmp14+65>: mov %ecx,0x0(%esp,1)
さらにこの直後はret命令。
一方、もし引数2が0の場合はZF=1になるので、JNZ命令は無視され、次のINC命令が実行される。
0x4808e2c8 <__longjmp14+64>: inc %eax
つまりEAXは 0 + 1 = 1になる。
まとめると、引数2が0以外の時はEAX=longjmp()の戻り値はその値になり、引数2が0の時は1がlongjmp()の戻り値になる。
"longjmp()の戻り値"というよりは「戻る時のsetjmp()の戻り値」の方が正しい表記だろう。
つまり、初回のsetjmp()では0が戻り、longjmp()から戻る時のsetjmp()の戻り値は必ず0以外になり、初回と区別されるようになっている。
肝心の戻り先アドレスは以下の命令でセットされる。
0x4808e2c9 <__longjmp14+65>: mov %ecx,0x0(%esp,1)
ECXはこの上でjmp_buf型から復元されている。遡れば、__setjmp14()の中で格納された、setjmp()の直後のアドレス。
それをESP+0に転送している・・・ということは、戻り先アドレスを上書きしていることになる。
では実際にステップ実行して確認してみる。
先ほどのtest命令で一旦ブレークポイントで停め、ステップ実行に入る。
(gdb) b *0x4808e2c4 Breakpoint 5 at 0x4808e2c4 (gdb) c Continuing. Breakpoint 5, 0x4808e2c4 in __longjmp14 () from /usr/lib/libc.so.12 (gdb) p/x $eax $6 = 0x1 (gdb) stepi 0x4808e2c6 in __longjmp14 () from /usr/lib/libc.so.12
これでtest命令が終わった。続いてjnz命令を実行する。
(gdb) stepi 0x4808e2c9 in __longjmp14 () from /usr/lib/libc.so.12
EAXが1だったので、ZFも0となり、inc命令は実行されず、mov命令まで進んだ。
(gdb) p/x $ecx $7 = 0x80489c3 (gdb) stepi 0x4808e2cd in __longjmp14 () from /usr/lib/libc.so.12
ECXには setjmp() の次のアドレス(=0x080489c3)が復元されている。mov命令実行後のスタックフレームを覗いてみる。
(gdb) x/4w $esp 0xbfbfdb40: 0x080489c3 0x08048bdb 0xbfbfdbcc 0xbfbfdb5c
スタックのTOP(=戻り先アドレス)に、ちゃんと0x080489c3が設定されている。あとはret命令へ進めば、無事main関数の中へ戻る。
(gdb) stepi 0x80489c3 in main () at test7.c:14 14 if (setjmp(buf)) { (gdb) p/x $pc $8 = 0x80489c3 (gdb) c Continuing. back in main Program exited normally.
以上で、無事setjmp/longjmpの流れをデバッグできた。
まとめると、setjmp/longjmpはアセンブラレベルでスタックフレームやスタックポインタを処理することで、関数の範囲を超えたジャンプを実現していることを確認できた。