TaterLi 个人博客

[内核学习]ARM64 MMU与页表

内核里一切虚拟地址都不是白给的:每条取指,每次访存,硬件都在 MMU 里做一次 VA 到 PA 的翻译.这篇把翻译机制拆开:地址空间怎么切,页表长什么样,每一位置的什么含义,最后亲手把四级页表从 TTBR 一路走到 PTE.全程板上实测,中间还炸出一个特别有教育意义的坑.

这篇回答几件事:

先看布局.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]SHshareable,00 不同享,11 inner shareable
[10]AFaccess flag,首次访问硬件置位
[11]nG0=全局 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 的注释写得清楚.自己写裸页表操作时,先想想这个顺序.

最后留几个容易忽略的细节:

退出移动版