GDBコマンドを使用したプログラムのデバッグ
1. 序章
デバッグは開発の一部です。 これは通常、統合開発環境(IDE)で行われます。 ただし、サードパーティのモジュールを使用する場合のように、リリース後のデバッグも便利なことがよくあります。
このチュートリアルでは、デバッグとは何かについて説明することから始めます。 次に、デバッガーの要件と機能について検討します。 その後、GNUProjectDebuggerとその基本的なオプションのいくつかについて詳しく説明します。 最後に、特定のデバッグセッション中に役立つ追加情報を含めます。
このチュートリアルのコードは、GNUBash5.1.4とGNUProjectDebugger10.1を使用してDebian11(Bullseye)でテストしました。 これはPOSIXに準拠しており、そのような環境で機能するはずです。
2. デバッグ
デバッグで通常意味するのは、コードの問題を取り除くことです。 大まかに言えば、問題またはエラーは、構文、ロジック、または実行にある可能性があります。 最初のタイプは解析またはコンパイル中にキャッチされますが、他のタイプは実行時の問題です。 各エラータイプの順番の例としては、閉じ括弧の欠落、無限ループ、誤ったファイルパスがあります。
あるいは、デバッグはコードパスの理解と最適化にも役立ちます。 たとえば、ループ、関数呼び出し、メモリ、およびその他の最適化があります。 これにより、実行がより高速かつ効率的になります。 たとえば、デバッガーは、特定のプログラムに最適なデータの種類を明らかにすることができます。
重要なのは、エラーチェックと最適化の両方を多くのツールで実行できることです。
3. デバッガー
デバッガは上記のようにデバッグするためのプログラムです。 デバッガーの選択は言語によって異なりますが、次のことが可能です。
- 特定の条件でターゲットプログラムをロードする
- 指定されたルールに基づいてターゲットを実行および停止します
- 現在の操作に関するスタックフレームおよびその他のデータを表示します
- 外出先でコードを変更する
上記のすべてを細かく制御するには、完全なデバッグシンボルテーブルを用意するのが最善です。
3.1. シンボルテーブル
実行可能ファイルには、シンボルテーブルと呼ばれるものがあります。 コードの最も重要な部分のアドレスが含まれています。 たとえば、Cプログラムでは、そのような部分の1つがメイン機能であるプログラムのエントリポイントです。
デフォルトのシステムシンボルテーブルに加えて、コンパイルされたファイルには、いわゆるデバッグシンボルテーブルを含めることができます。 その役割は、デバッガーに追加情報を提供することです。 これら2つの表の違いは、現在の記事の範囲外です。 ただし、後で明らかになるように、両方を持っている方が、必要ではないにしても、はるかに便利です。
3.2. ブレークポイント
おそらく、デバッガーの最も明確で便利な機能の1つは、必要に応じてコードを段階的に実行する機能です。 これを実現する方法の1つは、ブレークポイントを使用することです。 ブレークポイントは、実行を停止してデバッガーへの制御を放棄する必要があるコード内の場所を表します。
具体的には、ターゲットの実行前または実行中にブレークポイントを設定します。 ブレークポイントは、ファイル、コード行、関数の先頭、アドレス、またはその他の特定の条件に関連付けることができます。
これは、専用のデバッグシンボルテーブルが役立つ最初の場所です。 これにより、ターゲットを元の言語のコードの行とオブジェクトに分割できます。 または、元のAssembly形式のコードを実行する必要があります。
3.3. モニタリング
もちろん、ブレークポイントで実行を停止した後、現在の状態を確認したいと思います。 これが意味するのは、一連の関数呼び出し(バックトレース)と、現在および周囲の環境(スタックフレーム)を通過することです。
デバッガーに関連するもう1つの強力なオプションは、監視することです。 これにより、プログラムの実行によって変数と状態がどのように変更されるかを監視できます。
3.4. 変形
最後に、私たちが決定した変更は、多くの場合、コードに直接適用できます。 実際には、これはソースを書き直して、すぐに結果を確認できることを意味します。
もちろん、多くの言語にはデバッガーを含むIDEが付属しています。 ただし、それに加えて、外部デバッグプログラムもあります。
4. GNUプロジェクトデバッガー
おそらく、リリース後のデバッグで最も有名なサードパーティツールは、 GNUbinutilsパッケージのgdb(GNU Project Debugger)です。
gdb は多くの言語(執筆時点では12)で動作しますが、例のベースとしてCを使用します。 特に、Cソースtarget.cを使用します。
01 int inc(int a) {
02 return a+1;
03 }
04
05 int main(int argc, char** argv) {
06 for(int i=1; i < 5; i++) {
07 int a = 1;
08 a = inc(a);
09 }
10
11 return argc;
12 }
デバッグセッションの例を見てみましょう。
4.1. コンパイルとロード
リリース後のデバッグを行うので、最初に例をコンパイルする必要があります。 そのために、 gcc (GNU Cコンパイラ)を使用します。 GDBを最大限に活用するには、-gまたは-ggdbフラグを使用してgccにコンパイルするのが最適です。 どちらも、GDBに適したデバッグシンボルテーブルを生成することを保証します。
gcc -ggdb target.c -o target.o
次に、ターゲットをgdbにロードします。
gdb target.o
ターゲットプログラムtarget.oがロードされたら、いくつかの調査を行うことができます。
4.2. ソースコード
デバッグ中は、元の言語でソースコードを表示できることが重要です。 これを行う最も基本的な方法は、listコマンドです。
(gdb) list 1,3
1 int inc(int a) {
2 return a+1;
3 }
ここに、ファイルの最初の3行のコードをリストします。 list コマンドを使用すると、ファイル、行、関数、およびアドレスを指定できます。
コマンドラインに入った後にreturnキーを繰り返し押すと、ほとんどの場合、そのコマンドラインが繰り返されることに注意してください。 一部の特殊なケースでは、動作が少し異なります。 たとえば、 list の場合、returnキーを繰り返し押すと、コマンドの引数が破棄されます。
または、テキストユーザーインターフェイス(TUI)を使用することもできます。 このGDBモードでは、次のことが可能です。
- マウスのサポート
- コマンドバインディング
- シングルキーショートカット
- 間違いなくより便利なデータ表示
特に、現在実行されている行と設定されたブレークポイントを含むソースコードがすべて一目でわかります。 TUIに入るには、-tuiフラグを指定してgdbを開始するか、(gdb)プロンプトで tuienableと入力します。 ここで説明するすべてのコマンドは、TUIモードと通常モードの両方で使用できます。
4.3. ブレークポイント
GDBでターゲットを直接実行しようとすると、次のような結果になります。
(gdb) run
Starting program: /target.o
[Inferior 1 (process 666) exited with code 01]
プログラムの開始と完了の間には、入出力や、そのプロセスと対話する可能性はありません。 これにより、ソースコードを参照して引数を渡す以外に、いくつかのオプションが残ります。
breakコマンドを使用してincへの最初の呼び出しで実行を停止してみましょう:
(gdb) break inc
Breakpoint 1 at 0x112c: file target.c, line 2.
(gdb) run
Starting program: /target.o
Breakpoint 1, inc (a=1) at target.c:2
2 return a+1;
ブレークポイントを設定するだけです。 ブレークポイントは、ターゲットが一時停止してデバッガーへの制御を放棄する必要がある場所です。 もちろん、deleteを使用してブレークポイントを削除することもできます。 引数がない場合、deleteはすべてのブレークポイントを削除します。 clear コマンドにも同様の機能がありますが、ブレークポイントを削除するファイル、機能、および行番号を指定できます。
inc の最初の行のブレークポイントで制御され、停止しています。 今何?
4.4. 情報
実行が停止すると、何が起こっているのかを確認したいことがよくあります。 現在のコード行は表示されますが、 list を使用するか、TUIモードで直接表示することもできます。
ソースコードとは別に、コールスタックに関心があるかもしれません。 関数呼び出しのチェーンを表示するには、backtraceを使用します。
(gdb) backtrace
#0 inc (a=1) at target.c:2
#1 0x0000555555555156 in main (argc=1, argv=0x7fffffffe5f8) at target.c:8
incがtarget.cの8行目で呼び出されたことがわかります。 フレームに関する詳細情報を表示するために、いくつかのコマンドを自由に使用できます。
(gdb) frame
#0 inc (a=1) at target.c:2
2 return a+1;
(gdb) info frame
Stack level 0, frame at 0x7fffffffe4f0:
rip = 0x55555555512c in inc (target.c:2); saved rip = 0x555555555156
called by frame at 0x7fffffffe510
source language c.
Arglist at 0x7fffffffe4e0, args: a=1
Locals at 0x7fffffffe4e0, Previous frame's sp is 0x7fffffffe4f0
Saved registers:
rbp at 0x7fffffffe4e0, rip at 0x7fffffffe4e8
frame コマンドは、現在のフレームの最後の行と、それが属する関数を表示します。 さらに、 info frame コマンドは、現在のフレームに関する詳細情報を表示します。 frameとinfoframe はどちらも、最後の引数としてフレーム番号を受け入れます。
実際、infoは非常に用途の広いコマンドです。 これは、内部GDB値と実行情報の両方に役立ちます。 たとえば、 info locals を介してローカル変数を表示できますが、現時点では何もありません。
特定のオブジェクト値と式の評価を表示するために、printを使用することもできます。
(gdb) print a
$1 = 1
(gdb) print a+666
$2 = 667
(gdb) print/x a+666
$3 = 0x29b
x コマンドも同様に機能しますが、メモリアドレスの内容を表示します。 スラッシュの後にフォーマットを適用し、printとxの両方の引数として式を使用できることに注意してください。
少し巻き戻しましょう。
4.5. 再起動
重要なのは、 quitでGDBを終了するか、killで現在の実行を停止することです。
(gdb) kill
Kill the program being debugged? (y or n) y
[Inferior 1 (process 666) killed]
さらに、最初に一時的なブレークポイントでデバッグを開始するには、startコマンドを使用します。
(gdb) start
Temporary breakpoint 1 at 0x113c: file target.c, line 6.
Starting program: /target.o
Temporary breakpoint 1, main (argc=1, argv=0x7fffffffe5f8) at target.c:6
6 for(int i=1; i < 5; i++) {
次に、値を追跡して表示するように将来の監視を構成することもできます。
4.6. 視聴と表示
ここでTUIモードに入ると、ブレークポイントのあるソース全体ととマークされた現在の行が表示されます。
┌─target.c────────────────[...]
│ 1 int inc(int a) {
│ 2 return a+1;
│ 3 }
│ 4
│ 5 int main(int argc, char** argv) {
│B+>6 for(int i=1; i < 5; i++) {
│ 7 int a = 1;
│ 8 a = inc(a);
│ 9 }
│ 10
│ 11 return argc;
│ 12 }
[...]
これはループのドライバーなので、変数iに関心があるとしましょう。 特定のオブジェクトの変更を監視するには、watchコマンドを使用します。
(gdb) watch i
Hardware watchpoint 2: i
監視する場合、オブジェクトへの変更は、自動ブレークポイント(またはステップ)の形式として機能します。 または、rwatchまたはawatchを使用して、特定のオブジェクトの読み取りのみ、または読み取りと変更の両方を監視できます。
さらに、displayを介して各ステップに関する情報を表示できます。
(gdb) display i
1: i = 0
(gdb) display/x i
2: i = 0x0
display コマンドは、引数としてスラッシュ形式の指定と式もサポートしています。
重要なのは、各表示呼び出しに行が追加されることです。 1つを削除するには、 undisplay を使用して、上からの行番号を引数として使用できます。
(gdb) display
1: i = 0
2: /x i = 0x0
(gdb) undisplay 2
(gdb) display
1: i = 0
チェックを完了し、監視を構成したら、制御された実行を続けましょう。
4.7. ステッピング
停止後に再開するために、複数のコマンドを自由に使用できます。 最初に確認するのはcontinueです。
(gdb) continue
Continuing.
Hardware watchpoint 2: i
Old value = 0
New value = 1
main (argc=1, argv=0x7fffffffe5f8) at target.c:6
6 for(int i=1; i < 5; i++) {
1: i = 1
通常、続行は実行を再開するだけです。 ここでは、iのウォッチポイントにより次の停止まで進みます。
表示値も下部に表示されることに注意してください。 ウォッチポイントと表示を削除して整理しましょう。
(gdb) undisplay 1
(gdb)
points
Num Type Disp Enb Address What
2 hw watchpoint keep y i
breakpoint already hit 1 time
(gdb) delete 2
対照的に、 continue に対して、stepおよびnextは、次のソース行にブレークポイントが設定されているかのように機能します。 それらの違いは、 next は関数呼び出しをスキップし、stepはスタックフレームを使用して呼び出された関数内で進行することです。
(gdb) next
7 int a = 1;
(gdb) next
8 a = inc(a);
(gdb) next
6 for(int i=1; i < 5; i++) {
(gdb) next
7 int a = 1;
(gdb) next
8 a = inc(a);
(gdb) step
inc (a=1) at target.c:2
2 return a+1;
行a= inc(a)で、nextをforループ評価に戻しますが、stepをに入れます。 inc関数。 つまり、next で現在のスタックフレームをスキミングするのではなく、ステップで次のスタックフレームに入ります。
重要なことに、continue、next、およびstepは、引数として数値を受け入れます。 continue の場合、無視して停止しない停止(ブレークポイント、ウォッチポイントなど)の数を示します。 nextおよびstepの場合、この数は単なる繰り返し回数であり、リターンを何度も押すことをシミュレートします。
この比較的短いウォークスルーからでも、GDBで簡単に迷子になる可能性があることは明らかです。 そのような場合に役立つメカニズムがあります。
4.8. チェックポイント
保険と同じように、特定の時点でのデバッグセッションの状態を保存できます。 これは、 checkpoint コマンドを介して実行されます。このコマンドは、現在のターゲットをフォークし、そのフォークを一時停止します。
(gdb) checkpoint
checkpoint 1: fork returned pid 666.
(gdb) next
3 }
(gdb) next
main (argc=1, argv=0x7fffffffe5f8) at target.c:6
6 for(int i=1; i < 5; i++) {
(gdb) checkpoint
checkpoint 2: fork returned pid 667.
上記のスニペットでは、最初に PID666を使用して最初のチェックポイントを作成します。 その後、 next を使用していくつかの手順を実行し、 PID667を使用して2番目のチェックポイントを作成します。 重要なことに、データを共有していないにもかかわらず、両方のプロセスに同じアドレス割り当てがあります。
これで、チェックポイント1に復元する準備が整いました。
(gdb) restart 1
Switching to process 666
#0 inc (a=1) at target.c:2
2 return a+1;
チェックポイントの状態に関する情報には、現在のファイル、関数、および行が含まれます。 次に、すべてのチェックポイントが引き続き使用可能であることを確認します。
(gdb) info checkpoints
0 process 660 (main process) at 0x555555555160, file target.c, line 6
* 1 process 666 at 0x55555555512c, file target.c, line 2
2 process 667 at 0x555555555160, file target.c, line 6
アスタリスクは、プロセス、アドレス、ファイル、および行とともに、現在のチェックポイントを指します。
GDBの基本的な機能に飛び込んだ後、役立つかもしれないいくつかの追加の特定のポイントを見てみましょう。
5. エクストラ
このセクションでは、潜在的に有用なGDBの詳細について説明します。 簡潔にするために、前のセクションのコードスニペットを使用します。
5.1. ヘルプ
非常に多くのオプションがあるため、gdbは非常に手ごわいものになる可能性があります。 したがって、このミニサブセクションはヘルプに専念しています。 helpコマンドは、gdbであり、一般的にデバッグしている広大な暗い森の中のライトです。 help はチュートリアルを表すものではありませんが、プログラムを使用する場合の最良の味方です。
これは、デバッグシンボルテーブルなしでGDBを使用する場合に特に重要です。
5.2. 分解
gdb を起動するときに、コンパイル中に-gフラグの1つをgccに使用しなかった場合、警告が表示されます。デバッグなしtarget.oにあるシンボル。 デバッグシンボルテーブルに依存する次の操作は、ロードするように促します。
さらに、このテーブルがないと、マシンコード命令でしかデバッグできません。 機械命令のデバッグは普遍的ですが、通常は最後の手段です。 Cコードに相当するアセンブリを確認するには、GDBでdisassembleコマンドを使用できます。 簡単にするために、inc関数にのみ適用してみましょう。
(gdb) disassemble inc
Dump of assembler code for function inc:
0x0000000000001125 <+0>: push %rbp
0x0000000000001126 <+1>: mov %rsp,%rbp
0x0000000000001129 <+4>: mov %edi,-0x4(%rbp)
0x000000000000112c <+7>: mov -0x4(%rbp),%eax
0x000000000000112f <+10>: add $0x1,%eax
0x0000000000001132 <+13>: pop %rbp
0x0000000000001133 <+14>: ret
End of assembler dump.
これは、アセンブリのinc関数のコードです。 条件に関係なくdisassembleを実行すると、GDBは現在のdisassembledの周りに3つの命令のコンテキストを表示します。 デバッグシンボルが利用できない場合は、上記のような行から意味を抽出する必要があります。 確かに、これらはCコードの背後にあるマシン命令です。
各ステップで分解された現在の命令を表示するには、displayを使用できます。
(gdb) start
[...]
6 for(int i=1; i < 5; i++) {
(gdb) display/i $pc
1: x/i $pc
=> 0x555555555143 <main+15>: movl $0x1,-0x4(%rbp)
フォーマット指定子/i は命令が出力されていることを意味し、 $pcは現在の命令アドレスを格納するプログラムカウンターであることに注意してください。
もちろん、マシン命令をデバッグするとき、高級言語のような快適さはあまりありません。 他の多くの中で、これには元の名前によるオブジェクトと変数へのアクセスが含まれます。
ただし、コードをステップスルーすることはできますが、命令によってのみ可能です。 この目的のための手順は、すでに見たものと同様です– starti 、 stepi 、nexti。 マシンコードコマンドは、 i (つまり、 命令)接尾辞。 そうでなければ、それらの機能は多かれ少なかれ同等です。
5.3. コードの変更
重要なのは、デフォルトでは、コードの変更はアドレスとバイナリ形式でのみ発生する可能性があることです。 GDBには、アセンブラーやコンパイラーは組み込まれていません。 これは、コードを変更するには、マシン命令を変更して直接書き直す必要があることを意味します。
(gdb) start
[...]
6 for(int i=1; i < 5; i++) {
(gdb) x/i $pc
=> 0x555555555143 <main+15>: movl $0x1,-0x4(%rbp)
(gdb) set *(unsigned char*)0x555555555143 = 0x90
(gdb) x/i $pc
=> 0x555555555143 <main+15>: nop
ここでは、命令のアドレスを指定し、値 0x90 ( nop 命令のオペコード)を割り当てます。 実際、これらの変更は非常に正確であり、コードを簡単に壊す可能性があるため、このような変更には十分注意する必要があります。
5.4. 引数
多くのターゲットプログラムには、コマンドライン引数があります。 これらを追加するには、引数を渡して実行するか、setコマンドを使用します:
(gdb) run
Starting program: /target.o
[Inferior 1 (process 665) exited with code 01]
(gdb) run 1 2
Starting program: /target.o 1 2
[Inferior 1 (process 666) exited with code 03]
(gdb) set args 1 2
(gdb) run
Starting program: /target.o 1 2
[Inferior 1 (process 666) exited with code 03]
サンプルソースは引数の数をステータスとして返すため、コマンドラインと終了コードがどのように変化するかに注意してください。 引数を設定したかどうかを確認するには、 showargsを使用できます。
5.5. 高度なステッピング
untilとfinishの2つの追加のステッピングコマンドについて簡単に説明します。 until を使用して、特定の行に移動できます。
(gdb) start
[...]
6 for(int i=1; i < 5; i++) {
(gdb) until 9
main (argc=1, argv=0x7fffffffe5f8) at target.c:11
11 return argc;
for ループをジャンプして、returnステートメントに直接ジャンプしたことに注目してください。
同様に、finishを使用してジャンプできますが、今回はGDBに最後まで実行させることにより、現在の関数の外にあります:
(gdb) break inc
Breakpoint 1 at 0x112c: file target.c, line 2.
(gdb) run
[...]
2 return a+1;
(gdb) finish
Run till exit from #0 inc (a=1) at target.c:2
0x000055555555515d in main (argc=1, argv=0x7fffffffe5f8) at target.c:8
8 a = inc(a);
Value returned is $1 = 2
untilとfinishはどちらも、continueの特殊なケースのようなものです。
5.6. リモートデバッグ
最後に、このミニサブセクションを非常に強力なGDB機能であるリモートデバッグに当てました。 リモートデバッグにより、gdbはあるマシンで実行でき、ターゲットは別のマシンで実行できます…潜在的に異なるプラットフォームを使用します。
これを行う方法は、リモートスタブと呼ばれるものを使用することです。これにより、リモートターゲットを制御できます。 GDBのデフォルトのリモートスタブはgdbserverです。 リモートデバッグの構成はこの記事の範囲外ですが、これは非常に貴重な機能であると言えば十分です。
6. 概要
このチュートリアルでは、GNU ProjectDebuggerを使用したデバッグについて説明しました。 最初に、潜在的なターゲットをコンパイルしてそのソースコードを調べる方法を示しました。 その後、 gdb を使用してコードをステップ実行し、内部情報を表示しました。 次に、チェックポイントといくつかの高度なGDB機能について説明しました。
結論として、GDBは、デバッグ操作を細かく制御できる複数のオプションを備えた用途の広いツールであると言えます。