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 是不需要重新编译的替代方案,能报告同样类别的错误,还能报告内存泄漏。
遇到段错误时的检查清单
- 用
-g -Wall -Wextra -fsanitize=address,undefined重新构建并再次运行。 大多数情况下,报告会直接指出出错的行,问题就解决了。 - 如果没有 sanitizer 可用,就在 gdb 中运行并执行
backtrace。 要看第 1 帧,而不只是第 0 帧。 - 检查崩溃那一行上的每个指针。 逐个打印;
0x0说明是空指针,而像0x7fff5fc01000这样乱七八糟的值通常意味着未初始化或已被释放。 - 追问这个指针是从哪里来的。 没有检查的
malloc或fopen?一个已经返回的函数中局部变量的地址?在free之后使用的指针? - 检查崩溃附近的每个循环边界,看看是否把
<写成了<=。 - 如果栈跟踪有几千帧那么深, 那就是失控的递归,根本不是指针 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 程序只是有时候出现段错误?
因为无效访问是未定义行为,而不是必然崩溃。越过数组末尾写入一个元素,往往落在你的进程确实拥有的内存里,所以没有任何东西阻止你,它只是破坏了相邻的变量。只有当错误地址恰好落在已映射的内存页之外时,才会出现段错误,而这取决于那一次构建和运行的内存布局。
出现段错误是否意味着内存泄漏?
不是,它们是相反的问题。内存泄漏是你分配了却从未释放的内存:程序继续运行,占用慢慢增长。段错误则是访问了不属于你的内存。重复释放内存或者在释放后继续使用指针会导致段错误;忘记释放会导致内存泄漏。