10.3 使用gdb进行调试

我们将使用GNU的调试器gdb调试这个程序。gdb是一个功能很强大的调试器,它是一个自由软件,能够用在许多UNIX平台上。它同时也是Linux系统中的默认调试器。gdb已被移植到许多其他的计算机平台上,并且能够用于调试嵌入式实时系统。

10.3.1 启动gdb

现在,对我们的示例程序进行调试性编译并启动gdb,如下所示:

10.3 使用gdb进行调试 - 图1

gdb有详细的在线帮助,它的完整使用手册由一组文件构成,可以通过info程序或Emacs程序查阅。如下所示:

10.3 使用gdb进行调试 - 图2

gdb本身是一个基于文本的应用程序,但它为一些重复性的任务准备了一些快捷键。gdb的许多版本都具备带历史记录的命令行编辑功能,用户可以(尝试用方向键)回卷并再次执行以前输入过的命令。它的所有版本都支持“空命令”,即直接按下回车键再次执行最近执行过的那条命令。在用step或next命令单步执行程序时,这个“空命令”非常有用。

要退出gdb,使用quit命令即可。

10.3.2 运行一个程序

我们可以用run命令来执行这个程序。在run命令中给出的所有参数都将作为程序的参数传递给程序。在本例中,我们的程序无需任何参数。

在这里,我们假设你的系统和本书两位作者的一样,都产生了段错误。如果情况并非如此,请继续往下看。如果在编写自己的程序时遇到了段错误,在学完本章后就应该知道如何解决它了。如果没有遇到过段错误,但还想在阅读本书时继续使用这个示例程序,你可以直接跳到debug4.c程序,到那时我们已把这个程序的第一个内存访问错误修复好了。

10.3 使用gdb进行调试 - 图3

与前面一样,这个程序运行不正确。程序运行失败时,gdb会报告出失败的原因及位置。现在我们即可根据这些调查这个问题的根本原因。

根据你的操作系统内核、C函数库和编译器版本的具体情况,你可能会看到程序的错误发生在一个稍微不同的地点,比如发生在交换数组元素的第25行而不是发生在比较数组元素成员key的第23行。如果你是属于这种情况,则应该看到如下所示的输出结果:

10.3 使用gdb进行调试 - 图4

不管你是否属于这种情况,你都可以沿着我们的gdb样本示例继续学习。

10.3.3 栈跟踪

程序停止在源文件debug3.c的第23行,该行位于sort函数中。如果我们在编译程序时没有添加调试信息(cc -g),就无法看到程序失败时所停的位置,也无法用变量名来检查数据。

我们可以用backtrace命令来查出程序是如何到达这一位置的,如下所示:

10.3 使用gdb进行调试 - 图5

这是一个非常简单的程序,因为我们并未在其他的函数中调用很多函数,所以跟踪信息也很少。你可以看到,sort函数是由同一个文件中的main函数在第37行调用的。通常在实际工作中遇到的问题要复杂得多,backtrace命令将帮助我们找到程序到达错误地点的路径。当调试的函数可能会从许多不同的地方被调用时,这个命令将非常有用。

backtrace命令可以简写为bt,为了与其他调试器兼容,gdb还有一个命令where用来完成相同的功能。

10.3.4 检查变量

gdb在停止程序时给出的信息以及从跟踪栈得到的信息可以让我们看到函数参数的取值。

sort函数被调用时有一个参数a,它的取值是0x804a040。这是数组的地址,在不同的系统中这个值通常是不一样的,这要视用户使用的编译器和操作系统而定。

错误出现在第23行,该行对数组的两个元素进行比较,如下所示:

10.3 使用gdb进行调试 - 图6

我们可以用调试器检查函数参数、局部变量和全局数据的内容。print命令的作用就是给出变量和其他表达式的内容,如下所示:

10.3 使用gdb进行调试 - 图7

我们看到局部变量j的值是4。gdb会用伪变量来保存类似这样的输出值以备后用。这里就将值4赋给了伪变量$1,后续的命令将把它们的输出结果依次保存到$2、$3等中去。

局部变量j的值是4意味着程序尝试执行的是这样一条命令:

10.3 使用gdb进行调试 - 图8

我们传递给sort函数的数组array只有5个元素,它们的下标从0~4。因此,这条语句读的是一个不存在的数组元素array [5]。循环计数器变量j取了一个错误的值。

如果执行这个示例程序时,程序停在了第25行,就说明系统是在准备交换数组元素时才检测到读数组越界错误的,第25行执行的语句是:

10.3 使用gdb进行调试 - 图9

当j取值为4时,真正执行的是这样一条语句:

10.3 使用gdb进行调试 - 图10

我们可以用print命令的表达式来查看处理过的数组元素。gdb允许我们使用几乎所有合法的C语言表达式来打印变量、数组元素和指针的取值。

10.3 使用gdb进行调试 - 图11

gdb将命令的结果保存在伪变量$<number>中。最后一次操作的结果总是为$,倒数第二次操作的结果为$$。这使得我们可以把某次操作的结果用在另一个命令中。例如:

10.3 使用gdb进行调试 - 图12

10.3.5 列出程序源代码

我们可以直接在gdb里用list命令列出程序的源代码。这个命令会打印出围绕当前位置前后的一段代码,如果继续使用list命令,将显示更多的代码。你也可以给list命令提供一个行号或函数名作为参数,它将显示指定位置前后的代码。

10.3 使用gdb进行调试 - 图13

10.3 使用gdb进行调试 - 图14

我们可以看到在第22行,循环被设置为在变量j小于n时继续执行。而在本例中,n等于5,所以变量j的最大取值为4。当j取值为4时,参加比较的数组元素分别为a[4]和a[5]。对这一特定问题的一种解决方法是,将终止循环的条件改正为j < n-1。

我们对程序做出修改,将新的程序命名为debug4.c,重新编译并运行它:

10.3 使用gdb进行调试 - 图15

程序的运行仍然不正常,因为它输出的是错误的排序列表。下面我们用gdb对程序的运行做单步调试。

10.3.6 设置断点

为了找出程序失败的位置,我们需要能够查看程序在运行时所做的事情。我们可以通过设置断点在任一位置停止程序的运行。这将中断程序的运行并将控制权返回给调试器。然后我们即可对变量进行检查并让程序从断点位置继续执行。

在sort函数中有两个循环。外层循环针对每个数组元素执行一次,它的循环计数变量是i。内层循环的作用是交换相邻的两个元素。总的效果是让比较小的元素像“气泡”一样“冒”到数组的顶部。外层循环每执行一次,数组中最大的元素就会“下沉”到数组的底部。我们可以通过在外层循环中停止程序的运行并检查数组的状态来核实这一点。

有许多命令可以用来设置断点。用gdb的help breakpoint命令可以列出这些命令,如下所示:

10.3 使用gdb进行调试 - 图16

10.3 使用gdb进行调试 - 图17

在第21行设置一个断点,然后运行这个程序,如下所示:

10.3 使用gdb进行调试 - 图18

我们可以打印出数组元素的值,然后用cont命令继续执行程序。程序会一直运行直到它遇到下一个断点,在本例中就是它再次执行到第21行的时候。在同一时间程序中可以存在许多个断点。

10.3 使用gdb进行调试 - 图19

要想打印出一组连续的数据项,我们可以使用@<number>让gdb打印出指定数目的数组元素。如果要把数组中的所有元素都打印出来,使用的命令如下所示:

10.3 使用gdb进行调试 - 图20

注意:我们对输出结果做了些整理,让它们更容易阅读。因为这是第一次进入循环,所以数组未发生变化。继续执行程序,随着程序的进展,我们将看到数组array的后续变化:

10.3 使用gdb进行调试 - 图21

10.3 使用gdb进行调试 - 图22

我们可以用display命令告诉gdb,在每次程序停在断点位置时自动显示数组的内容,如下所示:

10.3 使用gdb进行调试 - 图23

此外,我们可以修改断点设置,使程序不是在断点处停下来,而只是显示要查看的数据,然后继续执行。我们用commands命令来完成这一工作。它的作用是指定在程序到达断点位置时需要执行的调试器命令。因为我们已设置了display命令,所以只需设置断点命令为继续执行即可。如下所示:

10.3 使用gdb进行调试 - 图24

现在,当程序继续执行时,它将一直执行到结束,外层循环每次执行都会打印出数组的内容,如下所示:

10.3 使用gdb进行调试 - 图25

gdb报告这个程序在退出时带有一个不常见的退出码,这是因为程序本身未调用exit函数,并且也没有从main函数返回一个值。本例中的退出码没有实际意义,只有exit函数才会提供有意义的退出码。

看上去程序执行外部循环的次数少于预期值。我们可以看到,循环终止条件中使用的参数n的值在每次到达断点时都在减少。这意味着循环不会执行足够的次数。问题出在程序的第30行,该行对变量n做了减法操作,如下所示:

10.3 使用gdb进行调试 - 图26

上面这行语句是出于优化程序的考虑,每次外部循环结束时,数组array中最大的元素将被放到数组的最底部,所以下一次执行外部循环时就没有必要考虑数组的最后一个元素了。但是,正如我们所看到的,这个优化措施影响了外部循环并引发了问题。针对这一问题的最简单的解决方法(当然还有其他方法)就是删除引起问题的一行。下面我们就通过用调试器打上补丁的方法来解决,看看是否能成功。

10.3.7 用调试器打补丁

我们已经看到,我们可以通过调试器设置断点和查看变量的取值。通过将断点的设置与相应的操作结合起来,就可以尝试修改程序(也被称为打补丁)而不需要改变程序的源代码并重新编译。在本例中,我们需要在程序的第30行中断程序,增加变量n的值,这样,程序执行到第30行时,n的值并未发生变化。

重新开始执行这个程序。首先,必须删除刚才设置的断点和display命令的内容。我们可以用info命令查看曾经设置过的断点及display命令的内容,如下所示:

10.3 使用gdb进行调试 - 图27

我们可以禁用这些设置,也可以将其全部删除。如果禁用它们,我们就可以在今后必要的时候重新启用这些保留的设置,如下所示:

10.3 使用gdb进行调试 - 图28

10.3 使用gdb进行调试 - 图29

程序一直运行到结束并给出了正确的结果。我们现在即可对源代码进行修改并用更多的数据对它进行测试了。

10.3.8 深入学习gdb

GNU调试器是一个功能非常强大的工具,它可以为我们提供许多与执行中的程序的内部状态有关的信息。在支持硬件断点功能的系统上,可以用gdb实时监控变量取值的变化情况。硬件断点是某些CPU提供的功能,这些处理器可以在触发某个特定条件(一般为对某个给定区域的内存访问操作)时自动停止运行。此外,gdb还可以监控表达式,即当某个表达式取了一个特定值时,gdb可以暂停程序的运行,而不管表达式的计算发生在程序中的位置,但这样做会对系统的性能有所影响。

断点可以和计数、条件结合在一起设置,只有在经过了指定的次数或满足某个条件时才触发断点。

gdb还可以将其自身附在已经运行的程序上。这对调试客户/服务器系统很有帮助,因为你可以在异常服务器正在运行时对其进行调试,而不必先停止它,然后再重启它。你可以在编译程序时用如gcc -o -g这样的命令来同时获得程序优化和调试信息的好处。但这样做的缺点是,优化可能会对程序代码的先后顺序进行调整,因此,在对代码进行单步调试时,你可能会发现你要在代码中跳来跳去以达到与原来的源代码同样的效果。

我们还可以用gdb来调试已经崩溃的程序。程序运行失败时,Linux和UNIX系统通常会产生一个核心转储(core dump),并将它保存在core文件中。这个文件其实是程序的内存映像文件,它包含程序在运行失败的那个时刻的全局变量的取值。你可以用gdb找出程序发生崩溃的位置。详细的资料请查阅gdb的手册页。

gdb遵守GPL的条款,大多数UNIX系统都支持它。我们强烈建议读者掌握这一工具。