10、段错误(Segmentation Fault)根因分析
段错误,英文叫 Segmentation Fault。说白了就是程序访问了它不该访问的内存。
我刚开始写 C 语言那会儿,最怕的就是这个。一跑程序,屏幕上蹦出个 "Segmentation fault (core dumped)",然后程序就挂了。没有任何提示,没有任何日志。嗯,那时候真是头皮发麻。
后来做嵌入式开发久了,我发现段错误其实没那么神秘。它背后就那么几种原因。你只要掌握了排查方法,大部分段错误都能在十分钟内定位。
段错误的本质
段错误是操作系统对内存访问违规的一种保护机制。当你的程序试图访问一个未被映射的虚拟地址,或者试图写入一个只读的内存区域时,MMU(内存管理单元)就会触发一个异常,操作系统收到这个异常后,就会向你的进程发送 SIGSEGV 信号。进程默认处理这个信号的方式就是终止并产生 core dump。
说白了,就是操作系统在说:「喂,你越界了,我不陪你玩了。」
常见的段错误根因
根据我这些年的经验,段错误的根因可以归纳为以下几类。我按出现频率排了个序:
| 根因类型 | 出现频率 | 典型场景 |
|---|---|---|
| 空指针解引用 | 极高 | malloc 失败后未检查返回值 |
| 数组越界 | 高 | 循环条件写错,访问了 arr[n] 而不是 arr[n-1] |
| 栈溢出 | 中 | 递归太深,或者局部变量太大 |
| 野指针 | 中 | free 之后继续使用指针 |
| 写只读内存 | 低 | 试图修改字符串常量 |
| 对齐访问违规 | 低 | ARM 平台上对非对齐地址进行 4 字节访问 |
空指针解引用
这是最常见的段错误原因。没有之一。
为什么会这样?因为很多程序员写代码时,默认 malloc 一定会成功。但在嵌入式系统中,内存资源是有限的。malloc 返回 NULL 的情况并不少见。
// 错误示例
int *p = (int *)malloc(sizeof(int) * 100);
*p = 42; // 如果 malloc 失败,p 为 NULL,这里就段错误了
// 正确做法
int *p = (int *)malloc(sizeof(int) * 100);
if (p == NULL) {
// 处理错误,比如返回错误码
return -1;
}
*p = 42;
我个人习惯是,每次 malloc 之后,紧接着就写 NULL 检查。这个习惯帮我避免了很多线上事故。
数组越界
数组越界是 C 语言的经典问题。C 语言不会帮你检查数组边界,你访问 arr[100] 时,它不会报错——直到你踩到了不该踩的内存。
我记得有一次排查一个段错误,查了整整一个下午。最后发现是一个 for 循环的边界条件写成了 i <= n 而不是 i < n。就多了一个等号,程序跑了两个小时才崩一次。
// 越界示例
int arr[10];
for (int i = 0; i <= 10; i++) { // 注意:i <= 10 越界了
arr[i] = i; // 当 i=10 时,写入 arr[10],越界
}
// 正确写法
for (int i = 0; i < 10; i++) {
arr[i] = i;
}
栈溢出
嵌入式系统的栈空间通常很小。比如在一些 MCU 上,栈可能只有 1KB 或 2KB。如果你在函数内部定义了一个大数组,或者递归调用太深,栈空间就会被耗尽。
栈溢出时,程序会尝试访问栈底之外的内存,这通常会触发段错误。
// 栈溢出示例
void deep_recursion(int n) {
char buffer[1024]; // 每次递归都分配 1KB
if (n > 0) {
deep_recursion(n - 1); // 递归 10 次就需要 10KB 栈空间
}
}
// 如果栈大小只有 4KB,调用 deep_recursion(5) 就会溢出
我曾经在一个项目中遇到过类似的问题。一个同事写了一个递归函数,在 PC 上测试没问题,但部署到嵌入式设备上就段错误。原因就是 PC 的栈空间大,而嵌入式设备的栈空间小。后来我们把递归改成了迭代,问题就解决了。
野指针
野指针是指向已释放内存的指针。你 free 了一个指针,但没有把它置为 NULL。后续代码又通过这个指针访问内存,这时候那块内存可能已经被分配给其他数据了。
// 野指针示例
int *p = (int *)malloc(sizeof(int));
*p = 10;
free(p);
// p 现在是野指针
*p = 20; // 段错误!或者更糟——静默地破坏其他数据
我的建议是:free 之后立即将指针置为 NULL。这样后续再使用这个指针时,就会触发空指针解引用,而不是野指针访问。空指针比野指针好排查得多。
// 安全做法
free(p);
p = NULL;
写只读内存
这个错误比较隐蔽。比如你定义了一个字符串常量,然后试图修改它:
char *str = "hello";
str[0] = 'H'; // 段错误!字符串常量存储在只读区
正确的做法是用数组来存储可修改的字符串:
char str[] = "hello";
str[0] = 'H'; // 正确,数组存储在栈上
段错误的排查方法
遇到段错误,不要慌。按照下面的步骤来排查:
- 看 core dump:如果系统生成了 core dump 文件,直接用 gdb 加载它。
gdb ./your_program core,然后输入bt查看调用栈。这是最快的方法。 - 加打印:如果没有 core dump,就在可疑的代码段前后加 printf,看程序执行到哪里崩的。
- 用 AddressSanitizer:如果编译环境支持,加上
-fsanitize=address编译选项。它能精确告诉你哪行代码访问了非法内存。 - 静态分析:用 splint、cppcheck 等工具扫描代码,它们能发现很多潜在的指针问题。
段错误根因分析流程图
下面这张图总结了段错误的排查思路,你可以把它当作一个决策树来用:
避坑指南
我曾经在一个项目中,被一个段错误折磨了整整三天。现象很奇怪:程序在某个函数里偶尔崩溃,但同样的输入,有时候崩有时候不崩。后来发现是一个全局变量被另一个线程意外修改了,导致指针变成了非法值。
从那以后,我总结了几条经验:
- 多线程环境下,对共享数据的访问一定要加锁。 不加锁的后果就是数据竞争,数据竞争会导致指针值被意外修改,然后段错误。
- 不要相信任何外部输入。 用户输入、网络数据、文件内容,都可能是恶意的或格式错误的。在解引用指针之前,先检查它是否合法。
- 编译时打开所有警告。 用
-Wall -Wextra -Werror编译。很多段错误在编译阶段就能被警告出来。 - 单元测试覆盖边界条件。 比如数组长度为 0 的情况、malloc 返回 NULL 的情况、递归深度为 0 的情况。这些边界条件最容易触发段错误。
段错误不可怕。可怕的是你面对段错误时没有思路。掌握了上面这些根因和排查方法,你就能在几分钟内定位问题。嗯,剩下的就是修复代码了。
公众号:蓝海资料掘金营,微信deep3321