第十章 调试
所有的软件都会存在缺陷,通常每100行代码就会存在2到5个缺陷。这些错误通常会使得程序和库并不会预期的表现,通常会使得一个程序的行为并不会如预想的那样。Bug跟踪,标识以及修复会占用程序软件开发过程中的大量时间。
在这一章,我们讨论软件缺陷,并且会考虑一些工具与技术用于跟踪特定的错误行为。这不同于测试(在各种条件下验证程序行为的任务),尽管测试与调试是相关联的,而且许多bug就是在测试过程中发现的。
我们会讨论下列主题:
错误类型 通常的调试技术 使用GDB与其他工具进行调试 断言 内存使用调试
错误类型
bug通常是由下列一些原因引起的,而其中的每一个都指出一个检测与修复的方法:
规范错误:如果一个程序没有进行正确的规范,毫无疑问,这个程序并不会表现出预期的行为。即使是世界上时优秀的程序员有时也会编写出错误的程序。在我们开始编程(或是设计)之前,要保证我们清楚的知道与了解我们的程序需要做什么。我们可以通过查看需求和与使用程序的用户所达成的协议来检测与修复许多(如果不是所有)的规范错误。
设计错误:任何规模的程序都需要创建之前进行设计。通常坐在电脑前,直接输入源码,并且希望程序第一次就正确工作,这样是不够的。我们需要花些时间来考虑如何组织我们的程序,我们需要使用哪些数据结构,以及如何使用他们。试着进行详细的设计,因为这样以后就可以省去许多重新编写的痛苦。
编码错误:当然,每个人都会出输入错误。由我们的设计创建源代码的过程是一个不完美的过程。这也是许多bug滋生的地方。当我们在程序中遇到一个bug时,不要忽视简单重读源代码或是请其他人来阅读源代码从而修复bug的可能性。令人惊奇的一件事是就通守与其他人讨论实现我们可以检测并修复许多bug。
试着在纸上执行程序核心,这个过程被称之为干运行(dry running)。对于许多重要的例程,一步步写下输入的值并且计算输出。我们并不必须使用计算机进行调试,而且有时就是计算机引起的问题。即使是那些编写库,编译器,以及操作系统的人也会出错。另一方面,不要急于责备工具;很有可能是在一个新的程序中存在bug,而不是存在于编译器中。
通常的调试技术
有许多不的方法可以用来调试与测试一个通常的Linux程序。我们通常运行程序并且查看发生了什么。如果程序不能工作,我们需要决定对其做些什么。我们可以修改程序并且再次运行,我们可以尝试获得程序内部运行的更多信息,或是我们可以直接监视程序的运行。调试的五个步骤为:
测试:发现存在哪些缺陷或是bug 稳定化:使得bug重新出现 本地化:标识相关的代码行 修正:修正代码 验证:保证修正正常工作
一个带有bug的程序
下面我们来看一下带有bug的程序。在本章的讨论中,我们将会尝试对其进行调试。这个程序是在一个大型软件系统的开发过程中编写的。其目的就是测试一个函数,sort,其作用是在一个item类型的结构数组上实现一个冒泡排序算法。这些项目以其成员key升序的顺序进行排列。这个程序在一个例子数组上调用 sort进行测试。在实际的工作中我们绝不会使用这种排序算法,因为其效率实在是太低了。我们在这里使用他是因为他很短小,理解相对简单,而且很容易出错。事实上,标准C库具有一个名为qsort的函数可以实现所要求的任务。
不幸的是,代码很难阅读,没有注释,而且原始程序也不可得了。我们不得不自己与其挣扎,我们由基本的例程debug1.c开始。
/* 1 */ typedef struct { /* 2 */ char *data; /* 3 */ int key; /* 4 */ } item; /* 5 */ /* 6 */ item array[] = { /* 7 */ {“bill”, 3}, /* 8 */ {“neil”, 4}, /* 9 */ {“john”, 2}, /* 10 */ {“rick”, 5}, /* 11 */ {“alex”, 1}, /* 12 */ }; /* 13 */ /* 14 */ sort(a,n) /* 15 */ item *a; /* 16 */ { /* 17 */ int i = 0, j = 0; /* 18 */ int s = 1; /* 19 */ /* 20 */ for(; i < n && s != 0; i++) { /* 21 */ s = 0; /* 22 */ for(j = 0; j < n; j++) { /* 23 */ if(a[j].key > a[j+1].key) { /* 24 */ item t = a[j]; /* 25 */ a[j] = a[j+1]; /* 26 */ a[j+1] = t; /* 27 */ s++; /* 28 */ } /* 29 */ } /* 30 */ n--; /* 31 */ } /* 32 */ } /* 33 */ /* 34 */ main() /* 35 */ { /* 36 */ sort(array,5); /* 37 */ }
我们试着编译这个程序:
$ cc -o debug1 debug1.c
编译成功,没有错误或是警告报告。
在我们运行这个程序之前,我们需要添加一些代码来输出结果。否则,我们就不知道程序是否进行了工作。我们会添加一些额外的代码在排序结束之后显示数组。我们称这个新版本为debug2.c。
/* 34 */ main() /* 35 */ { /* 36 */ int i; /* 37 */ sort(array,5); /* 38 */ for(i = 0; i < 5; i++) /* 39 */ printf(“array[%d] = {%s, %d}\n”, /* 40 */ i, array[i].data, array[i].key); /* 41 */ } /* 34 */ main() /* 35 */ { /* 36 */ int i; /* 37 */ sort(array,5); /* 38 */ for(i = 0; i < 5; i++) /* 39 */ printf(“array[%d] = {%s, %d}\n”, /* 40 */ i, array[i].data, array[i].key); /* 41 */ }
严格来说这些额外的代码并不算是程序修正的一部分。我们添加这些代码仅是为测试。我们必须非常小心不要在我们的测试代码中引入额外的bug。现在再次编译并且运行程序。
$ cc -o debug2 debug2.c $ ./debug2
当我们这样做时发生了什么依赖于我们的Linux平台以及我们所进行的设置。在作者的系统上,我们会得到下面的输出信息:
array[0] = {john, 2} array[1] = {alex, 1} array[2] = {(null), -1} array[3] = {bill, 3} array[4] = {neil, 4}
但是在另一个作者的系统(运行一个不同的内核),我们会得到下面的信息:
Segmentation fault
在我们的Linux系统上,我们会看到其中的一个信息或是另一上不同的结果。我们希望得看到下面的信息:
array[0] = {alex, 1} array[1] = {john, 2} array[2] = {bill, 3} array[3] = {neil, 4} array[4] = {rick, 5}
很明显,在代码中存在一个严重的问题。如果这个程序可以运行,那么他就不能对数组进行正确的排序,而如果程序结束并提示内存错误,那么是系统向程序发送了一个信号表明系统已经检测到一个非法的内存访问并且提前结束了程序的运行以防止内存被破坏。
操作系统检测非法内存访问的能力依赖于其硬件的配置以及内存管理系统的精巧实现。在大多数系统上,由操作系统分配给程序的内存远大于实际正在使用的内存。如果非法内存访问发生了这块内存区域,硬件也许就不能检测非法访问。这就是为什么并不是所有的Linux版本以及Unix产生内存错误的原因。
注:一些库函数,例如printf,也会阻止某些条件下的非法访问,例如使用一个空指针。
当我们跟踪数组访问问题时,通常增加数组元素的数量是一个好主意,因为这会增加错误数。如果我们读取超过数组字节结束处一个字节,我们也许就会消耗掉这些内存,因为分配给程序的内存将会达到操作系统特定的边界,通常为8K。
如果我们增加数组元素的数量,在这个例子中可以通过修改item成员data为一个4096字符的数组来做到,对于不存在的数组元素的访问也许就会是超出已分配的内存地址。每一个数组元素为4K大小,所以我们非正常使用的内存可以为0到4K。
如果我们这样修改,并将其结果称之为debug3.c,我们就会在两个作者的Linux版本上得到内存错误的信息。
/* 2 */ char data[4096]; $ cc -o debug3 debug3.c $ ./debug3 Segmentation fault (core dumped)
也有可能某些Linux或是Unix版本仍然不会产生内存错误信息。当ANSI C标准检测到未定义行为时,他会允许程序执行任何动作。当然看上去似乎是我们编写了一个非正常的C程序,而一个非正常的C程序可以执行任何奇怪的行为。正如我们将会看到的,错误类型就落入了未定义行为的类别。 |