发布时间:2022-07-05 文章分类:编程知识 投稿人:王小丽 字号: 默认 | | 超大 打印

使用GDB和Valgrind调试C程序

http://blog.chinaunix.net/u/4329/showart.php?id=1272512

Linux程序员一直以来都依赖于一小组基本调试工具集,许多更复杂的调试工具都是基于这些基本工具建立的。GNU调试器(gdb)是迄今为止用于跟踪和调试应用程序的最流行的调试工具,但如果你想做比仅仅插入几个断点并使用一些测试数据以监视应用程序运行状况更多的事情,它并不是你唯一的选择。例如,近年来出现的一些系统分析工具(如systemtap和frysk)就将为应用程序的性能增强提供帮助。

10.2.1 GNU调试器

GDB是如今最广为人知的著名的自由和开放源码软件之一。它被大量GNU软件项目以及众多与GNU没有关联但却希望能有一个高质量调试器的第三方软件所使用。事实上,许多第三方工具合并gdb并将其作为它们的调试功能的基础,即便它们在gdb之上建立了各级图形化抽象。你很可能已遇到过GDB——但可能根本没有意识到这一点。

GDB建立在任何调试器都有两个组成部分这一基本概念之上。首先,GDB的底层处理单独进程或线程的启动和关闭、跟踪代码执行以及在运行代码中插入和删除断点。GDB支持大量不同的平台和机制以在各种架构上实现这些(看似简单的)操作。其具体的功能可能会受底层硬件功能的影响而偶尔有所变动。

GDB甚至支持连接到Abatron BDI2000等硬件调试设备,它是一个专用的CPU级调试器,它通过停止和启动CPU自身可以用于远程调试运行在另一个系统上的Linux内核。与采用其他一些拙劣的调试器设计技术重新实现一个调试器相比,GDB更受欢迎。如果你是一个嵌入式开发者,你可能会遇到这种用法。

基于调试应用程序所必需的底层功能之上的是对可被程序员以有效方式使用的更高层接口的需求。GDB提供了一个带有一套标准命令的接口,而不管它在何种硬件之上运行。这可能是它为什么这么流行的原因之一——你只需要学习一次核心命令即可。

1. 为GDB的使用编译代码

GNU调试器以一种看起来好像很简单的命令行工具gdb的形式呈现。它期望以一个程序名作为它的一个参数或在执行之前被要求装载一个程序。然后它将启动调试器环境并等待指令。在你明确地告诉gdb在调试器环境中开始程序的执行之前,程序将不会开始执行。

作为示例,我们创建一个简单的Hello World程序用于gdb的使用:

使用GDB和Valgrind调试C程序

使用GDB和Valgrind调试C程序

这个示例代码使用了一个函数来显示欢迎信息,因为这将导致主程序至少进行一次函数调用。当使用调试器时,你将注意到一个新的堆栈帧被创建并可以看到函数调用的效果,这要比仅仅使用一个标准的测试程序看到更多的东西——关键是,在示例中使用函数调用是有原因的,因为你将在后面对GDB的功能进行测试时利用到这一点。

你可以使用GCC来编译这个示例程序:

使用GDB和Valgrind调试C程序

请注意在GCC命令行中指定的-g标记。它告诉GCC必须在最终产生的可执行文件中添加额外的调试符号和源代码级信息,与使用普通二进制文件相比,GDB可以为前者提供更详细的信息。GDB的确需要这些额外信息(它们通常从Linux二进制文件中剥离以节省空间)以使其能够提供源文件行号和其他相关信息。如果你在尝试编译下面的示例时不在最终可执行文件中加入调试信息,你将很快看到它们在输出上的差异。

除了-g标记以外,你可能还想在一些平台(特别是那些因性能原因而对机器代码指令调度进行优化的平台)上提供其他的GCC命令标记。带有精简指令集的现代非x86处理器就属于这一情况,因为现在的趋势是在编译器中进行优化而不是对机器指令集进行复杂化。这意味着在实践中关闭指令的重新调度将导致有意义的GDB输出。请参考前面第2章中对GCC命令行标记的说明。

2. 启动GDB

一旦应用程序使用正确的命令标记进行了编译,就可以通过调用gdb命令将hello程序装载到GDB中,如下所示:

使用GDB和Valgrind调试C程序

使用GDB和Valgrind调试C程序

GDB将hello ELF二进制文件装载到内存中并为程序的运行设置一个环境,然后它将同时在程序的二进制文件和连接进程序的任何库文件中查找有用的符号。run命令用于正常运行程序:

使用GDB和Valgrind调试C程序

一切都很好,但仅仅可以从头到尾运行一个程序并不是特别有用。这时就是GDB的强大命令集发挥作用的时候了。其中一部分命令我们将在下面单独讨论,你还可以通过GDB内置的帮助系统以及现有的系统文档(和GDB专著)找到完整的命令集以及它们的使用说明。GDB的帮助系统将命令集分成如下几类:

使用GDB和Valgrind调试C程序

例如,如果你想获得控制程序执行的命令列表,可以在提示符中输入help running。请花一些时间浏览GDB中可以使用的命令集,这样你就不用在今后需要使用的时候频繁地查找命令名称了。

l 设置断点

仅仅用GDB来运行程序并不是特别令人兴奋,除非你还可以在程序运行时控制程序的执行,此时就是break命令发挥作用的时候了。通过在运行时将一个断点插入程序,你可以在程序指令到达一个特定点时让GDB停止程序的执行。在程序执行开始时(即在main函数的入口)设置这样的一个断点是很常见的(甚至是惯例):

使用GDB和Valgrind调试C程序

你还可以使用break命令的简写形式:

使用GDB和Valgrind调试C程序

它将达到同样的效果。

然后使用run命令运行程序:

使用GDB和Valgrind调试C程序

请注意GDB是如何在指定的断点处暂停程序的执行的,然后将由你来决定是继续运行还是执行另一个操作。你可以使用step和next命令继续向前执行程序。step命令将继续执行程序直至到达程序源代码的新的一行,而stepi命令只向前执行一条机器指令(对处理器来说,源代码中的一行语句可能意味着许多条机器代码指令)。

next命令和nexti变体的行为与上面的两个命令类似,但它们在遇到函数调用时不会进入函数内部——这样你在调试代码时就不需要担心函数调用了。这个命令非常有用,因为C函数库很可能并不具备和你正在调试的应用程序一样级别的调试信息。因此,跟踪进入标准C库函数可能并没有什么意义,即使你有兴趣想了解这些函数实现的更多信息。

请注意,从技术上来说,在main函数的入口插入一个断点并不会在程序最开始处停止程序的执行,因为运行在Linux系统中的所有基于C语言的程序都会在运行时利用GNU C函数库来为调用main函数做好安排。因此,在到达断点时,已有许多额外的库函数执行了大量设置工作。

3. 显示数据

可以在GDB中使用print命令查询程序中存储的数据,例如,可以显示通过run命令传递给示例程序的可选参数。传递给run命令的参数将作为argv参数列表传递给被调用程序。下面显示了当我们使用添加的参数调用一个简单程序时,GDB产生的输出:

使用GDB和Valgrind调试C程序

你可以看到程序的参数列表,在argv列表的最后是NULL字符。示例程序是否使用这个参数列表并没有关系——它一直存在并且你可以调用GDB的print命令来返回它的值,如上面的输出所示。

4. Backtrace

GDB提供了许多有用的堆栈帧管理命令,并且包括了几个专门用于查看程序是如何到达它目前所在的位置的命令。其中最有用的一个命令是backtrace(简写为bt),它可以用于查看程序运行到当前位置之前所有的堆栈帧情况。下面是一个由backtrace命令提供信息的示例:

使用GDB和Valgrind调试C程序

在上面的GDB会话中,可以明显地看到程序进入print_hello函数并如何导致由bt(backtrace)命令列出的两个堆栈帧的产生。第一个(也是最里层的一个)是当前堆栈帧,它由print_hello函数使用,而外层堆栈帧被全局函数main用来保存它在调用print_hello之前的局部变量。

5. 一个有错误的示例

到目前为止,你已看到了一些在GDB环境中使用命令的示例,但你还没有试图调试一个包含实际错误的真正程序!下面的示例将向你显示如何使用GDB来完成日常的调试任务——定位一个错误的指针引用、在程序崩溃后进行回溯(backtrace)以及其他相关的动作。

你可以通过下面的简单的示例程序开始GDB的调试之旅。在源文件buggy.c中的代码定义了一个linked_list结构并使用头优先链表插入算法分配了10个元素。但不幸的是,链表中的data元素被分配到未分配的内存中(没有使用预分配的内存调用strncpy)。更糟的是,程序没有释放已分配的内存——我们将在本节后面介绍更多有关检测内存泄漏的内容。

下面是一个简单的有错误的程序:

使用GDB和Valgrind调试C程序

你可以使用GCC编译并运行这个程序,如下所示:

使用GDB和Valgrind调试C程序

正如你看到的,程序因一个错误而不幸崩溃了,这意味着它试图访问已分配数据存储区之外的内存。通常情况下,出现这种情况是因为一个指向非法位置而不是合法内存位置的指针被解引用(通常没有很简单的方法来知道存储在某个内存位置中的数字是否是一个真正的指针,知道时可能为时已晚)。在本例中,出现错误的原因是因为代码试图将数据写入一个内存还没有被分配的字符串。

你可以将这个有错误的程序装载到GDB中,然后再次运行它:

使用GDB和Valgrind调试C程序

这次,程序的崩溃被GDB检测到了,并且它已准备好对程序进行调试。你现在可以使用bt(backtrace)命令找出当程序由于一个错误的内存解引用而接收到一个致命信号时程序到底做了些什么。简写的bt命令可以加快你的输入:

使用GDB和Valgrind调试C程序

根据上面的backtrace命令的输出,我们可以看出buggy源程序的第26行代码导致对C库函数strncpy的调用,此时程序崩溃了。很明显,我们需要查看源程序中的那一行代码以找出问题所在:

使用GDB和Valgrind调试C程序

这行代码将test_string拷贝到链表元素tmp的data成员中,但data成员事先并没有被初始化,所以一个随机的内存位置被用来存放该字符串。这类的内存误用将很快导致一个预料中的而且可能不可避免的程序崩溃。我们所需要做的只是在strncpy之前进行一次简单的malloc调用(或使用一个调用malloc的字符串拷贝函数)。在本例的情况中,你只需设置data成员指向静态字符串test_string就可以避免任何字符串拷贝操作。

要解决这个错误,你可以将示例源代码中的strncpy调用替换为:

使用GDB和Valgrind调试C程序

这个程序现在可以正常编译和运行了:

使用GDB和Valgrind调试C程序

6. 调试核心转储文件

传统上,UNIX和类UNIX系统在程序崩溃时会进行核心转储或提供一个程序状态的二进制输出。如今,很多Linux发行版关闭了普通用户的核心转储文件创建功能以节省磁盘空间(让这些普通用户可能一无所知的核心转储文件散乱的分布在磁盘中既浪费空间也让用户感到不安)。正常情况下,一个特定的Linux发行版将使用ulimit(用户限制)命令来控制核心转储文件的创建。

你可以使用ulimit命令来查看当前用户限制:

使用GDB和Valgrind调试C程序

正如你看到的那样,核心转储文件的大小被设置为0(禁用)。可以通过给ulimit命令的-c可选标记传递一个新值来重置这个值。它应该被设置为以磁盘数据块为单位的最大核心转储文件大小。下面就是一个重置核心转储文件大小来为上面的示例程序服务的示例(为简洁起见,对输出进行了精简):

使用GDB和Valgrind调试C程序

请注意,在你的Linux发行版上,这样做可能还不足以重置核心转储文件的大小,这要取决于你的Linux系统的具体配置情况。

当设置了适当的核心转储文件大小之后,重新运行示例程序将导致发生一个真正的核心转储:

使用GDB和Valgrind调试C程序

你将在当前目录下看到一个新的核心转储文件:

使用GDB和Valgrind调试C程序

值得注意的是,在这个特定Linux发行版的情况下,核心转储文件的文件名包含了运行原程序的进程号。这个可选的特征可能并没有在你的Linux发行版所提供的内核配置中启用。

GDB可以读取核心转储文件并基于该文件开始一个调试会话。由于核心转储文件是由一个不再运行的程序产生的,所以并不是所有的gdb命令都可以使用——例如,试图在一个不再运行的程序中执行步进操作是没有意义的。但核心转储文件还是非常有用的,因为你可以以离线方式调试它们,所以你的用户可以将核心转储文件以及他们的本地机器环境通过电子邮件发送给你[⑥],以方便你进行远程调试。

你可以针对示例核心转储文件运行GDB:

使用GDB和Valgrind调试C程序

然后,你可以使用我们前面介绍的相同的方法继续调试会话。只需要记住程序流控制命令不能被使用——因为程序早已停止运行。

10.2.2 Valgrind

Valgrind是一个运行时诊断工具,它可以监视一个指定程序的活动并通知你在你的代码中可能存在的各种各样的内存管理问题。它类似于老式的Electric Fence工具(该工具将标准的内存分配函数替换为自己的函数以提高诊断能力),但被认为更容易使用并且在多个方面都提供了更丰富的功能——而且现在大多数主流Linux发行版都提供了该工具,所以在你的系统中使用它不需要花费太多时间,你只需安装它的软件包即可。

一个典型的Valgrind运行可能如下所示:

使用GDB和Valgrind调试C程序

使用GDB和Valgrind调试C程序

输出显示有80个字节的内存在程序结束时丢失了。通过指定leak-check选项,我们可以找到这个泄漏的内存来自哪里:

使用GDB和Valgrind调试C程序

你应该养成习惯在可能的情况下使用诸如Valgrind这样的工具来对发现和修复内存泄漏以及其他编程错误的过程进行自动化。因为这里只对Valgrind进行了肤浅的介绍,所以你需要查看它的在线文档以更全面的了解其功能。事实上,越来越多的开放源码项目都依赖于Valgrind作为其回归测试(任何一个具有相当规模的软件项目的一个重要组成部分)的一部分。

自动化代码分析

有越来越多的第三方工具可以用于执行自动化代码分析,寻找软件中各种典型类型的缺陷。这类代码覆盖工具一般提供静态、动态或混合形式的代码分析。这意味着工具可能只是检查源代码以确定潜在的缺陷,或它可能试图钩入其他一些进程,以获取确定软件中缺陷可能存在位置所必需的数据。

基于斯坦福大学的checker的商业代码分析工具Coverity经常被用在Linux系统中。它钩入编译过程并提取大量有用的信息,这些信息可用于发现很多潜在的问题。事实上,Coverity为越来越多的开放源码项目提供免费代码分析。它甚至还发现了Linux内核中相当多的以前未被发现的错误。这些问题被发现后立即得到了解决。

静态代码分析的一个比较有趣的用途是查找源代码中是否有非法使用GPL代码的情况。Blackduck软件就提供了这样一个工具,它可以帮助你扫描你的大型软件项目,以查找借用自开放源码项目的源代码,并确定处理方法。这对兼容性测试以及其他的你的法律团队可能会提醒你进行的活动将非常有用。