C语言非常道
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

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的值是多少。