第4章:启动汇编代码实战——编写Reset_Handler、SystemInit函数调用、跳转到main函数前的准备工作

说实话,很多做MCU开发的朋友,写了几年C语言应用层代码,却从来没认真看过启动文件。我刚开始带项目时也是这样,直到有一次板子死活跑不起来,debug大半天才发现是中断向量表没对齐——嗯,从那以后我再也不敢跳过启动汇编了。

这一章,咱们就手把手把启动流程走一遍。你跟着我写,写完就能理解芯片上电后到底干了些什么。

4.1 启动流程全景图

先看整体脉络。MCU上电后,硬件自动做三件事:

  1. 从0x00000000取栈顶指针(MSP初始值)
  2. 从0x00000004取复位中断向量(Reset_Handler地址)
  3. 跳转到Reset_Handler执行

剩下的,全是软件的事了。我画了张图,把整个启动链路串起来:

MCU启动汇编核心流程 上电复位 取MSP & PC Reset_Handler SystemInit() 搬运.data/.bss 跳转main() 用户应用运行 关键:SystemInit() 必须在搬运 .data 之前执行,否则时钟不对,搬运速度会出问题

4.2 编写Reset_Handler

Reset_Handler是整个软件世界的入口。说白了,芯片复位后第一条指令就在这里。我个人习惯把它放在启动文件最前面,紧跟在中断向量表之后。

来看一个典型的实现:

.section .text.Reset_Handler
.type   Reset_Handler, %function
Reset_Handler:
    /* 设置栈指针 */
    ldr     sp, =_estack

    /* 调用系统初始化 */
    bl      SystemInit

    /* 搬运.data段 */
    ldr     r0, =_sdata
    ldr     r1, =_edata
    ldr     r2, =_sidata
    movs    r3, #0
    b       LoopCopyDataInit

CopyDataInit:
    ldr     r4, [r2, r3]
    str     r4, [r0, r3]
    adds    r3, r3, #4

LoopCopyDataInit:
    adds    r4, r0, r3
    cmp     r4, r1
    bcc     CopyDataInit

    /* 清零.bss段 */
    ldr     r0, =_sbss
    ldr     r1, =_ebss
    movs    r2, #0
    b       LoopFillZerobss

FillZerobss:
    str     r2, [r0]
    adds    r0, r0, #4

LoopFillZerobss:
    cmp     r0, r1
    bcc     FillZerobss

    /* 跳转到main */
    bl      main

    /* 如果main返回,死循环 */
LoopForever:
    b       LoopForever

关键点:ldr sp, =_estack 这条指令必须在最前面。我在项目中遇到过因为栈指针没初始化就调用函数,结果压栈把数据写到了随机地址,整个系统直接跑飞。记住:栈指针是命根子。

4.3 SystemInit函数调用时机

为什么要在搬运.data之前调用SystemInit?你想想看,如果系统时钟还是默认的慢速内部振荡器,搬运几十KB的数据得等到猴年马月。SystemInit先把PLL配好,把主频提上去,后面的搬运就快多了。

SystemInit通常做这几件事:

功能模块 具体操作 为什么重要
时钟树配置 使能HSE/HSI,配置PLL,切换系统时钟 决定CPU主频和总线速度
Flash预取/等待周期 根据主频设置Flash延迟 高主频下必须配,否则取指令出错
电压调节器 配置核心电压范围 低功耗和高性能模式切换
外设时钟门控 关闭未使用的外设时钟 降低功耗,防止外设误触发

我的经验:SystemInit里不要做太复杂的事情。我曾经见过有人把外设初始化全塞进去,结果启动时间长了200ms,Bootloader升级时差点超时。SystemInit只做时钟和必要的基础配置,其他交给main。

4.4 数据段搬运与BSS清零

这里有个概念要搞清楚。.data段是已初始化的全局变量,编译后放在Flash里(叫_sidata),运行时需要搬到RAM里(_sdata到_edata)。.bss段是未初始化的全局变量,运行时必须清零。

为什么不能直接在Flash里用?因为C语言要求全局变量可读写,Flash只能读不能写。所以必须搬到RAM。

搬运的汇编逻辑其实很简单:

/* .data搬运:从Flash复制到RAM */
CopyDataInit:
    ldr     r4, [r2, r3]    /* 从Flash读4字节 */
    str     r4, [r0, r3]    /* 写到RAM */
    adds    r3, r3, #4      /* 地址+4 */
    cmp     r4, r1          /* 检查是否搬完 */
    bcc     CopyDataInit

/* .bss清零 */
FillZerobss:
    str     r2, [r0]        /* 写0 */
    adds    r0, r0, #4      /* 地址+4 */
    cmp     r0, r1          /* 检查是否清完 */
    bcc     FillZerobss

注意:链接脚本里的_sdata、_edata这些符号必须和汇编里的一致。我踩过最大的坑就是链接脚本改了符号名,汇编没同步,结果搬运地址错位,全局变量全是乱值。排查了整整一天。

4.5 跳转到main前的最后检查

在bl main之前,我建议做一次快速自检。这不是必须的,但能帮你省很多debug时间。我个人习惯加这么几行:

/* 跳转前检查 */
    /* 1. 检查栈指针是否在RAM范围内 */
    ldr     r0, =_estack
    ldr     r1, =_sstack
    cmp     r0, r1
    bls     StackError

    /* 2. 检查.data搬运是否完整 */
    ldr     r0, =_sdata
    ldr     r1, =_edata
    cmp     r0, r1
    bhi     DataError

    /* 一切正常,跳转main */
    bl      main

StackError:
DataError:
    /* 错误处理:可以点亮LED或进入Bootloader恢复模式 */
    b       ErrorHandler

这些检查在开发阶段特别有用。等产品稳定了,可以去掉以节省几十个指令周期。

4.6 常见踩坑与避坑指南

  • 中断向量表对齐问题:Cortex-M要求向量表按512字节对齐。我遇到过把向量表放在非对齐地址,结果第一个中断就进不了。
  • 栈大小估算不足:默认栈大小通常只有1KB,如果函数调用层级深或者有大量局部变量,栈溢出是必然的。我建议至少给2KB,RTOS环境下给4KB以上。
  • SystemInit里调用printf:千万别。这时候串口还没初始化,printf直接卡死。我见过有人这么干,debug时一脸懵。
  • 搬运顺序搞反:先清.bss再搬.data?不行。因为.data和.bss在RAM里可能是相邻的,先清零会把刚搬好的.data也清了。

一句话总结:启动汇编就像盖房子的地基,看不见摸不着,但出了问题整栋楼都得塌。花半小时把这段代码吃透,后面所有调试都会顺畅很多。