4、转换控制寄存器:TCR_ELx配置、IPS/TSZ/TG等字段含义、页表基址寄存器TTBR0/1

MMU 的核心,说白了就是一张“虚拟地址 → 物理地址”的映射表。但光有表不行,你得告诉硬件怎么查这张表、表有多大、每页多大、查表时用几级索引。这些配置,全压在 TCR_ELx(Translation Control Register)和 TTBR0/1(Translation Table Base Register)上。

我刚开始接触 ARMv8 的 MMU 时,觉得寄存器名字又长又多,头大。后来调过几次页表报错,才明白这几个寄存器就是 MMU 的“大脑”。配置错了,系统直接挂给你看。

4.1 TCR_ELx:转换控制寄存器

TCR_ELx 控制着整个地址翻译的行为。不同异常级有各自的 TCR:

  • TCR_EL1:给 OS 内核和用户空间用(EL0/EL1)
  • TCR_EL2:给虚拟化监控器用(EL2)
  • TCR_EL3:给安全监控器用(EL3)

每个 TCR 的位域结构基本一致,但 EL1 的 TCR 最复杂,因为它要同时管理内核空间和用户空间的翻译。咱们重点讲 TCR_EL1。

4.1.1 IPS:物理地址空间大小

IPS(Intermediate Physical Address Size)告诉 MMU:你的物理地址总线有多宽。ARMv8 支持多种物理地址宽度:

IPS 编码 物理地址宽度 最大物理内存
000 32 位 4 GB
001 36 位 64 GB
010 40 位 1 TB
011 42 位 4 TB
100 44 位 16 TB
101 48 位 256 TB
110 52 位 4 PB
⚠️ 我曾经踩过的坑:IPS 设小了,MMU 访问高地址物理内存时直接报 Translation Fault。比如你板子上有 8 GB DDR,IPS 却配成 32 位,系统只能看到前 4 GB。我调试时查了半天页表,最后发现是 IPS 没改。记住:IPS 必须 ≥ 实际物理地址宽度。

4.1.2 TG:翻译粒度

TG(Translation Granule)决定页表的最小页面大小。ARMv8 支持三种粒度:

  • 4 KB:最常用,兼容性好,页表层级多(4 级)
  • 16 KB:中间选项,页表层级少一级
  • 64 KB:大页,页表层级最少(2 级),适合大内存系统

我个人习惯在嵌入式场景用 4 KB 粒度。为什么?因为灵活。你想想看,一个系统里既有代码段、数据段,又有外设映射,4 KB 粒度能精细控制权限。64 KB 粒度虽然页表小,但映射外设时容易浪费空间。

4.1.3 T0SZ / T1SZ:地址空间大小

T0SZ 和 T1SZ 控制虚拟地址空间的大小。T0SZ 对应 TTBR0(用户空间),T1SZ 对应 TTBR1(内核空间)。

计算公式很简单:

虚拟地址位数 = 64 - T0SZ
虚拟地址空间大小 = 2^(64 - T0SZ)

举个例子:

  • T0SZ = 25 → 虚拟地址 39 位 → 512 GB 空间
  • T0SZ = 16 → 虚拟地址 48 位 → 256 TB 空间
💡 我的经验:嵌入式系统通常不需要 48 位虚拟地址。我一般设 T0SZ = 25(39 位),够用且页表小。但如果你跑 Linux,内核默认用 48 位。别乱改,除非你清楚自己在做什么。

4.1.4 其他关键字段

字段 作用 我的建议
SH 可共享性(Inner/Outer/Non-shareable) 多核系统用 Inner Shareable
IRGN/ORGN Cache 策略(Write-Back/Write-Through) 内存用 Write-Back,外设用 Non-cacheable
A1 ASID 选择(TTBR0 还是 TTBR1) 一般用 TTBR0 的 ASID
EPD0/EPD1 禁用 TTBR0/TTBR1 翻译 裸机程序可以关掉一个

4.2 TTBR0 / TTBR1:页表基址寄存器

TTBR0 和 TTBR1 存放页表的物理基地址。ARMv8 把虚拟地址空间分成两半:

  • 低地址空间(TTBR0):用户空间,地址范围由 T0SZ 决定
  • 高地址空间(TTBR1):内核空间,地址范围由 T1SZ 决定

为什么这么设计?我理解是为了隔离。用户程序只能访问低地址,内核访问高地址。切换进程时,只需要换 TTBR0,TTBR1 保持不变。这样内核页表不用每个进程都重建。

4.2.1 TTBR0 的配置

TTBR0 的格式:

位 [63:48]:ASID(地址空间标识符)
位 [47:x]:页表基地址(物理地址,按粒度对齐)
位 [x-1:0]:保留(必须为 0)

注意:基地址必须按页表粒度对齐。4 KB 粒度时,基地址低 12 位必须为 0。64 KB 粒度时,低 16 位为 0。

🔑 关键点:TTBR0 和 TTBR1 的基地址是物理地址,不是虚拟地址。MMU 在翻译过程中直接拿这个地址去访问物理内存。所以页表必须放在非换出的物理内存中。

4.2.2 ASID:地址空间标识符

ASID 是 TTBR0 高 16 位的一个字段。它的作用是:

  • 区分不同进程的 TLB 条目
  • 进程切换时,TLB 不需要全部刷掉
  • 只要 ASID 不同,TLB 可以同时缓存多个进程的映射

我调试过一个性能问题:进程切换频繁,TLB 每次都全刷,性能掉得厉害。加上 ASID 后,TLB 命中率从 60% 升到 95%。

4.3 配置示例:裸机 MMU 初始化

下面是一个简化版的 TCR_EL1 和 TTBR0 配置代码。假设我们使用 4 KB 粒度、39 位虚拟地址、40 位物理地址:

// 配置 TCR_EL1
// IPS = 010 (40位物理地址)
// TG0 = 00 (4KB粒度)
// T0SZ = 25 (39位虚拟地址)
// SH0 = 11 (Inner Shareable)
// ORGN0 = 01 (Write-Back, Write-Allocate)
// IRGN0 = 01 (Write-Back, Write-Allocate)
uint64_t tcr = (2 << 32) |   // IPS=010
               (0 << 14) |   // TG0=00
               (25 << 0) |   // T0SZ=25
               (3 << 12) |   // SH0=11
               (1 << 10) |   // ORGN0=01
               (1 << 8);     // IRGN0=01

__asm__ volatile("msr tcr_el1, %0" : : "r"(tcr));

// 配置 TTBR0
// 页表基地址: 0x80000000 (物理地址)
// ASID: 0
uint64_t ttbr0 = 0x80000000;  // 基地址,低12位为0

__asm__ volatile("msr ttbr0_el1, %0" : : "r"(ttbr0));

// 使能MMU
uint64_t sctlr;
__asm__ volatile("mrs %0, sctlr_el1" : "=r"(sctlr));
sctlr |= 1;  // M位=1
__asm__ volatile("msr sctlr_el1, %0" : : "r"(sctlr));
⚠️ 注意:使能 MMU 前,确保页表已经建好。否则 MMU 一开,第一条指令就取指失败。我建议在使能 MMU 后加一条 ISB 指令,保证配置生效。

4.4 知识结构图

下面这张图展示了 TCR_EL1、TTBR0/1 和页表之间的关系:

TCR_ELx 与 TTBR0/1 配置关系图 TCR_ELx 与 TTBR0/1 配置关系 TCR_EL1 IPS (物理地址宽度) TG (粒度) T0SZ/T1SZ SH/IRGN/ORGN TTBR0 (用户空间) TTBR1 (内核空间) L0 页表 L1 页表 L2 页表 L3 页表 💡 说明: TCR_ELx 的 T0SZ 决定 TTBR0 管理的虚拟地址空间大小,T1SZ 决定 TTBR1 的。页表层级数由 T0SZ/T1SZ 和 TG 共同决定。

4.5 避坑指南

最后,我总结几个实际项目中容易踩的坑:

  1. IPS 配小了:物理内存访问不到,系统启动一半就挂。检查你的 DDR 地址范围,IPS 必须覆盖所有物理内存。
  2. T0SZ 和 T1SZ 冲突:两个空间不能重叠。比如 T0SZ=25(低 512 GB),T1SZ=25(高 512 GB),中间留空。如果设成 T0SZ=16(低 256 TB),T1SZ=16(高 256 TB),中间没有空隙,但地址会重叠?不会,ARM 规定 TTBR0 和 TTBR1 空间不重叠,由硬件保证。
  3. TTBR 基地址未对齐:MMU 直接忽略低几位,导致页表地址错位。我建议在代码里加断言检查对齐。
  4. 使能 MMU 前页表未初始化:MMU 一开,第一条指令就取指失败。我习惯在使能前先写一个 dummy 页表,映射当前代码段。
💡 我的习惯:每次配置 TCR 和 TTBR 后,用 ISB 指令同步。然后读回寄存器确认值写进去了。别问我为什么,问就是被硬件 bug 坑过。