第3章:启动流程分析——从复位到RT-Thread跑起来
这一章我们聊聊启动流程。说白了,就是芯片上电后,从第一条指令到RT-Thread的entry函数,中间到底发生了什么。很多同学调试时遇到"程序没跑起来",其实根子就在启动阶段。
我个人习惯,拿到一块新板子,第一件事就是看启动代码。不是因为我闲,而是因为启动阶段的bug最难查——这时候调试器可能还没初始化好,串口也没输出,你连printf都用不了。
3.1 STM32上电启动流程
STM32上电后,硬件自动做三件事:
- 从0x00000000取栈顶指针——也就是MSP的初始值
- 从0x00000004取复位向量——也就是
Reset_Handler的地址 - 跳转到Reset_Handler执行
嗯,这里要注意:0x00000000和0x00000004这两个地址,实际映射到Flash的起始位置(或者System Memory,取决于BOOT引脚)。所以你的中断向量表必须放在Flash开头。
核心要点:STM32启动的硬件行为只有两步——取栈顶、取PC。剩下的全是软件的事。
我曾经遇到一个案例:同事把中断向量表偏移到了0x08002000,但忘了改SCB->VTOR。结果一上电,CPU从0x00000000读到的根本不是栈顶,而是随机数据。程序直接飞了。后来查了两天才找到原因。
3.2 Reset_Handler里干了什么
进入Reset_Handler后,软件开始接手。典型的启动流程如下:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 设置中断向量表偏移 | SCB->VTOR = FLASH_BASE |
| 2 | 初始化.data段 | 从Flash复制到SRAM |
| 3 | 清零.bss段 | 全局/静态变量初始化为0 |
| 4 | 调用SystemInit | 配置时钟、Flash预取等 |
| 5 | 调用C库函数 | 如__main或__libc_init_array |
| 6 | 跳转到main | 进入用户程序 |
你可能会问:RT-Thread的入口在哪?别急,这里有个关键机制——$Sub$$和$Super$$。
3.3 $Sub$$与$Super$$机制
这个机制是ARM编译器提供的"函数劫持"手段。说白了,就是让你在不修改原有代码的情况下,在函数调用前后插入自己的逻辑。
语法很简单:
// 原函数:main
// 定义$Sub$$main:替换main的调用
void $Sub$$main(void)
{
// 在main之前做初始化
rt_hw_board_init();
rt_system_scheduler_init();
// 调用原始的main
$Super$$main();
// main返回后执行
rt_system_scheduler_start();
}
// 定义$Super$$main:指向原始的main
void $Super$$main(void);
为什么RT-Thread要用这个机制?因为标准C库的启动代码最后会调用main(),而RT-Thread需要在此之前初始化内核,之后启动调度器。用$Sub$$main和$Super$$main,就能无缝插入这些操作,不用改链接脚本或启动文件。
我的经验:调试时如果发现调度器没跑起来,先检查$Sub$$main有没有被正确链接。有时候优化等级开太高,编译器会把这种"魔术符号"优化掉。我一般会在$Sub$$main里加个空的while(1),看能不能停住。
3.4 RT-Thread启动代码分析
RT-Thread的启动链,从Reset_Handler开始,经过$Sub$$main,最终进入entry函数。完整路径如下:
Reset_Handler
→ SystemInit (时钟配置)
→ __main / __libc_init_array (C运行时初始化)
→ $Sub$$main
→ rt_hw_board_init() // 硬件初始化
→ rt_system_heap_init() // 堆初始化
→ rt_application_init() // 创建主线程
→ $Super$$main() // 调用真正的main()
→ main() // 用户main,通常为空
→ rt_system_scheduler_start() // 启动调度器
这里有个细节:rt_application_init()会创建一个名为main的线程,这个线程的入口函数是main_thread_entry。而用户写的main()函数,实际上是在这个线程里被调用的。换句话说,RT-Thread启动后,用户代码跑在了一个线程上下文中,而不是裸机环境。
注意:不要在$Sub$$main里调用任何可能阻塞的函数!因为此时调度器还没启动,一旦阻塞就永远回不来了。我见过有人在这里调了rt_thread_mdelay,结果程序卡死,查了半天。
3.5 entry函数调用链
说到entry,很多同学以为它就是main。其实不对。RT-Thread的entry是线程的入口函数,由rt_thread_create或rt_thread_init指定。
典型的调用链是这样的:
- rt_application_init() 创建主线程,入口为
main_thread_entry - main_thread_entry 内部调用用户定义的
main() - main() 里创建其他线程,每个线程有自己的
entry - 调度器启动后,各线程按优先级轮转执行
所以,你写的每个线程函数,本质上就是一个entry函数。它的原型是:
void thread_entry(void *parameter)
{
while(1) {
// 线程逻辑
rt_thread_mdelay(100);
}
}
嗯,这里要注意:entry函数不能返回。如果返回了,线程就结束了,RT-Thread会回收它的资源。但如果你没有在创建线程时设置RT_THREAD_AUTO_START,线程不会自动运行,需要手动调用rt_thread_startup。
3.6 启动流程全景图
下面这张图把整个启动流程串起来了。我建议你把它打印出来贴在工位上,调试时对照着看。
3.7 常见启动故障与定位
根据我的经验,启动阶段的故障大概分三类:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 程序跑飞,无任何输出 | 中断向量表偏移错误、时钟配置失败 | 检查SCB->VTOR,用调试器看PC值 |
| 卡在$Sub$$main里 | 硬件初始化中某个外设挂死 | 在rt_hw_board_init里逐段加GPIO翻转 |
| 调度器启动后无线程运行 | 主线程未创建或优先级配置错误 | 检查rt_application_init返回值 |
| main()不执行 | $Sub$$main未正确链接 | 查看map文件,确认符号存在 |
避坑指南:我曾经在移植RT-Thread到新板子时,发现程序总是卡在rt_hw_board_init里的某个外设初始化。后来用逻辑分析仪一看,原来是I2C总线上有个设备没焊,初始化时一直等ACK。从那以后,我习惯在硬件初始化函数里加超时机制,或者用状态机轮询,而不是死等。