你有没有想过这样一个问题:我们在 C 语言里写 int *p = &a; 打印出来的那个 p 的地址,真的是内存条上那个实实在在的坐标吗?答案是否定的。你在程序里看到的每个"地址",其实都是一层"假象"——处理器和操作系统在背后吹了好多层的"牛皮",才让你用起来又安全又省心。
而这套"牛皮",不是一天吹出来的。从 1971 年 Intel 造出第一块微型处理器到今天,内存管理走过了一条极其曲折的路:裸机直接寻址 → 实模式分段 → 保护模式分段 → 平坦模式 → 分页 → 虚拟内存与多级页表。今天这篇,就是把这条路从头到尾给你走一遍。你不需要有任何前置知识,跟着年代线往下读,读完全部的"为什么"就通了。
先给你一张总的时间轴,心里有个谱:
1971-11 1974 1978 1982 1985
│ │ │ │ │
4004 8080 8086 80286 80386
(4位) (8位) (16位) (16位) (32位)
│ │ │ │ │
直接 直接 实模式 保护模式 保护模式成熟
物理 物理 分段 引入 + 分页引入
寻址 寻址 引入 (24位总线) (4GB 寻址)
│ │ │ │ │
│ └───────┴─────────┴──────────┴──┐
│ │ 分段(基址) 清零→平坦模式
│ │ + 分页 = 虚拟内存
└───────────── 一直演进到今天的 x86-64 ─────────┘
四个字先记住:段页之争、页胜出。为什么分页最终赢了?看完你就懂。
从裸机到分段:为什么 8086 需要一个"段"的概念
一切都要从"直接访问内存"的最原始年代说起。
1971 年 11 月 15 日,Intel 推出了世界第一块个人微型处理器 4004,那是一颗 4 位的处理器。随后又推出了 8080(8 位处理器)。在那个时候,访问内存只有最直白、最自然的一种想法:我要哪个字节,就报出那个字节的物理地址。物理地址,就是处理器真正送上地址总线、去内存条里定位某字节的那个编号——它是内存里货真价实的位置。
在那个年代,CPU 想要某个地址 → 直接把地址送到地址总线 → 内存控制器取回数据,一气呵成,中间没有任何弯弯绕。也就是说:
处理器 地址总线(如 16 根线) 内存
┌────┐ ────────地址帧(0~2^16-1)────────▶ ┌────────┐
│CPU │ │ RAM │
└────┘ ◀────────数据帧────────────── └────────┘
这段时期,对于内存来说,还没有"段"这个概念。你有多少根地址线,就能直白地访问多大空间,仅此而已。
转折发生在 8086 身上。8086 是一颗 16 位处理器,配置很"拧巴":
- 数据总线:16 位。
- 地址总线:20 位。
- 所有寄存器:一律 16 位。
这就尴尬了。地址总线是 20 位,意味着 CPU 理论上能寻址 2^20 = 1MB 的内存——这就是 8086 的最大寻址能力。可问题是,寄存器统统只有 16 位,一个 16 位的寄存器最最多能装下一个 0~2^16-1(也就是 0~65535,共 64KB 范围)的数。你拿着一个 16 位的"手",却要够到 20 位(1MB)那么大的货架,直接就够不着了。
怎么办?Intel 的工程师给出的答案是:把 1MB 的存储空间,切成很多个"段"来管理。
于是:
- 一个段,是内存里的一段区域,其最长可以到 64KB(由于偏移只有 16 位,所以一段最多 64KB,但不一定非得是 64KB)。
- 使用内存时,我们不再报"单一的一个绝对地址",而是报**"段基地址 + 段内偏移地址"** 两个数。
- 处理器记录每个段的起始地址,一段一段地去访问整片内存。
这就是分段(Segmentation)。它要解决的最根本问题,就是用 16 位的寄存器,去表达 20 位的地址空间。
实模式下分段的计算公式
那 1MB 的地址怎么用两个 16 位的量拼出来呢?规则极其朴素:
物理地址 = (段基地址 << 4) + 段内偏移
= 段基地址 × 16 + 段内偏移
也就是把段基地址左移 4 位(等价于乘以 16),再加上段内偏移,凑出一个 20 位的物理地址。对应的两个寄存器是:
- 段基址寄存器(16 位):保存每个段的起始位置;
- 段内偏移寄存器(16 位):保存在该段内的偏移量。
这里的"左移 4 位 + 加偏移",本质就是一种地址变换:用两个 16 位数,算出 20 位结果。它把 1MB 的寻址范围"装进"了 16 位寄存器的世界里。这种寻址方式,就叫做实模式(Real Mode)。
来算一个具体的例子。假设我要访问物理地址 0x05808,那么我可以令:
段基地址 = 0x0580
段内偏移 = 0x0008
物理地址 = (0x0580 << 4) + 0x0008
= 0x05800 + 0x0008
= 0x05808 ← 正好命中目标
注意一个小坑:同一个物理地址,往往有不止一种段基地址与偏移的组合。比如 0x0580:0x0008 和 0x057F:0x0018(0x057F0+0x18=0x05808)指向的其实是同一个物理地址 0x05808。这叫做"别名"现象(aliasing)。在早期用实模式写程序(例如 DOS 时代直接操作内存)时,这会让你在"反推某个地址属于哪段"时摸不着头脑——因为根本不唯一。这也是为什么后来我们要引入更规整的机制。
把这段逻辑凝练成一张图:
段基址寄存器(16位) 段内偏移寄存器(16位)
┌──────────────┐ ┌──────────────┐
│ 0x0580 │ │ 0x0008 │
└──────┬───────┘ └──────┬───────┘
│ << 4 (乘16, 变成0x05800) │
▼ │
0x05800 ──+──────────── │ + 0x0008
│ │
▼ ▼
0x05808 ← 20 位物理地址,寻址范围达 1MB
到这里,8086 通过"段 + 偏移"这套组合拳,把自己的寻址空间从 16 位(64KB)扩大到了 20 位(1MB)。分段机制的第一个成绩单:扩大了可寻址范围。
分段带来的礼物:程序的动态重定位
分段不只是为了贴上"1MB 寻址"这个标签,它还顺手解决了当年普遍软件工程最大的一个痛点:程序装进内存后,就再也挪不动位置。这个痛点,从"静态重定位"与"动态重定位"的区别里看得最清楚。
静态重定位:程序一装进去就被焊死了
在分段思想成熟之前,程序要在电脑上跑,必须采用静态重定位的方式:编译/链接阶段,就把程序运行时要用到的每个内存地址硬编码写死在代码里。这意味着:
- 程序第一次被装入内存后,它在内存里的位置就不可移动了;
- 操作系统无法再次对程序做重定位操作。
这个"焊死"的后果很严重。你在一个程序运行期间,既不能把它挪走变成一整块连续空闲,也没法轻易压缩内存。再厉害的操作系统,在这种"每块程序都焊死在原位"的前提下,都没法高效地完成内存的回收和分配。于是,一个本来就不富裕的内存空间里,还会冒出大量空隙——这些空隙彼此离散、不好利用,我们叫它外部碎片(External Fragmentation)。碎片越多,可用内存越"看着有,用不上"。
动态重定位:执行时现算地址
分段带来的动态重定位(Dynamic Relocation) 完全换了一个思路:不需要在装载阶段去改程序指令,而是在执行阶段,每次 CPU 访问内存的当下,才去把"逻辑地址"(编译、链接后生成的段内偏移地址,就叫逻辑地址)转换成物理地址。
转换靠的就是前面那两个寄存器:段基址寄存器 + 段内偏移寄存器。程序运行过程中,CPU 每次访问内存,都把"段基地址"与"段内偏移"加一加,当场算出真正的物理地址。
这样一来,程序在内存里就可以随意移动——我只需要移动的时候,顺手把段基址寄存器的值一并改掉就行,程序本身的代码一个字都不用动。这可太关键了:它给了操作系统在内存管理上极大的发挥空间,让"内存整理""内存回收"这样的操作真正成为可能。
你品一品这个区别:
静态重定位: 地址写死在代码里 → 程序不能动 → 碎片越堆越多
动态重定位: 地址每次现算 → 程序随便挪 → 碎片可被整理利用
分段的第二个成绩单:让程序可重定位,让内存管理有了余地。
实模式分段的致命伤:没有隔离
事物总有两面。分段带了好处,也埋下了隐患——而且是个大隐患:安全问题。
因为实模式是直接对物理内存做分段操作,此时程序与程序之间的地址没有任何隔离。换句话说:
- 我的程序可以读到你的程序的地址;
- 我的程序甚至可以直接读写操作系统所在的内存;
- 我一个野指针飞过去,轻则把别人程序的数据改得面目全非,重则直接把操作系统给干挂掉——宕机、蓝屏、死机,全都可能发生。
想象一个画面:大家都住在一个没有门、没有墙、没有产权证的大通铺里,你半夜翻个身都能踢到别人的铺盖,扫个地都可能把别人的家什清走。现实里大家讲究"我的地盘我做主",实模式的内存世界里却完全没有这回事。
这样的安全漏洞显然不能让系统真正地multitasking(多任务)起来。于是,保护模式下闸了。
保护模式登场:从 80286 到 80386
要谈保护模式,绕不开两颗芯片:80286 和 80386。
80286:保护模式的先行者,也是牺牲者
1982 年,Intel 推出 80286,并一并推出了基于"保护模式(Protected Mode)"的寻址方式。所谓保护模式,核心就一句话:CPU 在访问地址时,不再无脑放行,而是加上约束:
- 判断这段地址是否在允许的范围内(段界限检查);
- 判断当前程序对目标地址是否有访问权限(特权级检查)。
这正是对实模式"没有隔离"问题的正面回应。但 80286 的硬件配置又很尴尬:它的地址总线已经是 24 位(意味着能寻址 2^24 = 16MB),可通用寄存器仍然只 16 位,而每个段依然只有最大 64KB。结果就是:要访问 64KB 之外的内容,就不得不频繁地更换段基地址。这体验太差了——程序里夹着一大堆来回折腾段的代码,开发效率极低,还容易出错。
所以 80286 很快就被淘汰出局,Intel 紧跟着掏出了王炸。
80386:保护模式终于大放异彩
80386 一出,配置几乎"硬件拉满":除了段寄存器仍是 16 位之外,地址总线和通用寄存器都变成 32 位。也就是说:
- 寄存器能一步跳到 32 位(可表示 0~4GB);
- 地址总线也是 32 位,CPU 寻址能力直达
2^32 = 4GB。
在当年 CPU 昂贵异常的时代,80386 这套"直接把硬件顶到顶"的堆料,彻底把保护模式从"能用"推向了"好用"。从 80386 开始,保护模式才真正大放异彩——现代 x86 的分段、保护、以及我们要讲的分页,都和这颗芯片脱不开干系。
这里插一句概念厘清:分段机制是 x86 架构的"固有机制",是处理器的行为。哪怕到了今天的 x86-64,处理器在底层逻辑上依然"按照 [段机制 + 段偏移]"去定位内存——只是操作系统把段基址都设成了 0,让这一层"形同虚设"(这就是后文的平坦模式)。所以不是"分段消失了",而是"分段被架空成了一条挡不住的直通路"。这层认识很关键,别被"有了分页就不要分段"的说法带偏。
全局描述符表 GDT 与段描述符
保护模式的核心问题有两个:去哪找段描述符,以及如何判断当前程序有没有权限访问目标地址。要回答它们,先得认识一张表:GDT。
GDT(Global Descriptor Table,全局描述符表),是操作系统在内存里维护的一张表,用来存放所有的段描述符。它有三个硬特点:
- 整张表最大 64KB;
- 在内存中有且只有一张;
- 可以把它理解成一个数组,数组里每个元素就是一个 8 字节的段描述符。
于是:64KB ÷ 8字节 = 8192,GDT 最多能容纳 8192 个段描述符。
GDT 这张表放在内存哪个位置、有多大,需要一个"指针"来记。这个指针被放在一个专门的寄存器里——GDTR(Global Descriptor Table Register)。GDTR 是 48 位:高 32 位存放 GDT 地址,低 16 位存放 GDT 大小。注意:因为低 16 位表示大小,所以 GDT 上限自然是 2^16 = 64KB,和你刚才算的 8192 个描述符对上了。
GDTR (48 位)
┌──────────────────────────────────────┐
│ GDT 基地址 (32位) │ GDT 大小(16位)│
└──────────────────────────────────────┘
指向 最大 64KB
▼
┌───────────────────────────────────┐
│ GDT:段描述符[0] 段描述符[1] ... │ 每个 8 字节
└───────────────────────────────────┘
最多 8192 个描述符
解剖一个段描述符
每个段描述符 8 个字节,里面塞了"这个段从哪开始、到哪结束、喂给谁用、允不允许用"等全部信息。我们拆开来看(以保护模式下 32 位段的描述符为例):
- Segment Limit(段界限):共 20 位,被拆成两部分存放(低 0
15 位 + 高 1619 位),使用时需要合并。它标识一个段的大小上限。 - Base Address(段基地址):共 32 位,被拆成三部分存放(低 16
31 位、中间 07 位、高 24~31 位),合并后是一个完整的 32 位基地址。 - 段属性:共 12 位,可细分出 8 种属性:
TYPE、S、DPL、P、AVL、L、D/B、G。
为什么好好的 20 位、32 位字段要"五马分尸"地拆开存?这完全是历史包袱——段描述符格式诞生很早,Intel 为了向后兼容、为了不打破已有的 8 字节布局,只好把字段拆零到不同的字节里。具体为什么拆成这样,如今已无从查知,你只要记住"合并回去就好"即可。
下面逐个解析这 12 位属性:
S 属性:这是数据/代码段,还是系统段?
- S=0:该描述符是系统段(用于中断门、陷阱门、调用门、任务门、TSS、LDT 等系统级结构)。
- S=1:该描述符是数据段或者代码段。
TYPE 属性:段具体是什么、能不能写、能不能读
TYPE 的意义随 S 的不同而变化。
- 当 S=1(数据/代码段)时,
TYPE用来区分到底是数据段还是代码段:- 若是数据段,还可标记:
W写权限(可写/只读)、增长方向(向上扩展还是向下扩展,栈段常用向下扩展)、以及访问标记(是否被访问过)。 - 若是代码段,还可标记:
R读权限(可读/只能执行)、访问标记,以及是否是可「一致」的代码段(conforming,即能否被低特权级调用)。
- 若是数据段,还可标记:
- 当 S=0(系统段)时,
TYPE用来区分系统级的段种类,比如中断门(Interrupt Gate)、陷阱门(Trap Gate)、调用门(Call Gate)、任务门(Task Gate)等等。这些门机制用于系统调用、中断处理、任务切换,属于比较深的话题,这里点到为止,先不求甚解。
DPL 属性:访问这个段需要多高的权限
DPL(Descriptor Privilege Level,描述符特权级),记录了访问该段所要求的特权级,范围 0~3,数字越小特权级越高:
DPL = 00 → Ring0 最高权限(内核)
DPL = 01 → Ring1
DPL = 10 → Ring2
DPL = 11 → Ring3 最低权限(普通用户程序)
处理器会根据这个数值来控制段的访问:只有当前程序的特权级"够格"(足够高),才允许访问这个段。这就是保护模式"管权限"的核心抓手之一。
P 属性:这个段现在在不在内存里?
P(Present,存在位) 标记该段是否存在:
- P=1:该段在内存中;
- P=0:该段此刻不在内存中(可能只建了描述符,但内存还没分配,或已被换到磁盘)。
尝试访问一个 P=0 的段,会触发段不存在错误。这时候:
- 处理器检查到这个段不存在(P 是处理器负责检查的);
- 产生一个中断/异常;
- 由操作系统接管,把这个段从硬盘换回内存;
- 再把 P 置回 1,让程序继续跑。
在多任务、多用户系统中,这是一种非常经典的虚拟内存调度策略——只不过它是在"段"这个粒度上做换入换出。请记住这个"P 位 + 缺段异常 + OS 换页"的套路,因为一会儿我们讲分页时,页表项的"有效位"会复刻一模一样的思路。
AVL 属性:留给操作系统自便
AVL(Available,可用位) 占 1 位,其意义可由操作系统或应用程序自行定义。Intel 保证该位绝不会被占用作其他用途。说白了,它是一个"开放给系统软件玩"的保留位。
L 属性:长模式专属
L(Long,长模式位) 只在 IA-32e 模式(也就是 64 位模式) 下有意义,它标记该段是否为 64 位代码段:
- L=1:该段是 64 位代码段;
- L=0:32 位段。
(严格说,在 64 位模式下 L 通常必须为 1 才是 64 位代码,这里记住"L=1 表示 64 位代码段"就够。)
D/B 属性:默认操作数/栈寻址尺寸
D/B 在不同种类的段里含义不同:
- 在代码段中记作
D:D=0表示 16 位(用IP来做指令指针),D=1表示 32 位(用EIP)。 - 在栈段中记作
B:B=0表示用 16 位栈指针SP,B=1表示用 32 位栈指针ESP。 B位同时决定栈段的上边界:B=0时上边界是SP的最大值0xFFFF(64KB);B=1时上边界是ESP的最大值0xFFFFFFFF(4GB)。
G 属性:段界限的粒度
G(Granularity,粒度位) 控制段界限的单位:
- G=0:段界限粒度为 1 字节。20 位段界限 + 1 字节粒度,一个段的最大界限值是
2^20 × 1B = 1MB。 - G=1:段界限粒度为 4KB。一个段的最大界限值是
2^20 × 4KB = 4GB。
一旦粒度设为 4KB,段界限的"计数单位"就变大了,这样即使段界限只有 20 位,也能表达出高达 4GB 的段长——这正是 32 位模式下让"一个段铺满整个 4GB 地址空间"成为可能的硬件基础。
看到这里你应该有个感觉:段描述符把"我在哪(Base)、我多大(Limit/粒度)、谁能进(DPL/S/TYPE)、在不在(P)"全部固定了下来。剩下的问题是:我该用哪个描述符?我的权限够不够? 这两个问题由段寄存器里放的那个"选择子"来回答。
段选择子:找到描述符的索引
有了 GDT 这张"段档案表",接下来就是"如何根据一个段号,找到对应档案"。这个"段号"就是段选择子(Segment Selector),它放在段寄存器里。
和实模式不同,保护模式下段寄存器里不再直接放段基地址,而是放"选择子"。之所以叫"选择子",是因为它本质上存的是一个索引——指向 GDT(或 LDT)里某个段描述符的下标,就像 array[i] 里的那个 i。把索引交给 GDT,就能"按图索骥"拿到真正的段描述符。
段选择子是 16 位的,拆成三个字段:
段选择子 (16 位)
┌───────────────────────────────────────────┐
│ Index(13位) │TI│ RPL(2位) │
│ 段描述符索引 0~8191 │1 │ 0~3 │
└───────────────────────────────────────────┘
位:15 3 2 0
- Index:段的索引,指定"第几个段描述符",就像操作数组下标一样,范围 0~8191,对应 GDT 中最多 8192 个段描述符。
- TI(Table Indicator,表指示器):标明去哪张表找。
TI=0:此段描述符在 GDT 里(全局表);TI=1:此段描述符在 LDT 里(局部表,现在较少使用,我们暂不关注)。
- RPL(Requested Privilege Level,请求特权级):请求者的特权级,一般是构建这个选择子的程序的权限,共四层
RPL=00/01/10/11,对应 Ring0~Ring3。
这里有一个非常关键的保护判定规则:
当地址访问发生时,如果当前请求方的权限(RPL)低于目标段要求的权限(DPL),就会拒绝访问。
看,RPL 是"提出请求的人有多大的面子",DPL 是"这个门要求多大面子才给进"。当 RPL 的权限低于 DPL(也就是 RPL 数值大于 DPL 数值,别把数字大小和权限高低搞反了)时,就认定你没资格,拒绝放行。这一"面子的比对",就是保护模式里"保护"二字的精髓所在——它把"程序能不能碰某块内存"的决定权,从程序自己手里没收了,交给了处理器 + 操作系统。
为什么分段还是被淘汰了
好了,来到本文第一个"大反转":分段明明已经做到了"寻址 + 重定位 + 保护",为什么最后还是被分页取代?原因主要有两个。
问题一:硬件升级,让分段机制变"多余"了
80386 除了段寄存器是 16 位之外,地址总线和寄存器都是 32 位。我们来算一笔账:
- 单段内偏移就能访问到 4GB 空间(32 位偏移);
- 而每个任务最多可拥有
8192 × 2个段(GDT 的 8192 个 + LDT 的 8192 个); - 每个段最大 4GB;
那么一个任务理论上能访问到的空间高达:8192 × 2 × 4GB = 64TB。这远远超出了实际物理内存的大小。更重要的是,此时 CPU、寄存器、地址总线原生支持寻址 4GB,只要不停更换偏移地址,就能摸到内存的每一个字节——那还要那个"段"干嘛?分段在这个节骨眼上,已经变得可有可无了。
但 Intel 为了向后兼容,还是保留了段机制。办法很取巧:把每个段的段基值都设成 0,仅仅用段内偏移地址去访问内存空间。
你想想这意味着什么:既然每个段的起始地址都是 0,那么"段"和"段"之间的界限就形同虚设了——等于在操作系统层面不再分段了。这就是赫赫有名的平坦模式(Flat Model)。Linux 正是这样实现的。后面我们还会看到,平坦模型 + 分页组合起来,才是现代虚拟内存的完整配方。
问题二:分段导致内存碎片大,不利于管理
先给个画面。假设此时你有 200MB 内存,同时跑着 3 个应用:LOL、Chrome、微信,各自占了一部分内存。突然你又想再开一个「网易云」,可它需要连续的一段空间,却装不进来——因为此刻内存里明明还有 30MB 空闲,只是这些空闲是零零散散的,凑不出一块连续的区域给网易云用。
这时候传统的做法是"搬砖":
- 先把 Chrome 换/挪到磁盘里让出空间;
- 或者把 Chrome 挪到微信后面,让那堆零散空闲拼成一块连续的 30MB;
- 再让网易云加载进来。
但"把 Chrome 挪走再挪回",等于要把几十 MB 的内存数据反复横跳。磁盘访问是出了名的慢——内存是纳秒级,磁盘是几十毫秒级,差距能有好几个数量级——所以这套"搬来搬去"的方案效率极低。
总的来看,分段内存管理的粒度太粗了。 一个段动辄几十 MB,为了"连续性"不得不大动干戈地整体迁移。于是,紧跟着 80386 出世的,是分页管理——一种更加精细化的内存管理方式。这也就水到渠成地引出了本文的重头戏。
平坦模式:Linux 为什么把所有段基址清零
刚才提到,Linux 采用平坦模式把所有段基址都设成 0。再往深里走一步,你会发现这背后有个非常漂亮的"投靠"逻辑。
在平坦模式下:
线性地址 = 段基地址(0) + 段内偏移 = 段内偏移
也就是说,段这一层没有做任何偏移,它彻彻底底地变成了一条"直通"的透明中间层。于是:
- 你在程序里看到的"逻辑地址",在平坦模式下就等于"线性地址";
- 在 Linux 下,我们通常干脆把虚拟地址和线性地址当作同一个东西来看待(因为段基址为 0,分段等于没起作用,剩下的翻译工作全部交给分页)。
这就能推导出一个结论:在分页模式下,CPU 寄存器里存放的地址,一定是虚拟地址。你 C 语言里 printf 打出来、& 取出来的那些地址,全都不是物理地址,而是虚拟地址——它们要再经过分页那一层翻译,才能落到内存条上。
那么,平坦模式能在 4GB 内外不露破绽地"假装自己独占一切内存",靠的是谁?靠的就是下面的分页与虚拟内存。说白了一句口诀:平坦模式提供了"我是一个连续大世界"的假象,分页机制负责把这个假象撑圆。
内存分页:把 4GB 切成 4KB 的碎片
分页(Paging) 的思路非常直接:把内存等分成一页一页,每页 4KB 大小,按页为单位来管理内存。("页"这个词,英文叫 page,是虚拟内存和物理内存共同的基本单位;物理内存里对应的那个带"框"的 4KB 块,我们叫页框 / 物理帧(page frame),但现在你先把它们都当成"4KB 的小块"即可。)
分页相比分段,几个优势立竿见影:
- 不用整段搬进内存。按页来管理,就不必把一整个程序段都加载进内存,只需要把用到的页加载进来。内存利用率更高,能同时运行的软件自然就更多。
- 内存交换性能问题缓解。因为一页只有 4KB,要换内存时只需换掉一定的页,而不需要像分段那样把整个段换到磁盘——交换的代价小得多,速度也就快得多。
- 碎片问题被侧面化解。分段时代让人头疼的"凑不出一块连续空间",在分页下基本不存在了:因为任何一块物理页都是 4KB 的碎片,碎片本身就能被自由地拼凑使用,不需要连续的"大段"。
来看一张分页下的映射示意图。进程 A 的虚拟地址空间里看似"连续"的几页,在物理内存里可以是东一块西一块、完全离散的:
虚拟地址空间(进程A) 物理内存
┌───────────────────┐ ┌──────────────────────┐
│ 页1 (offset:0x0000)│──┼──▶ │ [页框7] 页1 │
│ 页2 (offset:0x1000)│──┼──▶ │ [页框9] 页2 │
│ 页3 (offset:0x2000)│──┼──▶ │ [页框3] 页4 │
│ 页4 (offset:0x3000)│──┼──▶ │ [页框12] 页3 │
│ 页5 (offset:0x4000)│ └──────────────────────┘
│ ... │ 物理地址到处散着,
└───────────────────┘ 但虚拟上依然"连续"!
对应地,分页机制构造出了一个"虚拟内存空间",让每个进程都误以为"自己掌控着所有内存"。 前面说的平坦模型,一定是在分页机制下运行的——分页机制是内存平坦模型的地基。没有分页,平坦模型就只是一句漂亮口号,撑不起"每个进程都能独占 4GB"的假象。
虚拟内存:为什么我们必须需要它
聊到这儿,一个更本质的问题必须交代清楚:为什么虚拟内存(Virtual Memory)的存在是必须的,而不是"锦上添花"? 我们从两个"没有虚拟内存就会立刻出事"的场景入手。
场景一:程序之间没有地址隔离
在 CPU 直接操作物理内存的年代,程序之间的地址没有隔离。你自己写个程序,就能访问到别人的程序地址,甚至访问操作系统本身的地址。于是只要手一抖(写了个越界指针、循环边界差 1),就可能把操作系统直接给干挂。你线的冲浪板没错,可大海已经把你的小船掀翻了——太危险。
虚拟内存给每个进程分配了独立的一套虚拟地址,互不干涉。 我的程序再怎么乱跑,也只能在自己的"虚拟地图"上撞墙,撞不到邻居,更撞不到内核。虚拟地址和物理内存之间的映射,由操作系统负责。
场景二:两个程序占用重叠的内存,无法并行
再想一个更硬的场景。如果两个程序占用的内存有重叠,想同时运行几乎不可能:
- 第一个程序在
2000这个位置写入了一个新值; - 它会把第二个程序存放在相同位置上的所有内容当场擦掉。
这样两个程序一起跑,会立刻崩溃——互相踩脚,谁都活不成。可是,一台现代电脑同时开几十个进程是家常便饭,哪能让它们"物理上井水不犯河水"地各占一块不重叠的物理内存?物理内存就那么大。
虚拟内存完美地化解了这件事: 每个进程在自己的虚拟空间里,都可以从地址 0 一直用到顶,哪怕大家的虚拟地址"重了",也完全没问题——因为背后的物理地址各归各的,由分页机制偷偷做映射。进程 A 看到的 0x400000 和进程 B 看到的 0x400000,会被映射到两块不同的物理页上,互不干扰。
第三个隐形好处:比物理内存更大的"幻觉"
就算物理内存满了,也没关系:把不常用的内存页先换到磁盘(swap)里,腾出空间。等要用的时候,再把它换回内存。这样,进程能"假装"自己拥有比实际物理内存大得多的地址空间。swap 就是我们常说的交换分区/交换文件——它让"内存不够用"这件事,从"立刻崩掉"变成了"慢一点但不崩"。
把这三个好处打包理解:
虚拟内存三大价值:
① 进程隔离 → 互不干涉、不能随便碰内核/别人
② 并行共存 → 虚拟地址重合也没关系,物理各归各
③ 超量幻觉 → 内存放不下的页换到磁盘, 用的时候再换回
一句话收尾这一段:分页机制是手段,虚拟内存是目的;没有分页,就没有实用主义的虚拟内存。
MMU 与页表:地址翻译的硬件主角
现在我们到了"翻译机关"的内部。既然虚拟地址≠物理地址,那这个翻译到底是谁在做?
三个地址名词,先分清
x86 里其实有一个地址"三兄弟",把它们分清,后面全通了:
- 逻辑地址(Logical Address):程序里能看到的地址,形如
段选择子:偏移。在平坦模式下它就是"偏移本身"。 - 线性地址(Linear Address):逻辑地址经过分段单元(段基址+偏移)之后得到的地址。因为平坦模式下段基址为 0,线性地址 ≈ 虚拟地址 ≈ 逻辑地址的偏移,三者基本"混为一谈"。
- 物理地址(Physical Address):真正送到底层物理内存总线上的地址。
完整翻译路径是:
逻辑地址 ──分段单元──▶ 线性地址 ──分页单元──▶ 物理地址
(段:偏移) (平坦模式 (MMU 查询 (内存条
= 虚拟地址) 页表) 上真实位置)
MMU:翻译的执行官
MMU(Memory Management Unit,内存管理单元),就是负责"查表并翻译地址"的硬件。在 x86 上它被集成在 CPU 里面。它的职责包括:
- 在每次内存访问时,把虚拟地址交给它;
- 它去查询页表,得出对应的物理地址;
- 顺带检查权限、触发出错(缺页异常、保护异常)等。
进程并不知道 MMU 的存在——对进程来说,它从头到尾用的都是自己的虚拟地址。翻译工作是透明的、由硬件自动完成的。这也是为什么进程能安心活在"假象"里。
页表:翻译的"字典"
页表(Page Table) 就是 MMU 用来翻译的那本"字典"。它记录了虚拟页号 → 物理页框号 的映射关系。每个进程都有一本自己的页表(这就是"每个进程独立地址空间"在硬件层面的落地)。
一个粗略的单级页表是这样工作的:把虚拟地址分成 虚拟页号(高 20 位) 和 页内偏移(低 12 位),用虚拟页号去查页表,得到物理页框号,再拼上 12 位页内偏移,就得到物理地址:
虚拟地址(32位)
┌──────────────┬────────────┐
│ 虚拟页号(20位) │ 页内偏移(12位) │
└──────┬───────┴──────┬─────┘
│ │
▼ │
┌─────────┐ │
│ 页表 │ │
│ 表项[0] │──▶物理页框号│
│ 表项[1] │ │
│ ... │ │
└────┬────┘ │
▼ ▼
物理页框号(20位) + 页内偏移(12位) = 物理地址(32位)
等等——这个"单级页表"真的可行吗?我们马上算一笔账,你就会发现它根本行不通,这才引出多级页表。
多级页表:为省内存而生
单级页表为什么不可行
以 80386 的 32 位系统为例,做一个功课。4GB 地址空间被切成 4KB 一页,得有多少页?
页数 = 4GB ÷ 4KB = 2^32 ÷ 2^12 = 2^20 = 1,048,576(约 104 万个)
如果用一张(单级)大表来描述这 104 万个映射,每个表项需要 4 字节(32 位),那么光这一张表就要占:
1,048,576 × 4 字节 ≈ 4MB
每个进程,都要维护这么一张 4MB 的表! 而且注意——这张表是"为最坏情况准备的":哪怕进程只用了一点点内存,也必须在表里为每一个可能用到的虚拟页预留一个表项。想象一下同时跑几十个进程,光页表就要吃掉几百 MB 内存,那真的是"字典比正文还厚"。这还不算,单级页表查找是"线性查表",页表又放在内存里,每次查表都是一次内存访问——代价巨大。
所以,为避免页表占用如此巨大的内存资源,80386 把页表拆成了两级。多级页表的核心思想是:按需分配——只用得到的那部分索引建表,其余不建。
x86-32 的两级页表
80386 把 32 位线性地址拆成三段:
线性地址(32位)
┌────────────┬────────────┬──────────────┐
│ 目录 DIR │ 页表 PAGE │ 页内 OFFSET │
│ (10位 31-22)│(10位 21-12)│ (12位 11-0) │
└────────────┴────────────┴──────────────┘
- 第一级叫页目录表(Page Directory):一张 4KB 的表,有 1K(1024)个表项,每个 4 字节。
- 第二级叫页表(Page Table):每张也是 4KB 的表,有 1K 个表项,每个 4 字节。
- 因为每级都用 10 位索引(
2^10 = 1024),所以索引值 × 4(每个表项 4 字节)+ 基地址,就能算出表项的位置。 - CR3 寄存器保存当前进程页目录表的物理地址(也常叫页目录基址寄存器 PDBR)。上下文切换时,操作系统换上新进程的 CR3,就完成了"换一个虚拟地址空间"。
翻译流程:
CR3 ──▶ 页目录表(4KB)
│ 目录项[D] = 某张页表的物理地址
▼
页表(4KB)
│ 表项[P] = 某物理页框的地址
▼
物理页框 + OFFSET(12位) = 物理地址
为什么两级能省内存?因为页目录只有 4KB 是"必须常驻"的,而具体的页表,只有当进程真真切切用到对应的那部分虚拟地址空间时,才会被分配。进程只用得着几个区域,就只建几张页表,而不是一次性把 4MB 和盘托出。这就叫按需建表,带来的是巨大的内存节约。
从两级到四级:x86-64 的页表
32 位时代两级就够,到了 64 位时代,地址空间暴涨,两级显然控制不住。今天的 x86-64 走的是 4 级页表:PML4 → PDPT → PD → PT,四级索引加偏移:
x86-64 虚拟地址(48位有效)
┌──────────┬─────────┬─────────┬─────────┬─────────┬──────────┐
│ 符号扩展 │ PML4 │ PDPT │ PD │ PT │ 页内偏移 │
│ (16位) │ (9位) │ (9位) │ (9位) │ (9位) │ (12位) │
│ 63..48 │ 47..39 │ 38..30 │ 29..21 │ 20..12 │ 11..0 │
└──────────┴─────────┴─────────┴─────────┴─────────┴──────────┘
└────── 四级索引 4×9=36位 + 0偏移 12位 = 48位 ──────┘
关键数字:
- 每级索引 9 位 → 每级表 512 个表项;
- 因为页表页是 4KB、每项 8 字节(64 位),所以
4096 ÷ 8 = 512 = 2^9,正好 512 项——9 位一次对上了; - 虚拟地址用低 48 位,高 16 位必须是符号扩展位(即第 47 位的值往高 16 位复制):第 47 位是 0,则 63
48 全 0;第 47 位是 1,则 6348 全 1。这种形式叫 canonical(规范)地址。如果程序访问一个非 canonical 地址,处理器会立刻抛出通用保护异常(#GP)——这也是很多"指针越界后先是Segmentation fault,一旦踩到野地址就变成更莫名的错误"背后的原因之一。
各级覆盖范围(从顶层往下):
| 层级 | 索引位数 | 该级覆盖范围 |
|---|---|---|
| PML4 | 9 位 | 每项覆盖 512GB,整表覆盖 256TB |
| PDPT | 9 位 | 每项覆盖 1GB |
| PD | 9 位 | 每项覆盖 2MB |
| PT | 9 位 | 每项覆盖 4KB |
这 4 级页表里每一层的项都有权限位和状态位:存在位(Present)、读写位(R/W)、用户/超级用户位(U/S)、访问位、脏位,以及我们今天重点要讲的能堵死恶意代码的 NX(不可执行)位等。
本文关于 80386 两级页表与 x86-64 四级页表的位划分、每级表容量、大页跳过层级等细节,均有对照 Intel 与公开权威资料核验,可放心使用。
页大小:4KB、2MB 与 1GB 的取舍
页的粒度不是越高越好,也不是越低越好——这里有一个经典的天平。
为什么默认是 4KB
4KB 是个"兼顾粒度与开销"的折中值:足够小,能让映射非常精细、内部浪费(内碎片,即一个进程最后那点不足一页的尾巴)很小;但同时页越细,页表项就越多,翻译要访问的层就越深、TLB 压力越大。所以 4KB 只是"默认选手"。
大页(Huge Pages):用粒度换速度
x86-64 支持用减少页表层级的方式,映射更大的页:
| 大页类型 | 大小 | 跳过的页表层级 |
|---|---|---|
| 标准页 | 4KB | 无(四级全走) |
| 大页 | 2MB | 跳过 PT(PD 的 PS 位置 1,直接指向 2MB 页) |
| 巨页 | 1GB | 跳过 PD + PT(PDPT 的 PS 置 1,直接指向 1GB 页) |
大页的收益很直接:一条 TLB 表项就能映射 2MB 甚至 1GB 的内存。对于数据库、超大规模内存、hypervisor 这类"要访问海量连续内存"的场景,大页能大幅减少缺页翻译次数和 TLB 压力。Linux 内核自己的映射、以及很多大型应用的堆,都爱用 2MB 大页来提速。
但大页不是免费的:
- 粒度变粗 → 内碎片变多。你只想要 1KB 数据,却被一个 2MB 的大页"包圆"了,剩下 1.99MB 都白白闲置;
- 映射不灵活。越大的页,越难在物理内存里找到足够大的连续区域,也越不利于精细化地做换入换出。
所以现实做法是"混用":大块的、稳定的、连续的内存用大页;碎小的、动态的、随机访问的部分用标准 4KB 页。
页大小与性能的感悟
一句话记住这个坑:页越大,翻译越快但越浪费(内碎片 + 排序难);页越小,越省内存但翻译越慢(页表大 + TLB 吃紧)。 天下没有"既又且"的好事,操作系统的选址智慧就在于在合适的场景用合适大小的页。
TLB:页表的硬件高速缓存
讲完页表,一个性能危机就摆上台面了。前面说过,每次地址翻译都要去查页表,而页表存在内存里。以 x86-64 四级页表为例,一次翻译最多要访问 4 次内存(读四级表),再加上真正取数据那次,一次访问可能变成 5 次内存访问!这要是不缓解,性能得崩到没法看。
TLB(Translation Lookaside Buffer,转译后备缓冲器 / 快表) 就是来解这个局的。它是 MMU 内部的一块小而快的硬件缓存,专门缓存"最近用过的 虚拟页号 → 物理页框号"这条翻译记录(连同权限位一起缓存),并靠局部性原理(一个程序往往只在少数几个页上来回访问)大幅命中。
TLB 命中与 TLB Miss
MMU 翻译地址时,先查 TLB:
虚拟地址
│
▼
┌───────┐
│ TLB │ ⌜─── 命中(Hit): 直接得到物理地址, 零次额外内存访问
└───┬───┘ └─── 未命中(Miss): 进入页表逐级"页面步行(page walk)"
│ 未命中
▼
页表逐级查询(多级, 多次内存访问)
│
▼
得到物理地址, 并把这条映射回填进 TLB(下次就快了)
- TLB 命中:不用去碰内存里的页表,零额外内存访问,物理地址当场拿到。
- TLB miss:MMU 必须去遍历(walk)页表(x86 由硬件自动完成,对软件透明),查到后回填 TLB。这次的开销就很大了,这也是为什么我们希望命中率足够高。
为什么 TLB 不能做得很大
既然 TLB 这么好,为什么不把它做成一整块大缓存、把所有映射都装下?三个理由:
- TBL 是 SRAM,而 SRAM 贵且功耗高,容量做大了成本爆炸;
- TLB 处于每次内存访问的关键路径上,做得越大,查找越慢,反而拖累单次访问速度——它必须够小、够快;
- 进程数可能成千上万,翻页表之后才有意义,指望"把所有进程所有页的映射都缓存"本身就是伪命题。
所以 TLB 注定是一块"小而精"的缓存,容量通常只有几十到几千条不等。大页之所以香,正因为一条 TLB 就能映射 2MB/1GB,等效地放大了 TLB 的覆盖面。
TLB 与进程切换 / 页表更新
TLB 缓存的是"某个进程的虚拟空间到物理空间"的映射。当发生进程切换或页表被修改时,缓存可能就失效了。管理方式主要有两种:
- 切换进程:因每个进程页表根不同(CR3 不同),切换到新进程时要保证 TLB 不含上一个进程的"陈年旧账",否则会映射错乱、误读别人内存。传统做法是切换时刷新 TLB;现代 x86 引入了 PCID/ASID(进程上下文标识符),给每条 TLB 记录打个"这是谁的"标签,从而避免每次切换都整体清空,减少重复丢失。
- 修改映射:当操作系统做了
mmap、munmap、换页等操作后,需要执行invlpg指令(刷新单条 TLB)或更新 CR3 来使对应缓存失效,否则可能一直读到"旧物理地址"。
顺带一提,有的 RISC CPU 是软件管理 TLB:miss 时抛异常给操作系统,由 OS 自己填 TLB。而 x86 走的是硬件自动 walk 页表的路子。二者各有取舍,但"TLB 是 x86 翻译性能的守护神"这个大结论是通用的。
内核与用户态的隔离:一页一页地看守
虚拟内存带来的隔离,最终要落在"每个内存页能不能被某个特权级访问"上。这正是用户态(Ring3)与内核态(Ring0)隔离的硬件基础。
页表项里的 U/S 位(User/Supervisor,用户/超级用户位) 是这个机制的核心:
U/S 位的作用:
访问发生在特权级:
Ring3(用户态): 只能访问 U/S=1 的页(用户页); 碰到 U/S=0(内核页) → 缺页异常, 禁止访问
Ring0(内核态): 可以访问 U/S=1 和 U/S=0 的页
于是,内核把自己所在的高地址内存页标记成"仅超级用户可访问"(U/S=0),用户在 Ring3 下无论怎么写代码,都碰不到内核页。哪怕你 ESP 指针乱飞、地址写到内核头上,也会在页表这一关被拦下,抛出一个非法访问,而不是真的把内核踩爆。
再配合页表项里另一个现代的保险——NX 位(No-eXecute,不可执行位)——你可以让某块内存"可读可写但不能当代码执行"。这就是著名的 W^X / DEP(数据执行保护) 思想,用来遏制传统的栈溢出/缓冲区溢出攻击(攻击者往栈里塞 shellcode,再跳到那里执行,NX 位让"那块区域不可执行",attack 直接失败)。
所以,内核 / 用户隔离不是一个大围墙,而是把每个 4KB 页都安了门禁——U/S 位管"谁能进",N 位管"进了能不能当代码用",R/W 位管"能不能写"。你之前读过的"用户态不能随便访问内核内存",在硬件底层就是这一位一位的检查在起作用。
分段与分页:两条路径的最终分道扬镳
走到这里,我们可以做一次正式的"复盘 PK"了:分段和分页,到底差在哪?为什么现代 OS 几乎都拥抱分页?
| 对比维度 | 分段 | 分页 |
|---|---|---|
| 基本单位 | 段,大小可变 | 页,大小固定(4KB 等) |
| 粒度 | 粗(一段常有几十 MB~4GB) | 细(4KB 起步) |
| 碎片形态 | 外部碎片多,难凑连续空间 | 内碎片轻,外碎片基本消除 |
| 是否为整体搬入 | 要整段搬进内存 | 只需把用到的页搬入(按需) |
| 换入换出成本 | 大(整段换,磁盘慢) | 小(按页换) |
| 权限保护粒度 | 段级(一个段一个权限) | 页级(可以一页一个权限,更细腻) |
| 虚拟内存实现 | 难(粒度太粗) | 易(天然支撑虚拟内存) |
| 现代 x86 的地位 | 保留但被"清零"成平坦模式 | 真正的主角 |
总结一句话:分段解决了"地址表达"与"重定位"的历史难题,但它的粒度太粗、保护太糙、虚拟内存太难;分页用"小而固定、按需换入、页级保护"补上了全部短板,成为现代虚拟内存的基石。 而 x86 出于兼容性把分段保留、清空"架空",正是"活在历史里又不拖累现在"的一种妥协。
再串一遍:从逻辑地址到物理地址的完整旅程
最后我们把整条流水线走一遍,你就能把所有名词一条龙地串起来。
假设你在 Linux 上得到一个虚拟地址(在平坦模式下这就是逻辑地址 = 线性地址),处理器要真的把这个字节读出来,后面的流程是:
- 分段(其实已被架空):平坦模式下段基址 = 0,所以逻辑地址 ≈ 线性地址,分段这层"象征性"走完,几乎不干活。
- TLB 命中检查:MMU 先看 TLB 里有没有这条 虚拟页→物理页 的映射。
- 有 → 直接拿物理页框号;
- 没有 → TLB miss,进入第 3 步。
- 页表 walk:MMU 从 CR3 拿页表根(PML4),按
PML4→PDPT→PD→PT四级逐级索引,中途碰到有效的大页(PS 位)可提前结束;沿途检查 U/S、R/W、Present、NX 等权限位,任何一条不满足 → 抛异常(缺页/保护)。 - 回填 TLB:查完把这条映射写进 TLB,供后续快速命中。
- 拼接物理地址:物理页框号 + 12 位页内偏移 = 物理地址,交给物理内存控制器取数。
逻辑/线性(虚拟)地址
│
▼
┌─────────┐ 命中→直接拼物理地址
│ TLB │◀────────────┐
└────┬────┘ │回填
│ miss │
▼ │
四级页表 walk (PML4→PDPT→PD→PT, 由 MMU 硬件完成)
│ 边查边检 U/S、R/W、Present、NX...
▼ │
物理页框号 + 页内偏移 = 物理地址
│ │
▼ │
内存条 ─────────────────┘
对着这张图,你可以把全文所有概念全数一遍:MMU(执行者)、页表/多级页表(字典)、TLB(字典的高速缓存)、平坦模式(把分段"架空")、U/S 位(内核/用户隔离)、NX 位(不可执行)、页大小(粒度与开销的取舍)——每一个名词都找到了它的位置。
亲手算一算:几道带详解答案的思考题
学完不练等于白看。下面几道题都紧扣全文关键点,先自己算,再看详解,效果最好。
思考题 1:8086 是 16 位处理器,为什么能寻址 1MB 的内存?请用公式说明,并给一个"段基址 0x1234、偏移 0x5678"的例子算出物理地址。
详解:8086 的地址总线虽然是 20 位(能表达 2^20 = 1MB),但寄存器都是 16 位。为了用 16 位寄存器表达 20 位地址,引入了分段:用"段基址+段内偏移"两个 16 位数,通过 物理地址 = 段基址 × 16 + 段内偏移(即左移 4 位)来产生 20 位结果。实例:
0x1234 × 16 + 0x5678
= 0x12340 + 0x5678
= 0x179B8 ← 这就是最终的 20 位物理地址
段内偏移最多 16 位(64KB),所以一段最大 64KB;而段基址可设不同值,配合偏移覆盖整个 1MB。
思考题 2:为什么说分段机制的"粒度太粗"会导致内存效率低下?请结合"200MB 内存跑 LOL/Chrome/微信并要再开网易云"讲讲。
详解:分段的换入换出以整段为单位,粒度太粗。当内存里已有几个大段(LOL、Chrome、微信)把空间占得零零碎碎后,虽然可能还"剩"30MB 空闲,但这 30MB 是不连续的散块,凑不出一块能装下网易云的连续段空间。要腾空间,就只能把某个大段(如 Chrome)整体换出/挪动再换入,把空闲"拼"成连续——而这来回搬运几十 MB 数据的成本,落在慢如蜗牛的磁盘上,效率极低。分段粒度太粗 → 无法按小块精细整理 → 碎片长期累积,管理成本居高不下。这正是分页用"4KB 小块按需换入"解决的痛点。
思考题 3:GDT 的作用是什么?为什么 GDTR 是 48 位的?一个 GDT 最多能放多少个段描述符?
详解:GDT(全局描述符表)是操作系统存放在内存里的"段描述符数组",保护模式下 CPU 靠它来描述每个段(基址、界限、权限、存在性等)。GDT 在内存中只有一张。GDTR 需要记录 GDT 在内存的基地址(32 位)+ GDT 的大小(16 位),两部分相加正好 48 位。因为大小字段是 16 位,GDT 最大为 2^16 = 64KB;每个段描述符 8 字节,所以最多 64KB ÷ 8 = 8192 个段描述符。
思考题 4:段选择子里的 TI 位和 RPL 位分别表示什么?"RPL 权限低于 DPL 就拒绝访问"这句话里的'低'指什么?
详解:段选择子是 16 位,包含三段:Index(13 位,08191,在 GDT/LDT 里的下标)、3,一般是构建该选择子的程序的权限)。"RPL 低于 DPL"这里的"低"指的是权限等级低,也就是 RPL 的数值比 DPL 大(因为特权级 0 最高、3 最低)。当请求方权限等级不够(RPL 数值 > DPL),就被拒绝访问,从而起到了保护作用。TI(1 位,表指示器:0→去 GDT 找,1→去 LDT 找)、RPL(2 位,请求特权级 0
思考题 5:为什么 Linux 采用"平坦模式"把所有段基址清零?清零之后,逻辑地址、线性地址、物理地址三者的关系变成什么样?
详解:80386 之后寄存器和地址总线都是 32 位,单靠偏移就能覆盖整个 4GB,分段在功能上有名无实;但 x86 硬件始终按"段+偏移"定位,为了兼容又绕不开段。于是 Linux(平坦模式)把所有段基址设成 0,让"逻辑地址 = 0 + 偏移 = 线性地址",即分段这层变成直通。因此编码和运行中接触的地址都是线性地址(等价于虚拟地址),它与物理地址不再相等——剩下的翻译全部交给分页/MMU。这也是"分页模式下 CPU 寄存器里的地址一定是虚拟地址"的原因。
思考题 6:为什么 32 位系统要采用两级页表,而不是一张 4MB 的巨型单级页表?请给出关键数字。
详解:32 位、4KB 页时共有 2^20 = 104 万个虚拟页。单级页表每个进程要一次性造出 2^20 个表项 × 4 字节 ≈ 4MB 的表,哪怕进程只用了很少内存也得全备着。两级页表只让页目录(4KB、1024 项)常驻,具体的页表按需分配:进程实际用到哪些区域才建对应的页表。这样大多数没用到的高位区间根本不会分配页表,内存开销从"一次性 4MB"骤降为"4KB + 用到的几张小页表",这就是按需建表的价值。到 x86-64,地址空间更大,进一步扩展为 PML4→PDPT→PD→PT 四级,原理相同。
思考题 7:什么是 TLB miss?它为什么会慢?既然 TLB 有缺点,为什么不能把它做得非常大?
详解:TLB 是缓存"虚拟页→物理页"映射的硬件快表。TLB miss 就是 MMU 在 TLB 里没找到这条虚拟页的映射,于是不得不去内存里逐级查询页表(x86-64 最多 4 次内存访问,即 page walk),查到后再回填 TLB。因为 miss 要多趟访问慢速的物理内存(相对 SRAM),所以代价大,才要尽力提高命中率。TLB 不能做得很大的原因:它是 SRAM,贵、功耗高、容量大了成本暴涨;而且它处在每次访问的关键路径上,做得越大查找越慢,会拖累单次访问延迟;加之进程极多,指望缓存一切本就不可能。所以 TLB 必须小而快,命中率靠局部性与大页(2MB/1GB 一条 TLB 顶一大片)来凑。
思考题 8:内核/用户的隔离在硬件上是靠哪个位实现的?为什么一个用户程序即使指针乱飞也"踩不到"内核?
详解:核心是页表项里的 U/S 位(用户/超级用户位)。内核的高地址内存页被标记为 U/S=0(仅超级用户可访问);用户程序运行在 Ring3,只能访问 U/S=1 的用户页。当它访问到 U/S=0 的内核页时,MMU 在做页表 walk 时就会因为特权不足而触发异常(缺页/保护),把这次访问拦下。所以用户指针无论怎么乱飞,只要落点在内核页,都会在这"一页一页的门禁"前被挡下,而不是真的改坏内核。再叠加 NX 位等,就构成了完整的进程隔离与防护体系。
到这里,我们从 1971 年那颗 4004 出发,顺着年代线走完了处理器内存管理的全程:先是看懂了 8086 用"段+偏移"把寻址撑到 1MB 的巧思,又品味到分段带来的动态重定位之利,也正视了它"没有隔离、粒度太粗"的历史局限;随后进入保护模式,透过 GDT、段描述符、段选择子看清了"权限保护"如何落地;最后拥抱分页、平坦模式与虚拟内存,认识 MMU 的页表翻译、多级页表为省内存而生的智慧、TLB 这块快表,以及 U/S、NX 位织起的内核/用户防线。
如今,这些概念安安静静地潜伏在每一台 x86 机器的每一个字节访问背后,支撑着 Linux、Windows 等现代系统的稳定与安全。理解它们的来龙去脉,你就掌握了"虚拟内存"这整座大厦的地基。分页管理底层的更多细节(页表项逐个位的含义、缺页处理、写时复制、交换算法等),我们在后续深入 Linux 内存管理时再细细拆解——到那时你会惊喜地发现,今天打下的这些地基,一粒灰尘都不会浪费。
还没有评论 — 第一条由你来留。