ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32上电启动全流程解析:从复位向量到RTOS第一个任务切换

STM32上电启动全流程解析:从复位向量到RTOS第一个任务切换 1. 启动流程到底在解决什么问题很多人玩 STM32 是从点灯开始的main函数里写个GPIO_SetBits编译下载灯亮了收工。但如果你哪天遇到程序跑飞、HardFault 死循环、或者自己写了个 Bootloader 跳转后 App 不启动你就会发现——main函数之前到底发生了什么这件事绕不过去。STM32 上电启动全流程说白了就是回答一个问题从上电那一瞬间到你的main函数第一行代码被执行CPU 到底干了哪些事这中间涉及复位向量表的定位、栈指针的初始化、Reset_Handler的执行、时钟树的配置、数据段的搬运、C 库的初始化一直到最后main被调用。如果你用的是 RTOS比如 uC/OS-II那还要再往后延伸到第一个任务是怎么被调度起来的PendSV 在其中扮演了什么角色。这篇文章适合三类人看第一类是想从“会点灯”进阶到“懂系统”的嵌入式新手第二类是在写 Bootloader、做 OTA 升级、搞多固件切换时被跳转问题折磨过的工程师第三类是准备面试、被问到“STM32 启动流程”时只能背个大概的求职者。我会从复位向量讲起一路拆到 uC/OS-II 的第一个任务切换把每个环节的原理、代码、踩坑点都摊开讲。先给一个全局的路线图让你脑子里有个框架阶段关键动作核心角色上电复位CPU 从固定地址取栈顶和 PC向量表执行 Reset_Handler调用 SystemInit、搬运数据段启动文件进入 main初始化外设、创建任务用户代码启动 RTOS初始化就绪表、触发第一次调度OSStart / PendSV第一个任务运行上下文切换完成PendSV 异常这张表你先记住后面每一节我都会展开。下面正式开始拆。2. 复位向量与向量表CPU 上电后第一眼看到什么2.1 Cortex-M 的复位行为不是“从头执行”x86 那种架构上电后是从某个固定物理地址开始取指令而 Cortex-M 的复位行为稍微不一样。上电或复位后CPU 会做两件事从地址0x00000000处读取前 4 个字节把它当作栈顶指针MSP的初始值写入 SP 寄存器。从地址0x00000004处读取接下来 4 个字节把它当作复位向量写入 PC 寄存器然后开始执行。注意这里读的是“地址 0x00000000”但实际映射到哪块存储介质取决于你的BOOT 引脚配置和芯片的存储器映射。以常见的 STM32F103 为例BOOT0 0从主 Flash 启动0x00000000被映射到0x08000000。BOOT0 1、BOOT1 0从系统存储器启动出厂 Bootloader。BOOT0 1、BOOT1 1从内置 SRAM 启动。所以你在启动文件里看到的向量表虽然链接脚本把它放在 Flash 起始处但 CPU 通过别名机制在0x00000000就能读到。这个“地址别名”是理解 Bootloader 跳转的关键后面会再提。2.2 向量表长什么样向量表本质上就是一个 4 字节对齐的数组前两项特殊后面是各个异常和中断的服务函数入口。以 STM32F1 的startup_stm32f10x_md.s为例开头是这样的__Vectors DCD __initial_sp ; 栈顶地址 DCD Reset_Handler ; 复位向量 DCD NMI_Handler ; NMI DCD HardFault_Handler ; 硬件错误 DCD MemManage_Handler ; 内存管理错误 DCD BusFault_Handler ; 总线错误 DCD UsageFault_Handler ; 用法错误 DCD 0 ; 保留 DCD 0 DCD 0 DCD 0 DCD SVC_Handler ; 系统服务调用 DCD DebugMon_Handler ; 调试监控 DCD 0 DCD PendSV_Handler ; 可挂起系统服务 DCD SysTick_Handler ; 系统滴答定时器 ; 后面是外设中断...__initial_sp是链接器根据你分配的栈大小算出来的栈顶地址。栈是向下生长的所以栈顶是 RAM 区域的最高地址。比如你的 RAM 从0x20000000开始大小 20KB栈大小 1KB那__initial_sp就是0x20005000。这里有个新手常犯的误区以为栈顶是 RAM 起始地址。不是的栈顶是 RAM 的高地址端因为栈向下生长。你可以在.map文件里搜__initial_sp确认它的实际值。2.3 为什么向量表要放在最前面这是 Cortex-M 的硬件约定没得商量。CPU 复位后不会去查什么配置寄存器它就是死板地从0x00000000和0x00000004取两个值。所以你的向量表必须位于存储器映射的起始位置或者通过SCB-VTOR寄存器重定位到别处。VTORVector Table Offset Register这个寄存器非常重要。默认情况下它指向0x00000000但你可以修改它把向量表搬到 RAM 里或者搬到 Flash 的其他位置。做 Bootloader 时App 的向量表通常不在0x08000000而在比如0x08008000这时候 App 启动后第一件事就是设置VTOR 0x08008000否则中断会跳到 Bootloader 的向量表里去直接跑飞。// App 中重定位向量表 SCB-VTOR 0x08008000;这一句看起来简单但漏了它你的 App 可能主循环跑得好好的一进中断就死。这个坑我在做双区升级时踩过排查了大半天。3. Reset_Handler从汇编到 C 世界的桥梁3.1 Reset_Handler 到底做了几件事复位向量指向Reset_Handler这是启动文件里的一段汇编。它的工作可以概括为四步调用SystemInit配置时钟树比如把 HSI 切到 HSE再倍频到 72MHz。把.data段从 Flash 搬运到 RAM。把.bss段清零。调用__main注意不是main由 C 库完成最后的初始化并跳转到main。以 STM32F1 的标准启动文件为例核心代码大概是这样Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT __main IMPORT SystemInit LDR R0, SystemInit BLX R0 LDR R0, __main BX R0 ENDP你可能注意到这里没看到搬运.data和清零.bss的代码。这是因为标准库的启动文件把这一步交给了 C 库的__main函数。__main是 ARM 编译器提供的入口它会调用__scatterload完成段搬运然后调用__rt_entry最终才到你的main。而如果你用的是 GCC 工具链比如 STM32CubeIDE 或者 Makefile 工程启动文件里会显式写出搬运逻辑Reset_Handler: ldr sp, _estack bl SystemInit bl __libc_init_array bl main bx lrGCC 的.data搬运和.bss清零通常由链接脚本配合__libc_init_array或者显式的循环完成。不同工具链细节不同但本质一样把有初值的全局变量从 Flash 拷到 RAM把没初值的全局变量区域清零。3.2 为什么非要搬 .data 和清 .bss这个问题问得好理解了它你就理解了嵌入式内存模型。全局变量int g_flag 5;的初值 5 存在哪它存在 Flash 里因为 Flash 掉电不丢但变量本身在运行时必须在 RAM 里因为要可读可写。所以启动时必须把 Flash 里存的那个 5 拷贝到 RAM 中对应的地址。而int g_buf[100];这种没有显式初值的变量C 标准要求它初始为 0。如果每个都往 Flash 里存 100 个 0太浪费空间了。所以编译器把它归到.bss段启动时统一清零即可。这里有个实操技巧如果你发现某个全局变量上电后不是预期的初值先检查启动文件的搬运逻辑有没有被裁剪或者链接脚本里.data段的加载地址和运行地址有没有配对。我见过有人为了省 Flash 手动改了链接脚本结果.data段没搬所有带初值的全局变量全是随机值。3.3 SystemInit 里藏着的时钟陷阱SystemInit是标准库提供的函数默认会把时钟配置成芯片能跑的最高频率。但这里有几个坑第一如果你用的是外部晶振HSE而板子上晶振没焊或者焊错了SystemInit里等待 HSE 就绪的循环会一直卡住程序根本到不了main。现象就是“下载成功但不运行”。解决办法是在system_stm32f10x.c里把HSE_VALUE改成你实际的晶振频率或者干脆先用 HSI 跑起来。第二SystemInit在main之前执行意味着你在main里配置的任何时钟都是“二次配置”。如果你在main里重新配了 PLL要确保先关掉再重配否则可能触发硬件错误。第三有些工程为了加快启动速度会把SystemInit里的时钟配置精简掉只保留必要的部分。这在低功耗场景很常见但要注意别把 Flash 等待周期Latency配错否则高频下取指会出错。4. 从 main 到 RTOSuC/OS-II 的启动链路4.1 main 函数里的初始化顺序有讲究裸机程序的main通常就是初始化外设然后进while(1)。但用了 uC/OS-II 之后main的职责变成了“初始化硬件 创建任务 启动调度器”。顺序很重要int main(void) { BSP_Init(); // 硬件初始化 OSInit(); // uC/OS-II 内核初始化 OSTaskCreate(Task1, ...); // 创建任务 OSTaskCreate(Task2, ...); OSStart(); // 启动调度器永不返回 }OSInit会初始化就绪表、优先级表、事件控制块等内核数据结构。OSTaskCreate把任务的控制块TCB挂到就绪表上。OSStart则找出最高优先级的就绪任务然后切换过去。关键点在于OSStart之前不能调用任何会触发调度的函数因为调度器还没启动。你在main里如果调了OSTimeDly行为是未定义的。4.2 OSStart 如何找到第一个任务OSStart的实现逻辑大致是void OSStart(void) { if (OSRunning FALSE) { OSStartHighRdy(); // 移植层函数 } }OSStartHighRdy是移植层代码通常用汇编写。它会调用OSTaskSwHook如果使能了。设置OSRunning TRUE。从最高优先级任务的 TCB 中取出栈指针恢复到 CPU 的 SP。弹出任务上下文寄存器然后执行中断返回指令。这个“中断返回指令”很关键。因为任务的上下文是模拟成“中断发生时压栈”的格式保存的所以用BX LR或者POP {PC}这种返回方式CPU 会以为是从中断返回从而恢复任务的运行状态。4.3 第一个任务的栈里预置了什么创建任务时OSTaskStkInit会在任务栈里预先填好一份“假的上下文”。以 Cortex-M3 为例这份上下文包括xPSR初始值通常是0x01000000表示 Thumb 状态。PC任务函数的入口地址。LR任务返回地址通常填一个死循环或者OSTaskDel。R12、R3、R2、R1初始值随意一般填 0。R0任务函数的参数。R11到R4手动压栈的寄存器初始值随意。当OSStartHighRdy恢复这个栈时CPU 从栈里弹出xPSR、PC、LR等PC 被设成任务函数入口于是任务就开始跑了。这就是“第一个任务是怎么起来的”的底层真相——它不是被调用起来的而是被“伪造的中断返回”带起来的。5. PendSV任务切换的幕后推手5.1 为什么任务切换要用 PendSVCortex-M 有三个系统异常SVC、PendSV、SysTick。SVC 用于系统调用SysTick 用于时间基准而 PendSV 专门用来做上下文切换。为什么不用普通中断因为上下文切换需要在一个“安全”的时机进行。如果在一个高优先级中断里直接切换任务可能会打乱中断嵌套的逻辑。PendSV 的优先级被设为最低这样它就会在所有其他中断处理完之后才执行保证了切换的原子性和安全性。uC/OS-II 在 Cortex-M 上的移植就是把OSCtxSw和OSIntCtxSw都指向 PendSV 的触发。当任务主动让出 CPU比如OSTimeDly或者时间片用完时内核挂起 PendSV 异常等当前中断处理完PendSV 服务函数执行真正的切换。5.2 PendSV_Handler 的汇编逻辑PendSV 的处理函数是移植层的核心典型实现如下简化版PendSV_Handler: MRS R0, PSP ; 获取进程栈指针 CBZ R0, PendSV_NoSave ; 如果是第一次跳过保存 STMDB R0!, {R4-R11} ; 手动保存 R4-R11 LDR R1, OSTCBCur LDR R1, [R1] STR R0, [R1] ; 更新当前 TCB 的栈指针 PendSV_NoSave: PUSH {LR} BL OSTaskSwHook ; 调用钩子函数 LDR R0, OSPrioCur LDR R1, OSPrioHighRdy LDRB R2, [R1] STRB R2, [R0] ; 更新当前优先级 LDR R0, OSTCBCur LDR R1, OSTCBHighRdy LDR R2, [R1] STR R2, [R0] ; 更新当前 TCB LDR R0, [R2] ; 取出新任务的栈指针 LDMIA R0!, {R4-R11} ; 恢复 R4-R11 MSR PSP, R0 ; 更新进程栈指针 ORR LR, LR, #0x04 ; 确保返回后使用 PSP POP {PC} ; 异常返回恢复剩余上下文这段代码的精髓在于R4-R11 是手动保存的R0-R3、R12、LR、PC、xPSR 是硬件自动保存的。因为 Cortex-M 的异常进入机制只自动压栈一部分寄存器剩下的必须由软件补上。这也是为什么任务栈的布局要严格按照这个顺序来初始化。5.3 第一次切换的特殊处理注意上面代码里的CBZ R0, PendSV_NoSave。第一次触发 PendSV 时OSTCBCur还没有有效的栈指针因为还没有任务运行过所以跳过保存步骤直接进入切换逻辑。这个细节如果处理不好第一次切换就会把垃圾数据当成上下文恢复直接硬件错误。我在移植 uC/OS-II 到一颗国产 Cortex-M0 芯片时就因为 M0 没有CBZ指令需要改成CMPBEQ而且 M0 的压栈寄存器和 M3 略有不同调了好一阵才稳定。6. 实操用调试器亲眼看到启动过程6.1 在 Reset_Handler 下断点光看代码不够直观最好的学习方式是用调试器单步跟一遍。以 Keil MDK 为例打开工程进入 Debug 模式。在startup_stm32f10x_md.s的Reset_Handler第一行下断点。复位芯片程序会停在断点处。打开 Register 窗口观察 SP 和 PC 的值。此时 SP 应该等于__initial_sp的值PC 指向Reset_Handler。你可以打开 Memory 窗口查看0x00000000处的数据前 4 字节就是栈顶地址接着 4 字节就是Reset_Handler的地址。6.2 观察 .data 搬运前后的内存变化在SystemInit调用前后各下一个断点然后在 Memory 窗口查看某个带初值的全局变量的地址。搬运前该地址在 RAM 里可能是随机值搬运后应该变成你设定的初值。如果你用的是 GCC 工具链可以在链接脚本里找到_sidata、_sdata、_edata这些符号它们分别标记了.data段在 Flash 中的起始、在 RAM 中的起始和结束。搬运循环就是在这几个地址之间做拷贝。6.3 用 SysTick 和 PendSV 观察任务切换在 uC/OS-II 跑起来之后你可以在PendSV_Handler入口下断点然后全速运行。每次任务切换都会命中这个断点。观察OSTCBCur和OSTCBHighRdy的变化你就能直观看到任务是怎么被换出去的。有个小技巧在 Watch 窗口里添加OSTCBCur-OSTCBPrio和OSTCBHighRdy-OSTCBPrio每次断点命中时对比这两个值就能知道是从哪个优先级切到哪个优先级。7. 常见问题与排查速查表启动相关的问题往往现象诡异因为出问题的阶段在main之前你连打印都打不出来。下面这张表是我这些年攒下来的经验按现象查原因现象可能原因排查方法下载成功但不运行HSE 未起振卡在 SystemInit换 HSI 测试或检查晶振焊接进 main 前就 HardFault栈顶地址错误或栈溢出查 .map 中 __initial_sp增大栈全局变量初值不对.data 段未搬运检查启动文件和链接脚本中断进不去VTOR 未重定位检查 SCB-VTOR 是否指向正确向量表任务切换后跑飞任务栈初始化错误检查 OSTaskStkInit 的栈布局第一次切换死机PendSV 首次保存逻辑有误检查 OSTCBCur 是否为空Bootloader 跳 App 失败未关中断、未重定位 VTOR跳转前 __disable_irqApp 里设 VTOR低功耗唤醒后异常时钟未恢复唤醒后重新配置 SystemInit再补充几个排查技巧技巧一用 HardFault_Handler 抓现场。在HardFault_Handler里加一段汇编把压栈的 PC 值取出来存到全局变量然后调试时看这个变量就能知道是从哪条指令跑飞的。技巧二检查栈使用量。在栈顶附近填一个魔数比如0xDEADBEEF运行一段时间后看这个魔数有没有被覆盖就能估算栈的峰值使用量。uC/OS-II 提供了OSTaskStkChk函数可以直接查任务栈的使用情况。技巧三Bootloader 跳转前一定要关中断。我见过太多人跳转后 App 跑飞就是因为 Bootloader 里开着的中断在 App 里没有对应的处理函数。跳转前执行__disable_irq()App 启动后再__enable_irq()能避免大部分问题。技巧四注意 MSP 和 PSP 的切换。裸机程序用的是 MSP主栈指针RTOS 任务用的是 PSP进程栈指针。如果你在任务里调用了会切换栈的代码或者中断处理里错误地操作了 PSP就会出现栈混乱。uC/OS-II 在 PendSV 里通过修改 LR 的 bit2 来确保返回后使用 PSP这个细节不能错。8. 几个容易被忽略的启动细节8.1 中断向量表的对齐要求Cortex-M 要求向量表的起始地址必须按异常数量的多少对齐。最少要 128 字节对齐因为前 32 个异常占 128 字节如果用了更多外设中断可能需要 256 或 512 字节对齐。你在链接脚本里如果没做对齐重定位 VTOR 后可能取到错误的向量。8.2 __libc_init_array 的作用GCC 工具链下Reset_Handler会调用__libc_init_array它负责调用所有标记为constructor的函数以及初始化 C 的全局对象。如果你用 C 写 STM32这个函数不调用全局对象的构造函数就不会执行现象是“对象成员全是随机值”。8.3 启动时间优化有些场景对启动时间敏感比如需要快速响应的工业控制。优化手段包括精简SystemInit里的时钟配置、把不必要的外设初始化延后、用__attribute__((section))把关键代码放到 RAM 里执行。我做过一个项目把启动时间从 80ms 压到 12ms主要就是砍掉了冗余的时钟等待和 Flash 预取配置。8.4 双固件与向量表重映射做 OTA 升级时通常有两个固件区Bootloader 和 App。App 的向量表不在0x08000000所以 App 启动后必须重定位 VTOR。同时链接脚本里 App 的起始地址要和实际烧录地址一致否则中断向量里的函数地址全是错的。这个“地址一致性”是 Bootloader 开发里最容易出错的地方建议在链接脚本里用宏定义统一管理。9. 我个人的一点经验折腾启动流程这些年最大的体会是不要怕看汇编。启动文件那几十行汇编是整个系统最底层的骨架看懂了它你对“程序是怎么跑起来的”就有了根上的理解。很多人觉得汇编难其实启动文件里的汇编就那么几种指令加载地址、跳转、压栈弹栈看多了就熟了。另一个体会是调试器是你最好的老师。与其对着文档猜不如实际下个断点看看寄存器、看看内存真相一目了然。我刚开始学的时候就是把Reset_Handler到main的每一步都单步走了一遍走完之后很多模糊的概念一下子就清晰了。最后分享一个习惯每次新建工程我都会先确认三件事——栈顶地址对不对、向量表位置对不对、时钟配置对不对。这三件事确认了后面基本不会出大问题。启动流程这东西平时不显山不露水但一旦出问题就是“下载成功不运行”这种让人抓狂的现象提前把它搞明白能省下大量排查时间。
返回列表