Menu

C 语言常见错误:新手易犯的错误及解决方法

每个 C 程序员都至少犯过一次的错误:漏写分号、把 == 写成 =、函数隐式声明、scanf 忘了 &、用 == 比较字符串、整数除法、变量未初始化和差一错误。每一个都按症状、原因和解决方法讲解。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

每个 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。

Coddy 编程语言插图

和 Coddy 一起学编程

开始