第3章:启动流程分析——从复位到RT-Thread跑起来

这一章我们聊聊启动流程。说白了,就是芯片上电后,从第一条指令到RT-Thread的entry函数,中间到底发生了什么。很多同学调试时遇到"程序没跑起来",其实根子就在启动阶段。

我个人习惯,拿到一块新板子,第一件事就是看启动代码。不是因为我闲,而是因为启动阶段的bug最难查——这时候调试器可能还没初始化好,串口也没输出,你连printf都用不了。

3.1 STM32上电启动流程

STM32上电后,硬件自动做三件事:

  1. 从0x00000000取栈顶指针——也就是MSP的初始值
  2. 从0x00000004取复位向量——也就是Reset_Handler的地址
  3. 跳转到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指定。

典型的调用链是这样的:

  1. rt_application_init() 创建主线程,入口为main_thread_entry
  2. main_thread_entry 内部调用用户定义的main()
  3. main() 里创建其他线程,每个线程有自己的entry
  4. 调度器启动后,各线程按优先级轮转执行

所以,你写的每个线程函数,本质上就是一个entry函数。它的原型是:

void thread_entry(void *parameter)
{
    while(1) {
        // 线程逻辑
        rt_thread_mdelay(100);
    }
}

嗯,这里要注意:entry函数不能返回。如果返回了,线程就结束了,RT-Thread会回收它的资源。但如果你没有在创建线程时设置RT_THREAD_AUTO_START,线程不会自动运行,需要手动调用rt_thread_startup。

3.6 启动流程全景图

下面这张图把整个启动流程串起来了。我建议你把它打印出来贴在工位上,调试时对照着看。

STM32 + RT-Thread 启动流程全景图 上电 → 取向量 → 跳转Reset_Handler Reset_Handler 设置VTOR | 初始化.data/.bss | SystemInit __main / __libc_init_array $Sub$$main (RT-Thread插入点) rt_hw_board_init → rt_system_heap_init rt_application_init → $Super$$main() main() 用户代码 rt_system_scheduler_start 多线程运行 (各线程entry函数) 硬件自动完成 软件接管

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。从那以后,我习惯在硬件初始化函数里加超时机制,或者用状态机轮询,而不是死等。