跳转到内容

3. 观察点

c
#include <stdio.h>

int main(void) {
    int sum = 0, i = 0;
    char input[5];

    while (1) {
        sum = 0;
        scanf("%s", input);
        for (i = 0; input[i] != '\0'; i++)
            sum = sum * 10 + input[i] - '0';
        printf("input=%d\n", sum);
    }
    return 0;
}

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

bash
$ ./main
123
input=123
67
input=67
12345
input=123407

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

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

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

bash
(gdb) x/7b input
0xbfb8f0a7:0x310x320x330x340x350x000x00

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

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

Breakpoint 2, main () at main.c:12
12sum = sum*10 + input[i] - '0';
(gdb) p sum
$2 = 1234

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

bash
(gdb) x/7b input
0xbfb8f0a7:0x310x320x330x340x350x040x00

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

bash
(gdb) n
11for (i = 0; input[i] != '\0'; i++)
(gdb) p sum
$3 = 12345
(gdb) n
12sum = sum*10 + input[i] - '0';
(gdb) x/7b input
0xbfb8f0a7:0x310x320x330x340x350x050x00

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

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

bash
(gdb) delete breakpoints
Delete all breakpoints? (y or n) y
(gdb) start
Breakpoint 1 at 0x80483b5: file main.c, line 5.
Starting program: /home/akaedu/main
main () at main.c:5
5int sum = 0, i = 0;
(gdb) n
9sum = 0;
(gdb) (直接回车)
10scanf("%s", input);
(gdb) (直接回车)
12345
11for (i = 0; input[i] != '\0'; i++)
(gdb) watch input[5]
Hardware watchpoint 2: input[5]
(gdb) i watchpoints
Num     Type           Disp Enb Address    What
2       hw watchpoint  keep y              input[5]
(gdb) c
Continuing.
Hardware watchpoint 2: input[5]

Old value = 0 '\0'
New value = 1 '\001'
0x0804840c in main () at main.c:11
11for (i = 0; input[i] != '\0'; i++)
(gdb) c
Continuing.
Hardware watchpoint 2: input[5]

Old value = 1 '\001'
New value = 2 '\002'
0x0804840c in main () at main.c:11
11for (i = 0; input[i] != '\0'; i++)
(gdb) c
Continuing.
Hardware watchpoint 2: input[5]

Old value = 2 '\002'
New value = 3 '\003'
0x0804840c in main () at main.c:11
11for (i = 0; input[i] != '\0'; i++)

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

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

c
while (1) {
    sum = 0;
    scanf("%s", input);
    for (i = 0; input[i] != '\0'; i++) {
        if (input[i] < '0' || input[i] > '9') {
            printf("Invalid input!\n");
            sum = -1;
            break;
        }
        sum = sum * 10 + input[i] - '0';
    }
    printf("input=%d\n", sum);
}

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

bash
$ ./main
123a
Invalid input!
input=-1
dead
Invalid input!
input=-1
1234578
Invalid input!
input=-1
1234567890abcdef
Invalid input!
input=-1
23
input=23

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

表 10.3. gdb 基本命令 3

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

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

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