ついに、リンカの説明をするときが来た。
ここに至るまでに、何度「リンカのところで説明する」と書いただろうか? ここまで読んできた人ならば、 リンカというものが、なにやら色々やっているんだな、というのはわかってきたのではないかと思う。
筆者が常々思っていることのひとつに、「C言語に関する書籍は、リンカの説明をおざなりにしすぎだ」というのがある。
多くのC言語の書籍は、
と、いう解説がなされがちである。この説明を見たら、多くの人が、「え、リンクってなんですか?」と、思うに違いない。
アセンブラには、「人間が読めるニーモニックを、機械が読める機械語に変換する」みたいな、最低限の説明が付くものの、 リンカの説明は「リンクをします」のひとことだけである!
ここでは、いつも雑な説明をされがちな、リンカについて説明をしていきたいと思う。
C言語の言語仕様には、明示的にリンクについて書かれてはいないものの、 extern 指定子など、言語仕様の一部に、リンクの処理を無視して説明できない仕様を含んでいるのは間違いない。 リンクについて知れば、C言語への理解も、もう一歩深まるだろう。
リンカの説明前にいくつか必要な説明をしておこう。
.globl main main: ret
このプログラムを gcc -static でコンパイルして、objdump -d で逆アセンブルを確認してほしい。 main 関数に含まれる命令は1個しかないのに、実際の実行ファイルには大量の命令が含まれていることが確認できるはずだ。 この大量の命令は、libcと呼ばれるライブラリ等から来る命令だ。
libc は、C言語の仕様を満たすために定義された関数、データを含むライブラリだ。 Cを書いたことがある人なら、printf、malloc などの関数を使ったことがあるだろう。 printf関数やmalloc関数はこの、libc の中に実装されている。 libc には、いくつか種類があって、x86_64 の Linux では、glibc(The GNU C Library) と呼ばれるlibcが使われている。 Windows では、msvcrt(Microsoft Visual Studio C Runtime か?)と呼ばれるlibcが使われることが多い。
実際には、Linux上のgccでビルドした場合、libcの他に、スタートアップルーチンと、libgcc がリンクされる。 これらの詳細については、またあとでlibcのところで説明しよう。
ここで、"gcc" というコマンドが、.s を変換するだけでなく、libcとのリンクも行っている点に注意しよう。 ついでに、これまでも "gcc" というコマンドを使って、アセンブリ(.s) を実行ファイル(a.out) に変換していたことを思い出して欲しい。
"gcc"というコマンドは、その名前に反して、*Cコンパイラではない*! "gcc"は、コンパイラドライバ (compiler driver)と呼ばれるプログラムで、 ソースコードファイルを実行ファイルに変換する時に必要なコマンドを良い感じに呼び出してくれるツールだ。
コンパイラドライバの挙動は、仕様もなく、ドキュメンテーションもされておらず、OS、コンパイラ毎に挙動がマチマチで、 「良い感じに」処理してくれるとしか言いようのない動作をする。
Linux 上の gcc コンパイラドライバは、大体以下のような動作をする。(実際には色々オプションによって挙動変わるので、もう少し複雑だ。ちなみに、clang はこの動作をかなりの精度で再現する)
ld(リンカ) は gcc から呼ばれるときは、collect2 というラッパーを経由して呼ばれるが、 現代ではLTO(Link Time Optimization) を使わない場合はcollect2 = ld と解釈してもらって構わない。(TODO : collect2 が必要な理由を調べる)。
gcc に、-v を付けると、実際に背後でどういうコマンドが実行されるか確認できる。
$ gcc -v a.c Using built-in specs. COLLECT_GCC=gcc COLLECT_LTO_WRAPPER=/usr/lib/gcc/x86_64-linux-gnu/5/lto-wrapper Target: x86_64-linux-gnu Configured with: ../src/configure -v --with-pkgversion=&39;Ubuntu 5.4.0-6ubuntu1~16.04.9&39; --with-bugurl=file:///usr/share/doc/gcc-5/README.Bugs --enable-languages=c,ada,c++,java,go,d,fortran,objc,obj-c++ --prefix=/usr --program-suffix=-5 --enable-shared --enable-linker-build-id --libexecdir=/usr/lib --without-included-gettext --enable-threads=posix --libdir=/usr/lib --enable-nls --with-sysroot=/ --enable-clocale=gnu --enable-libstdcxx-debug --enable-libstdcxx-time=yes --with-default-libstdcxx-abi=new --enable-gnu-unique-object --disable-vtable-verify --enable-libmpx --enable-plugin --with-system-zlib --disable-browser-plugin --enable-java-awt=gtk --enable-gtk-cairo --with-java-home=/usr/lib/jvm/java-1.5.0-gcj-5-amd64/jre --enable-java-home --with-jvm-root-dir=/usr/lib/jvm/java-1.5.0-gcj-5-amd64 --with-jvm-jar-dir=/usr/lib/jvm-exports/java-1.5.0-gcj-5-amd64 --with-arch-directory=amd64 --with-ecj-jar=/usr/share/java/eclipse-ecj.jar --enable-objc-gc --enable-multiarch --disable-werror --with-arch-32=i686 --with-abi=m64 --with-multilib-list=m32,m64,mx32 --enable-multilib --with-tune=generic --enable-checking=release --build=x86_64-linux-gnu --host=x86_64-linux-gnu --target=x86_64-linux-gnu Thread model: posix gcc version 5.4.0 20160609 (Ubuntu 5.4.0-6ubuntu1~16.04.9) COLLECT_GCC_OPTIONS=&39;-v&39; &39;-mtune=generic&39; &39;-march=x86-64&39; /usr/lib/gcc/x86_64-linux-gnu/5/cc1 -quiet -v -imultiarch x86_64-linux-gnu a.c -quiet -dumpbase a.c -mtune=generic -march=x86-64 -auxbase a -version -fstack-protector-strong -Wformat -Wformat-security -o /tmp/cc8AbPic.s # cc1 を呼んでいる GNU C11 (Ubuntu 5.4.0-6ubuntu1~16.04.9) version 5.4.0 20160609 (x86_64-linux-gnu) compiled by GNU C version 5.4.0 20160609, GMP version 6.1.0, MPFR version 3.1.4, MPC version 1.0.3 GGC heuristics: --param ggc-min-expand=100 --param ggc-min-heapsize=131072 ignoring nonexistent directory "/usr/local/include/x86_64-linux-gnu" ignoring nonexistent directory "/usr/lib/gcc/x86_64-linux-gnu/5/../../../../x86_64-linux-gnu/include" #include "..." search starts here: #include <...> search starts here: /usr/lib/gcc/x86_64-linux-gnu/5/include /usr/local/include /usr/lib/gcc/x86_64-linux-gnu/5/include-fixed /usr/include/x86_64-linux-gnu /usr/include End of search list. GNU C11 (Ubuntu 5.4.0-6ubuntu1~16.04.9) version 5.4.0 20160609 (x86_64-linux-gnu) compiled by GNU C version 5.4.0 20160609, GMP version 6.1.0, MPFR version 3.1.4, MPC version 1.0.3 GGC heuristics: --param ggc-min-expand=100 --param ggc-min-heapsize=131072 Compiler executable checksum: d079eab342c322d6be59e8628e10ae67 COLLECT_GCC_OPTIONS=&39;-v&39; &39;-mtune=generic&39; &39;-march=x86-64&39; as -v --64 -o /tmp/ccNgb4Ka.o /tmp/cc8AbPic.s # as を呼んでいる GNU assembler version 2.26.1 (x86_64-linux-gnu) using BFD version (GNU Binutils for Ubuntu) 2.26.1 COMPILER_PATH=/usr/lib/gcc/x86_64-linux-gnu/5/:/usr/lib/gcc/x86_64-linux-gnu/5/:/usr/lib/gcc/x86_64-linux-gnu/:/usr/lib/gcc/x86_64-linux-gnu/5/:/usr/lib/gcc/x86_64-linux-gnu/ LIBRARY_PATH=/usr/lib/gcc/x86_64-linux-gnu/5/:/usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu/:/usr/lib/gcc/x86_64-linux-gnu/5/../../../../lib/:/lib/x86_64-linux-gnu/:/lib/../lib/:/usr/lib/x86_64-linux-gnu/:/usr/lib/../lib/:/usr/lib/gcc/x86_64-linux-gnu/5/../../../:/lib/:/usr/lib/ COLLECT_GCC_OPTIONS=&39;-v&39; &39;-mtune=generic&39; &39;-march=x86-64&39; /usr/lib/gcc/x86_64-linux-gnu/5/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/5/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/5/lto-wrapper -plugin-opt=-fresolution=/tmp/ccqaqkj9.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s --sysroot=/ --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -z relro /usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu/crt1.o /usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/5/crtbegin.o -L/usr/lib/gcc/x86_64-linux-gnu/5 -L/usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/5/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/5/../../.. /tmp/ccNgb4Ka.o -lgcc --as-needed -lgcc_s --no-as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed /usr/lib/gcc/x86_64-linux-gnu/5/crtend.o /usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu/crtn.o # collect2 を呼んでいる
cc1, as, collect2 コマンドが呼ばれていることを確認しよう。また、わかりにくいが、リンク時に、crtなんとか.o と、-lc -lgcc をリンクしていることも見ておいてほしい。 crtなんとか.o がスタートアップルーチンを含むオブジェクト、-lc が libc、-lgcc が、libgcc だ。
昔は、Cのプリプロセッサは独立したコマンドとして呼ばれていたが、今のCコンパイラは、Cプリプロセッサを内蔵しており、プリプロセスしながらコンパイルを行う。 そのため、通常はプリプロセッサが単体で呼ばれることはない。(-E オプションを付けてgccを呼び出せば明示的にプリプロセスだけを実行させることはできる)
cc1 は gcc の内部コマンドなので、シェルから呼び出すことはできない。上の -v の出力で出てきたcc1のパスを直接実行しよう。
$ cat a.c int main() { } $ /usr/lib/gcc/x86_64-linux-gnu//5/cc1 a.c main Analyzing compilation unit Performing interprocedural optimizations <*free_lang_data> <visibility> <build_ssa_passes> <opt_local_passes> <free-inline-summary> <whole-program> <inline>Assembling functions: main Execution times (seconds) phase setup : 0.00 ( 0%) usr 0.00 ( 0%) sys 0.01 (50%) wall 1093 kB (85%) ggc phase finalize : 0.00 ( 0%) usr 0.00 ( 0%) sys 0.01 (50%) wall 0 kB ( 0%) ggc TOTAL : 0.00 0.00 0.02 1286 kB
cc1 に a.c を渡すと、a.s というアセンブリファイルが出力されるており、cc1 が「Cソースをアセンブリに変換する」という教科書どおりのコンパイラの動作をしていることを確認しよう。 cc1 は、内部コマンドなので、ユーザが直接使うことは想定されておらず、オプション等はGCCのバージョンによって大きく変わる。筆者もあんまり把握していないので、詳しい説明は省略する。
as は Linux標準のアセンブラだ。アセンブリをオブジェクトコードに変換する。 これまで書いてきたいくつかのアセンブリのソースを、as を使って変換してみよう。
$ as main.s $ ls a.out main.s
gcc -c で main.s をアセンブルした時は、main.o に出力してくれるが、as でアセンブルすると、出力ファイル名を指定しない場合 a.out というファイルに出力される。 紛らわしいが、この a.out は実行できるファイルではない。
as でアセンブルした場合と、 gcc で実行できるa.outを作った場合で、ファイルタイプが異なる。 readelf -h は、オブジェクトファイルのヘッダ情報を出力するコマンドだ。 これを見ると実行できるファイルかどうか判断できる。
$ as main.s $ readelf -h a.out ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: REL (Relocatable file) # Type が REL Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x0 Start of program headers: 0 (bytes into file) Start of section headers: 320 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 0 (bytes) Number of program headers: 0 Size of section headers: 64 (bytes) Number of section headers: 8 Section header string table index: 5 $ gcc -no-pie main.s $ readelf -h a.out ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) # Type が EXEC Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x400430 Start of program headers: 64 (bytes into file) Start of section headers: 6592 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 9 Size of section headers: 64 (bytes) Number of section headers: 31 Section header string table index: 28
これはどういうことなのか、というのが、このリンクの章で説明したいことだ。あとでじっくり説明するので、少々お待ちください。
a.out 以外のファイル名で出力する場合は、-o <ファイル名> オプションを使う。
最後にld。ld は、Linux標準のリンカだ。リンカは、アセンブラが出力したオブジェクトをリンクし、実行ファイルや共有ライブラリを出力する。
$ cat main.s .globl main main: ret $ as main.s -o main.o $ ld main.o -o a.out /usr/bin/ld: warning: cannot find entry symbol _start; defaulting to 0000000000400078 $ readelf -h a.out ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) 実行ファイル Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x400078 Start of program headers: 64 (bytes into file) Start of section headers: 360 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 1 Size of section headers: 64 (bytes) Number of section headers: 5 Section header string table index: 2 $ ./a.out segmentation fault (core dumped)
ld を使って、main.o をリンクすると、今度は、きちんと実行できるファイルが出力されていることを確認しよう。
しかし、この実行ファイルは、実行はできるものの、実行すると正常終了しない。
(gdb) b main Breakpoint 1 at 0x400078 (gdb) start Temporary breakpoint 2 at 0x400078 Starting program: /mnt/d/wsl/src/pllp/docs/link/x/a.out Breakpoint 1, 0x0000000000400078 in main () (gdb) x/4i $pc => 0x400078 <main>: retq 0x400079: add %al,(%rax) 0x40007b: add %al,(%rax) 0x40007d: add %al,(%rax) (gdb) p $rsp $1 = (void *) 0x7ffffffee4f0 (gdb) p *(void**) $rsp $2 = (void *) 0x1 (gdb) stepi 0x0000000000000001 in ?? () (gdb) stepi
プログラムの最後に ret 命令を書いていたが、 rsp が指す先には、戻りアドレスは存在していない。(プログラムカウンタが1になっている点を確認しよう)
これは、スタートアップルーチンをリンクしていないためだ。
スタートアップルーチンは、mainを呼んだあとにもいくらかの後処理をする。 スタートアップルーチンを正しくリンクした場合、プログラムカウンタが main に来たときは、 スタックにmainの後処理をするアドレスが積まれている。 なので、gccコンパイラドライバを使ってリンクした場合には、main から単に ret するだけで、プログラムが正しく終了できる。
mainのあとに必要な処理はいくつかあるが、一番重要なのは、OSに対してプログラムの終了を依頼することだ。 詳細はシステムコールのところで説明するが、x86_64 では、rax レジスタに 60 を設定して、syscall 命令を実行すればよい。
.globl main main: mov $60, %rax syscall
$ as exit.s -o exit.o $ ld exit.o -o a.out /usr/bin/ld: warning: cannot find entry symbol _start; defaulting to 0000000000400078 $ ./a.out
プログラムが正しく終了した。
"/usr/bin/ld: warning: cannot find entry symbol _start; defaulting to 0000000000400078" が気になるので、これも説明しておこう。
さきほど書いたように、コンパイラドライバ経由でリンクされたオブジェクトは、スタートアップルーチンで初期化をする。 これはつまり、main の前にいくらかプログラムが動いているわけで、プログラムの本当の開始位置は、main ではないのだ。
ld では、オプション無しでオブジェクトをリンクすると、mainではなく、"_start" ラベルが置かれた位置をプログラム開始位置とする、と決められている (興味がある人は、gdb で _start にブレークポイントを置いてプログラムを実行してみよう)。 "cannot find entry symbol _start" というのは、この "_start" ラベルが見つからないという意味だ。
プログラムの起動と終了を要約すると、以下のようになる。
通常、コンパイラドライバ経由でオブジェクトをリンクした場合、スタートアップルーチンがリンクされるが、 今は、スタートアップルーチンをリンクしないで実行ファイルを作っているので、最初の _start が定義されてない。 よって、"cannot find entry symbol _start"という警告が出るのだ。
自分で _start を定義してスタートアップルーチンを書いてみよう。 と、いってもやることは、1. mainを呼ぶ 2. mainが終わったら終了する の二個だけだ。
.globl _start _start: call main mov $60, %rax syscall main: ret
$ as start_exit.s -o start_exit.o $ ld start_exit.o -o a.out $ ./a.out
これで警告も出ないし、正しく終了できる実行ファイルが作れた。
以上、コンパイラドライバの内容と、コンパイラドライバを使わないで、アセンブル、リンクを行う方法について簡単に説明した。
色々はしょって解説したのでわかりにくかったかもしれないが、 コンパイラドライバは、環境やバージョンによって動作が変わるので、あんまり細かい挙動について理解する必要はなくて、 「コンパイラ、アセンブラ、リンカを順番に呼び出しているんだな」程度に理解してもらえればよいと思う。
ついでに小技を紹介しておこう。コンパイラドライバであるgccを実行するときに、-Wl,-Wa というオプションを使うと、背後で実行されるリンカやアセンブラに直接オプションを渡すことができる。
例えば、よく使うのは、rpathを指定する場合で、 "-Wl,-rpath,/usr/local/lib" というオプションをgccに渡すと、リンカldに"-rpath" "/usr/local/lib" というオプションを渡してくれる。 (rpathの説明は省略する。必要になったときに思い出してほしい)
これでリンカを単体で使う方法を説明できたので、いよいよ次はリンカの説明に入っていこう。
リンカがやっていることは、主に
の二点だ。
次のふたつのファイルをリンクしてみよう。
.globl _start _start: ret
nop
$ as link0.s -o link0.o $ as link1.s -o link1.o $ ld link0.o link1.o $ objdump -d a.out a.out: file format elf64-x86-64 Disassembly of section .text: 0000000000400078 <_start>: 400078: c3 retq 400079: 90 nop
link0.s の ret 命令と、link1.s の nop 命令が一個のa.outにまとめられていることを確認してほしい
リンカの挙動を決める仕様は存在していないが、世の中の大体のリンカは、引数に指定した順にオブジェクトを並べていく。 引数のファイルの順序を変えると、並ぶ命令の順序も変わる。
$ ld link1.o link0.o $ objdump -d a.out a.out: file format elf64-x86-64 Disassembly of section .text: 0000000000400078 <_start-0x1>: 400078: 90 nop 0000000000400079 <_start>: 400079: c3 retq
C++ のグローバル変数のコンストラクタの呼び出し順などは、この順序で決まる実装が多いので、覚えておくと何かの役に立つかもしれない。
続いて、リンカの重要な仕事が、ラベル参照の解決だ。これまでにも簡単に解説してきたが、あらためて説明しておこう。
アセンブリファイル中には、ラベルが置ける。プログラムに含まれる命令、データは、リンクが終わるまでアドレスが確定しないが、 ラベルを使うことで、リンク後に決まるアドレスの値を確定前に参照することができる。
.globl _start _start: mov $label0, %rax
.globl label0 label0: mov $_start, %rax
$ as link_label0.s -o link_label0.o $ as link_label1.s -o link_label1.o $ objdump -d link_label0.o link_label0.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <_start>: 0: 48 c7 c0 00 00 00 00 mov $0x0,%rax $ objdump -d link_label1.o link_label1.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <label0>: 0: 48 c7 c0 00 00 00 00 mov $0x0,%rax $ ld link_label0.o link_label1.o $ objdump -d a.out a.out: file format elf64-x86-64 Disassembly of section .text: 0000000000400078 <_start>: 400078: 48 c7 c0 7f 00 40 00 mov $0x40007f,%rax 000000000040007f <label0>: 40007f: 48 c7 c0 78 00 40 00 mov $0x400078,%rax
リンクすると、_start ラベルが置かれたアドレスが0x400078、label0 ラベルが置かれたアドレスが0x40007d と確定する。 リンク前は 0 だった mov 命令のオペランドが、リンク後には確定されたラベルのアドレスに書き換わっている点を確認しよう。 このように、オブジェクト内に含まれているラベルへの参照を、確定したアドレスに書きかえることを「解決する(resolve)」と言う。
このラベルの解決をするためにアセンブラ、リンカが何をやっているかを見ていこう。
オブジェクトファイルには、機械語、データの他に、ラベルの解決に必要な情報が含まれている。 シンボル (symbol) と リロケーション(relocation)だ。
シンボル は、アドレスを識別する文字表現を保持しておく情報だ。 アセンブリ言語で書かれたラベルは、このシンボルに変換されて、オブジェクトファイル内に保持される。 また、デバッガやリンカの動作を助けるために、追加の情報が付けられることもある。
シンボル には 定義済みシンボル (defined symbol) 、 未定義シンボル (undefined symbol) の二種類がある。
定義済みシンボル は、ラベル等を使ってファイル内に定義された実体が存在するシンボルだ。 定義済みシンボルは、ファイル内で定義された位置のオフセットと、追加情報、文字列表現の情報を持つ。
未定義シンボル は、オブジェクトファイル中に実体が存在しないシンボルだ。 未定義シンボルは、あとでリンクするときのために文字列と追加情報を持ち、定義済みシンボルと違って定義された位置のオフセットは持たない。
このあたりの定義もかなり曖昧で、単にシンボルと言うときは、定義済みシンボルのことを指すことが多い。 毎回"定義済みシンボル"と表記するのはあまり一般的ではないが、 このリンカの章では、説明の曖昧さをなくすために、毎回"定義済みシンボル"と書くことにする。 他の章では、慣習に従って、必要のないときは"定義済みシンボル"の意味で"シンボルと書くことにしよう。
"ラベル" と "定義済みシンボル" は、かなり似たものになる。 何が違うか…というのは…筆者もあまり把握していないが… アセンブリ言語などの、ソースコード中に目印として置かれるものは"ラベル"、 オブジェクトに含まれている情報は"シンボル"、と呼ばれることが多いように思う。
オブジェクト内に含まれる"定義済みシンボル"を"ラベル"と呼ぶことはあまりないので、その慣習にしたがって以下では、 ソースコードに貼られた情報をラベル、オブジェクト内に含まれた情報を定義済みシンボルと呼ぶことにする。 (あまり厳密な使いわけはされてないと思うので、呼び間違えても話は通じるはず)
これまで何度か使ってきたreadelfに"-s"を付けて実行すると、オブジェクトに含まれる定義済みシンボル、未定義シンボルの情報を表示することができる。
.globl _start .globl sym0 _start: nop sym0: nop sym1: nop nop nop nop sym2: nop
--
$ readelf -s defsym.o Symbol table &39;.symtab&39; contains 8 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 SECTION LOCAL DEFAULT 1 2: 0000000000000000 0 SECTION LOCAL DEFAULT 2 3: 0000000000000000 0 SECTION LOCAL DEFAULT 3 4: 0000000000000002 0 NOTYPE LOCAL DEFAULT 1 sym1 5: 0000000000000006 0 NOTYPE LOCAL DEFAULT 1 sym2 6: 0000000000000000 0 NOTYPE GLOBAL DEFAULT 1 _start 7: 0000000000000001 0 NOTYPE GLOBAL DEFAULT 1 sym0
$ objdump -d defsym.o defsym.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <_start>: 0: 90 nop 0000000000000001 <sym0>: 1: 90 nop 0000000000000002 <sym1>: 2: 90 nop 3: 90 nop 4: 90 nop 5: 90 nop 0000000000000006 <sym2>: 6: 90 nop
sym0, sym1, sym2 というみっつのシンボルを定義しているプログラムだ。
これのラベルをダンプすると、
Num: Value Size Type Bind Vis Ndx Name 4: 0000000000000002 0 NOTYPE LOCAL DEFAULT 1 sym1 5: 0000000000000006 0 NOTYPE LOCAL DEFAULT 1 sym2 6: 0000000000000000 0 NOTYPE GLOBAL DEFAULT 1 _start 7: 0000000000000001 0 NOTYPE GLOBAL DEFAULT 1 sym0
このようになる。1-3 については、あとのセクションのところで詳しく書く。 0番は…筆者も何か知らない。
Value のカラムで示される値が、ファイル内でのオフセットだ。sym0,sym1,sym2 のそれぞれのオフセット値を確認してほしい。
次にSizeとTypeカラム、シンボルは上のほうで書いたように、デバッグ時等にも使える情報を含んでいる。これもあとでもう少し詳しく説明しよう。
次はBindとVisカラム。プログラムを書いたことのある人なら、「スコープ」の重要性は理解しているだろう。 オブジェクトファイルにもこのスコープの概念があり、シンボルが見える範囲を制御することができる。 オブジェクトファイルにもいくつかのスコープ階層がある。
ローカルシンボルは、ファイル内でのみ参照可能な定義済みシンボルだ。C言語のstaticが付いたグローバル変数、関数がこれに相当すると思ってもらってよい。 グローバルシンボルは、逆にファイル外からも参照できる定義済みシンボルで、こちらはC言語でいうとstaticが付かないグローバル変数、関数が相当する。
ローカルシンボルは、別のファイルからは参照できない。
_start: mov $local_symbol, %rax mov $global_symbol, %rax
.globl global_symbol local_symbol: nop global_symbol: nop
$ as local_symbol0.s -o local_symbol0.o $ as local_symbol1.s -o local_symbol1.o $ ld local_symbol0.o local_symbol1.o /usr/bin/ld: warning: cannot find entry symbol _start; defaulting to 0000000000400078 local_symbol0.o: In function `_start': (.text+0x3): undefined reference to `local_symbol'
local_symbol が local_symbol1.s で定義されているが、定義されていないとエラーが出ていることを確認しよう。
ついでに、_start 見つからないとエラーが出ているのを確認してほしい。 これまでのアセンブリファイルでは、ファイルの最初に、
.globl _start .globl main
などと書いていたことを思い出そう。ようやくこれを説明するときがきた。
.globl は、アセンブラに対して、指定した定義済みシンボルをグローバルシンボルにしろ、と指示する疑似命令だ。(.globalでもいい)
"main" シンボルは、スタートアップルーチンからファイルを超えて参照されるので、グローバルシンボルにしておく必要がある。 そのため、これまでのアセンブリファイルには、".globl main" を書いていた。
"_start" シンボルの扱いは、通常のシンボルとは少し違うが、 リンク時の挙動として、「グローバルシンボルの"_start"をプログラムの開始位置とする」と決められている。 オブジェクトを超えて外部から見えるように扱うので、グローバルシンボルとして扱うほうが自然だろう。
次に、エクスポートシンボルだ。これは、DLL 等の共有ライブラリを作るときなどに使われるシンボルで、 実行時にもファイルを超えて参照される定義済みシンボルだ。
共有ライブラリを使う場合には、実行時にもファイルを超えて未定義シンボルと定義済みシンボルを結びつけるリンク処理が必要になり、 そのための情報を残しておく必要がある。 エクスポートシンボルはそのためのシンボルだ。
Linux では、visibilityという属性(readelf -s で見られる Vis カラム)が、エクスポートシンボルかどうかに影響していて、 リンク時にエクスポートシンボルを残すと指示した場合に、Vis が DEFAULT のシンボルがエクスポートシンボルになる。
共有ライブラリについては、いくらか書くべきことが多いので、エクスポートシンボルの扱いについてはまたあとで別に書くとしよう。
あとウィークシンボルというのもあるが、これはLinux(というかELF)固有の概念なので特にここでは書かない。興味がある人は各自で調べてほしい。
あとc,c++に依存したシンボル属性もある。これはあとでC言語のところで説明しよう。
次にリロケーション だ。 リロケーション は、アドレス解決後の値を埋め込む方法を保持する情報だ。
readelfを使えばリロケーションの情報も見ることができる。"-r" で、リロケーション情報を表示だ。
さきほどの link/link_label1.s をアセンブルしたlink_label1.o を見てみよう。
$ readelf -r link_label1.o Relocation section &39;.rela.text&39; at offset 0xe8 contains 1 entries: Offset Info Type Sym. Value Sym. Name + Addend 000000000003 00050000000b R_X86_64_32S 0000000000000000 _start + 0
$ readelf -s link_label1.o Symbol table &39;.symtab&39; contains 6 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 SECTION LOCAL DEFAULT 1 2: 0000000000000000 0 SECTION LOCAL DEFAULT 3 3: 0000000000000000 0 SECTION LOCAL DEFAULT 4 4: 0000000000000000 0 NOTYPE GLOBAL DEFAULT 1 label0 5: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _start $
詳しく見ていこう。
.globl _start _start: movl $ref_32bit, %eax # 32bitラベルを参照 movw $ref_16bit, %ax # 16bitラベルを参照 movb $ref_8bit, %al # 8bitラベルを参照 jmp ref_as_jmp_label # PC 相対アドレスを参照 movl $ref_32bit + 32, %eax # オフセット付き mov $60, %rax syscall
.text .globl ref_32bit .globl ref_16bit .globl ref_8bit .globl ref_as_jmp_label nop ref_32bit: nop ref_16bit: nop ref_8bit: nop ref_as_jmp_label: nop
--
$ readelf -r reloc.o Relocation section '.rela.text' at offset 0x170 contains 5 entries: Offset Info Type Sym. Value Sym. Name + Addend 000000000001 00050000000a R_X86_64_32 0000000000000000 ref_32bit + 0 000000000007 00060000000c R_X86_64_16 0000000000000000 ref_16bit + 0 00000000000a 00070000000e R_X86_64_8 0000000000000000 ref_8bit + 0 000000000011 00050000000a R_X86_64_32 0000000000000000 ref_32bit + 20 00000000000c 000800000002 R_X86_64_PC32 0000000000000000 ref_as_jmp_label - 4
$ objdump -d reloc.o # リンク前の逆アセンブル reloc.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <_start>: 0: b8 00 00 00 00 mov $0x0,%eax 5: 66 b8 00 00 mov $0x0,%ax 9: b0 00 mov $0x0,%al b: e9 00 00 00 00 jmpq 10 <_start+0x10> 10: b8 00 00 00 00 mov $0x0,%eax 15: 48 c7 c0 3c 00 00 00 mov $0x3c,%rax 1c: 0f 05 syscall
$ objdump -dr reloc.o # -dr でリロケーションと逆アセンブルを同時に表示 reloc.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <_start>: 0: b8 00 00 00 00 mov $0x0,%eax 1: R_X86_64_32 ref_32bit 5: 66 b8 00 00 mov $0x0,%ax 7: R_X86_64_16 ref_16bit 9: b0 00 mov $0x0,%al a: R_X86_64_8 ref_8bit b: e9 00 00 00 00 jmpq 10 <_start+0x10> c: R_X86_64_PC32 ref_as_jmp_label-0x4 10: b8 00 00 00 00 mov $0x0,%eax 11: R_X86_64_32 ref_32bit+0x20 15: 48 c7 c0 3c 00 00 00 mov $0x3c,%rax 1c: 0f 05 syscall
$ ld reloc.o reloc_label.o -Ttext=0x0 # ラベルの配置アドレスを8bit以内にするためtextの位置を0にしておく $ objdump -d a.out # リンク後の逆アセンブル a.out: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <_start>: 0: b8 1f 00 00 00 mov $0x1f,%eax 5: 66 b8 20 00 mov $0x20,%ax 9: b0 21 mov $0x21,%al b: e9 12 00 00 00 jmpq 22 <ref_as_jmp_label> 10: b8 3f 00 00 00 mov $0x3f,%eax 15: 48 c7 c0 3c 00 00 00 mov $0x3c,%rax 1c: 0f 05 syscall 1e: 90 nop 000000000000001f <ref_32bit>: 1f: 90 nop 0000000000000020 <ref_16bit>: 20: 90 nop 0000000000000021 <ref_8bit>: 21: 90 nop 0000000000000022 <ref_as_jmp_label>: 22: 90 nop
--
Offset Info Type Sym. Value Sym. Name + Addend 000000000001 00050000000a R_X86_64_32 0000000000000000 ref_32bit + 0
まず、Offset、これは解決した値を埋める位置を示している。最初の mov 命令の32bitオペランドの位置が、 先頭から1byteのところにあるのを確認しよう。 これが1byteなので、Offsetは1だ。
#リンク前 v ここ(offset=1byte)に解決した32bit値を入れる 0: b8 00 00 00 00 mov $0x0,%eax #リンク後 0: b8 1f 00 00 00 mov $0x1f,%eax
次に、Info と Type。x86_64 では、 Info の下32bitが Type になり、Type のカラムは、この32bit値を読みやすい形で表示しているだけだ。 Info の下32bit が、0x0000000a の場合、Type が R_X86_64_32 になる。
Type の解釈方法は、CPUによって異なる。x86_64 の場合は、 AMD64 ABI に書かれている。 まあ筆者もこれはちゃんと読んだことはなくて、自分でリンカを作るとかでなければ、/usr/include/elf.h にある定義を見ればよいと思う。
reloc.o に含まれるリロケーションの Type には色々あるのを確認してほしい。シンボルの参照方法は、一種類ではなく、複数ある。
movl $ref_32bit, %eax # 32bitラベルを参照 movw $ref_16bit, %ax # 16bitラベルを参照
このふたつでは、命令中のオペランドの書き換えかたが変わるはずだ。ここで、「どうやってオペランドを書き換えるか」を示すのが、リロケーションのTypeだ。
Offset Info Type Sym. Value Sym. Name + Addend 000000000001 00050000000a R_X86_64_32 0000000000000000 ref_32bit + 0 000000000007 00060000000c R_X86_64_16 0000000000000000 ref_16bit + 0
offset=0000000000000001 の 32bit mov のオペランドのリロケーションと、offset=0000000000000007 の 16bit mov のオペランドのリロケーションで Type が違う点、解決後の値の埋めかたが変わっている点を確認してほしい。
#asm movl $ref_32bit, %eax # 32bitラベルを参照 movw $ref_16bit, %ax # 16bitラベルを参照 #リンク前 0: b8 00 00 00 00 mov $0x0,%eax 5: 66 b8 00 00 mov $0x0,%ax #リンク後 0: b8 1f 00 00 00 mov $0x1f,%eax # ラベル ref_32bit を解決した値 1f を 32bit値として入れる 5: 66 b8 20 00 mov $0x20,%ax # ラベル ref_16bit を解決した値 20 を 16bit値として入れる
このように、リロケーションの指示に応じて、適切なリンク後の値をオブジェクトに入れる処理をリロケーション(relocation)と呼ぶ。 これはタイポではないと信じている。リロケーションを置換する処理をリロケーションと呼ぶのが一般的なはずだ。
また、同じサイズのリロケーションでもTypeが異なる場合がある。
movl $ref_32bit, %eax # 32bitラベルを参照 jmp ref_as_jmp_label # PC 相対アドレスを参照
Offset Info Type Sym. Value Sym. Name + Addend 000000000001 00050000000a R_X86_64_32 0000000000000000 ref_32bit + 0 00000000000c 000800000002 R_X86_64_PC32 0000000000000000 ref_as_jmp_label - 4
x86_64 機械語では、分岐命令のアドレスは、分岐命令の直後のアドレスからの相対値になる。 上の jmp 命令で、ref_as_jmp_label ラベルが、解決されたあと、その値がそのまま埋められると、jmp 命令は正しく機能しない。 リンカは、ref_as_jmp_label の解決後、jmp 命令の次の命令が置かれたアドレスとの相対値を埋める必要がある。
# asm jmp ref_as_jmp_label # PC 相対アドレスを参照 # リンク前 b: e9 00 00 00 00 jmpq 10 <_start+0x10> # リンク後 b: e9 12 00 00 00 jmpq 22 <ref_as_jmp_label> 10: b8 3f 00 00 00 mov $0x3f,%eax (.. 略 ..) 0000000000000022 <ref_as_jmp_label>: 22: 90 nop
jmp 命令の次の命令のアドレスが0x10、 ref_as_jmp_label の解決後の値が 0x22、その差は +0x12 となって、jmp 命令のオペランドには +0x12 が埋められることを確認してほしい。
最後に Addend だ。
リロケーションは、参照するシンボルから何byte離れているかのオフセットを持つことができる。
movl $ref_32bit + 32, %eax # オフセット付き
この命令の場合、$ref_32bit の値は、リンク後に決まるが、即値movl 命令には加算機能が無いので、 この +32 は、アセンブル時にも実行時にも計算することはできない。 しかし、実際にはこれは正しく意図したとおりに動作するはずだ。この +32 はリンカが計算している。
アセンブラは、アセンブル時に、ラベル + 定数となっている値は、あとでリンカが計算できるように、「ラベル + 定数」という情報をそのまま リロケーションの中にエンコードする。
Offset Info Type Sym. Value Sym. Name + Addend 000000000011 00050000000a R_X86_64_32 0000000000000000 ref_32bit + 20
readelf -r を見ると、ref_32bit + 20 (10進で32) と、そのままの形で情報が保存されているのが確認できるはずだ。
最後に、リロケーションのサイズについて。
リロケーションする値が、リロケーションの型のサイズを上回っていた場合、リンク時にエラーになる。
$ as reloc_label.s -o reloc_label.o $ as reloc.s -o reloc.o $ ld reloc.o reloc_label.o -Ttext=0x100 # ラベルの配置アドレスを8bitを超えるようにする。 reloc.o: In function `_start': (.text+0xa): relocation truncated to fit: R_X86_64_8 against symbol `ref_8bit' defined in .text section in reloc_label.o
ref_8bit の値が、R_X86_64_8 (8bit) に収めるために、切り捨てられたというエラーメッセージが出るはずだ。
C 言語でも似たようなエラーを見たことがある人がいるかもしれない。
#include <stdio.h> extern int a[]; extern int b[]; int main() { printf("%d %d\n", a[0], b[0]); }
int a[1024*1024*1024*4ULL]; int b[1024*1024*1024*4ULL];
ぱっと思い付くのは、32bit の範囲を超えるサイズの配列を定義した場合だ。
$ gcc large0.c large1.c /tmp/ccOwnkuE.o: In function `main&39;: large0.c:(.text+0xc): relocation truncated to fit: R_X86_64_PC32 against symbol `a&39; defined in COMMON section in /tmp/cclJzRGJ.o collect2: error: ld returned 1 exit status
x86_64 の gcc は、特に指定しない場合、配列の参照に 32bit リロケーションを使うので、配列サイズが32bitを超えてしまうと、 リンクに失敗するようになる。
対処法としては、gcc に -mcmodel=large を付けると、配列の参照時のリロケーションに、64bit リロケーションを使ってくれるようになる。
$ gcc -mcmodel=large large0.c large1.c $ ./a.out 0 0
色々と書いたので、少しまとめておこう。ここを読んでよくわからなければ、もう一度この章を最初から読んでいってほしい。
(TODO:図を入れる)
1. アセンブラが、ラベル位置、ラベル文字列、ラベル参照を適切なシンボル、リロケーションにエンコードしてオブジェクトに入れる
2. リンカは、起動されると渡されたオブジェクトを順に並べていく
3. オブジェクトを並べると定義済みシンボルの最終的な位置が確定する
4. 確定した定義済みシンボルの値をリロケーションに埋めていく
シンボル情報はDLLを使わない場合、実行時には必要の無い情報だが、 デバッグ時や、その他問題の対応時に人間にとっていくらか有用な場合があることを覚えておこう。
これまで何度か gdb を使ってプログラムを動かしてきたと思うが、そのときに、数字のアドレスではなく、 文字列としてのシンボルを扱えていたことを思い出してほしい。 例えば、"break _start" などとすると、_startシンボルを解決した後のアドレスにブレークポイントを設定できていたはずだ。
デバッガは、デバッグ情報が存在しなかった場合でも、 残っているシンボル情報から、なるべくアドレスがわかりやすい値になるように 最善を尽くす努力をしていて、
というような挙動をする。
一番最初に info registers と打ったときの結果を思い出してほしい。
(gdb) start Temporary breakpoint 1 at 0x4004d6 Starting program: /mnt/d/wsl/src/pllp/docs/x8664_asm_language/a.out Temporary breakpoint 1, 0x00000000004004d6 in main () (gdb) info registers rax 0x4004d6 4195542 rbx 0x0 0 rcx 0x0 0 rdx 0x7ffffffee518 140737488282904 rsi 0x7ffffffee508 140737488282888 rdi 0x1 1 rbp 0x4004e0 0x4004e0 <__libc_csu_init> rsp 0x7ffffffee428 0x7ffffffee428 r8 0x400550 4195664 r9 0x7fffff410ab0 140737475840688 r10 0x846 2118 r11 0x7fffff050740 140737471907648 r12 0x4003e0 4195296 r13 0x7ffffffee500 140737488282880 r14 0x0 0 r15 0x0 0 rip 0x4004d6 0x4004d6 <main> eflags 0x246 [ PF ZF IF ] cs 0x33 51 ss 0x2b 43 ds 0x0 0 es 0x0 0 fs 0x0 0 gs 0x0 0
rbp の値に __libc_csu_init という文字列が付いているのがわかるだろう。 これが gdb が rbp に残っていた値から探してきたシンボルの文字列だ。
__libc_csu_init は、名前からして、libc の C Start Up の init だという推測ができる。 main に来る前のスタートアップの初期化処理で rbp レジスタに何か値を入れていたのが残っていたのだろう。
という二点を知っておけば、main に来た直後にデバッガで全レジスタを表示したら、スタートアップ処理に関連するシンボルが表示される可能性が高いという のは推測できるようになって、この表示は不思議な表示ではなくなったはずだ。
シンボル情報は、実行時には必要ないものもあるので、実行ファイルから消すことものできる。 Linux環境では、strip -s するとシンボル情報を消すことが可能だ。
$ gcc -static main.s $ readelf -s a.out Symbol table '.symtab' contains 1803 entries: 番号: 値 サイズ タイプ Bind Vis 索引名 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND (.. 略 ..) 1802: 00000000004100d0 279 FUNC WEAK DEFAULT 6 fopen64 $ strip -s a.out $ readelf -s a.out $ # 何も表示されない $ $ gdb a.out GNU gdb (Ubuntu 8.1-0ubuntu3) 8.1.0.20180409-git Copyright (C) 2018 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "x86_64-linux-gnu". Type "show configuration" for configuration details. For bug reporting instructions, please see: <http://www.gnu.org/software/gdb/bugs/>. Find the GDB manual and other documentation resources online at: <http://www.gnu.org/software/gdb/documentation/>. For help, type "help". Type "apropos word" to search for commands related to "word"... Reading symbols from a.out...(no debugging symbols found)...done. (gdb) start No symbol table loaded. Use the "file" command.
strip -s すると、readelf -s しても何も表示されなくなっていることを確認しよう。
また、gdb の start コマンドも使えなくなっていることも確認してほしい。
gdb の start コマンドは、定義済みシンボル "main" のあるアドレスにブレークポイントを設定するが、 strip -s してしまうと、定義済みシンボル "main" に関する情報は削除されてしまうので、 start コマンドは使えなくなってしまう。
また、これまで gdb で disassemble コマンドを実行したときに、 main に書いた覚えのない命令が付いているのに気付いた人もいるかもしれない。
$ gcc main.s $ gcc -static main.s $ gdb a.out GNU gdb (Ubuntu 8.1-0ubuntu3) 8.1.0.20180409-git Copyright (C) 2018 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "x86_64-linux-gnu". Type "show configuration" for configuration details. For bug reporting instructions, please see: <http://www.gnu.org/software/gdb/bugs/>. Find the GDB manual and other documentation resources online at: <http://www.gnu.org/software/gdb/documentation/>. For help, type "help". Type "apropos word" to search for commands related to "word"... Reading symbols from a.out...(no debugging symbols found)...done. (gdb) start Temporary breakpoint 1 at 0x400b4d Starting program: /home/w0/src/pllp/docs/link/a.out Temporary breakpoint 1, 0x0000000000400b4d in main () (gdb) disassemble Dump of assembler code for function main: => 0x0000000000400b4d <+0>: retq 0x0000000000400b4e <+1>: xchg %ax,%ax # この xchg は自分で書いたものではない End of assembler dump. (gdb)
main の ret のあとに、自分で書いたことのないxchgが付いているのを確認しよう。(環境によっては状況が変わるかもしれない)
定義済みシンボルは、あくまで単一のアドレスを指すだけなので、 mainシンボルが置かれた位置を記録できるが、mainに含まれる命令群の終わりは記録されていない。
disassemble というコマンドは、指定された関数に含まれる命令列を表示するコマンドだが、 gdbから見た場合、関数の先頭アドレスしかわからないのだ。 そのため、disassemble は、次の定義済みシンボルまでの領域を関数と推測して、それを表示している。
(gdb) x/4i main 0x400b4d <main>: retq 0x400b4e <main+1>: xchg %ax,%ax 0x400b50 <get_common_indeces.constprop.1>: push %rbx 0x400b51 <get_common_indeces.constprop.1+1>: sub $0x88,%rsp
x コマンドなどで命令列を表示すれば、次の命令に定義済みシンボルが起かれていることを確認できるはずだ。 (これも環境によって変わる場合があるので、別の表示になっているかもしれない)
幸い、Linuxで採用されているELFでは、定義済みシンボルに、サイズとシンボルの種類を与えることができる。
.globl main main: ret .size main, 1 # main のサイズ = 1byte .type main, @function # main は FUNC
$ gcc main_with_size.s $ LANG=C readelf -s a.out | grep '\(Size\)\|\( main\)' Num: Value Size Type Bind Vis Ndx Name Num: Value Size Type Bind Vis Ndx Name 55: 00000000000005fa 1 FUNC GLOBAL DEFAULT 13 main $ gdb a.out GNU gdb (Ubuntu 8.1-0ubuntu3) 8.1.0.20180409-git Copyright (C) 2018 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "x86_64-linux-gnu". Type "show configuration" for configuration details. For bug reporting instructions, please see: <http://www.gnu.org/software/gdb/bugs/>. Find the GDB manual and other documentation resources online at: <http://www.gnu.org/software/gdb/documentation/>. For help, type "help". Type "apropos word" to search for commands related to "word"... Reading symbols from a.out...(no debugging symbols found)...done. (gdb) disassemble main Dump of assembler code for function main: 0x00000000000005fa <+0>: retq End of assembler dump.
readelf で見て、Size が 1 になっており、 disassemble の結果が正しく1byte分の命令だけ表示されていることを確認しよう。 (シンボルのタイプをFUNCにする効果は筆者もよく知らない)
シンボルに関する情報は、このようにプログラムを解析する場合にも活用できることが ご理解いただけただろうか。
ただ、現実的には、自分でプログラムをビルドした時には、より強力なデバッグ情報を 活用してデバッグをすることが多いだろう。
デバッガを使ったことがある人なら、実行中の関数のローカル変数の状態を表示したりしたことがあるはずだ。 これらを表示するために必要な情報はシンボル情報には含まれておらず、 別途デバッグ情報と呼ばれる情報を付加してビルドする必要がある。
デバッグ情報については、またあとのデバッガの章で詳しく解説することにしよう。
この節では、デバッグ情報が無くてもデバッガは残された情報を 努力して表示しているということを説明したかった。 このデバッガの努力は、他人がビルドしてデバッグ情報が手に入らないようなプログラムを 解析するぐらい追い詰められた時に役立ってくれるはずだ。
現代の多くのコンピュータでは、プログラムもデータもメモリに置かれたビット列であって、 そのビット列の解釈の方法に違いがあるだけで、保存された情報に本質的な区別はない。
それでも、ビット列の使いかたにあわせて、 ビット列を分類してまとめておくと、有用な場合がある。
わかりやすいのは、読み書きするビット列と読み込み専用ビット列の区別だ。
本当に小さな組み込み機器に入っているコンピュータには、 書き換え可能なRAMはほんの数KBぐらいしかなく、 それに少し大きめのマスクROMが付いている、というものが多く存在する。
そのようなコンピュータでメモリを効率良く使用するには、プログラムで使うビット列を、
というように使い分けて、小さなRAMに乗るデータは必要最低限にしたほうがいい。
また、一部のマイナーなコンピュータでは、データ用のメモリと、プログラム用のメモリが 完全に別れているものもあるが、そういう場合は、 プログラムとして使われるビット列だけを別にまとめておかないと、 正しく動くプログラムが出力できない。
このようなことを実現するために、 オブジェクトファイルは、ビット列を分類する機能を持っている。 この分類を、セクション(section)と呼ぶ。
多くの環境で使われているセクションは、次のよっつだ
(data,rodata はともかく、text,bss という名前は意味不明だが、 これは多くの環境で採用されている名前なので、 深く考えないでそういうものだと覚えるしかない。)
bss は data とほぼ同じだが、 bss が data とは別に用意されている理由について説明をしておこう。
実際のプログラムでは、読み書きされるビット列には、プログラム開始時には初期値が必要ないものが多い。 この初期値の必要ないビット列を保存する領域は、オブジェクトファイル内にビット列を保存する必要がなく、 領域のサイズだけを保存しておけば十分だ。
そこで、初期値のない領域については、オブジェクトファイル内にビット列を保存するのではなく、 かわりに、領域のサイズだけを記録しておく。 そうすれば、オブジェクトファイルのサイズを減らすことができる。
PCでは実行ファイルのサイズは大きな問題ではないが、ROM領域の限られた小さいマシンでは、 実行ファイルのサイズが小さくなれば助かることも多い。 そのため、初期値の必要ないデータは、初期値ありの読み書きするデータとは区別しておくのだ。
(TODO:適切な図を入れる)
実際の環境では、この標準的なよっつのセクション(text,data,rodata,bss)に加えて、 環境依存のセクションや、リンクを補助するためのセクションがいくつかある。それについてはまた必要になったときに説明しよう。
セクションの使いかたを理解していこう。
アセンブリ言語には、内で指定されたビット列(命令、データ)をどのセクションに配置するか を指示する疑似命令があり、アセンブラは、これに従って、プログラマに指示された通りにビット列を 適切なセクションに配置していく。
.section .text, "ax", @progbits .byte 0xaa .section .rodata, "a", @progbits .byte 0x55 .section .data, "aw", @progbits .byte 0xff .section .bss, "aw", @nobits
".section" 疑似命令は、以下に続く命令、データを配置するセクションを指定するための疑似命令だ。".section <名前>, <フラグ>, <タイプ>" と書くと、指定した名前のセクションに命令、データを配置できる。 フラグとタイプの指定方法は、 https://sourceware.org/binutils/docs-2.34/as/Section.html#Section にあるが(Linux の場合は ELF Version の箇所を見よう)、簡単に書いておくと、
フラグは
という文字を繋げてダブルクオートで囲って記述する。
タイプは
などが指定できる
さきほどの例の、
.section .text, "ax", @progbits
と行は、「以降の命令やデータを実行時にロードされて(a)実行可能な(x)、データを持った(@progbits) ".text" セクションに置く」という意味になる。
これを確認してみよう。 objdump コマンドに "-s" を付けて実行すると、各セクションに含まれるデータを表示できる
$ as section.s $ objdump -s a.out a.out: ファイル形式 elf64-x86-64 セクション .text の内容: 0000 aa . セクション .data の内容: 0000 ff . セクション .rodata の内容: 0000 55 U
各セクションに指定したとおりにデータが格納されていることを確認しよう。
readelf コマンドに "-S" オプションを付けて実行すると、各セクションに保存されたフラグなどを表示することができる。
$ readelf -S a.out There are 8 section headers, starting at offset 0xf8: セクションヘッダ: [番] 名前 タイプ アドレス オフセット サイズ EntSize フラグ Link 情報 整列 [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .text PROGBITS 0000000000000000 00000040 0000000000000001 0000000000000000 AX 0 0 1 [ 2] .data PROGBITS 0000000000000000 00000041 0000000000000001 0000000000000000 WA 0 0 1 [ 3] .bss NOBITS 0000000000000000 00000042 0000000000000000 0000000000000000 WA 0 0 1 [ 4] .rodata PROGBITS 0000000000000000 00000042 0000000000000001 0000000000000000 A 0 0 1 [ 5] .symtab SYMTAB 0000000000000000 00000048 0000000000000078 0000000000000018 6 5 8 [ 6] .strtab STRTAB 0000000000000000 000000c0 0000000000000001 0000000000000000 0 0 1 p [ 7] .shstrtab STRTAB 0000000000000000 000000c1 0000000000000034 0000000000000000 0 0 1 Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings), I (info), L (link order), O (extra OS processing required), G (group), T (TLS), C (compressed), x (unknown), o (OS specific), E (exclude),
指定した、.text, .data, rodata, .bss に付けたフラグ、タイプが適用されていることを確認しよう。
なお、GNU のアセンブラは、よく使われる .text, .data, .bss のみっつのセクションはフラグ、タイプが事前に決まっており、この場合は、フラグ、タイプを省略して
.section .text .byte 0xaa .section .rodata .byte 0x55 .section .data .byte 0xff .section .bss
と書いても全く同じオブジェクトが出力される。さらに、何故か .text, .data, .bss のみっつのセクションについては、専用の疑似命令が用意されており、
.text .byte 0xaa .section .rodata .byte 0x55 .data .byte 0xff .bss
と、書いても同じ結果になる。コンパイラが出力するアセンブリは、この省略形で出力されるので、覚えておこう。(なぜか.rodataには用意されていない。理由は筆者も知らないし、おそらくどうでもいい理由なので考えないようにしよう)
もう少し色々書いて理解を深めておこう。
.text などの名前は現代ではただの習慣なので、セクション名は自分の好きなように付けてよい。
.section anatano-sukina-namae,"awx",@progbits
".text"などのように、セクション名の先頭に"."が付いてるのも習慣で、無くてもよい。
アセンブリに記述されたデータは、セクション毎に集められる。同じセクションを2度書いた場合、順番に並べられる。
.section section0 label0: .byte 0x41 # asciiの'A' .byte 0x42 .byte 0x43 .byte 0x44 .section section1 label1: .byte 0x45 .byte 0x46 .byte 0x47 .byte 0x48 .section section0 label2: .byte 0x49 .byte 0x4a .byte 0x4b .byte 0x4c
$ as section-order.s -o a.out $ objdump -s a.out a.out: file format elf64-x86-64 Contents of section section0: 0000 41424344 494a4b4c ABCDIJKL Contents of section section1: 0000 45464748 EFGH
section0 には、"ABCD" "IJKL" が順に格納されて、section1 には EFGH が格納されていることを確認しよう。(アセンブリ上で分割されていたsection0が連結(リンク)されている)
定義済みラベルに与えられるアドレスも、セクション毎に振られていく。
$ readelf -S a.out # -S = セクションを表示 There are 9 section headers, starting at offset 0x180: Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .text PROGBITS 0000000000000000 00000040 0000000000000000 0000000000000000 AX 0 0 1 [ 2] .data PROGBITS 0000000000000000 00000040 0000000000000000 0000000000000000 WA 0 0 1 [ 3] .bss NOBITS 0000000000000000 00000040 0000000000000000 0000000000000000 WA 0 0 1 [ 4] section0 PROGBITS 0000000000000000 00000040 0000000000000008 0000000000000000 0 0 1 [ 5] section1 PROGBITS 0000000000000000 00000048 0000000000000004 0000000000000000 0 0 1 [ 6] .symtab SYMTAB 0000000000000000 00000050 00000000000000d8 0000000000000018 7 9 8 [ 7] .strtab STRTAB 0000000000000000 00000128 0000000000000016 0000000000000000 0 0 1 [ 8] .shstrtab STRTAB 0000000000000000 0000013e 000000000000003e 0000000000000000 0 0 1 Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings), I (info), L (link order), O (extra OS processing required), G (group), T (TLS), C (compressed), x (unknown), o (OS specific), E (exclude), l (large), p (processor specific) $ readelf -s a.out # -s = シンボルを表示 Symbol table '.symtab' contains 9 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 SECTION LOCAL DEFAULT 1 2: 0000000000000000 0 SECTION LOCAL DEFAULT 2 3: 0000000000000000 0 SECTION LOCAL DEFAULT 3 4: 0000000000000000 0 SECTION LOCAL DEFAULT 4 5: 0000000000000000 0 NOTYPE LOCAL DEFAULT 4 label0 6: 0000000000000000 0 SECTION LOCAL DEFAULT 6 7: 0000000000000000 0 NOTYPE LOCAL DEFAULT 5 label1 8: 0000000000000004 0 NOTYPE LOCAL DEFAULT 4 label2
readelf -s の出力のNdxカラムとName,Valueに注目しよう。 Ndx カラムは、そのシンボルが所属するセクションの名前を識別するインデクスだ。
readelf -S の出力を見ると、4番目のセクションがsection0, 5番目のセクションがsection1ということがわかる。 (4,5 以外のセクションは今は見なくてよい)
[ 4] section0 PROGBITS 0000000000000000 00000040 0000000000000008 0000000000000000 0 0 1 [ 5] section1 PROGBITS 0000000000000000 00000048 0000000000000004 0000000000000000 0 0 1
この 4, 5 と、readelf -s の出力の Ndx カラムが対応する。
Num: Value Size Type Bind Vis Ndx Name 5: 0000000000000000 0 NOTYPE LOCAL DEFAULT 4 label0 7: 0000000000000000 0 NOTYPE LOCAL DEFAULT 5 label1 8: 0000000000000004 0 NOTYPE LOCAL DEFAULT 4 label2
これをあわせて読むと、
となる。
さきほど書いたアセンブリと格納されている情報が一致していることを確認しよう。
.section section0 # 以降のオブジェクトとラベルをsection0に配置 label0: # label0 を section0 の先頭(アドレス0)に定義 .byte 0x41 # 1byteのデータをsection0 に可能 .byte 0x42 .byte 0x43 .byte 0x44 .section section1 # 以降のオブジェクトとラベルをsection1に配置 label1: # label1 を section1 の先頭(アドレス0)に定義 .byte 0x45 .byte 0x46 .byte 0x47 .byte 0x48 .section section0 # 以降のオブジェクトとラベルをsection0に配置 label2: # label2 を section0 のアドレス4 に配置 .byte 0x49 .byte 0x4a .byte 0x4b .byte 0x4c
余談だが、readelfを使う場面と、objdumpを使う場面の使いわけ方法だが、筆者もあまり把握していない(どちらもセクションを表示する機能があるが、readelfの-sとobjdumpの-Sで違っており、これはいつも間違える)。両方実行してみて使えそうなほうを使うことが多い。
readelfはELFに特化したコマンドなのに対して、objdump はELFに限らず、Windowsや古のUnix用のオブジェクトも対応したコマンドになっている。多くの場合は、ヘッダやシンボルなどのメタ情報はELF固有のものが多いのでreadelfを使ったほうがよくて、実際のデータ部分を解析する場合は、objdumpを使ったほうがよいように思う。
(ここまで書いた )
リンカは、与えられたオブジェクトファイルに含まれるビット列を結合するときに、 セクションごとにまとめて、アドレスを与えて結合していく。
ここまで、シンボル、リロケーション、セクション、セグメント について説明してきた。
GNU/Linux 環境では、これらの情報は、ELF (Executable Loadable Format) というフォーマットを持つオブジェクトファイルの中に保存される。
ELFの構造を覚えておけば、アセンブラ、リンカが何をやってるかというのがより具体的に イメージできるようになるだろう。
また、ELFファイルを読むプログラムをぱっと書けるようになれば、 まあ色々な場面で役立つことがあるはずだ。 筆者もELFファイルをロードするプログラムを書いて救われた場面が何度かある。
ELF ファイルを読むために必要な構造体などは、elf.h というヘッダに含まれている。 elf.h は、glibc のヘッダの一部としてインストールされるようなので、 Linux上でC言語が使える環境なら、通常はあわせてインストールされているはずだ。
man elf を見れば、このelf.hに含まれている定義の説明も確認できるだろう。
一旦構造を理解すれば、あとはこの elf.h と man elf を見れば ELFを読み書きするプログラムが書けるようになるはずだ。
オブジェクトファイルのフォーマットは、OS ごとに異なるが、 リロケーション、シンボル、セクションといった概念は、どのオブジェクトでも大きく変わることはない。 ここでは、Windowsで使われているCOFFについて簡単に説明する。 これまで見てきた概念は、特定の実装に依存せず広く利用されているのを確認してほしい。
(ちなみに、OSXでは、Mach-O というまた別のフォーマットが採用されている。筆者はあまり詳しくないので、OSXユーザは各自で調べてください)
Microsoft の開発環境をインストールして cl.exe などのツールが動くようにしてほしい。 cl.exe が動くようになっていれば、dumpbin.exe というコマンドも使えるようになっているはずだ。 dumpbin.exe は、readelf のようにオブジェクトの内容を表示するコマンドがある。これで色々見ていってみよう。