GNU 项目调试器实战教程

GNU 项目调试器是查找程序错误的强大工具。
76 位读者喜欢这篇文章。
magnifying glass on computer screen, finding a bug in the code

Opensource.com

如果您是一名程序员,并且想在您的软件中加入特定功能,您首先会考虑实现它的方法——例如编写一个方法、定义一个类或创建新的数据类型。然后,您用编译器或解释器可以理解的语言编写实现代码。但是,如果编译器或解释器不理解您心目中的指令,即使您确信自己做的一切都正确,该怎么办?如果软件在大多数时候运行良好,但在某些情况下会导致错误,该怎么办?在这些情况下,您必须知道如何正确使用调试器来查找问题的根源。

GNU 项目调试器(GDB)是查找程序错误的一个强大工具。它通过跟踪程序执行期间内部发生的情况,帮助您揭示错误或崩溃的原因。

本文是关于 GDB 基本用法的实战教程。要跟随示例进行操作,请打开命令行并克隆此存储库

git clone https://github.com/hANSIc99/core_dump_example.git

快捷方式

GDB 中的每个命令都可以缩短。例如,info break(显示设置的断点)可以缩短为 i break。您可能会在其他地方看到这些缩写,但在本文中,我将写出完整的命令,以便清楚地了解使用了哪个功能。

命令行参数

您可以将 GDB 附加到每个可执行文件。导航到您克隆的存储库,并通过运行 make 进行编译。您现在应该有一个名为 coredump 的可执行文件。(有关更多信息,请参阅我关于 创建和调试 Linux 转储文件 的文章。)

要将 GDB 附加到可执行文件,请输入:gdb coredump

您的输出应如下所示

它显示未找到调试符号。

调试信息是目标文件(可执行文件)的一部分,包括数据类型、函数签名以及源代码和操作码之间的关系。此时,您有两个选择

编译调试信息

要在二进制文件中包含调试信息,您必须重新编译它。打开 Makefile 并删除第 9 行的井号 (#)

CFLAGS =-Wall -Werror -std=c++11 -g

g 选项告诉编译器包含调试信息。运行 make clean,然后运行 make 并再次调用 GDB。您应该获得此输出并可以开始调试代码

额外的调试信息将增加可执行文件的大小。在本例中,它使可执行文件的大小增加了 2.5 倍(从 26,088 字节增加到 65,480 字节)。

通过键入 run -c1,使用 -c1 开关启动程序。程序将启动并在到达 State_4 时崩溃

您可以检索有关程序的其他信息。命令 info source 提供有关当前文件的信息

  • 101 行
  • 语言:C++
  • 编译器(版本、调整、架构、调试标志、语言标准)
  • 调试格式:DWARF 2
  • 没有可用的预处理器宏信息(当使用 GCC 编译时,只有在 使用 -g3 标志编译 时宏才可用)。

命令 info shared 打印动态库列表,其中包含程序启动时加载的虚拟地址空间中的地址,以便程序可以执行

如果您想了解 Linux 中的库处理,请参阅我的文章 如何在 Linux 中处理动态库和静态库

调试程序

您可能已经注意到,您可以使用 run 命令在 GDB 中启动程序。run 命令接受命令行参数,就像您从控制台启动程序一样。-c1 开关将导致程序在 stage 4 崩溃。要从头开始运行程序,您不必退出 GDB;只需再次使用 run 命令。如果不使用 -c1 开关,程序将执行无限循环。您必须使用 Ctrl+C 停止它。

您也可以逐步执行程序。在 C/C++ 中,入口点是 main 函数。使用命令 list main 打开显示 main 函数的源代码部分

main 函数在第 33 行,因此通过键入 break 33 在此处添加断点

通过键入 run 运行程序。正如预期的那样,程序在 main 函数处停止。键入 layout src 以并行显示源代码

您现在处于 GDB 的文本用户界面 (TUI) 模式。使用向上和向下箭头键滚动浏览源代码。

GDB 突出显示要执行的行。通过键入 next (n),您可以逐行执行命令。如果您未指定新命令,GBD 将执行上一个命令。要单步执行代码,只需按 Enter 键即可。

有时,您会注意到 TUI 的输出会有些损坏

如果发生这种情况,请按 Ctrl+L 以重置屏幕。

使用 Ctrl+X+A 可以随意进入和退出 TUI 模式。您可以在手册中找到 其他键绑定

要退出 GDB,只需键入 quit

观察点

此示例程序的核心部分是由状态机在无限循环中运行组成。变量 n_state 是一个简单的枚举,用于确定当前状态

while(true){
	switch(n_state){
	case State_1:
		std::cout << "State_1 reached" << std::flush;
		n_state = State_2;
		break;
	case State_2:
		std::cout << "State_2 reached" << std::flush;
		n_state = State_3;
		break;
	
	(.....)
	
	}
}

您希望在 n_state 设置为值 State_5 时停止程序。为此,请在 main 函数处停止程序,并为 n_state 设置观察点

watch n_state == State_5

仅当所需变量在当前上下文中可用时,使用变量名设置观察点才有效。

当您通过键入 continue 继续程序执行时,您应该获得如下输出

如果您继续执行,当观察点表达式求值为 false 时,GDB 将停止

您可以为一般值更改、特定值以及读取或写入访问指定观察点。

更改断点和观察点

键入 info watchpoints 以打印先前设置的观察点列表

删除断点和观察点

如您所见,观察点是编号的。要删除特定的观察点,请键入 delete,后跟观察点的编号。例如,我的观察点的编号为 2;要删除此观察点,请输入 delete 2

注意: 如果您使用 delete 而不指定编号,则所有观察点和断点都将被删除。

断点也适用相同的情况。在下面的屏幕截图中,我添加了几个断点,并通过键入 info breakpoint 打印了它们的列表

要删除单个断点,请键入 delete,后跟其编号。或者,您可以通过指定其行号来删除断点。例如,命令 clear 78 将删除在第 78 行设置的断点编号 7。

禁用或启用断点和观察点

您可以禁用断点或观察点,而不是删除它,方法是键入 disable,后跟其编号。在下面,断点 3 和 4 被禁用,并在代码窗口中标记为减号

也可以通过键入类似 disable 2 - 4 的内容来修改一系列断点或观察点。如果要重新激活这些点,请键入 enable,后跟它们的编号。

条件断点

首先,通过键入 delete 删除所有断点和观察点。您仍然希望程序在 main 函数处停止,但是,不是指定行号,而是通过直接命名函数来添加断点。键入 break main 以在 main 函数处添加断点。

键入 run 以从头开始执行,程序将在 main 函数处停止。

main 函数包含变量 n_state_3_count,当状态机命中状态 3 时,该变量会递增。

要根据 n_state_3_count 的值添加条件断点,请键入

break 54 if n_state_3_count == 3

继续执行。程序将在状态机执行三次后在第 54 行停止。要检查 n_state_3_count 的值,请键入

print n_state_3_count

使断点成为条件断点

也可以使现有断点成为条件断点。使用 clear 54 删除最近添加的断点,并通过键入 break 54 添加一个简单的断点。您可以通过键入以下内容使此断点成为条件断点

condition 3 n_state_3_count == 9

3 指的是断点编号。

在其他源文件中设置断点

如果您的程序由多个源文件组成,则可以通过在行号之前指定文件名来设置断点,例如 break main.cpp:54

捕捉点

除了断点和观察点之外,您还可以设置捕捉点。捕捉点适用于程序事件,例如执行系统调用、加载共享库或引发异常。

要捕捉用于写入 STDOUT 的 write 系统调用,请输入

catch syscall write

每次程序写入控制台输出时,GDB 都会中断执行。

在手册中,您可以找到涵盖 断点、观察点和捕捉点 的完整章节。

评估和操作符号

变量值的打印使用 print 命令完成。一般语法为 print <expression> <value>。可以通过键入以下内容修改变量的值

set variable <variable-name> <new-value>.

在下面的屏幕截图中,我给变量 n_state_3_count 赋值为 123

/x 表达式以十六进制打印值;使用 & 运算符,您可以打印虚拟地址空间内的地址。

如果您不确定某个符号的数据类型,可以使用 whatis 查找

如果您想列出 main 函数作用域中可用的所有变量,请键入 info scope main

DW_OP_fbreg 值指的是基于当前子例程的堆栈偏移量。

或者,如果您已在函数内部并想列出当前堆栈帧上的所有变量,则可以使用 info locals

查看手册以了解有关 检查符号 的更多信息。

附加到正在运行的进程

命令 gdb attach <process-id> 允许您通过指定进程 ID (PID) 附加到已在运行的进程。幸运的是,coredump 程序将其当前 PID 打印到屏幕上,因此您不必手动使用 pstop 查找它。

启动 coredump 应用程序的一个实例

./coredump

操作系统给出 PID 2849。打开一个单独的控制台窗口,移动到 coredump 应用程序的源目录,并附加 GDB

gdb attach 2849

当您附加 GDB 时,GDB 会立即停止执行。键入 layout srcbacktrace 以检查调用堆栈

输出显示进程在执行 std::this_thread::sleep_for<...>(...) 函数时中断,该函数在 main.cpp 的第 92 行被调用。

一旦您退出 GDB,该进程将继续运行。

您可以在 GDB 手册中找到有关 附加到正在运行的进程 的更多信息。

在堆栈中移动

通过使用 up 两次在堆栈中向上移动到 main.cpp,返回到程序

通常,编译器会为每个函数或方法创建一个子例程。每个子例程都有自己的堆栈帧,因此在堆栈帧中向上移动意味着在调用堆栈中向上移动。

您可以在手册中找到更多关于堆栈求值的信息。

指定源文件

当附加到已运行的进程时,GDB 将在当前工作目录中查找源文件。或者,您可以使用 directory 命令手动指定源目录。

评估转储文件

阅读创建和调试 Linux 转储文件以获取有关此主题的信息。

TL;DR(太长不看)

  1. 我假设您正在使用最新版本的 Fedora
  2. 使用 -c1 开关调用 coredump:coredump -c1

  3. 使用 GDB 加载最新的转储文件:coredumpctl debug
  4. 打开 TUI 模式并输入 layout src

backtrace 的输出显示崩溃发生在距离 main.cpp 五个堆栈帧的位置。输入以直接跳转到 main.cpp 中错误的代码行

查看源代码表明程序试图释放一个并非由内存管理函数返回的指针。这会导致未定义的行为并导致 SIGABRT

不使用符号进行调试

如果没有可用的源文件,事情会变得非常困难。当尝试解决逆向工程挑战时,我第一次体验到这一点。 了解一些汇编语言的知识也很有用。

查看此示例的工作原理。

转到源目录,打开 Makefile,并像这样编辑第 9 行

CFLAGS =-Wall -Werror -std=c++11 #-g

要重新编译程序,运行 make clean,然后运行 make 并启动 GDB。该程序不再具有任何调试符号来引导您查看源代码。

info file 命令显示二进制文件的内存区域和入口点

入口点与 .text 区域的开头相对应,其中包含实际的操作码。要在入口点添加断点,输入 break *0x401110,然后通过输入 run 开始执行

要在特定地址设置断点,请使用解引用运算符 * 指定它。

选择反汇编器风格

在深入研究汇编之前,您可以选择要使用的汇编风格。GDB 的默认风格是 AT&T,但我更喜欢 Intel 语法。使用以下命令更改它:

set disassembly-flavor intel

现在打开汇编窗口和寄存器窗口,输入 layout asmlayout reg。您现在应该看到类似这样的输出

保存配置文件

虽然您已经输入了很多命令,但您实际上还没有开始调试。如果您正在大量调试应用程序或尝试解决逆向工程挑战,将您的 GDB 特定设置保存在文件中可能会很有用。

此项目 GitHub 存储库中的配置文件 gdbinit 包含最近使用的命令

set disassembly-flavor intel
set write on
break *0x401110
run -c2
layout asm
layout reg

set write on 命令使您能够在执行期间修改二进制文件。

退出 GDB 并使用配置文件重新打开它:gdb -x gdbinit coredump

读取指令

应用 c2 开关后,程序将崩溃。程序在入口函数处停止,因此您必须写入 continue 以继续执行

idiv 指令执行整数除法,被除数在 RAX 寄存器中,除数作为参数指定。商加载到 RAX 寄存器中,余数加载到 RDX 中。

从寄存器概览中,您可以看到 RAX 包含 5,因此您必须找出存储在堆栈位置 RBP-0x4 的值。

读取内存

要读取原始内存内容,您必须指定比读取符号更多的参数。当您在汇编输出中向上滚动一点时,您可以看到堆栈的划分

您最感兴趣的是 rbp-0x4 的值,因为这是存储 idiv 参数的位置。从屏幕截图中,您可以看到下一个变量位于 rbp-0x8,因此 rbp-0x4 处的变量是 4 字节宽。

在 GDB 中,您可以使用 x 命令来检查任何内存内容

x/ < 可选参数 n f u > < 内存地址 addr >

可选参数

  • n:重复计数(默认值:1)指的是单位大小
  • f:格式说明符,类似于 printf 中的格式说明符
  • u:单位大小
    • b:字节
    • h:半字(2 字节)
    • w:字(4 字节)(默认值)
    • g:巨字(8 字节)

要打印出 rbp-0x4 处的值,输入 x/u $rbp-4

如果您牢记这种模式,则检查内存就很简单了。查看手册中检查内存部分。

操纵汇编代码

算术异常发生在子例程 zeroDivide() 中。当您使用向上箭头键向上滚动一点时,您可以找到此模式

0x401211 <_Z10zeroDividev>              push   rbp
0x401212 <_Z10zeroDividev+1>            mov    rbp,rsp  

这称为函数序言

  1. 调用函数的基指针 (rbp) 存储在堆栈上
  2. 堆栈指针 (rsp) 的值加载到基指针 (rbp)

完全跳过此子例程。您可以使用 backtrace 检查调用堆栈。您只比 main 函数提前一个堆栈帧,因此您可以使用单个 up 返回到 main

在您的 main 函数中,您可以找到此模式

0x401431 <main+497>     cmp    BYTE PTR [rbp-0x12],0x0
0x401435 <main+501>     je     0x40145f <main+543>
0x401437 <main+503>     call   0x401211<_Z10zeroDividev>

仅当 jump equal (je) 的计算结果为 true 时,才会进入子例程 zeroDivide()。您可以轻松地将其替换为 jump-not-equal (jne) 指令,该指令的操作码为 0x75(前提是您在 x86/64 架构上;在其他架构上,操作码是不同的)。通过输入 run 重新启动程序。当程序在入口函数处停止时,通过输入以下内容来操纵操作码:

set *(unsigned char*)0x401435 = 0x75

最后,输入 continue。程序将跳过子例程 zeroDivide(),并且不会再崩溃。

结论

您可以在许多集成开发环境 (IDE) 中找到在后台运行的 GDB,包括 Qt Creator 和 VSCodium 的 Native Debug 扩展。

了解如何利用 GDB 的功能很有用。通常,并非所有 GDB 的功能都可以从 IDE 中使用,因此从命令行使用 GDB 的经验对您有利。

接下来阅读什么
标签
User profile image.
Stephan 是一位技术爱好者,他欣赏开源,因为它能深入了解事物的工作原理。Stephan 在工业自动化软件这个主要为专有领域的公司担任全职支持工程师。如果可能,他会从事他基于 Python 的开源项目、撰写文章或骑摩托车。

评论已关闭。

© . All rights reserved.