前面已经学习了:
变量
输入输出
判断
循环
函数
数组
字符串
指针
结构体
动态内存
文件
头文件
多文件
Makefile
学习到这里,代码开始变复杂。
于是会经常遇到:
编译报错
链接报错
程序崩溃
输出不对
数据莫名其妙改变
这时候最重要的不是:
“多试几次”
而是学会:
定位问题。
一、什么是 Bug
Bug 就是程序中的错误。
例如:
int a = 10;
int b = 0;
printf("%d\n", a / b);程序出现错误。
又比如:
int arr[5];
arr[10] = 100;也是错误。
常见错误可以分成:
编译错误
链接错误
运行时错误
逻辑错误
二、编译错误
编译器无法把 .c 代码编译成目标文件。
例如:
#include <stdio.h>
int main() {
printf("Hello\n"
return 0;
}少了:
)
可能出现:
error: expected ')' before 'return'
这类问题属于:
编译错误。
三、常见编译错误
1. 少分号
错误:
int a = 10正确:
int a = 10;2. 括号不匹配
错误:
if (a > 10 {正确:
if (a > 10) {3. 大括号不匹配
例如:
int main() {
if (a > 10) {
printf("A\n");
return 0;
}少一个:
}
4. 拼写错误
例如:
pritnf("Hello");正确:
printf("Hello");5. 忘记头文件
例如使用:
malloc(sizeof(int));却没有:
#include <stdlib.h>应该补上。
四、警告不是错误
编译时可能出现:
warning
例如:
int a = 10.5;可能出现类型转换相关警告。
程序也许仍然可以生成。
但是:
不要习惯性忽略 warning。
推荐使用:
gcc -Wall -Wextra例如:
gcc -Wall -Wextra main.c -o main这样可以让编译器告诉你更多潜在问题。
五、链接错误
有时候:
.c
可以成功编译。
但是最后生成程序时失败。
例如:
#include "hello.h"
int main() {
hello();
return 0;
}hello.h:
void hello();但是你没有编译:
hello.c
执行:
gcc main.c -o main可能出现:
undefined reference to `hello`
这属于:
链接错误。
六、编译错误和链接错误
可以这样区分:
编译错误
↓
代码语法/类型等有问题
↓
.c → .o 失败
链接错误
↓
各个 .o 之间找不到需要的东西
↓
.o → 可执行文件失败
例如:
main.c
↓
编译
↓
main.o
成功。
但是:
main.o
↓
寻找 hello()
↓
找不到实现
↓
链接失败
七、运行时错误
程序已经编译成功:
gcc
↓
成功
但是运行以后崩溃。
例如:
int *p = NULL;
printf("%d\n", *p);程序可能直接:
崩溃
这就是:
运行时错误。
八、段错误
Linux 下经常看到:
Segmentation fault
简称:
Segfault
Windows 环境下可能表现为:
程序崩溃
访问冲突
常见原因:
空指针解引用
野指针
数组越界
use-after-free
写入不可访问内存
九、空指针
错误:
int *p = NULL;
*p = 100;问题:
p
↓
NULL
↓
没有有效对象
却进行:
*p = 100;这是非法访问。
正确思路:
if (p != NULL) {
*p = 100;
}十、数组越界
例如:
int arr[5];
arr[5] = 100;合法下标:
0
1
2
3
4
arr[5] 已经越界。
可以记:
int arr[5]
↓
5 个元素
↓
下标 0 ~ 4
十一、数组越界为什么危险
最麻烦的地方是:
越界不一定马上崩溃。
例如:
int arr[5];
arr[100] = 10;程序可能:
看起来没问题
也可能:
数据损坏
也可能:
直接崩溃
所以:
程序没有崩溃 ≠ 代码正确。
十二、字符串最容易越界
例如:
char name[5];
strcpy(name, "Alice");"Alice" 实际需要:
A
l
i
c
e
\0
一共:
6 个 char
而:
char name[5];只有 5 个位置。
因此发生越界。
应该:
char name[6];
strcpy(name, "Alice");或者根据实际需求预留足够空间。
十三、字符串的 \0
这是 C 字符串最常见的坑之一。
例如:
char str[6] = "Hello";实际上:
H
e
l
l
o
\0
所以:
"Hello"
需要:
6 个 char
不是 5 个。
十四、scanf 的坑
例如:
char name[20];
scanf("%s", name);如果输入特别长:
abcdefghijklmnopqrstuvwxyz...
可能导致:
缓冲区溢出。
更安全的基本写法:
scanf("%19s", name);因为需要留一个位置给:
\0
十五、scanf 和换行问题
例如:
int age;
scanf("%d", &age);
char name[50];
fgets(name, sizeof(name), stdin);可能发现:
fgets()
直接读取了一个空行。
原因是:
scanf("%d")
读取数字以后:
\n
可能还留在输入缓冲区。
这是初学阶段非常常见的问题。
十六、解决 scanf + fgets
简单场景可以使用:
int c;
while ((c = getchar()) != '\n' && c != EOF) {
}然后:
fgets(name, sizeof(name), stdin);完整:
int age;
char name[50];
scanf("%d", &age);
int c;
while ((c = getchar()) != '\n' && c != EOF) {
}
fgets(name, sizeof(name), stdin);十七、指针错误
例如:
int a = 10;
int *p;
*p = 20;这里:
p
没有初始化。
它里面可能是一个随机地址。
这就是危险的。
正确:
int a = 10;
int *p = &a;
*p = 20;十八、野指针
例如:
int *p;
printf("%d\n", *p);p 没有指向有效内存。
不要这样写。
推荐:
int *p = NULL;如果暂时没有指向对象:
明确设为 NULL
十九、悬空指针
例如:
int *p = malloc(sizeof(int));
*p = 100;
free(p);
printf("%d\n", *p);释放以后:
p
↓
旧地址
↓
内存已经释放
这时候使用 *p 就是:
use-after-free。
二十、推荐 free 后置 NULL
free(p);
p = NULL;这样可以降低误用旧地址的风险。
但注意:
free(p)
已经完成释放。
p = NULL;只是让指针不再保存那个旧地址。
二十一、double free
错误:
int *p = malloc(sizeof(int));
free(p);
free(p);同一块内存被释放两次。
这是严重的内存管理错误。
推荐:
free(p);
p = NULL;并且从设计上明确:
哪一段代码负责释放这块内存。
二十二、内存泄漏
例如:
void test() {
int *p = malloc(sizeof(int) * 100);
// 使用 p
}如果没有:
free(p);那么申请的内存可能无法再被程序访问。
就是:
内存泄漏。
二十三、malloc 和 free 必须配对
可以养成:
malloc
↓
使用
↓
free
的习惯。
例如:
int *p = malloc(sizeof(int) * 10);
if (p == NULL) {
return 1;
}
/* 使用 */
free(p);
p = NULL;二十四、realloc 的坑
危险写法:
p = realloc(p, new_size);如果失败:
realloc 返回 NULL
那么原来的指针可能已经无法通过 p 保存。
更推荐:
int *temp = realloc(p, new_size);
if (temp == NULL) {
// p 仍然有效
} else {
p = temp;
}二十五、结构体指针错误
例如:
typedef struct {
int age;
} Student;
Student *p = NULL;
p->age = 18;错误。
因为:
p
↓
NULL
不能使用:
p->age二十六、. 和 ->
写错
结构体变量:
Student s;
s.age = 18;结构体指针:
Student *p = &s;
p->age = 18;不要写反:
p.age也不要在普通结构体变量上随意:
s->age简单记:
变量
↓
.
指针
↓
->
二十七、文件操作的坑
例如:
FILE *fp = fopen("data.txt", "r");
fscanf(fp, "%d", &x);如果:
data.txt
不存在
那么:
fp == NULL
继续使用:
fscanf(fp, ...);就可能出问题。
所以:
if (fp == NULL) {
printf("打开失败\n");
return 1;
}二十八、不要忘记 fclose
打开:
FILE *fp = fopen(...);结束:
fclose(fp);文件操作基本习惯:
fopen
↓
检查 NULL
↓
读/写
↓
fclose
二十九、文件模式写错
例如:
fopen("data.txt", "w");会覆盖原文件。
如果只是想追加:
fopen("data.txt", "a");如果只想读取:
fopen("data.txt", "r");所以使用 fopen() 时,要先想清楚:
我要读取?
我要覆盖写?
我要追加?
我要二进制?
三十、逻辑错误
这是最麻烦的一类:
程序不崩溃,但是结果不对。
例如:
int sum = 0;
for (int i = 0; i < 10; i++) {
sum += i;
}
printf("%d\n", sum);如果你本来想计算:
1 + 2 + ... + 10
那么结果显然不对。
但是:
编译成功
程序运行
甚至完全不崩溃
这就是:
逻辑错误。
三十一、调试的核心思想
不要看到 Bug 就直接乱改。
推荐:
发现问题
↓
复现问题
↓
确定错误发生的位置
↓
观察变量
↓
缩小范围
↓
找到原因
↓
修改
↓
重新测试
这才是调试。
三十二、第一种方法:printf 调试
最简单的调试方法:
printf()例如:
int a = 10;
int b = 20;
printf("DEBUG: a = %d\n", a);
printf("DEBUG: b = %d\n", b);
int c = a + b;
printf("DEBUG: c = %d\n", c);通过输出:
DEBUG: a = 10
DEBUG: b = 20
DEBUG: c = 30
逐步观察程序状态。
三十三、为什么 printf 很有用
假设:
for (int i = 0; i < n; i++) {
arr[i] *= 2;
}结果不对。
可以:
for (int i = 0; i < n; i++) {
printf(
"DEBUG: i=%d arr[i]=%d\n",
i,
arr[i]
);
arr[i] *= 2;
}这样就可以看到:
到底是哪一次循环出了问题
三十四、打印地址
学过指针之后,可以打印地址:
printf("%p\n", (void *)p);例如:
int a = 10;
int *p = &a;
printf("a address = %p\n", (void *)&a);
printf("p value = %p\n", (void *)p);应该看到:
a address = ...
p value = ...
两者应该对应同一个地址。
三十五、打印结构体状态
例如:
printf(
"name=%s age=%d score=%.2f\n",
s.name,
s.age,
s.score
);特别适合:
学生管理系统
课程表
配置文件
这类项目。
三十六、第二种方法:GDB
printf 是最简单的调试方式。
更专业的是:
GDB
GDB 是:
GNU Debugger
可以:
设置断点
单步执行
查看变量
查看调用栈
查看内存
三十七、使用 -g 编译
使用 GDB 时:
gcc -g main.c -o main-g 会加入调试信息。
多文件:
gcc -g main.c student.c file.c -o main然后:
gdb ./mainWindows 下如果使用 MinGW/GDB,则命令形式通常类似:
gdb main.exe三十八、GDB 设置断点
进入 GDB:
gdb ./main设置:
break main
或者:
break 10
然后运行:
run
程序会在断点暂停。
三十九、单步执行
常见命令:
next
执行下一行。
step
进入函数。
continue
继续运行到下一个断点。
四十、查看变量
GDB:
print a
或者:
p a
例如:
(gdb) p a
$1 = 10
查看指针:
p p
查看指针指向的值:
p *p
四十一、查看调用栈
如果程序崩溃,可以使用:
backtrace
或者:
bt
它可以显示:
main()
↓
foo()
↓
bar()
↓
崩溃
这样可以知道:
程序是从什么函数一路走到错误位置的。
四十二、第三种方法:Sanitizer
现代 GCC / Clang 中非常有用的工具:
AddressSanitizer
简称:
ASan
可以帮助发现:
数组越界
use-after-free
double free
部分内存问题
编译:
gcc -fsanitize=address -g main.c -o main多文件:
gcc -fsanitize=address -g \
main.c student.c file.c \
-o main运行程序时,如果发生很多典型内存错误,Sanitizer 会输出详细报告。
四十三、UndefinedBehaviorSanitizer
还有:
UBSan
可以检测一些:
未定义行为
例如:
gcc -fsanitize=undefined -g main.c -o main也可以:
gcc -fsanitize=address,undefined -g main.c -o main开发阶段非常有帮助。
四十四、Valgrind
Linux 环境下还经常使用:
Valgrind
它可以帮助检查:
内存泄漏
非法内存访问
未初始化内存
例如:
valgrind --leak-check=full ./main不过不同系统上的可用性和安装方式不同。
当前阶段知道:
GDB
Sanitizer
Valgrind
分别用于不同类型的调试即可。
四十五、Makefile 加入调试选项
之前学习了:
CC = gcc
CFLAGS = -Wall -Wextra
调试时可以:
CC = gcc
CFLAGS = -Wall -Wextra -g -fsanitize=address,undefined
然后:
make这样整个项目都会使用调试选项构建。
四十六、Debug 和 Release
项目较大以后,经常区分:
Debug
Release
Debug:
方便调试
保留调试信息
启用检查
Release:
用于正式发布
通常使用优化
可能关闭部分调试选项
简单 Makefile 可以:
CFLAGS_DEBUG = -Wall -Wextra -g -fsanitize=address,undefined
CFLAGS_RELEASE = -Wall -Wextra -O2
以后再根据需要选择。
四十七、编译优化
例如:
gcc -O2 main.c -o main-O2 表示开启一定程度的优化。
但是调试阶段通常不建议一开始就追求高优化。
比较适合:
开发 / 调试
↓
-g
↓
Sanitizer
发布
↓
-O2
四十八、遇到 Bug 时先看错误信息
例如:
main.c:25:10:
error: ...
这里包含:
文件
行号
列号
错误信息
所以首先看:
哪个文件?
哪一行?
什么错误?
而不是直接从头到尾乱找。
四十九、错误行不一定是真正原因
例如:
int *p = NULL;
foo(p);然后:
void foo(int *p) {
printf("%d\n", *p);
}程序可能在:
printf
这里崩溃。
但是:
真正的问题可能早在 main() 里传入了
NULL。
所以调试不能只盯着崩溃的那一行。
要沿着:
数据来源
↓
函数调用
↓
变量变化
↓
最终错误
往回追。
五十、最实用的调试思路:缩小范围
假设:
程序输出错误
不要一次改 20 行。
可以:
先确认输入是否正确
↓
确认变量
↓
确认循环次数
↓
确认中间结果
↓
确认最终结果
一次只排除一个可能性。
五十一、二分法调试
程序有 1000 行。
不知道哪里错。
可以先:
检查前 500 行产生的数据
如果正确:
错误在后 500 行
再:
检查 250 行
不断缩小范围。
这就是:
二分定位问题。
五十二、断言 assert
C 标准库提供:
assert()需要:
#include <assert.h>例如:
int age = 18;
assert(age >= 0);如果条件:
true
继续执行。
如果:
false
程序会报告断言失败。
五十三、assert 的用途
例如:
void process(int *p) {
assert(p != NULL);
*p = 100;
}意思是:
我认为进入这个函数时
p 必须不是 NULL
如果程序出现违反这个假设的情况:
立刻停下来
这对定位 Bug 非常方便。
五十四、不要把 assert 当普通输入检查
例如:
int age;
scanf("%d", &age);
assert(age >= 0);用户输入:
-10
程序就退出。
但实际程序可能应该:
提示错误
要求重新输入
所以:
assert
↓
检查程序员认为“不应该发生”的情况
而不是替代正常的用户输入验证。
五十五、常见坑总结
目前最重要的错误:
数组
├─ 越界
字符串
├─ \0
├─ 缓冲区溢出
指针
├─ 未初始化
├─ NULL
├─ 悬空指针
└─ 错误解引用
动态内存
├─ 忘记 free
├─ double free
├─ use-after-free
└─ realloc 使用不当
结构体
├─ . / ->
└─ NULL 指针
文件
├─ fopen 失败
├─ 忘记 fclose
└─ 模式错误
多文件
├─ undefined reference
├─ 重复定义
└─ 声明 / 定义混乱
五十六、一个完整的调试流程
以后遇到 Bug,可以按照:
① 让 Bug 稳定复现
↓
② 看编译器 / 运行时错误
↓
③ 确定文件和位置
↓
④ 检查输入
↓
⑤ 打印关键变量
↓
⑥ 检查指针 / 数组边界
↓
⑦ 检查内存生命周期
↓
⑧ 使用 GDB / Sanitizer
↓
⑨ 找到真正原因
↓
⑩ 修复后重新测试
五十七、综合示例:找 Bug
下面代码:
#include <stdio.h>
#include <stdlib.h>
int main() {
int n = 5;
int *arr = malloc(sizeof(int) * n);
for (int i = 0; i <= n; i++) {
arr[i] = i * 10;
}
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
free(arr);
return 0;
}看起来似乎没什么问题。
但是:
for (int i = 0; i <= n; i++)这里是:
i <= 5
所以会访问:
arr[5]
但是:
arr
只有:
arr[0]
arr[1]
arr[2]
arr[3]
arr[4]
因此应该:
for (int i = 0; i < n; i++) {五十八、使用 Sanitizer 检查
编译:
gcc -Wall -Wextra -g \
-fsanitize=address,undefined \
main.c -o main运行:
./mainSanitizer 很可能会报告类似:
heap-buffer-overflow
并指出:
哪一行
什么访问
这比单纯看程序输出更容易发现内存越界。
五十九、本章最重要的习惯
以后写 C 代码,逐渐养成:
编译:
-Wall -Wextra
调试:
-g
内存问题:
-fsanitize=address,undefined
动态内存:
malloc ↔ free
指针:
初始化
数组:
检查边界
字符串:
预留 \0
文件:
fopen → 检查 → 使用 → fclose
代码修改:
一次一个问题
六十、练习
练习 1
找出下面代码的问题:
int arr[5];
for (int i = 0; i <= 5; i++) {
arr[i] = i;
}修改为正确代码。
练习 2
找出:
int *p;
*p = 10;的问题。
要求使用一个真正的变量让 p 正确指向它。
练习 3
修复:
int *p = malloc(sizeof(int));
*p = 10;
free(p);
printf("%d\n", *p);要求正确处理释放后的指针。
练习 4
故意写一个:
内存泄漏
然后自己修改成正确版本。
练习 5
故意写一个数组越界程序。
使用:
-fsanitize=address观察编译运行后的错误报告。
练习 6
创建:
main.c
student.c
student.h
故意不参与编译:
student.c
观察:
undefined reference
然后修复。
练习 7
使用 GDB:
gcc -g main.c -o main
gdb ./main练习:
break main
run
next
print
continue
Comments NOTHING