動的ライブラリの観察その2
今回は動的ライブラリの関数呼び出しの様子を観察してみる。
動的ライブラリで定義されている関数のアドレスは動的ライブラリがメモリにロードされるまで決定しない。そのどこに有るかわからない関数をどのようにして呼び出すのかが見どころ。
動的ライブラリはこれ
$ cat libfoo1.c int do_foo(int i) { return i; }
$ gcc -dynamiclib -o libfoo1.dylib libfoo1.c
動的ライブラリを呼び出す方はこれ
$ cat hoge1.c int do_foo(int); int main(int argc, char **argv) { do_foo(5); do_foo(6); return 0; }
$ gcc -L./ -lfoo1 -o hoge1 hoge1.c
動的ライブラリの do_foo(int) を呼び出す様子を gdb で追跡してその概要をシーケンス図風にしたものがこれ。jmp, call はアセンブリ言語の分岐命令の様子を表し、indirect jmp と reference の組み合わせは メモリ上に格納されたアドレスを読み込み、さらにそのアドレスへと分岐する様子を表している。注意しなければならないのはアセンブリのcall,jmpは必ずしも呼び出し元に戻るとは限らないこと。今回も関数というよりはC言語の goto をイメージしながら見た方が良い。
- 1回目の do_foo(int) 呼び出し
libSystem.B.dylib の dyld_stub_binder(と、そこから呼び出される一連の処理)の中で do_foo(int) のアドレスが解決されて最終的に do_foo(int) に処理が移っている。この時スタックは 1:call() 実行直後の状態に復元されているので do_foo(int) の中で ret 命令を実行すると処理は 1:call() の次の命令から実行される。また、解決された do_foo(int) のアドレスは dyld_stub_binder の中でメモリ上のアドレス 0x100001010 の位置にも格納される。
- 2回目の do_foo(int) 呼び出し
2回目以降は 0x100001010 に do_foo(int) のアドレスが格納されているので余計な処理をせず直接 do_foo(int) へ処理を移すことができる。
dyld_stub_do_foo, stub helpers, __nl_symbol_ptr や __la_symbol_ptr は静的リンカが動的リンカのために生成したもの。hoge1.o にはそのようなセクションが含まれないことからも静的リンカが生成したとがわかる。
hoge1.o のセクション一覧(otoolの出力抜粋)
$ otool -l hoge1.o hoge1.o: Section sectname __text segname __TEXT Section sectname __eh_frame segname __TEXT
静的リンカが生成したセクションの内容は以下のようになっている(main実行開始時点)。gobjdumpよりもgdbでダンプした方が見やすかったのでgdbでのダンプを載せておく。
__symbol_stub1 セクションのダンプ
(gdb) disas 0x100000f2e 0x100000f3a Dump of assembler code from 0x100000f2e to 0x100000f3a: 0x0000000100000f2e <dyld_stub_do_foo+0>: jmpq *0xdc(%rip) # 0x100001010 0x0000000100000f34 <dyld_stub_exit+0>: jmpq *0xde(%rip) # 0x100001018 End of assembler dump.
__stub_helper セクションのダンプ
(gdb) disas 0x0000000100000f3a 0x0000000100000f5e Dump of assembler code from 0x100000f3a to 0x100000f5e: 0x0000000100000f3a < stub helpers+0>: lea 0xc7(%rip),%r11 # 0x100001008 0x0000000100000f41 < stub helpers+7>: push %r11 0x0000000100000f43 < stub helpers+9>: jmpq *0xb7(%rip) # 0x100001000 0x0000000100000f49 < stub helpers+15>: nop 0x0000000100000f4a < stub helpers+16>: pushq $0x0 0x0000000100000f4f < stub helpers+21>: jmpq 0x100000f3a < stub helpers> 0x0000000100000f54 < stub helpers+26>: pushq $0xe 0x0000000100000f59 < stub helpers+31>: jmpq 0x100000f3a < stub helpers> End of assembler dump.
__nl_symbol_ptr セクションのダンプ
(gdb) x /2xg 0x0000000100001000 0x100001000: 0x00007fff865fb4ac 0x0000000000000000
__la_symbol_ptr セクションのダンプ
(gdb) x /2xg 0x0000000100001010 0x100001010: 0x0000000100000f4a 0x0000000100000f54
ちなみに main(int, char**) はこう。
(gdb) disas main Dump of assembler code for function main: 0x0000000100000f04 <main+0>: push %rbp 0x0000000100000f05 <main+1>: mov %rsp,%rbp 0x0000000100000f08 <main+4>: sub $0x10,%rsp 0x0000000100000f0c <main+8>: mov %edi,-0x4(%rbp) 0x0000000100000f0f <main+11>: mov %rsi,-0x10(%rbp) 0x0000000100000f13 <main+15>: mov $0x5,%edi 0x0000000100000f18 <main+20>: callq 0x100000f2e <dyld_stub_do_foo> 0x0000000100000f1d <main+25>: mov $0x6,%edi 0x0000000100000f22 <main+30>: callq 0x100000f2e <dyld_stub_do_foo> 0x0000000100000f27 <main+35>: mov $0x0,%eax 0x0000000100000f2c <main+40>: leaveq 0x0000000100000f2d <main+41>: retq End of assembler dump.
ちなみに 0x0000000100001000 経由で関節参照されている 0x00007fff865fb4ac の冒頭はこう。
(gdb) x /5i 0x00007fff865fb4ac 0x7fff865fb4ac <dyld_stub_binder>: push %rbp 0x7fff865fb4ad <dyld_stub_binder+1>: mov %rsp,%rbp 0x7fff865fb4b0 <dyld_stub_binder+4>: sub $0xc0,%rsp 0x7fff865fb4b7 <dyld_stub_binder+11>: mov %rdi,(%rsp) 0x7fff865fb4bb <dyld_stub_binder+15>: mov %rsi,0x8(%rsp)
このダンプ結果からもdo_foo(int)の初回呼び出しでは上のシーケンス図の通りに処理が移っていくことがわかる。
main+20のcall → dyld_stub_do_foo+0 → dyld_stub_do_foo+0 の jmp → 0x100001010 の値を参照 → stub helpers+16 → stub helpers+21 の jmp → stub helpers+0 → stub helpers+9 の jmp → 0x0000000100001000 の値を参照 → dyld_stub_binder
Mac-O Programing Topics: Indirect Addressingにインダイレクトアドレッシング(間接アドレッシング)と言うものの説明がある。
関節アドレッシングとは、他のファイルで定義されたシンボルをそのファイルの構造を知らなくても参照できるようなコードを生成する技術の名前である。これにより、シンボルを定義しているファイルはそれを参照しているファイルとは独立して変更することが可能となる。間接アドレッシングを利用すれば、動的リンカがリンク時に変更しなければならない箇所が減り、結果としてコードを共有しやすくなったり、パフォーマンスが改善されたりする。
あるファイルが別のファイルで定義されているデータを参照する場合、「シンボルリファレンス」が作成される。シンボルリファレンスとはどのシンボルがどのファイルからインポートされたかを特定するためのものである。シンボルリファレンスには「ノンレジー(nonlazy)」と 「レジー(lazy)」の二種類がある。
- ノンレジーシンボルリファレンスはモジュールのロード時に動的リンカによって解決(シンボルの定義に結合)される。ノンレジーシンボルリファレンスは基本的にはシンボルへのポインタであり、データや関数へのノンレジーポインタはコンパイラが生成する。
- レジーシンボルリファレンスはロード時ではなく最初に使用されるときに動的リンカによって解決される。以降の使用ではシンボルの定義に直接ジャンプする。レジーシンボルリファレンスはシンボルポインタとシンボルスタブからなる。シンボルスタブとはシンボルポインタをデリファレンスしてジャンプする小さなコードである。レジーシンボルリファレンスは、他のファイルで定義されている関数への呼び出しが見つかった場合にコンパイラが作成する。
x86_64では静的リンカが、動的リンカに必要な関節シンボルテーブルはもちろん、全てのスタブ関数、スタブヘルパ関数、レジー&ノンレジーポインタの生成の責任を負う。
__symbol_stub1, __stub_helper, __nl_symbol_ptr, __la_symbol_ptr 辺りが正にこれか。
まとめ
- レジーシンボルリファレンスを使って、動的ライブラリの関数に直接飛ぶのかそのアドレス解決のためのコードに飛ぶのかを分岐させている
- 一度動的ライブラリの関数のアドレスが解決されると2回目以降の呼び出しでは解決のオーバーヘッドは無い