4. 启动文件startup_stm32xxxx.s逐行精读

启动文件是 STM32 上电后执行的第一段代码,通常命名为 startup_stm32f103xb.s(以 F1 中容量为例,其他系列后缀不同)。它由汇编编写,承担四件事:定义栈和堆、建立中断向量表、编写复位处理入口、在进入 main 之前调用系统初始化。理解这个文件,等于掌握了从“芯片复位”到“C 语言世界”的完整交接过程。

一、栈与堆的定义:先给 C 运行环境划出内存

文件开头通常先声明栈大小和堆大小,例如:

Stack_Size      EQU     0x00000400
                AREA    STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem       SPACE   Stack_Size
__initial_sp

Heap_Size       EQU     0x00000200
                AREA    HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem        SPACE   Heap_Size
__heap_limit

Stack_Size 为 0x400,即 1 KB;Heap_Size 为 0x200,即 512 B。这两个值不是随便写的:栈用于函数调用、局部变量和中断嵌套,堆用于 malloc 等动态分配。若程序里用了 printf 浮点格式化或递归较深,1 KB 栈可能不够,需要调大;若完全不用动态内存,堆可以设为 0。注意 __initial_sp 标号紧跟在栈空间之后,它正是向量表第一个字要填的“栈顶地址”。

栈是“向下生长”的:入栈时 SP 减小,出栈时 SP 增大。因此 __initial_sp 指向栈区最高地址,作为初始 SP 值。

二、中断向量表:一张“异常编号到函数地址”的映射表

向量表放在只读代码段,开头是 DCD 伪指令逐项列出 32 位地址:

                AREA    RESET, DATA, READONLY
                EXPORT  __Vectors
__Vectors       DCD     __initial_sp
                DCD     Reset_Handler
                DCD     NMI_Handler
                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
                ; 之后是外设中断,如 WWDG_IRQHandler、PVD_IRQHandler ...

第 0 项是初始栈顶,第 1 项是复位入口,第 2 项起依次为 NMI、HardFault 等内核异常,再往后是各外设中断。表中出现 0 的位置表示该异常未使用,一旦触发会跳转到地址 0 导致异常。外设中断的顺序必须与芯片参考手册中的向量表编号严格一致,这也是为什么不同型号的启动文件不能混用。

向量表默认位于 Flash 起始地址 0x08000000。如果程序需要重定位向量表(例如做 Bootloader 跳转或使用 SRAM 运行),会通过 SCB->VTOR 寄存器修改基址,此时启动文件里的表仍是“模板”,实际生效地址由 VTOR 决定。

三、复位处理:从 Reset_Handler 到 main 的交接

复位后 CPU 从向量表第 1 项取出 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 不是用户写的 main,而是 C 库的入口,它负责把已初始化数据从 Flash 拷贝到 RAM、清零 .bss 段,最后才调用用户的 main。因此 SystemInit 运行在数据段尚未初始化完成的环境中,它内部不能依赖已初始化的全局变量。

四、时钟初始化调用:SystemInit 做了什么

SystemInit 定义在 system_stm32f1xx.c 中,启动文件只负责“调用”它。以 F1 为例,它主要完成:使能外部高速晶振 HSE、配置 PLL 倍频、切换系统时钟源到 PLL、设置 AHB/APB 分频。常见配置是 8 MHz 晶振经 9 倍频得到 72 MHz 系统时钟。若 HSE 起振失败,SystemInit 会停留在等待循环或回退到 HSI,这解释了“程序卡在启动阶段”的常见现象。

需要区分:SystemInit 只配置时钟树,不配置外设。GPIO、USART 等初始化仍由用户在 main 中完成。若使用 HAL 库,HAL_Init() 会配置 SysTick 和中断优先级分组,通常在 main 开头调用。

startup_stm32xxxx.s 结构与执行链 启动文件内部结构 ① 栈/堆定义 Stack_Size = 0x400 (1KB) Heap_Size = 0x200 (512B) ② 中断向量表 __Vectors [0] __initial_sp [1] Reset_Handler [2..15] 内核异常 [16..] 外设中断 ③ Reset_Handler BL SystemInit BX __main ④ 弱定义中断处理 [WEAK] 可被用户覆盖 上电到 main 的执行链 芯片复位,CPU 从 0x08000000 取向量 加载 __initial_sp 到 SP,跳转 Reset_Handler 调用 SystemInit:配置 HSE / PLL / 时钟树 跳转 __main:拷贝 .data、清零 .bss 调用用户 main(),进入 C 语言世界 注:SystemInit 运行时 .data/.bss 尚未初始化

五、弱定义与用户覆盖

启动文件里所有中断处理函数都用 [WEAK] 声明,并给出一个死循环实现,例如:

NMI_Handler     PROC
                EXPORT  NMI_Handler                [WEAK]
                B       .
                ENDP

“弱”意味着:如果用户在 C 文件里定义了同名函数,链接器会优先使用用户的强符号,启动文件里的实现被丢弃。这就是为什么我们能在 stm32f1xx_it.c 中重写 SysTick_Handler 而不冲突。若用户没有定义,则中断触发后停在 B . 死循环,便于调试时定位“哪个中断没写处理函数”。

六、逐行精读的实用检查清单

检查项典型值/写法判断依据
栈大小0x400(1KB)有浮点 printf 或深递归时需增大
堆大小0x200(512B)不用 malloc 可设为 0
向量表首项__initial_sp必须是栈顶地址,不是函数
复位入口Reset_Handler向量表第 1 项
时钟调用BL SystemInit在 __main 之前执行
中断顺序与参考手册一致型号不匹配会导致中断错位
常见错误:把 Reset_Handler 里的 SystemInit 调用删掉,程序仍能运行但时钟停留在 HSI 8 MHz,表现为串口波特率错误、延时变长。另一个错误是向量表外设中断顺序与手册不符,导致“进错中断”。

把启动文件读透后,再遇到“程序不进 main”“中断不响应”“时钟不对”这类问题,就能沿着“向量表 → Reset_Handler → SystemInit → __main → main”这条链逐段排查,而不是盲目改代码。