内核里一切虚拟地址都不是白给的:每条取指,每次访存,硬件都在 MMU 里做一次 VA 到 PA 的翻译.这篇把翻译机制拆开:地址空间怎么切,页表长什么样,每一位置的什么含义,最后亲手把四级页表从 TTBR 一路走到 PTE.全程板上实测,中间还炸出一个特别有教育意义的坑.
这篇回答几件事:
- TCR/TTBR 这些控制寄存器怎么决定地址空间布局
- 四级页表,描述符每一位的含义,MAIR 和 AttrIndx 的关系
- 一个用户页从"没分配"到"写入"到"改只读",PTE 怎么一步步变
- 内核页和用户页在页表里的可见差别
- 编译期配置和运行期现实怎么错位,为什么模块里不能乱用 PAGE_OFFSET
- 改页表为什么要讲 TLB 的规矩
先看布局.MMU 开着的时候(SCTLR 的 M 位),VA 空间从中间劈成两半:低一半归用户,基址在 TTBR0_EL1;高一半归内核,基址在 TTBR1_EL1.劈在哪由 TCR_EL1 的 T0SZ/T1SZ 决定,这个数是"顶部保留位数".板上模块直接读:
TCR_EL1 = 0x00000072b5503510
T0SZ=16 -> 用户 VA 48 bit; T1SZ=16 -> 内核 VA 48 bit
TG0=0 TG1=2 (TG0:0=4K TG1:2=4K) IPS=2 (2=40bit)
T0SZ=16 就是用户半区 2^48,T1SZ=16 内核半区同样 48 位.4KB 粒度,IPS=2 表示中间物理地址 40 位,RK3399 的物理空间就这么大.
然后是这篇的第一个意外.查这块板内核的编译配置,写的是 VA_BITS=52,PGTABLE_LEVELS=5,而 TCR 读出来明明是 48 位 4 级.这不是配置错了,是 arm64 的一个特色:它是主流架构里少有的能运行时折叠页表级数的.内核源码里 vabits_actual 不是编译期常量,是启动后从 TCR 现场算的,arch/arm64/include/asm/memory.h:232:
#define vabits_actual (64 - ((read_tcr() >> 16) & 63))
一份按 52 位编出来的内核,跑到只支持 48 位的 A72/A53 上,启动时发现硬件不给 52,就地折成 4 级用.听起来很美,直到我在模块里踩了坑.
坑是这样的.我要在模块里手工走页表,页表本身存在线性映射区,拿到页表的物理地址要拼回虚拟地址,最直觉的写法:PAGE_OFFSET + pa.编译,上板,insmod,当场 Oops:
walk kernel/linear(__get_free_page) 0xffff000006993000 (4 级, 起=0xfff1000002d97000)
Unable to handle kernel paging request at virtual address fff0000002d97000
Internal error: Oops: 0000000096000004
看第一个坏地址 0xfff0000002d97000,比正经线性映射 0xffff0000xxxx 少了一段 f.原因:模块是从内核头文件里把 PAGE_OFFSET 常量内联进自己代码的,这个内核按 52 位布局编,常量是 0xfff0000000000000;而运行期折成 48 位后,线性映射实际铺在 0xffff000000000000.内核自己的代码启动时会用运行期值,模块内联的却是编译期值,两边就这样错开了.最后不碰任何 PA/VA 转换宏,用两个导出函数自校准:
void *v = vmalloc(PAGE_SIZE);
pfn = vmalloc_to_pfn(v); /* 这页的物理帧号 */
lm_base = (unsigned long)page_address(vmalloc_to_page(v))
- (pfn << PAGE_SHIFT); /* 运行期线性基址 */
vmalloc_to_pfn 给 PA 侧,page_address 给 VA 侧,一减,运行期真实基址 0xffff000000000000到手,后面走表全用它.这个坑提醒一句:在这种"编译 52 运行 48"的内核上,模块里的 PAGE_OFFSET,__pa,__va 都别想当然,要么自校准,要么用导出函数.
布局定了,看页表本体.48 位 VA,4KB 页,48 - 12 = 36 位要分给索引,每级页表一项 8 字节,一页放 512 项,9 位一级,36/9 正好 4 级:pgd(L0)→pud(L1)→pmd(L2)→pte(L3),剩下的 12 位是页内偏移.硬件拿 VA[47:39] 查 L0,拿 [38:30] 查 L1,依此类推,最后 L3 出来的是页框号加权限位.
描述符低位是类型和属性.非末级的表项,bit0 是 valid,bit1 区分"指向下一级表"还是"这里直接是个大页"(block);末级 PTE 的 bit1:0 是 11 表示有效的页.属性位分中低段和高段两片:
| 位 | 名字 | 意思 |
|---|---|---|
| [4:2] | AttrIndx | 查 MAIR 的索引 |
| [7:6] | AP | 访问权限,EL0/EL1 各自读写 |
| [9:8] | SH | shareable,00 不同享,11 inner shareable |
| [10] | AF | access flag,首次访问硬件置位 |
| [11] | nG | 0=全局 TLB 项,1=按 ASID 隔离 |
| [51] | DBM | 脏位由硬件维护 |
| [53] | PXN | 特权级不可执行 |
| [54] | UXN | 用户态不可执行 |
AttrIndx 指向 MAIR_EL1,那是一张 8 格的属性查找表,每格一个字节,高半 nibble 是 inner 属性低半是 outer.板上一口气读出来:
MAIR_EL1 = 0x000000040044ffff
AttrIndx0 = 0xff (Normal WB)
AttrIndx1 = 0xff (Normal WB)
AttrIndx2 = 0x44 (Normal NC)
AttrIndx3 = 0x00 (Device nGnRnE)
AttrIndx4 = 0x04 (Device nGnRE)
0xff 是写回缓存,0x44 不缓存,0x00/0x04 是两档 Device 内存.内核普通页默认走 AttrIndx=0,源码里也写死了这个约定,pgtable-prot.h 的 _PAGE_DEFAULT 就是 PTE_ATTRINDX(MT_NORMAL),memory.h 里还有条注释专门强调 "MT_NORMAL must be index 0".所以页表项里存的不是"缓存怎么配"这一长串,是个 3 位索引,省地方,八种预配置随便选.想配第九种?没了,八格是架构上限,想换就重编 MAIR,全系统一起换.
下面是这篇最好看的部分:一个用户页的一生.测试程序 mmap 一页匿名内存,分三个时刻各走一次表,全部真实输出.第一次,mmap 完没碰过:
pte[0x9f] = 0x0000000000000000
整个 PTE 全零.四级表一路都是 valid 的 table,只有最后一格是空的 — 这就是按需分页在页表层的样子:mmap 只登记了 VMA(内核账本),页根本没来,等第一次访问缺页了再分配.写一个字进去,再走:
pte[0x9f] = 0x00e800000a28ef43
PA=0x00000a28e000 AttrIndx=0 AP=1 SH=3 AF=1 nG=1 DBM=1 PXN=1 UXN=1
变化全在这一个 64 位里:物理页框 0xa28e000 分配好了,AttrIndx=0 走 WB 缓存,AP=01 表示用户态可写,AF 硬件置了 1(这页被摸过了),nG=1 说明这条 TLB 记录按进程 ASID 隔离,PXN/UXN 双 1 不可执行.DBM=1,脏位硬件管.最后 mprotect 改成只读:
pte[0x9f] = 0x00e000000a28efc3
PA=0x00000a28e000 AttrIndx=0 AP=3 SH=3 AF=1 nG=1 DBM=0 PXN=1 UXN=1
同一个物理页,AP 从 01 变 11(两级都只读),DBM 顺带清了.三行输出,把 mprotect 的全部实际动作演完了 — 它根本没碰内存,只改了这一格的几个位,再让 TLB 记录失效.
内核页的走表顺带也做了.线性映射区一页(__get_free_page 拿的),从 TTBR1 起:L0[0]→L1[0]→L2[0x35]→pte;模块自己的静态变量落在 vmalloc 区:L0[0x100](十进制 256)→三层→pte.和用户页最大的可见差别是 nG=0:内核页的 TLB 项是全局的,进程切换不用刷,不然每次切换都得把内核映射重建一遍,太亏.另外 TTBR1 的值本身也有看头,读出来 0x0454_000002d97000,高位带着 ASID 字段,低位是 L0 表物理地址 — 翻译起点和进程标记打包在一个寄存器里.
nG=1 + ASID 的组合就是进程隔离的底座:每个进程有自己的 ASID,TLB 里用户页的记录都挂着它,切换 mm 只要换 TTBR0,别的进程的 TLB 项自动不命中,不用全刷.代价是 ASID 只有 8 或 16 位(TCR 的 AS 位决定,板上是 8 位),用完了内核就得滚动回收,context.c 里那套生成新 ASID 时批量 flush 的逻辑就是干这个的.
最后说改页表的规矩,这是最容易写出偶发死机的地方.TLB 是翻译结果的缓存,你改了页表,硬件不知道,必须显式发 TLBI(invalidate)指令.顺序有讲究:先写 PTE,DSB 保证写落地,再 TLBI,再一条 DSB 等失效完成,然后才允许别人访问.更凶的是替换"会改变翻译结果形状"的表项,比如把一个大页拆成小页,ARM 要求 break-before-make:先把旧表项置成无效,TLBI,确认没人再用旧翻译,再写新表项.不这么干,两个 CPU 可能同时用两套翻译,架构允许直接给你报错.内核把这套路数封装在 pmdp_invalidate 这类接口里,include/linux/pgtable.h 的注释写得清楚.自己写裸页表操作时,先想想这个顺序.
最后留几个容易忽略的细节:
- PTE 的 PA 字段名义上有 52 位槽位,这板 40 位物理地址只用到一部分,52 位 PA 的机器会把 PA[51:48] 塞进 bits[15:12],那个编码比较绕,真碰到再细说.
- Contiguous bit(bit52):16 个连续 PTE 打上这个标记,TLB 把它们折叠成一项,内核映射大面积连续区域时省 TLB,代价是这 16 项必须同生共死.
- AF 位是硬件写的,内核回收内存时要主动清掉再走 TLBI,不然一个"看起来最近被访问过"的假象会让回收算法失准,这就是页面老化逻辑的硬件接口.
- DBM 开着时脏位硬件维护,但只读化(mprotect)时内核要自己清,实测 state3 里 DBM 从 1 变 0 就是它.
- 线性映射的页 AttrIndx=1 而 vmalloc 的页 AttrIndx=0,两格 MAIR 都是 0xff,语义相同,选哪格是内核不同代码路径的口味,别读出"两种缓存策略"来.
- 页表项高段 bits[58:55] 是软件位,PTE_SPECIAL 这类内核标记放那,硬件不看.读原始 PTE 时先把这些摘出去再当硬件属性解.
- 模块里做 PA/VA 转换,优先用 vmalloc_to_page/page_address 这类导出函数,别拿 PAGE_OFFSET 手拼,这篇那个 Oops 就是现成的反面教材.