目录
从一行代码说起
写 C 程序时,我们一直在操作形如 0x7ffe34dd2394 这样的指针。这个数字不是内存条上的物理位置,而是虚拟地址。CPU 取指令、读写变量时发出的都是虚拟地址,它要经过一段硬件翻译,才能落到真实的物理页框上。这段翻译就是内存寻址。
很多开发者把”指针”和”物理内存位置”混为一谈,于是在排查内存越界、共享内存、进程隔离之类的问题时容易想偏。本文把 x86 从实模式到 x86-64 的寻址演进讲清楚,拆解分段与分页两段翻译,给出可真机验证的程序与原理图,每一处结论都回引官方资料与实验输出。
缩写对照
下文多处用到架构与内核术语缩写,先在此集中给出中英文全称与含义,便于回查。其中 PGD / P4D / PUD / PMD / PTE 是内核的页表层级命名,英文全称取自内核页表文档[7];PML4 / PDPT / PD / PT 是 Intel 与 AMD 手册对各级页表的硬件称谓[1][2]。
实模式 / 保护模式 / 长模式:x86 的三种工作模式(real / protected / long mode)
GDT(Global Descriptor Table):全局描述符表,保护模式下段选择子查的表
LDT(Local Descriptor Table):局部描述符表,进程可自建的段表
CS/DS/SS/ES/FS/GS:段寄存器,即代码 / 数据 / 栈 / 附加段,FS 与 GS 可设非零基址
SDM:Intel® 64 and IA-32 架构软件开发者手册(Software Developer's Manual)
IA-32:Intel Architecture 32-bit,即 x86 32 位架构
PAE(Physical Address Extension):物理地址扩展,把物理地址从 32 位扩到 36 位
LA57(57-bit Linear Address):57 位线性地址,对应五级分页
CR0 / CR3 / CR4:控制寄存器,CR0.PG 开分页、CR3 存顶级页表基址、CR4.PAE 开 PAE
KASLR(Kernel Address Space Layout Randomization):内核地址空间布局随机化
MMU(Memory Management Unit):内存管理单元,执行地址翻译的硬件
TLB(Translation Lookaside Buffer):转译后备缓冲器,即页表缓存
PGD(Page Global Directory):页全局目录,x86-64 中即 PML4(顶级页表)
P4D(Page Level 4 Directory):页四级目录,为五级页表引入,四级分页下折叠进 PGD
PUD(Page Upper Directory):页上级目录
PMD(Page Middle Directory):页中间目录
PTE(Page Table Entry):页表项,页表遍历的叶子节点
PML4(Page Map Level 4):Intel/AMD 手册对顶级页表的硬件称谓,等同内核所说的 PGD
PDPT(Page Directory Pointer Table):页目录指针表,四级分页的第三级
PD(Page Directory):页目录,四级分页的第二级
PT(Page Table):页表,四级分页的第一级
PTI(Page Table Isolation):页表隔离,内核与用户页表分离以缓解侧信道
PFN(Page Frame Number):物理页框号
MAXPHYADDR(Maximum Physical Address):CPU 支持的最大物理地址位宽
MSR(Model Specific Register):模型特定寄存器,FS/GS 基址经其设置
SWAPGS:长模式下交换 GS 基址与 IA32_KERNEL_GS_BASE 的指令
TLS(Thread Local Storage):线程局部存储
COW(Copy On Write):写时复制
ASLR(Address Space Layout Randomization):地址空间布局随机化
NX(No Execute):不可执行位
SIGSEGV(Signal Segmentation Violation):段错误信号
canonical(规范地址):高位为符号扩展、可被硬件接受并翻译的地址
guard hole(保护空洞):用户空间末端紧邻的 4 kB 不可映射区间
THP(Transparent Huge Pages):透明大页,内核自动把连续小页合并为 2 MiB 大页
历史背景:x86 寻址的演进
寻址方式不是凭空设计的,它是对硬件成本、寻址范围和隔离需求一步步妥协的结果。下面按时间线梳理 x86 的几个关键节点,机制部分以 Intel® 64 and IA-32 架构手册(SDM)为准[1],年份与型号为公开史料。
图 1:x86 寻址能力随处理器代际的演进,分段与分页先后登场。
year CPU addressing segment paging
---------------------------------------------------------------
1978 8086 20-bit (1 MB) yes no (real mode)
1982 80286 24-bit (16 MB) yes no (protected mode)
1985 80386 32-bit (4 GB) yes yes (2-level page tables)
~1995 Pentium Pro 36-bit physical (PAE) yes yes (3-level page tables)
2003 x86-64 48-bit virtual flat yes (4-level page tables)
2019+ Ice Lake 57-bit virtual (LA57) flat yes (5-level page tables)
1978 年的 Intel 8086 是 16 位处理器,但地址总线有 20 位,可访问 1 MB 内存。问题在于 16 位寄存器最多表示 64 KB,装不下 1 MB 地址。Intel 的解法是分段:用”段寄存器:偏移”组合出物理地址,物理地址 = 段值 × 16 + 偏移。这就是实模式(real mode)的分段,段寄存器 CS/DS/SS/ES 各存一个 16 位段值[1]。
先把段值和偏移这两个角色说清楚。段值可以理解为段的起始编号:8086 把内存按 16 字节为粒度划分,每个段都从 16 的整数倍地址开始,段寄存器里存的就是段起始地址除以 16 得到的商。偏移则是访问目标在段内的位移,同样占 16 位,因此一个段最长 64 KB。取指令时用 CS 配合指令指针 IP,读写数据默认用 DS,栈操作用 SS 配合栈指针 SP,不同段寄存器各有分工[1]。
为什么要乘以 16?这是寄存器位宽与地址总线位宽不对等时的折中。16 位段值在二进制下左移 4 位,恰好补齐 20 位地址总线所需的低 4 位;左移 4 位等价于乘以 16,换算成十六进制写法,就是在段值末尾补一个 0。硬件上由总线接口单元的地址加法器完成这次左移与相加,对程序员透明。
用一个具体例子过一遍计算。PC 开机后 BIOS 会把引导扇区加载到物理地址 0x7C00。假设此时 CS = 0x07C0、IP = 0x0000,按公式计算:
physical address = segment * 16 + offset
= 0x07C0 * 16 + 0x0000
= 0x7C00
再换一个带偏移的例子。设 DS = 0x1000,程序要读写段内第 0x34 个字节,物理地址 = 0x1000 × 16 + 0x0034 = 0x10000 + 0x0034 = 0x10034。段值左移后贡献地址的高位部分,偏移填充低位,两者拼出完整的 20 位物理地址。
把段值和偏移都取到最大值,可以验证这套机制的寻址上限:0xFFFF × 16 + 0xFFFF = 0x10FFEF,比 1 MB 的上界 0xFFFFF 还多出 65520 字节。这部分空间后来被称为高端内存区(HMA),需要打开 A20 地址线才能使用。
这种基址平移的设计有一个副作用:同一个物理地址可以有多组段值:偏移写法。仍以 0x7C00 为例,0x07C0:0x0000、0x0000:0x7C00、0x0700:0x0C00 指向的是同一处内存。实模式不做隔离与越界检查,段与段可以任意重叠,程序可以读写整个 1 MB 空间,这正是后续保护模式要解决的问题之一。
1982 年的 80286 引入保护模式(protected mode),地址总线扩到 24 位(16 MB)。分段不再是简单的”段值×16”,而是用段选择子去查全局/局部描述符表(GDT/LDT),每个段描述符给出 24 位段基址、段限长和访问权限。保护模式有了权限隔离,但仍然只有分段、没有分页[1]。
这一次段寄存器里存的不再是段值,而是 16 位的段选择子(selector),它本身不含任何地址信息,只是一张表的索引。选择子拆成三部分:高 13 位是描述符表里的下标,第 2 位选择查全局描述符表(GDT)还是局部描述符表(LDT),最低 2 位是请求特权级(RPL)[1]。
查表取出的段描述符是一个 8 字节结构,装着三样东西:24 位段基址、段限长(286 为 16 位,386 起扩为 20 位并增加粒度位)、访问权限(是否可读写执行、允许哪一级代码访问)。地址计算仍然是基址加偏移,但多出两道检查:偏移超出段限长,或者权限不够,CPU 直接抛出异常,拒绝这次访问[1]。
看一个具体例子。设 DS = 0x0008,拆开这三部分:下标 = 0x0008 右移 3 位 = 1,表指示位 = 0(查 GDT),RPL = 0。假设 GDT 第 1 项写着基址 0x00200000、限长 0x0000FFFF,访问段内偏移 0x1234 时:
selector 0x0008 -> index = 1, TI = 0 (GDT), RPL = 0
GDT[1] -> base = 0x00200000, limit = 0x0000FFFF
offset 0x1234 <= limit 0x0000FFFF (check ok)
linear address = base + offset
= 0x00200000 + 0x1234
= 0x00201234
为什么不再沿用段值×16?因为实模式的段值只够表达基址,装不下限长和权限。把这三样打包进描述符、让段寄存器退化为表索引,操作系统就拿到了管控每段内存边界与行为的抓手。代价是切换段寄存器要多查一次表,CPU 内部为此准备了描述符缓存,避免每次访存都重新读表。
1985 年的 80386 把地址扩到 32 位(4 GB),并且第一次在分段之上叠加了分页(paging)。分段产出 32 位线性地址,分页再把线性地址翻译成 32 位物理地址;页表为两级结构(页目录 + 页表),页大小 4 KB,由 CR0 的 PG 位开启[1]。从这一代起,“分段 + 分页”的两段式翻译模型确立。
分页的思路是把内存切成固定大小的小块来管理。线性地址空间与物理内存都被切成 4 KB 的页,线性地址拆成三段:高 10 位是页目录下标,中间 10 位是页表下标,低 12 位是页内偏移。CR3 寄存器保存页目录的物理地址,翻译时先查页目录找到页表,再查页表找到物理页框,最后拼上页内偏移[1]。
沿用具体数字走一遍。设线性地址为 0x00401234,按 10/10/12 拆开:页目录下标 = 1,页表下标 = 1,页内偏移 = 0x234。假设两级查表后命中的页表项指向物理页框 0x00005000:
linear address 0x00401234
dir index = 0x00401234 >> 22 = 1
tbl index = (0x00401234 >> 12) & 0x3FF = 1
offset = 0x00401234 & 0xFFF = 0x234
CR3 -> PD[1] -> PT[1] -> page frame 0x00005000
physical = 0x00005000 + 0x234 = 0x00005234
为什么要两级而不是一张大表?32 位地址空间有 2 的 20 次方个页,单张页表需要一百万个表项,占 4 MB,而且大部分表项对应着程序根本用不到的地址。两级结构下,没使用的页目录项可以直接标记为空,对应的整张二级页表就不必分配,稀疏的地址空间只需为实际用到的区域建表。用查表次数换取内存占用的设计思路,在后续每一代页表结构里都延续了下来。
Pentium Pro 引入 PAE(Physical Address Extension),把可寻址的物理地址扩到 36 位,页表变成三级(页目录指针表 + 页目录 + 页表),由 CR4 的 PAE 位开启[1]。x86-64(AMD64,2003 年)进入长模式(long mode),采用扁平模型(flat model),分页升级为四级(PML4 → PDPT → PD → PT),支持 48 位虚拟地址,虚拟地址空间共 256 TB,其中用户空间占 128 TB;CR3 指向顶级页表[2][3]。LA57 在 Intel Ice Lake 微架构(2019 年)上首次实现[9],由 CR4 的 LA57 位开启,该位是 bit 12,6.14 内核把它定义为 X86_CR4_LA57_BIT,位于 arch/x86/include/uapi/asm/processor-flags.h[11];CPU 侧以 CPUID.(EAX=07H,ECX=0) 的 ECX 第 16 位枚举这一能力,内核据此在引导阶段设置 __pgtable_l5_enabled 并把 pgdir_shift 调整为 48[11]。虚拟地址扩到 57 位后页表相应变成五级,整体虚拟地址空间由 256 TB 增至 128 PB,用户空间对应扩到 64 PB[3][9]。
页表级数一路增加,背后是同一笔账。从 PAE 起页表项从 4 字节扩到 8 字节,一页 4 KB 只能放 512 项,每级索引相应从 10 位缩到 9 位。PAE 的线性地址仍是 32 位,拆成 2 + 9 + 9 + 12:顶级页目录指针表只有 4 项,2 位下标即可索引。x86-64 的 48 位拆成 9 × 4 + 12,LA57 的 57 位拆成 9 × 5 + 12,低 12 位始终是页内偏移,地址每宽一段就在上面多叠一级 9 位索引。级数随地址位宽增长,每一级仍然只占一页、按需分配,稀疏地址空间不会为没用的区域建表。
地址翻译的三段式模型
现代 x86 的完整地址翻译分两段:先分段,再分页。逻辑地址(selector:offset)经段式转换得到线性地址,线性地址再经页式转换得到物理地址。
图 2:从逻辑地址到物理地址的两段翻译。图中按从上到下的顺序展示一条地址的完整旅程:最上方是程序发出的逻辑地址(段选择子:偏移),中间经分段得到线性地址,最下方经分页得到物理地址。x86-64 下分段被弱化,逻辑地址的偏移分量直接等于线性地址。
logical address (selector : offset)
|
| segmentation: selector -> descriptor (base, limit, DPL)
| linear = base + offset
v
linear address (32-bit on IA-32, 48-bit on x86-64)
|
| paging: walk page tables indexed by linear address bits
| CR3 -> PGD -> PUD -> PMD -> PTE -> page frame
v
physical address (page frame + offset)
关键在于:分段和分页是两套独立的机制。分段做”段基址 + 偏移”的平移并施加权限检查,分页做”虚拟页 → 物理页框”的映射。x86-64 之后,分段基本退场(见下一节),真正承担保护与翻译主力的,是分页。
Linux 下的分段:扁平模型
保护模式那套选择子查描述符的分段机制,到 x86-64 基本被架空了。长模式把 CS/DS/SS/ES 的段基址强制为 0、段限长忽略:基址加偏移的结果就是偏移本身,段式转换变成一次透明直通,逻辑地址直接等于线性地址[2]。
为什么要把分段架空?分段和分页的职责本就重叠,都在做地址转换与保护。80386 引入分页之后,主流操作系统一致选择分页作为隔离与保护的主力,分段只保留一个壳。x86-64 索性在硬件层把这个壳固定成基址 0、限长无效,软件与硬件就此达成一致[2]。
唯一的例外是 FS 与 GS:长模式仍允许这两个段保留非零基址,由内核通过 MSR 设置。Linux 用 FS 承载线程局部存储(TLS),每个线程的 FS 基址指向自己的 TLS 区块,不同线程执行同一条 fs 段前缀的访存指令,读写的是各自的数据;GS 在内核态指向每 CPU 数据区,用户态则为 0[2]。本文实证四会专门验证 FS 基址与 TLS 变量的关系。
图 3:x86-64 长模式下,CS/DS/SS/ES 基址为 0(扁平),FS/GS 基址可由内核经 MSR 设置,用于 TLS。
selector -> GDT/LDT descriptor
base (x86-64 long mode)
----------------------
CS base = 0 (forced)
DS base = 0 (forced)
SS base = 0 (forced)
ES base = 0 (forced)
FS base = <non-zero, set by kernel, for TLS>
GS base = <non-zero in kernel mode, 0 in user mode>
因此在 Linux 的 x86-64 进程里,程序员拿到的那个指针(虚拟地址)几乎直接就是分页机制的输入。下面把分页这一段讲透。
x86-64 四级页表与地址翻译
x86-64 默认四级分页。历史背景一节已经算过这笔账:页表项 8 字节、一页放 512 项、每级索引 9 位,48 位虚拟地址于是拆成 4 个 9 位索引加 1 个 12 位页内偏移(9 × 4 + 12 = 48)[2][3]。从高到低,这四级依次叫 PGD、PUD、PMD、PTE,查法与 80386 的两级页表同构,只是多走两级。
内核源码里的叫法略有出入:内核文档把页表抽象成 PGD、P4D、PUD、PMD、PTE 五级,硬件不足五级时把中间层折叠(fold)掉。x86 未启用 LA57 时按四级工作,P4D 折叠进 PGD:p4d_t 定义成 pgd_t 的封装,p4d_offset() 返回 pgd 自身,这一层不占内存,属于类型上的占位(6.14 内核 include/asm-generic/pgtable-nop4d.h)[4][11]。各级的位移量也是确定的常量:PGDIR_SHIFT 为 39、PUD_SHIFT 为 30、PMD_SHIFT 为 21,相邻两级相差 9,对应每级 512 个表项[11]。
图 4:48 位虚拟地址的 9+9+9+9+12 拆分,以及从 CR3 出发的四级页表遍历。
x86-64, 4-level paging (P4D folded into PGD), 4 KiB pages
bits 47..39 38..30 29..21 20..12 11..0
+--------+--------+--------+--------+--------+
| PGD | PUD | PMD | PTE | offset |
| 9b | 9b | 9b | 9b | 12b |
+--------+--------+--------+--------+--------+
CR3 --(PGD idx)--> PGD[idx] --(PUD idx)--> PUD[idx]
--(PMD idx)--> PMD[idx] --(PTE idx)--> PTE[idx]
|
v
page frame (physical) + off
= physical address
翻译过程由 MMU 硬件自动完成:以 CR3 给出的顶级页表物理基址为起点,依次取虚拟地址的 PGD、PUD、PMD、PTE 四段索引逐级下行,最后一级 PTE 给出物理页框,拼上 12 位页内偏移得到物理地址[1][2]。本文实证一会用真实程序把两个虚拟地址按 9+9+9+9+12 拆开,实证二再经 pagemap 验证翻译的终点。
每个用户进程都有独立的一套页表,顶级页表基址保存在进程 mm_struct 的 pgd 字段里。调度切换到某个进程时,内核把它的页表基址写入 CR3,同一虚拟地址在不同进程里随之翻译成不同的物理页,这就是进程地址空间相互隔离的实现方式[1][7]。x86 侧的切换路径在 arch/x86/mm/tlb.c:switch_mm_irqs_off() 取出下一个进程的 pgd,经 load_new_mm_cr3() 组装成 CR3 映像后由 write_cr3() 写入[11]。内核线程没有用户地址空间,调度时沿用上一进程的页表运行(惰性 TLB),不重新加载 CR3[10];对应的 enter_lazy_tlb() 只置一个 is_lazy 标志,并不触碰 CR3[11]。
四级遍历每级都要读一次内存,一次翻译就是 4 次访存。CPU 用 TLB(Translation Lookaside Buffer)缓存最近翻译过的映射来摊薄这笔开销:TLB 命中时直接给出物理页框,跳过整段遍历;未命中才走四级查表,走完再把结果填进 TLB[1][7]。需要说明的是,遍历并非总在 PTE 结束:映射到 2 MiB 或 1 GiB 大页时,PMD 或 PUD 表项本身就是叶子,直接给出大页框,遍历在中间层提前结束[7]。内核用 pmd_leaf() 与 pud_leaf() 判定某级表项是不是叶子,判据是表项里的 _PAGE_PSE 位;2 MiB 与 1 GiB 这两个粒度则来自 PMD_SIZE(1 左移 21 位)与 PUD_SIZE(1 左移 30 位)[11]。本文实验机的透明大页策略为 madvise,普通匿名映射默认仍是 4 KiB 页,故下文按四级遍历讨论。
图 5:TLB 在 MMU 翻译路径中的位置,命中则直接返回物理页框。
CPU -> virtual address
|
v
+-----------+ hit +-------------------+
| TLB |--------->| physical page frame|
+-----------+ +-------------------+
| miss
v
page table walk (CR3 -> PGD -> ... -> PTE) -> fill TLB
页表项的关键字段
页表遍历最终落在叶子级的 PTE(页表项)上。x86-64 的 PTE 是 64 位,可以理解为门牌号加一纸门禁:bits 51..12 放物理页框号,回答这个虚拟页住在物理内存哪里;低 12 位与最高位放一组标志位,回答这个页允许谁、以什么方式访问。MMU 在每次访存时对照标志位强制检查[1]。
图 6:x86-64 PTE 的位布局(4 KiB 页,四级分页)。
63 62..52 51..12 11..9 8 7 6 5 4 3 2 1 0
+--+-----------+--------------+-----+---+----+---+---+-----+-----+---+---+---+
|NX| reserved | PFN | ign.| G |PAT | D | A | PCD | PWT |U/S|R/W| P |
| | must be 0 | bits 12..51 | | | | | | | | | | |
+--+-----------+--------------+-----+---+----+---+---+-----+-----+---+---+---+
bit(s) 字段 含义
------- ------------------------- -------------------------------------------
63 NX (No Execute) 1 表示该页不可执行,需 IA32_EFER.NXE 开启
62..52 reserved 保留位,须为 0
51..12 page frame (PFN) 物理页框号,页框基址 = PFN << 12
11..9 ignored / 软件位 硬件忽略;Linux 借作 3 个软件标志位
8 G (Global) TLB 全局项,切换 CR3 时不失效
7 PAT / PSE 4 KiB 页为 PAT;大页为 PSE,即叶子
6 D (Dirty) CPU 写入后置 1
5 A (Accessed) CPU 读或写后置 1
4 PCD (Page Cache Disable) 该页禁用缓存
3 PWT (Page Write Through) 该页写穿透
2 U/S (User/Supervisor) 0 表示仅内核态可访问
1 R/W (Read/Write) 0 表示只读
0 P (Present) 0 表示页不在物理内存中
页框号字段的有效位宽由 CPU 的物理地址位宽 MAXPHYADDR 决定:本文实验机 /proc/cpuinfo 报告 52 bits physical,因此 PTE 中 PFN 字段的 40 个位(bits 51..12)全部有效;MAXPHYADDR 更小的 CPU 上,高位属保留位、须为 0[1]。内核侧把 __PHYSICAL_MASK_SHIFT 定为 52,PTE 取页框号时使用的掩码 PTE_PFN_MASK 即由此推出[11]。
低 12 位里最常用的是三个权限位,各自对应一类熟悉的保护场景。R/W 为 0 表示只读:常量区、写时复制的共享页靠它兜底,写入即触发异常。U/S 为 0 表示仅内核态可访问:用户程序触碰内核页会被 MMU 当场拦下,这是用户态与内核态隔离的硬件基础。NX 为 1 表示不可执行:栈和堆标记 NX 之后,注入其中的代码无法运行,该位需要 IA32_EFER.NXE 开启才生效[1]。
P 位(Present)为 0 表示这个页当前不在物理内存中:可能是尚未分配的匿名页,也可能是被换出到 swap 的页。访问它会触发缺页异常,内核再决定是分配物理页、从 swap 换回,还是投递 SIGSEGV[1][7]。
A 位与 D 位是 CPU 自动维护的访问痕迹:页被读或写过,A 位置 1;被写入过,D 位置 1。内核回收页时借 A 位判断冷热,换出时借 D 位判断页是否被改脏、是否需要写回。
剩下几个位职责相对专门。bits 11..9 硬件忽略,Linux 借作软件标志:bit 9 为 _PAGE_SPECIAL、bit 10 为 userfaultfd 写保护、bit 11 为 soft-dirty 标记。bit 7 身兼两职:4 KiB 页下是 PAT,配合 PCD/PWT 选择内存类型;大页下改作 PSE,表明该 PMD 或 PUD 表项已是叶子。G 位标记的 TLB 全局项在切换 CR3 时不失效,内核映射借此避免被进程切换冲刷[11]。
R/W、U/S、NX、P 这些位在每次访存时被 MMU 强制校验,越权访问直接异常。进程隔离与内存保护不是操作系统的软件约定,而是由这组标志位在硬件层兜底。
规范地址与非规范空洞
指针在字面上有 64 位,但四级分页只翻译低 48 位,高 16 位不能随意填写。处理器的规则是:bits 63..48 必须全部等于 bit 47,相当于把 bit 47 复制到所有更高位,满足条件的地址称为规范地址(canonical address)[9]。
这条规则是给未来留的余地。地址位宽日后扩展时,只需把复制源从 bit 47 换成更高的实现位,已有的规范地址解释不变,软件无需改造,本节末尾的 LA57 正是这种扩展的实例[3][9]。
违反规则的地址是非规范地址,0x0000800000000000 就是一例:它的 bit 47 为 1,bits 63..48 却全是 0,复制关系断裂。访问非规范地址不会进入页表翻译,CPU 当场抛出异常,进程收到的就是段错误[9]。
内核里的等价判定见 arch/x86/include/asm/page.h 的 __is_canonical_address():把地址按有效位宽做符号扩展,即 ((s64)vaddr << (64 - vaddr_bits)) >> (64 - vaddr_bits),结果仍等于原地址才算规范。vaddr_bits 取自 CPUID.80000008H:EAX[15:8](内核里是 boot_cpu_data.x86_virt_bits),四级分页下为 48,此时该判定等价于要求 bits 63..48 全部等于 bit 47[11]。内核文档把同一件事表述为:最高实现位以上的位为符号扩展,因此按无符号解释时,用户地址与内核地址之间会出现空洞[3]。
这一约束直接决定了地址空间的划分方式:可用地址被切成低半区与高半区两半,各 128 TB,中间夹着不可用的非规范空洞。
图 7:四级分页下的规范地址划分,用户空间与内核空间之间是非规范空洞[3]。
0x0000000000000000
用户空间 ~128 TB,bit 47 = 0
0x00007fffffffefff
0x00007ffffffff000
guard hole 4 kB
0x00007fffffffffff
----------------------------------------------------------
0x0000800000000000
非规范空洞 ~8 EB,不可访问
0x7fffffffffffffff
0x8000000000000000
非规范空洞 ~8 EB,不可访问
0xffff7fffffffffff
----------------------------------------------------------
0xffff800000000000
内核空间 bit 47 = 1,高位全为 1
0xffffffffffffffff
因此内核地址都形如 0xffff8… 或 0xffffffff…,用户态地址则落在 0x0000… 与 0x00007fff… 之间,本文实证一观测到的两个地址正是后一种情形。
图 7 里的几个边界在 6.14 源码里都有对应常量,不妨对照着认一遍。用户空间上界由 task_size_max() 给出,未启用 LA57 时取 (1 左移 47 位) 减一个页,即 0x00007ffffffff000;它是上界本身,用户空间最后一个可用字节是它减一,与文档表格的 0x00007fffffffefff 吻合。内核侧空洞的起点是 GUARD_HOLE_BASE_ADDR,等于 -256 左移 PGDIR_SHIFT,即 0xffff800000000000,长度 8 TB,为管理程序保留。真正的直接映射从 page_offset_base 开始,未开 KASLR 时固定为 __PAGE_OFFSET_BASE_L4,即 0xffff888000000000;内核代码自身的映射起点是 __START_KERNEL_map,即 0xffffffff80000000[11]。这也解释了平时在 /proc/kallsyms 里看到的内核符号地址为何多为 0xffffffff 开头。LA57 把有效位宽提到 57 位后,规范性条件相应变为 bits 63..56 相同,非规范空洞随之收窄,用户空间扩至 64 PB[3][9]。
实证一:拆解一个虚拟地址的页表索引
下面这个程序取一个全局变量和一个栈变量的地址,按 9+9+9+9+12 拆出各级索引,直观看到”虚拟地址本身就是页表索引加偏移的编码”。
#include <stdio.h>
#include <stdint.h>
// 把 48 位虚拟地址按 x86-64 四级页表的 9+9+9+9+12 拆解,
// 演示 CPU 如何用虚拟地址的各段位去索引各级页表。
static void decode_vaddr(uintptr_t addr)
{
printf("virtual address: %p\n", (void *)addr);
printf(" PGD index (bits 39-47): %3lu\n", (addr >> 39) & 0x1ff);
printf(" PUD index (bits 30-38): %3lu\n", (addr >> 30) & 0x1ff);
printf(" PMD index (bits 21-29): %3lu\n", (addr >> 21) & 0x1ff);
printf(" PTE index (bits 12-20): %3lu\n", (addr >> 12) & 0x1ff);
printf(" page offset (bits 0-11): %3lu\n", addr & 0xfff);
}
int main(void)
{
// 取一个全局变量与一个栈变量的地址,分别拆解,
// 直观看到虚拟地址本身就是页表索引与偏移的编码。
static int g_var;
int s_var = 0;
decode_vaddr((uintptr_t)&g_var);
printf("\n");
decode_vaddr((uintptr_t)&s_var);
return 0;
}
virtual address: 0x648694d40014
PGD index (bits 39-47): 201
PUD index (bits 30-38): 26
PMD index (bits 21-29): 166
PTE index (bits 12-20): 320
page offset (bits 0-11): 20
virtual address: 0x7ffc9bcacae4
PGD index (bits 39-47): 255
PUD index (bits 30-38): 498
PMD index (bits 21-29): 222
PTE index (bits 12-20): 172
page offset (bits 0-11): 2788
全局变量落在 bit 47 为 0 的用户空间低半区;栈变量地址以 0x7ffc 开头、PGD 索引为 255,处于用户空间末段,与图 4 的位拆分吻合[3]。按内核文档,四级页表下用户空间为 0x0000000000000000–0x00007fffffffefff,约 128 TB,紧随其后的 0x00007ffffffff000–0x00007fffffffffff 是 4 kB 的 guard hole,两者都落在 PGD 索引 0–255 覆盖的范围内;内核空间自 0xffff800000000000 起,对应 PGD 索引 256–511[3]。这两个地址都是 bit 47 为 0 的规范用户态地址,与图 7 的划分吻合。
实证二:把虚拟地址翻译成物理地址
光拆索引还不够,翻译的终点是物理地址。Linux 提供 /proc/pid/pagemap:每个虚拟页对应文件里一个 64 位表项,bits 0-54 为物理页框号(PFN),bit 63 为 present 位;读取 PFN 需要 CAP_SYS_ADMIN 权限(Linux 4.0 起)[5]。下面的程序用同一地址既做索引拆解、又经 pagemap 取出物理落点。
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
// 通过 /proc/self/pagemap 把一个已映射的虚拟页翻译为物理页框号(PFN),
// 进而算出物理地址,直观展示分页机制把虚拟地址落到物理内存的最终结果。
// 读取 PFN 需要 CAP_SYS_ADMIN 权限(Linux 4.0 起),故在 root 下运行。
int main(void)
{
static volatile unsigned char buf[4096];
// 写一字节,确保该页已被分配并建立映射
buf[0] = 1;
long page_size = sysconf(_SC_PAGESIZE);
uintptr_t vaddr = (uintptr_t)&buf;
off_t offset = (off_t)(vaddr / page_size) * (off_t)sizeof(uint64_t);
int fd = open("/proc/self/pagemap", O_RDONLY);
if (fd < 0) {
perror("open /proc/self/pagemap");
return 1;
}
uint64_t entry = 0;
ssize_t n = pread(fd, &entry, sizeof(entry), offset);
close(fd);
if (n != (ssize_t)sizeof(entry)) {
fprintf(stderr, "pread /proc/self/pagemap failed\n");
return 1;
}
if (!(entry & (1ULL << 63))) {
printf("page not present (bit 63 clear)\n");
return 1;
}
// 内核文档:pagemap 每个表项 64 位,bits 0-54 为 PFN,bit 63 为 present。
uint64_t pfn = entry & (((uint64_t)1 << 55) - 1);
uint64_t phys = pfn * (uint64_t)page_size + (vaddr % (uintptr_t)page_size);
// 先按 9+9+9+9+12 拆解该虚拟地址,再给出其物理落点,
// 把"地址解码"与"地址翻译"在同一次运行中串起来。
printf("virtual address : %p\n", (void *)vaddr);
printf(" PGD index (bits 39-47): %3lu\n", (vaddr >> 39) & 0x1ff);
printf(" PUD index (bits 30-38): %3lu\n", (vaddr >> 30) & 0x1ff);
printf(" PMD index (bits 21-29): %3lu\n", (vaddr >> 21) & 0x1ff);
printf(" PTE index (bits 12-20): %3lu\n", (vaddr >> 12) & 0x1ff);
printf(" page offset (bits 0-11): %3lu\n", vaddr & 0xfff);
printf("page size : %ld bytes\n", page_size);
printf("page frame (PFN): %lu\n", pfn);
printf("physical address: 0x%lx\n", (unsigned long)phys);
return 0;
}
virtual address : 0x5cb573de4040
PGD index (bits 39-47): 185
PUD index (bits 30-38): 213
PMD index (bits 21-29): 414
PTE index (bits 12-20): 484
page offset (bits 0-11): 64
page size : 4096 bytes
page frame (PFN): 94551
physical address: 0x17157040
同一地址既给出了页表索引(PGD 185 / PUD 213 / PMD 414 / PTE 484 / 偏移 64),又经 pagemap 得到物理页框 94551,拼出物理地址 0x17157040(= 94551 × 4096 + 64)。这正是图 4 那段遍历的实证结果:虚拟地址 0x5cb573de4040 最终落在物理页框 94551 的第 64 字节[5]。需要注意,不同进程、不同次运行里这个虚拟地址映射到哪个物理页框并不固定,由当时空闲页框与 ASLR 决定。
实证三:每进程页表与地址空间隔离
前文讲进程隔离时说过,每个进程有自己的页表,CR3 指向各自的顶级页表,于是同一个虚拟地址在不同进程里可以映射到不同的物理页框。fork 把这个特性用得更有意思:子进程诞生时不复制内存,而是先与父进程共享同一批物理页,页表项被统一改成只读;任何一方写入时再触发写保护缺页,由内核决定怎么处置,这套机制叫写时复制(COW)[1][7]。
“写入时才复制”并不等于”每次写入都复制”。6.14 内核的缺页处理函数 do_wp_page() 会先判断这页是不是已经归写入方独占:若是独占(PageAnonExclusive 置位,或 wp_can_reuse_anon_folio() 判定引用计数已降到 1 且不在 swapcache),内核调 wp_page_reuse() 就地把它改回可写,既不分配新页也不复制内容;只有页仍被多方共享时才走 wp_page_copy() 分配新页并复制[11]。fork 刚返回时父子共同映射,属于后者,首次写入会看到换页;若对方已经退出,页归一方独占,就属于前者,写入不换页。
下面的程序让子进程先写、父进程等待后再写,用这个顺序把两条分支都清晰地观察出来。
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
// 把虚拟地址对应的物理页框号(PFN)读出并打印,
// 用于观察父、子进程对同一虚拟地址分别映射到了哪些物理页。
static void print_pfn(const char *who, volatile unsigned char *p)
{
long page_size = sysconf(_SC_PAGESIZE);
uintptr_t vaddr = (uintptr_t)p;
off_t offset = (off_t)(vaddr / page_size) * (off_t)sizeof(uint64_t);
int fd = open("/proc/self/pagemap", O_RDONLY);
uint64_t entry = 0;
if (fd >= 0) {
ssize_t r = pread(fd, &entry, sizeof(entry), offset);
(void)r;
close(fd);
}
int present = (entry >> 63) & 1;
uint64_t pfn = present ? (entry & (((uint64_t)1 << 55) - 1)) : 0;
printf("%-20s vaddr=%p pfn=%lu present=%d\n",
who, (void *)vaddr, pfn, present);
}
int main(void)
{
static volatile unsigned char g_buf[4096];
// 提前写一次,确保页面已分配并建立映射(记为物理页 F0)
g_buf[0] = 0;
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
// 子进程先观测再写入:此时仍与父进程共享物理页 F0,
// 写入会触发写时复制,分配到独立的物理页。
print_pfn("child shared-first", &g_buf[0]);
g_buf[0] = 1;
print_pfn("child after-COW", &g_buf[0]);
printf("child reads g_buf[0]=%d (独立副本)\n", (int)g_buf[0]);
exit(0);
}
// 父进程先等子进程写完并退出,再读写自己的副本,
// 避免父进程先写把共享页 F0 独占,掩盖子进程的写时复制。
waitpid(pid, NULL, 0);
print_pfn("parent shared-first", &g_buf[0]);
g_buf[0] = 2;
print_pfn("parent after-COW", &g_buf[0]);
printf("parent reads g_buf[0]=%d (独立副本)\n", (int)g_buf[0]);
return 0;
}
child shared-first vaddr=0x632867e96040 pfn=74495 present=1
child after-COW vaddr=0x632867e96040 pfn=287942 present=1
child reads g_buf[0]=1 (独立副本)
parent shared-first vaddr=0x632867e96040 pfn=74495 present=1
parent after-COW vaddr=0x632867e96040 pfn=74495 present=1
parent reads g_buf[0]=2 (独立副本)
fork 之后、写入之前,父子都映射到物理页 74495(写时复制尚未发生)。子进程写入触发 COW,分配到新物理页 287942;此时原页 74495 已由父进程独占,父进程随后写入时 PFN 保持 74495,未观察到新的页框分配。最终同一个虚拟地址 0x632867e96040,在子进程里落在物理页 287942、在父进程里落在物理页 74495,两个进程各自的页表把它翻译到了不同的物理页框[1][5]。子进程读到 1、父进程读到 2,印证二者持有独立副本。
父进程那一步 PFN 未变,正对应上文提到的 wp_page_reuse() 分支:子进程退出后该页已归父进程独占,内核就地把它改为可写,不再分配新页框[11]。为了把这条分支单独拎出来验证,再做一个对照:子进程不写这个共享页、直接退出,父进程等它退出后再写,此时按源码应当走复用分支。
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
// 把虚拟地址对应的物理页框号(PFN)读出并打印,
// 用于观察写入前后该虚拟页是否落到了不同的物理页上。
static void print_pfn(const char *who, volatile unsigned char *p)
{
long page_size = sysconf(_SC_PAGESIZE);
uintptr_t vaddr = (uintptr_t)p;
off_t offset = (off_t)(vaddr / page_size) * (off_t)sizeof(uint64_t);
int fd = open("/proc/self/pagemap", O_RDONLY);
uint64_t entry = 0;
if (fd >= 0) {
ssize_t r = pread(fd, &entry, sizeof(entry), offset);
(void)r;
close(fd);
}
int present = (entry >> 63) & 1;
uint64_t pfn = present ? (entry & (((uint64_t)1 << 55) - 1)) : 0;
printf("%-22s vaddr=%p pfn=%lu present=%d\n",
who, (void *)vaddr, pfn, present);
}
int main(void)
{
static volatile unsigned char g_buf[4096];
// 提前写一次,确保页面已分配并建立映射(记为物理页 F0)
g_buf[0] = 0;
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
// 子进程不写这个共享页,直接退出,
// 于是 F0 的映射者只剩父进程一个。
exit(0);
}
// 等子进程退出后,父进程才写入。此时该页已无人共享,
// 内核可以走 wp_page_reuse() 就地改为可写,而不必分配新页。
waitpid(pid, NULL, 0);
print_pfn("parent before write", &g_buf[0]);
g_buf[0] = 7;
print_pfn("parent after write ", &g_buf[0]);
printf("parent reads g_buf[0]=%d\n", (int)g_buf[0]);
return 0;
}
parent before write vaddr=0x60988f337040 pfn=109743 present=1
parent after write vaddr=0x60988f337040 pfn=109743 present=1
parent reads g_buf[0]=7
写入前后都是物理页 109743,说明这次写缺页没有换页:内核判定该页已独占,直接改为可写。这组对照说明,写时复制的准确表述是”必要时才复制”,而不是”每次写入都复制”[11]。
还有一个疑问需要排除:PFN 不变只说明没有换页,会不会这次写压根没触发缺页?要坐实”确实触发了缺页、只是走了复用分支”,需要更直接的观测。内核在 wp_page_reuse() 里会累加一个名为 PGREUSE 的计数器,可从 /proc/vmstat 读取。下面的程序把样本放大到 64 MiB(16384 个 4 KiB 页),让两种模式分别跑一遍:copy 模式下子进程存活、页仍被父子共享;reuse 模式下子进程立即退出、页归父进程独占。父进程写的是同一批页,差别只在写入时是否还有他人共享。
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/wait.h>
// 用一块足够大的匿名映射,把写缺页的处置放大到可计数的规模:
// 16384 个 4 KiB 页,即 64 MiB。页表策略为 madvise 时不会自动合并成大页。
#define NPAGES 16384
#define PAGESZ ((size_t)4096)
#define SIZE ((size_t)NPAGES * PAGESZ)
// 用法:cow_paths copy,子进程存活,页仍被父子共享,写入应走复制分支;
// cow_paths reuse,子进程立即退出,页归父进程独占,写入应走复用分支。
// 两种模式里父进程写的是同一批页,差别只在于写入时是否还有他人共享。
// 运行前后读 /proc/vmstat 里的 pgreuse,即可分辨实际走了哪条分支。
int main(int argc, char **argv)
{
int reuse_mode = (argc > 1 && argv[1][0] == 'r');
volatile unsigned char *buf = mmap(NULL, SIZE, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (buf == MAP_FAILED) {
perror("mmap");
return 1;
}
// 先把每一页都写一次,确保映射全部建立(记为物理页 F)
for (size_t i = 0; i < NPAGES; i++)
buf[i * PAGESZ] = 1;
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
// 复用模式下直接退出,共享者只剩父进程;
// 复制模式下保持存活,使这批页始终被父子共同映射。
if (reuse_mode)
_exit(0);
for (;;)
pause();
}
if (!reuse_mode)
usleep(200000); // 等子进程进入 pause,确保映射关系已建立
// 父进程把每一页再写一次,这一次才进入写缺页处理
for (size_t i = 0; i < NPAGES; i++)
buf[i * PAGESZ] = 2;
printf("mode=%s pages=%d\n", reuse_mode ? "reuse" : "copy", NPAGES);
kill(pid, SIGKILL);
waitpid(pid, NULL, 0);
return 0;
}
mode=copy pgreuse delta = 100
mode=reuse pgreuse delta = 16321
mode=copy pgreuse delta = 97
mode=reuse pgreuse delta = 16158
读数对比很直观。16384 个页里,reuse 模式累计了约 1.6 万次 PGREUSE,几乎每页的写缺页都走了 wp_page_reuse();copy 模式的差值只有一百上下。这个一百上下不是复用分支的贡献,而是噪声:pgreuse 是全机计数器而非进程级的,运行期间这台机器上其他进程的缺页也会计入。把样本放大到 16384 页,正是为了让信号与噪声拉开两个数量级。两组数据合起来,把”必要时才复制”落到了可重复测量的事实之上[11]。
实证四:Linux 对分段的有限使用(FS 基址)
前文提到 x86-64 长模式下 CS/DS/SS/ES 基址强制为 0,仅 FS/GS 保留了非零基址的能力。Linux 用 FS 基址承载线程局部存储:以 __thread 声明的变量,编译器会为其访问生成 FS 段前缀,运行时的线性地址即”FS 基址 + 偏移”[8]。下面的程序用 arch_prctl(ARCH_GET_FS) 读出 FS 基址,并把它与一个 __thread 变量的地址做差,验证这种地址生成方式是否真实存在。
#define _GNU_SOURCE
#include <stdio.h>
#include <stdint.h>
#include <unistd.h>
#include <sys/syscall.h>
// x86-64 长模式下 CS/DS/SS/ES 的段基址被强制为 0,段式机制仅在 FS/GS 上保留。
// 本程序用 arch_prctl 读出 FS 与 GS 的 64 位基址,并对比一个 __thread 变量的
// 地址与 FS 基址的差值,实证 FS 基址确实参与了线性地址的计算。
#ifndef ARCH_GET_FS
#define ARCH_GET_FS 0x1003
#endif
#ifndef ARCH_GET_GS
#define ARCH_GET_GS 0x1004
#endif
// 读取指定段的 64 位基址,成功返回 0,失败返回 -1
static long get_seg_base(int code, unsigned long *base)
{
return syscall(SYS_arch_prctl, code, base);
}
int main(void)
{
static __thread int tls_var = 42;
unsigned long fs_base = 0;
unsigned long gs_base = 0;
if (get_seg_base(ARCH_GET_FS, &fs_base) != 0) {
perror("arch_prctl(ARCH_GET_FS)");
return 1;
}
if (get_seg_base(ARCH_GET_GS, &gs_base) != 0) {
perror("arch_prctl(ARCH_GET_GS)");
return 1;
}
printf("FS base = 0x%lx\n", fs_base);
printf("GS base = 0x%lx\n", gs_base);
printf("&tls_var = %p\n", (void *)&tls_var);
printf("&tls_var - FS base = %ld\n",
(long)((uintptr_t)&tls_var - (uintptr_t)fs_base));
printf("tls_var = %d\n", tls_var);
return 0;
}
FS base = 0x7b204b585740
GS base = 0x0
&tls_var = 0x7b204b58573c
&tls_var - FS base = -4
tls_var = 42
FS 基址是一个非零值,且 &tls_var 与它的差值恒为 -4(重复运行结果一致):tls_var 的线性地址确实由 FS 基址加上一个固定的小偏移形成,FS 的段基址功能在 64 位模式下仍然保留。GS 基址在用户态读出为 0,与内核文档的描述吻合:GS 在用户空间没有通用用途,内核态的每 CPU 指针经 SWAPGS 在进入与退出内核时切换,用户态读到的并不是内核的 GS 基址[8]。
至于 CS/DS/SS/ES 基址恒为 0,那是长模式的架构规定,内核文档的原文是”In 64-bit mode the CS/SS/DS/ES segments are ignored and the base address is always 0”[8]。用户态没有读取这几个段基址的接口,因此本实证不对它们做直接观测,也不据本节输出断言它们被”测到”。
能不能自己动手把 FS 基址改掉?不建议。FS 通常已被线程库占用,直接用 ARCH_SET_FS 改写极易导致崩溃[6][8]。读写这两个基址在内核里对应 do_arch_prctl_64() 的四个分支:读走 x86_fsbase_read_task() 与 x86_gsbase_read_task(),写走 x86_fsbase_write_cpu() 与 x86_gsbase_write_cpu(),且写入的基址若大于等于 TASK_SIZE_MAX 会被拒绝并返回 EPERM[11]。man 手册另提到部分内核配置下 ARCH_SET_GS 会被禁用[6]。手册与源码的这点差异提醒我们,具体可用性应以实际运行的内核为准。本实证因此只读取、不修改[6][11]。
验证环境与方法说明
本文实验在 Linux 6.8.0(Ubuntu 24.04)、glibc 2.39、x86-64 环境下完成。文中嵌入的输出为某次真机运行结果;由于 ASLR 与页框分配的不确定性,重复运行时虚拟地址与 PFN 会变化,但地址拆分模式、COW 行为、FS 非零基址与 TLS 变量相对 FS 基址的固定偏移等结构性结论保持一致。
除官方手册与内核文档外,本文还直接对照了 Linux 6.14 的内核源码树(标签 v6.14)。凡引用源码处均标注 [11],并在行文中点明具体文件,例如 arch/x86/include/asm/page_64_types.h、arch/x86/mm/tlb.c、mm/memory.c、fs/proc/task_mmu.c。需要说明一处版本差异:实验机运行的内核是 6.8.0,与所参考的 6.14 源码并非同一版本。凡涉及代码路径与常量取值的论断,依据是 6.14 源码;凡涉及可观测行为的结论,依据是 6.8.0 上的真机输出。二者在本文讨论的机制上表现一致(例如写时复制的复用分支已在实验机上直接观测到),但读者所用内核版本若不同,具体常量与分支仍应以实际运行的内核核对。
该机 /proc/cpuinfo 报告 address sizes: 52 bits physical, 48 bits virtual,flags 中不含 la57,说明它按四级分页运行、物理地址位宽为 52 位;透明大页策略为 madvise,普通匿名映射默认仍是 4 KiB 页,故本文按四级遍历到 PTE 讨论。
一个值得记取的坑:最初写 aspace_isolation 时,若父进程先于子进程写入,父进程的写时复制会把共享页独占,子进程随后就地写入、不再分配新页框,PFN 看起来”没变”,容易误判 COW 未发生。这正是上文 wp_can_reuse_anon_folio() 分支的表现,并非异常[11]。让父进程 wait 子进程先写完,COW 才清晰可见。这再次说明,涉及内存行为的结论必须真机验证、仔细控制竞态,不能凭直觉下判断。
附:相关高频面试题与参考解析
以下题目提炼自操作系统与 Linux 内存方向在知乎、CSDN、SegmentFault、Stack Overflow 等社区被反复问到的问题,选取与本文内容直接相关的十一题。参考答案回引前文与官方资料,便于对照核查。
问题一:逻辑地址、线性地址、物理地址有什么区别?
答题思路:按”两段式翻译”的框架作答,先给出三者定义,再点出 x86-64 下逻辑地址与线性地址基本相等。
参考答案:逻辑地址是程序视角的”段选择子:偏移”;经段式转换得到线性地址;线性地址再经页式转换得到物理地址,物理地址才是内存条上的真实位置[1]。x86-64 长模式把 CS/DS/SS/ES 段基址强制为 0,逻辑地址基本等于线性地址,真正的翻译落在分页上[2]。
问题二:为什么 x86-64 下分段被弱化了?Linux 为什么主要依赖分页?
答题思路:从隔离粒度、可移植性、32 位 4 GB 局限三个角度讲,落到”分页既能隔离又能灵活映射”。
参考答案:分段做整段平移与权限检查,粒度粗且各架构差异大;分页以页为单位,既能实现进程隔离,又能把不连续的物理页映射成连续的虚拟地址,还便于写时复制、共享内存等机制[1][2]。因此 Linux 在 x86-64 上采用扁平分段(基址为 0),把保护与翻译主力交给分页,仅在 FS/GS 上保留非零基址用于 TLS[2][6]。
问题三:x86-64 四级页表如何拆分 48 位虚拟地址?CR3 起什么作用?
答题思路:先背出 9+9+9+9+12 的位拆分,再讲 CR3 是顶级页表基址、进程切换时更换。
参考答案:48 位虚拟地址拆为 PGD(9) + PUD(9) + PMD(9) + PTE(9) + 页内偏移(12),四级页表逐级索引,末级 PTE 给出物理页框,拼上偏移得物理地址[2][3]。CR3 存放顶级页表(PML4/PGD)的物理基址,MMU 从 CR3 出发遍历;调度到某个用户进程时,内核把它的页表基址载入 CR3,于是不同进程看到各自独立的虚拟地址空间[1][7]。内核线程没有用户地址空间,调度时沿用上一进程的页表运行,不重新加载 CR3[10]。本文实证一、实证二分别验证了索引拆分与翻译结果。
问题四:为什么需要多级页表?TLB 又解决什么问题?
答题思路:先算单级页表覆盖整个地址空间的开销,再说明多级按需建表省内存,最后用 TLB 解释性能。
参考答案:以 32 位、4 KiB 页为例,单级页表需要约一百万个页表项才能覆盖整个 4 GiB 空间,每个进程一份则开销随进程数线性放大;内核文档给出的理由是:单级页表会非常稀疏,因为大段虚拟地址通常并不使用;改用层级页表后,虚拟地址空间中的大片空洞只需在上层标记为未映射,不再浪费下层页表所需的内存[7]。代价是四级遍历一次翻译需要 4 次内存访问,TLB 与页遍历缓存缓住最近用过的映射,命中时直接返回物理地址,免去遍历[1][7]。本文图 5 即 TLB 在翻译路径中的位置。
问题五:什么是写时复制(COW)?fork 后父子如何共享与隔离内存?
答题思路:讲清 fork 后共享物理页、写入才复制,并用 pagemap 实证。
参考答案:fork 之后父子进程先共享同一批物理页,页表项被改为只读;任一方首次写入时触发写保护缺页。内核此时并不总是复制:若这页已经归写入方独占,内核就地把页改回可写,不分配新页也不复制(wp_page_reuse() 分支);只有页仍被多方共享时才分配新页并复制内容(wp_page_copy() 分支),两条分支都在 6.14 的 do_wp_page() 里[11]。fork 刚返回时父子共同映射,属于后者,首次写入会看到换页。本文实证三用 /proc/self/pagemap 观察到:写入前父子同映射到物理页 74495,子进程写入后获得独立页 287942,且各自读到不同的值(1 与 2);其对照组则观察到页已独占时写入不换页,印证了复用分支[5][11]。
问题六:用户态如何把一个虚拟地址翻译成物理地址?
答题思路:这是动手题,给出 /proc/pid/pagemap 的读取方式,并点出权限限制。
参考答案:读取 /proc/pid/pagemap,按”虚拟页号 × 8”定位到 64 位表项,bits 0-54 为物理页框号(PFN),bit 63 为 present 位,物理地址 = PFN × 页大小 + 页内偏移[5]。读取 PFN 自 Linux 4.0 起需要 CAP_SYS_ADMIN 权限,故通常在 root 下运行;6.14 源码里这个限制落在 fs/proc/task_mmu.c 的 pagemap_read(),由 pm.show_pfn = file_ns_capable(file, &init_user_ns, CAP_SYS_ADMIN) 决定,无权限时 PFN 字段被清零而不是打开失败[11]。本文实证二即按此方式把一个虚拟地址翻译为物理地址 0x17157040。
问题七:页表项里有哪些关键标志位?各自作用是什么?
答题思路:列出 P/RW/US/A/D/NX 并各说一句作用,强调它们是硬件强制校验的。
参考答案:x86-64 的 PTE 含若干标志位:P(Present,页是否在内存)、R/W(是否可写)、U/S(用户态/内核态可访问)、A(访问位,CPU 首次读或写时置 1)、D(脏位,CPU 写时为 1)、NX(不可执行)等,高位存放物理页框地址[1]。这些位由 MMU 在每次访存时强制校验,越权访问直接异常,是进程隔离与内存保护的硬件基础。本文图 6 给出字段布局。
问题八:32 位系统为什么有 4 GB 内存上限?x86-64 与 PAE 如何突破?
答题思路:从 32 位地址线宽度讲起,再区分 PAE(扩物理地址但仍 32 位虚拟)与 x86-64(扩虚拟地址到 48 位)。
参考答案:32 位处理器的线性地址只有 32 位,理论最多寻址 4 GB 虚拟地址空间,这是地址位宽决定的上限[1]。PAE(Pentium Pro 引入)把可寻址的物理地址扩到 36 位,但单个进程的虚拟地址仍受 32 位限制,需要特殊映射才能利用大内存[1]。x86-64 把虚拟地址扩到 48 位,虚拟地址空间共 256 TB,其中用户空间占 128 TB,并采用四级页表,从根本上解除了 4 GB 虚拟上限[2][3]。LA57 把虚拟地址扩到 57 位,虚拟地址空间增至 128 PB[3][9]。
问题九:什么是规范地址(canonical address)?为什么 48 位虚拟地址只给用户态 128 TB?
答题思路:先讲符号扩展的约束,再说明它把地址空间切成两半、中间留下非规范空洞。
参考答案:x86-64 定义的虚拟地址有 64 位,但四级分页只实现低 48 位,处理器要求 bits 63..48 全部等于 bit 47,即高位为 bit 47 的符号扩展,满足者称规范地址,否则访问即触发异常、根本不进入翻译流程[9]。内核里对应的判据见 __is_canonical_address(),其做法是把地址按 vaddr_bits(四级分页下为 48)做符号扩展后与自身比较[11]。于是可用地址被切成低半区与高半区两半各 128 TB,中间夹着不可用的非规范空洞。内核文档据此把用户空间定为 0x0000000000000000–0x00007fffffffefff(约 128 TB),其后 4 kB 为 guard hole,内核空间自 0xffff800000000000 起[3]。这也解释了内核地址为何都形如 0xffff8…。LA57 下规范条件变为 bits 63..56 相同,用户空间相应扩到 64 PB[3][9]。
问题十:大页(huge page)对页表遍历有什么影响?
答题思路:点明遍历并非总走到 PTE,2 MiB 由 PMD 直接映射、1 GiB 由 PUD 直接映射。
参考答案:启用大页时,PMD 表项可直接映射 2 MiB、PUD 表项可直接映射 1 GiB 的连续物理区域,页表遍历在中间层提前结束,无需走到 PTE[7]。内核用 pmd_leaf() 与 pud_leaf() 判定某级表项是否为叶子,判据是表项的 _PAGE_PSE 位,粒度来自 PMD_SIZE(1 左移 21 位)与 PUD_SIZE(1 左移 30 位)[11]。收益是 TLB 压力下降、页表开销减小,代价是内存浪费与分配难度上升[7]。本文实验机的透明大页策略为 madvise,普通匿名映射默认仍是 4 KiB 页,因此实证部分按四级遍历到 PTE 讨论。
问题十一:fork 之后父子进程写同一个共享页,每次都会分配新的物理页吗?
答题思路:不要脱口而出”会”。先区分”页仍被多方共享”与”页已归自己独占”两种情形,再落到 do_wp_page() 的两个分支上,最后用 pagemap 说明如何实测。
参考答案:并非每次。Linux 6.14 的 do_wp_page() 先判断该匿名页能否复用:PageAnonExclusive 已置位,或 wp_can_reuse_anon_folio() 判定该页引用计数已降到 1 且不在 swapcache,则调 wp_page_reuse() 就地把它改为可写,既不分配新页也不复制内容;只有页仍被多方共享时才走 wp_page_copy() 分配新的 folio 并复制[11]。fork 之后父子共同映射该页,首次写入属于后者,会看到换页;若对方已退出、页归自己独占,则属于前者,PFN 保持不变。本文实证三的对照组让子进程不写即退出、父进程随后写入,观测到写入前后 PFN 同为 109743,即后一种情形[11]。这也解释了为什么调试时常常看到”父进程先写之后子进程写不再换页”的现象,那是复用分支在起作用,而非 COW 失效。
参考来源
[1] Intel® 64 and IA-32 Architectures Software Developer’s Manual, Volume 3 (System Programming Guide). 实模式分段、保护模式、分页、PAE、IA-32e/long mode 的官方架构规范。https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
[2] AMD64 Architecture Programmer’s Manual, Volume 2: System Programming. 四级分页的位布局(9+9+9+9+12)、PTE 格式、FS/GS 基址 MSR 的官方规范。https://www.amd.com/system/files/TechDocs/24593.pdf
[3] The Linux Kernel documentation, x86_64 Memory Management. 48 位与 57 位(LA57)虚拟地址、四级与五级布局。https://docs.kernel.org/arch/x86/x86_64/mm.html
[4] The Linux Kernel documentation, Process Addresses. PGD/P4D/PUD/PMD/PTE 五级抽象、架构级数不足时的折叠。https://docs.kernel.org/mm/process_addrs.html
[5] The Linux Kernel documentation, pagemap (admin-guide/mm). /proc/pid/pagemap 格式:bits 0-54 为 PFN、bit 63 为 present、读取 PFN 需 CAP_SYS_ADMIN。https://www.kernel.org/doc/html/latest/admin-guide/mm/pagemap.html
[6] Linux man-pages, arch_prctl(2). ARCH_GET_FS/ARCH_SET_FS、FS/GS 基址操作。https://man7.org/linux/man-pages/man2/arch_prctl.2.html
[7] The Linux Kernel documentation, Page Tables. PGD / P4D / PUD / PMD / PTE 各层级的英文全称(Page Global Directory、Page Level 4 Directory、Page Upper Directory、Page Middle Directory、Page Table Entry)、PFN 定义、页表折叠、层次化页表节省页表内存、TLB 与页遍历缓存、大页在 PMD/PUD 层直接映射、SIGSEGV 与写时复制。https://docs.kernel.org/mm/page_tables.html
[8] The Linux Kernel documentation, Using FS and GS segments in user space applications. “In 64-bit mode the CS/SS/DS/ES segments are ignored and the base address is always 0”、FS 用于 TLS 且编译器为 __thread 变量生成 FS 段前缀、GS 在用户空间无通用用途、arch_prctl 与 SWAPGS。https://docs.kernel.org/arch/x86/x86_64/fsgs.html
[9] Intel, 5-Level Paging and 5-Level EPT White Paper. 规范地址定义(“Linear addresses that use only bits 47:0 … are called canonical”)、48 位与 57 位两档规范性条件、CR4.LA57[bit 12]、虚拟地址空间由 256 TB 增至 128 PB。https://cdrdv2-public.intel.com/671442/5-level-paging-white-paper.pdf
[10] Linux kernel commit 3d28ebceaffab(x86/mm: Rework lazy TLB to track the actual loaded mm)。内核线程无 mm 时借用上一进程的 mm_struct,CR3 指向其 pgd,即惰性 TLB 模式下不重新加载 CR3。https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=3d28ebceaffab40f30afa87e33331560148d7b8b
[11] Linux 6.14 内核源码树(标签 v6.14)。本文引用的位置包括:arch/x86/include/asm/page_64_types.h(__VIRTUAL_MASK_SHIFT、task_size_max()、__PAGE_OFFSET_BASE_L4、_START_KERNEL_map、GUARD_HOLE)、arch/x86/include/asm/page_64.h(task_size_max() 的 LA57 分支)、arch/x86/include/asm/page.h(_is_canonical_address())、arch/x86/include/asm/pgtable_64_types.h(PGDIR_SHIFT / PUD_SHIFT / PMD_SHIFT、PTRS_PER、PMD_SIZE / PUD_SIZE、MAX_POSSIBLE_PHYSMEM_BITS)、include/asm-generic/pgtable-nop4d.h(P4D 折叠)、arch/x86/include/asm/pgtable.h(pmd_leaf() / pud_leaf())、arch/x86/include/asm/pgtable_types.h(PAGE_BIT* 各位定义、PTE_PFN_MASK)、arch/x86/mm/tlb.c(switch_mm_irqs_off()、load_new_mm_cr3()、enter_lazy_tlb())、mm/memory.c(do_wp_page()、wp_page_copy()、wp_page_reuse()、wp_can_reuse_anon_folio())、fs/proc/task_mmu.c(pagemap_read() 的 CAP_SYS_ADMIN 检查、PM_PFRAME_BITS)、arch/x86/kernel/process_64.c(do_arch_prctl_64())、arch/x86/kernel/cpu/common.c(x86_virt_bits 的来源)、arch/x86/include/uapi/asm/processor-flags.h(X86_CR4_LA57_BIT = 12)、arch/x86/include/asm/cpufeatures.h(X86_FEATURE_LA57 的 CPUID 位)、arch/x86/boot/compressed/pgtable_64.c(LA57 引导期判定)、Documentation/arch/x86/x86_64/mm.rst 与 Documentation/admin-guide/mm/pagemap.rst。https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tag/?h=v6.14