第3章 启动文件分析:STM32F103启动流程、堆栈设置、中断向量表、SystemInit函数详解
说实话,很多工程师写了几年STM32代码,却从来没仔细看过启动文件。我见过太多人遇到“上电不运行”、“HardFault”这类问题,折腾半天才发现是启动配置出了问题。这一章,咱们就把启动文件彻底拆开看看。
3.1 启动文件到底在干什么?
启动文件(startup_stm32f10x_hd.s)是芯片上电后执行的第一段代码。它不归你写,但你必须懂它。它的核心任务就四个:
- 设置堆栈指针 —— 给C语言运行环境搭好“地基”
- 初始化中断向量表 —— 告诉CPU中断来了该跳到哪里
- 调用SystemInit —— 把时钟系统配置好
- 跳转到main —— 终于进入你的应用程序
流程其实很简单,我画了张图帮你理清脉络:
3.2 堆栈设置 —— 别让“栈溢出”找上门
启动文件开头通常有这样一段:
Stack_Size EQU 0x00000400
AREA STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem SPACE Stack_Size
__initial_sp
这里定义的是栈大小,单位是字节。0x00000400 就是 1KB。我个人习惯,如果项目里函数调用层级深、局部变量多,至少给 2KB 以上。你想想看,一个简单的串口中断服务函数里如果定义了个 512 字节的数组,栈空间瞬间就吃掉一半。
我曾经接手过一个项目,设备运行几小时后就死机。排查了三天,最后发现是栈溢出把中断向量表给踩了。启动文件里栈只配了 512 字节,而代码里有个递归函数……嗯,从那以后我每次建工程第一件事就是检查栈大小。
堆的设置类似:
Heap_Size EQU 0x00000200
AREA HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem SPACE Heap_Size
__heap_limit
堆主要给 malloc 这类动态内存分配用。嵌入式里我建议尽量少用动态分配,真要用了,记得算好最大申请量。
3.3 中断向量表 —— 芯片的“紧急联络簿”
中断向量表本质上是一个函数指针数组。每个中断源对应一个表项,存放着对应的中断服务函数地址。STM32F103 的向量表默认放在 0x08000000(Flash 起始地址),但启动后芯片会把它映射到 0x00000000。
表的结构是这样的:
| 偏移地址 | 中断类型 | 说明 |
|---|---|---|
| 0x0000_0000 | 栈顶地址 | __initial_sp |
| 0x0000_0004 | 复位向量 | Reset_Handler |
| 0x0000_0008 | NMI | 不可屏蔽中断 |
| 0x0000_000C | HardFault | 硬件错误,最常见的问题来源 |
| 0x0000_0010 | MemManage | 内存管理错误(MPU相关) |
| 0x0000_0014 | BusFault | 总线错误 |
| 0x0000_0018 | UsageFault | 用法错误 |
| …… | …… | …… |
| 0x0000_0040 | SVCall | 系统服务调用 |
| 0x0000_0044 | DebugMonitor | 调试监视器 |
| 0x0000_0048 | PendSV | 可挂起系统中断(RTOS调度关键) |
| 0x0000_004C | SysTick | 系统滴答定时器 |
| 0x0000_0050 起 | 外设中断 | USART、SPI、TIM等 |
启动文件里用 DCD 指令逐个定义这些向量:
__Vectors DCD __initial_sp ; 栈顶地址
DCD Reset_Handler ; 复位向量
DCD NMI_Handler
DCD HardFault_Handler
DCD MemManage_Handler
DCD BusFault_Handler
DCD UsageFault_Handler
……
调试技巧:如果程序跑飞进了 HardFault,先别慌。在 HardFault_Handler 里设个断点,然后查看堆栈里的返回地址,基本就能定位到是哪条指令触发的。我一般会在 HardFault_Handler 里加一段代码,把 LR 和 PC 保存到某个全局变量里,方便事后分析。
3.4 SystemInit 详解 —— 时钟是芯片的心脏
SystemInit 函数在 system_stm32f10x.c 里实现。它负责把芯片从默认的 8MHz HSI 切换到用户想要的时钟配置。默认情况下,STM32F103 上电后用的是 HSI(8MHz),经过 PLL 倍频后可以达到 72MHz。
函数的核心流程:
- 设置 Flash 等待周期 —— 72MHz 下必须配 2 个等待周期,否则 Flash 读取会出错
- 配置 HSE 或 HSI —— 选择外部晶振还是内部振荡器
- 配置 PLL —— 设置倍频系数,比如 8MHz × 9 = 72MHz
- 切换系统时钟源 —— 从 HSI 切换到 PLL 输出
- 配置 AHB/APB 分频 —— 设置各总线时钟频率
我截取一段典型的配置代码:
void SystemInit (void)
{
/* 设置 Flash 等待周期 */
FLASH->ACR = (uint32_t)0x00000002; // 72MHz 下需要 2 个等待周期
/* 使能 HSE 并等待就绪 */
RCC->CR |= (uint32_t)0x00000001; // 使能 HSI(默认已使能)
RCC->CR |= RCC_CR_HSEON; // 使能 HSE
while(!(RCC->CR & RCC_CR_HSERDY)); // 等待 HSE 就绪
/* 配置 PLL:HSE 8MHz × 9 = 72MHz */
RCC->CFGR |= RCC_CFGR_PLLSRC_HSE; // PLL 时钟源选择 HSE
RCC->CFGR |= RCC_CFGR_PLLMULL9; // PLL 倍频系数 ×9
/* 使能 PLL 并等待就绪 */
RCC->CR |= RCC_CR_PLLON;
while(!(RCC->CR & RCC_CR_PLLRDY));
/* 切换系统时钟到 PLL */
RCC->CFGR &= ~RCC_CFGR_SW; // 清除系统时钟切换位
RCC->CFGR |= RCC_CFGR_SW_PLL; // 选择 PLL 作为系统时钟
while((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);
/* 配置 AHB/APB 分频 */
RCC->CFGR |= RCC_CFGR_HPRE_DIV1; // AHB = 系统时钟 = 72MHz
RCC->CFGR |= RCC_CFGR_PPRE2_DIV1; // APB2 = AHB = 72MHz
RCC->CFGR |= RCC_CFGR_PPRE1_DIV2; // APB1 = AHB/2 = 36MHz
}
注意:APB1 最高只能跑到 36MHz,APB2 可以到 72MHz。如果你把 APB1 配成 72MHz,外设(比如 USART2、I2C1)可能会工作异常。我见过有人把 TIM2 的时钟配错了,PWM 频率死活不对,查了半天才发现是 APB1 分频没设对。
3.5 启动流程中的几个“坑”
做调试这么多年,我总结了几条启动阶段最容易出问题的地方:
- 堆栈溢出 —— 尤其是用了 RTOS 的项目,任务栈和系统栈要分开算。我习惯在启动文件里把栈设到 4KB 以上,反正 F103 的 RAM 有 20KB 或 48KB,够用。
- 中断向量表偏移 —— 如果你用了 Bootloader,记得在应用程序里调用
NVIC_SetVectorTable()重新映射向量表。否则中断来了跳错地方,直接 HardFault。 - SystemInit 里的等待循环 —— 如果外部晶振没焊好或者不起振,程序会卡死在
while(!(RCC->CR & RCC_CR_HSERDY))里。调试时遇到“上电没反应”,先查晶振。 - Flash 等待周期 —— 72MHz 下如果忘了配等待周期,程序会随机死机。这种问题最难排查,因为不是每次都复现。
我的调试习惯:每次新板子第一次上电,我会在 Reset_Handler 最开头放一个 GPIO 翻转的代码。如果灯亮了,说明芯片跑起来了;如果没亮,说明连启动都没进去。这个“最小系统验证法”帮我省了无数时间。
3.6 小结
启动文件虽然不常改,但它是整个系统的基石。堆栈设多大、向量表怎么排、时钟怎么配,这些决定了你的程序能不能稳定跑起来。下次遇到“上电死机”、“随机 HardFault”这类问题,别急着翻应用层代码,先回启动文件看看——说不定问题就出在这里。