每个写过 C 的人都收到过那句判决:
Segmentation fault (core dumped)
我第一次见到它时以为是编译器在骂人,查了半天栈没查出所以然。现在回头看,这行字背后是一整条硬件链路:你的程序碰了一个虚拟地址,MMU 去页表里查映射,发现这个地址压根没有映射(或者权限不对),硬件抛出异常,内核接手,给进程发 SIGSEGV。本章把这条链路一节一节拆开:内存芯片怎么造(DRAM 为什么会「忘事」)、虚拟地址怎么翻译(为什么每份数据有两个地址)、以及段错误发生的完整过程。学完你会发现,操作系统那门课里「内存管理」的硬件底座,全在这一章。
主存用 DRAM,缓存(第 7 章)用 SRAM,一字之差,电路差了十万八千里:
| SRAM | DRAM | |
|---|---|---|
| 存一位用 | 6 个晶体管(触发器) | 1 个晶体管 + 1 个电容 |
| 供电后 | 状态自持 | 电容漏电,几十毫秒就忘 |
| 速度 | ~1 ns | ~100 ns |
| 密度/价格 | 低/贵 | 高/便宜 |
DRAM 靠电容存电荷表示 0 和 1,而电容天生漏电,数据必须周期性重写一遍(刷新),否则几十毫秒后你的内存真的会「忘记自己」。刷新期间那一行不能正常访问,这是 DRAM 延迟抖、比 SRAM 慢一个量级的物理根源之一;省下的 5 个晶体管换来的密度和价格,又让它垄断主存几十年。工程永远是这笔账。
DRAM 内部是二维阵列,访问分两步:先选行(整行进感测放大器,即「行缓冲」),再选列(从行缓冲里挑字节)。这直接制造了一条硬件层的局部性规则:连续访问同一行的列,快;频繁跨行,每次都要重新激活,慢。从 Cache 到 DRAM 到 SSD,每一层存储都在奖励顺序访问、惩罚随机访问,这条规律值得记成条件反射。
程序里写的地址(指针的值)从来都不是物理内存的真实位置,而是虚拟地址。翻译工作由 MMU(内存管理单元,长在 CPU 里)完成,翻译依据是操作系统维护的页表。
基本设定:虚拟地址空间切成固定大小的页(主流 4KB),物理内存切成同样大的页框,页表记录「哪个虚拟页装在哪个页框里」:
虚拟地址 0x0000 1234 = 虚拟页号 0x1 | 页内偏移 0x234
│ 查页表
物理地址 0x0007 8234 = 页框号 0x78 | 页内偏移 0x234 (偏移原样不动)
翻译只改高位,偏移低位直通,这让硬件做起来便宜。页表项(PTE)里除了页框号还有几个关键位:有效位(这页在不在内存)、权限位(可读/可写/可执行)、脏位(被改过没有,回写磁盘前有用)。
这套东西买来了三样真正值钱的货:
隔离。每个进程有自己的页表,同一个虚拟地址 0x400000 在两个进程里指向不同的物理页。进程 A 想读 B 的内存?它的虚拟地址空间里根本不存在那条映射,MMU 直接拒绝。安全边界从「希望程序守规矩」变成「硬件物理上做不到越界」。
超额分配。物理内存 16GB,十个进程各「看到」8GB 也不冲突,因为大部分虚拟页此刻并不在物理内存里,躺在磁盘上。只有真正被访问到的页才占页框,这就是「把磁盘当内存的慢速后援」。
按需加载。程序启动不必把整个可执行文件读进内存,先建好「这段虚拟地址对应文件第几页」的映射,访问到了才加载。虚拟内存和文件系统(OS 课的 mmap)在这里合流。
如果查页表发现有效位是 0(页不在物理内存),MMU 不猜不蒙,直接抛异常,控制权交给内核,这一串跟着发生:内核确认这是合法访问(不是野指针)→ 找一个空闲页框(紧张就按策略踢一个出去,被踢的是脏页还要先写回)→ 从磁盘把数据读进来(磁盘读几十万纳秒,期间这个进程被挂起,别的进程顶上,这就是为什么你感觉不到)→ 更新页表 → 重新执行触发缺页的那条指令。
注意最后一步:触发异常的指令会被重新执行,不是跳过。硬件中断不保存「半完成的访存」,OS 处理完后原指令从头再来。第一次在书上读到这点我愣了一下,后来意识到这是整个虚拟内存机制能透明运转的关键:程序完全不知道自己缺过页。
按上面的流程,每次访存前都要先查页表,而页表本身也在内存里(多级页表要查好几次),等于一次访存变两三次,亏到姥姥家。解法又是第 7 章那一招:在 MMU 旁边放一块小而快的缓存,专门存最近用过的「虚拟页号 → 页框号」映射,叫 TLB。
于是每次地址翻译的完整决策树是:
CPU 发出虚拟地址
→ 查 TLB:命中(~1 拍) → 直接拼物理地址,完事
└─未命中 → 查页表(几次访存) → 装入 TLB
└─页表项无效 → 缺页异常(第 8.3 节,磁盘级别的慢)
三张表串成一条越来越慢的链:TLB 命中纳秒级,页表命中百纳秒级,缺页到磁盘是十万纳秒级。程序的局部性(第 7 章)在每一层都在救你:反复访问同一批页,TLB 命中率高;访问模式散乱,TLB 反复 miss,性能断崖。数据库、虚拟机这类大内存软件专门调大页(huge page)来降低 TLB miss,就是在这条链上做文章。
现在能完整回答开头的问题了。int *p = NULL; *p = 1; 这行代码从执行到打印 Segmentation fault 的全过程:
分页机制还顺手给了每个页权限位,所以「改只读数据段」和「执行数据段」也会段错误,原因相同:MMU 查到映射,但权限位不放行。野指针未必当场崩:碰巧落在你有映射的页里,读写就成功了,炸弹留给以后(第 14 章 C 指针课的 UB 章节专门展开)。操作系统课程会从软件侧把页表管理、置换算法再讲一遍(那里叫「内存管理」章),两门课在这章握手。
ulimit -c unlimited 后跑,用 gdb 打开 core 文件 bt,看看崩在哪一行;再试试解引用 (int*)0x1000,对比报错是否相同、为什么。