指针、段错误,和操作系统凭什么管着我的内存

#C 语言#指针#操作系统 共 1,709 字 约 6 分钟

大一写 C,最让我头皮发麻的不是编译报错,而是程序跑起来之后蹦出的那行冷冰冰的英文:Segmentation fault (core dumped)。

它不告诉你哪一行错了,不告诉你为什么,就一句话,然后程序死了。

那阵子我心里堵着三个问题:为什么 C 要搞出”指针”这种反人类的东西?这个 Segmentation fault 到底是谁报出来的?以及,凭什么操作系统不让我的程序随便读写内存?

后来我把这三个问题连起来,发现它们其实是同一个故事。

指针不是反人类,它只是一个存地址的变量

把内存想象成一条极长的储物柜,每个格子一个字节,从 0 开始编号。这个编号就是地址。

指针没有那么玄,它就是一个”值恰好是某个地址”的变量。取地址符 & 问”这个变量住在哪个格子”,解引用符 * 说”去那个格子把东西取出来”。

c
UTF-8|4 Lines|
int x = 42;
int *p = &x;                 // p 的值,是 x 所在的地址
printf("%p\n", (void *)p);   // 打印出一个地址,比如 0x7ffee3b4c8ac
printf("%d\n", *p);          // 顺着地址回去取,拿到 42

从机器的视角看,地址不过是个整数,在 64 位机器上,一个指针就是 8 个字节。上一篇里我们看到 CPU 用寄存器装地址、用 mov 搬地址,指针只是把 CPU 每天在做的事,搬到了 C 语言这一层。

C 之所以离不开它:传一个大结构体时不必整个拷贝,只递一个地址;想在运行时申请内存、构建链表和树,全靠指针串起来;想直接读写某个硬件寄存器的地址,也只有指针能做到。

反人类的从来不是指针本身,而是没人告诉你它背后是一整套地址规则。

Segmentation fault 到底是谁在报错

先纠正一个我当年的误解:段错误不是你的程序自己发现的,是 CPU 和操作系统联手抓的。

当你解引用一个非法地址,比如一个空指针,发生的是这样一条链:

Text
UTF-8|19 Lines|
你的代码:  *p = 42        (p 是非法地址,例如 0)


        CPU 里的 MMU  ── 拿虚拟地址去查页表 ──► 没有有效映射


        触发硬件异常 (page fault)  ── 控制权陷入操作系统内核


        内核缺页处理程序  ── 判断:是合法缺页,还是非法访问?
                │                    (对地址 0,判定非法)

        内核给你的进程发信号:SIGSEGV


        你没接管这个信号 ── 走默认动作:终止进程 + dump core


        shell 打印:Segmentation fault (core dumped)

“段错误”这个名字,就来自这条链的最后一环:内核认定你碰了不属于你的内存段,于是把你的进程杀掉,顺便把出错瞬间的内存快照写成一个 core 文件,方便你事后验尸。

为什么操作系统不让你碰别人的内存:虚拟地址与页表

关键的一步认知:你指针里存的每一个地址,都是虚拟地址,不是物理内存的真实编号。

每个进程都活在一个自己独享的、从 0 到很大的虚拟地址空间里。这是操作系统和 MMU 一起制造的假象。你每次访问内存,MMU 都在中间做一次翻译:

Text
UTF-8|8 Lines|
  进程看到的虚拟地址空间          物理内存(真实的条)
  ┌──────────────────┐          ┌─────────────┐
  │ 0x0000  (未映射)  │───╳      │   物理帧 A   │
  │ 0x4000  代码段    │──┐       │   物理帧 B   │
  │ 0x8000  堆        │  ├─页表─► │   物理帧 C   │
  │ 0xfff0  栈        │──┘       │     ...      │
  └──────────────────┘          └─────────────┘
      每个页表项都带权限位:可读 / 可写 / 可执行 / 用户态可访问

页表就是那张翻译表,而且每一项都带着权限位。于是两件事被牢牢管住:

你的野指针最多搞崩自己的进程,因为别的进程和内核的物理页根本不在你的页表里,你连它们的地址都翻译不出来。

地址 0 附近的页,内核故意不做映射,所以解引用 NULL 必然翻译失败、必然段错误。这不是巧合,是特意留下的一道保护栏。

不是所有越界都会立刻崩,这才是最可怕的

说个反直觉的结论:能触发段错误,其实是”幸运”的情况,因为它当场就让你知道错了。

真正阴险的是那些不触发段错误的非法访问:

c
UTF-8|3 Lines|
int a[4];
a[10] = 1;        // 越界写。如果 a[10] 的地址恰好落在已映射的合法页里
                  // 不报错,但你悄悄改坏了旁边的数据

数组越界,只要越过的地址还在你自己已映射的页里,MMU 就查得到、权限也够,于是一声不吭地放行,你却已经踩坏了别的变量。use-after-free 更隐蔽:内存 free 之后你继续用,它可能还没被回收,读写”看起来正常”,直到某次分配把这块地复用,bug 才以完全无关的形式爆发出来。

段错误是朋友,静默的内存损坏才是敌人

段错误会当场终止程序、指出事故现场;而落在合法页里的越界、悬空指针,会把损坏藏起来,让你在几百行之外、几秒钟之后才看到症状。调这种 bug 的痛苦,远大于面对一句干脆的 Segmentation fault。这也是后来我理解各种内存安全工具(以及后面会聊到的 GC、Rust)的起点。

亲手制造一次段错误

不用等它自己出现,我们主动造一个,再用工具看清它:

c
UTF-8|6 Lines|
// segv.c
int main(void) {
    int *p = NULL;
    *p = 42;          // 向地址 0 写,必然 SIGSEGV
    return 0;
}
Bash
UTF-8|3 Lines|
gcc -g segv.c -o segv
./segv
# Segmentation fault (core dumped)

那句英文本身没什么信息量,换成 gdb 就清楚多了:

Bash
UTF-8|7 Lines|
gdb ./segv
(gdb) run
# Program received signal SIGSEGV, Segmentation fault.
# 0x000055... in main () at segv.c:4
# 4           *p = 42;
(gdb) print p
# $1 = (int *) 0x0

gdb 直接告诉你:收到的是 SIGSEGV、停在第 4 行、出事的指针 p0x0。信号、地址、现场,一次给全。

写在最后

后来我不再怕那行 Segmentation fault 了。它不是在刁难我,是操作系统在替我兜底:我的程序想踩一块不属于它的内存,内核借着硬件的力气把它拦住,而不是让整台机器陪葬。

指针也不再反人类。它把”地址”这个 CPU 每天都在做的动作,摊开摆在我面前。理解了地址,才算真正理解 C 为什么长这样,也才明白上一篇里那些 mov、那些寄存器,装的到底是什么。

顺着这条线,下一个问题自己冒了出来:既然手动管理内存这么容易翻车,有没有语言能替我把这件事管起来?那是我遇见 Go 之后的事了。