12、最终跳转:如何从汇编跳转到main函数
好,咱们一路从复位向量、堆栈初始化、BSS段清零、数据段搬运,走到了这里。现在,启动代码的最后一步——跳转到 main()。
这一步看起来简单,不就是一条跳转指令吗?
嗯,确实,从汇编层面看,可能就是一句 BL main 或者 B main。但这里面的门道,比你想象的多。我在好几个项目里都见过,因为这一步没处理好,程序跑飞了,或者全局变量莫名其妙被篡改。
今天咱们就把这“临门一脚”彻底讲透。
12.1 跳转前的最后检查
在跳转之前,启动代码必须确认几件事。我习惯把这叫做“跳转前 checklist”。
跳转前必须完成的三件事:
- 堆栈指针(SP)已就绪——C语言函数调用离不开栈。
- 全局变量已初始化——BSS清零、DATA段搬运完成。
- 系统时钟/外设已处于安全状态——至少不能有未处理的中断悬空。
你想想看,如果堆栈指针指向一个无效地址,main() 里第一条 int a = 0; 就可能把栈写穿。我曾经在一个低功耗项目里遇到过,因为跳转前忘了关看门狗,结果 main() 刚执行两行就被复位了。查了两天才找到原因。
12.2 两种跳转方式:B vs BL
汇编里跳转到 main,常见的有两种写法:
; 方式一:直接跳转(不返回)
B main
; 方式二:带链接跳转(可返回)
BL main
有什么区别?
| 指令 | 含义 | 是否保存返回地址 | 典型用途 |
|---|---|---|---|
B main |
无条件跳转到 main | 否 | 嵌入式裸机,main 不返回 |
BL main |
跳转并保存返回地址到 LR | 是 | 带 RTOS 的场景,或 main 可能返回 |
我个人习惯在裸机项目里用 B main。为什么?因为裸机上的 main() 本质上是个死循环,它永远不会返回。用 BL 反而浪费了 LR 寄存器,而且如果 main() 意外返回了,程序会跳回启动代码里一个未定义的位置——那画面太美我不敢看。
小技巧: 如果你用 BL main,建议在 main() 返回后加一条死循环:
BL main
B . ; 无限循环,防止跑飞
12.3 参数传递:谁给 main 传参?
标准 C 语言的 main() 签名是:
int main(int argc, char *argv[]);
但在嵌入式世界里,绝大多数情况下 main() 是无参数的:
int main(void);
为什么?因为嵌入式系统没有操作系统来传递命令行参数。如果你在启动代码里看到有人给 main 传参,那多半是某种特殊设计——比如把启动模式编码到 argc 里。
我记得有个项目,Bootloader 和应用程序共用一套启动代码。Bootloader 跳转到应用前,把固件版本号放在 R0 寄存器里,应用层的 main() 通过读取 R0 来判断版本。这种做法很巧妙,但需要汇编和 C 的调用约定完全对齐。
12.4 调用约定:ABI 的最后一环
跳转到 main() 时,CPU 的寄存器状态必须符合 ARM 的 AAPCS(ARM Architecture Procedure Call Standard)。
简单说,跳转前要保证:
- R0-R3:可以用于传参,但通常不关心
- R4-R11:必须由被调用者(启动代码)保存,但跳转前一般不需要特意处理
- R12(IP):可能被链接器用作临时寄存器,跳转前不用管
- R13(SP):必须指向有效的栈空间
- R14(LR):如果用
BL,LR 会被自动设置;如果用B,LR 保持原值 - R15(PC):跳转指令会自动修改
注意: 有些编译器生成的启动代码,会在跳转前把某些特殊寄存器清零。比如,把 FP(帧指针)设为 0,方便调试器回溯调用栈。如果你自己手写启动代码,别忘了这个细节。
12.5 一个完整的跳转示例
下面是一个典型的 ARM Cortex-M 启动代码片段,展示了从复位到 main() 的完整流程:
; 启动代码入口
Reset_Handler:
; 1. 设置堆栈指针
LDR SP, =_estack
; 2. 清零 BSS 段
LDR R0, =_sbss
LDR R1, =_ebss
MOVS R2, #0
B .L_loop_bss
.L_bss:
STR R2, [R0], #4
.L_loop_bss:
CMP R0, R1
BCC .L_bss
; 3. 搬运 DATA 段
LDR R0, =_sdata
LDR R1, =_edata
LDR R2, =_sidata
B .L_loop_data
.L_data:
LDR R3, [R2], #4
STR R3, [R0], #4
.L_loop_data:
CMP R0, R1
BCC .L_data
; 4. 调用 SystemInit(可选)
BL SystemInit
; 5. 跳转到 main
BL main ; 这里用 BL,因为有些 RTOS 需要 main 返回
; 6. main 返回后的安全处理
B . ; 死循环
注意第 5 步,这里用了 BL main。如果 main() 意外返回,第 6 步的死循环会兜底。
12.6 跳转后的世界:谁在管理 main?
一旦跳转到 main(),启动代码的任务就结束了。但 main() 并不是孤立的——它背后还有两样东西在默默工作:
- C 运行时库(CRT):比如
printf、malloc这些函数,它们的初始化可能在main()之前就完成了。 - 中断向量表:如果
main()里开了中断,CPU 会通过向量表找到中断服务函数。
我见过一个案例,有人在 main() 第一行就调 printf,结果程序卡死。查了半天发现,printf 依赖的串口初始化被放在了 main() 的后面。说白了,就是启动代码没把 printf 的底层环境准备好。
建议: 在 main() 的开头,先做两件事:关中断、初始化基础外设(比如串口、时钟)。然后再开中断、进入主循环。这个顺序能避免很多诡异的问题。
12.7 特殊场景:从 Bootloader 跳转到应用
如果你的系统有 Bootloader,跳转逻辑会更复杂一些。Bootloader 跳转到应用前,通常需要:
- 关闭所有外设中断
- 恢复默认时钟配置
- 重置堆栈指针为应用的栈顶
- 跳转到应用的中断向量表
我曾经在一个 OTA 项目里,Bootloader 跳转前忘了关 DMA 中断。结果应用启动后,DMA 还在搬运旧数据,直接把应用的内存踩了。从那以后,我每次写跳转代码,都会加一条“关所有外设”的宏。
12.8 一张图看懂跳转流程
下面我用 SVG 画了一张跳转流程图,把从复位到 main() 的完整路径展示出来:
12.9 总结
从汇编跳转到 main(),看似简单,实则是启动代码的“收官之战”。
- 用
B还是BL?裸机推荐B,带 RTOS 推荐BL。 - 跳转前,堆栈、BSS、DATA 必须就绪。
- 调用约定要遵守,寄存器状态要干净。
- 如果有 Bootloader,跳转前要彻底清理外设状态。
嗯,这一章就到这里。记住,main() 不是起点,而是启动代码精心铺好路之后的终点。你铺的路越稳,main() 跑得就越安心。
公众号:蓝海资料掘金营,微信 deep3321