2.3 程序的调试
对于计算机系统来说,在屏幕或者其他设备上呈现内容、送出数据,这称为输出;通过键盘、鼠标或者其他设备得到内容和数据,这称为输入。但是,C语言并没有输入输出的能力,这并不是该语言的组成部分。
要输入或者输出内容,需要借助于操作系统,由它代理,但这并不是一件简单的事情。在最终实现这一功能之前,我们将通过另一种方法来观察程序的计算结果,那就是用调试器来调试一个程序。调试器是一个软件程序,它允许我们以各种方式控制程序的执行,例如单步执行每一条语句,并随时观察执行结果。对于有经验的程序员来说,调试器是必不可少的工具,因为我们很少会写出完全正确的程序,即使它非常简单。在这种情况下,就需要通过调试器来查找产生错误的原因。
在本书中,我们推荐的调试器是GDB,和GCC一样也是GNU开源项目的一部分。它的功能十分强大,但在这里无法一一展示,只能小试牛刀。下面以Windows平台为例,简单地演示一下调试一个可执行文件的过程。
首先,我们要用GCC来翻译c0201.c,翻译的时候使用选项“-g”,它的目的是向翻译后的可执行程序中添加包括源代码、符号表在内的调试信息,这些额外的内容将有助于GDB更好地完成调试工作:
D:\exampls>gcc c0201.c -o c0201.exe -g
这里,以正常字体显示的内容是Windows命令行的固有部分,通常为提示符或者调试器的输出内容;以粗体显示的部分是我们输入的命令,下同。
接下来是启动gdb并调试刚生成的程序c0201.exe:
D:\exampls>gdb c0201.exe -silent
Reading symbols from c0201.exe...done.
选项-silent用于屏蔽gdb的前导信息,否则它会先在屏幕上打印一堆免责条款。启动gdb后,它输出的信息表明已经读入了c0201.exe的符号表。接下来,gdb会显示自己的提示符“(gdb)”,提示并等待你输入调试命令。
调试一个程序的时候,应该在我们关注的地方,或者在故障点的前边设置一个断点,让程序执行到这里停下来,这样我们就可以慢慢地用别的调试命令进行观察。在gdb中,设置断点的方法很多,包括在指定的内存地址处设置断点、在源代码的某一行设置断点、或者在某个函数的入口处设置断点,等等。设置断点的命令是“b”或者“break”,在这里我们是将main函数的入口处作为断点:
(gdb)b main
Breakpoint 1 at 0x40155d: file c0201.c, line 5.
b命令在执行后返回了断点的具体信息,也就是说,断点(main函数的入口位置)的内存地址为0x40155d,对应于源文件的第5行(也就是说,main函数位于源文件的第5行)。因此,如果我们用内存地址的方式来设置这个断点,则可以是
b * 0x40155d
星号“*”意味着是以内存地址作为断点的。或者,如果用源代码行的形式设置这个断点,则可以是
b 5
一旦设置了断点,下一步就是用“r”或者“run”命令执行被调试的程序,执行后会自动在第一个断点处停下来:
(gdb)r Starting program: D:\exampls\c0201.exe [New Thread 1500.0x1e34] [New Thread 1500.0x2fb8] Thread 1 hit Breakpoint 1, main()at c0201.c:5 5 n = 1;
在运行了被调试的程序后,GDB的输出信息显示程序已经启动,下一个将要执行的语句是第5行的“n = 1;”。
注意,这条语句并没有执行,而仅仅是告诉你,再继续执行程序的话,执行的语句会是它。
在当前位置,变量n和sum已经分配,但并没有开始赋值。此时,这两个变量的值会是多少呢?我们可以使用“p”或者“print”命令来分别显示:
(gdb)p n
$1 = 16
(gdb)p sum
$2 = 11671024
GDB的p命令用于打印一个表达式的值,在这里是表达式n和sum。GDB先计算表达式的值,并把它保存在一个存储区中,存储区的名字用“$”外加数字来表示,并且这个数字会随着调试过程的进行而不断递增(这意味着存储区也是不断开辟的)。以上,第一个p命令执行后,GDB的回应是$1 = 16,意思是表达式n的值保存在$1中,其内容为16。
注意,在你的计算机上,变量n和sum的当前值可能和这里显示的不同。这很好理解,内存是反复使用的,当一个程序终止后,它占用的内存会分配给其他程序使用;当一个变量不再使用后,它占用的内存也会重新分配,并成为另一个变量。因为变量n和sum刚刚分配,还没有往里面保存任何数值,故它们的内容是随机的,是其他程序或者变量用过的垃圾值。
顺便说一下,既然$1是GDB用于保存计算结果的内部存储区的名字,那么我们也可以用p命令来打印它:
(gdb)p $1
$3 = 16
下面,我们将通过单步执行程序,来看一看变量n和sum赋值后的值。调试命令“n”或者“next”用于继续执行源文件中的下一行。
(gdb)n
6 sum = 0;
执行“n”命令后,实际执行的是第5行“n = 1;”,GDB显示下一个即将执行的源代码行,也就是第6行的“sum = 0;”。
因为此时已经往变量n写入了1,所以我们可继续用p命令来观察它现在的存储值:
(gdb)p n
$4 = 1
显然,经赋值后,变量n的值已经变成1。
继续执行下一条语句,实际执行的是第6行“sum = 0;”。执行后,GDB停下并显示下一条即将执行的源代码行,也即第8行的“while(n<= 100)”,第7行为空行,所以直接跳过了:
(gdb)n
8 while(n <= 100)
刚才执行的语句是往变量sum保存数值0,故我们可以再次用p命令来观察变量sum现在的存储值,可发现它已经变成0:
(gdb)p sum
$5 = 0
继续用n命令执行下一个源代码行,则将计算while语句的控制表达式,并根据该表达式的值决定是否进入循环体,执行后GDB显示下一条即将执行的源代码行是第10行:
(gdb)n
10 sum = sum + n;
进入循环体之后,我们想再看看变量n和sum的当前值。但这次使用p命令的方法不一样,这次是用花括号将表达式n和sum围住以形成一个集合。GDB允许用这种方式来一次性地打印多个表达式的值:
(gdb)p {n, sum}
$6 = {1, 0}
显然,变量n和sum此时的值依然分别为1和0。继续用n命令执行第10行,执行后GDB停留在即将执行的第11行:
(gdb)n
11 n = n + 1;
注意,第10行已经执行完毕,但第11行还没有执行。猜猜看,变量n和sum此时的值是多少?猜测之后,用p命令看看结果是否如你所想:
(gdb)p {n, sum}
$7 = {1, 1}
继续用n命令执行下一个源代码行,这将执行第11行的“n = n + 1;”,执行后控制又回到了循环的起始处,也即第8行:
(gdb)n
8 while(n <= 100)
此时,变量n和sum的值各自会是多少?使用p命令打印一下就知道了:
(gdb)p {n, sum}
$8 = {2, 1}
因为现在处于一个循环体内,如果继续用n命令往下执行,则其过程与前面相比大同小异。前面已经循环过一次,本次循环完整的调试过程如下:
(gdb)n 10 sum = sum + n; (gdb)n 11 n = n + 1; (gdb)n 8 while(n <= 100) (gdb)p {n, sum} $9 = {3, 3}
显然,第二次循环过后,变量n的值为3,变量sum的值也是3。你可能已经发现了,我们现在进退维谷:如果继续用n命令执行,则将陷入循环,直到变量n的值等于101。
好在这也算不上什么大的问题,我们可以在循环语句的后面设置断点,然后命令程序一直执行,直至到达这个断点。为了搞清楚while语句的下一条语句的行号,我们需要列出源文件的内容,这需要使用“l”或者“list”命令:
(gdb)l
3 unsigned long long int n, sum;
4
5 n = 1;
6 sum = 0;
7
8 while(n <= 100)
9 {
10 sum = sum + n;
11 n = n + 1;
12 }
l命令默认每次显示10行源代码,但我们关心的那一行显然还没有出来。为此,可继续使用l命令来显示后面的行:
(gdb)l
13
14 return 0;
15 }
好了,我们已经知道while语句之后是return语句,它的行号是14,现在就可以用b命令设置一个新的断点:
(gdb)b 14
Breakpoint 2 at 0x401583: file c0201.c, line 14.
现在,可以用一个新的命令“c”或者“continue”来持续执行程序,直至遇到断点或者程序结束。因为已经设置断点,故程序将持续执行,在第14行处停下:
(gdb)c
Continuing.
Thread 1 hit Breakpoint 2, main()at c0201.c:14
14 return 0;
非常好,既然已经退出了while循环,说明累加过程已经成功结束,变量sum的值就是累加结果。我们来看看它到底是多少:
(gdb)p {n, sum}
$10 = {101, 5050}
显然,变量n的当前值是101,变量sum的当前值是5050,和高斯同学的结果一模一样。
本次调试即将结束,我们可以先用c命令让程序“跑完全程”,然后再用“q”或者“quit”结束本次调试工作,这将使得调试器GDB结束运行并返回到操作系统:
(gdb)c
Continuing.
[Inferior 1(process 1500)exited normally]
(gdb)q
D:\exampls>
即使是对于一个经验非常丰富的程序员来说,在编写程序的时候也避免不了出错。程序中的语法错误通常可以在翻译阶段就能被诊断出来,但逻辑错误却很难被发现和纠正,比如在解决问题时使用了错误或者不完备的方法。在这种情况下,调试器可能是唯一的救命稻草。通过设置适当的断点,你可以观察结果并和预期的结果进行比较以缩小问题代码的范围,并最终发现问题所在。
GDB是非常强大的工具,它的用法可以写一本厚厚的书,上述调试过程虽然只能说是蜻蜓点水、走马观花,但对于本书后续的讲解来说应该足够了。
练习2.1
1.你可曾想过如何检验关系运算符<=的结果(或者说关系表达式的值)?很简单,将该表达式的值保存到一个变量,在调试器里设置断点并检查变量的值就可以办到。在下面的程序里,第一条语句是将表达式5<= 6的值写入变量m;第二次是将表达式33<= 32的值写入变量m。请编辑、保存、翻译并调试这个程序,在第一条语句那里设置断点,然后使用n命令和p命令观察变量m的值如何变化。注意:关系运算符的优先级高于赋值运算符。
int main(void) { int m; m = 5 <= 6; m = 33 <= 32; return 0; }
2.在上一章里我们曾经说过,语句
n = 1; sum = 0;
可以合并为一条语句:
n =(sum = 0)+ 1;
请修改源文件c0201.c,将那两条语句替换为这条语句。然后,翻译并调试新生成的可执行文件,观察这条语句执行后变量n和sum的值是多少。