4. 启动引导中的内存初始化

好,咱们进入正题。MMU 这东西,说白了就是个地址翻译器。CPU 发出的地址是虚拟的,MMU 负责查页表,把它变成物理地址,然后才去访问内存。你想想看,如果没有 MMU,所有程序都直接操作物理地址,那系统还怎么隔离?怎么跑多任务?

我刚开始做嵌入式 Linux 移植时,最头疼的就是这步。板子一上电,串口打印停在某个地址访问异常,查了半天发现是页表没配好。嗯,从那以后我养成了一个习惯——先把 MMU 关掉,把页表在物理内存里搭好,再统一开启。这个顺序,千万别搞反。

4.1 MMU 开启前的准备工作

开启 MMU 之前,有几件事必须确认。我个人习惯列个清单,逐项打勾:

  • 页表基地址已写入 TTBR0/TTBR1——ARM 架构下,页表必须放在物理地址连续的区域内。
  • 域访问控制寄存器(DACR)已配置——每个域可以设成“无访问”“仅管理”“客户”等权限。
  • TLB 已失效——上电后 TLB 里是随机值,必须先清空。
  • ICache/DCache 处于已知状态——我建议先关掉 Cache,等页表稳定后再开。
⚠ 我曾经踩过的坑: 在 MMU 开启瞬间,CPU 会立即使用新页表。如果页表所在的物理内存还没初始化好,或者 TLB 里残留了无效项,轻则异常,重则死机。所以,页表必须放在已初始化的物理内存中,并且要保证这段内存不会被其他操作覆盖。

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);
💡 我的习惯: 页表基地址尽量放在 DDR 的起始位置附近,但不要放在 0x0 地址。0x0 通常留给异常向量表或 ROM。我一般选在 DDR 偏移 16MB 的位置,既不会和启动代码冲突,也方便调试时查看。

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)里操作。我总结了一个标准流程:

  1. 失效 TLB:写 TLBIALL 寄存器,或者用 CP15 指令。
  2. 设置页表基地址:写 TTBR0,如果使用 2 级页表还要配 TTBCR。
  3. 配置域访问控制:写 DACR,把用到的域设成“客户”模式。
  4. 设置 SCTLR 的 M 位:将 SCTLR[0] 置 1,开启 MMU。
  5. 同步屏障:执行 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
🔑 关键点: 开启 MMU 后,CPU 立即使用新页表。所以页表本身必须映射到虚拟地址空间,否则开启后 CPU 就找不到页表了。我常用的做法是恒等映射——让虚拟地址等于物理地址,这样页表自身也能被访问。

4.5 虚拟地址到物理地址的映射策略

映射策略直接影响系统性能。我一般按以下原则分配:

内存区域 映射方式 缓存策略 说明
DDR(代码+数据) 段映射 写回(Write-Back) 性能最优,适合大部分内存
外设寄存器 段映射 不缓存(Device) 避免缓存导致寄存器读写异常
帧缓冲 段映射或大页 写回或写通 根据显示控制器要求调整
进程私有数据 二级页表(小页) 写回 需要按页保护时使用

外设寄存器这块,我吃过亏。有一次把 UART 寄存器映射成写回缓存,结果发送数据时,数据一直留在 Cache 里,串口就是不输出。查了两天才发现是缓存策略的问题。所以,外设寄存器一定要用 Device 类型,或者至少用非缓存映射。

4.6 页表初始化流程图

下面这张图是我自己总结的页表初始化流程,每次做新板子都照着走一遍:

MMU 与页表初始化流程 MMU 与页表初始化流程 1. 准备页表物理内存 2. 配置一级页表(L1) 需要二级页表? 是 3. 配置二级页表(L2) 否 4. 使用段映射 5. 失效 TLB + 设置 TTBR 6. 设置 SCTLR.M 开启 MMU ✅ MMU 初始化完成 ⚠ 开启后立即生效 页表必须恒等映射

4.7 调试与验证

MMU 初始化完成后,怎么确认它工作正常?我一般做三件事:

  1. 读 TTBR0 寄存器:确认页表基地址写入正确。
  2. 访问一个已知的虚拟地址:比如把某个 LED 的 GPIO 寄存器映射到虚拟地址,写个值看灯亮不亮。
  3. 检查异常状态:如果发生 Data Abort 或 Prefetch Abort,查 DFSR/IFSR 寄存器,看是不是页表项配错了。
💡 调试小技巧: 我习惯在页表初始化代码里加一段自检逻辑——遍历一级页表,检查每个段映射表项的物理地址是否在 DDR 范围内。如果发现异常地址,直接打印错误并停机。这个习惯帮我抓到了好几次因链接脚本配置错误导致的页表越界问题。

好了,MMU 和页表初始化就讲到这里。记住一个核心原则:页表是 MMU 的地图,地图错了,CPU 就会迷路。配页表时多花点心思,后面调试能省下大把时间。