C 语言编程中两个方便的 GDB 断点技巧
在过去的几个月里,我发现了几个使用 GDB 断点的小窍门。这些都是我自己想出来的,而且我也没有在其他地方看到过对它们的讨论,所以我真的应该与大家分享一下。
连续断言
在典型的 C 语言实现中,assert 宏以及 raise 和 abort 都有很多不足之处,因此我提出了在调试器下表现更好的替代定义:
#define assert(c) while (!(c)) __builtin_trap()
#define assert(c) while (!(c)) __builtin_unreachable()
#define assert(c) while (!(c)) *(volatile int *)0 = 0
每种功能的用途略有不同,但都具有最重要的特性:在出现缺陷时直接立即停止程序。没有一种具有偶尔有用的次要特性:可选择允许程序继续通过缺陷。如果程序到达任何这些宏的主体,那么就没有可靠的继续。即使手动将指令指针推向断言也是不够的。编译器会认为程序无法继续通过该条件,并据此生成代码。
MSVC 生态系统在 x86 上有一个解决方案:int3
。它的可移植名称是 __debugbreak
,我在其他地方借用了这个名称。
#define assert(c) do if (!(c)) __debugbreak(); while (0)
在 x86 处理器上,它会插入一条 int3
指令,该指令会触发一个中断,诱导连接的调试器,或以其他方式异常终止程序。因为是中断,所以程序可能会继续运行。它甚至会将指令指针留在下一条指令上。到目前为止,GCC 还没有与之匹配的固有函数,但 Clang 最近添加了 __builtin_debugtrap
函数。在 GCC 中,您需要一些可移植性较差的内联汇编:asm("int3")
。
然而,无论你如何在程序中获得 int3
,GDB 目前都无法理解它。问题就出在我提到的那个特性上:指令指针指向的不是 int3
,而是下一条指令。这让 GDB 感到困惑,导致它在错误的地方,甚至可能在错误的作用域中中断。例如
for (int i = 0; i < n; i++) {
// ...
int3_assert(...);
}
如果将 int3
放在循环的最末端,GDB 会在下一次循环迭代的顶端中断,因为 GDB 参与时,指令指针已经在那里了。如果将 int3
放在函数的末尾,GDB 也会在调用者中中断,情况与此类似。要解决这个问题,我们需要在中断触发后,指令指针仍在断点 “内部”。简单添加一个 nop
:
#define breakpoint() asm ("int3; nop")
它的表现非常出色,消除了 GDB 在使用普通 int3
时遇到的所有问题。这不仅为可连续断言奠定了坚实的基础,还可用作快速条件断点,而传统的条件断点速度太慢。
for (int i = 0; i < 1000000000; i++) {
if (/* rare condition */) breakpoint();
// ...
}
GDB 能否更好地处理 int3
?可以!例如,Visual Studio 不需要 nop
指令。据我所知,目前还没有与 GDB(甚至 LLDB)兼容的 ARM 同等指令。最接近的指令,brk #0x1
,也不能满足需要。
命名位置
GDB 的内置用户界面可理解三类断点位置:符号、无上下文行号和绝对地址。当你在 GDB 下设置一些断点并(重新)启动程序时,每种断点的处理方式都不同:
- 解析每个符号,在其运行时地址上设置断点。
- 将每个 file+lineno 元组映射到运行时地址,并在该地址上设置断点。如果该行不存在(即文件较短),则跳过该行。
- 在每个绝对地址上精确放置断点。如果不是映射地址,就不要启动程序。
第一种情况是最好的,因为它能适应程序的变化。修改代码、重新编译,断点一般都会保留在你想要的位置。
第三种情况最没用。这些断点很少能在重建过程中存活,有时甚至不能在重新运行过程中存活。
第二种情况介于有用和无用之间。如果你编辑了带有断点的源文件–很可能是因为你把断点放在这里是有原因的–行号就很有可能不再正确。相反,行号会漂移,需要手动替换。这太乏味了,GDB 应该做得更好。你觉得这不合理吗?Visual Studio 调试器就能通过外部代码编辑有效地做到这一点!GDB 前端往往会处理得更好,尤其是当它们同时也是代码编辑器,可以直接观察到所有编辑时。
作为一种变通方法,我们可以通过临时命名行号来获得第一种方法。这需要编辑源代码,但请记住,我们之所以需要这样做,是因为相关源代码正在发生变化。如何命名行?C 和 C++ 标签为程序位置命名:
void example(double *nums, int n, ...)
{
for (int i = 0; i < n; i++) {
loop: // named position at the start of the loop
// ...
}
}
名称 loop
是 example 的局部名称,但限定的 example:loop
是全局名称,与其他符号一样适用。比如说,尽管这个循环在源代码中的位置发生了变化,我仍然可以可靠地跟踪它的进程。
(gdb) dprintf example:loop,"nums[%d] = %g\n",i,nums[i]
这样做的一个缺点是要处理 -Wunused-label
(由 -Wall
启用),因此我考虑在默认设置中禁用警告。更新:马修-费尔南德斯指出,未使用的标签属性可以消除警告,从而解决我的问题:
for (int i = 0; i < n; i++) {
loop: __attribute((unused))
// ...
}
我更常用的是装配标签,为了方便起见,通常命名为 b
:
for (int i = 0; i < n; i++) {
asm ("b:");
// ...
}
和 int3 一样,有时需要给它一个 nop,以便 GDB 有东西可以破解。在任何时候 “启用 “它都很快捷:
(gdb) b b
因为它不是 .globl
,所以是一个弱符号,我可以在每个翻译单元中放置一个符号,所有符号都由同一个 GDB 断点项覆盖(没有听起来那么有用)。我没有实际检查过,但我可能更经常使用 dprintf 来处理这类命名行,而不是实际的断点。
如果你也有类似的技巧和窍门,我想了解一下!
本文文字及图片出自 Two handy GDB breakpoint tricks