从一个实例来认识GDB与高效调试
GDB的全称是GNU project debugger,是类Unix系统上一个十分强大的调试器。这里通过一个简单的例子(插入算法)来介绍如何使用gdb进行调试,特别是如何通过中断来高效地找出死循环;我们还可以看到,在修正了程序错误并重新编译后,我们仍然可以通过原先的GDB session进行调试(而不需要重开一个GDB),这避免了一些重复的设置工作;同时,在某些受限环境中(比如某些实时或嵌入式系统),往往只有一个Linux字符界面可供调试。这种情况下,可以使用job在代码编辑器、编译器(编译环境)、调试器之间做到无缝切换。这也是高效调试的一个方法。
先来看看这段插入排序算法(a.cpp),里面有一些错误。
// a.cpp #include <stdio.h> #include <stdlib.h> int x[10]; int y[10]; int num_inputs; int num_y = 0; void get_args(int ac, char **av) { num_inputs = ac - 1; for (int i = 0; i < num_inputs; i++) x[i] = atoi(av[i+1]); } void scoot_over(int jj) { for (int k = num_y-1; k > jj; k++) y[k] = y[k-1]; } void insert(int new_y) { if (num_y = 0) { y[0] = new_y; return; } for (int j = 0; j < num_y; j++) { if (new_y < y[j]) { scoot_over(j); y[j] = new_y; return; } } } void process_data() { for (num_y = 0; num_y < num_inputs; num_y++) insert(x[num_y]); } void print_results() { for (int i = 0; i < num_inputs; i++) printf("%d\n",y[i]); } int main(int argc, char ** argv) { get_args(argc,argv); process_data(); print_results(); return 0; }
// a.cpp #include <stdio.h> #include <stdlib.h> int x[10]; int y[10]; int num_inputs; int num_y = 0; void get_args(int ac, char **av) { num_inputs = ac - 1; for (int i = 0; i < num_inputs; i++) x[i] = atoi(av[i+1]); } void scoot_over(int jj) { for (int k = num_y-1; k > jj; k++) y[k] = y[k-1]; } void insert(int new_y) { if (num_y = 0) { y[0] = new_y; return; } for (int j = 0; j < num_y; j++) { if (new_y < y[j]) { scoot_over(j); y[j] = new_y; return; } } } void process_data() { for (num_y = 0; num_y < num_inputs; num_y++) insert(x[num_y]); } void print_results() { for (int i = 0; i < num_inputs; i++) printf("%d\n",y[i]); } int main(int argc, char ** argv) { get_args(argc,argv); process_data(); print_results(); return 0; }
代码就不分析了,稍微花点时间应该就能明白。你能发现几个错误?
使用gcc编译:
gcc -g -Wall -o insert_sort a.cpp
"-g"告诉gcc在二进制文件中加入调试信息,如符号表信息,这样gdb在调试时就可以把地址和函数、变量名对应起来。在调试的时候你就可以根据变量名查看它的值、在源代码的某一行加一个断点等,这是调试的先决条件。“-Wall”是把所有的警告开关打开,这样编译时如果遇到warning就会打印出来。一般情况下建议打开所有的警告开关。
运行编译后的程序(./insert_sort),才发现程序根本停不下来。上调试器!(有些bug可能一眼就能看出来,这里使用GDB只是为了介绍相关的基本功能)
TUI模式
现在版本的GDB都支持所谓的终端用户接口模式(Terminal User Interface),就是在显示GDB命令行的同时可以显示源代码。好处是你可以随时看到当前执行到哪条语句。之所以叫TUI,应该是从GUI抄过来的。注意,可以通过ctrl + x + a来打开或关闭TUI模式。
gdb -tui ./insert_sort
死循环
进入GDB后运行run命令,传入命令行参数,也就是要排序的数组。当然,程序也是停不下来:
为了让程序停下来,我们可以发送一个中断信号(ctrl + c),GDB捕捉到该信号后会挂起被调试进程。注意,什么时候发送这个中断有点技巧,完全取决于我们的经验和程序的特点。像这个简单的程序,正常情况下几乎立刻就会执行完毕。如果感觉到延迟就说明已经发生了死循环(或其他什么),这时候发出中断肯定落在死循环的循环体中。这样我们才能通过检查上下文来找到有用信息。大型程序如果正常情况下就需要跑个几秒钟甚至几分钟,那么你至少需要等到它超时后再去中断。
此时,程序暂停在第44行(第44行还未执行),TUI模式下第44行会被高亮显示。我们知道,这一行是某个死循环体中的一部分。
因为暂停的代码有一定的随机性,可以多运行几次,看看每次停留的语句有什么不同。后面执行run命令的时候可以不用再输入命令行参数(“12 5”),GDB会记住。还有,再执行run的时候GDB会问是否重头开始执行程序,当然我们要从头开始执行。
基本确定位置后(如上面的44行),因为这个程序很小,可以单步(step)一条条语句查看。不难发现问题出在第24行,具体的步骤就省略了。
无缝切换
在编码、调试的时候,除非你有集成开发环境,一般你会需要打开三个窗口:代码编辑器(比如很多人用的VIM)、编译器(新开的窗口运行gcc或者make命令、执行程序等)、调试器。集成开发环境当然好,但某些倒闭的场合下你无法使用任何GUI工具,比如一些仅提供字符界面的嵌入式设备——你只有一个linux命令行可以使用。显然,如果在VIM中修改好代码后需要先关闭VIM才能敲入gcc的编译命令,或者调试过程中发现问题需要先关闭调试器才能重新打开VIM修改代码、编译、再重新打开调试器,那么不言而喻,这个过程太痛苦了!
好在可以通过Linux的作业管理机制,通过ctrl + z把当前任务挂起,返回终端做其他事情。通过jobs命令可以查看当前shell有哪些任务。比如,当我暂停GDB时,jobs显示我的VIM编辑器进程与GDB目前都处于挂起状态。
以下是些相关的命令,比较常用
fg %1 // 打开VIM,1是VIM对应的作业号
fg %2 // 打开GDB
bg %1 // 让VIM到后台运行
kill %1 && fg // 彻底杀死VIM进程
GDB的“在线刷新”
好了,刚才介绍了无缝切换,那我们可以在不关闭GDB的情况下(注意,ctrl + z不是关闭GDB这个进程,只是挂起)切换到VIM中去修改代码来消除死循环(把第24行的“if (num_y = 0)" 改成"if (num_y == 0)")。动作序列可以是:
ctrl + z // 挂起GDB
jobs // 查看VIM对应的作业号,假设为1
fg %1 // 进入VIM,修改代码..
ctrl + z // 修改完后挂起VIM
gcc -g -Wall -o insert_sort a.cpp // 重新编译程序
fg %2 // 进入GDB,假设GDB的作业号为2
现在,我们又返回GDB调试界面了!但在调试前还有一步,如何让GDB识别新的程序(因为程序已经重新编译)?只要再次运行run就可以了。因为GDB没有关闭,所以之前设置的断点、运行run时传入的命令行参数等还保留着,不需要重新输入。很好用吧!
GDB自动检测到程序发生改变,重新加载符号。
其他bug
关于本例中的其他bug,这里就不多说了。有兴趣的同学,可以和我讨论。