GDB 自动化操作的技术
程序员在调试时往往分成两派,一派用debugger另一派用print。至于本人嘛,是一个“机会主义者”,有时用print,有时却改投debugger阵营。
实话说,print要比用debugger设下断点更为简单粗暴,有时甚至会更有用。不过debugger对比于print有三个优点:
无需重新编译
可以在调试时改变变量
debugger可以实现print做不到的复杂操作
在本文,我会介绍一些在gdb中自动化操作的技术,保证可以让你大开眼界,见识下gdb真正的力量。
会话/历史/命令文件
通常我们只有在程序出问题才会启动gdb,开始调试工作,调试完毕后退出。不过,让gdb一直开着未尝不是更好的做法。每个gdb老司机都懂得,gdb在r
的时候会加载当前程序的最新版本。也即是说,就算不退出gdb,每次运行的也会是当前最新的版本。不退出当前调试会话有两个好处:
调试上下文可以得到保留。不用每次运行都重新设一轮断点。
一旦core dump了,可以显示core dump的位置,无需带着core重新启动一次。
在开发C/C++项目,我一般是这样的工作流程:一个窗口开着编辑器,编译也在这个窗口执行;另一个窗口开着gdb,这个窗口同时也用来运行程序。一旦要调试了(或者,又segment fault了),随手就可以开始干活。
当然了,劳作一天之后,总需要关电脑回家。这时候只能退出gdb。不想明天一早再把断点设上一遍?gdb提供了保留断点的功能。输入save br .gdb_bp
,gdb会把本次会话的断点存在.gdb_bp
中。明天早上一回来,启动gdb的时候,加上-x .gdb_bp
,让gdb把.gdb_bp
当做命令文件逐条重新执行,一切又回到昨晚。
condition break/watch/catch
下面是一个带bug的二分查找实现:
#include <iostream>
using std::cout;
using std::endl;
int binary_search(int *ary, unsigned int ceiling, int target)
{
unsigned int floor = 0;
while (ceiling > floor) {
unsigned int pivot = (ceiling + floor) / 2;
if (ary[pivot] < target)
floor = pivot + 1;
else if (ary[pivot] > target)
ceiling = pivot - 1;
else
return pivot;
}
return -1;
}
int main()
{
int a[] = {1, 2, 4, 5, 6};
cout << binary_search(a, 5, 7) << endl; // -1
cout << binary_search(a, 5, 6) << endl; // 4
cout << binary_search(a, 5, 5) << endl; // 期望3,实际运行结果是-1
return 0;
}
你打算调试下binary_search(a, 5, 5)
这个组合。若如果用print大法,就在binary_search
中插入几个print,运行后扫一眼,看看target=5
的时候运行流是怎样的。
debugger大法看似会复杂一点,如果在binary_search
中插断点,那么前两次调用只能连按c
跳过。其实没那么复杂,gdb允许用户设置条件断点。你可以这么设置:
b binary_search if target == 5
现在就只有第三次调用会触发断点。
问题看上去跟floor
和ceiling
值的变化有关。要想观察它们的值,可以p floor
和p ceiling
。不过有个简单的方法,你可以对它们设置watch断点:wa floor if target == 5
。当floor
的值变化时,就会触发断点。
对于我们的示例程序来说,靠脑补也能算出这两个值的变化,专门设置断点似乎小题大做。不过在调试真正的程序时,watch断点非常实用,尤其当你对相关代码不熟悉时。使用watch断点可以更好地帮助你理解程序流程,有时甚至会有意外惊喜。另外结合debugger运行时修改值的能力,你可以在值变化的下一刻设置目标值,观察走不同路径会不会出现类似的问题。如果有需要的话,还可以给某个内存地址设断点:wa *0x7fffffffda40
。
除了watch之外,gdb还有一类catch断点,可以用来捕获异常/系统调用/信号。因为用途不大(我从没实际用过),就不介绍了,感兴趣的话在gdb里面help catch
看看。
commands/define
gdb提供名为commands
的机制,可以给某个断点挂上待触发的命令。举个例子,b binary_search if target == 5
之后,输入:
comm
i locals
i args
end
这样当上面的断点被触发时,i locals
和i args
命令会被触发,列出当前上下文内的变量。这个功能挺废的,因为你完全可以在断点被触发后才敲入这几个命令。要不是有define
,commands
就真成摆设了。接下来我们要介绍commands
的好基友、最强大的gdb命令之一,define
命令。
一如unix世界里面的许多程序一样,gdb内部实现了一门DSL(领域特定语言)。用户可以通过这门DSL来编写自定义的宏,甚至编写调试用的自动化脚本。我们可以用define
命令编写自定义的宏。
继续上面的例子,你可以自定义一个命令代替b xxx comm ...
:
(gdb) define br_info
Type commands for definition of "br_info".
End with a line saying just "end".
>b $arg0
>comm
>i locals
>i args
>end
(gdb) br_info binary_search if target == 5
当if target == 5
条件满足时,br_info binary_search
会被执行。br_info
展开成为一系列命令,并用binary_search
替换掉$arg0
。一行顶过去五行!
除了在会话内创建自定义宏外,我们还可以用gdb的DSL编写宏文件,并导入到gdb中。
举个有实际意义的例子。由于源代码的改变,我们需要更新断点的位置。通常的做法是删掉原来的断点,并新设一个。让我们现学现用,用宏把这两步合成一步:
# gdb_macro
define mv
if $argc == 2
delete $arg0
# 注意新创建的断点编号和被删除断点的编号不同
break $arg1
else
print "输入参数数目不对,help mv以获得用法"
end
end
# (gdb) help mv 会输出以下帮助文档
document mv
Move breakpoint.
Usage: mv old_breakpoint_num new_breakpoint
Example:
(gdb) mv 1 binary_search -- move breakpoint 1 to `b binary_search`
end
# vi:set ft=gdb ts=4 sw=4 et
使用方法:
(gdb) b binary_search
Breakpoint 1 at 0x40083b: file binary_search.cpp, line 7.
(gdb) source ~/gdb_macro
(gdb) help mv
Move breakpoint.
Usage: mv old_breakpoint_num new_breakpoint
Example:
(gdb) mv 1 binary_search -- move breakpoint 1 to `b binary_search`
(gdb) mv 1 binary_search.cpp:18
Breakpoint 2 at 0x4008ab: file binary_search.cpp, line 18.
还可以进一步,把source ~/gdb_macro
也省掉。你可以创建gdb配置文件~/.gdbinit
,让gdb启动时自动执行里面的指令。如果把自己常用的宏写在该文件中,就能直接在gdb里面使用了,用起来如内置命令一般顺滑。
调试脚本
在第一节会话/历史/命令文件
结尾,我提到用-x
指定命令文件来回放断点。那时的命令文件也算是一种用gdb的DSL编写的调试脚本。由于调试是件交互性的活,需要事先写好调试脚本的场景不多。即使如此,除了让gdb自动设置断点,依然有不少场景下可以用上调试脚本。其中之一,就是让gdb自动采集特定函数调用的上下文数据。我把这种方法称为“拖网法”,因为它就像拖网捕鱼一样,把逮到的东西都一股脑带上来。
设想如下的情景:某个项目出现内存泄露的迹象。事先分配好的内存池用着用着就满了,一再地吞噬系统的内存。内存管理是自己实现的,所以无法用valgrind来分析。鉴于内存管理部分代码最近几个版本都没有改动过,猜测是业务逻辑代码里面有谁借了内存又不还。现在你需要把它揪出来。一个办法是给内存的分配和释放加上日志,再编译,然后重新运行程序,谋求复现内存泄露的场景。不过更快的办法是,敲上这一段代码:
(假设分配内存的接口是my_malloc(char *p, size_t size)
,释放内存的接口是free(char *p)
)
# /tmp/malloc_free
# 设置输出不要分屏
set pagination off
b my_malloc
comm
silent
printf "malloc 0x%x %lu\n", p, size
bt
c
end
b my_free
comm
silent
printf "free 0x%x\n", p
bt
c
end
c
直接让gdb执行它:
sudo gdb -q -p $(pidof $your_project) -x /tmp/malloc_free > log
运行一段时间后kill掉gdb,打开log看看里面的内容:
$ less log
Attaching to process 8738
Reading symbols from ...done.
Reading symbols from /lib/x86_64-linux-gnu/libc.so.6...Reading symbols from /usr/
lib/debug//lib/x86_64-linux-gnu/libc-2.19.so...done.
done.
Loaded symbols for /lib/x86_64-linux-gnu/libc.so.6
......
malloc 0x0 82
#0 my_malloc (p=0x0, size=82) at memory.cpp:8
#1 0x0000000000400657 in write_buffer (p=0x0, size=82) at memory.cpp:17
#2 0x00000000004006b6 in main () at memory.cpp:25
malloc 0x852c39c0 13
#0 my_malloc (p=0x7ffd852c39c0 "\001", size=13) at memory.cpp:8
#1 0x0000000000400657 in write_buffer (p=0x7ffd852c39c0 "\001", size=13) at memory.cpp:17
#2 0x00000000004006b6 in main () at memory.cpp:25
free 0x400780
#0 my_free (p=0x400780 <__libc_csu_init> "AWA\211\377AVI\211\366AUI\211\325ATL\215%x\006 ") at memory.cpp:14
#1 0x0000000000400632 in read_buffer (p=0x400780 <__libc_csu_init> "AWA\211\377AVI\211\366AUI\211\325ATL\215%x\006 ") at memory.cpp:16
#2 0x00000000004006fe in main () at memory.cpp:28
free 0x0
......
现在我们可以写个脚本对下帐。每次解析到malloc
时,在对应指针的名下记下一项借出。解析到free
时,表示销掉对应最近一次借出的还款。把全部输出解析完后,困扰已久的坏账情况就将水落石出,欠钱不还的老赖也将无可遁形。这种“拖网法”真的是简单粗暴又有效。
我们还可以用这种“拖网法”获取指定函数的调用者比例、调用参数的分布范围等等。注意,不要在生产环境撒网,毕竟这么做对性能有显著影响。而且要做统计的话,也有更好的方法可以选。
用python拓展gdb
除了用gdb自身的DSL,我们还可以使用python来给gdb写脚本。凭借python的力量,我们甚至可以在gdb里跟外部程序交互,展示更多的可能性。“你们对力量一无所知”。
欲知后事如何,请听下回分解。
make building blocks that people can understand and use easily, and people will work together to solve the very largest problems.
make building blocks that people can understand and use easily, and people will work together to solve the very largest problems.