4. ARM内存管理:MMU工作原理、页表结构、TLB与Cache、内存属性与域

好,咱们今天聊点硬核的——ARM的内存管理。说实话,我早年做裸机开发时,觉得MMU就是个累赘,直接操作物理地址多痛快。直到第一次把Linux往一块新板子上移植,系统跑起来就崩,崩得毫无规律……查了三天,最后发现是Cache没配好,数据一致性问题。从那以后,我再也不敢小看MMU了。

你想想看,现代ARM芯片动不动就上GHz,外设却还跑在几十兆赫兹的总线上。没有MMU和Cache,CPU得活活等死。所以这一章,咱们把MMU的底裤扒干净。

4.1 MMU到底在干什么?

MMU全称Memory Management Unit,说白了就是个地址翻译器。CPU发出的地址叫虚拟地址(VA),经过MMU翻译成物理地址(PA),然后才去访问内存或外设。

为什么要多此一举?三个核心原因:

  • 隔离:每个进程都有自己的虚拟地址空间,谁也碰不到谁。A进程的0x8000和B进程的0x8000,物理上根本不是同一个地方。
  • 简化:应用程序不用管物理内存有多大、碎片有多严重,它看到的就是一个连续的、从0开始的地址空间。
  • 保护:通过权限位,可以禁止用户态代码访问内核空间,或者禁止执行栈上的代码。

核心概念:MMU以“页”为单位进行映射。ARM常用的页大小有4KB、64KB、1MB、16MB。页越小,粒度越细,但页表也越大;页越大,页表越小,但可能浪费空间。我一般建议:代码段用1MB大页,数据段用4KB小页,兼顾性能和灵活性。

4.2 页表结构——两级还是三级?

ARM的页表结构分两种:经典的两级页表(ARMv7之前)和LPAE(Large Physical Address Extension)的三级页表(ARMv7-A之后,Cortex-A系列)。

咱们以最常用的ARMv7-A两级页表为例:

  • 一级页表(L1):也叫页表目录。每个表项(PTE)覆盖1MB地址空间。整个4GB地址空间需要4096个表项,每个表项4字节,总共16KB。一级表项可以指向一个1MB的段(Section),也可以指向一个二级页表。
  • 二级页表(L2):每个二级页表覆盖1MB地址空间,包含256个表项(如果是4KB小页),每个表项4字节,总共1KB。二级表项可以指向4KB的小页(Page),也可以指向64KB的大页(Large Page)。

翻译过程是这样的:

  1. CPU发出32位虚拟地址。
  2. 高12位(bits[31:20])作为索引,查一级页表,找到对应的L1表项。
  3. 如果L1表项是段映射,直接得到物理地址(高12位来自表项,低20位来自虚拟地址)。
  4. 如果L1表项指向二级页表,则用虚拟地址的bits[19:12]作为索引,查二级页表,得到L2表项。
  5. L2表项给出物理页帧号(PFN),加上虚拟地址的低12位(页内偏移),得到最终物理地址。

我的经验:调试页表问题时,我习惯在QEMU里先搭一个最小系统,用单级段映射(只用L1,不用L2)把MMU跑通。这样问题范围小,出错了也容易定位。等段映射稳定了,再切到两级页表。别一上来就搞复杂配置,容易把自己绕进去。

4.3 TLB与Cache——性能的命根子

每次地址翻译都去查页表?那性能就完蛋了。所以ARM引入了TLB(Translation Lookaside Buffer),说白了就是页表的硬件缓存。

TLB里存的是最近用过的虚拟地址到物理地址的映射关系。CPU发一个虚拟地址,先查TLB,命中就直接拿到物理地址,不命中才去走页表查内存。TLB命中率通常能达到99%以上,没有它,MMU就是个摆设。

Cache呢?Cache缓存的是实际的数据和指令。ARM的Cache分I-Cache(指令缓存)和D-Cache(数据缓存)。I-Cache只缓存指令,D-Cache缓存数据。还有L1、L2、L3的层级结构。

这里有个大坑——Cache一致性问题。我当年就栽在这上面。

我曾经在调试一个DMA驱动时,CPU往内存里写了一段数据,然后启动DMA把数据搬走。结果DMA搬出来的全是错的。查了两天,发现CPU写的数据还在D-Cache里,没刷回内存。DMA直接读内存,读到的当然是旧数据。解决方案:在启动DMA之前,调用clean_dcache_range()把相关Cache行刷回内存。

ARM的Cache操作通常需要特权模式,通过CP15协处理器指令来完成:

// 使整个D-Cache失效(invalidate)
MRC p15, 0, r0, c1, c0, 0   // 读SCTLR
BIC r0, r0, #0x4             // 清C位(关闭D-Cache)
MCR p15, 0, r0, c1, c0, 0   // 写SCTLR
// 然后执行一系列invalidate操作
// 最后再重新使能D-Cache

嗯,这里要注意:不同ARM核的Cache行大小可能不一样。Cortex-A9是32字节,Cortex-A15是64字节。操作Cache时一定要查TRM(技术参考手册),别想当然。

4.4 内存属性与域——精细控制每一块内存

ARM允许你给不同的内存区域设置不同的属性。这些属性定义在页表项里,包括:

属性 说明 典型用途
XN(Execute Never) 禁止执行该区域代码 数据段、栈、堆
Domain(域) 将内存划分为16个域,每个域有独立的访问权限 内核空间与用户空间隔离
Cacheable 是否允许Cache缓存 普通内存可缓存,外设寄存器不可缓存
Bufferable 是否允许写缓冲 普通内存可缓冲,外设寄存器通常不可缓冲
AP(Access Permission) 读写权限:只读、读写、只允许特权访问等 保护关键系统数据结构
Shareable 是否在多核间共享 多核通信的内存区域必须设为Shareable

域(Domain)这个概念,我刚开始学的时候觉得挺绕的。简单说:ARM有16个域,每个域有一个访问控制寄存器(DACR),可以设置三种权限:

  • 00:无访问权限(访问会触发Domain Fault)
  • 01:客户权限(由页表项的AP位决定具体读写权限)
  • 11:管理者权限(不检查AP位,完全可读写)

实际项目中,我通常把内核空间放在Domain 0,设为管理者权限;用户空间放在Domain 1,设为客户权限。这样内核可以访问任何地方,用户进程只能访问自己的空间。简单粗暴,但有效。

避坑指南:外设寄存器的内存属性一定要配成非Cache、非Buffer、非Shareable。否则,CPU读一个状态寄存器,可能读到的是Cache里的旧值,而不是外设的真实状态。我见过有人配错了,结果UART轮询永远读不到数据,因为D-Cache一直返回旧值。

4.5 一张图看懂MMU整体流程

下面这张SVG图,把MMU从虚拟地址到物理地址的完整路径画出来了。我建议你把它存下来,调试时对照着看。

ARM MMU 地址翻译与缓存流程 ARM MMU 地址翻译与缓存流程 CPU 核心 虚拟地址 (VA) TLB (地址翻译缓存) 命中 未命中 页表查询 (查内存) 回填TLB 物理地址 (PA) Cache (I-Cache / D-Cache) 命中 未命中 主存 (DRAM) 物理内存 内存属性检查 Cacheable / Bufferable XN / Domain / AP 同时检查属性 根据属性决定是否缓存 域访问控制 DACR 寄存器 域检查在翻译前进行 关键路径 ① CPU发出VA ② 查TLB(命中则快速) ③ 未命中则查页表 ④ 检查域和属性 ⑤ 访问Cache或内存

4.6 实战要点总结

最后,我把这些年调试MMU踩过的坑,总结成几条实战要点:

  • 启动MMU前,先配好页表:页表必须在物理内存中连续存放,且地址对齐到16KB边界(一级页表)。我习惯在链接脚本里单独划出一块区域放页表。
  • 使能MMU时,关中断:MMU开关的瞬间,地址空间会剧烈变化。如果此时来了中断,中断向量表可能找不到。我一般先关全局中断,使能MMU,然后立刻跳转到虚拟地址空间,再开中断。
  • 外设区域一定要配成strongly-ordered或device类型:这种类型禁止编译器和硬件重排访问顺序,保证读写外设寄存器的顺序和代码一致。
  • 多核系统注意TLB广播:在一个核上修改了页表,其他核的TLB里可能还有旧映射。需要发送TLB无效化广播(TLBIALL)或使用IPI让其他核刷TLB。
  • 调试时打开MMU的异常报告:ARM的MMU能产生多种异常(Translation Fault、Domain Fault、Permission Fault等)。在异常向量表里加上这些异常的处理函数,打印出出错的虚拟地址和原因,调试效率翻倍。

我的习惯:每次移植到一个新平台,我会先写一个最小的MMU测试程序:只映射两页内存,一页是DDR,一页是UART。然后通过UART打印"Hello MMU"。如果这一步能跑通,说明MMU基本配置没问题。然后再逐步增加映射区域。这样一步步来,出问题了也知道是哪里出的。