2. ARMv8架构基础

好,咱们正式开始啃 MMU 这块硬骨头。很多做嵌入式开发的朋友,一听到 MMU 就觉得头大,觉得那是跑 Linux 才需要操心的东西。其实不然,裸机程序、RTOS 里,MMU 同样能帮你做很多事情。今天这一章,咱们就把 MMU 最核心的几个概念掰开揉碎了讲清楚。

2.1 虚拟地址与物理地址:一个“骗术”的开始

说白了,MMU 干的事就是一个“地址翻译”的活儿。CPU 执行指令时,拿到的地址是 虚拟地址(VA,Virtual Address),经过 MMU 硬件翻译之后,才变成真正去访问内存、外设寄存器的 物理地址(PA,Physical Address)。

你想想看,为什么要有这么一层“欺骗”?

  • 隔离性:每个进程/任务都以为自己独占整个地址空间,互不干扰。A 进程的 0x1000 和 B 进程的 0x1000,翻译后可能是完全不同的物理地址。
  • 安全性:用户态程序无法直接访问内核空间,MMU 在翻译时会检查权限。
  • 简化链接:链接脚本里可以统一用虚拟地址,不用关心物理内存到底在哪儿。

核心公式: CPU 执行 → 虚拟地址 (VA) → MMU 查表 → 物理地址 (PA) → 访问内存

我在做早期一个 Cortex-A7 的裸机项目时,就犯过傻。直接往某个物理地址写数据,结果程序跑飞了。后来才发现,MMU 默认是开启的,我写的那个地址被映射到了别处。嗯,从那以后,我每次上电第一件事就是确认 MMU 的状态。

2.2 页表与页表项:翻译的“字典”

MMU 怎么知道 VA 对应哪个 PA?靠的就是 页表(Page Table)。页表是一张存放在内存里的表格,每一行就是一个 页表项(PTE,Page Table Entry)。

一个典型的页表项里,包含了这些关键信息:

字段 说明
物理地址基址 映射到的物理页框起始地址
访问权限 读、写、执行、用户/特权级
属性位 可缓存、可共享、非安全等
有效位 该映射是否有效

ARMv8 的页表是分级的,最常见的是 4 级页表(L0, L1, L2, L3)。为什么要分级?

  • 节省内存:如果只用一级页表,4GB 地址空间用 4KB 粒度,需要 100 多万个页表项,每个 8 字节,光页表就占 8MB 内存。分级后,只有真正用到的地址范围才会创建页表。
  • 支持大块映射:L1 页表项可以直接映射 1GB 的块,L2 可以映射 2MB 的块,L3 才是 4KB 的页。这样,像 DDR 这种大块连续内存,一个页表项就搞定了。

我的习惯: 在调试 MMU 问题时,我通常会先打印出当前使用的页表基地址寄存器(TTBR0_EL1),然后手动解析几个关键虚拟地址对应的页表项,看看物理地址对不对、权限对不对。这比光看代码靠谱多了。

2.3 转换粒度:4K / 16K / 64K

ARMv8 支持三种页面大小,也就是 转换粒度(Translation Granule):

  • 4KB:最常用,兼容性好,Linux 默认就是它。粒度小,内存利用率高,但页表层级多(4级),TLB 压力大。
  • 16KB:介于两者之间,某些场景下可以减少页表层级。
  • 64KB:粒度大,页表层级少(2级或3级),TLB 可以覆盖更大的内存范围。适合大内存、高性能计算场景。
粒度 页表级数 最大映射范围(单级) 典型场景
4KB 4 (L0-L3) L1: 1GB, L2: 2MB, L3: 4KB 通用 Linux, RTOS
16KB 3 (L0-L2) L1: 2GB, L2: 32MB, L3: 16KB 存储服务器
64KB 2 (L0-L1) L0: 512GB, L1: 64KB 高性能计算, GPU

注意: 转换粒度是在系统启动时通过 TCR_EL1 寄存器配置的,一旦设定,整个系统的页表结构就固定了。你不能在运行中随意切换。我曾经在一个项目中,因为启动代码里 TCR 配置错了,导致 4KB 粒度的页表被当成了 64KB 来解析,结果 MMU 一开就挂,查了整整两天。

2.4 地址空间:32位与64位的差异

ARMv8 支持两种执行状态:AArch32 和 AArch64。它们的地址空间差别很大。

  • AArch32:虚拟地址空间最大 4GB(32位)。通常分为用户空间(0x00000000 - 0xBFFFFFFF)和内核空间(0xC0000000 - 0xFFFFFFFF),这是 Linux 的经典 3:1 划分。
  • AArch64:虚拟地址空间理论上是 2^64,但实际实现中,ARMv8 通常只使用低 48 位(256TB)或 52 位(4PB)。地址空间被分为两个区域:低地址区域(用户空间)和高地址区域(内核空间),中间有一大段“空洞”。

为什么要有空洞?说白了,是为了兼容性和未来扩展。你想想看,如果现在就把 64 位地址全用上,那页表得多大?硬件也扛不住。

下面这张图,是我自己画的一个 AArch64 地址空间布局,帮你直观理解:

AArch64 虚拟地址空间布局(48位) 内核空间 (Kernel Space) 0xFFFF0000_00000000 - 0xFFFFFFFF_FFFFFFFF 大小: 256 TB 空洞 (Non-canonical) 0x00008000_00000000 - 0xFFFF7FFF_FFFFFFFF 用户空间 (User Space) 0x00000000_00000000 - 0x00007FFF_FFFFFFFF 大小: 256 TB 高地址 低地址 说明:用户空间和内核空间之间有一大段不可访问的“空洞” 任何访问空洞地址的操作都会触发 MMU 缺页异常

在实际项目中,我建议你根据芯片的 物理内存大小 和 外设地址映射 来规划虚拟地址空间。比如,你的芯片只有 512MB DDR,那虚拟地址空间完全没必要映射到 256TB,只需要映射实际用到的部分就行,这样可以大大减小页表占用的内存。

避坑指南: 我曾经在一个项目里,因为把外设寄存器的物理地址映射到了用户空间,并且忘记设置“非缓存”属性,结果 CPU 读到的寄存器值永远是旧数据。后来加上 MT_DEVICE_nGnRnE 属性才解决。记住:外设寄存器一定要用设备内存属性,不能用普通内存属性。

好了,这一章的概念比较多,但都是后面配置 MMU 的基础。你只要把“虚拟地址 vs 物理地址”、“页表结构”、“粒度选择”和“地址空间布局”这四个点吃透,后面写代码就会顺畅很多。