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字节)。
典型的配置步骤:
- 关闭MMU和Cache:操作SCTLR寄存器,清掉M、C、I位
- 建立页表:在内存中分配一块16KB区域,填充段描述符
- 设置TTBR0:把页表基地址写入TTBR0寄存器
- 使能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 知识结构总览
下面这张图,把本章的核心知识点串起来了。你可以把它当作启动代码的“检查清单”:
这张图里,红色节点(MMU和Cache)是风险最高的部分。我建议你在调试时,每做完一步就检查一下对应的系统寄存器。比如使能MMU后,立刻读一下TTBR0和SCTLR,确认配置生效了再往下走。
好了,汇编级启动的核心内容就这些。记住:向量表别放歪,指令集别切错,MMU别忘恒等映射,Cache使能前先清空。把这四点做到位,启动代码就稳了八成。剩下的两成,靠调试经验和一点点运气——嗯,运气其实也是准备出来的。