第3章 启动文件分析:STM32F103启动流程、堆栈设置、中断向量表、SystemInit函数详解

说实话,很多工程师写了几年STM32代码,却从来没仔细看过启动文件。我见过太多人遇到“上电不运行”、“HardFault”这类问题,折腾半天才发现是启动配置出了问题。这一章,咱们就把启动文件彻底拆开看看。

3.1 启动文件到底在干什么?

启动文件(startup_stm32f10x_hd.s)是芯片上电后执行的第一段代码。它不归你写,但你必须懂它。它的核心任务就四个:

  • 设置堆栈指针 —— 给C语言运行环境搭好“地基”
  • 初始化中断向量表 —— 告诉CPU中断来了该跳到哪里
  • 调用SystemInit —— 把时钟系统配置好
  • 跳转到main —— 终于进入你的应用程序

流程其实很简单,我画了张图帮你理清脉络:

STM32F103 上电启动流程 上电复位 / 硬件复位 从0x00000000取栈顶地址 → SP 从0x00000004取复位向量 → PC 跳转到 Reset_Handler 调用 SystemInit() 配置时钟 跳转到 main() 💡 关键点: • 向量表首地址固定为 0x00000000 • 前4字节 → 栈顶地址 • 后4字节 → 复位向量 ⚠️ 常见坑: • 堆栈设置太小 → 跑飞 • 中断向量表偏移错误 → 死机 • SystemInit 没配好 → 时钟不对

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。

函数的核心流程:

  1. 设置 Flash 等待周期 —— 72MHz 下必须配 2 个等待周期,否则 Flash 读取会出错
  2. 配置 HSE 或 HSI —— 选择外部晶振还是内部振荡器
  3. 配置 PLL —— 设置倍频系数,比如 8MHz × 9 = 72MHz
  4. 切换系统时钟源 —— 从 HSI 切换到 PLL 输出
  5. 配置 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”这类问题,别急着翻应用层代码,先回启动文件看看——说不定问题就出在这里。