2. 嵌入式系统启动流程

芯片上电那一刻,发生了什么?很多人以为直接跳进 main 函数就完事了。其实没那么简单。

我个人习惯把 Bootloader 的启动流程比作一场接力赛。从复位向量到 C 环境初始化,再到跳转 main,每一棒都不能掉链子。今天咱们就把它彻底拆开看看。

第一棒:复位向量与异常向量表

芯片复位后,CPU 做的第一件事是什么?

它去固定的地址取指令。ARM Cortex-M 系列从 0x00000000 取栈顶指针,从 0x00000004 取复位向量。说白了,这就是芯片的「出厂设定」。

核心要点:复位向量指向的地址,就是 Bootloader 的入口。这个入口通常用汇编写,叫 Reset_Handler。

我在项目中遇到过一个问题:某次把向量表放到了 0x08010000,但芯片死活不跑。查了半天才发现,SCB->VTOR 没重新映射。嗯,这里要注意——如果你的 Bootloader 不在默认地址,必须手动设置向量表偏移。

// 设置向量表偏移(Cortex-M3/M4)
SCB->VTOR = (uint32_t)BOOTLOADER_BASE_ADDR;

第二棒:汇编启动代码干了什么

进入 Reset_Handler 后,通常是一段汇编代码。你想想看,这时候 C 环境还没准备好,全局变量都没初始化,你没法用 C 代码。

这段汇编主要做三件事:

  1. 关闭中断——防止在初始化过程中被意外打断
  2. 设置栈指针——从向量表取栈顶地址,写入 SP 寄存器
  3. 初始化 .bss 和 .data 段——清零 .bss,从 Flash 拷贝 .data 到 RAM

个人经验:我建议在汇编阶段就把看门狗先关了。有些芯片默认开启看门狗,初始化时间稍长就会触发复位。我曾经因为这个坑,调试了整整两天。

Reset_Handler PROC
    EXPORT  Reset_Handler
    ; 关闭全局中断
    CPSID   I
    ; 设置栈指针
    LDR     SP, =_estack
    ; 清零 .bss 段
    LDR     R0, =_sbss
    LDR     R1, =_ebss
    MOVS    R2, #0
    B       .L1
.L2:
    STR     R2, [R0]
    ADDS    R0, R0, #4
.L1:
    CMP     R0, R1
    BCC     .L2
    ; 拷贝 .data 段
    LDR     R0, =_sdata
    LDR     R1, =_edata
    LDR     R2, =_sidata
    B       .L3
.L4:
    LDR     R3, [R2]
    STR     R3, [R0]
    ADDS    R0, R0, #4
    ADDS    R2, R2, #4
.L3:
    CMP     R0, R1
    BCC     .L4
    ; 使能全局中断
    CPSIE   I
    ; 跳转到 main
    BL      main
    ENDP

第三棒:C 运行时环境初始化

汇编跑完后,控制权交给 C 代码。但这时候还不是 main 函数,而是 __libc_init_array 或类似的 C 运行时初始化函数。

它负责:

  • 初始化 C 库(比如 malloc 的堆区)
  • 调用全局对象的构造函数(C++ 项目)
  • 设置系统时钟和 PLL

你可能会问:为什么不在 main 里做这些?因为 main 本身也是被调用的。C 标准规定,在 main 执行之前,运行时环境必须就绪。

避坑指南:我曾经在一个项目中,把系统时钟初始化放在了 main 函数里。结果某些外设在 main 之前就启动了,时钟没配好,外设直接跑飞。后来我学乖了——时钟配置必须放在启动代码阶段,越早越好。

第四棒:硬件外设的早期初始化

进入 main 函数后,Bootloader 的第一件事不是检查固件,而是初始化关键外设。

我个人习惯按这个顺序来:

优先级 外设 说明
1 系统时钟 配置 HSE/PLL,确保 CPU 跑在目标频率
2 看门狗 如果硬件强制开启,必须及时喂狗
3 Flash 控制器 设置等待周期,确保 Flash 读取稳定
4 通信接口 UART/SPI/I2C,用于接收固件数据
5 GPIO 状态指示、强制升级按键检测

这里有个细节:Flash 控制器的等待周期必须根据系统时钟频率来设置。频率越高,等待周期越多。如果配错了,读 Flash 会出错,程序直接跑飞。

第五棒:固件有效性检查与跳转决策

外设初始化完成后,Bootloader 进入核心决策阶段:是跳转到应用程序,还是留在 Bootloader 等待升级?

判断依据通常有三个:

  1. 强制升级标志——某个 GPIO 引脚被拉低,或者 RAM 中有特定标记
  2. 固件校验——CRC32 或 SHA256 校验应用程序区
  3. 升级请求标记——上次运行时设置了需要升级的标记

决策逻辑:如果固件有效且无升级请求,跳转;否则,进入升级模式。

跳转本身很简单:关闭中断、重置栈指针、跳转到应用程序的复位向量。但要注意——跳转前必须把外设恢复到复位状态。否则应用程序一启动,发现 UART 已经被 Bootloader 配置过了,直接懵掉。

void jump_to_app(uint32_t app_addr) {
    uint32_t stack_ptr = *(uint32_t *)app_addr;
    uint32_t reset_vec = *(uint32_t *)(app_addr + 4);

    // 关闭全局中断
    __disable_irq();

    // 恢复外设到复位状态
    deinit_all_peripherals();

    // 设置主栈指针
    __set_MSP(stack_ptr);

    // 跳转到应用程序
    void (*app_entry)(void) = (void (*)(void))reset_vec;
    app_entry();
}

完整生命周期流程图

下面这张图把整个过程串起来了。你可以把它贴在工位上,调试时对照着看。

Bootloader 启动流程 Bootloader 完整启动流程 芯片复位 取复位向量 汇编启动代码 关中断 · 设栈 · 清 BSS · 拷 Data C 运行时初始化 main 函数入口 外设早期初始化 固件有效性检查 CRC 校验 · 升级标志 · 按键检测 固件无效 进入升级模式 固件有效 跳转应用程序 关键:跳转前必须恢复外设到复位状态,否则应用程序无法正常工作

几个容易踩的坑

做 Bootloader 启动流程,有几个地方我吃过亏,分享给你:

  • 向量表对齐——有些芯片要求向量表 256 字节或 512 字节对齐。不对齐,VTOR 设置无效。
  • 中断优先级分组——Bootloader 和应用程序必须用相同的优先级分组。否则跳转后中断行为异常。
  • 栈空间预留——Bootloader 的栈不能太小。我见过有人只给 256 字节,结果调用链一深就栈溢出。
  • 外设去初始化顺序——先关中断,再复位外设,最后清中断标志。顺序反了会触发意外中断。

一个小技巧:在跳转前,往 RTC 备份寄存器写一个魔数。应用程序启动后读取这个魔数,就知道自己是被 Bootloader 正常跳转过来的,还是意外复位。这个信息对调试很有用。

好了,启动流程就拆到这里。从复位到 main,每一步都有它的道理。理解透了,后面做固件升级和安全设计才能站得住脚。