C语言学习笔记——程序跑不对怎么办

前面几篇都在讲”怎么写对”. 但真实情况是: 代码写完, 编译过了, 一跑结果不对. 这时候该怎么办

这篇不按知识点排, 按报错的形状排 —— 从”编译器直接骂你”到”编译一声不吭但结果错了”, 难度递增. 每一节都是”看到什么现象 → 往哪儿想 → 怎么查”

0 综述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
程序跑不对怎么办
├── 1 先分清楚三类错误
├── 2 基础: 编译器报错怎么读
├── 3 基础: 中英文标点
├── 4 基础: 少分号、多分号
├── 5 基础: 大括号不配对
├── 6 基础: scanf 忘了 &
├── 7 基础: 格式符和类型不匹配
├── 8 基础: 数组越界
├── 9 进阶: 编译过了但结果不对
├── 10 进阶: 变量没初始化
├── 11 进阶: 整数除法与精度
├── 12 进阶: 死循环怎么找
├── 13 进阶: 打印式调试
├── 14 提高: 学会看警告
├── 15 提高: 二分定位法
├── 16 提高: 崩溃了怎么查
└── 17 提高: 查错清单

1 先分清楚三类错误

看到程序不对劲, 第一步不是改代码, 而是判断它属于哪一类. 三类错误的查法完全不同:

类型 什么时候暴露 编译器说话吗 典型
编译错误 编译时 说, 而且会拒绝生成程序 少分号、括号不配对
警告 编译时 说, 但照样生成程序 格式符不匹配、变量没用到
逻辑错误 运行时 一声不吭 死循环、算错、越界

第一类最好办, 因为编译器直接把位置指给你了. 第三类最难, 因为它不报错, 程序照跑, 只是结果是错的. 这篇的重点在第二类和第三类

有个心态问题值得先讲: 编译器报错不是”你不行”, 它在帮你干活. 一个 C 程序从写到跑通, 中间有几十次报错是很正常的. 看到一屏红字先别慌, 从第一条开始看

2 基础: 编译器报错怎么读

GCC 的报错长这样:

1
2
3
4
e1.c: In function 'main':
e1.c:4:5: error: expected ',' or ';' before 'printf'
4 | printf("%d\n", a);
| ^~~~~~

拆开看, 每一段都有用:

  • e1.c:4:5 —— 文件名 : 行号 : 列号. 这是最重要的信息, 直接告诉你去哪一行
  • error: —— 级别. 除了 error 还有 warning 和 note
  • expected ',' or ';' before 'printf' —— 它在 printf 前面期待一个逗号或分号
  • 下面那行带 ^~~~~~ 的, 是把出错的位置指出来

读这句话的关键是 “expected … before …” 这个句式. 它不是在说”printf 写错了”, 而是在说”这里缺了个东西”. 缺什么? 它猜是逗号或分号

于是往上翻一行:

1
2
int a = 1
printf("%d\n", a);

int a = 1 后面少了个分号. 补上就好了

报错行号经常比真正的问题晚一行

上面那个例子, 问题在第 3 行(少分号), 但编译器报的是第 4 行. 因为编译器是一路读下来的, 直到读到 printf 才发现”上一句还没结束”

所以看到一个”莫名其妙”的报错, 先看它上面那一行. 尤其是 expected ';' 这类, 十有八九错在上一行

另一个常见现象是一个错误引发一大串报错. 比如少了一个右大括号, 后面几十行全会报错. 这时候只改第一条, 重新编译, 别一口气全改 —— 很多”错误”会自己消失

3 基础: 中英文标点

这是中文输入法环境下最经典的坑, 而且报错信息往往极其莫名

C 语言只认英文半角标点. 但中文输入法下打出来的 , ; () "" 全角符号, 编译器不认识:

1
2
3
4
int a = 1;          // 这是中文分号
if (a > 0) { // 中文括号
printf("yes"); // 中文分号
}

肉眼几乎看不出来, 因为全角和半角在多数编辑器里长得接近. 判断办法:

  • 把光标移到符号上, 看编辑器底部的状态栏(有些编辑器会显示字符编码或宽度)
  • 全角字符占两个字符宽度, 可以选中它看宽度
  • 最土但最有效的办法: 删掉重打一遍, 确保输入法是英文状态

一条实用经验: 写代码前先把输入法切成英文, 或者用支持”自动切换输入法”的编辑器

Dev-C++ 里全角字符会显示得比周围宽一点. 养成习惯后一眼就能看出来. 另外中文注释里的标点无所谓, 因为注释整个会被忽略, 只有代码部分必须是英文标点

4 基础: 少分号、多分号

少分号的报错特征就是上面说的 expected ';' 系列. 记住往回看一行

多分号更阴险, 因为它通常不报错. 最典型的是 if 或 while 后面多打了一个分号:

1
2
3
4
if (a > b);                    // 多了个分号!
{
printf("会执行\n");
}

这个分号被当成了一个空语句, if 管的就是这个空语句. 于是后面那个大括号和 if 完全没关系, 里面的 printf 永远执行

GCC 对这种写法会给一个警告:

1
2
3
4
warning: this 'if' clause does not guard... [-Wmisleading-indentation]
4 | if (a > b);
| ^~
note: ...this statement, but the latter is misleadingly indented as if it were guarded by the 'if'

注意警告里那句 “misleadingly indented”(缩进有误导性)—— GCC 是在说”你缩进得像被 if 管着, 但实际上没有”

while 后面多分号更严重, 直接变成死循环:

1
2
3
4
5
6
int x = 1;

while (x < 3); // 死循环! 循环体是个空语句, x 永远不变
{
x++;
}

while (x < 3); 的意思是”只要 x<3 就执行空语句”. 空语句什么都不做, x 永远是 1, 条件永远成立, 卡死

; 单独出现就是空语句, 是合法的 C 语法. 它有时候是故意用的, 比如:

1
while (getchar() != '\n');     // 故意空转, 把这一行剩下的字符吃干净

这里的分号是故意的, 表示”读到换行为止, 循环体什么也不做”. 区别在于你是故意还是手滑 —— 故意写的时候最好加个注释说明

5 基础: 大括号不配对

少一个大括号的报错很有特点:

1
2
3
e2.c:7:1: error: expected declaration or statement at end of input
7 | }
| ^

关键在 “at end of input”(在输入结束时). 这句话的意思是”我一直读到文件末尾, 还没把该收的收完”

看到 at end of input, 基本可以断定是大括号少了, 而且报的那个行号通常是文件的最后一行, 不是你真正漏掉的位置

真正漏在哪? 得自己找. 有两个办法:

办法一: 用编辑器的括号匹配. 把光标放在某个 { 上, 编辑器会高亮对应的 }. 一路点下来, 哪个 { 找不到配对就是它

办法二: 看缩进. 正常写法的缩进是整齐的, 少一个 } 会让后面整段缩进突然变浅. 这个视觉信号很明显

办法三: 从后往前数. 从函数最外层开始, 数每个 { 和 } 的数量差

大括号不配对还有一种情况: 多了一个 }

这时候报错通常在多出来的那个 } 所在行, 内容类似:

1
error: expected identifier or '(' before '}' token

意思变成了”这里不该有 }“. 所以:

  • 报 at end of input → 少了 }
  • 报 before '}' → 多了 }

另外, 养成打 { 的时候顺手把 } 也打上的习惯(很多编辑器有自动补全), 能避免绝大多数这类问题

6 基础: scanf 忘了 &

这个在第一个程序里提过, 但因为踩的人太多, 这里再说一遍

1
2
int a;
scanf("%d", a); // 忘了 &

scanf 需要的是变量的地址, 也就是”数据该往哪儿放”. 少了 &, 传进去的是 a 的值(一个不确定的垃圾数), scanf 会把这个数当成地址去写

后果通常是程序直接崩溃, 而且崩溃的位置离出错行很远, 看起来完全无关. 也可能不崩, 但某些变量的值莫名其妙被改掉

为什么这个错误特别难查:

  • 有些编译器只给警告不给错误, 程序照样能编译出来
  • 崩溃报告指向的是 scanf 内部或者干脆是别的函数, 因为写坏的内存是后面才用到的
  • 有时候根本看不出异常, 只是某个无关变量的值变了

判断办法: 只要看到 scanf, 就把它的所有参数检查一遍, 除了数组名和指针, 其他变量前面都必须有 &

1
2
3
scanf("%d", &a);        // 基本变量, 要 &
scanf("%s", str); // 字符数组名本身就是地址, 不要 &
scanf("%d", &arr[i]); // 数组元素是变量, 要 &

7 基础: 格式符和类型不匹配

printf 的格式符写错了, 编译器通常只给警告, 程序照跑, 但输出是乱的:

1
2
double d = 3.14;
printf("%d\n", d); // 用 %d 打 double

GCC 的警告很直白:

1
2
3
4
5
6
warning: format '%d' expects argument of type 'int', but argument 2 has type 'double' [-Wformat=]
5 | printf("用 %d 打 double = %d\n", d);
| ~^ ~
| | |
| int double
| %f

它甚至在下面把正确格式符 %f 都标出来了. 可惜这是警告, 程序还是生成了, 运行输出:

1
用 %d 打 double = 1374389535

一个莫名其妙的数字. 原因是 double 在内存里是 8 字节的浮点格式, %d 却按 4 字节整数去读, 读出来的当然不是 3.14

反过来也一样:

1
2
int n = 42;
printf("%f\n", n); // 用 %f 打 int

输出 0.000000. %f 按浮点格式去解释 42 那几个字节, 得到的接近 0

这类问题的排查方式: 输出结果”完全看不懂”的时候, 先怀疑格式符. 对着数据类型的格式符表检查一遍:

类型 格式符
int %d
long %ld
float / double %f
char %c
字符串 %s
无符号 %u
十六进制 %x

有个容易记混的点: float 和 double 在 printf 里都用 %f. 因为 C 会把 float 自动提升成 double. 但 scanf 不一样 —— float 要用 %f, double 必须用 %lf(那个 l 不能省). 这个不对称经常考

8 基础: 数组越界

数组与字符串里说过: C 不检查数组越界. 这是逻辑错误, 编译器基本不说话

1
2
3
4
5
6
int a[5];
int i;

for (i = 0; i <= 5; i++) { // 应该是 i < 5
a[i] = i;
}

i 取到 5 的时候, a[5] 已经越界了. 但程序照跑不误, 因为 a[5] 那块内存确实存在, 只是不属于这个数组

实测结果是”写完了没崩”. 但越界写的那一个位置, 可能正好是别的变量的地盘:

1
2
3
4
5
6
int a[5];
int b = 100;

for (i = 0; i <= 5; i++) a[i] = i;

printf("b = %d\n", b); // 可能不是 100 了!

b 的值可能被那次越界写覆盖掉. 这种 bug 极其难查, 因为看起来毫不相干的两个变量互相影响

排查办法: 循环边界是最值得反复检查的地方. 记住两条:

  • 长度为 n 的数组, 合法下标是 0 到 n-1
  • 所以循环条件几乎总是 < n, 而不是 <= n

9 进阶: 编译过了但结果不对

从这里开始是逻辑错误, 编译器完全帮不上忙. 程序能编译、能运行、能输出, 只是结果错了

这类问题有个系统的查法, 按顺序排除:

第一步: 确认输入对不对. 在程序最开始把所有输入打印出来. 很多时候问题根本不在算法, 而是 scanf 根本没读到预想的值

第二步: 确认中间结果. 在关键步骤后打印变量的值, 看它是从哪一步开始不对的

第三步: 缩小范围. 如果第 3 步之前都对, 第 5 步开始不对, 那问题就在第 3、4 步之间

第四步: 用简单数据测. 别一上来就用复杂输入. 用最小的例子(比如数组只放 2 个元素)手算一遍, 再和程序输出对比

后面几节讲的就是这些具体手段

10 进阶: 变量没初始化

这是”结果莫名其妙”的头号原因:

1
2
3
4
5
6
7
int sum;
int i;

for (i = 1; i <= 5; i++) {
sum += i; // sum 从来没赋过初值!
}
printf("%d\n", sum);

sum += i 等价于 sum = sum + i, 而右边的 sum 一开始是内存里的垃圾值. 所以结果是在垃圾值上加 1 到 5

期望是 15, 实测输出是:

1
32773

数据类型里说过: 局部变量不初始化就是不确定的值. 正确的写法是 int sum = 0;

32773 这个数字本身没有任何意义. 它是那块栈内存里残留的东西, 换个编译器、改下代码、甚至多定义一个变量, 结果就变了

所以看到明显不合逻辑但又不是随机数的结果(比如这种五位六位的怪数), 第一反应就是”某个变量没初始化”

排查办法很简单: 把所有累加、累乘用的变量在定义时就赋好初值. sum = 0, product = 1, 这类变量永远不要省初始化

11 进阶: 整数除法与精度

运算符与表达式整篇都在说这个, 这里只讲怎么在调试时发现它:

1
2
3
4
int total = 7, count = 2;
double avg = total / count;

printf("%f\n", avg);

期望 3.5, 实际输出 3.000000

判断的关键: 结果是”小数部分莫名其妙不见了”或者”总是差一点点”. 这时候去看那个除法的两边是不是都是整数

1
2
3
total / count            // 3    两个都是 int
(double)total / count // 3.5 转成浮点再除
total / 2.0 // 3.5 让一边变浮点

修法有两条: 强转 (double) 或者让其中一个操作数是浮点字面量 2.0

还有个更隐蔽的变体是用 scanf 读浮点数时用错了格式符:

1
2
double d;
scanf("%d", &d); // 错! double 要用 %lf

%d 只写 4 个字节, 而 double 是 8 个字节, 剩下一半是垃圾, 算出来的数完全不对. 这个错误的症状是 “输入 3.5, 输出一个天文数字”

12 进阶: 死循环怎么找

程序卡住不动、光标一直闪、什么也不输出 —— 这是死循环

第一步: 加打印定位. 在循环体里打印循环变量:

1
2
3
4
while (i < 10) {
printf("i = %d\n", i); // 先看看 i 有没有变化
/* ... */
}

如果 i 一直不变, 说明循环里没有改变 i, 或者改变 i 的语句根本执行不到

第二步: 检查 continue 的位置. 这是最隐蔽的一种:

1
2
3
4
5
6
int i = 0;

while (i < 5) {
if (i == 2) continue; // i 到 2 就卡住了
i++;
}

i 到 2 时 continue, 直接跳回条件判断, 而 i++ 在 continue 后面, 永远执行不到. i 卡在 2 上死循环

同样的逻辑用 for 写就没事, 因为 for 的 i++ 写在第三格, continue 之后照样会执行

第三步: 检查 while 后面有没有多个分号(第 4 节讲的)

第四步: 检查浮点数的相等判断:

1
2
3
4
double x = 0.0;
while (x != 1.0) { // 危险! 浮点数可能永远等不到恰好 1.0
x += 0.1;
}

因为浮点数的精度问题, x 累加 10 次 0.1 之后可能不等于 1.0. 正确的写法是用 <= 或 < 配合误差范围

死循环不一定能”看出来”是死循环. 如果程序看起来只是在慢慢跑, 也可能是循环次数多得离谱

比如:

1
2
3
int i;
for (i = 0; i <= 100; i++); // 多了个分号, 循环体现在是空语句
printf("done\n"); // 这行要跑 101 次循环之后才到

这里不会死循环(101 次很快就跑完), 但如果循环上限是 10 亿, 就会卡很久

判断办法: 让程序自己报进度. 在循环里每隔一定次数打印一次, 就能看出它是”在跑”还是”卡住了”

13 进阶: 打印式调试

上面反复提到”加打印”, 这里集中说一下这个最基本也最好用的手段

核心思想: 在代码的关键位置插入 printf, 把变量的值”打出来看”. 程序的实际执行路径和变量变化, 一比就能看出哪一步跑偏了

1
2
3
4
5
6
7
8
9
10
11
int i, a[5] = {3, 1, 4, 1, 5};
int s = 0;

printf("[探针] 进入循环前 s=%d\n", s);

for (i = 0; i < 5; i++) {
s += a[i];
printf("[探针] i=%d a[i]=%d s=%d\n", i, a[i], s);
}

printf("[探针] 循环结束 i=%d s=%d\n", i, s);

实测输出:

1
2
3
4
5
6
7
[探针] 进入循环前 s=0
[探针] i=0 a[i]=3 s=3
[探针] i=1 a[i]=1 s=4
[探针] i=2 a[i]=4 s=8
[探针] i=3 a[i]=1 s=9
[探针] i=4 a[i]=5 s=14
[探针] 循环结束 i=5 s=14

一眼就能看出每一步的累积过程, 哪一步开始不对立刻暴露

几条实用经验:

  • 加个可搜索的标记, 比如 [探针] 或 ===. 调试完直接搜这个标记就能全删掉, 不会漏
  • 打印的时候带上变量名. 别只打 printf("%d\n", s), 要打 printf("s=%d\n", s). 输出多了之后, 光看数字根本不知道是哪个变量
  • 调试完记得删干净. 留着这些输出会影响程序的正常显示
  • 复杂表达式拆开打. 比如 f(a+b)*g(c) 结果不对, 就先分别打印 a+b 和 g(c) 的值

有个比 printf 更专业的工具叫调试器(Dev-C++ 里是那个带小虫子的按钮), 可以设置断点、单步执行、实时看变量. 功能比 printf 强得多, 但上手有门槛. 考试和日常练习里 printf 完全够用, 而且不用学新东西

14 提高: 学会看警告

很多人的习惯是”能编译过就行”, 警告一律无视. 这是个很亏的习惯 —— 警告往往正是逻辑错误的预警

打开全部警告的编译选项(GCC 是 -Wall), 上面那些问题基本都能提前暴露:

1
2
3
4
5
int a;
scanf("%d", a); // -Wformat 相关警告
int sum; sum += 1; // -Wmaybe-uninitialized
if (x = 5) // -Wparentheses
if (a > b); // -Wmisleading-indentation

Dev-C++ 默认的警告级别比较低. 可以在 工具 → 编译选项 → 在编译时加入以下命令 里填上:

1
-Wall

警告要看, 而且要看懂. 前面第 7 节那个格式符警告, GCC 说得非常清楚, 甚至连正确的 %f 都标出来了. 无视它, 就得多花半小时去查一个本来一眼就能看出的问题

有个细节值得知道: “警告”和”错误”的区别不是严重程度, 而是”能不能继续”

  • error 是语法层面的问题, 编译器无法生成程序(比如少分号, 它根本不知道这句话在哪结束)
  • warning 是语义层面的怀疑, 语法上说得通, 但很可能不是你想要的意思

所以警告反而更值得看 —— 语法错误编译器一定会拦下来, 而警告是你唯一的提示

另外, 有些编译器支持 -Werror(把警告当错误处理), 强制你处理掉所有警告. 练习阶段不建议开, 但代码写完之后看看警告列表是个好习惯

15 提高: 二分定位法

程序输出不对, 但不知道错在哪一段. 除了逐段加 printf, 还有个更高效的办法: 二分

思路是砍掉一半代码, 看问题还在不在:

  1. 把程序后半部分注释掉, 只留下前半部分
  2. 运行, 看结果对不对
  3. 如果前半部分就是错的 → 问题在前半, 继续对半砍
  4. 如果前半部分对 → 问题在后半, 去查后半

这样每轮砍掉一半, 100 行的程序七八次就能定位到具体几行

用注释来”关掉”代码是这里的关键技巧:

1
2
3
4
5
/* 先注释掉这一整段, 看前面是否正确
for (i = 0; i < n; i++) {
...
}
*/

二分法的前提是代码能被切成独立的两半. 如果前后两半互相依赖, 就切不开. 这时候退回到”在关键节点打探针”的办法. 两种手段配合用, 定位速度会快很多

16 提高: 崩溃了怎么查

程序直接退出、弹个窗口说”程序已停止工作”、或者干脆什么都不输出就结束 —— 这是崩溃

C 里导致崩溃的常见原因:

除法/取余的除数是 0:

1
2
int a = 10, b = 0;
int c = a / b; // 崩

实测这个程序什么输出都没有, 连前面那行 printf 的内容都没打出来. 退出码是 -1073741676, 也就是 Windows 的整数除零异常

之所以前面的输出也没了, 是因为 printf 的内容先存在缓冲区里, 程序崩溃时缓冲区没来得及刷出去

指针用错. 这个要等指针那篇详细讲, 但常见的两种崩溃原因可以先记住:

1
2
3
4
5
int *p;                // 野指针, 从没赋过值
*p = 10; // 往一个随机地址写 → 崩

int *q = NULL;
*q = 10; // 往空地址写 → 崩

数组越界写. 越界本身可能不崩, 但如果越界写到了别的变量或返回地址上, 就可能莫名其妙崩在别处

栈溢出. 最常见的是递归没有终止条件, 一路递归下去把栈用光:

1
2
3
int f(int n) {
return f(n + 1); // 永远不返回, 栈很快耗尽 → 崩
}

排查思路:

  1. 先定位崩在哪一行. 在可疑位置前面加 printf, 看最后一条打出来的是哪句, 崩溃就在它后面
  2. 检查所有的除法和取余, 看除数有没有可能是 0
  3. 检查所有指针在使用前有没有赋过有效地址
  4. 检查递归有没有终止条件, 以及终止条件能不能到达

有一个很坑的现象: 崩溃位置常常不是出错位置

1
2
3
int a[5];
a[10] = 1; // 越界写, 这里可能没事
printf("%d\n", a[0]); // 结果这里崩了

因为越界写只是”写到了不该写的地方”, 写的那一刻可能毫无反应. 真正崩的时候是后面某处用到了被写坏的内存

所以查崩溃不能只看崩溃点, 要往前找. 这也解释了为什么”注释掉一半代码”的二分法特别有效 —— 它能帮你确定崩溃是哪段代码引入的

17 提高: 一个查错清单

把上面的东西整理成一张可以照着走的表. 遇到问题从上往下过一遍:

编译不过

  1. 看第一条报错, 别管后面那一堆
  2. expected ';' → 往上看一行, 十有八九是上一行少分号
  3. at end of input → 少了大括号
  4. before '}' → 多了大括号
  5. 报错信息完全看不懂 → 检查中英文标点

编译过了但结果不对

  1. 在程序开头打印所有输入, 确认读进来的对不对
  2. 检查所有累加变量的初值(sum = 0)
  3. 检查所有除法的两边类型(是不是整数除法)
  4. 检查所有 printf/scanf 格式符和变量类型是否匹配
  5. 检查所有循环边界(< 还是 <=)

卡住不动

  1. 在循环里打印循环变量, 看有没有在变
  2. 检查 continue 会不会跳过步进语句
  3. 检查 while/for 后面有没有多余的分号

崩溃

  1. 用 printf 定位最后执行到哪一行
  2. 检查除数为 0
  3. 检查指针和数组越界
  4. 检查递归的终止条件

最后一件事: 改一个地方, 编译运行一次

新手最容易犯的错是一口气改十几个地方, 然后发现程序还是不对 —— 这时候根本不知道是哪个改动起了作用, 还是引入了新问题

正确的节奏是: 定位到一个具体问题 → 改它 → 编译运行验证 → 再改下一个. 慢一点, 但每一步都是确定的. 十几处一起改看着快, 实际会绕远路