每个 C 程序员都会遇到同样的一小串错误,通常在第一周,偶尔在十年之后。之所以值得把它们整理出来,是因为大多数错误产生的报错信息并不能说明问题本身,更糟的是,有些根本没有报错。
下面每一条都包括症状、背后的原因和解决方法。
漏写分号与连锁报错
症状:一大堆错误,全都报在看起来正确的行上。
program.c:6:5: error: expected ';' before 'printf'
program.c:7:5: error: expected declaration specifiers before 'return'
原因:C 语言用 ; 结束语句。漏掉一个,编译器就会把下一行接到当前行后面,然后在合并后的文本变得说不通的地方报告错误,通常是下一行。
int main(void) {
int x = 5 /* missing semicolon */
printf("%d\n", x);
return 0;
}
解决方法:出现一批错误时,只修改第一个,然后重新编译。它后面的错误可能都是连带产生的。另外,一定要检查编译器所指那一行的上一行。
没闭合的花括号或没结束的 /* 注释也会引发同样的连锁报错,错误甚至可能出现在几十行之外。
再补充一点:if、for、while 的头部后面,以及函数定义的右花括号后面,都不加分号。if (x > 0); 可以正常编译,但什么也不做:那个 ; 就是整个 if 主体。
把比较写成了赋值
症状:条件永远为真,或者某个变量莫名其妙地变了。
int x = 5;
if (x = 10) { /* assigns 10, then tests 10 -> true */
printf("x is ten\n"); /* always prints; x is now 10 */
}
原因:= 是赋值,== 是比较。赋值表达式的值就是被赋的值,所以 if (x = 10) 判断的是 10,它非零,因此为真。编译器会接受它,因为偶尔这确实是人们想要的写法。
解决方法:在每个条件中都使用 ==,并打开 -Wall,让编译器发出警告:
warning: suggest parentheses around assignment used as truth value
如果你确实想在条件里赋值(while ((c = getchar()) != EOF) 这种写法很常见),多加一层括号就能表明意图,警告也会消失。
函数隐式声明
症状:
warning: implicit declaration of function 'printf'
warning: implicit declaration of function 'malloc'
……有时紧接着出现奇怪的运行结果或链接错误。
原因:编译器遇到了一个它找不到声明的函数调用。在 C99 之前的标准中,编译器会假定这个函数返回 int,然后继续编译;在 64 位系统上,这个假定会把返回的指针截断成 32 位,这就是为什么漏掉 <stdlib.h> 会让 malloc 导致程序崩溃。
解决方法:包含正确的头文件。
| 函数 | 头文件 |
|---|---|
printf, scanf, fopen | <stdio.h> |
malloc, free, atoi, rand, exit | <stdlib.h> |
strlen, strcpy, strcmp | <string.h> |
sqrt, pow, fabs | <math.h> |
isdigit, toupper | <ctype.h> |
time | <time.h> |
对于你自己写的函数,同样的警告意味着你在定义之前就调用了它。在 main 上方写一个函数原型:
scanf 忘了写 &
症状:程序在输入时崩溃,或者什么也没读到,变量保持不变。
int age;
scanf("%d", age); /* missing & : passes the value, not the address */
原因:scanf 要把数据写入你的变量,所以它需要变量的地址。传入 age 等于交出了变量里原有的垃圾值,scanf 会把它当作要写入的位置,结果通常是段错误。
解决方法:在变量前面加 &。除了数组以外的所有类型都要加,因为数组本身就是地址:
同一类问题还有两个相邻的陷阱:在 scanf 中读取 double 要用 %lf(在 scanf 里 %f 表示 float,把 float 大小的字节写进 double 会得到错误的值);%s 一定要限定宽度,50 字节的缓冲区就写 %49s,否则一个很长的单词就会溢出。
更好的做法是:用 fgets 读取一整行再解析,这样不会溢出,也不会在缓冲区里留下多余的输入。两者的更多内容见 scanf。
用 == 比较字符串
症状:两个明明相同的字符串,比较结果却是不同。
char a[] = "hello";
char b[] = "hello";
if (a == b) { /* comparing two addresses: false */
printf("same\n");
}
原因:C 字符串不是一个值,而是指向第一个字符的指针。== 比较的是指针。两个存放相同文本的数组位于不同的地址,所以结果为假。(更让人困惑的是,比较两个相同的字面量有时结果为真,因为编译器可能只存一份副本,这让 bug 时有时无。)
解决方法:使用 strcmp,并记住相等时它返回 0:
反过来理解也会让人上当:if (strcmp(a, b)) 在字符串不同时为真,因为非零结果表示“不相等”。一定要显式地写出 == 0。
整数除法
症状:平均值为 0,百分比永远是 0 或 100,比值丢了小数部分。
int correct = 7, total = 10;
double score = correct / total; /* 0.0, not 0.7 */
原因:两个操作数都是 int,所以 C 语言执行整数除法,并在结果赋给 double 之前就截断了。7 / 10 是 0,把 0 转换成 double 就是 0.0。
解决方法:在除法之前,把其中一个操作数变成浮点数:
转换其中一个操作数,另一个会自动提升。对结果做转换就太晚了,截断已经发生。参见类型转换。
同样的陷阱也藏在求中点的 (a + b) / 2 这类表达式里,以及 1 / 2 * x 中,无论 x 是多少,后者永远是 0。
漏写或写错 return
症状:函数返回一个看似合理的错误数字,每次运行或每次构建都不一样。
int add(int a, int b) {
int sum = a + b;
/* no return statement */
}
原因:执行到非 void 函数的末尾却没有返回,得到的是一个不确定的值,实际上就是返回寄存器里碰巧存着的东西。如果调用者使用了这个值,就是未定义行为。
更隐蔽的形式是:某些路径有返回,另一些路径没有:
int classify(int n) {
if (n > 0) return 1;
if (n < 0) return -1;
/* n == 0 falls off the end */
}
解决方法:确保每条路径都有返回,并用 -Wall 编译。GCC 的 “control reaches end of non-void function” 警告能同时抓住这两种情况。
变量未初始化
症状:输出垃圾值,或者结果在每次运行之间、不同优化级别之间都会变化。
int total; /* contains whatever was on the stack */
for (int i = 1; i <= 5; i++) {
total += i; /* adding to garbage */
}
printf("%d\n", total); /* some enormous number */
原因:局部变量不会被清零。全局变量或 static 变量会自动设为零;局部变量的初始值则是那个栈地址上原有的字节。
解决方法:在声明时就初始化。这没有任何开销,却能消除这一整类 bug:
-Wall -Wextra 会对其中很多情况发出警告(“may be used uninitialized”),-fsanitize=memory 或 valgrind 能抓住剩下的。这个错误尤其麻烦,因为未初始化的指针会直接导致段错误。
差一错误
症状:漏掉了最后一个元素,或者多访问了一个元素,程序在之后某个时刻出现异常。
int arr[5];
for (int i = 0; i <= 5; i++) { /* touches arr[5], which does not exist */
arr[i] = i;
}
原因:一个有 n 个元素的数组,下标是 0 到 n - 1。<= 会多执行一轮。
解决方法:使用 i < n 的写法,并根据数组算出 n,而不是把数字写两遍:
字符串版本的差一错误(忘了给 '\0' 留位置)是同一个错误换了个样子:把 "hello" 放进 char word[5] 就是缓冲区溢出。
两个值得了解的小问题
循环头后面的分号。 for (int i = 0; i < 10; i++); 后面跟一个花括号代码块,会让循环空转十次,然后只执行一次代码块。它能顺利通过编译。
对指针使用 sizeof。 在函数内部,数组形参就是一个指针,所以 sizeof(arr) 得到的是指针的大小(8 字节),而不是数组的大小。把长度作为单独的参数传入:
能避免大部分错误的习惯
从第一个程序开始,就打开警告编译:
gcc -Wall -Wextra -g program.c -o program
-Wall -Wextra 能抓住条件中的赋值、漏写的 return、读取未初始化的变量、未使用的变量,以及与参数不匹配的 printf 格式说明符。开发期间再加上 -fsanitize=address,undefined,几乎所有剩下的错误都能在发生的那一刻被抓住。
把每一个警告都当作一个你还没碰上的错误。没有任何警告的 C 程序不一定正确,但几乎每个崩溃的 C 程序,都曾先发出过某种警告。
常见问题
C 语言中 implicit declaration of function 是什么意思?
编译器遇到了一个函数调用,却从没见过这个函数的声明。几乎总是因为你忘了 #include:printf 需要 <stdio.h>,malloc 需要 <stdlib.h>,strlen 需要 <string.h>。它也可能表示你在定义自己的函数之前就调用了它,在 main 上方写一个函数原型就能解决。
为什么 C 程序报错的那一行看起来没问题?
通常是因为真正的错误在上一行。漏写的分号、没闭合的花括号或没结束的注释,会让编译器把两行当成一行来读,于是它在最终无法解析的地方报告错误。一定要先检查报错行的上一行。
为什么 C 语言不能用 == 比较字符串?
因为 C 字符串是 char *,也就是一个指针,所以 == 比较的是两个地址,而不是它们指向的字符。存放在不同位置的两个相同字符串比较结果为 false。请使用 <string.h> 中的 strcmp(a, b) == 0,内容相同时它返回 0。
为什么 C 语言中 5 / 2 等于 2?
因为两个操作数都是整数,所以 C 语言执行整数除法,丢掉了小数部分。把其中一边变成浮点数就能得到 2.5:5.0 / 2,用变量时写 (double)a / b。对结果做类型转换就太晚了:(double)(5 / 2) 是 2.0。