1. 上电那一刻,芯片到底在忙什么
很多人调 STM32 调了几年,GPIO、串口、定时器玩得飞起,但一旦遇到“程序跑不起来”“HardFault 一上电就挂”“RTOS 起不来”这类问题,就开始抓瞎。根子往往不在你写的那几行业务代码,而在从上电到main()之间那段“看不见的路”。这篇就把 STM32 从复位向量一路走到第一个任务的全流程拆开讲,顺带把 uC/OS-II 的启动衔接和 PendSV 的角色说透。
先把结论摆前面:STM32 上电后,硬件先根据 BOOT 引脚决定从哪里取复位向量,取到向量表头两个字——第一个是初始 MSP(主堆栈指针),第二个是 Reset_Handler 地址。CPU 把这两个值装进 SP 和 PC,就开始跑启动文件。启动文件干三件事:初始化堆栈、把.data从 Flash 搬到 RAM、把.bss清零,然后调用SystemInit()配时钟,最后跳__main(注意不是main),由 C 库完成分散加载后再进main()。进了main()你初始化外设、建任务、调OSStart(),uC/OS-II 挑最高优先级任务,靠 SVC 异常切到第一个任务,之后任务切换全靠 PendSV。
这条链路里任何一环出问题,现象都可能是“上电没反应”。下面按顺序拆。
2. 复位向量与启动模式:芯片从哪里开始跑
2.1 三种启动模式与向量表映射
STM32 的 BOOT0/BOOT1 引脚决定上电后哪块存储被映射到0x0000_0000。以常见的 F1/F4 为例:
| BOOT1 | BOOT0 | 启动区域 | 映射地址 |
|---|---|---|---|
| x | 0 | 主 Flash | 0x0800_0000 映射到 0x0000_0000 |
| 0 | 1 | 系统存储器 | 出厂 Bootloader |
| 1 | 1 | 内嵌 SRAM | 0x2000_0000 映射到 0x0000_0000 |
关键点:Cortex-M 复位后从0x0000_0000取 MSP,从0x0000_0004取复位向量。所以“映射”这件事决定了你烧在 Flash 里的向量表能不能被 CPU 看到。很多人用 SRAM 调试时忘了改SCB->VTOR,中断一进来就飞,就是这个原因。
2.2 向量表前两个字长什么样
向量表本质是一个 32 位数组,放在 Flash 起始处。前两项:
__Vectors: DCD __initial_sp ; 0x0000_0000 初始 MSP DCD Reset_Handler ; 0x0000_0004 复位向量 DCD NMI_Handler DCD HardFault_Handler ...__initial_sp是链接器算出来的栈顶地址,通常指向 RAM 末尾(栈向下生长)。如果你在.ld或.sct里把栈放错位置,比如和堆重叠,上电后第一次压栈就可能踩坏数据。我见过一个项目,栈顶设成了0x2000_0000(RAM 起始),结果一上电进Reset_Handler压栈就写到了非法区域,直接锁死。
提示:用
fromelf --text -c或objdump -s反汇编看向量表前 8 字节,确认第一个字是合理的 RAM 地址(如0x2000_xxxx),第二个字是奇数(Thumb 状态,最低位为 1)。
2.3 重定位向量表:VTOR 的用法
当你的程序不是从 Flash 起始跑,比如做了 Bootloader + App 分区,App 的向量表在0x0800_8000,那 App 启动时必须把SCB->VTOR改成0x0800_8000。否则中断还是跳到 Bootloader 的向量表,现象就是“App 里中断不响应”或“跳到了 Bootloader 的 Handler”。
// App 的 SystemInit 或 main 开头 SCB->VTOR = 0x08008000; __DSB(); __ISB();__DSB保证写操作完成,__ISB清流水线,这两句别省,省了在部分型号上会偶发取到旧向量。
3. 启动文件逐行拆:从 Reset_Handler 到 main
3.1 栈和堆的初始化
启动文件开头定义栈大小和堆大小:
Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_sp0x400是 1KB,对裸机够用,但跑 uC/OS-II 且任务里用大局部数组就不够了。栈溢出是嵌入式最隐蔽的 bug 之一,表现千奇百怪。建议在调试阶段把栈填成0xDEADBEEF,跑一段时间后看还剩多少,或者用__initial_sp减去当前 SP 估算峰值。
堆一般给0x200,如果你不用malloc可以设 0。但注意 uC/OS-II 的OSMemCreate用的是自己的内存池,不依赖 C 堆,所以堆小点没关系。
3.2 数据段搬运与 BSS 清零
这是启动文件里最核心的两段循环:
; 把 .data 从 Flash 搬到 RAM LDR R0, =|Image$$ER_IROM1$$RO$$Limit| LDR R1, =|Image$$RW_IRAM1$$Base| LDR R3, =|Image$$RW_IRAM1$$ZI$$Base| CMP R0, R1 BEQ BSS_INIT COPY_LOOP: CMP R1, R3 LDRCC R2, [R0], #4 STRCC R2, [R1], #4 BCC COPY_LOOP ; 清零 .bss BSS_INIT: LDR R1, =|Image$$RW_IRAM1$$ZI$$Base| LDR R3, =|Image$$RW_IRAM1$$ZI$$Limit| MOV R2, #0 ZERO_LOOP: CMP R1, R3 STRCC R2, [R1], #4 BCC ZERO_LOOP为什么要有这一步?因为全局变量分两类:初始化过的(.data)在 Flash 里存了初值,运行时必须在 RAM;未初始化的(.bss)按 C 标准要清零。如果你发现某个全局变量上电后是随机值,八成是.bss没清干净,或者链接脚本里段边界算错了。
注意:用 GCC 时这些符号名不一样,是
_sidata、_sdata、_edata、_sbss、_ebss,在.ld文件里定义。换工具链时启动文件必须跟着换,别直接抄。
3.3 SystemInit 与时钟树
Reset_Handler最后调SystemInit(),这个函数在system_stm32fxxx.c里,主要干两件事:配时钟(HSE、PLL、AHB/APB 分频)和设 VTOR。以 F103 为例,外部 8MHz 晶振,目标 72MHz:
HSE = 8MHz PLL 倍频 = 9 -> 72MHz AHB 分频 = 1 -> HCLK = 72MHz APB1 分频 = 2 -> PCLK1 = 36MHz APB2 分频 = 1 -> PCLK2 = 72MHz配完要等HSERDY、PLLRDY标志位,别用死延时。我见过有人把while(!(RCC->CR & RCC_CR_HSERDY))写成if,晶振没起振就直接往下跑,结果时钟还是默认的 8MHz HSI,串口波特率全错。
3.4 __main 不是 main
Reset_Handler最后是LDR R0, =__main然后BX R0。这个__main是 C 库的入口,它负责调用__scatterload完成分散加载(把各段按链接脚本放到正确位置),再调__rt_entry,最后才进你的main()。所以如果你在main()之前想干点啥,别去改__main,用__attribute__((constructor))或者在SystemInit里加。
4. 从 main 到 uC/OS-II 第一个任务
4.1 main 里的初始化顺序
裸机时代main里就是while(1)加业务。上了 uC/OS-II 后,main的职责变成“初始化 + 建任务 + 启动调度器”:
int main(void) { BSP_Init(); // 时钟、GPIO、串口 OSInit(); // 初始化 uC/OS-II 内核 OSTaskCreate(TaskStart, // 起始任务 (void *)0, &TaskStartStk[TASK_STK_SIZE - 1], TASK_START_PRIO); OSStart(); // 启动调度,不返回 return 0; }顺序不能乱:OSInit必须在任何OSxxx调用之前;OSTaskCreate必须在OSStart之前。OSStart之后 CPU 就交给内核了,main的栈也不再使用。
4.2 OSStart 如何挑第一个任务
OSStart的逻辑很直接:从就绪表里找最高优先级任务,然后调OSStartHighRdy()。这个函数是汇编写的,干三件事:
- 调
OSTaskSwHook() - 把
OSPrioCur和OSPrioHighRdy设成最高优先级 - 从该任务的 TCB 里恢复 SP,然后
POP寄存器,BX LR跳到任务函数
关键点:第一个任务不是靠 PendSV 切过去的,而是直接恢复上下文跳过去的。因为此时还没有“当前任务”的概念,不需要保存现场。很多人以为第一个任务也走 PendSV,这是误解。
4.3 PendSV 在任务切换中的角色
PendSV 是 Cortex-M 专门为 RTOS 设计的异常,优先级可编程且通常设为最低。为什么用 PendSV 而不是 SVC 或普通中断做切换?因为 PendSV 可以“挂起”——如果当前正在处理一个高优先级中断,OSCtxSw只是把 PendSV 置位,等所有中断处理完,PendSV 才执行。这样保证任务切换不会打断中断,也不会在中断里切任务导致现场混乱。
uC/OS-II 的OSCtxSw和OSIntCtxSw最终都触发 PendSV:
OSCtxSw: LDR R0, =NVIC_INT_CTRL LDR R1, =NVIC_PENDSVSET STR R1, [R0] BX LRPendSV_Handler 里保存当前任务 R4-R11 到其栈,从就绪表取新任务,恢复新任务 R4-R11,更新 PSP,最后BX LR返回新任务。整个过程 PSP 是任务栈指针,MSP 是内核和中断栈指针,两者分开,互不干扰。
提示:PendSV 优先级一定要设成最低(如
0xFF),SVC 设成次低。如果 PendSV 比某个中断优先级高,那个中断里触发的任务切换会立刻打断中断,现场就乱了。
5. 实操:用调试器看启动全过程
5.1 复位后单步跟踪
以 Keil + J-Link 为例,复位后不要直接点 Run,先点 Reset,然后单步:
- 看 PC 停在
0x0000_0004附近的Reset_Handler - 看 SP 是否等于
__initial_sp(在 Watch 窗口输入__initial_sp) - 单步过
.data搬运循环,看 RAM 里全局变量初值是否出现 - 进
SystemInit,看RCC->CFGR的 PLL 位是否置上 - 进
main,在OSStart处下断点
这套流程走一遍,你对启动的理解会比看十篇文章都深。
5.2 用串口打点确认阶段
没有调试器时,在关键节点翻转一个 GPIO 或用串口发一个字符:
// SystemInit 末尾 GPIOA->BSRR = GPIO_BSRR_BS5; // 时钟配好了 // main 开头 GPIOA->BSRR = GPIO_BSRR_BR5; // 进了 main // OSStart 之前 GPIOA->BSRR = GPIO_BSRR_BS5; // 准备启动调度用示波器看这个引脚,就能判断卡在哪一段。这个方法在产线调试时特别管用。
5.3 常见启动故障速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 上电无反应,调试器也连不上 | BOOT 引脚错、晶振没起振 | 查 BOOT0 电平、示波器看晶振 |
| 能连调试器但不进 main | 向量表错、栈顶非法 | 看__initial_sp、反汇编向量表 |
| 进 main 后 HardFault | .data搬运越界、时钟没配好 | 单步过启动文件、查链接脚本 |
| 中断不响应 | VTOR 没设、NVIC 没使能 | 查SCB->VTOR、NVIC_ISER |
| RTOS 起不来 | OSStart前没建任务、PendSV 优先级错 | 查OSTaskCreate返回值、NVIC 优先级 |
| 任务切换后跑飞 | PSP/MSP 混用、栈溢出 | 查CONTROL寄存器、栈填充法 |
6. 几个容易踩的坑和我的经验
第一个坑:启动文件版本和芯片型号不匹配。ST 的固件库里startup_stm32f103xb.s和startup_stm32f103xe.s中断向量数量不同,用错了要么中断号对不上,要么编译报重复定义。换芯片第一件事就是换启动文件。
第二个坑:链接脚本里 RAM 长度写错。比如 F103C8 只有 20KB RAM,你按 64KB 写,链接能过,但运行时栈顶指向了不存在的地址,一上电就挂。查芯片手册的 Memory Map,别凭感觉。
第三个坑:uC/OS-II 的OS_TASK_TMR_PRIO和用户任务优先级冲突。如果你开了软件定时器,内核会占一个优先级,用户任务别用同一个。我见过有人把起始任务设成和定时器任务同优先级,结果OSStart后行为诡异。
第四个坑:PendSV 里没清中断标志。Cortex-M 的 PendSV 是“挂起”型,写ICSR的PENDSVCLR位可以清,但 uC/OS-II 的移植通常靠“进入异常自动清”的机制。如果你自己写移植,忘了这一步,PendSV 会反复进,任务切换卡死。
第五个坑:调试时在中断里打断点。RTOS 跑起来后,在 PendSV 里下断点会让整个系统时序乱掉,看起来像“任务不切换”。调试 RTOS 尽量用 GPIO 打点或printf到内存缓冲,别在异常里停。
7. 把这条链路串起来看
从复位向量到第一个任务,本质是三次“交接”:硬件把控制权交给启动文件,启动文件交给 C 库和main,main交给 RTOS 内核。每一次交接都有明确的契约——向量表格式、段布局、TCB 结构。理解这些契约,比记住某个寄存器的位定义更重要。
我个人的习惯是,每换一个芯片平台,先花半小时把启动文件、链接脚本、SystemInit这三样东西过一遍,用调试器单步走一次启动流程。这半小时能省掉后面几天的“玄学调试”。尤其是现在很多项目用 CubeMX 生成代码,启动文件是自动生成的,很多人看都不看,出了问题就抓瞎。工具越方便,越要懂底层。
最后分享一个小技巧:在Reset_Handler的第一条指令处放一个NOP,然后用调试器看 PC 是不是停在那里。如果不停,说明向量表取错了,问题在 BOOT 或 Flash 映射,跟你的 C 代码一点关系都没有。这个判断能帮你快速排除一半的启动问题。