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

C语言学习笔记——程序跑不对怎么办
Kanwuqing前面几篇都在讲”怎么写对”. 但真实情况是: 代码写完, 编译过了, 一跑结果不对. 这时候该怎么办
这篇不按知识点排, 按报错的形状排 —— 从”编译器直接骂你”到”编译一声不吭但结果错了”, 难度递增. 每一节都是”看到什么现象 → 往哪儿想 → 怎么查”
0 综述
1 | 程序跑不对怎么办 |
1 先分清楚三类错误
看到程序不对劲, 第一步不是改代码, 而是判断它属于哪一类. 三类错误的查法完全不同:
| 类型 | 什么时候暴露 | 编译器说话吗 | 典型 |
|---|---|---|---|
| 编译错误 | 编译时 | 说, 而且会拒绝生成程序 | 少分号、括号不配对 |
| 警告 | 编译时 | 说, 但照样生成程序 | 格式符不匹配、变量没用到 |
| 逻辑错误 | 运行时 | 一声不吭 | 死循环、算错、越界 |
第一类最好办, 因为编译器直接把位置指给你了. 第三类最难, 因为它不报错, 程序照跑, 只是结果是错的. 这篇的重点在第二类和第三类
有个心态问题值得先讲: 编译器报错不是”你不行”, 它在帮你干活. 一个 C 程序从写到跑通, 中间有几十次报错是很正常的. 看到一屏红字先别慌, 从第一条开始看
2 基础: 编译器报错怎么读
GCC 的报错长这样:
1 | e1.c: In function 'main': |
拆开看, 每一段都有用:
e1.c:4:5—— 文件名 : 行号 : 列号. 这是最重要的信息, 直接告诉你去哪一行error:—— 级别. 除了error还有warning和noteexpected ',' or ';' before 'printf'—— 它在printf前面期待一个逗号或分号- 下面那行带
^~~~~~的, 是把出错的位置指出来
读这句话的关键是 “expected … before …” 这个句式. 它不是在说”printf 写错了”, 而是在说”这里缺了个东西”. 缺什么? 它猜是逗号或分号
于是往上翻一行:
1 | int a = 1 |
int a = 1 后面少了个分号. 补上就好了
报错行号经常比真正的问题晚一行
上面那个例子, 问题在第 3 行(少分号),
但编译器报的是第 4 行. 因为编译器是一路读下来的,
直到读到 printf 才发现”上一句还没结束”
所以看到一个”莫名其妙”的报错, 先看它上面那一行.
尤其是 expected ';' 这类, 十有八九错在上一行
另一个常见现象是一个错误引发一大串报错. 比如少了一个右大括号, 后面几十行全会报错. 这时候只改第一条, 重新编译, 别一口气全改 —— 很多”错误”会自己消失
3 基础: 中英文标点
这是中文输入法环境下最经典的坑, 而且报错信息往往极其莫名
C 语言只认英文半角标点. 但中文输入法下打出来的
, ; () ""
全角符号, 编译器不认识:
1 | int a = 1; // 这是中文分号 |
肉眼几乎看不出来, 因为全角和半角在多数编辑器里长得接近. 判断办法:
- 把光标移到符号上, 看编辑器底部的状态栏(有些编辑器会显示字符编码或宽度)
- 全角字符占两个字符宽度, 可以选中它看宽度
- 最土但最有效的办法: 删掉重打一遍, 确保输入法是英文状态
一条实用经验: 写代码前先把输入法切成英文, 或者用支持”自动切换输入法”的编辑器
Dev-C++ 里全角字符会显示得比周围宽一点. 养成习惯后一眼就能看出来. 另外中文注释里的标点无所谓, 因为注释整个会被忽略, 只有代码部分必须是英文标点
4 基础: 少分号、多分号
少分号的报错特征就是上面说的
expected ';' 系列. 记住往回看一行
多分号更阴险, 因为它通常不报错.
最典型的是 if 或 while 后面多打了一个分号:
1 | if (a > b); // 多了个分号! |
这个分号被当成了一个空语句, if
管的就是这个空语句. 于是后面那个大括号和 if
完全没关系, 里面的 printf 永远执行
GCC 对这种写法会给一个警告:
1 | warning: this 'if' clause does not guard... [-Wmisleading-indentation] |
注意警告里那句 “misleadingly
indented”(缩进有误导性)—— GCC 是在说”你缩进得像被
if 管着, 但实际上没有”
while 后面多分号更严重,
直接变成死循环:
1 | int x = 1; |
while (x < 3); 的意思是”只要 x<3
就执行空语句”. 空语句什么都不做, x 永远是 1,
条件永远成立, 卡死
; 单独出现就是空语句, 是合法的 C 语法.
它有时候是故意用的, 比如:
1 | while (getchar() != '\n'); // 故意空转, 把这一行剩下的字符吃干净 |
这里的分号是故意的, 表示”读到换行为止, 循环体什么也不做”. 区别在于你是故意还是手滑 —— 故意写的时候最好加个注释说明
5 基础: 大括号不配对
少一个大括号的报错很有特点:
1 | e2.c:7:1: error: expected declaration or statement at end of input |
关键在 “at end of input”(在输入结束时). 这句话的意思是”我一直读到文件末尾, 还没把该收的收完”
看到 at end of input,
基本可以断定是大括号少了,
而且报的那个行号通常是文件的最后一行,
不是你真正漏掉的位置
真正漏在哪? 得自己找. 有两个办法:
办法一: 用编辑器的括号匹配. 把光标放在某个
{ 上, 编辑器会高亮对应的 }. 一路点下来, 哪个
{ 找不到配对就是它
办法二: 看缩进. 正常写法的缩进是整齐的, 少一个
} 会让后面整段缩进突然变浅.
这个视觉信号很明显
办法三: 从后往前数. 从函数最外层开始, 数每个
{ 和 } 的数量差
大括号不配对还有一种情况: 多了一个
}
这时候报错通常在多出来的那个 } 所在行,
内容类似:
1 | error: expected identifier or '(' before '}' token |
意思变成了”这里不该有 }“. 所以:
- 报
at end of input→ 少了} - 报
before '}'→ 多了}
另外, 养成打 { 的时候顺手把 }
也打上的习惯(很多编辑器有自动补全), 能避免绝大多数这类问题
6 基础: scanf 忘了 &
这个在第一个程序里提过, 但因为踩的人太多, 这里再说一遍
1 | int a; |
scanf 需要的是变量的地址,
也就是”数据该往哪儿放”. 少了 &, 传进去的是
a 的值(一个不确定的垃圾数),
scanf 会把这个数当成地址去写
后果通常是程序直接崩溃, 而且崩溃的位置离出错行很远, 看起来完全无关. 也可能不崩, 但某些变量的值莫名其妙被改掉
为什么这个错误特别难查:
- 有些编译器只给警告不给错误, 程序照样能编译出来
- 崩溃报告指向的是
scanf内部或者干脆是别的函数, 因为写坏的内存是后面才用到的 - 有时候根本看不出异常, 只是某个无关变量的值变了
判断办法: 只要看到 scanf,
就把它的所有参数检查一遍, 除了数组名和指针, 其他变量前面都必须有
&
1 | scanf("%d", &a); // 基本变量, 要 & |
7 基础: 格式符和类型不匹配
printf 的格式符写错了,
编译器通常只给警告, 程序照跑,
但输出是乱的:
1 | double d = 3.14; |
GCC 的警告很直白:
1 | warning: format '%d' expects argument of type 'int', but argument 2 has type 'double' [-Wformat=] |
它甚至在下面把正确格式符 %f 都标出来了.
可惜这是警告, 程序还是生成了, 运行输出:
1 | 用 %d 打 double = 1374389535 |
一个莫名其妙的数字. 原因是 double 在内存里是 8
字节的浮点格式, %d 却按 4 字节整数去读, 读出来的当然不是
3.14
反过来也一样:
1 | int n = 42; |
输出 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 | int a[5]; |
i 取到 5 的时候, a[5] 已经越界了.
但程序照跑不误, 因为 a[5]
那块内存确实存在, 只是不属于这个数组
实测结果是”写完了没崩”. 但越界写的那一个位置, 可能正好是别的变量的地盘:
1 | int a[5]; |
b 的值可能被那次越界写覆盖掉. 这种 bug 极其难查,
因为看起来毫不相干的两个变量互相影响
排查办法: 循环边界是最值得反复检查的地方. 记住两条:
- 长度为
n的数组, 合法下标是0到n-1 - 所以循环条件几乎总是
< n, 而不是<= n
9 进阶: 编译过了但结果不对
从这里开始是逻辑错误, 编译器完全帮不上忙. 程序能编译、能运行、能输出, 只是结果错了
这类问题有个系统的查法, 按顺序排除:
第一步: 确认输入对不对.
在程序最开始把所有输入打印出来. 很多时候问题根本不在算法, 而是
scanf 根本没读到预想的值
第二步: 确认中间结果. 在关键步骤后打印变量的值, 看它是从哪一步开始不对的
第三步: 缩小范围. 如果第 3 步之前都对, 第 5 步开始不对, 那问题就在第 3、4 步之间
第四步: 用简单数据测. 别一上来就用复杂输入. 用最小的例子(比如数组只放 2 个元素)手算一遍, 再和程序输出对比
后面几节讲的就是这些具体手段
10 进阶: 变量没初始化
这是”结果莫名其妙”的头号原因:
1 | int sum; |
sum += i 等价于 sum = sum + i, 而右边的
sum 一开始是内存里的垃圾值.
所以结果是在垃圾值上加 1 到 5
期望是 15, 实测输出是:
1 | 32773 |
数据类型里说过:
局部变量不初始化就是不确定的值. 正确的写法是
int sum = 0;
32773 这个数字本身没有任何意义.
它是那块栈内存里残留的东西, 换个编译器、改下代码、甚至多定义一个变量,
结果就变了
所以看到明显不合逻辑但又不是随机数的结果(比如这种五位六位的怪数), 第一反应就是”某个变量没初始化”
排查办法很简单: 把所有累加、累乘用的变量在定义时就赋好初值.
sum = 0, product = 1,
这类变量永远不要省初始化
11 进阶: 整数除法与精度
运算符与表达式整篇都在说这个, 这里只讲怎么在调试时发现它:
1 | int total = 7, count = 2; |
期望 3.5, 实际输出 3.000000
判断的关键: 结果是”小数部分莫名其妙不见了”或者”总是差一点点”. 这时候去看那个除法的两边是不是都是整数
1 | total / count // 3 两个都是 int |
修法有两条: 强转 (double)
或者让其中一个操作数是浮点字面量 2.0
还有个更隐蔽的变体是用 scanf 读浮点数时用错了格式符:
1 | double d; |
%d 只写 4 个字节, 而 double 是 8 个字节,
剩下一半是垃圾, 算出来的数完全不对. 这个错误的症状是 “输入 3.5,
输出一个天文数字”
12 进阶: 死循环怎么找
程序卡住不动、光标一直闪、什么也不输出 —— 这是死循环
第一步: 加打印定位. 在循环体里打印循环变量:
1 | while (i < 10) { |
如果 i 一直不变, 说明循环里没有改变
i, 或者改变 i
的语句根本执行不到
第二步: 检查 continue 的位置.
这是最隐蔽的一种:
1 | int i = 0; |
i 到 2 时 continue, 直接跳回条件判断, 而
i++ 在 continue 后面,
永远执行不到. i 卡在 2 上死循环
同样的逻辑用 for 写就没事, 因为 for 的
i++ 写在第三格, continue
之后照样会执行
第三步: 检查 while
后面有没有多个分号(第 4 节讲的)
第四步: 检查浮点数的相等判断:
1 | double x = 0.0; |
因为浮点数的精度问题, x 累加 10 次 0.1
之后可能不等于 1.0. 正确的写法是用 <= 或
< 配合误差范围
死循环不一定能”看出来”是死循环. 如果程序看起来只是在慢慢跑, 也可能是循环次数多得离谱
比如:
1 | int i; |
这里不会死循环(101 次很快就跑完), 但如果循环上限是 10 亿, 就会卡很久
判断办法: 让程序自己报进度. 在循环里每隔一定次数打印一次, 就能看出它是”在跑”还是”卡住了”
13 进阶: 打印式调试
上面反复提到”加打印”, 这里集中说一下这个最基本也最好用的手段
核心思想: 在代码的关键位置插入 printf,
把变量的值”打出来看”. 程序的实际执行路径和变量变化,
一比就能看出哪一步跑偏了
1 | int i, a[5] = {3, 1, 4, 1, 5}; |
实测输出:
1 | [探针] 进入循环前 s=0 |
一眼就能看出每一步的累积过程, 哪一步开始不对立刻暴露
几条实用经验:
- 加个可搜索的标记, 比如
[探针]或===. 调试完直接搜这个标记就能全删掉, 不会漏 - 打印的时候带上变量名. 别只打
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 | int a; |
Dev-C++ 默认的警告级别比较低. 可以在 工具 → 编译选项 → 在编译时加入以下命令 里填上:
1 | -Wall |
警告要看, 而且要看懂. 前面第 7 节那个格式符警告, GCC
说得非常清楚, 甚至连正确的 %f 都标出来了. 无视它,
就得多花半小时去查一个本来一眼就能看出的问题
有个细节值得知道: “警告”和”错误”的区别不是严重程度, 而是”能不能继续”
error是语法层面的问题, 编译器无法生成程序(比如少分号, 它根本不知道这句话在哪结束)warning是语义层面的怀疑, 语法上说得通, 但很可能不是你想要的意思
所以警告反而更值得看 —— 语法错误编译器一定会拦下来, 而警告是你唯一的提示
另外, 有些编译器支持 -Werror(把警告当错误处理),
强制你处理掉所有警告. 练习阶段不建议开,
但代码写完之后看看警告列表是个好习惯
15 提高: 二分定位法
程序输出不对, 但不知道错在哪一段. 除了逐段加 printf,
还有个更高效的办法: 二分
思路是砍掉一半代码, 看问题还在不在:
- 把程序后半部分注释掉, 只留下前半部分
- 运行, 看结果对不对
- 如果前半部分就是错的 → 问题在前半, 继续对半砍
- 如果前半部分对 → 问题在后半, 去查后半
这样每轮砍掉一半, 100 行的程序七八次就能定位到具体几行
用注释来”关掉”代码是这里的关键技巧:
1 | /* 先注释掉这一整段, 看前面是否正确 |
二分法的前提是代码能被切成独立的两半. 如果前后两半互相依赖, 就切不开. 这时候退回到”在关键节点打探针”的办法. 两种手段配合用, 定位速度会快很多
16 提高: 崩溃了怎么查
程序直接退出、弹个窗口说”程序已停止工作”、或者干脆什么都不输出就结束 —— 这是崩溃
C 里导致崩溃的常见原因:
除法/取余的除数是 0:
1 | int a = 10, b = 0; |
实测这个程序什么输出都没有, 连前面那行
printf 的内容都没打出来. 退出码是 -1073741676,
也就是 Windows 的整数除零异常
之所以前面的输出也没了, 是因为 printf
的内容先存在缓冲区里,
程序崩溃时缓冲区没来得及刷出去
指针用错. 这个要等指针那篇详细讲, 但常见的两种崩溃原因可以先记住:
1 | int *p; // 野指针, 从没赋过值 |
数组越界写. 越界本身可能不崩, 但如果越界写到了别的变量或返回地址上, 就可能莫名其妙崩在别处
栈溢出. 最常见的是递归没有终止条件, 一路递归下去把栈用光:
1 | int f(int n) { |
排查思路:
- 先定位崩在哪一行. 在可疑位置前面加
printf, 看最后一条打出来的是哪句, 崩溃就在它后面 - 检查所有的除法和取余, 看除数有没有可能是 0
- 检查所有指针在使用前有没有赋过有效地址
- 检查递归有没有终止条件, 以及终止条件能不能到达
有一个很坑的现象: 崩溃位置常常不是出错位置
1 | int a[5]; |
因为越界写只是”写到了不该写的地方”, 写的那一刻可能毫无反应. 真正崩的时候是后面某处用到了被写坏的内存
所以查崩溃不能只看崩溃点, 要往前找. 这也解释了为什么”注释掉一半代码”的二分法特别有效 —— 它能帮你确定崩溃是哪段代码引入的
17 提高: 一个查错清单
把上面的东西整理成一张可以照着走的表. 遇到问题从上往下过一遍:
编译不过
- 看第一条报错, 别管后面那一堆
expected ';'→ 往上看一行, 十有八九是上一行少分号at end of input→ 少了大括号before '}'→ 多了大括号- 报错信息完全看不懂 → 检查中英文标点
编译过了但结果不对
- 在程序开头打印所有输入, 确认读进来的对不对
- 检查所有累加变量的初值(
sum = 0) - 检查所有除法的两边类型(是不是整数除法)
- 检查所有
printf/scanf格式符和变量类型是否匹配 - 检查所有循环边界(
<还是<=)
卡住不动
- 在循环里打印循环变量, 看有没有在变
- 检查
continue会不会跳过步进语句 - 检查
while/for后面有没有多余的分号
崩溃
- 用
printf定位最后执行到哪一行 - 检查除数为 0
- 检查指针和数组越界
- 检查递归的终止条件
最后一件事: 改一个地方, 编译运行一次
新手最容易犯的错是一口气改十几个地方, 然后发现程序还是不对 —— 这时候根本不知道是哪个改动起了作用, 还是引入了新问题
正确的节奏是: 定位到一个具体问题 → 改它 → 编译运行验证 → 再改下一个. 慢一点, 但每一步都是确定的. 十几处一起改看着快, 实际会绕远路
