你一边开着浏览器的几十个标签页,一边跑着编辑器、音乐播放器和下载工具。它们都在读写内存,而且很多程序里的地址看起来还会从同一个位置开始。奇怪的是,这些程序通常不会把彼此的数据覆盖掉,也不需要提前知道自己会被放进哪一根内存条的哪个位置。
这不是因为物理内存天然懂得“进程”。内存硬件看到的只是地址、读写信号和一串二进制数据。真正让多进程能够同时驻留、彼此隔离,又能在需要时共享代码的,是操作系统、编译链接工具与处理器内存管理单元共同维护的一套映射规则。
你可以把主内存管理理解成三方协作:程序只使用自己的虚拟地址;硬件在每次访存时完成快速翻译和权限检查;操作系统决定哪些虚拟区域存在、它们映射到哪些物理页框,以及内存紧张时谁先让出位置。这样做解决了“物理内存有限而进程很多”的矛盾,但也带来了页表空间、地址翻译、缓存一致性和换页 I/O 等成本。

读完这一章,你应当能沿着一次普通的内存访问,讲清楚地址从哪里来、如何被保护、怎样变成物理地址,以及连续分配、分页、多级页表和交换各自解决了什么麻烦。
CPU 能直接使用的存储位置主要是寄存器、缓存和主内存。磁盘或固态硬盘里的可执行文件不能被 CPU 直接当作指令流执行,它们必须先被装入内存,至少让即将使用的部分出现在可访问的物理存储中。
寄存器很快,但数量很少;主内存容量大得多,访问延迟也高得多。缓存夹在两者之间,保存近期使用过的指令和数据副本。缓存命中主要由硬件处理,而操作系统更关心另一件事:当前这次访问究竟属于哪个进程,它有没有权利读、写或执行这个位置。
假设每条加载指令之前都插入一段普通代码,先问“这个地址合法吗”,再真正读取。这样的检查既慢又不可靠,因为有问题的程序完全可以跳过检查代码。保护必须落在每次访存都绕不过去的硬件路径上。
一种直观的早期方案是基址寄存器和界限寄存器。界限描述进程可用逻辑地址的长度,基址描述这段空间放在物理内存的起点。若逻辑地址为 ,基址为 ,界限为 ,硬件先检查:
检查通过后,物理地址才是:
若基址为 120000,界限为 40000,逻辑地址 3500 会被翻译成物理地址 123500;逻辑地址 50000 已经越界,硬件不会让这次写入落到别人的内存上,而是触发异常,把控制权交给内核。

基址与界限只能由内核态特权指令修改。进程切换时,操作系统把新进程的边界装进相应寄存器。这个方案小而快,却要求一个进程占用一段连续的物理空间,也不擅长给同一进程内部的代码、数据和栈设置不同权限。分页系统把保护粒度缩小到页,但“每次访问都由硬件强制检查”这个原则没有变。
内存保护不是把某个地址“藏起来”。进程即使猜到了另一个进程的数值地址,只要当前地址空间没有对应映射,或者页表权限不允许,访问仍会失败。安全边界来自映射与权限,而不是地址难猜。
下面的实验把逻辑地址送进基址—界限硬件。拖动逻辑地址,观察合法访问如何加上基址,以及越界访问为什么在到达物理内存前就被截住。
源代码里写的是变量名、函数名和数组下标,CPU 最终需要的却是数值地址。中间至少要经过编译、链接、装入和运行四个语境。把它们混成一句“编译器生成物理地址”,会漏掉现代系统最关键的灵活性。
编译器通常先把符号变成目标模块内部的相对位置,并留下尚未解析的外部符号。链接器把多个目标文件与所需库组合起来,安排代码段、只读数据段、可写数据段等布局,修补能够在链接时确定的引用,生成可执行文件或共享对象。

地址绑定可以发生在不同阶段:
现代通用操作系统主要依赖执行时绑定。程序发出的地址属于自己的虚拟地址空间,MMU 根据当前进程的映射把它翻译成物理地址。进程可以认为某个数组一直位于同一个虚拟地址,即使它背后的物理页框已经被操作系统换过位置。
“虚拟”说的是地址解释方式,不是数据不存在。一个虚拟页可能映射到物理页框,也可能暂时没有驻留、映射到文件内容,或者被标成完全不可访问。页表项会告诉硬件当前属于哪种情况。
同一个虚拟地址数值可以在不同进程中指向不同物理页框。反过来,多个进程的不同虚拟地址也可以指向同一个只读代码页,从而共享库代码。地址空间因此同时提供了隔离和受控共享,这两件事看似相反,实际由“是否建立同一映射、给什么权限”统一表达。
动态加载关心“某段代码何时进入内存”。一个很少触发的诊断模块可以等到第一次调用时再装入,省下启动时间和常驻空间。
动态链接关心“外部符号何时与共享对象中的实现对应”。可执行文件不必复制库的全部机器码,运行时链接器可以装入共享库、解析符号,并通过间接表或重定位结果把调用接到正确入口。多个进程还能把同一份只读库代码页映射进各自地址空间,而每个进程保留独立的可写数据。
这种共享不是没有条件。库要维持兼容的接口;运行时解析会增加启动或首次调用成本;位置无关代码和间接跳转也可能带来少量开销。更新共享库虽然能让多个程序一起获得修复,但不兼容更新同样可能一起破坏它们,所以实际系统会用版本化文件名、符号版本或打包依赖来约束变化。
动态加载、动态链接和分页可以同时出现。分页负责地址空间与物理页框的映射;动态链接决定共享对象中的符号如何接上;按需分页还可能让已经完成映射的代码页直到第一次执行时才真正从文件读入内存。
如果每个进程都必须占一整段连续物理内存,管理器只要记录“从哪里开始、长度多少”。这和基址—界限机制很搭:装入进程时找一块足够大的空闲区,写入两个寄存器,运行时用一次比较和加法完成保护与重定位。
固定分区是在系统启动时把内存切成若干块。它容易实现,但分区比进程大时,分区内部剩下的空间也不能分给别人,这叫内部碎片;进程比分区大时,即使总空闲内存很多,也可能没有任何一个分区装得下。
可变分区按进程实际需要切块,进程结束后形成空洞。相邻空洞可以合并,但被已分配区域隔开的空洞无法直接拼在一起。运行一段时间后,空闲总量也许足够,却散落成很多小块,这叫外部碎片。

假设空洞依次为 100 KB、500 KB、200 KB、300 KB、600 KB,现在要放入 212 KB 的进程:
没有一种策略对所有工作负载都最好。进程大小分布、释放顺序、空洞数据结构和搜索成本都会改变结果。教材中的单次示例能帮助你理解规则,却不能单凭一次放置就宣布某个算法永远更优。
外部碎片的直接补救是把已分配区域搬到一侧,让小空洞合并成大空洞。前提是程序地址能在运行时重定位,否则移动后所有旧地址都会失效。即使有硬件重定位,复制大量内存也会占用带宽,移动期间还要处理并发访问和设备 I/O,所以压缩不是可以频繁执行的免费整理。
分页后来成为主流,原因之一就是它不要求一个进程的物理空间连续。不过分页消除的是外部碎片,页的最后一部分仍可能用不满,内部碎片并没有消失。
选择算法,再依次分配或释放进程。实验会显示每次搜索的目标空洞,以及总空闲量与最大空洞的差别。
分页先选定一个固定大小,例如 4 KiB。进程的虚拟地址空间按这个大小切成页,物理内存按同样大小切成页框。页和页框大小相同,因此任意一个虚拟页都能放进任意空闲页框,进程的物理位置不再需要连成一段。
若页大小是 字节,虚拟地址的低 位表示页内偏移,其余高位表示虚拟页号。页表用虚拟页号查到物理页框号,偏移原样保留:
例如页大小为 4 KiB,也就是 字节,逻辑地址 13420 可写成:
所以页号是 3,页内偏移是 1132。如果页表记录“虚拟页 3 → 物理页框 17”,物理地址就是:

操作系统维护空闲页框,建立和撤销页表项,在进程切换时选择当前地址空间,并处理翻译失败产生的异常。MMU 在每次取指、读数据或写数据时,查询翻译缓存或遍历页表,拼出物理地址,同时检查访问权限。
页表项通常不只含页框号,还会带有若干状态与权限信息:
如果页不存在、权限不符或条目非法,硬件触发页故障类异常。异常并不自动等于程序错误:按需分配、写时复制、文件映射和从交换区换入都可能故意借助页故障完成懒加载。只有当内核判断这次访问没有合法解释时,才会把错误反馈给进程。
页越小,进程末尾浪费的内部碎片通常越少,权限和回收粒度也更细;但页数更多,页表更大,TLB 覆盖的总内存更小。页越大,页表项减少,连续访问更容易被少量 TLB 条目覆盖,I/O 也能成块进行;代价是内部碎片和无谓数据搬运可能增加。
如果一个对象占用 个整页后还多出一点空间,它需要 个页,最后一页未使用部分就是内部碎片。在“请求大小相对页边界近似均匀”的简化假设下,平均浪费常被估成半页,但真实程序并不一定符合这个分布,不能把半页当成每个进程的固定损失。
分页可以让多个进程把各自的虚拟页映射到同一物理页框。共享库的只读代码很适合这样处理:每个进程看到自己的地址,背后却是同一份物理代码。共享内存则可以把同一物理页以可写方式映射给协作进程,但同步问题要由程序额外处理。
私有数据通常映射到不同页框。创建子进程时还可以先让父子进程共享只读页面;谁第一次写,页故障处理程序再复制一份,这就是写时复制。它说明“共享”与“隔离”不是二选一,而是可以按页面和权限精确安排。
分页解决了放置问题,却给每次访存加了一道翻译。如果页表在内存中,而一次普通访问先读页表再读目标数据,访问次数会显著增加。多级页表还可能要读取好几级条目。
TLB 是 MMU 内部用于缓存近期地址翻译的小型高速结构。处理器用虚拟页号以及地址空间标识查询它:命中时立即得到页框号和权限;未命中时,硬件或软件执行页表遍历,找到条目后再把结果填入 TLB。

用一个简化模型估算:TLB 查询时间为 ,主内存访问时间为 ,TLB 命中率为 ;忽略缓存、乱序执行和多级页表差异,并假设未命中只多读一次页表,则有效访问时间近似为:
如果 ns、 ns、命中率为 99%,结果约为 81.8 ns。命中率看起来只下降一个百分点,未命中路径却要多付一次内存访问,所以局部性很重要。
真实处理器更复杂:页表可能有多级,页表项本身也会被数据缓存和页表遍历缓存命中;一次未命中不一定真的访问内存很多次;多级 TLB、大页与推测执行也会改变延迟。因此这个公式适合理解方向,不适合当作某颗 CPU 的精确性能模型。
不同进程可以使用相同虚拟页号,却映射到不同页框。若 TLB 条目只有虚拟页号,切换进程时就必须清掉旧条目。现代架构会给翻译附加地址空间标识,例如 ASID 或 PCID,让不同进程的条目可以同时留在 TLB 中。
标识数量仍然有限,页表修改也可能让已有缓存翻译过时。操作系统撤销映射、收紧权限或回收页框后,需要按架构规则使相关 TLB 条目失效;多核系统还要通知可能缓存了旧翻译的其他核心。这个过程会产生处理器间通信,所以频繁改变映射也有成本。
点击不同场景,实验会逐步显示 TLB 查询、页表遍历、权限检查和最终结果。它完全在页面内运行,不依赖网络。
假设 32 位虚拟地址、4 KiB 页面、每个页表项 4 字节。虚拟页号有 20 位,共可能有 个页表项。若给每个进程都准备一张完整单级页表,大小就是:
对于 32 位地址,这已经不算小;换成更大的虚拟地址空间,直接为所有可能页面预留条目更不现实。关键观察是:一个进程通常只实际使用虚拟空间中的少数区域,中间大片地址根本没有映射。
多级页表把虚拟页号再切成几段。顶级条目只在某一大片虚拟区域被使用时,才指向下一层页表;未使用区域不必分配下级表。
经典的 32 位、4 KiB 页面、两级页表示例可以把地址分成 10 位页目录索引、10 位页表索引和 12 位页内偏移。每级 10 位可选择 1024 个条目,最后 12 位覆盖 4096 字节。这样仍能描述整个 32 位空间,却能只为用到的区域创建二级表。

代价也很直接:TLB 未命中后,硬件可能需要依次读取多个层级。处理器会使用页表遍历缓存,大页还可以在较高层提前结束遍历,但层次越深,最坏路径越长。多级结构是在页表空间和遍历成本之间交换。
普通页表叶子条目映射一个基础页面。若某个高层条目允许直接映射一整块连续物理区域,遍历就不必进入更低层。大页用一个 TLB 条目覆盖更多字节,很适合大块连续、权限一致的内存;但分配与迁移需要更大的连续页框,内部碎片也可能增加。
多级页表按虚拟地址的层级索引,结构清楚,也得到主流硬件直接支持。面对很大的稀疏地址空间,还可以从其他方向压缩元数据。
哈希页表把虚拟页号和地址空间标识做哈希,桶内再比较真正的键。查找性能受冲突和装载因子影响,硬件与操作系统需要处理碰撞。
倒排页表则围绕物理页框组织,系统为每个物理页框记录“它属于哪个进程的哪个虚拟页”,表大小更接近物理内存规模,而不是每个进程的虚拟空间规模。它节省表项,却让“从虚拟页找物理页框”不再是直接数组索引,通常需要哈希等辅助结构;共享页也更难在单个倒排条目里表达。
页表的概念模型和具体操作系统实现不必一一对应。操作系统可能用统一的五层接口描述多种架构,再把硬件不存在的层折叠掉;处理器实际支持几级、哪些大页大小,仍由架构和具体型号决定。
如果所有已承诺页面都必须常驻物理内存,可并发进程数很快会被内存容量卡住。交换的基本想法是把暂时不用的内容写到后备存储,释放页框,之后访问时再读回来。
早期系统常把整个进程换出、换入。进程很大时,这样搬运太粗。分页系统更常在页面粒度上回收:干净的文件页如果能从原文件重新读取,可以直接丢弃;被修改过的匿名页没有原文件副本,需要写入交换空间;被修改的共享文件页则按映射语义回写。

真正拖慢系统的是存储 I/O。一次 TLB 未命中仍在纳秒级硬件路径附近周旋,而从后备存储换入可能慢许多数量级。内存压力持续很高时,系统可能不断换入刚要用的页,又换出马上会再用的页,CPU 大量时间耗在等待 I/O,这就是抖动式的恶化。
设备直接内存访问时,硬件可能正按照某个物理地址读写缓冲区。如果操作系统悄悄搬走这个页框,设备仍会访问旧位置。内核通常要在 I/O 期间固定相关页面,或借助 IOMMU 与驱动协议安全更新映射。
内核关键数据、实时任务锁定的页面以及某些设备内存也可能不可换出。可回收页越少,剩余页面承受的压力越大。交换因此是容量缓冲,不是物理内存的等速替代品。
移动设备的闪存写入、能耗和应用生命周期都有自己的约束。一些系统会优先压缩内存、丢弃可重建缓存,或直接冻结与终止后台应用,再在用户返回时恢复应用状态。具体策略随系统变化,但背后的权衡相同:保留更多进程能改善切换体验,频繁写入和恢复又会增加延迟、能耗与存储压力。
抽象层面,两类架构都把虚拟地址交给 MMU,通过分层翻译表得到物理地址和访问属性,并用 TLB 缓存结果。差异主要落在表项格式、控制寄存器、页大小选择、权限位和虚拟化翻译规则上。
32 位 x86 的经典地址路径可以先通过分段得到线性地址,再由分页把线性地址变成物理地址。分段能表达基址、界限和权限,但现代通用操作系统通常采用接近平坦的分段设置,把主要隔离工作交给分页。
在 64 位模式中,分段的地址重定位作用大幅弱化,分页成为主体。常见配置使用 4 KiB 基础页,并支持较大的映射;实现可以采用四级或五级分页。处理器从页表根开始按虚拟地址的若干字段逐级索引,页表项提供物理地址部分以及存在、读写、用户/管理、禁止执行等属性。
进程切换会改变页表根。PCID 一类地址空间标签能减少切换时清空全部 TLB 的需要,但操作系统修改映射后仍必须正确执行失效操作。大页减少页表层级和 TLB 压力,却要求更大连续物理区域,和前面讨论的取舍完全一致。
AArch64 软件发出虚拟地址,MMU 先查 TLB,未命中时由表遍历单元读取翻译表。Armv8-A 可描述 4 KiB、16 KiB 或 64 KiB 翻译粒度,具体处理器实现支持哪些粒度需要查询能力寄存器。多级表可以在较高层直接映射大块,也可以继续指向下一层获得更细粒度。
EL0/EL1 语境中,用户空间与内核空间可以使用不同的表基址配置。ASID 给非全局翻译标记所属地址空间,使不同应用的相同虚拟地址可以共存在 TLB。表项还携带访问权限、内存类型、可缓存性和共享性等属性;普通内存与设备寄存器不能被硬件用完全相同的方式对待。
虚拟机还可能经历两阶段翻译:来宾操作系统把虚拟地址翻译为中间物理地址,管理程序再把中间物理地址翻译为真实物理地址。这里的“阶段”是两套地址空间控制权,“层级”则是某个阶段内部的多级表,二者不要混淆。
学习实例的目标不是记一排寄存器缩写,而是能问对问题:虚拟地址被切成哪些索引和偏移?页表根在哪里?哪个位控制用户访问、写入和执行?TLB 条目如何区分进程?修改映射后怎样使旧翻译失效?大页在哪一级结束遍历?
只要沿着这组问题看手册,换一代处理器或换一种架构时仍能迅速建立完整模型。
现在回到浏览器读取一个变量的瞬间:编译和链接已经把符号安排进虚拟地址布局;CPU 指令计算出虚拟地址;MMU 用当前地址空间标识查询 TLB;命中后检查权限并拼出物理地址,未命中则遍历多级页表;如果页面暂不驻留,异常进入内核,内核可能分配页框或从后备存储换入;映射建立后,原指令重试并继续执行。
这条链路里的每一层都在解决一个具体矛盾:
操作系统内存管理没有一个“只赚不亏”的技巧。真正成熟的设计,是把最常走的路径压得足够短,同时为稀有但昂贵的情况准备正确的恢复机制。