第四章 ARM Cortex-A启动流程:异常向量表、SVC模式、MMU与Cache初始化、跳转到Linux内核

好,咱们接着往下聊。上一章我们把汇编基础打牢了,这一章要动真格的了——真正把Bootloader跑起来。

ARM Cortex-A的启动流程,说白了就是一条从复位到Linux内核的必经之路。我做了这么多年底层开发,发现很多新手栽就栽在这几个关键点上:异常向量表放错位置、MMU配置把系统搞挂、Cache没处理好导致数据不一致。嗯,咱们一个一个来拆解。

4.1 异常向量表:系统的“紧急联络簿”

异常向量表是什么?你可以把它想象成一张表格,CPU遇到各种异常时,会自动跳到这里查“该找谁处理”。

我个人习惯把异常向量表放在0x00000000或者0xFFFF0000这两个地址。为什么?因为ARM硬件设计就这么规定的——复位后CPU强制从0x00000000开始取指令。

核心要点:异常向量表必须4字节对齐,每个表项是一条跳转指令(B或LDR)。

来看一个典型的向量表代码:

@ 异常向量表,放在代码段最开头
.section .vectors, "ax"
.globl _start
_start:
    b   reset_handler        @ 0x00: 复位
    b   undef_handler        @ 0x04: 未定义指令
    b   svc_handler          @ 0x08: 软件中断(SWI)
    b   prefetch_abort_handler @ 0x0C: 预取中止
    b   data_abort_handler   @ 0x10: 数据中止
    b   .                    @ 0x14: 保留
    b   irq_handler          @ 0x18: 普通中断
    b   fiq_handler          @ 0x1C: 快速中断

这里有个坑,我曾经踩过——向量表里每条指令占4字节,但B指令的跳转范围有限。如果你的处理函数离得太远,B指令跳不过去,那就出大事了。我建议用LDR PC, [PC, #offset]这种方式,或者直接放一个跳转表。

避坑指南:我曾经在一个项目里,因为链接脚本没配好,异常向量表被放到了0x8000开头的位置,结果CPU复位后直接跑飞。查了两天才发现是链接脚本的VMA设错了。记住:复位向量必须在链接脚本中指定为0x00000000

4.2 进入SVC模式:给CPU“换身份”

复位后CPU默认在什么模式?ARM架构规定,复位后CPU进入SVC模式(超级用户模式)。但有些情况下,你可能需要显式切换一下。

为什么要用SVC模式?说白了,Bootloader要干的事——配置时钟、初始化DDR、设置MMU——这些都需要最高权限。SVC模式就是那个“管理员账号”。

@ 确保进入SVC模式
reset_handler:
    @ 读取当前模式
    mrs     r0, cpsr
    @ 清除模式位,设置为SVC模式
    bic     r0, r0, #0x1F
    orr     r0, r0, #0x13    @ SVC模式,IRQ/FIQ使能
    msr     cpsr, r0

你想想看,如果这时候你还在User模式,连CPSR都改不了,那后面的硬件初始化根本没法做。所以第一步就是确认自己“有权限”。

我个人习惯在进入SVC模式后,顺手把栈指针也设好。因为C语言函数调用需要栈,而SVC模式有自己的栈指针(SP_svc)。

    @ 设置SVC模式的栈指针
    ldr     sp, =__svc_stack_top

小技巧:栈顶地址我一般放在DDR的高端地址,比如0x80000000附近。这样栈向下增长时,不会和代码段、数据段冲突。

4.3 MMU与Cache初始化:给CPU装上“导航系统”

MMU(内存管理单元)和Cache,这两个东西是Cortex-A性能的关键。但初始化顺序搞反了,系统直接死给你看。

我记得第一次做Cortex-A9的板子,上来就开了MMU,结果DDR还没初始化完,系统直接挂掉。后来才明白:先初始化DDR,再配置页表,最后开MMU

4.3.1 页表配置

ARM Cortex-A的MMU使用两级页表。一级页表叫L1页表,每个表项对应1MB的内存块。二级页表叫L2页表,可以细到4KB。

对于Bootloader来说,我建议直接用L1页表就够了。为什么?因为Bootloader不需要那么细的粒度,1MB的映射精度完全够用。

@ 一级页表项格式(简化版)
@ bit[1:0] = 0b10 表示段映射(Section)
@ bit[18:12] = 域(Domain)
@ bit[31:20] = 基地址(1MB对齐)

#define MMU_SECTION_ENTRY(base, flags) \
    ((base) | (flags))

@ 示例:映射0x00000000 - 0x00100000为设备内存
ldr     r0, =0x00000000
orr     r0, r0, #0xC06      @ 设备内存,可读写,不可Cache
str     r0, [r1, #0x000]    @ 写入页表

关键点:页表必须放在1MB对齐的地址上。我一般放在DDR的起始位置,比如0x80000000,然后页表从0x80004000开始(留出4KB给异常向量表)。

4.3.2 Cache初始化

Cache这东西,开得好性能翻倍,开不好数据全乱。我见过最典型的错误是:开了Cache但没做Invalidate

为什么?因为DDR上电后的初始状态是不确定的。Cache里可能残留着上电时的垃圾数据。如果你直接开Cache,CPU读到的可能是这些垃圾数据,而不是真正的内存内容。

@ 使能L1 Cache前,先Invalidate
    @ 使能L1 Cache前,先Invalidate
    mrc     p15, 0, r0, c1, c0, 0   @ 读取SCTLR
    bic     r0, r0, #0x0004          @ 关闭数据Cache
    bic     r0, r0, #0x1000          @ 关闭指令Cache
    mcr     p15, 0, r0, c1, c0, 0   @ 写入SCTLR
    dsb                             @ 数据同步屏障

    @ Invalidate整个L1 Cache
    mov     r0, #0
    mcr     p15, 0, r0, c7, c5, 0   @ 无效化指令Cache
    mcr     p15, 0, r0, c7, c6, 0   @ 无效化数据Cache
    mcr     p15, 0, r0, c7, c10, 4  @ 数据同步屏障
    dsb
    isb

嗯,这里要注意:Invalidate和Clean是两回事。Invalidate是把Cache标记为无效,Clean是把脏数据写回内存。Bootloader阶段一般只需要Invalidate,因为还没有脏数据。

4.3.3 开启MMU

页表配好了,Cache也Invalidate了,接下来就是开MMU。这一步很简单,但顺序不能错:

@ 开启MMU和Cache
    mrc     p15, 0, r0, c1, c0, 0   @ 读取SCTLR
    orr     r0, r0, #0x1            @ 使能MMU
    orr     r0, r0, #0x1000         @ 使能指令Cache
    orr     r0, r0, #0x0004         @ 使能数据Cache
    mcr     p15, 0, r0, c1, c0, 0   @ 写入SCTLR
    dsb
    isb

避坑指南:我曾经在一个项目里,开了MMU之后直接访问外设寄存器,结果系统卡死。查了半天才发现——外设寄存器的地址空间没有映射到页表里。记住:开MMU之前,确保所有要访问的地址都有对应的页表项。

4.4 跳转到Linux内核:最后的“接力棒”

好了,硬件初始化完了,MMU和Cache都开了,接下来就是把控制权交给Linux内核。这一步看似简单,但有几个硬性要求:

  1. CPU必须在SVC模式——Linux内核要求启动时处于SVC模式
  2. MMU必须关闭——等等,我刚才不是开了MMU吗?

对,这里有个矛盾:Bootloader开了MMU,但Linux内核要求启动时MMU关闭。怎么办?

我个人习惯的做法是:在跳转前关闭MMU和Cache,然后跳转到Linux内核的入口地址。Linux内核启动后会自己重新配置MMU。

@ 跳转到Linux内核
    @ 关闭MMU和Cache
    mrc     p15, 0, r0, c1, c0, 0
    bic     r0, r0, #0x1            @ 关闭MMU
    bic     r0, r0, #0x1000         @ 关闭指令Cache
    bic     r0, r0, #0x0004         @ 关闭数据Cache
    mcr     p15, 0, r0, c1, c0, 0
    dsb
    isb

    @ 设置参数并跳转
    mov     r0, #0                  @ 机器类型ID
    ldr     r1, =0x80000000         @ ATAGS或DTB地址
    ldr     r2, =0x00000000         @ 保留
    ldr     pc, =0x80008000         @ Linux内核入口地址

你想想看,为什么Linux内核要求MMU关闭?因为内核要重新建立自己的页表,如果Bootloader的页表还开着,两个页表会打架。所以跳转前必须把MMU关干净。

小技巧:跳转前记得把中断关了。我曾经因为没关中断,跳转到内核后一个未处理的中断触发,导致内核启动失败。加一句cpsid if就能解决。

4.5 总结与避坑清单

好了,这一章的内容就这些。我整理了一个避坑清单,你写代码时可以对照着检查:

步骤 常见错误 解决方案
异常向量表 向量表地址不对齐 确保4字节对齐,链接脚本指定VMA
SVC模式 忘记设置栈指针 进入SVC模式后立即设置SP_svc
MMU初始化 页表未覆盖所有地址 检查页表映射范围,包括外设地址
Cache初始化 未做Invalidate 开Cache前必须Invalidate整个Cache
跳转内核 MMU未关闭 跳转前关闭MMU和Cache

我个人觉得,ARM Cortex-A的启动流程就像一场接力赛。Bootloader是第一棒,Linux内核是第二棒。交接棒的时候,一定要把状态清理干净,不然下一棒跑不起来。

下一章我们会讲设备树(DTB)的解析和传递,这是Bootloader和内核之间最重要的“通信协议”。到时候见。