使用GDB命令调试程序
1. 简介
调试是开发的一部分。它通常在集成开发环境 (IDE) 中完成。但是,发布后的调试通常也很方便,例如在使用第三方模块时。
在本教程中,我们将从讨论什么是调试开始。接下来,我们考虑调试器的要求和功能。之后,我们将深入了解 GNU 项目调试器及其一些基本选项。最后,我们包括额外的信息,在特定的调试会话中很有帮助。
我们使用 GNU Bash 5.1.4 和 GNU Project Debugger 10.1 在 Debian 11 (Bullseye) 上测试了本教程中的代码。它是 POSIX 兼容的,应该可以在任何这样的环境中工作。
2. 调试
我们通常所说的调试是指消除代码中的问题。从广义上讲,问题或错误 可能存在于语法、逻辑或执行中。第一种类型在解析或编译期间被捕获,而其他类型则是运行时问题。每种错误类型的示例依次是缺少右括号、无限循环和错误的文件路径。
或者,调试也可以帮助理解和优化代码路径。例如,有循环、函数调用、内存和其他优化。这有助于使执行更快、更高效。例如,调试器可以揭示最适合给定程序的数据类型。
重要的是,错误检查和优化都可以使用许多工具来完成。
3. 调试器
调试器是如上所述的调试程序。调试器的选择可能取决于语言,但它们允许:
- 在特定条件下加载目标程序
- 根据指定规则运行和停止目标
- 显示当前操作周围的堆栈帧和其他数据
- 随时随地修改代码
为了更好地控制上述所有内容,我们最好有一个完整的调试符号表。
3.1. 符号表
可执行文件具有所谓的符号表。它包含代码最重要部分的地址。例如,在 C 程序中,这样的部分之一就是 main 函数——程序的入口点。
除了默认的系统符号表,编译后的文件还可以有一个所谓的调试符号表。它的作用是为调试器提供额外的信息。这两个表之间的区别超出了本文的范围。但是,正如将变得清楚的那样,如果没有必要,两者兼有会更方便。
3.2. 断点
调试器最具定义性和最有用的功能之一可能是能够在需要时逐步执行代码。实现此目的的方法之一是使用断点。断点代表代码中应该停止执行并将控制权交给调试器的地方。
具体来说,我们在目标运行之前或期间设置断点。断点可以与文件、代码行、函数的开头、地址或其他特定条件相关。
这是专用调试符号表派上用场的第一个地方。它允许我们将目标分解为原始语言的代码行和对象。或者,我们必须以原始汇编 格式检查代码。
3.3. 监控
当然,在使用断点停止执行之后,我们可能想看看当前状态是什么。这可能意味着遍历函数调用链(回溯),以及当前和周围环境(堆栈帧)。
另一个与调试器相关的强大选项是观察。它允许我们监控程序运行如何修改变量和状态。
3.4. 修改
最后,我们决定的任何更改通常都可以直接应用于代码。在实践中,这意味着重写源代码并能够立即看到结果。 当然,许多语言都带有一个 IDE,其中包括一个调试器。但是,除此之外,还有外部调试程序。
4. GNU 项目调试器
可能最著名的用于发布后调试的第三方工具是来自GNU binutils 包的*gdb *(GNU 项目调试器) 。
尽管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 }
在这里,我们列出了文件中的前三行代码。list命令允许我们指定文件、行、函数和地址。
请注意,在输入任何命令行后重复按回车键通常会重复该命令行。在某些特殊情况下,它的行为会有所不同。例如,对于list,重复按 return 会丢弃命令的参数。
或者,我们可以使用文本用户界面 (TUI)。这种 GDB 模式允许:
- 鼠标支持
- 命令绑定
- 单键快捷键
- 可以说更方便的数据显示 特别是,带有当前执行的行和任何设置的断点的源代码一目了然。要进入 TUI,我们只需使用*-tui标志启动gdb或在(gdb)提示符下键入tui enable 。*我们讨论的所有命令在 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删除断点。没有任何参数,删除删除所有断点。虽然clear命令具有类似的功能,但它可以指定要从中删除断点的文件、函数和行号。
我们处于控制之中,并在inc的第一行的断点处停止。现在怎么办?
4.4. 信息
一旦执行停止,我们经常想看看发生了什么。当前代码行是可见的,但我们可能还希望通过列表或直接在 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和info frame都接受一个帧号作为它们的最后一个参数。
实际上,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命令还支持斜杠格式规范和表达式作为参数。
重要的是,每个显示调用都会添加行。要删除一个,我们可以使用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
一般来说,continue只是恢复执行。在这里,由于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)行,我们接下来回到for循环求值,但我们进入了inc函数。换句话说,我们使用step进入下一个堆栈帧,而不是使用next浏览当前堆栈帧。
重要的是,continue、next和step接受一个数字作为它们的参数。对于continue,它表示要忽略且不停止的停止数(断点、观察点等)。对于next和step,这个数字只是一个重复计数——它模拟了多次按下返回。 即使从这个相对较短的演练中,我们也很容易在 GDB 中迷失方向。有一种机制可以帮助解决这种情况。
4.8. checkpoint
就像保险一样,我们可以在给定点保存调试会话的状态。这是通过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.
在上面的代码片段中,我们最初使用PID 666创建了第一个检查点。之后,我们使用next执行几个步骤,并使用PID 667创建第二个检查点。重要的是,尽管不共享任何数据,但两个进程具有相同的地址分配。
现在我们准备恢复到检查点 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。** help命令是gdb和一般调试的广阔黑暗森林中的**一盏灯。虽然help并不代表教程,但它是我们使用该程序时最好的盟友。
这在使用没有调试符号表的 GDB 时尤其重要。
5.2. Disassembly
启动gdb时,如果我们在编译期间没有对gcc使用*-g*标志之一,我们会收到警告:在 target.o 中找不到调试符号。任何依赖于调试符号表的后续操作都会提示我们加载一个。
此外,没有这张表,我们只能在机器码指令中调试。机器指令调试是通用的,但通常是不得已的方法。要查看我们的 C 代码的汇编等效项,我们可以使用 GDB 中的反汇编命令。为简单起见,让我们仅将其应用于我们的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是counter*,它存储当前指令地址。
当然,在调试机器指令时,我们没有高级语言的许多舒适。其中包括通过原始名称访问对象和变量。
但是,我们仍然可以单步执行代码,但只能通过指令。目的说明与我们已经看过的说明相似——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. 论据
许多目标程序将具有命令行参数。要添加这些,我们可以传递参数以run 或使用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]
注意命令行和退出代码是如何变化的,因为我们的示例源返回参数的数量作为其状态。要检查我们是否设置了任何参数,我们可以使用show args。
5.5. 高级步进
让我们简要讨论两个额外的步进命令——until 和finish。我们可以使用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 功能——remote debugging。远程调试允许gdb在一台机器上运行,而它的目标在另一台机器上运行……可能具有不同的平台。
我们这样做的方式是通过称为远程存根的东西,它允许我们控制远程目标。GDB 的默认远程存根是gdbserver。配置远程调试不在本文的讨论范围之内,但可以说它是一项非常宝贵的功能。