软件中每个重要代码段都会有缺陷,一般来说,每 100 行代码会有两到三个错误。
常用调试技巧
调试和测试 Linux 程序的方法一般是先运行程序并观察其输出结果,如果不能正常工作,我们就需要决定应该采取哪些措施。可以修改程序然后重新尝试(代码检查 - 试运行 - 出错法 ),也可以在程序中增加一些语句以获得更多关于程序内部运行情况的信息(取样法 ),还可以直接检查程序的执行情况(受控执行法 )。
当程序的运行情况和预期不同时,重新阅读程序通常是一个好办法。有些工具可以帮助你完成代码检查 工作,编译器就是其中比较明显的一个。如果程序有语法错误,它就会告诉你。
提示 :在编译程序时为了获得更多的信息可以使用 gcc -Wall -pedantic -ansi
取样法 是指在程序中添加一些代码以手机与程序运行时的行为相关的更多信息的方法。取样法的常见做法是,在程序中添加 printf 函数调用以打印出变量在程序运行的不同阶段的值。不过需要注意的是,添加额外的代码时必须要非常小心地避免引入新的漏洞。
取样法的实现有两种技巧。一种是用 C 语言的预处理器有选择的包括取样代码,这样只需重新编译程序就可以达到包含或去除调试代码的目的。实现方式很简单,只需使用下面的结构:
#ifdef DEBUG printf(“variable x has value %d/n”, x); #endif |
在编译程序时可以加上编译器标志 -DDEBUG ,这样就可以将额外的调试代码添加进来。如果更进一步可以设计得稍微复杂一些,实际上它也是使用了 C 语言的预处理功能,典型结构如下:
#define BASIC_DEBUG 1 #define EXTRA_DEBUG 2 #define SUPER_DEBUG 4
#if (DEBUG & EXTRA_DEBUG) printf(……); #endif |
这种情况下,我们必须总是定义 DEBUG 宏,但我们可以设置它为代表一组调试信息或代表一个调试级别。比如编译器标志 -DDEBUG=5 将启用 BASIC_DEBUG 和 SUPER_DEBUG ,但不包括 EXTRA_DEBUG ;标志 -DDEBUG=0 将禁止所有的调试信息。
另一种技巧则无需重新编译,它在程序中增加一个作为调试标志的全局变量,这使得用户可以在命令行上通过 -d 选项切换是否启用调试模式,即使程序已经发行了,仍然可以这样做,该方法同时还会在程序中增加一个用于记录调试信息的函数。典型结构如下:
if (debug){ sprintf(msg, …); write_debug(msg); } |
这样做法的好处是如果用户遇到了问题,他们自己就可以在运行程序时打开调试功能,替你完成诊断错误的工作;而明显的不足则是它会使程序的长度大大增加。
受控执行法 就是使用某些工具在程序运行或者源代码级别上查看程序的比较详细的状态信息。这种工具包括 adb 、 sdb 和 dbx 等。一般情况下,我们可以使用 gdb 对程序进行受控调试运行。不过,为了能够调试程序,我们需要在编译它时加上一个或者多个特殊的编译器选项,比如 -g 标志就是对程序进行调试性编译时常用的一个选项。
使用 gdb 进行调试
Gdb 是一个功能强大的调试器,它是一个自由软件,能够用在许多 UNIX 平台上。它同时也是 Linux 系统的默认调试器。关于它的详细使用信息可以参考手册,在我的博客上有几篇 文章 专门介绍了 gdb 。
其他调试工具
除了像 gdb 这样彻底的调试器外, Linux 系统一般还会提供许多能够帮助你完成调试工作的其他工作。其中有的是提供关于程序的静态信息,另外一些则是提供动态分析。
静态分析 只能通过程序的源代码提供信息。 ctags 、 cxref 和 cflow 等就是一些静态分析程序,它们可以通过源文件提供有关函数调用和函数所在位置的有用信息。
动态分析 提供的是与程序执行过程中的行为有关的信息。 prof 和 gprof 等就是一些动态分析程序,它们提供的信息包括已经执行了哪些函数以及这些函数的执行时间。
工具 lint 是 C 语言编译器的一个前端,它增加了一些常识性的测试并可以产生一些警告信息。它可以检测出未经复制的变量使用、函数的参数未使用等异常情况。
ctags 、 cxref 和 cflow 这三个工具构成了 X/Open 规范的一部分内容,因此具有软件开发能力的 UNIX 系统都会有这三个工具。
工具 ctags 为程序的所有函数创建索引。每个函数对于一个列表,在列表中累出该函数在程序中的调用位置,就像书籍的索引。
工具 cxref 分析 C 语言源代码并生成一个交叉引用表。它可以显示每个符号(变量、 #define 定义和函数)都在程序的哪个位置使用过。
工具 cflow 打印出一个函数调用树。该图示按函数之间的正向调用顺序显示了函数之间调用的关系,它可以让我们看清楚一个程序的框架结构,理解它的操作流程,理解对某个函数的改动将会产生怎样的影响。有些版本的 cflow 除了可以处理源代码外,还可以处理目标文件。
如果想查找程序的性能问题,常用的一种技巧是使用执行记录。它通常需要特殊的编译器选项和辅助程序的支持。程序的执行记录可以显示执行它所花费的时间具体都用在什么操作上了。
编译程序时,给编译器加上 -p 标志(针对 prof 工具)或 -pg 标志(针对 gprof 工具)就可以创建出 profile 程序。而工具 prof (或者 gprof )就可以根据 profile 程序运行时所产生的执行记录文件打印出一个报告。当使用了上述标志的程序运行时,监控数据将被写入当前目录下的文件 mon.out (工具 gprof 使用 gmon.out )中。详细细节请查看手册, 51cto 有 一篇文章 “使用 GNU gprof 进行 Linux 平台下的程序分析”专门讲解了如何使用 gprof 工具。
断言
经常有这样的情况,程序运行中出现的问题与不正确的假设有关但并非代码的错误。这些不正确的假设往往是被直观认为不会发生的事件,因此我们需要对系统的内部逻辑做出确认。
针对这种情况, X/Open 提供了 assert 宏,它的作用就是测试某个假设是否成立,如果不成立就立即停止程序的运行。
#include
void assert(int expression) |
assert 宏对表达式进行求值,如果结果非零,它就往标准错误写一些诊断信息,然后调用 abort 函数结束程序的运行。
头文件 assert.h 定义的宏受 NDEBUG 的影响。如果程序在处理这个头文件时已经定义了 NDEBUG 就不定义 assert 宏。这意味着,可以在编译期间使用 -DNDEBUG 关闭断言功能。
断言的常用用法以及注意事项大概有以下几个方面:
( 1 )在函数开始处检验传入参数的合法性如
int resetBufferSize(int nNewSize) { assert(nNewSize >= 0); } |
( 2 )每个 assert 只检验一个条件,因为同时检验多个条件时,如果断言失败,无法直观的判断是哪个条件失败
不建议使用 : assert(nOffset>=0 && nOffset+nSize<=m_nInfomationSize);
建议使用 : assert(nOffset >= 0);
assert(nOffset+nSize <= m_nInfomationSize);
( 3 )不能使用改变环境的语句,因为 assert 在 NDEBUG 被定义时无效。如果这么做,会使用程序在真正运行时遇到问题
错误 : assert(i++ < 100) 这是因为如果出错,比如在执行之前 i=100 ,那么这条语句就不会执行,那么 i++ 这条命令就没有执行。
正确 : assert(i < 100)
i++;
内存调试
动态内存分配时一个很容易出现持续漏洞的领域,而且漏洞一旦出现还很难查找。如果在程序中惯用 malloc 和 free 函数来分配内存,你就必须清楚自己分配过的每一块内存,并且要确定没有使用已经释放的内存块,这一点非常重要。
内存块通常都是由 malloc 函数分配给指针变量。如果指针变量的取值发生了变化,又没有其他指针指向这块内存,这块内存变得无法使用,这就是一种内存泄露现象。
如果在一个已分配的内存块尾部的后面写数据,就很可能会损坏 malloc 库用于记录内存分配情况的数据结构。出现这种情况,经过一段时间,一个 malloc 调用,甚至是一个 free 调用都会引发段错误并导致程序崩溃。
实际上,目前已有工具可以帮助解决这两类问题。 ElectricFence 函数库 可以用 Linux 的虚拟内存机制来保护 malloc 和 free 所使用的内存,当它发现内存被破坏时就通知程序的运行。 ElectricFence 会将 malloc 及其管理函数替换为适应计算机处理器虚拟内存机制的版本,从而保护系统不受非法内存访问的破坏。
Valgrind 工具 可以检测出前面提出的许多问题,特别是它可以检测出数组访问错误和内存泄露。程序不需要重新编译就可以直接使用该工具,甚至还可以用它来调试一个正在运行程序的内存访问情况。