戻る

リンカ

ついに、リンカの説明をするときが来た。

ここに至るまでに、何度「リンカのところで説明する」と書いただろうか? ここまで読んできた人ならば、 リンカというものが、なにやら色々やっているんだな、というのはわかってきたのではないかと思う。

筆者が常々思っていることのひとつに、「C言語に関する書籍は、リンカの説明をおざなりにしすぎだ」というのがある。

多くのC言語の書籍は、

  1. コンパイラがソースコードをアセンブリコードに変換します
  2. アセンブラがアセンブリコードを機械語に変換します
  3. リンカが機械語をリンクして実行ファイルが作られます

と、いう解説がなされがちである。この説明を見たら、多くの人が、「え、リンクってなんですか?」と、思うに違いない。

アセンブラには、「人間が読めるニーモニックを、機械が読める機械語に変換する」みたいな、最低限の説明が付くものの、 リンカの説明は「リンクをします」のひとことだけである!

ここでは、いつも雑な説明をされがちな、リンカについて説明をしていきたいと思う。

C言語の言語仕様には、明示的にリンクについて書かれてはいないものの、 extern 指定子など、言語仕様の一部に、リンクの処理を無視して説明できない仕様を含んでいるのは間違いない。 リンクについて知れば、C言語への理解も、もう一歩深まるだろう。

コンパイラドライバ、libc、スタートアップルーチン

リンカの説明前にいくつか必要な説明をしておこう。

link/main.s

	.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 命令を実行すればよい。

link/exit.s

	.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" ラベルが見つからないという意味だ。

プログラムの起動と終了を要約すると、以下のようになる。

  1. _start ラベルが置かれた位置からプログラムスタート
  2. スタートアップルーチンが初期化する
  3. スタートアップルーチンが main を call する
  4. main でユーザが書いたプログラムを処理
  5. main から ret
  6. スタートアップルーチンに戻ってくる
  7. 終了処理
  8. OS に終了依頼

通常、コンパイラドライバ経由でオブジェクトをリンクした場合、スタートアップルーチンがリンクされるが、 今は、スタートアップルーチンをリンクしないで実行ファイルを作っているので、最初の _start が定義されてない。 よって、"cannot find entry symbol _start"という警告が出るのだ。

自分で _start を定義してスタートアップルーチンを書いてみよう。 と、いってもやることは、1. mainを呼ぶ 2. mainが終わったら終了する の二個だけだ。

link/start_exit.s

	.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の説明は省略する。必要になったときに思い出してほしい)

これでリンカを単体で使う方法を説明できたので、いよいよ次はリンカの説明に入っていこう。

リンクとはなにか

リンカがやっていることは、主に

の二点だ。

次のふたつのファイルをリンクしてみよう。

link/link0.s

	.globl	_start
_start:
	ret

link/link1.s

	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++ のグローバル変数のコンストラクタの呼び出し順などは、この順序で決まる実装が多いので、覚えておくと何かの役に立つかもしれない。

続いて、リンカの重要な仕事が、ラベル参照の解決だ。これまでにも簡単に解説してきたが、あらためて説明しておこう。

アセンブリファイル中には、ラベルが置ける。プログラムに含まれる命令、データは、リンクが終わるまでアドレスが確定しないが、 ラベルを使うことで、リンク後に決まるアドレスの値を確定前に参照することができる。

link/link_label0.s

	.globl	_start
_start:
	mov	$label0, %rax

link/link_label1.s

	.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"を付けて実行すると、オブジェクトに含まれる定義済みシンボル、未定義シンボルの情報を表示することができる。

link/defsym.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が付かないグローバル変数、関数が相当する。

ローカルシンボルは、別のファイルからは参照できない。

link/local_symbol0.s

_start:
	mov	$local_symbol, %rax
	mov	$global_symbol, %rax

link/local_symbol1.s

	.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
$
 

詳しく見ていこう。

link/reloc.s

	.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

link/reloc_label.s

	.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 言語でも似たようなエラーを見たことがある人がいるかもしれない。

link/large0.c

#include <stdio.h>
extern int a[];
extern int b[];

int main()
{
    printf("%d %d\n", a[0], b[0]);
}

link/large1.c

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では、定義済みシンボルに、サイズとシンボルの種類を与えることができる。

link/main_with_size.s

	.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)に加えて、 環境依存のセクションや、リンクを補助するためのセクションがいくつかある。それについてはまた必要になったときに説明しよう。

セクションの使いかたを理解していこう。

アセンブリ言語には、内で指定されたビット列(命令、データ)をどのセクションに配置するか を指示する疑似命令があり、アセンブラは、これに従って、プログラマに指示された通りにビット列を 適切なセクションに配置していく。

link/section.s

	.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度書いた場合、順番に並べられる。

link/section-order.s

	.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を使ったほうがよいように思う。

(ここまで書いた )

リンカは、与えられたオブジェクトファイルに含まれるビット列を結合するときに、 セクションごとにまとめて、アドレスを与えて結合していく。

実行ファイル、セグメント

ELF

ここまで、シンボル、リロケーション、セクション、セグメント について説明してきた。

GNU/Linux 環境では、これらの情報は、ELF (Executable Loadable Format) というフォーマットを持つオブジェクトファイルの中に保存される。

ELFの構造を覚えておけば、アセンブラ、リンカが何をやってるかというのがより具体的に イメージできるようになるだろう。

また、ELFファイルを読むプログラムをぱっと書けるようになれば、 まあ色々な場面で役立つことがあるはずだ。 筆者もELFファイルをロードするプログラムを書いて救われた場面が何度かある。

elf.h

ELF ファイルを読むために必要な構造体などは、elf.h というヘッダに含まれている。 elf.h は、glibc のヘッダの一部としてインストールされるようなので、 Linux上でC言語が使える環境なら、通常はあわせてインストールされているはずだ。

man elf を見れば、このelf.hに含まれている定義の説明も確認できるだろう。

一旦構造を理解すれば、あとはこの elf.h と man elf を見れば ELFを読み書きするプログラムが書けるようになるはずだ。

ldscript

ローダ

PE/COFF

オブジェクトファイルのフォーマットは、OS ごとに異なるが、 リロケーション、シンボル、セクションといった概念は、どのオブジェクトでも大きく変わることはない。 ここでは、Windowsで使われているCOFFについて簡単に説明する。 これまで見てきた概念は、特定の実装に依存せず広く利用されているのを確認してほしい。

(ちなみに、OSXでは、Mach-O というまた別のフォーマットが採用されている。筆者はあまり詳しくないので、OSXユーザは各自で調べてください)

Microsoft の開発環境をインストールして cl.exe などのツールが動くようにしてほしい。 cl.exe が動くようになっていれば、dumpbin.exe というコマンドも使えるようになっているはずだ。 dumpbin.exe は、readelf のようにオブジェクトの内容を表示するコマンドがある。これで色々見ていってみよう。

戻る