首页 > 代码库 > 从一个实例来认识GDB与高效调试

从一个实例来认识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;
}

代码就不分析了,稍微花点时间应该就能明白。你能发现几个错误?

使用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,这里就不多说了。有兴趣的同学,可以和我讨论。

从一个实例来认识GDB与高效调试