4. Bootloader核心概念
好,咱们今天聊点硬核的——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)。
翻译过程是这样的:
- CPU发出32位虚拟地址。
- 高12位(bits[31:20])作为索引,查一级页表,找到对应的L1表项。
- 如果L1表项是段映射,直接得到物理地址(高12位来自表项,低20位来自虚拟地址)。
- 如果L1表项指向二级页表,则用虚拟地址的bits[19:12]作为索引,查二级页表,得到L2表项。
- 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从虚拟地址到物理地址的完整路径画出来了。我建议你把它存下来,调试时对照着看。
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基本配置没问题。然后再逐步增加映射区域。这样一步步来,出问题了也知道是哪里出的。