Menu

C 语言段错误:产生原因与解决方法

段错误(segfault)意味着你的程序访问了不属于它的内存。几乎所有段错误都可以归结为五个原因,本页为每个原因给出最小示例和修复方法,并介绍如何用 gdb 和 AddressSanitizer 找到出错的具体行。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

Segmentation fault (core dumped)

这一行是 C 语言中被搜索最多的错误,但它并没有看上去那么神秘。你的程序向处理器请求某个内存地址,操作系统检查你的进程是否被允许访问这个地址,答案是否定的。于是内核用 SIGSEGV 信号终止了这个进程。

关键在于:崩溃只是症状,崩溃的位置往往并不是 bug 所在。错误的指针通常是在别处、更早的时候产生的,这里只是它第一次被使用的地方。本页介绍几乎涵盖所有段错误的五个原因,然后介绍两个能在几秒内找到真正出错行的工具。

下面会崩溃的示例特意没有做成可运行的代码块,因为它们本来就会崩溃。先读一读,再看紧随其后的修复版本。

“不属于你的内存”是什么意思

程序启动时,操作系统会把几个区域映射到它的地址空间中:代码、全局变量、栈,以及堆当前增长到的范围。地址空间中的其他部分,包括地址 0,都是未映射的。访问未映射的地址,或者向只读地址写入,硬件就会捕获这次访问。

所以段错误并不是编译器抓住了你。它是一道运行时护栏,只有当无效地址恰好位于你已映射的内存页之外时才会触发。这就是为什么同一个 bug 在一台机器上会崩溃,在另一台机器上却看似正常:内存布局不同。

原因 1:解引用 NULL 指针

这是最常见的原因,也最容易修复。地址 0 永远不会被映射,所以通过空指针读写一定会出错。

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = NULL;
    *p = 42;              /* CRASH: writing to address 0 */
    printf("%d\n", *p);
    return 0;
}

现实中更常见的是没有检查的内存分配:

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *data = malloc(1000000000000UL * sizeof(int));  /* fails, returns NULL */
    data[0] = 1;                                        /* CRASH */
    free(data);
    return 0;
}

malloc 无法满足请求时返回 NULL;fopen 在文件不存在时、strchr 在找不到字符时也是如此。凡是可能返回 NULL 的函数,使用其结果之前都要检查。

把指针初始化为 NULL,而不是让它保持未初始化。空指针会立即且明显地崩溃;垃圾指针则可能破坏某些数据,很久之后才崩溃。这种模式的更多内容见空指针。

原因 2:越过数组末尾写入

C 语言不检查数组边界。一个有 10 个元素的数组,下标 10 就是数组后面的那块内存,编译器会毫无怨言地帮你算出这个地址。

#include <stdio.h>

int main(void) {
    int arr[10];

    for (int i = 0; i <= 10; i++) {   /* <= instead of < : one too many */
        arr[i] = i;
    }

    printf("done\n");
    return 0;
}

它会不会崩溃全凭运气。越过局部数组末尾写入四个字节,通常会落在栈上的其他数据上,比如保存的寄存器、另一个变量、返回地址,于是程序破坏了自己,之后在某个毫不相关的地方崩溃。大幅度的越界则会离开已映射的内存页,立即引发段错误。

更夸张的版本一定会崩溃:

#include <stdio.h>

int main(void) {
    int arr[10];
    arr[1000000] = 42;      /* far outside anything mapped: CRASH */
    return 0;
}

修复方法是养成写 i < n 的习惯,并且计算出 n,而不是把它手写两遍:

字符串也有自己的版本:缓冲区没有给结尾的 '\0' 留出空间。

#include <string.h>

int main(void) {
    char name[5];
    strcpy(name, "Alexander");   /* 9 chars + terminator into 5 bytes */
    return 0;
}

strcpy 完全不知道 name 有多大。改用 snprintf,因为你会告诉它大小,所以它知道:

原因 3:free 之后继续使用指针(悬空指针)

调用 free(p) 之后,内存被归还给分配器。指针仍然保存着原来的地址,但那个地址已经不属于你了。

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = malloc(sizeof *p);
    *p = 42;
    free(p);

    printf("%d\n", *p);   /* use after free: may print garbage, may crash */
    free(p);              /* double free: usually aborts or corrupts the heap */
    return 0;
}

与之相关的陷阱是返回局部变量的地址。函数一返回,它的栈帧就消失了:

#include <stdio.h>

int *make_number(void) {
    int value = 42;
    return &value;      /* the frame dies here; the pointer dangles */
}

int main(void) {
    int *p = make_number();
    printf("%d\n", *p);   /* undefined: garbage, or a crash */
    return 0;
}

根据你的本意,有两种修复方法。返回值而不是指针,或者在堆上分配内存,由调用者负责释放:

在 free 之后立即把指针设为 NULL,是一个成本很低的防御习惯:它把悄无声息的释放后使用变成一次立即、明显的空指针解引用崩溃,并且让第二次 free(p) 变得无害,因为标准规定 free(NULL) 什么也不做。关于内存分配的部分,请参阅动态内存和内存泄漏。

原因 4:失控递归导致栈溢出

每次函数调用都会在栈上放一个栈帧,而栈是一块大小固定的区域(通常为 8 MB)。没有基线条件的递归,或者基线条件永远到达不了的递归,会一直跑出栈的末尾。

#include <stdio.h>

int countdown(int n) {
    printf("%d\n", n);
    return countdown(n - 1);    /* no base case: never stops */
}

int main(void) {
    return countdown(5);
}

如果递归跳过了基线条件,也会发生同样的情况:

int f(int n) {
    if (n == 0) return 1;
    return n * f(n - 2);    /* from an odd n, never equals 0 */
}

每个递归函数都需要一个从任何输入出发都能到达的基线条件:

巨大的局部数组也会导致栈溢出:在函数内写 int buffer[10000000]; 会向栈申请 40 MB,第一次写入时就会出错。大缓冲区请用 malloc 在堆上分配。相关的大小请参阅栈与堆,基线条件的设计请参阅递归。

原因 5:向字符串字面量写入

这个原因常让人意外,因为代码看起来人畜无害。

#include <stdio.h>

int main(void) {
    char *s = "hello";
    s[0] = 'H';          /* CRASH: string literals are read-only */
    printf("%s\n", s);
    return 0;
}

字符串字面量存放在可执行文件的只读段中。char *s = "hello" 指向那里;通过这个指针写入就是一次保护错误,操作系统会像对待未映射访问一样,把它报告为段错误。

修复方法是使用数组,它会得到一份属于自己、可以修改的副本:

把指向字面量的指针声明为 const char *,就能把这个运行时崩溃变成编译期错误,这无疑更好。养成这个习惯。

找到真正出错的行:gdb

用 -g 编译,让可执行文件带上调试符号,然后在调试器中运行它:

gcc -g program.c -o program
gdb ./program

在 gdb 中:

(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x0000555555555151 in process_item (item=0x0) at program.c:14
14          return item->count * 2;

(gdb) backtrace
#0  process_item (item=0x0) at program.c:14
#1  0x000055555555518a in main () at program.c:23

(gdb) print item
$1 = (struct Item *) 0x0

三个命令就能完成大部分工作。run 启动程序,并在出错的地方停下。backtrace(或 bt)显示到达那里的调用链,第 #1 帧通常才是错误指针真正产生的地方。print 查看变量,而 item = 0x0 直接指出了问题所在。

在 macOS 上,对应的做法是 lldb ./program,然后输入 run 和 bt。

更快地找到它:AddressSanitizer

更好的办法是让编译器给程序插桩。AddressSanitizer 会在无效访问发生的那一刻就捕获它,包括那些本来不会崩溃的访问:

gcc -g -fsanitize=address program.c -o program
./program
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
WRITE of size 4 at 0x602000000010 thread T0
    #0 0x4011f6 in main program.c:11

0x602000000010 is located 0 bytes inside of 4-byte region
freed by thread T0 here:
    #1 0x4011c9 in main program.c:10
previously allocated by thread T0 here:
    #2 0x4011a6 in main program.c:8

这份报告指出了 bug 的类别、出错的那一行、释放内存的那一行,以及分配内存的那一行。它是调试 C 语言内存 bug 最有效的工具,支持 Linux 和 macOS 上的 GCC 和 clang,代价是运行时间大约变成 2 倍,而这在开发阶段无关紧要。

搭配 -fsanitize=undefined,可以同时捕获有符号整数溢出和其他未定义行为:

gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program

valgrind ./program 是不需要重新编译的替代方案,能报告同样类别的错误,还能报告内存泄漏。

遇到段错误时的检查清单

  1. 用 -g -Wall -Wextra -fsanitize=address,undefined 重新构建并再次运行。 大多数情况下,报告会直接指出出错的行,问题就解决了。
  2. 如果没有 sanitizer 可用,就在 gdb 中运行并执行 backtrace。 要看第 1 帧,而不只是第 0 帧。
  3. 检查崩溃那一行上的每个指针。 逐个打印;0x0 说明是空指针,而像 0x7fff5fc01000 这样乱七八糟的值通常意味着未初始化或已被释放。
  4. 追问这个指针是从哪里来的。 没有检查的 malloc 或 fopen?一个已经返回的函数中局部变量的地址?在 free 之后使用的指针?
  5. 检查崩溃附近的每个循环边界,看看是否把 < 写成了 <=。
  6. 如果栈跟踪有几千帧那么深, 那就是失控的递归,根本不是指针 bug。

预防段错误

让段错误变得罕见的习惯:

  • 始终用 -Wall -Wextra 编译,把警告当作 bug 对待。
  • 初始化每个指针,没有更好的值就设为 NULL。
  • 检查 malloc、calloc、realloc 和 fopen 的返回值。
  • 释放之后立即把指针设为 NULL。
  • 使用 snprintf 和 fgets,而不是 sprintf 和 gets。
  • 把指向字符串字面量的指针声明为 const char *。
  • 优先使用 sizeof arr / sizeof arr[0],而不是写死的长度。
  • 在 CI 中用 AddressSanitizer 运行测试套件。

段错误其实是一种友好的失败方式:它告诉你出问题了。同一类 bug 如果悄悄破坏了相邻的变量,三个函数之后才产生错误的结果,那要糟糕得多,而上面这些工具两种情况都能发现。

常见问题

C 语言中的段错误是什么?

当你的程序访问了不允许访问的内存时,操作系统触发的一种崩溃:通过无效指针读写、越过数组末尾进入未映射的内存页,或者栈溢出。内核会向进程发送 SIGSEGV 信号,终止进程并打印 "Segmentation fault (core dumped)"。

怎样找到段错误发生的位置?

带调试符号编译,并在调试器中运行:先 gcc -g program.c -o program,再 gdb ./program,输入 run,崩溃后输入 backtrace。它会打印出确切的文件和行号。对于内存 bug,更快的方法是 gcc -g -fsanitize=address program.c -o program,只要运行程序,就会打印一份完整的报告,说明哪里出了什么问题。

为什么我的 C 程序只是有时候出现段错误?

因为无效访问是未定义行为,而不是必然崩溃。越过数组末尾写入一个元素,往往落在你的进程确实拥有的内存里,所以没有任何东西阻止你,它只是破坏了相邻的变量。只有当错误地址恰好落在已映射的内存页之外时,才会出现段错误,而这取决于那一次构建和运行的内存布局。

出现段错误是否意味着内存泄漏?

不是,它们是相反的问题。内存泄漏是你分配了却从未释放的内存:程序继续运行,占用慢慢增长。段错误则是访问了不属于你的内存。重复释放内存或者在释放后继续使用指针会导致段错误;忘记释放会导致内存泄漏。

Coddy 编程语言插图

和 Coddy 一起学编程

开始