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

阅读余下内容
 

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注


京ICP备12002735号