4. 汇编级启动代码:ARM/Thumb指令集切换、中断向量表设置、MMU与Cache的早期配置

好,咱们进入Bootloader最底层的部分——汇编级启动。这部分代码,说白了就是CPU上电后执行的第一口奶。它要是写错了,后面C语言写得再漂亮也白搭。我这些年调试过不少启动失败的问题,十有八九都跟这几个环节有关:指令集切错了、向量表放歪了、或者MMU把内存映射搞砸了。

4.1 ARM/Thumb指令集切换:别让CPU“听不懂”你的代码

ARM处理器支持两种主要指令集:ARM(32位指令)和Thumb(16位指令)。Thumb的代码密度高,但功能弱一些;ARM指令功能强,但占空间。现代Bootloader经常混合使用——关键路径用ARM,普通路径用Thumb。

切换指令集的核心,靠的是BX或BLX指令。它们会根据目标地址的最低位(LSB)来决定切换目标:

  • LSB = 0:切换到ARM状态
  • LSB = 1:切换到Thumb状态

关键点:地址的LSB并不是真正的地址位,它只是一个标志位。CPU在执行跳转时会自动将其屏蔽,只取高31位作为实际地址。

来看一个典型的切换示例:

@ 从ARM切换到Thumb
_start:
    .arm                    @ 当前是ARM指令
    ldr r0, =thumb_func
    bx r0                   @ 跳转,r0最低位为1 → 切换到Thumb

.thumb
thumb_func:
    mov r1, #0x55
    bx lr                   @ 返回时自动切回ARM

我在项目中遇到过一个问题:某款芯片的BootROM用Thumb指令启动,但我们的Bootloader第一行代码是ARM指令。结果CPU一跑就崩。查了半天才发现——启动后CPU默认状态是ARM,但ROM里的跳转地址LSB被设成了1,导致CPU以为自己该切Thumb,实际却执行了ARM指令。嗯,这种“指令集错配”的坑,踩一次就记住了。

我曾经踩过的坑:在GCC汇编器中,.arm和.thumb伪指令只影响当前段的编码方式。如果你在同一个函数里来回切换,一定要确保所有标签的地址对齐正确。否则BX跳转时LSB算错,CPU直接跑飞。

4.2 中断向量表设置:CPU的“紧急通讯录”

中断向量表,就是CPU在发生异常时查找处理函数地址的表格。ARM的向量表通常放在地址0x00000000(或通过VBAR寄存器重映射的位置)。

标准的向量表布局如下:

偏移地址 异常类型 优先级 说明
0x00 Reset 1(最高) 上电/复位入口
0x04 Undefined Instruction 6 未定义指令异常
0x08 Software Interrupt (SWI) 6 系统调用
0x0C Prefetch Abort 5 指令预取异常
0x10 Data Abort 2 数据访问异常
0x14 Reserved — 保留
0x18 IRQ 4 普通中断
0x1C FIQ 3 快速中断

每个表项存放的是一条跳转指令(通常是B或LDR PC),而不是函数地址本身。为什么?因为向量表每个表项只有4字节,放不下完整的函数地址跳转逻辑。

我习惯这样写向量表:

.section .vectors, "ax"
.global _start

_start:
    b   reset_handler        @ 0x00: Reset
    b   undef_handler        @ 0x04: Undefined Instruction
    b   swi_handler          @ 0x08: Software Interrupt
    b   prefetch_abort_handler @ 0x0C: Prefetch Abort
    b   data_abort_handler   @ 0x10: Data Abort
    nop                      @ 0x14: Reserved
    b   irq_handler          @ 0x18: IRQ
    b   fiq_handler          @ 0x1C: FIQ

小技巧:如果芯片支持VBAR(Vector Base Address Register),建议把向量表重映射到SRAM中。这样可以在运行时动态修改中断处理函数,调试起来方便很多。我一般把向量表放在SRAM开头,VBAR指向那里。

4.3 MMU与Cache的早期配置:别让性能拖后腿

Bootloader早期,MMU通常是关闭的。但到了第二阶段(比如加载内核之前),我们往往需要打开MMU和Cache来加速内存访问。不过,早期配置MMU是个精细活——配错了直接死机。

MMU的核心是页表。ARMv7-A架构下,我们常用一级页表(段映射)来做早期配置。每个段映射1MB空间,一个一级页表只需要16KB(4096个表项,每个4字节)。

典型的配置步骤:

  1. 关闭MMU和Cache:操作SCTLR寄存器,清掉M、C、I位
  2. 建立页表:在内存中分配一块16KB区域,填充段描述符
  3. 设置TTBR0:把页表基地址写入TTBR0寄存器
  4. 使能MMU:设置SCTLR的M位,同时做DSB和ISB同步

来看一段实际的页表初始化代码:

@ 建立一级页表(段映射)
@ r0 = 页表基地址
@ r1 = 要映射的物理地址
@ r2 = 要映射的虚拟地址
@ r3 = 属性(可缓存、可共享等)

setup_page_table:
    ldr r0, =page_table_base
    mov r1, #0x0000          @ 物理地址0x00000000
    mov r2, #0x0000          @ 虚拟地址0x00000000
    mov r3, #0x0C0E          @ 段描述符:可读可写,cacheable, bufferable

    @ 填充4096个表项,每个1MB
    mov r4, #4096
1:
    str r3, [r0], #4         @ 写入描述符,地址+4
    add r3, r3, #0x100000    @ 下一个1MB的物理地址
    subs r4, r4, #1
    bne 1b

    @ 设置TTBR0
    ldr r0, =page_table_base
    mcr p15, 0, r0, c2, c0, 0   @ TTBR0 = 页表基地址

    @ 使能MMU
    mrc p15, 0, r0, c1, c0, 0   @ 读取SCTLR
    orr r0, r0, #0x1             @ 设置M位(MMU使能)
    mcr p15, 0, r0, c1, c0, 0   @ 写入SCTLR
    dsb                          @ 数据同步屏障
    isb                          @ 指令同步屏障

我曾经踩过的坑:在使能MMU之前,一定要确保当前执行的代码所在的虚拟地址和物理地址是1:1映射的。否则MMU一开,PC指针指向的地址还没映射好,CPU直接取指异常。我当年调试一个板子,MMU使能后系统就挂,查了两天才发现是代码段所在的虚拟地址没做恒等映射。

4.4 Cache早期配置:先清后配,别留脏数据

Cache的早期配置,核心原则就四个字:先清后配。在打开Cache之前,必须确保Cache里没有脏数据。否则,你打开Cache的一瞬间,CPU可能从Cache里读到过时的数据。

我建议的Cache初始化流程:

  • Invalidate整个Cache:清空所有Cache行,确保没有有效数据
  • 配置Cache策略:写回(Write-back)还是写通(Write-through)?
  • 使能Cache:设置SCTLR的C位(数据Cache)和I位(指令Cache)

指令Cache的使能相对安全——它只缓存指令,不涉及数据一致性。数据Cache就要小心了,尤其是DMA和CPU共享内存的场景。

个人习惯:在Bootloader阶段,我通常只开指令Cache,数据Cache等到内核启动后再由内核管理。这样既提升了指令读取速度,又避免了数据一致性的麻烦。如果你非要开数据Cache,记得把DMA缓冲区所在的区域配成非缓存(Strongly Ordered或Device类型)。

4.5 知识结构总览

下面这张图,把本章的核心知识点串起来了。你可以把它当作启动代码的“检查清单”:

汇编级启动代码核心流程 上电/复位入口 设置中断向量表 指令集切换(ARM/Thumb) 初始化MMU页表 使能MMU + Cache 跳转到C语言入口 ⚠ 常见陷阱 • 向量表地址必须4字节对齐 • BX跳转时LSB决定指令集 • MMU使能前必须做恒等映射 • Cache使能前先Invalidate 💡 调试建议 • 用JTAG单步跟踪第一条指令 • 检查SCTLR寄存器确认MMU状态 • 用串口打印“心跳”定位死机点 • 保留一段非缓存区域做调试用

这张图里,红色节点(MMU和Cache)是风险最高的部分。我建议你在调试时,每做完一步就检查一下对应的系统寄存器。比如使能MMU后,立刻读一下TTBR0和SCTLR,确认配置生效了再往下走。

好了,汇编级启动的核心内容就这些。记住:向量表别放歪,指令集别切错,MMU别忘恒等映射,Cache使能前先清空。把这四点做到位,启动代码就稳了八成。剩下的两成,靠调试经验和一点点运气——嗯,运气其实也是准备出来的。