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控制器配置、最后跳转到内核入口。顺序不能乱,一步错步步错。

Cortex-A Bootloader 启动四阶段 异常向量表 基址重定位 MMU + Cache 页表/使能 DDR 初始化 时序/校准 跳转 Linux ⚠ 关键依赖关系 • 异常向量表必须在MMU使能前完成重定位,否则取指异常 • MMU初始化需要先建立页表,页表通常放在SRAM或已初始化的DDR中 • DDR初始化必须在跳转前完成,因为Linux内核需要运行在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初始化分成三步走:

  1. 建立页表——在内存中构建一级或二级页表,定义虚拟地址到物理地址的映射关系。
  2. 设置TTB(Translation Table Base)——告诉MMU页表放在哪里。
  3. 使能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、跳转内核——这四个环节环环相扣,任何一个出问题,板子都起不来。调试的时候建议逐个环节验证,不要一口气全使能,否则出了问题根本不知道是哪里崩的。