前言
起点在我的 Final Year Project 的提交 63c50b7 Add periodic timer interrupt and tick counting。本文修改后的代码库在提交 5ba8cf4 Enable the MMU with a static level 1 translation table。
本项目基于 QEMU virt 平台,使用 Cortex-A57 CPU 模型。现在项目已经能做到:
- 打印当前异常等级(EL1,这相当于 x86-64 的 Ring 0,属于操作系统内核级别的权限)
- 使用 PL011 串口(以下简称 UART),响应 UART 接收中断并回显收到的字符
- 使用 ARM Generic Timer(EL1 物理定时器)每秒触发一次中断,并在中断中打印
tick: N
在阅读完本篇文章,理解相关概念并加入所有功能后,os 将能做到:
- 造出一张 AArch64 页表
- 逐位检查描述符
- 打开 MMU,并证明系统仍然正常
- 故意访问一块未映射的内存,检查异常向量
如果要尝试运行代码,你需要先修改 Makefile 指定编译器,链接器和模拟器的路径,然后:
make
make run
此时 QEMU 会在前台运行。按 Ctrl-A 松开后再按 X 就可以退出。
基本概念
虚拟地址和物理地址
目前的 UART 代码有这样的片段:
// drivers/uart/uart.c
#define UART_BASE 0x09000000UL
#define UART_DR (*(volatile uint32_t *)(UART_BASE + 0x00))
CPU 执行 str w0, [x1] 时,x1 里是 0x0900_0000。CPU 并不知道这是 UART,它只是把这个数字交给地址总线。这个数字的具体含义取决于 Memory Management Unit(MMU):
MMU off: CPU -- 0x09000000 --> Memory / Device
MMU on: CPU -- 0x09000000 --> [MMU] --> table walk --> another number --> Memory / Device
在这里,0x0900_0000 是由程序(包括内核)所发出的虚拟地址(VA),而 another number 才是查表后真正送到内存控制器的物理地址(PA)。
在之前的实现中,直接写 0x0900_0000 就碰到 UART,是因为这个常量恰好等于QEMU virt 这块虚拟板分配给 PL011 串口(UART)的物理地址。这是完全的硬编码,只要换到另一个平台,这个地址便会失效。
在 MMU 关着的时候,没有任何机制替代码纠正这个数字。这正是虚拟内存要解决的问题之一:有了页表,驱动可以统一使用一个固定的虚拟地址,由页表把它映射到「这块板子实际上 UART 的地址」。之后换板子只需要修改页表,而不用再重写驱动。
MMU(Memory Management Unit)
MMU 要做的便是:拿到 VA,查一张表,得到 PA,顺便检查权限。
VA --> [table walk] --> PA + permissions
这张表就是页表。它不是硬件,而是要之后手动往内存里写的一段数字。
如果查表过程中发现这张表里没有 VA 对应的 PA,MMU 就会报 translation fault 异常。如果找到了对应的表项,但表项里的权限不允许这次访问(比如往只读页写数据),MMU 就会报 permission fault 异常。
页
AArch64 的地址是一个 64 位整数,存在 64 位的通用寄存器里。
uint64_t *p; // p 变量本身占 8 字节
// p 里面装的是一个 64 位的地址值,也是 8 字节
AArch64 是字节寻址,每个字节都拥有一个唯一地址。但如果一个字节就对应保存一条翻译记录,那么只是表就要几个 GB。所以硬件按固定大小的块翻译。
AArch64 支持 4 KB,16 KB,64 KB 三种粒度,其中 4 KB 是最小也是最常用的。在 4 KB 粒度下,每页的长度是 2^12 = 4096 字节,即可以包含 4096 (或者 0x1000)个地址。
虚拟地址的组成
把地址想成「第几页」+「这一页里的第几个字节」:
虚拟地址 = 页号(参与翻译) + 页内偏移(不翻译)
例如,页号从 0 开始:
0x0000_0000-0x0000_0FFF属于第 0 页0x0000_1000属于第 1 页,页内偏移是0x0000x0000_1001仍在第 1 页,页内偏移是0x001
用一个更像现实里的地址来看的话:
0x0900_0000 -> 页号 0x09000 + 偏移 0x000
0x0900_0123 -> 页号 0x09000 + 偏移 0x123
具体算法如下:
页号 = 地址 >> 12(也就是 地址 / 0x1000)
偏移 = 地址 & 0xFFF(也就是 地址 % 0x1000)
页基址 = 地址 & ~0xFFF(清掉低 12 位,得到这一页的开头)
偏移范围
低 12 位能表示 2^12 = 4096 个值,所以 4 KB 粒度下,页内偏移只能是 0x000 到 0xFFF。
偏移加到 0xFFF 就已经是这一页的最后一个字节,再加 1 就进位到下一页:
0x0900_0FFC
0x0900_0FFD
0x0900_0FFE
0x0900_0FFF <- 这一页的最后一个字节
0x0900_1000 <- 下一页的第一个字节,偏移回到 0x000
这也是为什么页的起始地址在十六进制里总是末尾三个 0,因为它们是 4 KB 对其的。
页表的粒度是 4 KB,它只知道「这一页整体在哪」,不区分页内的第几个字节。所以:
- 翻译只替换页号
- 页内偏移原样带到物理地址
VA 0x0900_0123 -> 页号 0x09000 翻译成物理页号 0x09000
-> 偏移 0x123 原样保留
-> 得到 PA 0x0900_0123
页内第 0x123 个字节,翻译后还是目标页的第 0x123 个字节。这就是「页内连续」的含义。
多级页表
目前的代码配置是 39 位 VA(这是在后面的 TCR_EL1 节选出来的值,这里请先当成已知参数),而 4 KB 粒度下偏移就要占 12 位(还记得之前说的 2^12 = 4096 么),那么剩下给页号可以使用的位数是 39 - 12 = 27 位。
但如果直接用 27 位页号做索引,页表需要 2^27 项,每项 8 字节,共 1 GB,且每个地址空间都要一份,而其中大部分项还是空的。因此,AArch64 采用多级页表。让着 27 位切分成 9 + 9 + 9,对应 L1,L2,L3 三级。上级无效时可以不分配下级,这样只需要为实际用到的地址区域付出内存。
VA[38:12] 为 27 位页号,其中
- VA[38:30] L1 索引
- VA[29:21] L2 索引
- VA[20:12] L3 索引
VA[11:0] 的 12 位偏移,不参与页表翻译
对 0x0900_0000:
VA[38:30] = 0 -> 查 l1_table[0]
VA[29:21] = 0x48 -> 查 l2_table[0x48] (l2_table 是 l1_table[0] 指向的那张 L2 表)
VA[20:12] = 0x000 -> 查 l3_table[0] (l3_table 是 l2_table[0x48] 指向的那张 L3 表)
VA[11:0] = 0x000 -> 偏移
而这里的 9 位也不是随便选的:一张页表是包含了许多描述符(下一章节)的数组,每个描述符 8 字节,一张表有 2^9 = 512 项,共 512 × 8 = 4096 字节,刚好是一页。
也就是说,索引位数是被「一张表恰好占一页」反推出来的:页大小 4 KB / 描述符 8 字节 = 512 项,512 项需要 9 位来索引。
这样设计有两个好处:
- 一张表正好占一个物理页,可以直接用分配物理页的方式分配页表
- 页表本身也 4 KB 对齐,物理基址的低 12 位必然是 0,这样描述符里就不用存这 12 位,可以留出来放权限和属性(见下一节)
描述符
页表是一个数组,其中每个元素叫描述符(descriptor),8 字节。
一个描述符主要包含四类信息:
- 访问权限:读写与执行权限,以及 EL0(用户态)是否有权访问
- 内存属性:这块内存是普通内存还是设备寄存器。设备寄存器的读写不能被缓存或重排,否则外设行为会出错(例如前面用到的
0x0900_0000UART 就是设备内存) - 地址:指向下一级表的物理基址。如果是最后一级(L3 页描述符,或 L1/L2 的块描述符),则是最终物理页(块)的物理基址
- 有效位与类型:这条描述符是否有效,以及它的指向的类型
这里的重点便是类型,有三种:
- table(表描述符):这条描述符指向下一级页表,翻译还没结束,要继续往下查。出现在 L0(如果配置有),L1,L2。本项目从 L1 开始所以只涉及 L1,L2
- page(页描述符):这条描述符直接指向一个 4 KB 物理页,翻译到此结束。只出现在 L3
- block(块描述符):这条描述符直接指向一大块物理内存,翻译到此结束,不再往下查。出现在 L1,L2
块描述符是 AArch64 的一个优化:如果一大块连续的虚拟地址都映射到连续的物理地址,而且权限和属性都一样,就可以用一条描述符覆盖整块,而不必为每个 4 KB 页都分配一个 L3 描述符。
而在我们这个为了方便理解而尽量少写代码的项目里,我们也可以利用它:
- 只有 L1 页表
- L1 页表只包含两个块描述符
- 第一个覆盖设备区,第二个覆盖 RAM 区
- 这样就不需要 L2,L3 页表了,虽然代价是一个描述符就覆盖了 1 GB 的虚拟地址空间,而这个空间里不一定真的有 RAM
愉快的概念部分结束了,接下来是充满了记不住名字的寄存器和字段的地狱。
第一步:构建 L1 页表
目标:在内存中准备好 L1 表并把打印出来,先不开启 MMU。
首先是新建 mm/mmu.h,放了个函数声明 mmu_build_tables() 和 mmu_dump_tables(),然后是 mm/mmu.c,这是接下来定义表的地方。
表本体定义
static uint64_t l1_table[512] __attribute__((aligned(4096)));
定义一个 512 条 8 字节描述符的数组(4 KB)。唯一要注意的是 aligned(4096) 因为表基址低 12 位必须是 0。
描述符的字段结构
[1:0] Type:描述符类型
在值为 00 时,标识当前描述符无效,此时不会继续翻译而是直接 fault。
而对于 01 和 11 则随着不同层级有不同含义:
| 层级(本文的 4 KB 配置) | 01 |
11 |
|---|---|---|
| L0 | 不允许作为块描述符 | Table |
| L1,L2 | Block | Table |
| L3 | 无效 | Page |
由于是放在 .bss 段,而在启动时 .bss 已经清零了,所以没写的条目就是零既无效,所以不需要初始化。
本章节需要两条都是 L1 的 01(块),所以翻译在 L1 就结束,不需要 L2,L3。
[4:2] AttrIndx:内存类型
由于 AttrIndex 只有 3 位,最多 8 个编号。但内存类型的组合远多于 8 种。于是架构设计成两层间接:AttrIndex 的三位只存一个属性索引,真正的属性定义放在 MAIR_EL1 (Memory Attribute Indirection Register (EL1))寄存器中。
MAIR_EL1 是一个 64 位的寄存器,被划分为了 8 个 8 位的字段 Attr0 - Attr7,每个字段定义一种内存属性。这八个槽位本身没有固定含义,每个槽位的内容可以自己定义。
每个槽 8 位被拆分成了两部分:
- 当
[7:4]=0000时,表示 Device 内存,低四位用来选择设备内存的具体类型。本文使用整个字节 0x00,表示 Device-nGnRnE,即不合并访问,不重排访问,不提前确认写入完成 - 其余情况,
[7:4]指定 Outer 缓存属性,[3:0]指定 Inner 缓存属性
Normal 是普通内存类型,主要用于 RAM 中的代码和数据。它允许硬件采用缓存,推测访问等机制,但也可以配置为不可缓存。Inner 和 Outer 则用来分别指定内存系统内(离 CPU 核近的那几级,通常是 L1,也常包括 L2)外(更靠外的缓存,通常是 L3 或系统级缓存)层的缓存策略。
| 值 | 含义 |
|---|---|
0100 |
Normal,不可缓存 |
1010 |
Normal,写通(Write-Through) |
1110 |
Normal,写回(Write-Back),不写分配 |
1111 |
Normal,写回 + 写分配 |
其中:
- 写通(Write-Through):写数据时,同时写入缓存和主内存
- 写回(Write-Back):写数据时,只写缓存,把这行标记为脏。等这行缓存被淘汰时才把脏数据一次性写回内存
- 写分配(Write-Allocate):先把这一行从内存读进缓存,再在缓存里改。后面如果还要读写这块数据就能会命中
- 不写分配(No-Write-Allocate):不读进缓存,直接把数据写到内存。适合写一次就不再用的数据,避免污染缓存
不需要记住那么多,因为本项目只会定义两个槽:
- 槽 0 =
0xFF即 Normal WB+WA,RAM 用 - 槽 1 =
0x00即 Device-nGnRnE 设备用 - 槽 2~7 保持
0x00
// MAIR_EL1
// Slot 0 [7:0] = 0xFF Normal memory, outer and inner write-back, read/write allocate
// Slot 1 [15:8] = 0x00 Device-nGnRnE
// Slots 2-7 stay 0x00
#define MAIR_VALUE (0xFFULL)
[7:6] AP:读写权限
两位只有四种组合:
| AP | EL1 | EL0 |
|---|---|---|
00 |
rw |
- |
01 |
rw |
rw |
10 |
r |
- |
11 |
r |
r |
本文将使用 00 允许 EL1 读写。由于 EL0 还不存在所以先完全拒绝。将来加入用户态时,用户页会改成 01 而内核页继续保持 00。
[10] AF:访问标志
作用是标记「这一页或这一块内存是否已经被访问过」:
AF = 0:硬件认为这块内存还没被访问过。第一次访问时会触发 Access Flag fault(访问标志异常),交给软件处理AF = 1:正常访问,不产生异常
AF 原本是给操作系统统计页面冷热,从而决定内存不足时把哪些页换出(写入 swap 分区或交换文件)用的。为了保持本项目的简洁,本项目不做页面换出,所以直接把 AF 置 1,以免遇到 Access Flag fault。
[47:12] Output Address:翻译结果的物理基址
输出地址字段的位置取决于描述符所在的层级。在采用的 4 KB 翻译粒度,48 位物理地址,此时描述符格式如下:
| 描述符 | 映射大小 | 物理基址字段 | 对齐要求 |
|---|---|---|---|
| L1 块描述符 | 1 GB | [47:30] |
1 GB |
| L2 块描述符 | 2 MB | [47:21] |
2 MB |
| L3 页描述符 | 4 KB | [47:12] |
4 KB |
本文只使用 L1 块描述符,因此描述符的 [47:30] 保存目标物理块基址的对应位。
[53] PXN 与 [54] UXN:执行权限
- PXN(Privileged Execute-Never)置 1 表示 EL1 不能从这块内存取指执行
- UXN(Unprivileged Execute-Never)置 1 表示 EL0 不能从这块内存取指执行
之前提到过,本项目的第零个描述符指向设备区,由于不是代码所以置 PXN = 1, UXN = 1 禁止执行。第一个描述符执行内核 RAM 所以可以置 PXN = 0, UXN = 1 禁止 EL0 执行,允许 EL1 执行。
其余位
[11] nG 负责 TLB 全局性,[9:8] SH 控制内存在多核间的共享域,[5] NS 控制访问安全还是非安全物理地址空间(仅 Secure 状态有意义)。本项目只有一套页表,并且是单核运行在非安全状态,所以全部置 0 即可。
总结
| 位段 | 名字 | 作用 | 本文的值 |
|---|---|---|---|
[1:0] |
类型 | L1 时:00 无效 / 01 块 / 11 表 |
01 |
[4:2] |
AttrIndx | MAIR 槽号 0–7 | 0 或 1 |
[7:6] |
AP | 访问权限 | 00 |
[10] |
AF | Access Flag | 1 |
[47:12] |
地址 | 输出物理基址 | 0 / 0x4000_0000 |
[53] |
PXN | EL1 禁止执行 | 设备 1,RAM 0 |
[54] |
UXN | EL0 禁止执行 | 都是 1 |
| 其余 | 保留 / nG / SH | 本手册保持 0 | 0 |
从而可以定义以下宏:
#define DESC_BLOCK (0b01ULL << 0) // [1:0] = 01: Block
#define DESC_TABLE (0b11ULL << 0) // [1:0] = 11: Table / Page
#define DESC_ATTR(idx) ((uint64_t)(idx) << 2) // [4:2] MAIR Slot Number
#define DESC_AP_EL1_RW (0ULL << 6) // [7:6]
#define DESC_AF (1ULL << 10) // [10]
#define DESC_PXN (1ULL << 53) // [53]
#define DESC_UXN (1ULL << 54) // [54]
#define DESC_ADDR(pa) ((uint64_t)(pa) & 0x0000FFFFFFFFF000ULL) // [47:12]
#define ATTR_NORMAL 0 // MAIR 0xFF
#define ATTR_DEVICE 1 // MAIR 0x00
描述符构建
l1_table[0] = DESC_ADDR(0x00000000) | // Map 1 GB at 0x0000_0000 as device memory
DESC_BLOCK |
DESC_ATTR(ATTR_DEVICE) |
DESC_AF |
DESC_PXN | DESC_UXN;
l1_table[1] = DESC_ADDR(0x40000000) | // Map 1 GB at 0x4000_0000 as RAM
DESC_BLOCK |
DESC_ATTR(ATTR_NORMAL) |
DESC_AF |
DESC_UXN;
两条的差别只有三处:
| 差别 | 描述符 0 | 描述符 1 | 原因 |
|---|---|---|---|
| 地址 | 0x00000_0000 |
0x4000_0000 |
区分设备区与 RAM |
| AttrIndx | 1 |
0 |
寄存器不能缓存 |
| PXN | 1 |
0 |
内核代码在 RAM 中运行 |
运行
make
make run
得到输出:
Current EL: 1
l1_table addr = 0x0000000040103000
l1_table[0] = 0x0060000000000405
l1_table[1] = 0x0040000040000401
Platform timer frequency: 62500000
UART and Timer IRQ ready
tick: 100
tick: 200
第二步:明确翻译机制
高低半区
虽然 AArch64 的虚拟地址是 64 位,但并不是所有 64 位地址都可用。地址空间被切成两段:低半区从 0x0000_0000_0000_0000 向上,高半区从 0xFFFF_FFFF_FFFF_FFFF 向下,中间没有覆盖到的地址是无效的空洞。硬件会检查虚拟地址的高位(位数决定方式在下一章节):全 0 走低半区,用 TTBR0_EL1;全 1 走高半区,用 TTBR1_EL1;既不全 0 也不全 1,就落在空洞里触发翻译错误。
这两个寄存器的 [47:12] 是根表的物理基址,[63:48] 是 ASID(Address Space ID)。不同的进程可以拥有相同的 VA,如果 TLB(MMU 内部用来缓存 VA -> PA 翻译结果的表)只按 VA 索引,那么就会拿到另一个进程旧结果,解决方案只有在切换进程时先刷新整个 TLB。有了 ASID,可以理解为非全局映射的 TLB 条目会变成(ASID,VA)-> PA,从而不用在切换进程时刷新整个 TLB。
这样分段的好处是:用户空间放低半区,内核空间放高半区,切换进程时只需要修改 TTBR0_EL1,内核映射(TTBR1_EL1)保持不变。所以在 Linux 等系统中,内核地址通常以 0xFFFF_... 开头,用户地址则从很小的数开始。
本文为了保持简洁并尽量沿用之前的代码,所以只用低半区:内核链接在 0x4010_0000,并把 l1_table 的物理地址写入 TTBR0_EL1。高半区则直接关闭。
TCR_EL1:翻译机制的参数
Translation Control Register(EL1)决定翻译机制本身如何运转,包含以下字段:
- 地址空间:
T0SZ/T1SZ也就是高低半区 - 粒度:
TG0/TG1 - 遍历属性:
IRGN0/ORGN0/SH0 - PA 宽度:
IPS
[5:0] T0SZ 与 [21:16] T1SZ:高低半区虚拟地址位数
T0SZ 和 T1SZ 决定了低半区和高半区的虚拟地址有多少位。例如低半区(即由 TTBR0_EL1 管理的那一半):
VA 位数 = 64 - T0SZ
顶层索引位数 = VA 位数 − 12 − 9 * 下面层数
| T0SZ | VA 位数 | 4 KB 粒度下的层级 | 顶层索引位数 | 起始层可用条目 | 可寻址 |
|---|---|---|---|---|---|
| 16 | 48 | L0 -> L1 -> L2 -> L3 | 9 | 512 | 256 TB |
| 25 | 39 | L1 -> L2 -> L3 | 9 | 512 | 512 GB |
| 30 | 34 | L1 -> L2 -> L3 | 4 | 16 | 16 GB |
| 33 | 31 | L1 -> L2 -> L3 | 1 | 2 | 2 GB |
本文为了让层级直接从 L1 开始,T0SZ 必须大于等于 25 且小于等于 33。更小的合法值会从 L0 开始(而 L0 不允许使用块描述符),更大的合法值会从 L2 或 L3 开始。本文选 25 是因为它在这个区间里给出的地址空间最大(512 GB),而页表内存的代价完全相同(都是一张 4 KB 表)。
对于 T1SZ,虽然本文暂时不用高半区,但这里仍然写 25 保持一个合法值。
[7] EPD0 与 [23] EPD1:是否禁止通过 TTBR0 / TTBR1 遍历
0:允许,正常遍历1:使用对应 TTBR 的访问若发生 TLB miss,会触发 Translation fault,不再进行页表遍历,但已有的 TLB 命中仍可能使用缓存结果
对于 EPD0 本文必须置 0,而高半区因为不使用所以 EPD1 置 1。
[9:8] IRGN0 / [11:10] ORGN0 与 [25:24] IRGN1 [27:26] / ORGN1:页表遍历本身的缓存属性
这几个字段需要与 MAIR_EL1 区分开。MAIR_EL1 配合描述符里的 AttrIndx,决定的是翻译完成后访问目标页的属性。MMU 开启后,每次 TLB 未命中,硬件都会自动遍历页表。由于还没有拿到描述符,无法通过 AttrIndx 去查 MAIR,所以读取页表本身的缓存属性要单独写在 TCR_EL1 的 IRGN0,ORGN0 和 SH0(解释在下一小节)里。
这两位的组合如下:
| 值 | 含义 |
|---|---|
00 |
Normal,不可缓存 |
01 |
Normal,写回 + 写分配 |
10 |
Normal,写通 |
11 |
Normal,写回 |
对于 IRGN0 和 ORGN0,本文使用 01,对于 IRGN1 和 ORGN1 由于已被 EPD1 屏蔽所以保持 00 即可。
[13:12] SH0 与 [29:28] SH1:页表遍历的共享域
| 值 | 含义 |
|---|---|
00 |
非共享 |
10 |
外部共享 |
11 |
内部共享 |
区分于描述符里的 SH 负责被映射的数据/代码的共享域,这里 TCR_EL1 的 SH0 和 SH1 分别指定 TTBR0 和 TTBR1 页表遍历本身的共享域,即读取页表时,硬件要不要和其他核保持缓存一致。SH0 本文填 11 内部共享。而 SH1 对应高半区,本文不使用,保持 00 即可。
[15:14] TG0 与 [31:30] TG1:高低半区的粒度
TG0 负责低半区的粒度设置:
| 值 | 粒度 |
|---|---|
00 |
4 KB |
01 |
64 KB |
10 |
16 KB |
本文将 TG0 置 00(4 KB)。这一位决定了页内偏移 12 位,每级索引 9 位,也决定了整张 l1_table 的布局。
TG1 的编码和 TG0 故意不一样:
| 值 | 粒度 |
|---|---|
01 |
16 KB |
10 |
4 KB |
11 |
64 KB |
本文将 TG1 置 10(4 KB)。虽然 EPD1 = 1 让它不生效,但任然写对避免后续踩坑。
[22] A1:ASID 从哪个 TTBR 取
0:ASID 位于TTBR0_EL1[63:48]1:ASID 位于TTBR1_EL1[63:48]
本文不用 ASID(只有一个地址空间),保持 0。
[34:32] IPS:物理地址宽度
| 值 | PA 位数 | 可寻址 |
|---|---|---|
000 |
32 | 4 GB |
001 |
36 | 64 GB |
010 |
40 | 1 TB |
011 |
42 | 4 TB |
100 |
44 | 16 TB |
101 |
48 | 256 TB |
本文置为 010(40 位),允许翻译得到的物理地址最大为 2^40 - 1,具体映射范围由页表决定。另外,设置为 40 位会需要设置描述符中 [47:12] Output Address 中的 [47:40] 置为 0。由于本项目使用的物理地址都小于 2^40,所以高位自然为 0,无需额外处理。
其余位
[36]AS选择 ASID 的宽度:0= 8 位,1= 16 位(可选特性)。本文只有一个地址空间,使用 ASID 0,因此选择 8 位即可。[37]TBI0,[38]TBI1:翻译时忽略虚拟地址的最高 8 位,这 8 位不再需要满足符号扩展的合法性要求,方便软件在指针高字节里存标记,本项目不使用所以置 0[63:39]不启用其余可选功能,置为 0
总结
| 位段 | 名 | 作用 | 本文的值 |
|---|---|---|---|
[5:0] |
T0SZ | 低半区 VA 位数 = 64 - T0SZ | 25(39 位) |
[7] |
EPD0 | 禁止 TTBR0 遍历 | 0 |
[9:8] |
IRGN0 | 遍历的内层缓存属性 | 01 |
[11:10] |
ORGN0 | 遍历的外层缓存属性 | 01 |
[13:12] |
SH0 | 遍历的共享域 | 11 |
[15:14] |
TG0 | 低半区粒度 | 00(4 KB) |
[21:16] |
T1SZ | 高半区 VA 位数 | 25 |
[22] |
A1 | ASID 来源 | 0 |
[23] |
EPD1 | 禁止 TTBR1 遍历 | 1 |
[31:30] |
TG1 | 高半区粒度 | 10(4 KB) |
[34:32] |
IPS | 物理地址宽度 | 010(40 位) |
| 其余 | 保留 | 保持 0 | 0 |
从而可以定义:
#define TCR_VALUE ( \
(25ULL << 0) | /* T0SZ = 25 39 bit VA */ \
/* TG0 = 00 (4 KB granule), omitted since it is the default */ \
(0b01ULL << 8) | /* IRGN0 = 01 Inner Write-Back, RA/WA */ \
(0b01ULL << 10)| /* ORGN0 = 01 Outer Write-Back, RA/WA */ \
(0b11ULL << 12)| /* SH0 = 11 Inner Shareable */ \
(25ULL << 16) | /* T1SZ = 25 TTBR1 unused, keep valid */ \
(0b1ULL << 23) | /* EPD1 = 1 Disable TTBR1 walks */ \
(0b10ULL << 30)| /* TG1 = 10 4 KB granule (not 00) */ \
(0b010ULL << 32) /* IPS = 010 40 bit physical address */ \
)
CPU 复位后 TCR_EL1 部分配置字段的复位值是 UNKNOWN,所以必须整体赋值,不能只 OR 几位。
第三步:打开 MMU
SCTLR_EL1:系统控制寄存器
System Control Register (EL1) 是 EL1 下控制 CPU 整体行为的总开关寄存器,MMU 的开关就在这里。
我们需要主要的有三位:
| 位段 | 名字 | 作用 | 本文的值 |
|---|---|---|---|
[0] |
M |
MMU 总开关 | 1 |
[2] |
C |
数据缓存控制。为 0 时,本文配置下的 Normal 内存数据访问按不可缓存处理 | 1 |
[12] |
I |
指令缓存 | 1 |
| 其余 | 保留 / RES1 | 保持读出时的值,不碰 | — |
定义三个宏:
// SCTLR_EL1 bits
#define SCTLR_M (1ULL << 0) // MMU enable
#define SCTLR_C (1ULL << 2) // Cache enable
#define SCTLR_I (1ULL << 12) // Instruction cache enable
启动函数
void mmu_enable(void)
{
__asm__ volatile("msr mair_el1, %0" :: "r"(MAIR_VALUE));
__asm__ volatile("msr tcr_el1, %0" :: "r"(TCR_VALUE));
__asm__ volatile("msr ttbr0_el1, %0" :: "r"((uint64_t)l1_table));
// dsb: wait until all earlier memory writes (including the page table entries) have completed,
// so the MMU's table walker can see them
__asm__ volatile("dsb sy" ::: "memory");
// isb: flush the pipeline so later instructions are fetched only after
// the system register writes above have taken effect
__asm__ volatile("isb");
// Read-modify-write: SCTLR_EL1 has RES1 bits that must stay 1,
// so don't overwrite it with a constant
uint64_t sctlr;
__asm__ volatile("mrs %0, sctlr_el1" : "=r"(sctlr));
sctlr |= SCTLR_M | SCTLR_C | SCTLR_I;
__asm__ volatile("msr sctlr_el1, %0" :: "r"(sctlr) : "memory");
__asm__ volatile("isb");
}
分别写入 MAIR_EL1,TCR_EL1,TTBR0_EL1 后,先执行 dsb sy,等待之前的内存写入(包括页表)全部完成。再执行 isb,清空流水线,保证后续指令是在这些寄存器配置生效之后才取指执行。最后读出 SCTLR_EL1,把 M/C/I 置 1 再写回。
另外就是需要把函数定义加入头文件,并在 kernel_main() 中调用它。
测试翻译
AArch64 提供 AT(Address Translate)指令,可以手动触发一次翻译,结果写进 PAR_EL1。这样能让硬件明确反应某个地址翻译到了哪里。
__asm__ volatile("at s1e1r, %0" :: "r"(va));
__asm__ volatile("isb");
__asm__ volatile("mrs %0, par_el1" : "=r"(par));
s1e1r 的意思是:Stage 1,EL1,Read。执行后结果写入 PAR_EL1。由于翻译结果是否成功会导致 PAR_EL1 的格式不同,这里只介绍成功的情况:
- [63:56] 内存属性,编码和 MAIR 的 Attr 一样
- [47:12] PA:翻译结果的物理地址
- [8:7] SH:共享域,编码和描述符里的 SH 一样
- [0] F:Fault,翻译是否失败。
0成功,1失败
输出
分别翻译三个地址:UART(设备区),内核入口(RAM 区),以及 0x8000_0000(完全没映射),得到结果如下:
Current EL: 1
l1_table addr = 0x0000000040103000
l1_table[0] = 0x0060000000000405
l1_table[1] = 0x0040000040000401
VA = 0x0000000009000000 translation succeeded, PA = 0x0000000009000000, PAR = 0x0000000009000b00
VA = 0x0000000040100000 translation succeeded, PA = 0x0000000040100000, PAR = 0xff00000040100a00
VA = 0x0000000080000000 translation failed, PAR = 0x000000000000080b
Platform timer frequency: 62500000
UART and Timer IRQ ready
tick: 100
tick: 200
第四步:搞破坏
现在尝试读一块没映射的地址:
uart_print("Reading 0x80000000, l1_table[2] is invalid\n");
uint64_t bad = *(volatile uint64_t *)0x80000000UL;
uart_print("read back: ");
uart_print_hex(bad);
uart_print("\n");
输出
...
Platform timer frequency: 62500000
UART and Timer IRQ ready
Reading 0x80000000, where l1_table[2] is invalid
=== Caught EL1 exception ===
kind: 0
ESR: 0x0000000096000005
EC: 0x0000000000000025
ELR: 0x00000000401012b4
SPSR: 0x0000000080000345
FAR: 0x0000000080000000
Unhandled EL1 exception
成功触发了异常。kind 为 0 是项目之前在 exception.c 里定义的同步异常。这里的 EC 是 Exception Class,位于 ESR_EL1[31:26]:
| EC | 含义 |
|---|---|
0x20 |
Instruction Abort(取指失败,来自较低 EL) |
0x21 |
Instruction Abort(取指失败,来自同一 EL) |
0x24 |
Data Abort,来自低 EL(用户程序) |
0x25 |
Data Abort,来自同一 EL(当前情况) |
0x3C |
BRK 断点 |
在 EC 为 0x25 时,Instruction Specific Syndrome(ISS)位于 ESR_EL1[24:0],其中 Data Fault Status Code(DFSC)位于 ESR_EL1.ISS[5:0]:
| DFSC | 含义 | 原因 |
|---|---|---|
0b000100 |
Translation fault, level 0 | 48 位 VA 下根表无效 |
0b000101 |
Translation fault, level 1 | L1 描述符为 0 |
0b000111 |
Translation fault, level 3 | L3 页描述符无效 |
0b001001 |
Access flag fault, level 1 | AF 忘了置 1 |
0b001111 |
Permission fault, level 3 | AP 设错 |
即,异常来源于相同 EL 的数据访问,原因是 L1 描述符无效。也就是我们之前写的 l1_table[2] = 0。
另外两个寄存器:
ELR_EL1:出错的那条指令的地址。用llvm-objdump -d kernel.elf能看到它就是那条ldrFAR_EL1:出错的虚拟地址,也就是之前代码中的0x8000_0000
结尾语
选手搓 OS 做 Final Year Project 完全是小时候听 Linus 励志故事听多了。等做到现在发现光是 MMU 都这么复杂的时候想换方向已经来不及了。