这一篇在干嘛?
本章讲的是”CPU 怎么找到一个内存单元”:80x86 上一次内存访问要经过分段单元和分页单元两级翻译,Linux 如何以最小代价用满这套硬件。这是内存管理三部曲的第一章(第 8 章讲内核怎么给自己分配内存,第 9 章讲进程地址空间),不懂寻址,后面全白搭。
图 1:第 2 章题图(Understanding the Linux Kernel, 3rd Edition)
三种地址与翻译流水线 | 硬件分段 | Linux 中的分段 | 硬件分页 | 硬件缓存与 TLB | Linux 的分页模型与页表处理 | 物理内存布局与内核页表 | 固定映射地址与缓存/TLB 管理
三种地址与翻译流水线
程序员嘴里说的”内存地址”,在 80x86 上必须区分三种东西:
| 地址 | 位数 | 含义 |
|---|---|---|
| 逻辑地址(logical address) | 16 位段 + 32 位偏移 | 机器语言指令里写的操作数地址,体现了 80x86 强迫 MS-DOS/Windows 程序员把程序分段的历史架构 |
| 线性地址(linear address,也叫虚拟地址) | 32 位无符号整数 | 可寻址 4GB,通常写成十六进制,范围 0x00000000 ~ 0xffffffff |
| 物理地址(physical address) | 32 位或 36 位 | 内存芯片上的单元地址,对应 CPU 地址引脚发给内存总线的电信号 |
翻译流水线是两级串联的:MMU 中的分段单元把逻辑地址翻译成线性地址,接着分页单元把线性地址翻译成物理地址。
图 2:逻辑地址翻译(原书图 2-1)——分段单元先工作,分页单元后工作
多处理器系统中所有 CPU 共享同一份内存芯片,而 RAM 的读写必须串行,所以在总线和每个 RAM 芯片之间插着一个内存仲裁器(memory arbiter):芯片空闲就放行某个 CPU,忙就让后来的等。单处理器系统也有仲裁器,因为还有 DMA 控制器这类专用处理器在和 CPU 并发工作。双 Pentium 的仲裁器每个芯片入口有两个端口,两个 CPU 使用公共总线前还要交换同步消息。对程序员来说仲裁器完全隐藏,由硬件电路自动管理。
硬件分段
从 80286 开始,Intel 处理器有两种地址翻译方式:实模式(主要为兼容旧型号、让系统得以引导,见附录 A)和保护模式(本章主角)。
段选择符与段寄存器
逻辑地址 = 段标识符(16 位,叫段选择符 Segment Selector)+ 偏移量(32 位)。CPU 提供 6 个段寄存器专门放段选择符:cs、ss、ds、es、fs、gs。其中三个有固定用途:
- cs:代码段寄存器,指向含程序指令的段;
- ss:栈段寄存器,指向当前程序栈所在段;
- ds:数据段寄存器,指向全局和静态数据段。
es、fs、gs 是通用段寄存器,可以指向任意数据段。
cs 还身兼一个关键职责:它内含一个 2 位字段表示 CPU 的当前特权级(CPL)——0 是最高特权,3 是最低。Linux 只用 0 和 3,分别对应内核态和用户态。
段描述符与 GDT/LDT
每个段由一个 8 字节的**段描述符(Segment Descriptor)描述,存放在全局描述符表(GDT)或局部描述符表(LDT)**中。通常系统只有一个 GDT,每个进程如需额外段可以有自己的 LDT。GDT 在内存中的地址和大小放在 gdtr 控制寄存器,当前 LDT 的放在 ldtr。
图 3:段选择符格式(原书图 2-2)
图 4:段描述符格式(原书图 2-3)
段描述符关键字段(表 2-1):
| 字段 | 含义 |
|---|---|
| Base | 段第一个字节的线性地址 |
| G | 粒度标志:0 = 段大小以字节计,1 = 以 4096 字节的倍数计 |
| Limit | 段最后一个内存单元的偏移,即段长上界(G=0 时 1 字节 |
| S | 系统标志:0 = 系统段(如存放 LDT 的段),1 = 普通代码/数据段 |
| Type | 段类型及其访问 rights |
| DPL | 描述符特权级:访问该段要求的最低 CPU 特权级。DPL=0 只有内核态(CPL=0)能访问,DPL=3 任何特权级都能访问 |
| P | 段存在标志。Linux 恒置 1,因为 Linux 从不把整个段换出到磁盘 |
| D/B | 偏移地址是 32 位(=1)还是 16 位(=0) |
| AVL | 供操作系统使用,Linux 忽略 |
Linux 常用的描述符类型:代码段描述符(S=1)、数据段描述符(S=1,栈段就是普通数据段)、任务状态段描述符 TSSD(S=0,只能放 GDT,Type=11 或 9 视对应进程是否正在 CPU 上执行;TSS 用于保存处理器寄存器内容,见 /linux内核/lk03)、LDT 段描述符 LDTD(S=0,Type=2,只能放 GDT)。
快速访问:非编程寄存器
段选择符和描述符长这样(表 2-2):index 字段(13 位)标识 GDT/LDT 中的表项;TI(Table Indicator)=0 表示在 GDT、=1 表示在 LDT;RPL 是请求者特权级。因为描述符是 8 字节,描述符在表内的偏移 = index × 8。例如 GDT 位于 0x00020000,index=2,则描述符地址 = 0x00020000 + 16 = 0x00020010。GDT 第一项恒为 0(保证空选择符触发异常),最多容纳 8191 项。
图 5:段选择符与段描述符(原书图 2-4)——选择符的 index 字段乘 8 后与 gdtr 相加定位描述符
每次翻译都去内存查 GDT 太慢,所以 80x86 为每个段寄存器配了一个非编程寄存器(程序员不可设置):段选择符装入段寄存器时,对应描述符一并装入非编程寄存器。此后该段的翻译直接用 CPU 寄存器,不必再访问内存中的 GDT/LDT——只有段寄存器内容变化时才需要重新查表。
分段单元的工作流程
图 6:逻辑地址翻译全过程(原书图 2-5)
分段单元做三件事:① 看 TI 字段决定查 GDT(从 gdtr 取基址)还是 LDT(从 ldtr 取基址);② 用 index × 8 加上表基址得到描述符地址;③ 把逻辑地址的偏移量加上描述符的 Base 字段,得到线性地址。多亏非编程寄存器,前两步只在段寄存器改变时才执行。
Linux 中的分段
分段本意是鼓励程序员把程序拆成逻辑实体,但 Linux 对分段的使用极为克制。原因很直接:分段和分页功能有重叠——两者都能把进程的地址空间隔离开。Linux 偏爱分页,因为:
- 所有进程用同一组段寄存器值(共享同一套线性地址)时,内存管理简单得多;
- Linux 要移植到各种架构,而 RISC 架构对分段的支持很有限。
所以 Linux 2.6 只在 80x86 硬件要求时才用分段。所有用户态进程共用用户代码段 + 用户数据段,所有内核态执行共用内核代码段 + 内核数据段。这四个段的描述符字段值:
| 段 | Base | G | Limit | S | Type | DPL | D/B | P |
|---|---|---|---|---|---|---|---|---|
| user code | 0x00000000 | 1 | 0xFFFF | 1 | 10 | 3 | 1 | 1 |
| user data | 0x00000000 | 1 | 0xFFFF | 1 | 2 | 3 | 1 | 1 |
| kernel code | 0x00000000 | 1 | 0xFFFF | 1 | 10 | 0 | 1 | 1 |
| kernel data | 0x00000000 | 1 | 0xFFFF | 1 | 2 | 0 | 1 | 1 |
对应选择符由宏 __USER_CS、__USER_DS、__KERNEL_CS、__KERNEL_DS 定义。注意所有段的 Base 都是 0、Limit 拉满——这意味着 Linux 里逻辑地址与线性地址相等:偏移量字段的值就等于线性地址的值。这是理解后面所有寻址代码的前提:既然分段被”架空”成恒等映射,真正干活的全是分页。
CPL 变化时段寄存器要跟着换:CPL=3 时 ds 必须装用户数据段选择符,CPL=0 时装内核数据段选择符;ss 同理,从用户态进内核态时 Linux 总是让 ss 指向内核数据段(内核态用自己的栈)。正因为”内核态可执行段”只有一个(__KERNEL_CS 指的段),call 指令只需给出偏移量、段选择符由 cs 隐含;保存指针时也不必存段选择符部分。
Linux 的 GDT 与 LDT
单处理器只有一个 GDT,多处理器每个 CPU 一份(因为每个 CPU 的 TSS 不同,个别表项还可能随当前进程不同)。所有 GDT 存在 cpu_gdt_table 数组里,地址/大小在 cpu_gdt_descr 数组,定义于 arch/i386/kernel/head.S。每个 GDT 有 18 个有效描述符 + 14 个空/保留项——故意留空项是为了让常一起访问的描述符落在同一条 32 字节的硬件缓存行里。
18 个描述符指向:四个用户/内核代码与数据段;每 CPU 一个 TSS 段(存于 init_tss 数组,Limit=0xeb 即 236 字节,DPL=0 禁止用户态访问);默认 LDT 段;三个线程局部存储 TLS 段(set_thread_area()/get_thread_area() 系统调用创建/释放,供多线程应用放线程私有数据);三个 APM(高级电源管理)BIOS 相关段;五个 PnP BIOS 服务相关段;一个处理”双重错误”(double fault)异常的专用 TSS(见 /linux内核/lk04)。
各表项的选择符值(原书图 2-6)为:__KERNEL_CS=0x60、__KERNEL_DS=0x68、__USER_CS=0x73、__USER_DS=0x7b、TSS=0x80、LDT=0x88,三个 TLS 段为 0x33/0x3b/0x43。
大多数用户态应用不用 LDT,内核准备了一个放在 default_ldt 数组的默认 LDT 共享。需要自己建 LDT 的典型是 Wine 这类要跑分段式 Windows 应用的程序,用 modify_ldt() 系统调用创建;此时该 CPU 的 GDT 副本中 LDT 表项会相应更新。
硬件分页
分页单元把线性地址翻译成物理地址,并检查访问类型是否与线性地址的访问权匹配——不匹配就产生 Page Fault 异常(缺页异常,见 /linux内核/lk04 和 /linux内核/lk08)。
线性地址被分成固定长度的页(page),页内的连续线性地址映射到连续物理地址。RAM 被切成固定长度的页框(page frame),页框长 = 页长。务必分清:页是一块数据,可以躺在任何页框里或磁盘上;页框是内存的一个存储区域。映射线性→物理地址的数据结构叫页表,放内存里,启用分页前必须由内核初始化好。80386 起支持分页,cr0 寄存器的 PG 标志置 1 启用;PG=0 时线性地址就是物理地址。
常规分页(4KB 页)
32 位线性地址切成三段:Directory(最高 10 位)+ Table(中间 10 位)+ Offset(最低 12 位)。翻译两步走:cr3 寄存器存当前页目录(Page Directory)的物理地址;Directory 字段选中页目录中一项,指向页表(Page Table);Table 字段选中页表中一项,指向页框;Offset 定位页框内字节。Directory 和 Table 各 10 位,所以页目录/页表各有 1024 项,1024 × 1024 × 4096 = 2³²,正好覆盖 4GB。
图 7:80x86 处理器的分页(原书图 2-7)
为什么搞两级而不是一级?如果用一级页表,一个进程哪怕只用几个地址,也要为完整 4GB 建一张 2²⁰ 项、4MB 的页表。两级方案只为实际使用的虚拟内存区分配页表,省下绝大部分内存。每个活跃进程必须有页目录,但页表按需分配。
页目录和页表的表项结构相同,关键字段:Present(页是否在内存;为 0 时其余位操作系统可自用,翻译时若发现 Present=0,分页单元把线性地址存入 cr2 并发出 14 号异常即 Page Fault);页框物理地址高 20 位(页框 4KB 对齐,低 12 位恒为 0);Accessed(分页单元置位,操作系统清零,供换页选择参考);Dirty(仅页表项有,写过即置位);Read/Write(读/写权);User/Supervisor(特权级要求);PCD/PWT(控制硬件缓存行为);Page Size(仅页目录项,置 1 表示 2MB/4MB 大页);Global(仅页表项,Pentium Pro 引入,防止常用页被刷出 TLB,需 cr4 的 PGE 标志配合)。
扩展分页与硬件保护
Pentium 起支持扩展分页:页框 4MB,用于把大块连续线性地址直接映射到物理地址,省掉中间页表、节省内存还保住 TLB 表项。此时线性地址只有 Directory(10 位)+ Offset(22 位)两个字段,页目录项的 Page Size 标志必须置 1,物理地址按 4MB 对齐(低 22 位为 0,地址字段只有高 10 位有效)。扩展分页由 cr4 的 PSE 标志启用,与常规分页共存。
图 8:扩展分页(原书图 2-8)
分页的保护方案比分段简单:特权只有两级(User/Supervisor 标志,=0 时仅 CPL<3 即内核态可访问),访问类型只有读写两种(无”禁止执行”)。80x86 的页无法保护”不被读”和”不被执行”——这是后面理解 Linux 页表宏的一个关键背景。
一个翻译实例
设进程分到线性地址 0x200000000x2003ffff(正好 64 页)。这些地址的最高 10 位都是 0x080(十进制 128),所以页目录里只有第 129 项有效,指向该进程的页表;Table 字段取值 00x3f,页表只有前 64 项有效,其余 960 项填 0。
图 9:分页示例(原书图 2-9)
进程读线性地址 0x20021406 的流程:① Directory=0x80 选中页目录第 0x80 项 → 找到页表;② Table=0x21 选中页表第 0x21 项 → 找到页框;③ Offset=0x406 定位页框内字节。如果页表第 0x21 项 Present=0,产生 Page Fault;进程访问 0x20000000~0x2003ffff 之外的地址同样触发 Page Fault——因为那些页表项全是 0、Present 全清。
PAE:32 位系统用上 64GB 内存
物理地址位数受地址引脚数限制。老处理器 32 位物理地址,理论上 4GB 封顶(实际上因内核线性地址空间紧张,见下文,不能直接寻址超过 1GB RAM)。大型服务器动辄几百上千进程,4GB 不够用,于是 Intel 从 Pentium Pro 起把地址引脚加到 36 位,并引入**物理地址扩展(PAE)**把 32 位线性地址翻译成 36 位物理地址(cr4 的 PAE 标志启用)。改动要点:
- 64GB 内存切成 2²⁴ 个页框,页表项的物理地址字段从 20 位扩到 24 位;加上 12 个标志位共 36 位,页表项从 32 位翻倍成 64 位,一个 4KB 页表只有 512 项;
- 新增一级页目录指针表(PDPT),只有 4 个 64 位项;cr3 存 PDPT 基址(PDPT 在内存前 4GB 且 32 字节对齐,27 位够用);
- 映射 4KB 页时线性地址切分为:31–30 选 PDPT 项、29–21 选页目录项(512 项之一)、20–12 选页表项(512 项之一)、11–0 为页内偏移;映射 2MB 大页时后两级合并,20–0 全是偏移。
PAE 的根本局限:线性地址仍是 32 位。内核程序员不得不复用同一批线性地址去映射不同 RAM 区域(动态重映射,第 8 章)。PAE 不扩大进程的线性地址空间——用户态进程照旧最多”看到”4GB;它的价值在于让内核利用到 64GB 物理内存,从而支撑更多进程。
64 位架构的分页
两级分页放不下 64 位地址:4KB 页意味着 Offset 12 位,剩下 52 位若对半分给 Directory 和 Table,每张表要有 2²⁶ 项(太荒谬)。就算只用 48 位寻址(256TB),两项各 18 位也意味着 256000+ 项的表。所以 64 位平台全部加加分页级数:
| 平台 | 页大小 | 使用的地址位数 | 分页级数 | 线性地址切分 |
|---|---|---|---|---|
| alpha | 8 KB | 43 | 3 | 10 + 10 + 10 + 13 |
| ia64 | 4 KB | 39 | 3 | 9 + 9 + 9 + 12 |
| ppc64 | 4 KB | 41 | 3 | 10 + 10 + 9 + 12 |
| sh64 | 4 KB | 41 | 3 | 10 + 10 + 9 + 12 |
| x86_64 | 4 KB | 48 | 4 | 9 + 9 + 9 + 9 + 12 |
硬件缓存与 TLB
CPU 主频以 GHz 计,DRAM 访问却要几百个时钟周期——不解决这个落差,CPU 大部分时间在干等。硬件缓存基于局部性原理(程序的循环结构 + 相关数据打包成线性数组,导致”最近用过的地址附近很快会再用”),用小而快的片上 SRAM 存最近用过的代码和数据。传输单位是行(line):几十个连续字节,在 DRAM 和 SRAM 之间以突发模式搬运。缓存的映射方式从”直接映射”(内存行只能放缓存的固定位置)到”全相联”(任意放),多数是 N 路组相联(可放 N 个位置之一)。
图 10:处理器硬件缓存(原书图 2-10)——缓存控制器为每条缓存行保存一个含 tag 和状态标志的表项
访问内存时,CPU 从物理地址提取子集索引,比较该子集内所有行的 tag 与地址高位:命中(hit)就直接用缓存行(读操作根本不碰 RAM);未命中(miss)则从 RAM 调入正确的行。写操作的策略有两种:write-through(同时写缓存和 RAM,等于写操作绕过缓存)和 write-back(只写缓存行,等 CPU 要求刷新或 FLUSH 信号——通常发生在缓存 miss 后——才写回 RAM)。write-back 更快,是 Linux 的默认选择(PCD、PWT 标志全清)。
多处理器系统每 CPU 有独立缓存,CPU 改了缓存就必须检查其他 CPU 的缓存里有没有同一份数据,有就通知对方更新——这叫缓存侦听(cache snooping),全部由硬件完成,内核不用管。
图 11:双处理器系统的缓存(原书图 2-11)
除了通用缓存,还有专门加速地址翻译的 TLB(Translation Lookaside Buffer):某线性地址第一次使用时走慢速页表查询,得到的物理地址存进 TLB 表项,以后同一线性地址直接查 TLB。每 CPU 有自己的局部 TLB。与硬件缓存不同,各 CPU 的 TLB 不需要同步——不同 CPU 上的进程可以把同一线性地址映射到不同物理地址。cr3 被改写时(换页表集合),硬件自动作废整个局部 TLB。
Linux 的分页模型与页表处理
Linux 采用一个同时适配 32 位和 64 位架构的统一分页模型。2.6.10 及之前是三级,2.6.11 起改为四级,四种表依次是:
- 页全局目录(Page Global Directory) →
- 页上层目录(Page Upper Directory) →
- 页中间目录(Page Middle Directory) →
- 页表(Page Table) → 页框
图 12:Linux 分页模型(原书图 2-12)——每级目录项指向下一级表,最后由页表项指向页框
关键设计:32 位无 PAE 时两级就够了,Linux 就把上层目录和中间目录”折叠”掉——表项数设为 1,并把这一项直接映射到页全局目录的相应表项。表的位置在指针序列里保留,所以同一套代码在 32 位和 64 位上都能跑。32 位 PAE 时用三级(Linux 的页全局目录对应硬件 PDPT,上层目录折叠,中间目录对应硬件页目录);64 位按硬件切分用三级或四级。
这套模型让两个设计目标成立:给每个进程分配不同物理地址空间(高效防地址错误);把”页”(数据)和”页框”(物理位置)分开——同一页可以换出到磁盘再从另一页框装回来,这正是虚拟内存的基本原料(第 17 章)。每个进程有自己的页全局目录和页表集合;进程切换时,内核把旧 cr3 存进前一进程的描述符,再用新进程描述符里的值装载 cr3(见 /linux内核/lk03)。
线性地址字段宏
| 宏 | 含义 | 80x86 无 PAE | 80x86 PAE |
|---|---|---|---|
| PAGE_SHIFT | Offset 字段位数(即页大小的 log2) | 12 | 12 |
| PMD_SHIFT | Offset+Table 合计位数(中间目录项能映射区域大小的 log2) | 22(4MB) | 21(2MB) |
| PUD_SHIFT | 上层目录项能映射区域大小的 log2 | = PMD_SHIFT | = PMD_SHIFT |
| PGDIR_SHIFT | 全局目录项能映射区域大小的 log2 | 22(4MB) | 30(1GB) |
| PTRS_PER_PTE/PMD/PUD/PGD | 各级表项数 | 1024/1/1/1024 | 512/512/1/4 |
配套的 PAGE_SIZE、PMD_SIZE、PGDIR_SIZE 等宏算区域大小,PAGE_MASK、PMD_MASK 等掩码屏蔽相应位。大页不使用最后一级页表,LARGE_PAGE_SIZE 等于 PMD_SIZE。
页表项类型与处理函数
pte_t、pmd_t、pud_t、pgd_t 分别描述四级表项的格式(PAE 时 64 位,否则 32 位),pgprot_t 是保护标志类型。__pte、pte_val 这类宏在无符号整数和专用类型之间来回转换。常用操作函数一组组列出来:
- 判空:pte_none/pmd_none/pud_none/pgd_none(表项为 0 返回 1);清除:pte_clear 等,ptep_get_and_clear() 清除并返回旧值;写入:set_pte 系列,set_pte_atomic 在 PAE 下保证 64 位写入原子性;
- 比较:pte_same(a,b) 判断两表项是否指向同一页且权限相同;pmd_large(e) 判断是否大页;
- pmd_bad 检查中间目录项是否指向”坏页表”(页不在内存 / 只读 / Accessed 或 Dirty 被清——Linux 强制存在的页表这两个标志必须置位);pud_bad 和 pgd_bad 恒为 0;没有 pte_bad——页表项指向不在内存、不可写甚至完全不可访问的页都是合法的;
- pte_present:Present 或 Page Size 标志任一为 1 返回 1。这里有个巧妙用法:对”在内存但无读写执行权限”的页,内核故意置 Present=0、Page Size=1——硬件看到 Present=0 必然触发 Page Fault,内核查 Page Size 标志就知道这不是缺页而是权限问题。
表 2-5/2-6 是读取和设置各标志位的函数(pte_user/read/write/exec/dirty/young/file 与 pte_wrprotect/pte_mkwrite/pte_mkdirty/pte_mkyoung 等)。其中 pte_read() 和 pte_exec() 都读 User/Supervisor 标志——再次印证 80x86 页不能单独禁止读或执行;pte_file() 读 Dirty 标志:Present=0 且 Dirty=1 表示该页属于非线性磁盘文件映射(第 16 章)。
表 2-7/2-8 是页表操作的主力宏和分配函数:pgd_index(addr) 算地址对应的全局目录项下标;pgd_offset(mm, addr) 经内存描述符找到页全局目录再定位表项;pgd_offset_k(addr) 直接查主内核页全局目录;pud_offset/pmd_offset 沿表逐级下走(在折叠的两级/三级系统中通常原样返回上层指针);分配方面 pgd_alloc(mm) 在 PAE 下连带分配三个映射用户态地址的中间目录;pte_alloc_map(mm, pmd, addr) 发现中间目录项为空时才真正分配新页表(新页框清零、初始化表项、置 User/Supervisor 标志)。这些函数一两行一个,现在不必背,但后面章节到处出现,混个脸熟很重要。
物理内存布局与内核页表
哪些页框不能用
初始化阶段内核要建立物理地址映射图:哪些范围可用、哪些不可用(映射硬件设备 I/O 共享内存、或放着 BIOS 数据)。以下页框被视为保留:落在不可用物理地址范围内的、含内核代码和已初始化数据结构的。保留页框永不被动态分配、永不被换出。
Linux 内核从物理地址 0x00100000(第二个 MB)开始装入,典型配置不到 3MB。为什么不从第一个 MB 开始?PC 架构的几个历史包袱:
- 页框 0 被 BIOS 用来放 POST(开机自检)检测到的硬件配置,很多笔记本 BIOS 甚至在系统运行后还往里写数据;
- 0x000a0000
0x000fffff 保留给 BIOS 例程和 ISA 显卡内部内存——就是所有 IBM 兼容机著名的 **640KB1MB 的洞**; - 个别机型还在第一个 MB 内留别的保留页框(如 IBM ThinkPad 把页框 0xa0 映射到 0x9f)。
内核启动早期查询 BIOS 拿到物理内存大小和地址范围列表,然后执行 machine_specific_memory_setup() 建立物理地址映射图(BIOS 列表不可用时走保守默认:0x9f 到 0x100 号页框全标保留)。一张 128MB 机器的典型映射表:
| 起始 | 结束 | 类型 |
|---|---|---|
| 0x00000000 | 0x0009ffff | Usable |
| 0x000f0000 | 0x000fffff | Reserved |
| 0x00100000 | 0x07feffff | Usable |
| 0x07ff0000 | 0x07ff2fff | ACPI data |
| 0x07ff3000 | 0x07ffffff | ACPI NVS |
| 0xffff0000 | 0xffffffff | Reserved |
注意 BIOS 可能对某些范围(表中 0x000a0000~0x000effff)不提供信息——Linux 保险起见一律视为不可用。BIOS 报的内存内核也未必全看得见:没编 PAE 支持就只有 4GB 视野。随后 setup_memory() 分析映射表并初始化描述内存布局的变量(num_physpages、totalram_pages、max_pfn、max_low_pfn、totalhigh_pages 等,高低内存的划分见下文)。
图 13:Linux 2.6 的前 768 个页框(3MB)(原书图 2-13)——_text 在 0x00100000,_etext 结束代码段,_edata 结束已初始化数据,_end 结束未初始化数据;这些符号是编译内核时生成的
进程页表的分界线 PAGE_OFFSET
进程线性地址空间一分为二:0x000000000xbfffffff 用户态/内核态都能访问;**0xc00000000xffffffff 只有内核态能访问**。PAGE_OFFSET 宏 = 0xc0000000,即内核在线性地址空间里的落脚点。页全局目录中映射低于 0xc0000000 的前 768 项(PAE 时前 3 项)随进程而变;其余项对所有进程相同,等于主内核页全局目录的对应项。
两阶段初始化内核页表
内核维护一套自己的页表,根是主内核页全局目录(master kernel Page Global Directory)。初始化完成后没有任何进程直接使用它——它是所有进程页全局目录高位部分的参照模板(主目录的改动如何传播到各进程,见 /linux内核/lk08)。
初始化分两阶段:内核映像刚装入时 CPU 还在实模式、分页未开。第一阶段建一个最小地址空间(内核代码数据段、初始页表、128KB 动态数据),刚好够装下内核、初始化核心数据结构;第二阶段利用全部 RAM 建正式页表。
第一阶段(临时页表):静态初始化的临时页全局目录在 swapper_pg_dir 变量里,临时页表从 pg0 开始(紧挨 _end)。假设内核段 + 临时页表 + 128KB 挤在前 8MB,就需要两张页表。目标是让这 8MB 在实模式和保护模式下都好访问,所以要把线性地址 0x000000000x007fffff 和 0xc00000000xc07fffff 都映射到物理 0x00000000~0x007fffff(恒等映射 + 内核态映射并存)。做法:swapper_pg_dir 全填 0,只初始化第 0、1、0x300(768)、0x301(769)项——0、0x300 项指向 pg0,1、0x301 项指向 pg0 后面的页框,四项都置 Present、Read/Write、User/Supervisor。startup_32() 汇编函数随即启用分页:
movl $swapper_pg_dir-0xc0000000,%eax
movl %eax,%cr3 /* 设置页表指针 */
movl %cr0,%eax
orl $0x80000000,%eax
movl %eax,%cr0 /* 置分页(PG)位 */第二阶段(最终页表):paging_init() 完成四步——调 pagetable_init() 填好表项;把 swapper_pg_dir 物理地址写进 cr3;CPU 和内核都支持 PAE 时置 cr4 的 PAE 标志;调 __flush_tlb_all() 作废全部 TLB。
RAM 小于 896MB 时最简单:32 位物理地址装得下全部 RAM,不需要 PAE。初始化循环等价于:
pgd = swapper_pg_dir + pgd_index(PAGE_OFFSET); /* 768 */
phys_addr = 0x00000000;
while (phys_addr < (max_low_pfn * PAGE_SIZE)) {
pmd = one_md_table_init(pgd); /* 返回 pgd 本身 */
set_pmd(pmd, __pmd(phys_addr | pgprot_val(__pgprot(0x1e3))));
/* 0x1e3 == Present, Accessed, Dirty, Read/Write,
Page Size, Global */
phys_addr += PTRS_PER_PTE * PAGE_SIZE; /* 0x400000 = 4MB */
++pgd;
}注意两点:高于 0xc0000000 的页目录项 User/Supervisor 全清(用户态进程别想碰内核空间);Page Size 置位,即内核用 4MB 大页映射 RAM,一张表都不用建。startup_32() 建的 8MB 恒等映射只在初始化阶段有用,之后由 zap_low_mappings() 清掉。
RAM 在 896MB~4096MB 时:RAM 无法整体映射进内核线性地址空间(原因见下一段的”为什么是 896”),初始化时只能把一个 896MB 的 RAM 窗口映射进内核空间,其余 RAM 靠动态重映射临时借用(第 8 章)。初始化代码同上一种情况。
RAM 超过 4096MB(CPU 支持 PAE、内核编了 PAE)时改用三级分页:
pgd_idx = pgd_index(PAGE_OFFSET); /* 3 */
for (i=0; i<pgd_idx; i++)
set_pgd(swapper_pg_dir + i, __pgd(__pa(empty_zero_page) + 0x001));
/* 0x001 == Present */
pgd = swapper_pg_dir + pgd_idx;
phys_addr = 0x00000000;
for (; i<PTRS_PER_PGD; ++i, ++pgd) {
pmd = (pmd_t *) alloc_bootmem_low_pages(PAGE_SIZE);
set_pgd(pgd, __pgd(__pa(pmd) | 0x001)); /* 0x001 == Present */
if (phys_addr < max_low_pfn * PAGE_SIZE)
for (j=0; j < PTRS_PER_PMD /* 512 */
&& phys_addr < max_low_pfn*PAGE_SIZE; ++j) {
set_pmd(pmd, __pmd(phys_addr | pgprot_val(__pgprot(0x1e3))));
/* 0x1e3 == Present, Accessed, Dirty, Read/Write,
Page Size, Global */
phys_addr += PTRS_PER_PTE * PAGE_SIZE; /* 0x200000 = 2MB */
}
}
swapper_pg_dir[0] = swapper_pg_dir[pgd_idx];用户空间的前 3 个页目录项指向空页 empty_zero_page;第 4 项指向新分配的页中间目录,其中前 448 项(共 512 项,后 64 项留给非连续内存分配,见 /linux内核/lk08)填入前 896MB 物理地址;最后把第 4 项复制到第 0 项,低物理内存在线性空间前 896MB 里有个镜像——这是 SMP 系统初始化所必需的,用完同样由 zap_low_mappings() 清除。
为什么分界线是 896MB? 内核线性地址从 0xc0000000 到 0xffffffff 共 1GB,其中至少 128MB 必须留给非连续内存分配和固定映射(下一节),所以直接映射的 RAM 窗口最多 1GB − 128MB = 896MB。直接映射区间之外的页框叫高端内存(high memory),max_low_pfn 之前的叫低端内存(low memory)。
固定映射地址与缓存/TLB 管理
固定映射线性地址(fix-mapped linear addresses)
第四个 GB 的开头映射物理内存,结尾则至少留 128MB 给非连续内存分配和固定映射线性地址。固定映射地址是一个恒定的线性地址(如 0xffffc000),但它对应的物理地址不是”线性地址 − 0xc0000000”这种线性关系,而是任意设定的——每个固定映射地址固定映射一个页框。内核用它取代”永不改变的指针变量”:解引用变量指针比立即数常量地址多一次内存访问,而且解引用前按良好习惯还要检查指针有效性;常量地址则两者开销皆无。
每个固定映射地址由 enum fixed_addresses 里的一个小整数索引表示:
enum fixed_addresses {
FIX_HOLE,
FIX_VSYSCALL,
FIX_APIC_BASE,
FIX_IO_APIC_BASE_0,
[...]
__end_of_fixed_addresses
};固定映射地址放在第四个 GB 末尾,fix_to_virt() 从索引算出线性地址:
inline unsigned long fix_to_virt(const unsigned int idx)
{
if (idx >= __end_of_fixed_addresses)
__this_fixmap_does_not_exist();
return (0xfffff000UL - (idx << PAGE_SHIFT));
}因为函数声明为 inline 且实参 FIX_IO_APIC_BASE_0 是编译期常量 3,编译器直接裁掉 if(条件恒假),算出 0xfffff000 − (3 << 12) = 0xffffc000,把整个函数调用替换成这个常量。如果传了非常量或越界索引,链接阶段因 __this_fixmap_does_not_exist 符号不存在而报错——越界检查在编译期就完成了。
set_fixmap(idx, phys) 和 set_fixmap_nocache(idx, phys) 把物理地址 phys 绑到 fix_to_virt(idx):后者额外置 PCD 标志,访问该页框时绕过硬件缓存(给内存映射的 I/O 寄存器用,缓存它们是危险的)。clear_fixmap(idx) 解除绑定。
用好硬件缓存
内核无法控制缓存本身(80x86 自动同步缓存),但可以在布局上讨好缓存:把数据结构中最常用的字段放在低偏移处,让它们落进同一缓存行;成批分配数据结构时尽量均匀摊满所有缓存行。L1_CACHE_BYTES 宏给出缓存行大小(Pentium 4 之前 32 字节,Pentium 4 为 128 字节)。cr0 的 CD 标志开关缓存,NW 标志选 write-through/write-back。
管好 TLB
TLB 与缓存不同:硬件不知道一个线性→物理映射何时失效,这只能由内核判断,所以 TLB 刷新是内核的活。Linux 2.6 提供一组架构无关的刷新方法,按改动范围对号入座:
| 方法 | 作用 | 典型时机 |
|---|---|---|
| flush_tlb_all | 刷新全部表项(含 Global 页) | 改内核页表项 |
| flush_tlb_kernel_range | 刷新一段线性地址范围的表项(含 Global 页) | 改一段内核页表 |
| flush_tlb | 刷新当前进程的非全局页表项 | 进程切换 |
| flush_tlb_mm | 刷新指定进程的非全局页表项 | fork 新进程 |
| flush_tlb_range | 刷新进程某线性区间的表项 | 释放进程的线性地址区间 |
| flush_tlb_pgtables | 刷新进程一组连续页表的表项 | 释放进程的部分页表 |
| flush_tlb_page | 刷新进程单个页表项对应的表项 | 处理缺页异常 |
架构无关方法要落到具体指令上,而 Intel 只提供两种手段:往 cr3 重写值会自动作废所有非全局页的表项;Pentium Pro 起的 invlpg 指令作废单个线性地址对应的表项。对应三个宏:__flush_tlb()(cr3 重写自身)、__flush_tlb_global()(先清 cr4 的 PGE 暂时关掉全局页、重写 cr3、再恢复 PGE)、__flush_tlb_single(addr)(执行 invlpg)。flush_tlb_pgtables 在 80x86 上是空函数——页表从父表摘链时 Intel 无事可做。多处理器系统上,一个 CPU 通过处理器间中断(IPI,见 /linux内核/lk04) 强迫其他 CPU 执行相应的刷新函数。
常规进程切换必然换页表集合,写 cr3 时硬件自动刷新,但内核有两招避开无谓刷新:切换前后的两个常规进程用同一套页表时不刷;切换到内核线程时不刷——内核线程没有自己的页表,直接用 CPU 上上一个常规进程的页表(见 /linux内核/lk09)。
多处理器上还有更精细的惰性 TLB 模式(lazy TLB mode):多个 CPU 共用同一套页表、某个 TLB 表项需要在所有 CPU 上作废时,正在跑内核线程的 CPU 可以推迟刷新——内核线程不访问用户态地址空间,过期的用户态映射对它无害。实现要点:cpu_tlbstate 是 NR_CPUS(默认 32)个元素的静态数组,每元素含 active_mm(当前进程内存描述符)和 state 标志(TLBSTATE_OK 非惰性 / TLBSTATE_LAZY 惰性);每个内存描述符有 cpu_vm_mask 字段,记录哪些 CPU 应收到 TLB 刷新相关的处理器间中断。CPU 开始执行内核线程时被置为 LAZY,同时把自己加入活跃内存描述符的 cpu_vm_mask;收到刷新中断后若发现自己处于 LAZY 态,就拒绝刷新并把自己从 cpu_vm_mask 里删掉——此后不再收到这类中断。等这个 CPU 切换到使用不同页表集的常规进程时,硬件写 cr3 顺带完成全部刷新;若切换到的常规进程用的还是原来那套页表,内核就老实调 __flush_tlb() 补刷所有非全局表项。
通关标准:能白板画出 32 位无 PAE 的"10+10+12"地址切分图并走完一次翻译;能说出 Linux 为什么把段 Base 全设为 0(逻辑地址=线性地址,保护交给分页);能解释 PAGE_OFFSET=0xc0000000 两侧分别是谁的领地、896MB 分界怎么来的;能区分"页"和"页框"、Present 和 Dirty 标志的用途。
常见坑:把"页"当"页框"
页是数据块(可以放内存任何页框,也可以在磁盘上),页框是物理内存的存储区域。交换、缺页这些机制全部建立在”同一页可以先后住进不同页框”之上——混用这两个词,虚拟内存的机制就全想不通了。
Linux 为什么"架空"分段(Base 全 0、逻辑地址=线性地址),把宝押在分页上?
三个原因:① 分段和分页功能重叠,都能隔离进程地址空间,但所有进程共用同一组段寄存器值时内存管理简单得多;② RISC 架构对分段支持很弱,依赖分段会毁掉 Linux 的可移植性目标;③ 分页有硬件缓存(TLB)加速且粒度是页而非段,更适合实现按需分配和换页。80x86 的段机制没法完全关掉,所以用恒等映射把它”无效化”,真正的隔离和保护全部交给分页单元。
两级页表为什么比一级页表省内存?到底省在哪?
一级页表必须覆盖全部 4GB 线性地址:2²⁰ 项 × 4 字节 = 4MB,哪怕进程只用了几个地址也得全建。两级方案把表拆成”页目录 + 按需分配的页表”:页目录常驻(4KB),但只有进程实际使用的 4MB 区域对应的页表才会被分配。上例中进程只用 64 页,页目录 1024 项里只有 1 项有效,页表也只分配一张——开销从 4MB 降到 8KB 量级。
PAE 打开了 64GB 物理内存,为什么单个进程仍然只能用 4GB?
PAE 只扩展物理地址(32→36 位),线性地址仍是 32 位。进程能”看见”的世界由线性地址空间决定,4GB 的线性空间没变大;变化在于内核可以在这 4GB 线性窗口里通过更换页表项动态映射不同的物理区域。所以 PAE 的受益者是内核(可管理更多内存、支撑更多进程),不是单个用户进程。
内核页表初始化为什么要把 0x00000000 和 0xc0000000 两段线性地址都映射到物理内存前 8MB?之后又为什么要拆掉恒等映射?
内核映像刚装入时 CPU 还在实模式、分页未启用,启用分页的瞬间(置 cr0 的 PG 位)CPU 仍以”线性地址=物理地址”的心智取指——如果只有 0xc0000000 一套映射,启用分页后的第一条指令就取错了。两套并存保证了启用分页前后指令流无缝衔接。等内核完成初始化、彻底运行在 0xc0000000 之上的映射后,低地址恒等映射成了安全隐患(用户态可借道访问内核低地址窗口),所以调 zap_low_mappings() 清除。
惰性 TLB 模式为什么只对正在执行内核线程的 CPU 安全?
因为内核线程没有自己的页表,借用当前 CPU 上上一个常规进程的页表,而且它只访问内核态线性地址(≥0xc0000000),从不碰用户态地址空间。过期的用户态 TLB 表项对它完全不构成错误映射,所以可以推迟刷新;等该 CPU 切回使用不同页表集的常规进程时,写 cr3 会硬件自动完成全部刷新。如果切回的进程恰好还用原来那套页表,惰性模式欠的账就必须立即补上(__flush_tlb() 全刷非全局表项)。