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 检查。这个习惯帮我避免了很多线上事故。

小技巧: 在嵌入式开发中,我建议尽量使用静态分配或栈分配,减少堆的使用。堆上的内存管理容易出问题,而且 malloc 失败后的处理逻辑往往比正常逻辑还复杂。

数组越界

数组越界是 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';  // 正确,数组存储在栈上

段错误的排查方法

遇到段错误,不要慌。按照下面的步骤来排查:

  1. 看 core dump:如果系统生成了 core dump 文件,直接用 gdb 加载它。gdb ./your_program core,然后输入 bt 查看调用栈。这是最快的方法。
  2. 加打印:如果没有 core dump,就在可疑的代码段前后加 printf,看程序执行到哪里崩的。
  3. 用 AddressSanitizer:如果编译环境支持,加上 -fsanitize=address 编译选项。它能精确告诉你哪行代码访问了非法内存。
  4. 静态分析:用 splint、cppcheck 等工具扫描代码,它们能发现很多潜在的指针问题。
核心思路: 段错误的排查,本质上就是「找到程序访问非法内存的那一行代码」。core dump 和 AddressSanitizer 是最有效的工具,优先使用它们。

段错误根因分析流程图

下面这张图总结了段错误的排查思路,你可以把它当作一个决策树来用:

段错误根因分析流程图 程序段错误 有 core dump? (gdb bt) gdb 查看调用栈 定位崩溃行 加 printf 二分定位 缩小范围 定位到行? 分析根因 空指针/越界/野指针 启用 AddressSanitizer 重新编译运行 修复代码,验证通过

避坑指南

我曾经在一个项目中,被一个段错误折磨了整整三天。现象很奇怪:程序在某个函数里偶尔崩溃,但同样的输入,有时候崩有时候不崩。后来发现是一个全局变量被另一个线程意外修改了,导致指针变成了非法值。

从那以后,我总结了几条经验:

  • 多线程环境下,对共享数据的访问一定要加锁。 不加锁的后果就是数据竞争,数据竞争会导致指针值被意外修改,然后段错误。
  • 不要相信任何外部输入。 用户输入、网络数据、文件内容,都可能是恶意的或格式错误的。在解引用指针之前,先检查它是否合法。
  • 编译时打开所有警告。-Wall -Wextra -Werror 编译。很多段错误在编译阶段就能被警告出来。
  • 单元测试覆盖边界条件。 比如数组长度为 0 的情况、malloc 返回 NULL 的情况、递归深度为 0 的情况。这些边界条件最容易触发段错误。
个人建议: 在嵌入式开发中,我习惯在代码里加一个「内存健康检查」函数。每隔一段时间,或者每次进入关键函数时,检查一下关键指针是否为空、关键数组是否越界。虽然会多花一点 CPU 时间,但能提前发现很多问题。

段错误不可怕。可怕的是你面对段错误时没有思路。掌握了上面这些根因和排查方法,你就能在几分钟内定位问题。嗯,剩下的就是修复代码了。


公众号:蓝海资料掘金营,微信deep3321