4. 汇编基础与启动代码:ARM Cortex-M汇编基础、启动文件(startup.s)解析、堆栈初始化
好,咱们进入第四章。这一章是Bootloader开发的基石——汇编与启动代码。说实话,很多做嵌入式应用层开发的工程师,一看到汇编就头疼。但我跟你说,搞Bootloader,汇编这块绕不过去。你想想看,芯片上电时C环境还没建立,你总不能用printf去初始化堆栈吧?
我个人习惯把启动代码比作「芯片的产房」——CPU刚出生,啥都不会,得有人帮它把基本环境搭好。今天我们就来亲手搭建这个产房。
4.1 ARM Cortex-M汇编基础
先聊几句汇编。Cortex-M系列用的是Thumb-2指令集,它兼容16位和32位指令。我刚开始从ARM7转到Cortex-M时,最不习惯的就是指令长度不固定。但用久了你会发现,这其实是优点——代码密度高了不少。
4.1.1 常用指令速览
Bootloader里常用的汇编指令其实不多,我列个表给你看:
| 指令 | 功能 | 我常用的场景 |
|---|---|---|
LDR |
从内存加载数据到寄存器 | 读取向量表地址、外设寄存器 |
STR |
将寄存器数据存储到内存 | 设置系统控制寄存器 |
MOV |
寄存器间数据传送 | 初始化栈指针、传递参数 |
BL |
带链接的跳转(调用函数) | 跳转到main()或Bootloader主逻辑 |
BX |
分支并切换指令集 | 跳转到应用程序入口 |
CPS |
修改处理器状态 | 开关全局中断 |
BX指令的LSB必须为1,表示Thumb状态。如果LSB是0,CPU会触发异常。我曾经在跳转APP时忘了加1,结果芯片直接HardFault,查了半天才发现是地址对齐问题。
4.1.2 寄存器模型
Cortex-M有16个通用寄存器(R0-R15),其中几个有特殊用途:
- R13(SP):栈指针,分MSP和PSP
- R14(LR):链接寄存器,保存返回地址
- R15(PC):程序计数器
嗯,这里要注意:Cortex-M的R13其实有两个物理寄存器——主栈指针(MSP)和进程栈指针(PSP)。复位后默认用MSP,这也是我们Bootloader里用的那个。
4.2 启动文件(startup.s)解析
启动文件,说白了就是芯片上电后执行的第一段代码。每个厂商的startup.s长得不太一样,但核心逻辑是一样的。我拆开给你看:
4.2.1 向量表定义
Cortex-M复位后,硬件会自动从地址0x00000000加载两个关键值:
- 地址0x00000000 → 初始栈指针(MSP)
- 地址0x00000004 → 复位向量(Reset_Handler)
这就是向量表的前两个条目。完整的向量表长这样:
; startup.s 片段
__Vectors DCD __initial_sp ; 栈顶地址
DCD Reset_Handler ; 复位中断
DCD NMI_Handler ; NMI
DCD HardFault_Handler ; 硬错误
DCD MemManage_Handler ; 内存管理
DCD BusFault_Handler ; 总线错误
DCD UsageFault_Handler ; 用法错误
; ... 后面还有几十个中断向量
4.2.2 Reset_Handler:真正的入口
复位后,CPU跳转到Reset_Handler。这里要做三件大事:
- 初始化堆栈:设置MSP和堆栈边界
- 拷贝数据段:把.data从Flash搬到RAM
- 清零BSS段:把.bss段全部清0
代码实现大致如下:
Reset_Handler PROC
EXPORT Reset_Handler
; 1. 设置栈指针(其实硬件已经做了,但再设一次更保险)
LDR R0, =__initial_sp
MSR MSP, R0
; 2. 拷贝.data段
LDR R0, =__data_start
LDR R1, =__data_end
LDR R2, =__data_flash_start
BL memcpy
; 3. 清零.bss段
LDR R0, =__bss_start
LDR R1, =__bss_end
MOV R2, #0
BL memset
; 4. 跳转到C的main函数
BL main
ENDP
你可能会问:为什么.data要拷贝?因为Flash不能写,而.data里的全局变量是可读写的,必须搬到RAM里。.bss段存的是未初始化的全局变量,C标准要求它们初始为0,所以得清零。
4.3 堆栈初始化
堆栈初始化,说白了就是告诉CPU:「你的临时数据往哪放」。Cortex-M的堆栈是向下生长的——压栈时SP减小,出栈时SP增大。
4.3.1 堆栈布局
典型的堆栈布局如下:
高地址
+------------------+ ← __initial_sp(栈顶)
| 栈空间 |
| (向下生长) |
+------------------+ ← 当前SP
| 堆空间 |
| (向上生长) |
+------------------+ ← __heap_start
| .bss段 |
+------------------+
| .data段 |
+------------------+
低地址
堆和栈之间是自由区域。如果堆栈溢出,它们就会互相踩踏——这就是著名的「堆栈碰撞」。我见过一个案例,堆分配太多,把栈空间挤没了,函数调用时SP直接越界,数据写到了堆里,然后堆里的malloc指针被覆盖,整个系统像多米诺骨牌一样崩溃。
4.3.2 堆栈大小怎么定?
这个问题没有标准答案,但我给个参考:
| 场景 | 建议栈大小 | 建议堆大小 |
|---|---|---|
| 简单Bootloader(无RTOS) | 512字节 - 1KB | 0(不用malloc) |
| 带文件系统的Bootloader | 1KB - 2KB | 512字节 - 1KB |
| 带网络协议的Bootloader | 2KB - 4KB | 1KB - 2KB |
4.4 本章知识体系
下面这张图把本章的核心逻辑串起来了:
这张图把从复位到main()的完整路径画清楚了。你写启动代码时,脑子里要有这个流程——每一步都环环相扣,少一个环节系统就起不来。
4.5 实战:手写一个最小启动文件
光说不练假把式。我给你一个最小可用的startup.s模板,你可以在自己的开发板上试试:
; 最小启动文件 startup.s
; 适用于STM32F103系列(Cortex-M3)
AREA RESET, DATA, READONLY
EXPORT __Vectors
EXPORT __Vectors_End
EXPORT __Vectors_Size
__Vectors DCD __initial_sp ; 栈顶
DCD Reset_Handler ; 复位向量
DCD NMI_Handler
DCD HardFault_Handler
; ... 其他向量按需添加
__Vectors_End
__Vectors_Size EQU __Vectors_End - __Vectors
AREA |.text|, CODE, READONLY
; 复位处理函数
Reset_Handler PROC
EXPORT Reset_Handler
; 设置栈指针
LDR R0, =__initial_sp
MSR MSP, R0
; 拷贝.data
LDR R0, =__data_start
LDR R1, =__data_end
LDR R2, =__data_flash_start
BL memcpy
; 清零.bss
LDR R0, =__bss_start
LDR R1, =__bss_end
MOV R2, #0
BL memset
; 跳转main
BL main
B .
ENDP
; 简单的memcpy实现
memcpy PROC
CMP R1, R0
BXEQ LR
loop
LDRB R3, [R2], #1
STRB R3, [R0], #1
CMP R0, R1
BNE loop
BX LR
ENDP
; 简单的memset实现
memset PROC
CMP R1, R0
BXEQ LR
loop2
STRB R2, [R0], #1
CMP R0, R1
BNE loop2
BX LR
ENDP
; 弱定义的中断处理(防止链接报错)
NMI_Handler PROC
B .
ENDP
HardFault_Handler PROC
B .
ENDP
END
好了,这一章的内容就到这。汇编基础、启动文件结构、堆栈初始化——这三块是Bootloader的根基。你把这章吃透了,后面讲跳转APP、中断重定向的时候,你会觉得特别顺。记住一句话:启动代码写不好,后面所有代码都是空中楼阁。