3. 观察点

接着上一节的步骤,经过调试我们知道,虽然sum已经赋了初值0,但仍需要在while (1)循环的开头加上sum = 0;

例 10.3. 观察点调试实例

  1. #include <stdio.h>
  2.  
  3. int main(void)
  4. {
  5. int sum = 0, i = 0;
  6. char input[5];
  7.  
  8. while (1) {
  9. sum = 0;
  10. scanf("%s", input);
  11. for (i = 0; input[i] != '\0'; i++)
  12. sum = sum*10 + input[i] - '0';
  13. printf("input=%d\n", sum);
  14. }
  15. return 0;
  16. }

使用scanf函数是非常凶险的,即使修正了这个Bug也还存在很多问题。如果输入的字符串超长了会怎么样?我们知道数组访问越界是不会检查的,所以scanf会写出界。现象是这样的:

  1. $ ./main
  2. 123
  3. input=123
  4. 67
  5. input=67
  6. 12345
  7. input=123407

下面用调试器看看最后这个诡异的结果是怎么出来的[21]。

  1. $ gdb main
  2. ...
  3. (gdb) start
  4. Breakpoint 1 at 0x80483b5: file main.c, line 5.
  5. Starting program: /home/akaedu/main
  6. main () at main.c:5
  7. 5 int sum = 0, i = 0;
  8. (gdb) n
  9. 9 sum = 0;
  10. (gdb) (直接回车)
  11. 10 scanf("%s", input);
  12. (gdb) (直接回车)
  13. 12345
  14. 11 for (i = 0; input[i] != '\0'; i++)
  15. (gdb) p input
  16. $1 = "12345"

input数组只有5个元素,写出界的是scanf自动添的'\0',用x命令看会更清楚一些:

  1. (gdb) x/7b input
  2. 0xbfb8f0a7: 0x31 0x32 0x33 0x34 0x35 0x00 0x00

x命令打印指定存储单元的内容。7b是打印格式,b表示每个字节一组,7表示打印7组[22],从input数组的第一个字节开始连续打印7个字节。前5个字节是input数组的存储单元,打印的正是十六进制ASCII码的'1''5',第6个字节是写出界的'\0'。根据运行结果,前4个字符转成数字都没错,第5个错了,也就是i从0到3的循环都没错,我们设一个条件断点从i等于4开始单步调试:

  1. (gdb) l
  2. 6 char input[5];
  3. 7
  4. 8 while (1) {
  5. 9 sum = 0;
  6. 10 scanf("%s", input);
  7. 11 for (i = 0; input[i] != '\0'; i++)
  8. 12 sum = sum*10 + input[i] - '0';
  9. 13 printf("input=%d\n", sum);
  10. 14 }
  11. 15 return 0;
  12. (gdb) b 12 if i == 4
  13. Breakpoint 2 at 0x80483e6: file main.c, line 12.
  14. (gdb) c
  15. Continuing.
  16.  
  17. Breakpoint 2, main () at main.c:12
  18. 12 sum = sum*10 + input[i] - '0';
  19. (gdb) p sum
  20. $2 = 1234

现在sum是1234没错,根据运行结果是123407我们知道即将进行的这步计算肯定要出错,算出来应该是12340,那就是说input[4]肯定不是'5'了,事实证明这个推理是不严谨的:

  1. (gdb) x/7b input
  2. 0xbfb8f0a7: 0x31 0x32 0x33 0x34 0x35 0x04 0x00

input[4]的确是0x35,产生123407还有另外一种可能,就是在下一次循环中123450不是加上而是减去一个数得到123407。可现在不是到字符串末尾了吗?怎么会有下一次循环呢?注意到循环控制条件是input[i] != '\0',而本来应该是0x00的位置现在莫名其妙地变成了0x04,因此循环不会结束。继续单步:

  1. (gdb) n
  2. 11 for (i = 0; input[i] != '\0'; i++)
  3. (gdb) p sum
  4. $3 = 12345
  5. (gdb) n
  6. 12 sum = sum*10 + input[i] - '0';
  7. (gdb) x/7b input
  8. 0xbfb8f0a7: 0x31 0x32 0x33 0x34 0x35 0x05 0x00

进入下一次循环,原来的0x04又莫名其妙地变成了0x05,这是怎么回事?这个暂时解释不了,但123407这个结果可以解释了,是12345*10 + 0x05 - 0x30得到的,虽然多循环了一次,但下次一定会退出循环了,因为0x05的后面是'\0'

input[4]后面那个字节到底是什么时候变的?可以用观察点(Watchpoint)来跟踪。我们知道断点是当程序执行到某一代码行时中断,而观察点是当程序访问某个存储单元时中断,如果我们不知道某个存储单元是在哪里被改动的,这时候观察点尤其有用。下面删除原来设的断点,从头执行程序,重复上次的输入,用watch命令设置观察点,跟踪input[4]后面那个字节(可以用input[5]表示,虽然这是访问越界):

  1. (gdb) delete breakpoints
  2. Delete all breakpoints? (y or n) y
  3. (gdb) start
  4. Breakpoint 1 at 0x80483b5: file main.c, line 5.
  5. Starting program: /home/akaedu/main
  6. main () at main.c:5
  7. 5 int sum = 0, i = 0;
  8. (gdb) n
  9. 9 sum = 0;
  10. (gdb) (直接回车)
  11. 10 scanf("%s", input);
  12. (gdb) (直接回车)
  13. 12345
  14. 11 for (i = 0; input[i] != '\0'; i++)
  15. (gdb) watch input[5]
  16. Hardware watchpoint 2: input[5]
  17. (gdb) i watchpoints
  18. Num Type Disp Enb Address What
  19. 2 hw watchpoint keep y input[5]
  20. (gdb) c
  21. Continuing.
  22. Hardware watchpoint 2: input[5]
  23.  
  24. Old value = 0 '\0'
  25. New value = 1 '\001'
  26. 0x0804840c in main () at main.c:11
  27. 11 for (i = 0; input[i] != '\0'; i++)
  28. (gdb) c
  29. Continuing.
  30. Hardware watchpoint 2: input[5]
  31.  
  32. Old value = 1 '\001'
  33. New value = 2 '\002'
  34. 0x0804840c in main () at main.c:11
  35. 11 for (i = 0; input[i] != '\0'; i++)
  36. (gdb) c
  37. Continuing.
  38. Hardware watchpoint 2: input[5]
  39.  
  40. Old value = 2 '\002'
  41. New value = 3 '\003'
  42. 0x0804840c in main () at main.c:11
  43. 11 for (i = 0; input[i] != '\0'; i++)

已经很明显了,每次都是回到for循环开头的时候改变了input[5]的值,而且是每次加1,而循环变量i正是在每次回到循环开头之前加1,原来input[5]就是变量i的存储单元,换句话说,i的存储单元是紧跟在input数组后面的。

修正这个Bug对初学者来说有一定难度。如果你发现了这个Bug却没想到数组访问越界这一点,也许一时想不出原因,就会先去处理另外一个更容易修正的Bug:如果输入的不是数字而是字母或别的符号也能算出结果来,这显然是不对的,可以在循环中加上判断条件检查非法字符:

  1. while (1) {
  2. sum = 0;
  3. scanf("%s", input);
  4. for (i = 0; input[i] != '\0'; i++) {
  5. if (input[i] < '0' || input[i] > '9') {
  6. printf("Invalid input!\n");
  7. sum = -1;
  8. break;
  9. }
  10. sum = sum*10 + input[i] - '0';
  11. }
  12. printf("input=%d\n", sum);
  13. }

然后你会惊喜地发现,不仅输入字母会报错,输入超长也会报错:

  1. $ ./main
  2. 123a
  3. Invalid input!
  4. input=-1
  5. dead
  6. Invalid input!
  7. input=-1
  8. 1234578
  9. Invalid input!
  10. input=-1
  11. 1234567890abcdef
  12. Invalid input!
  13. input=-1
  14. 23
  15. input=23

似乎是两个Bug一起解决掉了,但这是治标不治本的解决方法。看起来输入超长的错误是不出现了,但只要没有找到根本原因就不可能真的解决掉,等到条件一变,它可能又冒出来了,在下一节你会看到它又以一种新的形式冒出来了。现在请思考一下为什么加上检查非法字符的代码之后输入超长也会报错。最后总结一下本节用到的gdb命令:

表 10.3. gdb基本命令3

命令描述
watch设置观察点
info(或i) watchpoints查看当前设置了哪些观察点
x从某个位置开始打印存储单元的内容,全部当成字节来看,而不区分哪个字节属于哪个变量

[21] 不得不承认,在有些平台和操作系统上也未必得到这个结果,产生Bug的往往都是一些平台相关的问题,举这样的例子才比较像是真实软件开发中遇到的Bug,如果您的程序跑不出我这样的结果,那这一节您就凑合着看吧。

[22] 打印结果最左边的一长串数字是内存地址,在第 1 节 “内存与地址”详细解释,目前可以无视。