4、ARM Cortex-A启动详解:异常向量表、MMU与Cache初始化、DDR初始化、跳转到Linux内核
好,咱们进入第四章节。前面几章我们把启动ROM、SPL这些概念理清了,现在要真正面对Cortex-A内核的硬核初始化流程。说实话,这部分是Bootloader里最考验功底的地方,也是我当年调试时踩坑最多的环节。
ARM Cortex-A的启动,说白了就是给CPU搭好一个能跑Linux的舞台。这个舞台需要四根柱子:异常处理机制、内存管理单元、高速缓存、以及足够大的DDR空间。缺一根,Linux都站不稳。
核心要点:Cortex-A启动四步走——异常向量表定位、MMU+Cache初始化、DDR控制器配置、最后跳转到内核入口。顺序不能乱,一步错步步错。
4.1 异常向量表——CPU的紧急联络簿
异常向量表是什么?你可以把它想象成CPU的紧急联络簿。当发生中断、数据中止、未定义指令这些异常时,CPU会立刻查这个表,跳转到对应的处理函数。
Cortex-A系列默认把向量表放在地址0x00000000。但问题来了——我们的Bootloader可能运行在别的地址,比如0x20000000。如果不搬移向量表,异常一来CPU就跳到错误的地方去了。
我曾经踩过的坑:有一次调试板子,发现使能MMU后系统立刻崩溃。查了两天才发现,向量表还在0x0,但MMU已经把0x0映射到了别的物理地址。异常发生时CPU去0x0取向量,取到的全是垃圾指令。
正确的做法是:在MMU使能之前,先把向量表复制到当前运行地址,然后设置VBAR(Vector Base Address Register)。
/* 汇编示例:重定位异常向量表 */
ldr r0, =_start /* 当前代码起始地址 */
mcr p15, 0, r0, c12, c0, 0 /* 设置VBAR = _start */
isb /* 指令同步屏障 */
嗯,这里要注意:VBAR寄存器只在Cortex-A系列中可用(ARMv7-A及以上)。老架构比如ARM926EJ-S没有VBAR,向量表必须固定在0x0,那就得用MMU把0x0映射到当前代码段。
4.2 MMU与Cache初始化——给CPU装上导航和加速器
MMU(内存管理单元)和Cache是Cortex-A性能的核心。没有MMU,Linux跑不起来;没有Cache,Linux跑得比乌龟还慢。
我个人习惯把MMU初始化分成三步走:
- 建立页表——在内存中构建一级或二级页表,定义虚拟地址到物理地址的映射关系。
- 设置TTB(Translation Table Base)——告诉MMU页表放在哪里。
- 使能MMU和Cache——修改系统控制寄存器,打开对应的功能位。
| 寄存器 | 功能 | 关键位 |
|---|---|---|
| SCTLR (c1) | 系统控制寄存器 | M位[0]: MMU使能;C位[2]: DCache使能;I位[12]: ICache使能 |
| TTBR0 (c2) | 一级页表基址寄存器 | 存储页表物理地址,低14位必须为0(16字节对齐) |
| DACR (c3) | 域访问控制寄存器 | 每个域2位:00=无访问,01=客户,10=保留,11=管理者 |
小技巧:在使能MMU之前,先把ICache和DCache都关掉,等MMU稳定后再逐个打开。我见过有人同时使能MMU和DCache,结果因为Cache中残留了旧数据,系统直接挂掉。
页表怎么建?最简单的方式是用一级页表(Section映射),每个条目映射1MB空间。对于1GB的DDR,只需要1024个条目,占4KB内存。代码大概长这样:
/* 建立一级页表:将0x80000000~0x8FFFFFFF映射到自身 */
ldr r0, =0x80000000 /* 物理地址 */
ldr r1, =0x80000C0E /* Section描述符:可读可写、可执行、Cacheable */
str r1, [r0, r1, lsr #18] /* 写入页表项 */
你想想看,如果不用MMU,Linux内核怎么管理进程地址空间?每个进程都以为自己独占4GB空间,这全靠MMU的虚拟地址映射来实现。
4.3 DDR初始化——让系统有大房子住
DDR初始化是Bootloader里最敏感的一步。为什么?因为DDR控制器需要精确的时序参数,而这些参数跟硬件布线、颗粒型号、运行频率都有关。
我记得有一次,板子上的DDR走线长了2mm,导致DDR3在533MHz下频繁报错。最后不得不降频到400MHz才稳定。所以说,DDR初始化代码最好从原厂或参考设计中拿,自己手写风险很大。
典型的DDR初始化流程:
- 步骤1:配置DDR控制器寄存器——设置行列地址宽度、Burst长度、CAS延迟等。
- 步骤2:发送ZQ校准命令——DDR3/DDR4需要阻抗校准。
- 步骤3:等待DDR PLL锁定——时钟稳定后才能访问。
- 步骤4:执行写电平训练(Write Leveling)——调整DQS与时钟的相位关系。
- 步骤5:进行读写测试——验证DDR是否正常工作。
避坑指南:DDR初始化完成后,一定要做读写测试。我习惯写一个简单的模式测试:先写0xAA55AA55,再写0x55AA55AA,然后读回来比对。如果出错,说明DDR时序有问题,需要调整ODT(片上端接)或驱动强度。
4.4 跳转到Linux内核——最后的交接
所有初始化都完成后,最后一步就是把控制权交给Linux内核。这一步看起来简单,但有几个细节必须注意。
Linux内核期望的启动环境:
- MMU必须关闭(内核会重新建立自己的页表)
- DCache必须关闭(ICache可以开着)
- CPU必须处于SVC模式
- r0 = 0,r1 = 机器类型ID,r2 = 设备树(DTB)地址
/* 跳转到Linux内核的汇编代码 */
mov r0, #0 /* r0 = 0 */
ldr r1, =MACH_TYPE /* r1 = 机器类型ID */
ldr r2, =dtb_addr /* r2 = DTB物理地址 */
mov r3, #0 /* r3 = 0(保留) */
ldr r4, =kernel_addr /* r4 = 内核入口地址 */
bx r4 /* 跳转,不再返回 */
我曾经犯过的错:跳转前忘了关DCache,结果内核启动时访问的某些数据还在Cache里,物理内存中根本没有。内核读到的全是脏数据,启动到一半就panic了。从那以后,我每次跳转前都会执行一遍:mcr p15, 0, r0, c7, c10, 4(DSB)和mcr p15, 0, r0, c7, c5, 0(ICIALLU)。
还有一个容易被忽略的点:设备树地址(r2)必须保证在内核解压过程中不会被覆盖。如果内核是压缩的(zImage),它会自解压到某个地址,这个地址可能与DTB地址冲突。解决办法是把DTB放在内存的高地址区域,或者在内核解压完成后重新读取。
好了,这一章的内容就这些。异常向量表、MMU/Cache、DDR、跳转内核——这四个环节环环相扣,任何一个出问题,板子都起不来。调试的时候建议逐个环节验证,不要一口气全使能,否则出了问题根本不知道是哪里崩的。