4. Reset_Handler详解(上):从复位向量跳转到C环境的准备工作

好,咱们接着往下聊。上一章我们把启动文件的开头部分过了一遍,今天要啃的这块骨头,是整个启动流程里最核心的一环——Reset_Handler

说白了,芯片上电复位后,CPU第一件事就是去固定地址取栈指针,第二件事就是跳转到Reset_Handler。这个函数干的事,就是给C语言的世界铺好路。没有它,你的main函数根本跑不起来。

我个人习惯把Reset_Handler的工作分成三个阶段:三板斧。哪三板?搬数据、清BSS、调库。咱们一个一个说。

4.1 复位向量的真相

先看这张图,这是整个启动流程的骨架:

Reset_Handler 启动流程 复位向量 → Reset_Handler 第一阶段:设置栈指针 SP 从向量表首地址加载 MSP 第二阶段:数据段搬移 & BSS段清零 将 .data 从 Flash 复制到 SRAM 将 .bss 段全部初始化为 0 第三阶段:调用 SystemInit & main 初始化时钟 → 跳转 main() 进入 C 世界

这张图我画得比较直白。你注意看,从复位向量到进入main,中间其实就三步。但每一步都有坑,我当年刚接触STM32时,就在BSS清零上栽过跟头。

4.2 第一板斧:栈指针的初始化

芯片上电后,CPU从0x00000000取栈顶地址,从0x00000004取复位向量地址。这是ARM架构定的规矩,咱们改不了。

在STM32的启动文件里,你会看到这样的代码:

; 向量表起始地址
__Vectors       DCD     __initial_sp          ; 栈顶地址
                DCD     Reset_Handler         ; 复位向量
                DCD     NMI_Handler
                DCD     HardFault_Handler
                ; ... 其他中断向量

这里有个细节——__initial_sp 的值是谁定的?是链接脚本。我习惯在链接脚本里这样写:

_estack = ORIGIN(RAM) + LENGTH(RAM);  // 栈顶指向RAM最高地址

嗯,这里要注意:栈是向下生长的。所以栈顶要放在RAM的最高地址,这样栈向下扩展时不会踩到其他数据区。我在一个项目里见过有人把栈顶设在了RAM中间,结果一压栈就把全局变量给覆盖了——查了两天才找到原因。

💡 个人习惯:我一般会在链接脚本里给栈留至少1KB的余量。如果你的RAM有64KB,栈顶设在0x2000FF00而不是0x20010000,留256字节做保护。虽然浪费了一点点空间,但能避免栈溢出时直接破坏向量表。

4.3 第二板斧:数据段搬移

接下来是重头戏。C语言里的全局变量和静态变量,初始值都存放在Flash里。但变量是要在RAM里读写的,所以启动时得把初始值从Flash搬到RAM。

启动文件里通常是这样实现的:

; 复制 .data 段
    ldr     r1, =_sidata      ; Flash中的初始值起始地址
    ldr     r2, =_sdata       ; RAM中的.data起始地址
    ldr     r3, =_edata       ; RAM中的.data结束地址

copy_loop:
    cmp     r2, r3
    ittt    lo
    ldrlo   r0, [r1], #4
    strlo   r0, [r2], #4
    blo     copy_loop

这段代码看着简单,但有个关键点——_sidata_sdata_edata 这三个符号是谁定义的?

对,链接脚本。我见过不少新手直接复制启动文件,却忘了在链接脚本里定义这些符号,结果编译能过,跑起来就死机。说白了,启动文件和链接脚本是夫妻档,缺一个都不行

🔑 关键理解:搬数据这件事,本质上就是一次memcpy。只不过在C环境还没建立的时候,你得用汇编手写这个memcpy。我曾经试过用C语言写一个简单的拷贝函数放在启动阶段调用——结果编译器给优化没了,因为编译器认为那些变量还没初始化,拷贝是无效操作。从那以后,我再也不敢在启动阶段玩花活了。

4.4 第二板斧(续):BSS段清零

搬完数据,接下来清BSS。BSS段里放的是未初始化的全局变量和静态变量。C标准规定,这些变量默认值为0。所以启动时要把它们全清零。

; 清零 .bss 段
    ldr     r2, =_sbss
    ldr     r3, =_ebss
    movs    r0, #0

bss_loop:
    cmp     r2, r3
    itt     lo
    strlo   r0, [r2], #4
    blo     bss_loop

你可能会问:为什么不能把BSS段也放在Flash里,像.data一样搬过来?

原因很简单——BSS段在Flash里不占空间。链接脚本里BSS段的LMA(加载地址)和VMA(运行地址)是同一个地址,都在RAM里。Flash里根本没有BSS的副本。所以清零比搬数据快得多。

⚠️ 我曾经踩过的坑:有一次我为了省RAM,把一个大数组定义成了局部变量,结果栈溢出了。后来改成全局变量,但忘了在启动文件里把BSS段范围调大——链接脚本里BSS的结束地址设小了,导致数组的一部分没有被清零。程序跑起来数据全是乱的。排查了一整天,最后用调试器看内存才发现BSS段被截断了。所以,修改链接脚本后一定要检查BSS段范围是否覆盖了所有未初始化变量

4.5 第三板斧:调用SystemInit和main

数据准备好了,C环境基本就绪。但还差一步——时钟配置。

STM32上电后默认使用HSI(内部高速振荡器),频率只有8MHz(某些系列是16MHz)。你想跑72MHz?得先配置PLL。这个工作由SystemInit函数完成。

    bl      SystemInit    ; 配置系统时钟
    bl      main          ; 进入主函数
    bx      lr            ; 如果main返回,这里会死循环

注意最后那个 bx lr。如果main函数真的返回了,程序会跳回调用main之前的地址。但那个地址是什么?其实是个未定义状态。所以正规的启动文件会在main后面加一个死循环:

    bl      main
LoopForever:
    b       LoopForever

我个人习惯在main函数最后加一个 while(1);,但启动文件里再加一层保护,双重保险。

4.6 一个完整的Reset_Handler示例

把上面三部分拼起来,就是一个完整的Reset_Handler:

Reset_Handler   PROC
                EXPORT  Reset_Handler

                ; 1. 设置栈指针(其实硬件已经做了,但再设一次更保险)
                ldr     sp, =_estack

                ; 2. 搬移.data段
                ldr     r1, =_sidata
                ldr     r2, =_sdata
                ldr     r3, =_edata
copy_loop:
                cmp     r2, r3
                ittt    lo
                ldrlo   r0, [r1], #4
                strlo   r0, [r2], #4
                blo     copy_loop

                ; 3. 清零.bss段
                ldr     r2, =_sbss
                ldr     r3, =_ebss
                movs    r0, #0
bss_loop:
                cmp     r2, r3
                itt     lo
                strlo   r0, [r2], #4
                blo     bss_loop

                ; 4. 调用SystemInit和main
                bl      SystemInit
                bl      main

                ; 5. 死循环
dead_loop:
                b       dead_loop
                ENDP

这段代码我用了很多年,基本没出过问题。唯一一次翻车是在一个低功耗项目里——我为了省电,在进入睡眠前把SystemInit又调了一次,结果PLL重新锁定花了几个毫秒,导致唤醒后第一帧数据采集超时。所以,SystemInit不是随便调用的,它只应该在启动时执行一次。

4.7 小结

Reset_Handler的三板斧,说白了就是三件事:

  • 栈指针:给C语言的函数调用铺好路
  • 数据搬移和BSS清零:让全局变量有正确的初始值
  • 调用SystemInit和main:把控制权交给C语言

这三件事做完,C环境才算真正建立。你想想看,如果没有这些准备工作,main函数里随便访问一个全局变量,读到的可能是Flash里的初始值,也可能是RAM里的随机值——程序根本没法跑。

下一章我们会深入链接脚本,看看那些 _sdata_ebss 之类的符号到底是怎么定义的。到时候你会发现,启动文件和链接脚本之间的配合,比你想的还要精妙。


公众号:蓝海资料掘金营,微信deep3321