加载中...

本篇要回答的问题:内存管理只需谈清两件事——如何分配内存给运行中的软件(避免资源争抢)、如何运行外置存储上的软件。实模式与保护模式分别怎么解?

上一讲操作系统进场,承担起软件治理。治理中最基础的一环就是让多个软件合理共用计算机资源,而内存是其中最特殊的资源: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)**读到内存 0x7C00jmp 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、微服务里一模一样。以后每加一个间接层,提前预判后面三步的账单。

速记:可带走的判断

通用准则(跨领域复用)

  1. 架构不消灭复杂度,只把它搬到变更成本最低的地方。
  2. 契约越窄,两侧越能独立演化(BIOS 只认 512 字节)。
  3. 定义槽位,不定义实现。
  4. 只暴露事实,不暴露结论。
  5. 一个机制顺手解决七个问题说明切中本质;只解决一个可能只是补丁。
  6. 抽象泄漏永远从性能和故障两个口子漏。
  7. 试金石:你的抽象能不能被再虚拟化一次。
  8. 加间接层的账单是四步的,别只算第一步。

本讲专属

  1. 缺页中断是开放性设计的范本:CPU 把外存交互的变化点回调给 OS。
  2. 进程独立虚拟地址空间 = “感觉独占整机”,是软件治理最基础的要求。
  3. 实模式"程序拆成多个片段动态加载"的思想,后面动态库还会复用。

总结

本讲先概览了计算机从开机到关机的全过程,再聚焦内存管理的两个核心问题。实模式简单但不安全、容量受限;保护模式靠虚拟内存 + 缺页中断,既隔离了进程、又用按需加载解决了内存不足。

先把"是什么"回答清楚

概念 一句话说明
地址译码 CPU 发出的地址接到哪块芯片,由主板芯片组决定——同一个地址空间里既有内存条,也有 ROM 和显存
引导区契约 BIOS 与硬盘之间只有五条约定:第 0 扇区、512 字节、结尾 0x55AA、载入 0x7C00、跳过去
实模式 CPU 直接用物理地址访问内存,所有软件同处一个地址空间
保护模式 经地址映射表把虚拟地址转物理地址,进程地址空间相互隔离
虚拟内存 把地址空间分页,按需映射到物理内存
缺页中断 访问未映射页时触发,OS 接管分配/恢复/淘汰,像 CPU 的回调
进程 运行中的软件实例,各有独立虚拟地址空间
浮动地址 加载前未定的地址,实模式加载时固定,保护模式下可假设固定
A / D 位 页表项里"被访问过 / 被写过"两个标志。硬件只置位、不下结论,淘汰算法全由 OS 自己推
TLB 缓存地址翻译结果,用来还多级页表的性能债;代价是改页表要 shootdown、切进程要刷表(故有 ASID/PCID)
ring 0/3 特权级,纵向隔离用户态与内核态。没有它,页表的横向隔离形同虚设(用户程序直接改 CR3 即可破)

一句话速记

内存管理 = 分配内存 + 运行外存软件;实模式靠软中断+动态加载,保护模式靠**虚拟内存分页 + 缺页中断(CPU 给 OS 的回调)**一举解决隔离、按需加载与按需分配。

思考题

虚拟内存把"按需加载/换出外存"做成了缺页回调。回到你自己的系统:有没有某个"变化点"也适合用类似回调(钩子)的方式,把不稳定的实现细节交给上层来填,而核心保持稳定?

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关