本篇要回答的问题:内存管理只需谈清两件事——如何分配内存给运行中的软件(避免资源争抢)、如何运行外置存储上的软件。实模式与保护模式分别怎么解?
上一讲操作系统进场,承担起软件治理。治理中最基础的一环就是让多个软件合理共用计算机资源,而内存是其中最特殊的资源:CPU 可直接访问的存储只有寄存器、内存(RAM)、主板 ROM,而内存是唯一既被 CPU 内置支持、又直接和程序员打交道的基础资源。
计算机运行全过程
从 CPU 视角,加电到关机就是一整个"计算"过程;从 OS 视角,它由许多子"计算"过程接力完成:
| 阶段 | 边界意义 | 为何这样设计 |
|---|---|---|
| BIOS | 不固化在 CPU,放主板 ROM | 不同时期输入输出/外存设备差异大,改 BIOS 即可,无需改 CPU |
| 引导区引导程序 | 内置存储(ROM)→外置存储的边界 | 很短,BIOS 加载执行即可把控制权转给外存;写在外存便于修改(已是软件) |
| OS 引导程序 | 外置存储真正接手控制权 | OS 开始干活,初始化后把执行权交给 Shell |
| OS Shell | OS 与用户交互 | 字符界面是命令行(command.com / sh / bash),图形界面变成点鼠标/触屏 |
关键判断:从 CPU 到 BIOS 再到引导程序、OS、Shell,每一层不固化的设计都是为了把"会变的东西"从硬件下放到软件,便于迭代。
展开:开机接力的机械原理
上面那张表说的是「每一棒是什么」,这节补「为什么只能是这样」。先钉死三件底层事实,整条链就是被逼出来的唯一解。
事实 1 · CPU 只会做一件事。 内部有个 PC/IP(程序计数器),装着"下一条指令在哪个地址",然后无限循环:读 PC 指的指令 → 执行 → PC 自增或被 jmp 改掉 → 再读。它不认识"程序"“文件”“操作系统”,只认识地址里那串二进制。开机瞬间 PC 里是硬件写死的固定值(x86 是 0xFFFFFFF0,刻在硅片里)。
关键判断:CPU 固化的是「第一条指令的地址」,不是「第一条指令的内容」。 这个区分是读懂"BIOS 不固化在 CPU 中"的钥匙。
事实 2 · 地址 ≠ 内存条。 CPU 把地址送上总线后,主板芯片组决定这段地址接到哪块芯片(地址译码):
| CPU 发出的地址 | 物理上接到谁 |
|---|---|
0x00001000 |
DDR 内存条 |
0xFFFFFFF0 |
主板上那颗 Flash(BIOS 芯片) |
0xB8000 |
显卡显存 |
CPU 自己完全不知道差别,对它来说都是"地址"。谁接在哪一段上是主板说了算,不是 CPU 说了算。
事实 3 · 开机瞬间内存里是垃圾。 RAM 易失,断电即失,刚通电时全是随机噪声。所以第一条指令不可能来自内存条,只能来自断电不丢的 ROM。
三条合起来 → 唯一解:CPU 只从写死地址取第一条指令(1)+ 那地址接的是 ROM(2)+ 内存里是垃圾也只能接 ROM(3) ⟹ 开机后执行的第一段代码必然是 ROM 里的 BIOS。这不是设计选择,是物理逼出来的。
难点:CPU 没有"读硬盘"这条指令
读硬盘不是一个动作,是一套协议——往 I/O 端口写命令字、写扇区号、等中断、再一个字一个字读回来,而且 SATA 和 NVMe 还不一样。必须有一段代码专门干这事,这段代码就在 BIOS 里。于是整条链是同一个动作重复五遍:
我被上一棒放进内存、拿到执行权 → 我知道怎么把下一棒读进内存 →
jmp到下一棒入口 → 使命结束。
| 棒次 | 我在哪 | 我干的唯一的事 |
|---|---|---|
| ① CPU 加电 | 硅片 | 把 PC 置为 0xFFFFFFF0,开始取指 |
| ② BIOS | 主板 ROM,直接可寻址、不需要被加载 | 初始化硬件;用内置磁盘驱动把硬盘**第 0 扇区(512B)**读到内存 0x7C00;jmp 0x7C00 |
| ③ 引导区引导程序(MBR) | 硬盘第 0 扇区,现已在内存 | 只有 512B,啥也干不了:找到活动分区,把更大的 OS 引导程序读进来,跳过去 |
| ④ OS 引导程序(GRUB / bootmgr) | 硬盘上的文件,几百 KB~几 MB | 认识文件系统了,能读 /boot/vmlinuz,加载内核,跳过去 |
| ⑤ 内核 | 内存 | 初始化,最后启动第一批用户程序 |
| ⑥ Shell | 磁盘上的普通程序 | 死循环等命令 |
② 是唯一"不需要被别人加载"的一棒——它本来就躺在 CPU 可直接寻址的 ROM 里。从 ③ 开始,每一棒都得被上一棒搬进内存才能跑。
所谓"控制权转到外置存储",字面意思就是:从 ③ 起,CPU 正在执行的指令来源已是硬盘,不再是主板 ROM。
为什么不能省棒(反证法:假设合并了会怎样)
| 假设 | 后果 | 现状好在哪 |
|---|---|---|
| BIOS 做进 CPU | 换主板换成 NVMe,读盘方式变了 → 得换 CPU | 换主板自带配套 BIOS 芯片,CPU 不动 |
| 512B 引导程序做进 BIOS | BIOS 得懂 Windows/Linux/macOS + NTFS/ext4/APFS,装系统或系统改版就要刷 BIOS(刷坏变砖) | 装系统只是往第 0 扇区写 512 字节 |
| ③④ 合成一棒 | 512B 连"认识 ext4、按文件名找 vmlinuz"的代码都装不下 | 拆开后每棒体积预算大一个数量级:512B → 几 MB → 几十 MB 内核 |
关键判断:"BIOS 是硬件、引导程序是软件"不是说 BIOS 不是程序,而是说它住在硬件里,改它得用改硬件的方式,代价高一个量级。
补充:512 字节这个尺寸不是设计者挑的,是磁盘扇区的物理尺寸定的——能力被介质约束,于是必须再分一棒。
Shell 一点都不特殊
内核初始化完,总得有东西等着人下指令。Shell 就是那个死循环:打印提示符 → 读一行 → 解析出要启动哪个程序 → 启动 → 等它结束 → 回到提示符。图形界面时代,桌面和开始菜单干的是同一件事,只是"读你敲的字"变成"读你点了哪个图标"。它就是个普通程序,chsh 就能换掉。
一句话串起来
CPU 只认一个写死的地址,那地址接着 ROM,所以 BIOS 必然第一个跑;BIOS 会读盘但不认识文件系统,所以只读第 0 扇区那 512 字节;512 字节太小干不了活,所以去加载更大的 OS 引导程序;OS 引导程序认识文件系统了,于是能把内核文件读进来;内核跑起来后启动一个等命令的程序,就是 Shell。每一棒都只做一件事:把下一棒搬进内存,然后跳过去。
现实校正:① 现在多数机器是 UEFI 而非传统 BIOS,UEFI 反而认识 FAT32,直接从 ESP 分区读 .efi 启动,把 ③ 省掉了——框架一样,只是契约重划了一次(见下方架构思维第一节)。② 严格说 CPU 加电后、BIOS 之前还有 Intel ME / AMD PSP 在跑,不影响理解这条链。
内存管理要回答两个问题:如何分配内存、如何运行外存上的软件。回答前先理解 CPU 的两种模式:
| 模式 | 内存访问方式 | 对应 OS |
|---|---|---|
| 实模式 | 直接用物理地址访问内存 | 实模式操作系统 |
| 保护模式 | 通过地址映射表把虚拟地址转为物理地址 | 保护模式操作系统 |
实模式下的内存管理
实模式下所有软件(含 OS 本身)都在同一物理地址空间,CPU 看它们是同一个程序。
如何分配内存——两种方法实质相同:
| 方法 | 机制 |
|---|---|
| 公认地址放函数 | 把内存管理函数地址放在公认处(如 0x10000),软件去取并调用 |
| 软中断 | 约定某号中断(如 77 号)为内存管理中断,OS 初始化时把函数写入中断向量表对应项 |
中断本是 CPU 响应硬件事件的机制(如按键触发 9 号中断),CPU 也允许软件主动触发,称软中断。中断向量表本质就是"在公认地方放一堆函数地址",但还有优先级等更复杂的机制。
如何运行外存软件:把软件完整读入内存执行,执行前先把浮动地址固定下来(软件未加载时不知自己在哪,涉及数据/函数地址都得在加载时确定)。本质就是程序代码片段的动态加载。
保护模式下的内存管理
实模式有两个致命问题:
| 问题 | 表现 |
|---|---|
| 安全性 | 所有软件运行在一起,可随意改对方数据甚至指令,搞破坏极容易 |
| 复杂度低/数量少 | 单个软件可能大过可用内存;多开几个软件内存就不够 |
保护模式用虚拟内存解决:内存空间切成固定大小的内存页(如 64K),CPU 访问虚拟地址时先算属于哪一页,再经地址映射表转成物理地址。
| 机制 | 作用 |
|---|---|
| 地址映射表 | 数组:下标=页号,值=该页对应的物理内存首地址 |
| 缺页中断 | 某页无对应物理内存即缺页,CPU 发缺页中断,OS 接管:分配物理内存并恢复数据;无空闲则淘汰最久未访问页(淘汰前先保存数据) |
关键判断:有了缺页机制,运行软件不必一次性装入内存(按需加载),分配内存也无需额外机制(先给超大虚拟空间,用到哪页才真分配物理内存)。两个问题一并解决。
每个运行中的软件叫进程,各有独立的地址映射表,即每个进程有独立虚拟地址空间。这让每个软件"感觉"独占整机资源,浮动地址问题也消失(软件可假设自己的绝对加载地址)。保护进程基础资源的独立性,正是"保护"模式之名的由来。
架构思维上我们学到什么?
虚拟内存本质解决两个核心需求:
| 需求 | 困难 | 虚拟内存的解法 |
|---|---|---|
| 软件越来越大,需在外存执行指令而非全量加载 | CPU 不懂外存怎么读、不懂其数据格式 | 缺页中断按需把代码片段调入 |
| 同时运行软件越来越多,内存供不应求 | CPU 不了解外存细节,怎么按需换出 | 淘汰最久未用页、换出到外存 |
关键判断:缺页中断很像 CPU 留给操作系统的回调函数——通过它对"变化点"(外存读写、数据格式)实现了优雅的开放性设计。
架构思维:这一讲真正在教的东西
上面那节是许式伟自己给的答案(缺页中断=回调)。这节是把整讲拆开后,能带走的通用准则——表面讲内存管理,底下讲的是「怎么划边界」。
一、启动链:按「变更成本」分层,不按「功能」分层
| 环节 | 介质 | 改一次的代价 |
|---|---|---|
| CPU | 流片 | 几千万 |
| BIOS | 主板 ROM | 刷固件,有变砖风险 |
| 引导区引导程序 | 磁盘第 0 扇区 | 写文件,随便改 |
| 内核 / 用户态 | 文件 | 随便改 |
变更成本单调递减,变更频率单调递增,两条曲线对齐了架构就稳。
最能说明问题的细节:BIOS 根本不认识文件系统。它对引导区的契约窄到只有五件事——第 0 扇区、512 字节、结尾 0x55AA、加载到 0x7C00、跳过去。因为文件系统会变(FAT/NTFS/ext4/APFS/btrfs),BIOS 要是认它,每出一种新格式就得刷固件。于是"认识文件系统"这个职责被整个甩给那 512 字节——而它跟操作系统一起发货,想怎么改怎么改。
关键判断:架构不消灭复杂度,只搬运复杂度——往变更成本最低、影响面最小的地方搬。
推论:契约越窄,两侧越能独立演化。
反例同样重要:UEFI 固件直接认识 FAT32(读 ESP 分区)。这不是倒退——它把"一种文件系统"冻结成标准,换来了"引导程序可以有几 MB 而不是 512 字节"。边界不是划一次就永远对,约束变了要重划(第 60 讲专门讲这个)。
二、中断向量表:硬件定 when,软件定 what
原文说"公认地址放函数"与"软中断"本质相同。对,但软中断多出两样东西,也正是它活到今天的原因:
| 多出来的 | 价值 |
|---|---|
| 编号是名字,地址是实现 | OS 可随意重排代码布局,编号不变,调用方零感知。所以 int 0x80 太慢被 sysenter/syscall 取代时,语义没变只换实现,上层几乎无感 |
| 中断是一道闸门 | 直接 call 不改变特权级,int 会:切栈、切 ring、保存现场。这是用户态唯一合法的入内核通道 |
关键判断:定义槽位,不定义实现——硬件规定"第 N 项对应什么事件",绝不规定那一项里放什么函数。这是插件架构最古老的形态。
三、缺页回调成立的地基:出错指令会重新执行
许式伟点了"缺页中断像回调",但没说下面垫着的那块砖:中断返回后,出错的那条指令会重跑一遍。
因为有它,一次"异步的、可能几毫秒、可能失败"的外存加载,被完整藏进了一条同步指令的语义之下。一条 mov eax, [addr] 可能 5 纳秒也可能 5 毫秒,对上层语义完全一样——所以全世界的程序都不必为"内存可能不在"写一行代码。
代价也在这儿:
关键判断:抽象泄漏永远从「性能」和「故障」两个口子漏出来。
虚拟内存在功能语义上完美隐藏了外存,但藏不住时间语义(缺页慢几万倍)和故障语义(OOM killer、mmap 遇 I/O 错直接 SIGBUS)。所以实时系统 mlock 钉内存、数据库 O_DIRECT 绕开 page cache 自管缓冲池。任何"透明代理"型抽象(ORM、缓存层、把 RPC 伪装成本地调用)都会在同样这两个口子上被咬。
四、一个机制顺手解决七个问题 = 好架构的诊断标志
分页 + 缺页这一个机制解决了:
| # | 问题 | 是否原始目标 |
|---|---|---|
| 1 | 进程隔离(每进程一张表) | ✅ 设计目标 |
| 2 | 程序可大于物理内存 | ✅ 设计目标 |
| 3 | 内存分配(碰到才给物理页) | ✅ 设计目标 |
| 4 | 浮动地址消失 | 白捡 |
| 5 | 共享(动态库单份、fork 的 COW) | 白捡 |
| 6 | 权限(页表项 R/W/X → DEP/NX) | 白捡 |
| 7 | mmap(文件当内存用) | 白捡 |
关键判断:只解决了它被设计来解决的那个问题的机制,多半只是补丁;顺手解决一堆你没想到的问题的机制,说明切中了本质结构。
为什么能泛化?因为分页的本质是在"名字"和"东西"之间插了一层可编程映射。所有间接层都有这个性质(Lampson:一切问题都可以用加一个间接层解决——除了间接层太多这个问题)。
五、硬件只提供「事实」,不提供「判断」
页表项里有 A(accessed) 和 D(dirty) 两个位,硬件翻译地址时顺手置上。然后就没了——CPU 不告诉你该淘汰哪一页,不内置任何淘汰算法。LRU / CLOCK / 二次机会 / 工作集,全是 OS 在软件里靠这两个 bit 自己推的。
若当年 CPU 内置了 LRU,今天所有人都得忍受 1985 年的判断。它只给两个 bit,Linux 换页算法改了三十年,硬件一次没动。
关键判断:只暴露事实,不暴露结论。事实稳定,结论易变。
自查:我给上层的接口,给的是事实还是结论?
这条往上就是 OS 经典原则 mechanism / policy 分离,往下就是硬软分工的切法:
| 交给谁 | 为什么 | |
|---|---|---|
| 地址翻译 | 硬件(MMU + TLB) | 每条访存都做,高频 + 规则固定 |
| 缺页处理 | 软件(OS) | 万次一遇可以慢,而且要做决策 |
| 页表格式 | 硬件定 | 硬件要读它 |
| 页表内容 | 软件填 | 硬件不该有意见 |
高频 + 不变 → 硬件;低频 + 需决策 → 软件。 设计快慢路径分离时可直接照抄。
六、“感觉自己独占整台计算机” = 整个虚拟化世界的种子
操作系统卖的其实是一张幻觉清单:
| 资源 | 卖给你的幻觉 | 靠什么维持 |
|---|---|---|
| CPU | 我独占 CPU | 时间片 + 上下文切换 ← 时钟中断 |
| 内存 | 我独占全部地址空间 | 页表 ← 缺页中断 |
| 磁盘 | 我有文件,不是扇区 | 文件系统 |
| 网卡 | 我有一根独占的管子 | socket |
规律:每个幻觉 = 一张映射表 + 一个中断。表负责日常骗,中断负责"骗不下去时"的补救入口。
更关键的是这个幻觉可递归:虚拟机骗了整个 OS、容器骗了命名空间、JVM/V8 骗了字节码程序、云骗了整个企业(→ 第 55 讲)。
关键判断:抽象干不干净的试金石——它提供的幻觉能不能被再虚拟化一次而不崩。
x86 早年没通过:有 17 条"敏感但不特权"的指令,VMware 只能上二进制翻译硬扛,直到 Intel VT-x 补了 EPT(第二层页表)才干净。第一次抽象没做干净,就要在下一层付利息。
七、实模式不是"落后",是信任模型不同
实模式假设:所有代码都善意且正确。在"一机一程序、程序员就是用户、内存 640K"的年代这个假设成立,而且开销为零。只有当三个约束同时出现——软件来自陌生人、要同时跑很多个、软件比内存大——保护模式才成为必需。
关键判断:没有绝对更好的架构,只有匹配当下约束的架构。
证据是这个循环还在重复:单片机 / RTOS / unikernel 今天仍用平坦地址空间无隔离,因为它们的约束又回到了"代码全是自己写的 + 要确定性延迟"。约束回来了,老架构就回来了。
另外,"保护"其实是两套正交机制,原文只讲了第一套:
| 方向 | 机制 | 隔离谁 |
|---|---|---|
| 横向 | 页表 | 进程 ↔ 进程 |
| 纵向 | ring 0/3 | 用户态 ↔ 内核态 |
没有第二套,第一套是纸糊的——用户程序若能直接改 CR3,隔离一秒就破。
关键判断:任何隔离机制,都必须配一个"隔离机制本身不可被绕过"的元机制。 写权限系统时同理:谁来保护权限表?
八、原文为讲清楚做的简化(细节补丁)
| 原文说法 | 实际 | 为什么值得知道 |
|---|---|---|
| 内存页"比如 64K" | x86 是 4KB(另有 2MB / 1GB 大页) | 4K 是 1980 年代按当时磁盘块与内存容量定的。页大小是两头受挤的权衡:太小 → 页表条目暴增、缺页频繁、TLB 命中率掉;太大 → 内部碎片浪费、换页时白搬 I/O。今天 4K 已嫌小,所以才有透明大页(THP)。参数也是架构决策,而且参数会过期 |
| 地址映射表"是一个数组" | 多级页表(x86-64 四级,新 CPU 五级) | 64 位下平坦数组要 2^52 项,荒谬。多级页表本质是稀疏数组的树形表示,只为用到的区域分配中间层——同一模式见于 radix tree / trie / 稀疏文件 / B+树。索引空间远大于数据量时,用分层稀疏结构而非平坦数组 |
| (未提 TLB) | 多级页表让每次访存多走 4 次内存 → 必须上 TLB;改页表要 TLB shootdown,切进程要刷 TLB,于是又有 ASID/PCID | 见下方那条完整账单 |
关键判断(最值得带走的一条):
加间接层 → 性能变差 → 加缓存赎回来 → 缓存带来一致性问题 → 加失效协议。
这条链在 CDN、ORM、DNS、微服务里一模一样。以后每加一个间接层,提前预判后面三步的账单。
速记:可带走的判断
通用准则(跨领域复用)
- 架构不消灭复杂度,只把它搬到变更成本最低的地方。
- 契约越窄,两侧越能独立演化(BIOS 只认 512 字节)。
- 定义槽位,不定义实现。
- 只暴露事实,不暴露结论。
- 一个机制顺手解决七个问题说明切中本质;只解决一个可能只是补丁。
- 抽象泄漏永远从性能和故障两个口子漏。
- 试金石:你的抽象能不能被再虚拟化一次。
- 加间接层的账单是四步的,别只算第一步。
本讲专属
- 缺页中断是开放性设计的范本:CPU 把外存交互的变化点回调给 OS。
- 进程独立虚拟地址空间 = “感觉独占整机”,是软件治理最基础的要求。
- 实模式"程序拆成多个片段动态加载"的思想,后面动态库还会复用。
总结
本讲先概览了计算机从开机到关机的全过程,再聚焦内存管理的两个核心问题。实模式简单但不安全、容量受限;保护模式靠虚拟内存 + 缺页中断,既隔离了进程、又用按需加载解决了内存不足。
先把"是什么"回答清楚
| 概念 | 一句话说明 |
|---|---|
| 地址译码 | CPU 发出的地址接到哪块芯片,由主板芯片组决定——同一个地址空间里既有内存条,也有 ROM 和显存 |
| 引导区契约 | BIOS 与硬盘之间只有五条约定:第 0 扇区、512 字节、结尾 0x55AA、载入 0x7C00、跳过去 |
| 实模式 | CPU 直接用物理地址访问内存,所有软件同处一个地址空间 |
| 保护模式 | 经地址映射表把虚拟地址转物理地址,进程地址空间相互隔离 |
| 虚拟内存 | 把地址空间分页,按需映射到物理内存 |
| 缺页中断 | 访问未映射页时触发,OS 接管分配/恢复/淘汰,像 CPU 的回调 |
| 进程 | 运行中的软件实例,各有独立虚拟地址空间 |
| 浮动地址 | 加载前未定的地址,实模式加载时固定,保护模式下可假设固定 |
| A / D 位 | 页表项里"被访问过 / 被写过"两个标志。硬件只置位、不下结论,淘汰算法全由 OS 自己推 |
| TLB | 缓存地址翻译结果,用来还多级页表的性能债;代价是改页表要 shootdown、切进程要刷表(故有 ASID/PCID) |
| ring 0/3 | 特权级,纵向隔离用户态与内核态。没有它,页表的横向隔离形同虚设(用户程序直接改 CR3 即可破) |
一句话速记
内存管理 = 分配内存 + 运行外存软件;实模式靠软中断+动态加载,保护模式靠**虚拟内存分页 + 缺页中断(CPU 给 OS 的回调)**一举解决隔离、按需加载与按需分配。
思考题
虚拟内存把"按需加载/换出外存"做成了缺页回调。回到你自己的系统:有没有某个"变化点"也适合用类似回调(钩子)的方式,把不稳定的实现细节交给上层来填,而核心保持稳定?



