1. 上电那一刻,芯片到底在干什么
很多人写STM32代码,main函数里第一行灯就亮了,觉得理所当然。但如果你把调试器拔掉、让板子自己上电,从按下电源开关到main函数执行,中间其实跑了一大段你从来没写过的代码。这段代码就是启动流程,它决定了你的变量能不能被正确初始化、中断向量表放在哪里、堆栈指针从哪来。
我刚开始接触STM32的时候,遇到过一个很典型的问题:程序在调试器下跑得好好的,脱机之后就死机。查了半天,最后发现是启动文件里的堆栈大小设小了,调试时调试器帮忙兜住了,脱机就露馅。从那以后我就养成了一个习惯,每做一个新项目,先把启动流程从头到尾捋一遍,心里有数了再往下写业务代码。
这篇文章面向的是已经能点亮LED、能跑通串口,但对“芯片上电之后到底发生了什么”还比较模糊的嵌入式开发者。我会从复位向量开始,一路讲到第一个任务是怎么被调度起来的,中间涉及启动文件、向量表重定位、C运行时初始化、以及uC/OS-II这类RTOS的启动衔接。涉及到的芯片以STM32F1和F4系列为主,其他Cortex-M内核的芯片思路一致。
2. 复位向量与启动模式:芯片从哪里取第一条指令
2.1 Cortex-M的复位行为
Cortex-M内核的复位行为和经典的ARM7/ARM9不一样。ARM7/ARM9复位后从固定地址0x00000000取指令,而Cortex-M是从向量表里取两个值:第一个是初始堆栈指针(MSP),第二个是复位处理函数的入口地址。
具体来说,芯片复位后,硬件会自动做这几件事:
- 从地址0x00000000读取第一个字,加载到MSP(主堆栈指针)
- 从地址0x00000004读取第二个字,加载到PC(程序计数器)
- 然后开始执行PC指向的代码
这就是为什么启动文件里最前面放的是向量表,而且第一个表项是栈顶地址,第二个表项是Reset_Handler。你可以打开任何一个STM32工程的启动文件(比如startup_stm32f103xb.s),开头就能看到类似这样的定义:
__Vectors DCD __initial_sp ; Top of Stack DCD Reset_Handler ; Reset Handler DCD NMI_Handler ; NMI Handler DCD HardFault_Handler ; Hard Fault Handler ...__initial_sp是链接器根据分散加载文件算出来的栈顶地址,Reset_Handler就是复位处理函数。这两个值在编译链接阶段就确定好了,烧录到Flash的起始位置。
2.2 启动模式对向量表位置的影响
STM32有一个BOOT0引脚,配合BOOT1(有些型号是选项字节),决定芯片从哪块存储区启动:
| BOOT0 | BOOT1 | 启动区域 | 向量表物理地址 |
|---|---|---|---|
| 0 | x | 主Flash | 0x08000000 |
| 1 | 0 | 系统存储器 | 0x1FFFF000 |
| 1 | 1 | 内置SRAM | 0x20000000 |
这里有个关键点:Cortex-M内核复位时是从0x00000000取向量表的,但STM32通过硬件映射,把不同启动区域映射到了0x00000000。比如从主Flash启动时,0x08000000被映射到0x00000000,所以你读0x00000000实际上读的是0x08000000的内容。
这个映射关系很重要,后面讲向量表重定位的时候还会用到。我见过有人在SRAM里调试程序,忘了改向量表偏移,结果中断一进就HardFault,就是因为向量表还在Flash的地址上,而代码已经搬到SRAM了。
2.3 启动文件里的栈和堆
启动文件里除了向量表,还有两个重要的符号:__initial_sp和Heap_Size、Stack_Size。栈的大小在启动文件开头定义:
Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_sp0x400就是1KB的栈空间。这个值很多人从来不改,但如果你用了RTOS、开了比较大的局部变量、或者中断嵌套比较深,1KB可能不够。栈溢出不会报错,只会莫名其妙地死机或者改变量值,非常难查。
堆的大小也是类似的定义,Heap_Size决定malloc能用的空间。如果你不用动态内存分配,可以把堆设成0,省点RAM。
注意:栈顶地址
__initial_sp是栈的高地址端,Cortex-M的栈是向下生长的。栈溢出是往低地址方向溢出,可能覆盖到其他变量区域。
3. 从Reset_Handler到main:C运行时初始化
3.1 Reset_Handler做了哪几件事
复位向量指向的Reset_Handler通常长这样:
Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT __main IMPORT SystemInit LDR R0, =SystemInit BLX R0 LDR R0, =__main BX R0 ENDP流程很清晰:先调用SystemInit,然后跳到__main。注意这里不是直接跳到用户写的main函数,而是跳到C库的__main,由它来完成C运行时环境的初始化,最后才调用用户的main。
SystemInit是STM32库提供的函数,主要做这几件事:
- 配置时钟系统(HSI、HSE、PLL)
- 设置中断向量表偏移(通过SCB->VTOR)
- 使能FPU(如果有的话)
在标准库和HAL库里,SystemInit的实现略有不同,但核心逻辑一致。以F1系列为例,SystemInit会把时钟切到HSE加PLL,让系统跑在72MHz。如果你用的是内部HSI,或者外部晶振频率不是8MHz,就需要改system_stm32f1xx.c里的宏定义。
3.2 __main里的分散加载
__main是ARM C库提供的入口,它做的最重要的一件事是分散加载(scatter loading)。简单说,就是把Flash里的初始化数据搬到RAM里,把未初始化的数据区清零。
具体来说,编译链接后,程序的数据分为几个段:
| 段名 | 内容 | 运行时位置 | 是否初始化 |
|---|---|---|---|
| .text | 代码 | Flash | 是 |
| .rodata | 常量 | Flash | 是 |
| .data | 已初始化全局变量 | RAM | 从Flash拷贝 |
| .bss | 未初始化全局变量 | RAM | 清零 |
| .stack | 栈 | RAM | 不初始化 |
| .heap | 堆 | RAM | 不初始化 |
.data段里的变量,比如int g_var = 10;,它的初始值10存在Flash里,运行时需要拷贝到RAM。.bss段里的变量,比如int g_arr[100];,运行时需要清零。这些工作都是__main里的__scatterload完成的。
如果你自己写链接脚本(比如用GCC开发STM32),这部分逻辑需要自己实现。GCC的启动文件里通常有一段汇编,把.data段从Flash拷到RAM,把.bss段清零:
/* Copy the data segment initializers from flash to SRAM */ ldr r1, =_sidata ldr r2, =_sdata ldr r3, =_edata movs r4, #0 b LoopCopyDataInit CopyDataInit: ldr r5, [r1, r4] str r5, [r2, r4] adds r4, r4, #4 LoopCopyDataInit: adds r5, r2, r4 cmp r5, r3 bcc CopyDataInit /* Zero fill the bss segment */ ldr r2, =_sbss ldr r3, =_ebss movs r4, #0 b LoopFillZerobss FillZerobss: str r4, [r2] adds r2, r2, #4 LoopFillZerobss: cmp r2, r3 bcc FillZerobss这段代码看起来简单,但有个坑:如果你在.data段里放了很大的数组,拷贝时间会很长,影响启动速度。我做过一个项目,有人在.data段里放了一个16KB的查找表,结果启动时间多了好几毫秒。后来改成const放到.rodata段,直接从Flash读,启动就快了。
3.3 向量表重定位
前面提到,STM32复位时向量表被映射到0x00000000。但如果你用了Bootloader加App的架构,App的向量表就不在0x08000000了,需要重定位。
重定位的方法有两种:
第一种是在SystemInit里改SCB->VTOR寄存器:
SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET;VECT_TAB_OFFSET是App相对于Flash起始地址的偏移。比如Bootloader占用了16KB,App从0x08004000开始,那偏移就是0x4000。
第二种是在启动文件里改向量表的位置,但这种方法不灵活,不推荐。
注意:改
SCB->VTOR必须在使能任何中断之前完成。如果在SystemInit里改,那没问题,因为SystemInit是在__main之前调用的,中断还没使能。但如果你在main里才改,就要先把总中断关掉。
我踩过一个坑:Bootloader跳转到App之后,App里忘了改VTOR,结果串口中断一进来就跑到Bootloader的中断处理函数里去了。因为向量表还在Bootloader的位置,中断向量号对应的还是Bootloader的函数。这种问题现象很诡异,调试器看中断向量表是对的,但实际跑起来就是不对,因为调试器会帮你重定位。
4. 从main到第一个任务:RTOS启动衔接
4.1 裸机main和RTOS main的区别
裸机程序的main通常是一个死循环,所有逻辑都在里面轮询。而用了RTOS之后,main的主要工作是初始化硬件、创建任务、启动调度器,然后调度器接管CPU,永远不会返回。
以uC/OS-II为例,典型的main长这样:
int main(void) { BSP_Init(); OSInit(); OSTaskCreate(Task1, NULL, &Task1Stk[STK_SIZE-1], TASK1_PRIO); OSTaskCreate(Task2, NULL, &Task2Stk[STK_SIZE-1], TASK2_PRIO); OSStart(); return 0; }OSStart之后,调度器会从就绪表中找到最高优先级的任务,然后切换到它的上下文。这个切换过程涉及PendSV异常,是理解RTOS启动的关键。
4.2 PendSV在任务切换中的角色
Cortex-M内核有三个异常和RTOS调度密切相关:SVC、PendSV、SysTick。
- SVC:系统服务调用,用于任务主动请求内核服务,比如创建任务、申请信号量
- PendSV:可挂起的系统调用,用于任务切换
- SysTick:系统滴答定时器,提供时间基准
为什么任务切换要用PendSV而不是直接在SysTick里切?因为SysTick是周期中断,如果直接在SysTick中断里做任务切换,可能会打断一个正在执行的中断服务程序,导致中断嵌套混乱。PendSV的优先级可以设成最低,这样它会在所有其他中断处理完之后才执行,保证切换的安全性。
uC/OS-II在OSStart里会触发一次PendSV,让调度器切换到第一个任务。具体流程是:
OSStart找到最高优先级就绪任务- 设置
OSTCBCur指向该任务的控制块 - 触发PendSV异常
- PendSV处理程序恢复该任务的上下文
- 任务开始执行
PendSV处理程序的汇编代码通常长这样:
PendSV_Handler: MRS R0, PSP CBZ R0, PendSV_Handler_Nosave STMFD R0!, {R4-R11} LDR R1, =OSTCBCur LDR R1, [R1] STR R0, [R1] PendSV_Handler_Nosave: PUSH {R14} LDR R0, =OSTaskSwHook BLX R0 POP {R14} LDR R0, =OSPrioCur LDR R1, =OSPrioHighRdy LDRB R2, [R1] STRB R2, [R0] LDR R0, =OSTCBCur LDR R1, =OSTCBHighRdy LDR R2, [R1] STR R2, [R0] LDR R0, [R2] LDMFD R0!, {R4-R11} MSR PSP, R0 ORR R14, R14, #0x04 BX R14这段代码的核心是保存当前任务的R4-R11寄存器到任务栈,然后从新任务的栈里恢复R4-R11,最后更新PSP。R0-R3、R12、LR、PC、xPSR这些寄存器由硬件自动保存和恢复,不需要软件干预。
4.3 第一个任务的栈初始化
任务创建的时候,需要初始化任务的栈,让PendSV第一次切换到该任务时,能正确恢复上下文。uC/OS-II的OSTaskStkInit函数负责这件事:
OS_STK *OSTaskStkInit(void (*task)(void *pd), void *p_arg, OS_STK *ptos, INT16U opt) { OS_STK *stk; stk = ptos; *(stk) = (OS_STK)0x01000000L; /* xPSR */ *(--stk) = (OS_STK)task; /* PC */ *(--stk) = (OS_STK)0xFFFFFFFEL; /* LR */ *(--stk) = (OS_STK)0x12121212L; /* R12 */ *(--stk) = (OS_STK)0x03030303L; /* R3 */ *(--stk) = (OS_STK)0x02020202L; /* R2 */ *(--stk) = (OS_STK)0x01010101L; /* R1 */ *(--stk) = (OS_STK)p_arg; /* R0 */ *(--stk) = (OS_STK)0x11111111L; /* R11 */ ... return stk; }这里的关键是PC被设成了任务的入口地址,R0被设成了任务参数。当PendSV第一次切换到该任务时,硬件从栈里恢复PC和R0,任务函数就开始执行了。
xPSR的初始值0x01000000表示Thumb状态,因为Cortex-M只支持Thumb指令集。如果这个值设错了,任务一跑就HardFault。
注意:任务栈的大小要仔细估算。除了函数调用深度,还要考虑中断嵌套时硬件自动压栈的开销。Cortex-M在中断时会自动压栈8个字(32字节),如果中断嵌套深,这个开销会累积。
5. 常见问题与排查技巧实录
5.1 启动阶段常见问题速查
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 脱机死机,调试正常 | 栈溢出、堆溢出、未初始化变量 | 增大栈/堆,检查.bss段是否清零 |
| 中断一进就HardFault | 向量表未重定位、中断优先级配置错误 | 检查SCB->VTOR,检查NVIC配置 |
| 任务切换后跑飞 | 任务栈初始化错误、PendSV优先级不是最低 | 检查OSTaskStkInit,检查PendSV优先级 |
| 启动时间过长 | .data段太大、时钟配置慢 | 把大数组改成const,优化时钟配置 |
| 变量初值不对 | .data段拷贝失败、链接脚本错误 | 检查链接脚本,检查启动文件 |
5.2 栈溢出的排查方法
栈溢出是启动阶段最隐蔽的问题之一。我常用的排查方法是在栈的边界填充特定的值,然后定期检查这些值是否被改写。
在启动文件里,栈的定义是这样的:
Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_spStack_Mem是栈的起始地址(低地址端),__initial_sp是栈顶地址(高地址端)。栈溢出是往低地址方向溢出,所以可以在Stack_Mem附近填充一些标记值,比如0xDEADBEEF,然后定期检查这些值是否还在。
更专业的方法是使用MPU(内存保护单元),把栈的边界设成不可访问区域,一旦溢出就触发MemManage异常。但这个方法需要配置MPU,稍微复杂一些。
5.3 向量表重定位的坑
Bootloader加App的架构中,向量表重定位是必做的一步。但有几个细节容易忽略:
第一,SCB->VTOR的值必须是向量表对齐的。Cortex-M的向量表要求至少128字节对齐,实际上STM32的Flash扇区更大,所以通常按扇区对齐。
第二,App的链接脚本里,Flash的起始地址要改成App的实际地址。比如Bootloader占用16KB,App从0x08004000开始,那链接脚本里Flash的ORIGIN就要改成0x08004000。
第三,跳转到App之前,要先把所有中断关掉,把外设复位,避免Bootloader里的中断在App里触发。
我见过一个案例:Bootloader里开了串口中断,跳转到App之前没关,结果App里串口中断一进来就跑到Bootloader的中断处理函数里去了。因为向量表虽然重定位了,但NVIC里使能的中断还在,中断向量号对应的还是Bootloader的函数。
5.4 RTOS启动失败的排查
RTOS启动失败通常表现为:调度器启动后,任务不执行,或者执行一次就死机。
排查思路:
- 检查
OSStart之前是否创建了至少一个任务 - 检查任务栈是否足够大
- 检查PendSV和SysTick的优先级是否配置正确
- 检查
OSTaskStkInit的返回值是否正确 - 检查中断向量表里PendSV_Handler和SysTick_Handler是否指向了RTOS的实现
uC/OS-II在OSStart里会检查就绪表是否为空,如果为空会调用OSStartHang死循环。所以如果任务不执行,先看看是不是任务没创建成功。
还有一个常见问题是SysTick的配置。uC/OS-II需要SysTick提供时间基准,如果SysTick没配置或者配置错了,任务调度的时间片就不对。通常SysTick的中断频率设成100Hz到1000Hz,对应10ms到1ms的时间片。
6. 几个容易被忽略的启动细节
6.1 时钟配置对启动时间的影响
STM32复位后默认使用HSI(内部高速时钟),F1系列是8MHz,F4系列是16MHz。如果SystemInit里配置了HSE加PLL,启动时间会增加,因为HSE起振需要时间。
HSE的起振时间通常是几毫秒,具体取决于晶振和负载电容。如果启动时间要求很严格,可以考虑先用HSI跑,等HSE稳定后再切换。但这样代码会复杂一些,一般项目没必要。
还有一个细节:SystemInit里配置Flash等待周期(Latency)的时机。如果时钟频率提高了,但Flash等待周期没改,取指就会出错。STM32的库函数里通常会根据时钟频率自动设置等待周期,但如果你自己写时钟配置,别忘了这一步。
6.2 看门狗对启动的影响
如果使能了独立看门狗(IWDG),启动时间太长可能会导致看门狗复位。IWDG一旦启动就不能关闭,只能复位。所以如果启动流程里有耗时操作,比如等待外部芯片就绪,要么先不启动IWDG,要么在启动过程中定期喂狗。
窗口看门狗(WWDG)也有类似的问题,而且WWDG有窗口限制,喂狗太早也会复位。
6.3 启动文件里的弱符号
启动文件里所有的中断处理函数都定义成了弱符号(WEAK),比如:
NMI_Handler PROC EXPORT NMI_Handler [WEAK] B . ENDP弱符号的意思是:如果你在C代码里定义了同名函数,链接器会用你的函数覆盖启动文件里的。如果你没定义,就用启动文件里的死循环。
这个机制很方便,但有个坑:如果你函数名拼错了,链接器不会报错,中断一来就跑到死循环里去了。比如你把USART1_IRQHandler拼成了USART1_IRQhandler,链接器不会提示,但串口中断就是进不去。
我一般会在C代码里把用到的中断处理函数都显式定义一遍,哪怕里面是空的,这样至少能保证中断向量是对的。
6.4 启动阶段的调试技巧
调试启动阶段的问题,硬件断点比软件断点好用。因为软件断点需要修改Flash内容,而启动阶段可能还没初始化好Flash。硬件断点直接比较PC值,不依赖代码修改。
另外,可以在启动流程的关键节点翻转一个GPIO,用示波器看波形,测量各阶段的耗时。这个方法很直观,比在调试器里单步执行快得多。
如果连main都进不去,可以在Reset_Handler里翻转GPIO,确认芯片是否真的复位了。如果GPIO没反应,可能是时钟没配置对,或者芯片根本没启动。
7. 从启动流程看系统设计
把启动流程捋清楚之后,你会发现很多系统设计问题都能在这里找到根源。
比如启动时间优化,本质上就是减少.data段拷贝、加快时钟配置、推迟非必要初始化。我做过一个项目,启动时间要求200ms以内,最后就是把大数组从.data段移到.rodata段,把外部芯片的初始化放到任务里延迟执行,才勉强达标。
再比如Bootloader设计,向量表重定位、中断管理、外设复位,这些细节决定了Bootloader能不能稳定跳转到App。我见过太多Bootloader跳转失败的案例,最后查下来都是这些细节没处理好。
RTOS的启动衔接也是,PendSV的优先级、任务栈的初始化、SysTick的配置,任何一个环节出错都会导致调度器跑不起来。理解了启动流程,这些问题就不再是玄学,而是可以一步步排查的工程问题。
最后分享一个我常用的调试方法:在启动流程的每个关键节点加一个GPIO翻转,用逻辑分析仪抓波形。这样不仅能确认流程是否走到,还能测量每个阶段的耗时。比在调试器里单步执行高效得多,而且脱机也能用。