4. 启动引导中的内存初始化
好,咱们进入正题。MMU 这东西,说白了就是个地址翻译器。CPU 发出的地址是虚拟的,MMU 负责查页表,把它变成物理地址,然后才去访问内存。你想想看,如果没有 MMU,所有程序都直接操作物理地址,那系统还怎么隔离?怎么跑多任务?
我刚开始做嵌入式 Linux 移植时,最头疼的就是这步。板子一上电,串口打印停在某个地址访问异常,查了半天发现是页表没配好。嗯,从那以后我养成了一个习惯——先把 MMU 关掉,把页表在物理内存里搭好,再统一开启。这个顺序,千万别搞反。
4.1 MMU 开启前的准备工作
开启 MMU 之前,有几件事必须确认。我个人习惯列个清单,逐项打勾:
- 页表基地址已写入 TTBR0/TTBR1——ARM 架构下,页表必须放在物理地址连续的区域内。
- 域访问控制寄存器(DACR)已配置——每个域可以设成“无访问”“仅管理”“客户”等权限。
- TLB 已失效——上电后 TLB 里是随机值,必须先清空。
- ICache/DCache 处于已知状态——我建议先关掉 Cache,等页表稳定后再开。
4.2 一级页表的结构与配置
ARMv7-A 架构下,一级页表也叫 L1 页表。每个表项 4 字节,一共 4096 个表项,覆盖 4GB 虚拟地址空间。每个表项管理 1MB 的虚拟地址块。
一级页表项有三种类型:
| 类型 | 描述 | 适用场景 |
|---|---|---|
| 段映射(Section) | 直接映射 1MB 物理内存 | 大块连续内存,如 DDR、外设寄存器区 |
| 粗页表(Coarse Page Table) | 指向二级页表,粒度 4KB | 需要细粒度控制的区域 |
| 细页表(Fine Page Table) | 指向二级页表,粒度 1KB | 极少使用,兼容旧设计 |
实际项目中,我大部分区域都用段映射。为什么呢?简单、快。一级页表直接查一次就能得到物理地址,不需要再查二级页表。只有那些需要按页保护的内存区域,比如进程的堆栈、代码段,我才会用二级页表。
来看一个段映射表项的配置示例:
/* 一级页表段映射表项格式:
* bit[31:20] : 物理基地址(1MB 对齐)
* bit[19] : 不可执行(XN)
* bit[18] : 域(Domain)
* bit[17:16] : 域编号
* bit[15:14] : 缓冲/缓存策略
* bit[13] : 可共享(Shareable)
* bit[12] : 访问权限(AP[2])
* bit[11:10] : 访问权限(AP[1:0])
* bit[9] : 不可执行(XN)
* bit[8:5] : 域编号
* bit[4] : 应为 1(段映射标识)
* bit[3] : 缓存(C)
* bit[2] : 缓冲(B)
* bit[1:0] : 表项类型(0b10 表示段映射)
*/
#define SECTION_ENTRY(phys, domain, ap, cache) \
(((phys) & 0xFFF00000) | \
((domain) << 5) | \
((ap) << 10) | \
((cache) << 2) | \
0x02) /* 段映射标识 */
/* 配置 DDR 区域:物理地址 0x80000000,域 0,读写权限,写回缓存 */
uint32_t *l1_table = (uint32_t *)0x70000000; /* 页表放在 0x70000000 */
l1_table[0x800] = SECTION_ENTRY(0x80000000, 0, 0x3, 0x3);
4.3 二级页表的配置
二级页表也叫 L2 页表。每个二级页表包含 256 个表项,每个表项管理 4KB 的物理页。一级页表中的粗页表项指向这个二级页表的基地址。
二级页表项的类型更丰富:
- 大页(Large Page):64KB 粒度,适合帧缓冲等大块数据。
- 小页(Small Page):4KB 粒度,最常用。
- 极小页(Tiny Page):1KB 粒度,ARMv7 已废弃。
我项目中用得最多的是小页。每个小页表项的结构如下:
/* 二级页表小页表项格式:
* bit[31:12] : 物理页基地址(4KB 对齐)
* bit[11] : 不可执行(XN)
* bit[10:9] : 访问权限(AP[2:1])
* bit[8] : 可共享(Shareable)
* bit[7:6] : 访问权限(AP[0] + 扩展)
* bit[5] : 不可执行(XN)
* bit[4] : 应为 1(小页标识)
* bit[3] : 缓存(C)
* bit[2] : 缓冲(B)
* bit[1:0] : 表项类型(0b10 表示小页)
*/
#define SMALL_PAGE_ENTRY(phys, ap, cache) \
(((phys) & 0xFFFFF000) | \
((ap) << 4) | \
((cache) << 2) | \
0x02) /* 小页标识 */
/* 配置二级页表:虚拟地址 0x00000000 映射到物理 0x80000000 */
uint32_t *l2_table = (uint32_t *)0x70001000; /* 二级页表放在一级页表后面 */
l2_table[0] = SMALL_PAGE_ENTRY(0x80000000, 0x3, 0x3);
/* 在一级页表中配置粗页表项 */
#define COARSE_TABLE_ENTRY(l2_base, domain) \
(((l2_base) & 0xFFFFFC00) | \
((domain) << 5) | \
0x01) /* 粗页表标识 */
l1_table[0x000] = COARSE_TABLE_ENTRY(0x70001000, 0);
4.4 开启 MMU 的步骤
页表配好了,接下来就是开启 MMU。ARM 架构下,这一步在系统控制寄存器(SCTLR)里操作。我总结了一个标准流程:
- 失效 TLB:写 TLBIALL 寄存器,或者用 CP15 指令。
- 设置页表基地址:写 TTBR0,如果使用 2 级页表还要配 TTBCR。
- 配置域访问控制:写 DACR,把用到的域设成“客户”模式。
- 设置 SCTLR 的 M 位:将 SCTLR[0] 置 1,开启 MMU。
- 同步屏障:执行 DSB 和 ISB,确保后续指令在新页表下执行。
来看一段实际的汇编代码:
@ 开启 MMU 的汇编例程
@ R0 = 页表基地址(物理地址)
enable_mmu:
@ 1. 失效 TLB
MOV R1, #0
MCR p15, 0, R1, c8, c7, 0 @ TLBIALL
@ 2. 设置 TTBR0
MCR p15, 0, R0, c2, c0, 0 @ TTBR0
@ 3. 配置域访问:域 0 为客户模式
MOV R1, #0x1
MCR p15, 0, R1, c3, c0, 0 @ DACR
@ 4. 读取 SCTLR,设置 M 位
MRC p15, 0, R1, c1, c0, 0 @ 读 SCTLR
ORR R1, R1, #0x1 @ 设置 M 位
MCR p15, 0, R1, c1, c0, 0 @ 写 SCTLR
@ 5. 同步屏障
DSB
ISB
BX LR
4.5 虚拟地址到物理地址的映射策略
映射策略直接影响系统性能。我一般按以下原则分配:
| 内存区域 | 映射方式 | 缓存策略 | 说明 |
|---|---|---|---|
| DDR(代码+数据) | 段映射 | 写回(Write-Back) | 性能最优,适合大部分内存 |
| 外设寄存器 | 段映射 | 不缓存(Device) | 避免缓存导致寄存器读写异常 |
| 帧缓冲 | 段映射或大页 | 写回或写通 | 根据显示控制器要求调整 |
| 进程私有数据 | 二级页表(小页) | 写回 | 需要按页保护时使用 |
外设寄存器这块,我吃过亏。有一次把 UART 寄存器映射成写回缓存,结果发送数据时,数据一直留在 Cache 里,串口就是不输出。查了两天才发现是缓存策略的问题。所以,外设寄存器一定要用 Device 类型,或者至少用非缓存映射。
4.6 页表初始化流程图
下面这张图是我自己总结的页表初始化流程,每次做新板子都照着走一遍:
4.7 调试与验证
MMU 初始化完成后,怎么确认它工作正常?我一般做三件事:
- 读 TTBR0 寄存器:确认页表基地址写入正确。
- 访问一个已知的虚拟地址:比如把某个 LED 的 GPIO 寄存器映射到虚拟地址,写个值看灯亮不亮。
- 检查异常状态:如果发生 Data Abort 或 Prefetch Abort,查 DFSR/IFSR 寄存器,看是不是页表项配错了。
好了,MMU 和页表初始化就讲到这里。记住一个核心原则:页表是 MMU 的地图,地图错了,CPU 就会迷路。配页表时多花点心思,后面调试能省下大把时间。