目次
これまで何度も使ってきたgdb、つまりデバッガだが、これがどのように動いているかを見ていこう。
"デバッガ" とはなんだろうか。 "デバッガ" というと、バグを取ってくれるようなツールに聞こえるが、みなさんご存知のとおり、デバッガはプログラマのかわりにバグを取ってくれるわけではない。 実際のデバッガの動作は実行中のプログラムの状態を見れるツール、つまり "プログラムの状態ビューワ" とでも言ったほうが、現実とあっているだろう。
個人的には'デバッガ"という名称は実態とあってない、とは思うが、この章では、慣習にしたがって、プログラムの状態を調査、変更するツールのことを"デバッガ"と呼び、 そのデバッガを使って実際に何かをすることを"デバッグ"と呼ぶ。 また、デバッグされるプログラムの対象を"デバッギ(debugee)"と呼ぶ。
この章では、まず、デバッガが必要とする基本的な操作について説明し、続けてデバッグ情報についても説明する。そのあと、その操作とデバッグ情報を組み合わせて、デバッガの機能を実現する方法について説明していく。
Linux ではデバッガの実装時に役立つ、ptrace というシステムコールがある。
ptrace は、対象となるプロセスの状態を読み書きできるシステムコールである。
デバッガを実装する場合、対象となるプログラムのメモリやレジスタを読み書きしたい場合が多い。 ptrace を使えば、それが実現できる。
#include <stdio.h> #include <sys/ptrace.h> #include <sys/wait.h> #include <stdint.h> #include <unistd.h> #include <signal.h> int main(int argc, char **argv) { volatile uint64_t x; /* 他のプログラムから読み書きする変数はvolatileにする */ int pid; if ( (pid = fork()) == 0) { /* 子プロセス(tracee) */ x = 0xaa55aa55; while (1) { sleep(10); } } else { /* 親プロセス(tracer) */ int st; usleep(1000); ptrace(PTRACE_ATTACH, pid, 0, 0); /* 子プロセスを監視対象(tracee)にする */ waitpid(pid, &st, 0); /* traceeが停止するまで待機 */ x = ptrace(PTRACE_PEEKDATA, pid, &x, 0); /* traceeのメモリから値を取得 */ printf("%x\n", (int)x); ptrace(PTRACE_DETACH, pid, 0, 0); /* traceeを監視対象から外す */ kill(pid, SIGKILL); /* 子プロセスの終了 */ waitpid(pid, &st, 0); } return 0; }
$ gcc -Wall -no-pie -o ptrace1 ptrace1.c $ ./ptrace1
aa55aa55
まず、PTRACE_ATTACHと対象プロセスのpidを引数にして、ptrace を呼び出す。これで、対象プロセスがアタッチされる(操作可能になる)。 ptraceの説明では、操作する側のプロセス(ここでは親プロセス)を tracer、操作される側(ここでは子プロセス)をtracee と呼んでいる。それにならって、ここでは同じようにtracer,traceeと呼ぶことにしよう。
tracer が tracee をアタッチすると、tracee は停止する。これは非同期に実行されるので、停止したのが確定するまでwaitpidで待つ。
アタッチしたあと、PTRACE_PEEKDATAとpid,アドレスを引数にして、ptraceを呼び出すと、traceeのメモリからデータを読むことができる。
この例では、tracee は、trace から fork したプロセスなので、変数 "x" のアドレスは同じになっている。そのため、PTRACE_PEEKDATA に x のアドレスを渡すと、traceeの変数"x"の値が取得できる。 fork しない場合は、変数名とアドレスの対応は、なんらかの方法で取得する必要がある。取得方法についてはあとでデバッグ情報のところで解説しよう。
tracerが必要な操作を終えたあとは、PTRACE_DETACHでデタッチする(操作を終了する)。traceeがアタッチされたままだと、シグナルがtracerに送られてしまい、挙動が変わってしまう。このプログラムはシグナルを使っていないので影響ないが、正しく処理するときはデタッチしておこう。
tracee のメモリを書きかえたいときは、PTRACE_POKEDATA を使う。PTRACE_ATTACHで一時停止したプログラムは、PTRACE_CONT を使えば、再開できる。また、この例では使っていないが、再開したプログラムを再度一時停止したい場合は、SIGSTOPを止めたいスレッドに送る。
#include <stdio.h> #include <sys/ptrace.h> #include <sys/wait.h> #include <stdint.h> #include <unistd.h> #include <signal.h> int main(int argc, char **argv) { volatile uint64_t x; /* 他のプログラムから読み書きする変数はvolatileにする */ int pid; if ( (pid = fork()) == 0) { /* 子プロセス(tracee) */ x = 0; while (x==0) /* tracerが書きかえてくれるまで待つ */ ; printf("%x\n", (int)x); } else { /* 親プロセス(tracer) */ int st; usleep(1000); ptrace(PTRACE_ATTACH, pid, 0, 0); /* 子プロセスを監視対象(tracee)にする */ waitpid(pid, &st, 0); /* traceeが停止するまで待機 */ uint64_t newdata = 0x88888888; ptrace(PTRACE_POKEDATA, pid, &x, (void*)(uintptr_t)newdata); /* traceeのメモリへ書き込み */ ptrace(PTRACE_CONT, pid, 0, 0); /* 停止したtraceeを再開 */ ptrace(PTRACE_DETACH, pid, 0, 0); /* traceeを監視対象から外す */ waitpid(pid, &st, 0); /* 子プロセスの終了待ち */ } return 0; }
$ gcc -Wall -no-pie -o ptrace2 ptrace2.c $ ./ptrace2
88888888
メモリと同様に、traceeのレジスタを読み書きすることができる。読むときはPTRACE_GETREGS、書くときはPTRACE_SETREGSを使う。
#include <stdio.h> #include <sys/ptrace.h> #include <sys/wait.h> #include <sys/user.h> #include <stdint.h> #include <unistd.h> #include <signal.h> int main(int argc, char **argv) { int pid; if ( (pid = fork()) == 0) { /* 子プロセス(tracee) */ while (1) ; } else { /* 親プロセス(tracer) */ int st; usleep(1000); ptrace(PTRACE_ATTACH, pid, 0, 0); /* 子プロセスを監視対象(tracee)にする */ waitpid(pid, &st, 0); /* traceeが停止するまで待機 */ struct user_regs_struct regs; ptrace(PTRACE_GETREGS, pid, 0, ®s); /* traceeのレジスタの値を取得 */ printf("rip=%016llx, main=%016llx, delta=%llx\n", (long long)regs.rip, (long long)main, (long long)regs.rip - (long long)main); ptrace(PTRACE_DETACH, pid, 0, 0); /* traceeを監視対象から外す */ kill(pid, SIGKILL); waitpid(pid, &st, 0); } return 0; }
$ gcc -Wall -no-pie -o ptrace3 ptrace3.c $ ./ptrace3
rip=00000000004011c1, main=0000000000401186, delta=3b
プログラムカウンタが、main の近くにあることを確認しよう。
ここまでに、
これらの操作について説明してきた。 これらの操作は、デバッガを作るときの、一番基本となる操作である。 デバッガには、色々な機能があるが、多くの機能が、この操作を使って実現されている。 つまり、これらの操作ができれば、その上にデバッガを実装できるわけだ。
Linux では、これらの操作を実現するために、ptraceというシステムコールを使っていた。POSIX互換のOSでは、ptraceが実装されていることが多く、OSX等でも同じようにこれらの操作ができる。では他のシステムではどうだろうか。
Windowsでは、これらの操作と対応するAPIが用意されており、それを使えばこれらの操作を実現できる。
操作 | API | 対応するLinuxでの操作 |
---|---|---|
デバッグの開始 | DebugActiveProcess | ptrace(PTRACE_ATTACH) |
デバッグの終了 | DebugActiveProcessStop | ptrace(PTRACE_DETACH) |
実行の一時停止 | SuspendThread | SIGSTOPを送る |
実行の再開 | ResumeThread | ptrace(PTRACE_CONT) |
メモリからの読み込み | ReadProcessMemory | ptrace(PTRACE_PEEKDATA) |
メモリへの書き込み | WriteProcessMemory | ptrace(PTRACE_POKEDATA) |
レジスタからの読み込み | GetThreadContext | ptrace(PTRACE_PEEKUSER) |
レジスタへの書き込み | SetThreadContext | ptrace(PTRACE_POKEUSER) |
ではOSが無い場合はどうだろうか。
OSが無い場合の対処方はいくつかあるが、gdb stub を使う方法を紹介しよう。
gdb stub (GDBのマニュアルでは、Remote Stub と呼ばれている) というのは、デバッガからのコマンドを受けて、デバッガが必要とする操作を対象プログラムの中で実行するモジュールのことだ。
実行中のプログラムは、自分が実行されている環境のメモリやレジスタの値を読み書きできる。これはつまり自分自身がデバッガの機能を実現するための一部になれるということである。
gdb stub は、対象プログラムに埋め込まれて、外部のgdbからコマンドを待つ。gdbからはメモリを読み書きしろとか、レジスタを読み書きしろというコマンドが送られてくる。 gdb stub は、そのコマンドに従って、プログラムの状態を読み書きし、その結果をgdbに返す。
gdb のソースコードには、いくつかのCPU用のstubが付属している。例えば、i386用のstubは、 https://sourceware.org/git/?p=binutils-gdb.git;a=blob;f=gdb/stubs/i386-stub.c;h=04996b75cf68073a9bdc3baba92ad96419d567fd;hb=HEAD このようになっている。
詳細な説明は省略するが、レジスタやメモリを読み書きできるように作ってあることを読み取ってほしい
JTAG (Joint Test Action Group) とは、狭義にはハードウェアのテスト用の標準を定めるグループと、そのグループが決めた仕様のことである
現代のCPUのような超高密度な集積回路は、外部から触れられるピンだけを使って、内部の状態を観測することが現実的ではない。
そのため、集積度の高い回路では、内部に、テスト用の回路も作っておき、そのテスト用の回路を経由して、回路に問題がないかテストするという方法がとられることが多い。 JTAG は、このテスト用の回路をどうやって接続するか、というのを決めた仕様のことである。
…が、プログラマがJTAGと言った場合は、ほぼ確実に、CPUに搭載されたデバッグ用のハードウェアを使ってデバッグするインターフェース、ツール類のことを指す。 プログラマが言う"JTAG"は、本来の意味と少し違ってしまっていることに注意してほしい。現代(と言ってもかなり昔からだが)のCPUは、その機能の一部に、CPUやメモリの状態を読み書きする「デバッグ用ハードウェア」が搭載されている。このハードウェアは、ほぼ確実にJTAG仕様に準拠した信号線を経由して、外部と繋がっている。そのため、この「デバッグ用のハードウェア」を使う場合は、JTAGケーブルを使って、プログラムの状態を観測することになる。
この、「JTAG経由で繋がったデバッグ用のハードウェアを使ってgdbなどのデバッガを動かす」というのが、省略されて、「JTAGデバッグ」、そして、その周辺ツールが「JTAG」と呼ばれるようになっている。(筆者は、この用語の使いかたはWikipediaをWikiと略すよりひどいのではないかと思う。データの経路が名前になっているから、Wikipediaをインターネットと呼ぶようなものである)
「デバッグ用のハードウェア」は、ptraceやgdb stubとほぼ同じように、プログラムの実行状態の制御、メモリ・レジスタの読み書きができる。
デバッグ用のハードウェアは、CPUの状態とは独立して動くので、ソフトウェアが完全に壊れて何も動かない場合や、ソフトウェアが初期化されていない状態でも使える。 gdb stub を動かすには、割り込みなどが正しく処理されている必要があり、割り込みが動かないような問題をデバッグしたい場合や、割り込み等を初期化する前の状態をデバッグしたい場合には使えない。そのような場合でも、JTAG経由なら、デバッグできる場合が多いのは、助かる場面が多いだろう。
JTAG経由でデバッガを使う場合は、実装方法は色々あるが、一例として接続サーバー経由でgdbを使う方法を説明しておこう。
gdb stubのところで簡単に説明したが、gdbは、gdb stubに送るコマンドを、ネットワーク経由でも送れるようになっている。 JTAG用に実装された接続サーバーは、gdb stubと同様に、gdbから送られるコマンドをパースして、それをハードウェア依存のコマンドに変換してJTAG経由でそのコマンドを状態観測用ハードウェアに送る。ハードウェアから得られた情報は、gdb stubと同様に、ネットワーク経由でgdbに届けられる。 (接続サーバーが、gdb stubのように動作する)
組み込み開発でも、特に小さなCPUを使う場合は、 ICE (In-circuit Emulator)というものを使うことがある。
ICE は、状態観測用のインターフェースを付けた、デバッガに接続するためのCPUだ。
JTAGが、CPUの機能の一部として実装されているのに対し、ICE は、チップ全体がデバッグ用に作られている。
色々な周辺機器が繋がったSoCの場合、JTAG 経由では、CPUのコアしか状態観測ができず、I/Oの状態はCPUから見た状態しか観測できないが、ICE では、周辺I/Oも含めて、状態を監視、変更することができる。
例えば、タイマデバイスなどは、JTAG経由ではCPUを停止しても動き続けるが、ICE で状態を停止すると、タイマとCPUコアを同時に停止でき、問題が発生したときのタイマの状態を正しく観測できる。
ただ、これが実現できるのは、CPUが本当に小さい場合だけで、大きなCPUでは、デバッグ専用のハードウェアを作るのは現実的ではない。現在では、組み込み開発でも本物のICEを見ることはほとんどなくなってしまったのではないかと思う。(筆者も数回ぐらいしか使ったことがない)
ここまでで、CPUやメモリの状態を観測する基本的な方法を説明した。ここからは、その方法を使ってどのようにデバッガを作っていくかを説明しよう。
デバッガの機能で興味深いのは、変数や関数が存在するかのように見える 点ではないだろうか。
#include <stdio.h> int int_value = 99; char str_value[] = "Hello World"; int main() { printf("int_value = %d\n", int_value); }
$ gcc -no-pie -g -Wall -o print-a print-a.c
$ gdb --args ./print-a Reading symbols from ./print-a... (gdb) start Temporary breakpoint 1 at 0x40112a: file print-a.c, line 6. Starting program: /home/tanakmura/src/pllp/docs/debugger/print-a Temporary breakpoint 1, main () at print-a.c:6 6 printf("int_value = %d\n", int_value); (gdb) print int_value $1 = 99 (gdb) print str_value $2 = "Hello World" (gdb) set int_value=123456 (gdb) continue Continuing. int_value = 123456 [Inferior 1 (process 174188) exited normally]
(gdb) print int_value # 変数名 "int_value" が使える
gdb が、変数 "int_value","str_value" という変数の名前をそのまま使えていることを確認してほしい。 さらに、変数の型に応じて、適切な表示方法を採用しているのも確認してほしい。 int型の変数に対しては、数字を表示し、char[]型の変数に対しては、文字列を表示している。
CPUが実行する時に使うデータは、メモリ上に展開されたバイナリデータだけで、操作するデータの変数名や型などは、実行時には必要ではない。 ところが、デバッガからプログラムを実行すると、そこに変数名や型が存在するかのようにプログラムの状態を操作できるのだ。これはどうやって実現しているのだろうか。
これを実現するために、コンパイラやリンカは、実行ファイルの出力時に、デバッグ情報と呼ばれる、デバッガを補助するための追加のデータを、実行ファイルに含めて出力する。(Visual Studio のコンパイラでは、実行ファイルとは別に出力される)
$ gcc -no-pie -g -Wall -o print-a print-a.c
ここでは、コンパイル時に -g を付けている点に注意してほしい。この -g は、デバッグ情報を生成するようにgccに指示するオプションだ。これを付けると、gccとリンカは、出力される実行ファイルにデバッグ情報を追加する。
デバッグ情報の中身を見てみよう。デバッグ情報は readelf に -w オプションを付けるか、objdump に -g オプションを付けると見ることができる。どちらも同じものが表示されるので、以下では readelf -w を使うことにする。
$ readelf -w print-a Contents of the .eh_frame section: 00000000 0000000000000014 00000000 CIE Version: 1 Augmentation: "zR" Code alignment factor: 1 Data alignment factor: -8 Return address column: 16 Augmentation data: 1b DW_CFA_def_cfa: r7 (rsp) ofs 8 DW_CFA_offset: r16 (rip) at cfa-8 DW_CFA_nop DW_CFA_nop ... (以下大量の出力) ...
たった8行のプログラムにしては、かなりの量の情報がある。ここには一体何が含まれているのだろうか。以下では、このデバッグ情報の中身について説明していこう。
デバッグ情報には、どういう情報を含めておけばよいだろうか。まず、print-a の例で説明したような、
(gdb) print int_value (gdb) set int_value=123456
といったようなコマンドを実現するためには、
という処理が必要だ。これらの処理ができれば、gdb の print は、
という手順で実現できる。
では、シンボル名からアドレスや型情報を取得するデータとはどのようなものだろうか。
次のようなプログラムを考えよう。
#include <stdio.h> #include <string.h> #include <stdlib.h> enum var_type { TYPE_INT, TYPE_CHAR_ARRAY }; static const char *type_to_str(enum var_type t) { switch (t) { case TYPE_INT: return "int"; case TYPE_CHAR_ARRAY: return "char[]"; default: return "unknown type"; } } struct VarDebugInfo { const char *symbol; enum var_type type; long var_addr; }; /* デバッグ情報のようなもの */ static const struct VarDebugInfo dummy_debuginfo[] = { {"int_value", TYPE_INT, 0x8000}, {"str_value", TYPE_CHAR_ARRAY, 0x8008}, {NULL} /* 終端 */ }; static const struct VarDebugInfo * extract_var_debug_info(const char *symbol) { for (int i=0; ; i++) { if (dummy_debuginfo[i].symbol == NULL) { fprintf(stderr, "cannot find debug info for '%s'.\n", symbol); abort(); } if (strcmp(dummy_debuginfo[i].symbol, symbol) == 0) { return &dummy_debuginfo[i]; } } } int main(int argc, char **argv) { if (argc < 2) { printf("usage : %s <symbol>\n", argv[0]); return 1; } const struct VarDebugInfo *info = extract_var_debug_info(argv[1]); printf("sym: %s, type:%s, addr=0x%016lx\n", info->symbol, type_to_str(info->type), info->var_addr); return 0; }
これは、以下のテーブルから、文字列と関連付けられた情報を取得するプログラムだ。
/* デバッグ情報のようなもの */ const struct VarDebugInfo dummy_debuginfo[] = { {"int_value", TYPE_INT, 0x8000}, {"str_value", TYPE_CHAR_ARRAY, 0x8008}, {NULL} /* 終端 */ };
ここでは、テーブルに入っている値は意味のない値だが、とりあえず何か名前を指定すると、その名前と関連する情報が表示される点を確認してほしい。
$ gcc -o dummy-debuginfo dummy-debuginfo.c
$ ./dummy-debuginfo int_value sym: int_value, type:int, addr=0x0000000000008000 # "int_value" という文字列と関連する情報が表示される $ ./dummy-debuginfo str_value sym: str_value, type:char[], addr=0x0000000000008008 # "str_value" という文字列と関連する情報が表示される
では、この dummy_debuginfo に意味のある値が入っていたらどうなるだろうか。
次のプログラムをコンパイルしたのち、readelf -s を使って、int_value, str_value のアドレスを取得しよう。 (実行ファイルを簡単にするため、libcとスタートアップルーチンをリンクしていない。これの意味についてはリンカの章を参照のこと)
int int_value = 1234; char str_value[] = "Hello World"; int _start() { int_value = 9999; str_value[0] = '@'; asm volatile (" " ::: "memory"); /* メモリへ必ず書き込むようにする */ while (1) ; }
$ gcc -no-pie -g -nostartfiles -nostdlib -o debuggee1 debuggee1.c
$ readelf -s debuggee1 Symbol table '.symtab' contains 10 entries: 番号: 値 サイズ タイプ Bind Vis 索引名 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS debuggee1.c 2: 0000000000000000 0 FILE LOCAL DEFAULT ABS 3: 0000000000402000 0 NOTYPE LOCAL DEFAULT 4 __GNU_EH_FRAME_HDR 4: 0000000000404008 12 OBJECT GLOBAL DEFAULT 6 str_value 5: 0000000000404000 4 OBJECT GLOBAL DEFAULT 6 int_value 6: 0000000000401000 23 FUNC GLOBAL DEFAULT 3 _start 7: 0000000000404014 0 NOTYPE GLOBAL DEFAULT 6 __bss_start 8: 0000000000404014 0 NOTYPE GLOBAL DEFAULT 6 _edata 9: 0000000000404018 0 NOTYPE GLOBAL DEFAULT 6 _end
$ readelf -s debugee1 | grep int_value | awk '{print $2}' # 変数 int_value のアドレス 0000000000404000 $ readelf -s debugee1 | grep str_value | awk '{print $2}' # 変数 str_value のアドレス 0000000000404008
この取得したアドレスをさきほどのプログラムに入れたらどうなるだろうか。
/* debugee1.c のデバッグ情報 */ const struct VarDebugInfo debuginfo_for_debugee1[] = { {"int_value", TYPE_INT, 0x404000}, {"str_value", TYPE_CHAR_PTR, 0x404008}, {NULL} /* 終端 */ };
これは、もはやダミーの情報ではなく、debugee1 というプログラムのためのデバッグ情報になるのだ。
このテーブルを使って、gdb の print のような機能を実装してみよう。
/* gdb の print のような機能を実現するプログラム */ #include <stdio.h> #include <string.h> #include <stdlib.h> #include <unistd.h> #include <sys/ptrace.h> #include <sys/wait.h> #include <stdint.h> enum var_type { TYPE_INT, TYPE_CHAR_ARRAY }; struct VarDebugInfo { const char *symbol; enum var_type type; long var_addr; }; /* debuggee1.c のデバッグ情報 */ static const struct VarDebugInfo debuginfo_for_debuggee1[] = { /* var_addr には readelf -s で取得したアドレスを入れる */ {"int_value", TYPE_INT, 0x404000}, {"str_value", TYPE_CHAR_ARRAY, 0x404008}, {NULL} /* 終端 */ }; static const struct VarDebugInfo * extract_var_debug_info(const char *symbol) { for (int i=0; ; i++) { if (debuginfo_for_debuggee1[i].symbol == NULL) { fprintf(stderr, "cannot find debug info for '%s'.\n", symbol); abort(); } if (strcmp(debuginfo_for_debuggee1[i].symbol, symbol) == 0) { return &debuginfo_for_debuggee1[i]; } } } static void print_var_info(int pid, const struct VarDebugInfo *info) { int v; char buffer[1024]; switch (info->type) { case TYPE_INT: v = ptrace(PTRACE_PEEKDATA, pid, (void*)info->var_addr, 0); printf("%s:%d, addr=%lx\n", info->symbol, v, info->var_addr); break; case TYPE_CHAR_ARRAY: for (int i=0; ; i++) { uint8_t v8 = ptrace(PTRACE_PEEKDATA, pid, (void*)(info->var_addr+i), 0); buffer[i] = v8; if (v8 == 0) { break; } } printf("%s:%s, var_addr=%lx\n", info->symbol, buffer, info->var_addr); break; default: puts("xx"); } } int main(int argc, char **argv) { if (argc < 2) { printf("usage : %s <symbol>\n", argv[0]); return 1; } int r = access("./debuggee1", X_OK); if (r < 0) { fprintf(stderr, "cannot find program `debuggee1` (please compile debuggee1.c)\n"); return 1; } int pid; if ( (pid=fork()) == 0) { char *argv[2] = {"./debuggee1",NULL}; execve("./debuggee1", argv, NULL); } else { int st; usleep(1000); int r = ptrace(PTRACE_ATTACH, pid); if (r < 0) { perror("ptrace"); return 1; } waitpid(pid, &st, 0); const struct VarDebugInfo *info = extract_var_debug_info(argv[1]); print_var_info(pid, info); kill(pid, SIGKILL); } }
$ gcc -o debugger1 debugger1.c
$ ./debugger1 int_value int_value:9999, addr=404000
$ ./debugger1 str_value str_value:@ello World, var_addr=404008
このプログラムを実行して、
の二点を確認し、debuginfo_for_debugee1というテーブルが、gdb の print コマンドのようなものを実行するのに必要な情報として機能していることを確認してほしい。
このテーブルは、 シンボル名をキーにして、そのシンボルと関連する情報を取得できる データとなっている。
実際のデバッグ情報も、同じように、シンボルをキーにして、そのシンボルと関連する情報を取得できる構造になっている、つまり、このdebuginfo_for_debugee1と同じような構造になっているわけだ。
さきほどと同じように、readelf -w でデバッグ情報を見ていく。readelf -w は、デバッグ情報と関連するセクションを全て表示するが、表示するセクションを選ぶこともできる。 readelf -wi を使って、.debug_info セクションのみを表示してみよう。
$ readelf -wi debuggee1 .debug_info セクションの内容: コンパイル単位 @ オフセット 0x0: 長さ: 0x98 (32-bit) バージョン: 5 Unit Type: DW_UT_compile (1) 省略オフセット: 0x0 ポインタサイズ:8 <0><c>: 省略番号: 3 (DW_TAG_compile_unit) <d> DW_AT_producer : (間接文字列、オフセット: 0x7): GNU C17 11.1.0 -mtune=generic -march=x86-64 -g <11> DW_AT_language : 29 (C11) <12> DW_AT_name : (間接行文字列、オフセット: 0x27): debuggee1.c <16> DW_AT_comp_dir : (間接行文字列、オフセット: 0x0): /home/tanakmura/src/pllp/docs/debugger <1a> DW_AT_low_pc : 0x401000 <22> DW_AT_high_pc : 0x17 <2a> DW_AT_stmt_list : 0x0 <1><2e>: 省略番号: 1 (DW_TAG_variable) <2f> DW_AT_name : (間接文字列、オフセット: 0x52): int_value <33> DW_AT_decl_file : 1 <33> DW_AT_decl_line : 1 <34> DW_AT_decl_column : 5 <35> DW_AT_type : <0x43> <39> DW_AT_external : 1 <39> DW_AT_location : 9 byte block: 3 0 40 40 0 0 0 0 0 (DW_OP_addr: 404000) <1><43>: 省略番号: 4 (DW_TAG_base_type) <44> DW_AT_byte_size : 4 <45> DW_AT_encoding : 5 (signed) <46> DW_AT_name : int <1><4a>: 省略番号: 5 (DW_TAG_array_type) <4b> DW_AT_type : <0x61> <4f> DW_AT_sibling : <0x5a> <2><53>: 省略番号: 6 (DW_TAG_subrange_type) <54> DW_AT_type : <0x5a> <58> DW_AT_upper_bound : 11 <2><59>: Abbrev Number: 0 <1><5a>: 省略番号: 2 (DW_TAG_base_type) <5b> DW_AT_byte_size : 8 <5c> DW_AT_encoding : 7 (unsigned) <5d> DW_AT_name : (間接文字列、オフセット: 0x40): long unsigned int <1><61>: 省略番号: 2 (DW_TAG_base_type) <62> DW_AT_byte_size : 1 <63> DW_AT_encoding : 6 (signed char) <64> DW_AT_name : (間接文字列、オフセット: 0x5c): char <1><68>: 省略番号: 1 (DW_TAG_variable) <69> DW_AT_name : (間接文字列、オフセット: 0x36): str_value <6d> DW_AT_decl_file : 1 <6d> DW_AT_decl_line : 2 <6e> DW_AT_decl_column : 6 <6f> DW_AT_type : <0x4a> <73> DW_AT_external : 1 <73> DW_AT_location : 9 byte block: 3 8 40 40 0 0 0 0 0 (DW_OP_addr: 404008) <1><7d>: 省略番号: 7 (DW_TAG_subprogram) <7e> DW_AT_external : 1 <7e> DW_AT_name : (間接文字列、オフセット: 0x0): _start <82> DW_AT_decl_file : 1 <83> DW_AT_decl_line : 4 <84> DW_AT_decl_column : 5 <85> DW_AT_type : <0x43> <89> DW_AT_low_pc : 0x401000 <91> DW_AT_high_pc : 0x17 <99> DW_AT_frame_base : 1 byte block: 9c (DW_OP_call_frame_cfa) <9b> DW_AT_call_all_calls: 1 <1><9b>: Abbrev Number: 0
次の箇所に注目しよう
<1><2e>: 省略番号: 1 (DW_TAG_variable) <2f> DW_AT_name : (間接文字列、オフセット: 0x52): int_value # シンボル名int_value <33> DW_AT_decl_file : 1 <33> DW_AT_decl_line : 1 <34> DW_AT_decl_column : 5 <35> DW_AT_type : <0x43> # この0x43が下の <43> と対応していて、signed int を意味する <39> DW_AT_external : 1 <39> DW_AT_location : 9 byte block: 3 0 40 40 0 0 0 0 0 (DW_OP_addr: 404000) # int_valueのアドレスは0x404000 <1><43>: 省略番号: 4 (DW_TAG_base_type) # signed int <44> DW_AT_byte_size : 4 <45> DW_AT_encoding : 5 (signed) <46> DW_AT_name : int (.. 省略 ..) <1><4a>: 省略番号: 5 (DW_TAG_array_type) # char [] 型 <4b> DW_AT_type : <0x61> # 下の<61>と対応していて、char型を意味する <4f> DW_AT_sibling : <0x5a> (.. 省略 ..) <1><61>: 省略番号: 2 (DW_TAG_base_type) # char 型 <62> DW_AT_byte_size : 1 <63> DW_AT_encoding : 6 (signed char) <64> DW_AT_name : (間接文字列、オフセット: 0x5c): char (.. 省略 ..) <1><68>: 省略番号: 1 (DW_TAG_variable) <69> DW_AT_name : (間接文字列、オフセット: 0x36): str_value # シンボル名str_value <6d> DW_AT_decl_file : 1 <6d> DW_AT_decl_line : 2 <6e> DW_AT_decl_column : 6 <6f> DW_AT_type : <0x4a> # 上の <4a> 対応して char[] を意味する <73> DW_AT_external : 1 <73> DW_AT_location : 9 byte block: 3 8 40 40 0 0 0 0 0 (DW_OP_addr: 404008) # str_valueのアドレスは0x404008
この情報は、さきほど作った
/* debugee1.c のデバッグ情報 */ const struct VarDebugInfo debuginfo_for_debugee1[] = { {"int_value", TYPE_INT, 0x404000}, {"str_value", TYPE_CHAR_ARRAY, 0x404008}, {NULL} /* 終端 */ };
このテーブルとかなり似たデータが含まれている。つまり、この.debug_info セクションを適切に読むことができれば、 上で見たdebuggee1.c のプログラムと同じように シンボル名をキーにして、そのシンボルと関連する情報を取得 できるわけだ。
この.debug_infoに含まれる情報は、DWARF (debugging with attributed record formats)という仕様に従って、格納されている。 DWARFの構造は、少し複雑なので、ひととおりデバッグ情報について説明したあとで解説しよう。 しばらくは readelf -w の情報を参考に、読み進めていってほしい。
ここまでで、デバッグ情報を使えば、シンボルからそのアドレスや型情報などが取得できることを説明した。 それとは別に、デバッグ情報を使えば、アドレスからそのアドレスに関する情報を取得する こともできる。
$ gdb --args ./debuggee1 Reading symbols from ./debuggee1... (gdb) b _start Breakpoint 1 at 0x401004: file debuggee1.c, line 5. (gdb) run Starting program: /home/tanakmura/src/pllp/docs/debugger/debuggee1 Breakpoint 1, _start () at debuggee1.c:5 5 int_value = 9999; (gdb) print &str_value $1 = (char (*)[12]) 0x404008 <str_value> (gdb) print (char*)0x404008 $2 = 0x404008 <str_value> "Hello World" # 0x404008 = str_value だと表示される
0x404008 のアドレスにある値を表示しようとすると、そこは <str_value> だと表示されている点を確認してほしい。
デバッグ情報には、シンボルとアドレスの対応が含まれているので、これはデバッグ情報の読みかたを変えるだけで実現できる。
#include <stdio.h> #include <string.h> #include <stdlib.h> #include <ctype.h> enum var_type { TYPE_INT, TYPE_CHAR_ARRAY }; static const char *type_to_str(enum var_type t) { switch (t) { case TYPE_INT: return "int"; case TYPE_CHAR_ARRAY: return "char[]"; default: return "unknown type"; } } struct VarDebugInfo { const char *symbol; enum var_type type; long var_addr; }; /* デバッグ情報のようなもの */ static const struct VarDebugInfo dummy_debuginfo[] = { {"int_value", TYPE_INT, 0x404000}, {"str_value", TYPE_CHAR_ARRAY, 0x404008}, {NULL} /* 終端 */ }; static const struct VarDebugInfo * extract_var_debug_info_from_symbol(const char *symbol) { for (int i=0; ; i++) { if (dummy_debuginfo[i].symbol == NULL) { fprintf(stderr, "cannot find debug info for '%s'.\n", symbol); abort(); } if (strcmp(dummy_debuginfo[i].symbol, symbol) == 0) { return &dummy_debuginfo[i]; } } } static const struct VarDebugInfo * extract_var_debug_info_from_addr(long addr) { for (int i=0; ; i++) { if (dummy_debuginfo[i].symbol == NULL) { fprintf(stderr, "cannot find debug info for '0x%016lx'.\n", addr); abort(); } if (dummy_debuginfo[i].var_addr == addr) { return &dummy_debuginfo[i]; } } } int main(int argc, char **argv) { if (argc < 2) { printf("usage : %s <symbol>\n", argv[0]); return 1; } const struct VarDebugInfo *info; if (isdigit(argv[1][0])) { /* 数字を引数に渡された場合はそれをアドレスとする */ info = extract_var_debug_info_from_addr(strtol(argv[1], NULL, 0)); } else { /* そうでない場合はシンボル文字列とする */ info = extract_var_debug_info_from_symbol(argv[1]); } printf("sym: %s, type:%s, addr=0x%016lx\n", info->symbol, type_to_str(info->type), info->var_addr); return 0; }
$ gcc -o dummy-debuginfo2 dummy-debuginfo2.c
$ ./dummy-debuginfo2 0x404000 sym: int_value, type:int, addr=0x0000000000404000
$ ./dummy-debuginfo2 0x404008 sym: str_value, type:char[], addr=0x0000000000404008
シンボルとアドレスの対応を含む情報があれば、アドレスの数値から、シンボルに関する情報を取得できることを確認しよう。
デバッグ時には、 プログラムカウンタの値からファイル名・行番号を取得できていることに注目してほしい。
int a; int _start() { a++; *(int*)0 = 0; /* アドレス0にアクセス、エラーを起こして停止させる */ return 10; }
$ gcc -no-pie -g -nostartfiles -nostdlib -o debuggee2 debuggee2.c
$ gdb --args ./debuggee2 Reading symbols from ./debuggee2... (gdb) run Starting program: /home/tanakmura/src/pllp/docs/debugger/debuggee2 Program received signal SIGSEGV, Segmentation fault. _start () at debuggee2.c:5 5 *(int*)0 = 0; /* アドレス0にアクセス、エラーを起こして停止させる */ (gdb) disassemble Dump of assembler code for function _start: 0x0000000000401000 <+0>: push %rbp 0x0000000000401001 <+1>: mov %rsp,%rbp 0x0000000000401004 <+4>: mov 0x2ff6(%rip),%eax # 0x404000 <a> 0x000000000040100a <+10>: add $0x1,%eax 0x000000000040100d <+13>: mov %eax,0x2fed(%rip) # 0x404000 <a> 0x0000000000401013 <+19>: mov $0x0,%eax => 0x0000000000401018 <+24>: movl $0x0,(%rax) 0x000000000040101e <+30>: mov $0xa,%eax 0x0000000000401023 <+35>: pop %rbp 0x0000000000401024 <+36>: ret End of assembler dump.
Program received signal SIGSEGV, Segmentation fault. _start () at debuggee2.c:5 # debugee2.c の 5行目 ということがわかる 5 *(int*)0 = 0; /* アドレス0にアクセス、エラーを起こして停止させる */
エラーが発生している箇所とソースコードの対応がとれていることを確認しよう。
(アドレス0にアクセスすると、エラーが発生し、止まる理由と、そのときのプログラムカウンタが取得できる理由については、OSのところで解説する 注意:まだ書いてない。ここでは、アドレス0にアクセスすると停止して、そのときのプログラムカウンタの位置がわかることだけ見ておいてほしい)
これは、さきほどと同じように、アドレスと関連する情報を結び付けて保存してあるデバッグ情報を応用すれば実現できる。 さきほどの例では、変数のアドレスと、そのシンボル名、型情報が関連付けられて保存されていた。 この場合は、機械語アドレスとソースコードの位置の対応が保存されている。
ここで、全ての機械語の命令ごとに、ファイル名と行番号の全てを保存すると、膨大な量になってしまう点に注意しよう。 機械語は一命令数バイトだが、ファイル名は、文字数分のバイト数が必要で、直接保存すると、命令サイズに対して、デバッグ情報のサイズが数十倍になってしまう可能性がある。 これを回避するため、ソースコード位置の情報は、実際のデバッグ情報では、直前の機械語との差分のみを保存する専用のフォーマットになっている。
この、ソースコード位置に関連するデバッグ情報を表示するには、readelf -wl を使う。
$ readelf -wl debuggee2 セクション .debug_line のデバッグ内容の生ダンプ: オフセット: 0x0 長さ: 84 DWARF バージョン: 5 Address size (bytes): 8 Segment selector (bytes): 0 Prologue の長さ: 42 最小命令長: 1 命令ごとの最大操作数: 1 'is_stmt' の初期値: 1 Line ベース: -5 Line 範囲: 14 オペコードベース: 13 オペコード: オペコード 1 は 0 個の引数を持ちます オペコード 2 は 1 個の引数を持ちます オペコード 3 は 1 個の引数を持ちます オペコード 4 は 1 個の引数を持ちます オペコード 5 は 1 個の引数を持ちます オペコード 6 は 0 個の引数を持ちます オペコード 7 は 0 個の引数を持ちます オペコード 8 は 0 個の引数を持ちます オペコード 9 は 1 個の引数を持ちます オペコード 10 は 0 個の引数を持ちます オペコード 11 は 0 個の引数を持ちます オペコード 12 は 1 個の引数を持ちます The Directory Table (offset 0x22, lines 1, columns 1): エントリー 名前 0 (間接行文字列、オフセット: 0x0): /home/tanakmura/src/pllp/docs/debugger The File Name Table (offset 0x2c, lines 2, columns 2): エントリー Dir 名前 0 0 (間接行文字列、オフセット: 0x27): debuggee2.c 1 0 (間接行文字列、オフセット: 0x27): debuggee2.c Line Number Statements: [0x00000036] 列幅を 14 に設定します [0x00000038] 拡張命令コード 2: 設定アドレス 0x401000 [0x00000043] Special opcode 6: advance Address by 0 to 0x401000 and Line by 1 to 2 [0x00000044] 列幅を 6 に設定します [0x00000046] Special opcode 62: advance Address by 4 to 0x401004 and Line by 1 to 3 [0x00000047] 列幅を 5 に設定します [0x00000049] Special opcode 217: advance Address by 15 to 0x401013 and Line by 2 to 5 [0x0000004a] 列幅を 14 に設定します [0x0000004c] Special opcode 75: advance Address by 5 to 0x401018 and Line by 0 to 5 [0x0000004d] 列幅を 12 に設定します [0x0000004f] Special opcode 91: advance Address by 6 to 0x40101e and Line by 2 to 7 [0x00000050] 列幅を 1 に設定します [0x00000052] Special opcode 76: advance Address by 5 to 0x401023 and Line by 1 to 8 [0x00000053] Advance PC by 2 to 0x401025 [0x00000055] 拡張命令コード 1: 列の終り
差分が保存されているので、人間には読みにくい表示になっているが、次の行を見てほしい。
[0x00000043] Special opcode 6: advance Address by 0 to 0x401000 and Line by 1 to 2
これは、「前回のアドレスから、0byte進めた 0x401000 と、前回の行番号から1行進めた 2行目の位置が対応している」という情報だ。前回との差分は、今は見なくてよいので、ここでは、
[0x00000043] Special opcode 6: advance Address by 0 to 0x401000 and Line by 1 to 2
この部分だけを見よう。0x401000 と 行番号2 が対応付けられているのが確認できるはずだ。同様に続きも見ていくと
[0x00000043] Special opcode 6: advance Address by 0 to 0x401000 and Line by 1 to 2 # 0x401000 からは 2行目と対応 [0x00000046] Special opcode 62: advance Address by 4 to 0x401004 and Line by 1 to 3 # 0x401004 からは 3行目と対応 [0x00000049] Special opcode 217: advance Address by 15 to 0x401013 and Line by 2 to 5 # 0x401013 からは 5行目と対応 [0x0000004c] Special opcode 75: advance Address by 5 to 0x401018 and Line by 0 to 5 # 0x401018 からは 5行目と対応 [0x0000004f] Special opcode 91: advance Address by 6 to 0x40101e and Line by 2 to 7 # 0x40101e からは 7行目と対応 [0x00000052] Special opcode 76: advance Address by 5 to 0x401023 and Line by 1 to 8 # 0x401023 からは 8行目と対応
と、読みとれる(gccはカラム位置も含めて保存しているので、5行目が二回出てくる。とりあえず気にしないで読み進めてほしい)。さきほどのdisassembleやソースコードの表示とあわせると
0x0000000000401000 <+0>: push %rbp 0x0000000000401001 <+1>: mov %rsp,%rbp |
0x401000 からは 2行目と対応 |
int _start() { |
0x0000000000401004 <+4>: mov 0x2ff6(%rip),%eax # 0x404000 <a> 0x000000000040100a <+10>: add $0x1,%eax 0x000000000040100d <+13>: mov %eax,0x2fed(%rip) # 0x404000 <a> |
0x401004 からは 3行目と対応 |
a++; |
0x0000000000401013 <+19>: mov $0x0,%eax |
0x401013 からは 5行目と対応 |
*(int*)0 = 0; /* アドレス0にアクセス、エラーを起こして停止させる */ |
=> 0x0000000000401018 <+24>: movl $0x0,(%rax) |
0x401018 からは 5行目と対応 |
*(int*)0 = 0; /* アドレス0にアクセス、エラーを起こして停止させる */ |
0x000000000040101e <+30>: mov $0xa,%eax |
0x40101e からは 7行目と対応 |
return 10; |
0x0000000000401023 <+35>: pop %rbp 0x0000000000401024 <+36>: ret |
0x401023 からは 8行目と対応 |
}
|
機械語のアドレス、デバッグ情報に保存された位置情報、ソースコードが、正しく対応していることを確認しよう。
(C言語のソースコードと機械語の対応については、C言語の章を参照してほしい 注意:まだ書いてない )
ここまで見てきたアドレスとその情報の対応は、デバッグ情報の基本となる部分だ。次からは少し話がややこしくなるが、基本的な部分は変わらないので、「どうやってアドレスと情報を関連付けるか」という点を意識して読んでいってほしい。
ここまでの説明では、グローバルなアドレスとその情報の対応を見てきた。
続いて、ローカル変数について考えよう。ローカル変数がグローバル変数と違う点は、変数のアドレスが実行時までわからない点だ。 さらに、ローカル変数は、レジスタに割り当てられることがあり、そもそもアドレスが存在しないこともある。
ここまで見てきたデバッグ情報では、リンク時に確定したアドレスだけが格納されていた。これではローカル変数のアドレスはわからない。 単にアドレスをデバッグ情報に格納するよりもう少し何か対応が必要だ。その対応方法について説明しよう。
次のプログラムを考えよう。
#include <syscall.h> /* 最適化で呼び出しが消えないようにGCC 11ではnoipaを付ける */ #define NOINLINE __attribute__((noipa)) NOINLINE static int ret1(void) { return 1; } int global; NOINLINE static int f(void) { int x0 = ret1(); /* わかりやすくするため、別の関数を呼んでeaxに入っている戻り値を強制的に別のレジスタに移動させる */ int x1 = ret1(); /* わかりやすくするため、命令を一個入れる */ global++; return x0 + x1; } int _start() { int ret = f(); __asm__ __volatile__("syscall"::"a"(60),"D"(ret)); }
これは、変数がレジスタに乗るように書いたプログラムだ(レジスタを使うようにするために、最適化オプションを忘れないようにしよう)。 コンパイラによって状況は変わる可能性があるが、手元のGCC11では x0 は ebxに割り当てられた。
$ gcc -S -no-pie -O2 -nostartfiles -nostdlib -fno-asynchronous-unwind-tables -o debuggee3.s debuggee3.c
.file "debuggee3.c" .text .p2align 4 .type ret1, @function ret1: movl $1, %eax ret .size ret1, .-ret1 .p2align 4 .type f, @function f: pushq %rbx call ret1 movl %eax, %ebx /* x0 はebxレジスタに乗る */ call ret1 addl $1, global(%rip) addl %ebx, %eax popq %rbx ret .size f, .-f .p2align 4 .globl _start .type _start, @function _start: subq $8, %rsp call f movl %eax, %edi movl $60, %eax #APP # 27 "debuggee3.c" 1 syscall # 0 "" 2 #NO_APP addq $8, %rsp ret .size _start, .-_start .globl global .bss .align 4 .type global, @object .size global, 4 global: .zero 4 .ident "GCC: (GNU) 11.1.0" .section .note.GNU-stack,"",@progbits
これをgdbを経由して表示すると、
$ gcc -no-pie -O2 -g -nostartfiles -nostdlib -fno-asynchronous-unwind-tables -o debuggee3 debuggee3.c
$ gdb --args ./debuggee3 Reading symbols from ./debuggee3... (gdb) b f Breakpoint 1 at 0x401010: file debuggee3.c, line 13. (gdb) run Starting program: /home/tanakmura/src/pllp/docs/debugger/debuggee3 Breakpoint 1, f () at debuggee3.c:13 13 int x0 = ret1(); (gdb) disassemble Dump of assembler code for function f: => 0x0000000000401010 <+0>: push %rbx 0x0000000000401011 <+1>: call 0x401000 <ret1> 0x0000000000401016 <+6>: mov %eax,%ebx 0x0000000000401018 <+8>: call 0x401000 <ret1> 0x000000000040101d <+13>: addl $0x1,0xfdc(%rip) # 0x402000 <global> 0x0000000000401024 <+20>: add %ebx,%eax 0x0000000000401026 <+22>: pop %rbx 0x0000000000401027 <+23>: ret End of assembler dump. (gdb) print x0 $1 = <optimized out> # x0はまだ存在しない (gdb) nexti 0x0000000000401011 13 int x0 = ret1(); (gdb) nexti 0x0000000000401016 13 int x0 = ret1(); (gdb) nexti 16 int x1 = ret1(); (gdb) disassemble Dump of assembler code for function f: 0x0000000000401010 <+0>: push %rbx 0x0000000000401011 <+1>: call 0x401000 <ret1> 0x0000000000401016 <+6>: mov %eax,%ebx => 0x0000000000401018 <+8>: call 0x401000 <ret1> 0x000000000040101d <+13>: addl $0x1,0xfdc(%rip) # 0x402000 <global> 0x0000000000401024 <+20>: add %ebx,%eax 0x0000000000401026 <+22>: pop %rbx 0x0000000000401027 <+23>: ret End of assembler dump. (gdb) print x0 $2 = 1 /* ebxに入っているx0が表示される */
正しく表示される。gdb はなんらかの方法で、x0 の位置がebxレジスタに割り当てられていることを把握できている。
これは機械語の命令毎に、変数の割り当て状況を記録していけば実現できる。先程の例で、なんらかの方法で、
f: pushq %rbx /* まだローカル変数はない */ call ret1 /* この命令実行後に、戻り値がeaxに入っている */ movl %eax, %ebx /* この命令実行後にx0 はebxレジスタに乗る */ call ret1 /* この命令実行後に、戻り値がeaxに入っている、これがそのままx1になる。x0はebxレジスタにある */ addl $1, global(%rip) /* x0はebxレジスタにある。x1はeaxレジスタにある */ addl %ebx, %eax /* 戻り値がeaxに入る、x1はeaxレジスタが書きかえられるので消滅する。x0はebxレジスタにある */ popq %rbx /* ebxを復元、x0はebxレジスタが書きかえられるので消滅する */ ret /* ローカル変数はない */
このコメントに書かれたような情報が記録されているとしよう。
ソース位置への変換で見たように、デバッグ情報には、「機械語のアドレスと、その機械語のソースの位置の対応」を記録できる。 それと同様に、「機械語のアドレスと、その機械語における変数の割り当て状況」が、記録されていれば、 プログラムカウンタの位置から、その時の変数割り当ての状況が正しく把握できる。
これまでと同じように readelf で見てみよう。今回は、-wi -wo を使う。-wi は、上で書いたとおり、.debug_infoセクションを表示する。今回は、これに加えて、-wo で .debug_loclists セクションも表示する。(-wi -wo は -wio と書いても同じ意味になる。どちらでも構わない)
$ readelf -wi -wo debuggee3 .debug_info セクションの内容: コンパイル単位 @ オフセット 0x0: 長さ: 0x108 (32-bit) バージョン: 5 Unit Type: DW_UT_compile (1) 省略オフセット: 0x0 ポインタサイズ:8 <0><c>: 省略番号: 3 (DW_TAG_compile_unit) <d> DW_AT_producer : (間接文字列、オフセット: 0xc): GNU C17 11.1.0 -mtune=generic -march=x86-64 -g -O2 -fno-asynchronous-unwind-tables <11> DW_AT_language : 29 (C11) <12> DW_AT_name : (間接行文字列、オフセット: 0x27): debuggee3.c <16> DW_AT_comp_dir : (間接行文字列、オフセット: 0x0): /home/tanakmura/src/pllp/docs/debugger <1a> DW_AT_low_pc : 0x401000 <22> DW_AT_high_pc : 0x47 <2a> DW_AT_stmt_list : 0x0 <1><2e>: 省略番号: 4 (DW_TAG_variable) <2f> DW_AT_name : (間接文字列、オフセット: 0x0): global <33> DW_AT_decl_file : 1 <34> DW_AT_decl_line : 10 <35> DW_AT_decl_column : 5 <36> DW_AT_type : <0x44> <3a> DW_AT_external : 1 <3a> DW_AT_location : 9 byte block: 3 0 20 40 0 0 0 0 0 (DW_OP_addr: 402000) <1><44>: 省略番号: 5 (DW_TAG_base_type) <45> DW_AT_byte_size : 4 <46> DW_AT_encoding : 5 (signed) <47> DW_AT_name : int <1><4b>: 省略番号: 6 (DW_TAG_subprogram) <4c> DW_AT_external : 1 <4c> DW_AT_name : (間接文字列、オフセット: 0x5f): _start <50> DW_AT_decl_file : 1 <51> DW_AT_decl_line : 24 <52> DW_AT_decl_column : 1 <53> DW_AT_type : <0x44> <57> DW_AT_low_pc : 0x401030 <5f> DW_AT_high_pc : 0x17 <67> DW_AT_frame_base : 1 byte block: 9c (DW_OP_call_frame_cfa) <69> DW_AT_call_all_calls: 1 <69> DW_AT_sibling : <0x8e> <2><6d>: 省略番号: 1 (DW_TAG_variable) <6e> DW_AT_name : ret <72> DW_AT_decl_file : 1 <72> DW_AT_decl_line : 26 <73> DW_AT_decl_column : 9 <74> DW_AT_type : <0x44> <78> DW_AT_location : 0x10 (location list) <7c> DW_AT_GNU_locviews: 0xc <2><80>: 省略番号: 2 (DW_TAG_call_site) <81> DW_AT_call_return_pc: 0x401039 <89> DW_AT_call_origin : <0x8e> <2><8d>: Abbrev Number: 0 <1><8e>: 省略番号: 7 (DW_TAG_subprogram) <8f> DW_AT_name : f <91> DW_AT_decl_file : 1 <92> DW_AT_decl_line : 12 <93> DW_AT_decl_column : 21 <94> DW_AT_prototyped : 1 <94> DW_AT_type : <0x44> <98> DW_AT_low_pc : 0x401010 <a0> DW_AT_high_pc : 0x18 <a8> DW_AT_frame_base : 1 byte block: 9c (DW_OP_call_frame_cfa) <aa> DW_AT_call_all_calls: 1 <aa> DW_AT_sibling : <0xed> <2><ae>: 省略番号: 1 (DW_TAG_variable) <af> DW_AT_name : x0 <b2> DW_AT_decl_file : 1 <b2> DW_AT_decl_line : 13 <b3> DW_AT_decl_column : 7 <b4> DW_AT_type : <0x44> <b8> DW_AT_location : 0x1f (location list) <bc> DW_AT_GNU_locviews: 0x1b <2><c0>: 省略番号: 1 (DW_TAG_variable) <c1> DW_AT_name : x1 <c4> DW_AT_decl_file : 1 <c4> DW_AT_decl_line : 16 <c5> DW_AT_decl_column : 7 <c6> DW_AT_type : <0x44> <ca> DW_AT_location : 0x2c (location list) <ce> DW_AT_GNU_locviews: 0x2a <2><d2>: 省略番号: 2 (DW_TAG_call_site) <d3> DW_AT_call_return_pc: 0x401016 <db> DW_AT_call_origin : <0xed> <2><df>: 省略番号: 2 (DW_TAG_call_site) <e0> DW_AT_call_return_pc: 0x40101d <e8> DW_AT_call_origin : <0xed> <2><ec>: Abbrev Number: 0 <1><ed>: 省略番号: 8 (DW_TAG_subprogram) <ee> DW_AT_name : (間接文字列、オフセット: 0x7): ret1 <f2> DW_AT_decl_file : 1 <f3> DW_AT_decl_line : 6 <f4> DW_AT_decl_column : 21 <f5> DW_AT_prototyped : 1 <f5> DW_AT_type : <0x44> <f9> DW_AT_low_pc : 0x401000 <101> DW_AT_high_pc : 0x6 <109> DW_AT_frame_base : 1 byte block: 9c (DW_OP_call_frame_cfa) <10b> DW_AT_call_all_calls: 1 <1><10b>: Abbrev Number: 0 .debug_loclists セクションの内容: Offset Begin End Expression 0000000c v000000000000000 v000000000000000 location view pair 0000000e v000000000000000 v000000000000000 location view pair 00000010 v000000000000000 v000000000000000 views at 0000000c for: 000000000040103b 0000000000401040 (DW_OP_reg0 (rax)) 00000015 v000000000000000 v000000000000000 views at 0000000e for: 0000000000401040 0000000000401047 (DW_OP_reg5 (rdi)) 0000001a <リストの終端> 0000001b v000000000000000 v000000000000000 location view pair 0000001d v000000000000000 v000000000000000 location view pair 0000001f v000000000000000 v000000000000000 views at 0000001b for: 0000000000401018 000000000040101c (DW_OP_reg0 (rax)) 00000024 v000000000000000 v000000000000000 views at 0000001d for: 000000000040101c 0000000000401027 (DW_OP_reg3 (rbx)) 00000029 <リストの終端> 0000002a v000000000000000 v000000000000000 location view pair 0000002c v000000000000000 v000000000000000 views at 0000002a for: 000000000040101d 0000000000401026 (DW_OP_reg0 (rax)) 00000031 <リストの終端>
表示された .debug_info から、x0 と x1 の情報を見てみよう。
<2><ae>: 省略番号: 1 (DW_TAG_variable) <af> DW_AT_name : x0 <b2> DW_AT_decl_file : 1 <b2> DW_AT_decl_line : 13 <b3> DW_AT_decl_column : 7 <b4> DW_AT_type : <0x44> <b8> DW_AT_location : 0x1f (location list) <bc> DW_AT_GNU_locviews: 0x1b <2><c0>: 省略番号: 1 (DW_TAG_variable) <c1> DW_AT_name : x1 <c4> DW_AT_decl_file : 1 <c4> DW_AT_decl_line : 16 <c5> DW_AT_decl_column : 7 <c6> DW_AT_type : <0x44> <ca> DW_AT_location : 0x2c (location list) <ce> DW_AT_GNU_locviews: 0x2a
DW_AT_location が、(location list) というものに変化していることを確認しよう。グローバル変数の場合は、ここに絶対アドレスが書かれていた。 例えば、同じファイルに含まれているグローバル変数は、以下のようになっている。
<1><2e>: 省略番号: 4 (DW_TAG_variable) <2f> DW_AT_name : (間接文字列、オフセット: 0x0): global <33> DW_AT_decl_file : 1 <34> DW_AT_decl_line : 10 <35> DW_AT_decl_column : 5 <36> DW_AT_type : <0x44> <3a> DW_AT_external : 1 <3a> DW_AT_location : 9 byte block: 3 0 20 40 0 0 0 0 0 (DW_OP_addr: 402000)
ローカル変数の場合は、DW_AT_location に、ローカル変数用の位置情報が格納されている。この、location list の内容は、.debug_loclists セクションに書かれている。
.debug_loclists セクションの内容: Offset Begin End Expression 0000000c v000000000000000 v000000000000000 location view pair 0000000e v000000000000000 v000000000000000 location view pair 00000010 v000000000000000 v000000000000000 views at 0000000c for: 000000000040103b 0000000000401040 (DW_OP_reg0 (rax)) 00000015 v000000000000000 v000000000000000 views at 0000000e for: 0000000000401040 0000000000401047 (DW_OP_reg5 (rdi)) 0000001a <リストの終端> 0000001b v000000000000000 v000000000000000 location view pair 0000001d v000000000000000 v000000000000000 location view pair 0000001f v000000000000000 v000000000000000 views at 0000001b for: 0000000000401018 000000000040101c (DW_OP_reg0 (rax)) 00000024 v000000000000000 v000000000000000 views at 0000001d for: 000000000040101c 0000000000401027 (DW_OP_reg3 (rbx)) 00000029 <リストの終端> 0000002a v000000000000000 v000000000000000 location view pair 0000002c v000000000000000 v000000000000000 views at 0000002a for: 000000000040101d 0000000000401026 (DW_OP_reg0 (rax)) 00000031 <リストの終端>
x0 のDW_AT_locationは、
<b8> DW_AT_location : 0x1f (location list)
このようになっていた。この、 "0x1f" が、location list 内の位置と対応している。
0000001f v000000000000000 v000000000000000 views at 0000001b for: # DW_AT_location の 0x1f と対応 0000000000401018 000000000040101c (DW_OP_reg0 (rax)) 00000024 v000000000000000 v000000000000000 views at 0000001d for: 000000000040101c 0000000000401027 (DW_OP_reg3 (rbx)) 00000029 <リストの終端>
これは、x0 の値が、0x401018 - 0x40101c の間は、rax に、0x40101c - 0x401027 の間は、rbx に格納されることを意味している。
同じように、x1 の location list は、DW_AT_location の値が、0x2c なので、
0000002c v000000000000000 v000000000000000 views at 0000002a for: 000000000040101d 0000000000401026 (DW_OP_reg0 (rax)) 00000031 <リストの終端>
これと対応している。これは、x1 の値が、0x40101d - 0x401026 の間は、rax に格納されることを意味している。
x0 の値がraxとrbxに重複して格納されている区間があるので、解釈の違いがあるが、これを正しく読み取れば、機械語毎に変数とレジスタが正しく対応付けられることを確認しよう。
x0 の loclistが、↓こうなっていて
開始 | 終了 | レジスタ |
---|---|---|
0x401018 | 0x40101c | rax |
0x40101c | 0x401027 | rbx |
x1 の loclistが、↓こうなっている
開始 | 終了 | レジスタ |
---|---|---|
0x40101d | 0x401026 | rax |
これを機械語と対応させると
アドレス | 機械語 | x0のレジスタ | x1のレジスタ |
---|---|---|---|
0x401010 | push %rbx | 対応なし | 対応なし |
0x401011 | call 0x401000 | 対応なし | 対応なし |
0x401016 | mov %eax, %ebx | 対応なし | 対応なし |
0x401018 | call 0x401000 | rax | 対応なし |
0x40101d | addl $0x1, 0xfdc(%rip) | rbx | rax |
0x401024 | add %ebx, %eax | rbx | rax |
0x401026 | pop %rbx | rbx | 対応なし |
0x401027 | ret | 対応なし | 対応なし |
このようになる。
この情報があれば、gdb の print x0 は、次のように実現できる。
readelf -wi -wo の出力から、ローカル変数の表示が実現できることを確認しよう。
レジスタではなく、スタック上に確保された変数も見ておこう。
#include <syscall.h> /* 最適化で呼び出しが消えないようにGCC 11ではnoipaを付ける */ #define NOINLINE __attribute__((noipa)) NOINLINE static int ret1(int *p) { return *p; } NOINLINE static int f(void) { int x0 = 1; return ret1(&x0); } int _start() { int ret = f(); __asm__ __volatile__("syscall"::"a"(60),"D"(ret)); }
$ gcc -fno-stack-protector -no-pie -O2 -g -nostartfiles -nostdlib -fno-asynchronous-unwind-tables -o debuggee4 debuggee4.c
$ readelf -wi -wo debuggee4 .debug_info セクションの内容: コンパイル単位 @ オフセット 0x0: 長さ: 0xee (32-bit) バージョン: 5 Unit Type: DW_UT_compile (1) 省略オフセット: 0x0 ポインタサイズ:8 <0><c>: 省略番号: 1 (DW_TAG_compile_unit) <d> DW_AT_producer : (間接文字列、オフセット: 0x0): GNU C17 11.1.0 -mtune=generic -march=x86-64 -g -O2 -fno-stack-protector -fno-asynchronous-unwind-tables <11> DW_AT_language : 29 (C11) <12> DW_AT_name : (間接行文字列、オフセット: 0x0): debuggee4.c <16> DW_AT_comp_dir : (間接行文字列、オフセット: 0xc): /home/tanakmura/src/pllp/docs/debugger <1a> DW_AT_low_pc : 0x401000 <22> DW_AT_high_pc : 0x47 <2a> DW_AT_stmt_list : 0x0 <1><2e>: 省略番号: 2 (DW_TAG_subprogram) <2f> DW_AT_external : 1 <2f> DW_AT_name : (間接文字列、オフセット: 0x6d): _start <33> DW_AT_decl_file : 1 <34> DW_AT_decl_line : 16 <35> DW_AT_decl_column : 1 <36> DW_AT_type : <0x72> <3a> DW_AT_low_pc : 0x401030 <42> DW_AT_high_pc : 0x17 <4a> DW_AT_frame_base : 1 byte block: 9c (DW_OP_call_frame_cfa) <4c> DW_AT_call_all_calls: 1 <4c> DW_AT_sibling : <0x72> <2><50>: 省略番号: 3 (DW_TAG_variable) <51> DW_AT_name : ret <55> DW_AT_decl_file : 1 <56> DW_AT_decl_line : 18 <57> DW_AT_decl_column : 9 <58> DW_AT_type : <0x72> <5c> DW_AT_location : 0x10 (location list) <60> DW_AT_GNU_locviews: 0xc <2><64>: 省略番号: 4 (DW_TAG_call_site) <65> DW_AT_call_return_pc: 0x401039 <6d> DW_AT_call_origin : <0x79> <2><71>: Abbrev Number: 0 <1><72>: 省略番号: 5 (DW_TAG_base_type) <73> DW_AT_byte_size : 4 <74> DW_AT_encoding : 5 (signed) <75> DW_AT_name : int <1><79>: 省略番号: 6 (DW_TAG_subprogram) <7a> DW_AT_name : f <7c> DW_AT_decl_file : 1 <7d> DW_AT_decl_line : 10 <7e> DW_AT_decl_column : 21 <7f> DW_AT_prototyped : 1 <7f> DW_AT_type : <0x72> <83> DW_AT_low_pc : 0x401010 <8b> DW_AT_high_pc : 0x1b <93> DW_AT_frame_base : 1 byte block: 9c (DW_OP_call_frame_cfa) <95> DW_AT_call_all_calls: 1 <95> DW_AT_sibling : <0xbc> <2><99>: 省略番号: 7 (DW_TAG_variable) <9a> DW_AT_name : x0 <9d> DW_AT_decl_file : 1 <9e> DW_AT_decl_line : 11 <9f> DW_AT_decl_column : 7 <a0> DW_AT_type : <0x72> <a4> DW_AT_location : 2 byte block: 91 6c (DW_OP_fbreg: -20) <2><a7>: 省略番号: 8 (DW_TAG_call_site) <a8> DW_AT_call_return_pc: 0x401026 <b0> DW_AT_call_origin : <0xbc> <3><b4>: 省略番号: 9 (DW_TAG_call_site_parameter) <b5> DW_AT_location : 1 byte block: 55 (DW_OP_reg5 (rdi)) <b7> DW_AT_call_value : 2 byte block: 91 6c (DW_OP_fbreg: -20) <3><ba>: Abbrev Number: 0 <2><bb>: Abbrev Number: 0 <1><bc>: 省略番号: 10 (DW_TAG_subprogram) <bd> DW_AT_name : (間接文字列、オフセット: 0x68): ret1 <c1> DW_AT_decl_file : 1 <c2> DW_AT_decl_line : 6 <c3> DW_AT_decl_column : 21 <c4> DW_AT_prototyped : 1 <c4> DW_AT_type : <0x72> <c8> DW_AT_low_pc : 0x401000 <d0> DW_AT_high_pc : 0x3 <d8> DW_AT_frame_base : 1 byte block: 9c (DW_OP_call_frame_cfa) <da> DW_AT_call_all_calls: 1 <da> DW_AT_sibling : <0xeb> <2><de>: 省略番号: 11 (DW_TAG_formal_parameter) <df> DW_AT_name : p <e1> DW_AT_decl_file : 1 <e2> DW_AT_decl_line : 6 <e3> DW_AT_decl_column : 31 <e4> DW_AT_type : <0xeb> <e8> DW_AT_location : 1 byte block: 55 (DW_OP_reg5 (rdi)) <2><ea>: Abbrev Number: 0 <1><eb>: 省略番号: 12 (DW_TAG_pointer_type) <ec> DW_AT_byte_size : 8 <ed> DW_AT_type : <0x72> <1><f1>: Abbrev Number: 0 .debug_loclists セクションの内容: Offset Begin End Expression 0000000c v000000000000000 v000000000000000 location view pair 0000000e v000000000000000 v000000000000000 location view pair 00000010 v000000000000000 v000000000000000 views at 0000000c for: 000000000040103b 0000000000401040 (DW_OP_reg0 (rax)) 00000015 v000000000000000 v000000000000000 views at 0000000e for: 0000000000401040 0000000000401047 (DW_OP_reg5 (rdi)) 0000001a <リストの終端>
<2><af>: 省略番号: 8 (DW_TAG_variable) <b0> DW_AT_name : x0 <b3> DW_AT_decl_file : 1 <b4> DW_AT_decl_line : 13 <b5> DW_AT_decl_column : 7 <b6> DW_AT_type : <0x44> <ba> DW_AT_location : 2 byte block: 91 6c (DW_OP_fbreg: -20)
この場合、x0 のDW_AT_locationは、 (DW_OP_fbreg: -20) となっている。これは、「この関数のフレームの開始位置から-20byteの所に格納されている」という意味だ。
$ gcc -fno-stack-protector -no-pie -O2 -S -nostartfiles -nostdlib -fno-asynchronous-unwind-tables -o debuggee4.s debuggee4.c
.file "debuggee4.c" .text .p2align 4 .type ret1, @function ret1: movl (%rdi), %eax ret .size ret1, .-ret1 .p2align 4 .type f, @function f: subq $24, %rsp /* フレームサイズ24byte */ leaq 12(%rsp), %rdi movl $1, 12(%rsp) /* x0 = 1 */ call ret1 addq $24, %rsp ret .size f, .-f .p2align 4 .globl _start .type _start, @function _start: subq $8, %rsp call f movl %eax, %edi movl $60, %eax #APP # 19 "debuggee4.c" 1 syscall # 0 "" 2 #NO_APP addq $8, %rsp ret .size _start, .-_start .ident "GCC: (GNU) 11.1.0" .section .note.GNU-stack,"",@progbits
出力されたアセンブリを見ると、まず、rsp を 24byte 減らして、スタックフレームを確保し、rsp + 12 の位置にx0 を確保していることがわかる。 これは、関数の開始時点から見ると、rsp-12 の位置にx0を確保していることになる。 DWARFでは、スタックフレームは、関数の戻りアドレスを含めるので、スタックフレームの開始位置は、関数開始時点のrspの位置から、+8した位置になる。
フレーム開始位置から、-20byteの位置にx0が確保されていることを確認してほしい。
スタックに確保されたローカル変数は、このようにデバッグ情報の中にフレームからの距離が格納されている。
gdb の print はこれを使って、
と、なる。
さて、ここで注意してほしいのは、「フレーム開始位置」とは一体なんなのか?という点だ。
フレーム開始位置は、rsp の付近にあるということは予測できるが、rspの値は、プログラムの実行にあわせて変化するので、 「関数Xでは、rsp から N byte 離れた位置がフレーム開始位置になる」とは言えない。
たとえば、先程のアセンブリで考えると
f: /* fの開始時点では rsp+8 がフレーム開始位置 */ subq $24, %rsp /* この命令の実行後は rsp+32 がフレーム開始位置 */ leaq 12(%rsp), %rdi movl $1, 12(%rsp) call ret1 /* call 命令はrspを8減らすのでcallで飛んだ先ではrsp+40がフレーム開始位置、戻ってくるとrsp+32がフレーム開始位置 */ addq $24, %rsp /* この命令の実行後は rsp+8 がフレーム開始位置 */ ret
このように、rsp とフレーム開始位置の距離は変化する。
フレームの開始位置を正しく表現するには、どういう情報をデバッグ情報に含めればよいだろうか。 これについては、次のbacktraceのところで解説しよう。
さらに完全に理解したい人のために、デバッガを実装してみよう。(ほんとにやるの?)