4. 汇编启动代码:从复位向量到 main 函数
说实话,很多做嵌入式开发的朋友,写了几年 C 语言,却从来没认真看过启动代码。我刚开始带项目时也是这样,直到有一次芯片上电就跑飞,查了三天才发现是堆栈指针没初始化。嗯,从那以后,我养成了一个习惯——每次移植 Bootloader,第一件事就是手写汇编启动代码。
这一章,我们就来聊聊汇编级启动代码的核心四件事:复位向量、堆栈指针初始化、BSS 段清零、跳转到 main。说白了,就是芯片上电后,在 C 语言环境建立之前,CPU 必须完成的那些脏活累活。
4.1 复位向量:芯片上电的第一行代码
芯片上电后,CPU 会去一个固定的地址取第一条指令。这个地址,就是复位向量。以 ARM Cortex-M 系列为例,复位向量位于 0x00000004(向量表偏移 4 字节处)。
我个人习惯把向量表放在汇编文件的最前面,像这样:
; 向量表起始地址
.section .isr_vector, "a"
.global g_pfnVectors
.align 4
g_pfnVectors:
.word _estack ; 栈顶地址
.word Reset_Handler ; 复位向量
.word NMI_Handler ; NMI 中断
.word HardFault_Handler ; 硬错误中断
; ... 其他中断向量
这里要注意几点:
- _estack 是栈顶地址,必须在链接脚本中定义
- Reset_Handler 就是复位后执行的第一个函数
- 向量表必须 4 字节对齐,否则 CPU 会跑飞
4.2 堆栈指针初始化:给 C 语言一个安身之所
堆栈指针(SP)是 C 语言运行的基础。局部变量、函数调用、中断现场保存,全都依赖它。你想想看,如果 SP 指向了未初始化的 RAM 区域,或者更糟——指向了 ROM,那程序一跑就崩。
在 Cortex-M 上,复位后 CPU 会自动从向量表的第一项加载 SP。但如果你用的是 Cortex-A 或 RISC-V,就得手动初始化:
; 手动初始化堆栈指针(以 ARM Cortex-A 为例)
.section .text.startup
.global _start
_start:
; 设置栈指针
ldr sp, =_estack
; 或者分模式设置
mrs r0, cpsr
bic r0, r0, #0x1F ; 清除模式位
orr r0, r0, #0x12 ; IRQ 模式
msr cpsr, r0
ldr sp, =_irq_stack_top ; IRQ 模式栈
orr r0, r0, #0x11 ; FIQ 模式
msr cpsr, r0
ldr sp, =_fiq_stack_top ; FIQ 模式栈
; 最后切回 SVC 模式
orr r0, r0, #0x13
msr cpsr, r0
ldr sp, =_svc_stack_top ; SVC 模式栈
4.3 BSS 段清零:让全局变量从零开始
C 语言中未初始化的全局变量和静态变量,默认值应该是 0。但芯片上电后,RAM 里的数据是随机的。所以,启动代码必须把 BSS 段全部清零。
BSS 段的起始地址和结束地址,通常在链接脚本中定义:
; BSS 段清零
.section .text.startup
.global _start
_start:
; ... 前面是 SP 初始化 ...
; 清零 BSS 段
ldr r0, =_sbss ; BSS 起始地址
ldr r1, =_ebss ; BSS 结束地址
mov r2, #0
bss_loop:
cmp r0, r1
bhs bss_done ; 如果 r0 >= r1,跳转
str r2, [r0], #4 ; 写入 0,地址加 4
b bss_loop
bss_done:
; ... 后续跳转到 main ...
这里有个细节:为什么用 str 而不是 strb? 因为 32 位处理器一次写 4 字节效率更高。如果 BSS 段很大(比如几百 KB),用 strb 会慢 4 倍。
4.4 数据段拷贝:把初始化数据搬到 RAM
除了 BSS 段,还有 数据段(.data)。它存放的是已初始化的全局变量和静态变量。这些变量的初始值存储在 Flash 中,启动时需要拷贝到 RAM 里。
; 数据段拷贝
ldr r0, =_sdata ; RAM 中的数据段起始地址
ldr r1, =_edata ; RAM 中的数据段结束地址
ldr r2, =_sidata ; Flash 中的初始值起始地址
data_loop:
cmp r0, r1
bhs data_done
ldr r3, [r2], #4 ; 从 Flash 读取
str r3, [r0], #4 ; 写入 RAM
b data_loop
data_done:
; ... 跳转到 main ...
你可能会问:为什么不直接把 .data 段放在 RAM 里? 因为 RAM 掉电会丢失数据。初始值必须保存在非易失存储器(Flash)中,上电后再搬过去。
| 段名 | 存储位置 | 运行位置 | 启动操作 |
|---|---|---|---|
| .text | Flash | Flash | 无需操作 |
| .rodata | Flash | Flash | 无需操作 |
| .data | Flash | RAM | 从 Flash 拷贝到 RAM |
| .bss | 无 | RAM | 清零 |
4.5 跳转到 main:最后的交接
做完上面所有准备工作,终于可以跳转到 C 语言的 main 函数了。但这里有个坑:直接跳转还是用 BL 指令?
我个人习惯用 BL(带链接的跳转),这样 main 函数返回后还能执行一些清理工作。不过,在嵌入式系统中,main 函数通常不会返回。所以,很多启动代码直接用 B(无条件跳转)来节省一点栈空间。
; 跳转到 main 函数
bl main ; 带链接跳转
; 如果 main 返回,进入死循环
loop_forever:
b loop_forever
这里还有一个容易被忽略的点:main 函数的参数。在标准 C 中,main 函数可以带 argc 和 argv。但在嵌入式系统中,我们通常用 void main(void)。如果你非要支持参数,可以在跳转前设置 r0 和 r1:
mov r0, #0 ; argc = 0
mov r1, #0 ; argv = NULL
bl main
4.6 完整的启动流程
把上面所有步骤串起来,就是一个完整的汇编启动流程。我画了一张图,帮你理清思路:
4.7 常见问题与避坑指南
写启动代码这么多年,我总结了一些常见问题,分享给你:
- 栈溢出检测: 可以在栈底填充一个特殊值(如 0xDEADBEEF),然后在 main 函数中定期检查。如果这个值被覆盖了,说明栈溢出了。
- 中断优先级: 在跳转到 main 之前,最好把所有中断都关掉。等 C 语言环境建立好之后,再逐个使能。否则,中断可能在半路触发,导致不可预知的行为。
- 看门狗: 有些芯片上电后看门狗默认是开启的。如果启动代码执行时间太长,看门狗会复位芯片。我建议在启动代码最前面就把看门狗关掉。
- 链接脚本: 启动代码和链接脚本是强耦合的。_sdata、_ebss 这些符号必须在链接脚本中正确定义。我见过太多人改了启动代码却忘了改链接脚本,结果程序跑飞。
好了,这一章的内容就到这里。启动代码虽然看起来简单,但它是整个系统的基石。写好了,后面的 C 语言代码就能安心运行;写不好,整个项目都会跟着遭殃。希望你能动手写一遍,哪怕是在模拟器上跑一跑,也比只看不练强得多。