第二章 嵌入式启动流程:CPU复位向量、中断向量表、启动模式选择、片内ROM与片外Flash的映射

大家好,我是你们的Bootloader实战课讲师。今天咱们聊聊嵌入式系统上电后,CPU到底干了些什么。说白了,就是芯片从复位到跑起第一行代码的完整过程。这部分内容我做了十几年,踩过的坑比写过的代码还多,今天全抖给你们。

2.1 CPU复位向量——芯片的“出生证明”

芯片一上电,CPU第一件事是什么?不是初始化内存,也不是配置外设。它先去找一个固定地址,这个地址就叫复位向量

我习惯把复位向量比作芯片的“出生证明”。上面写着:你该从哪儿开始跑代码。不同架构的CPU,这个地址不一样:

  • ARM Cortex-M系列:复位向量在0x00000000,但里面存的是栈指针,0x00000004才是复位入口地址。
  • ARM Cortex-A系列:复位向量通常在0x00000000或0xFFFF0000,取决于硬件配置。
  • RISC-V:复位向量一般在0x80000000或0x00000000,具体看芯片设计。
  • x86:复位向量在0xFFFFFFF0,这个地址离4GB边界就差16字节。

重要提醒:复位向量里存的不是代码,而是地址。CPU会从这个地址里取出第一条指令来执行。我在项目中遇到过有人把代码直接写在复位向量位置,结果CPU跳到一个随机地址,系统直接死机。

2.2 中断向量表——系统的“紧急联络簿”

中断向量表,说白了就是一本“紧急联络簿”。每个中断源对应一个表项,里面存着中断服务程序的入口地址。

嗯,这里要注意:中断向量表和复位向量是两码事。复位向量只管上电启动,中断向量表管的是运行时各种突发事件。

典型的ARM Cortex-M中断向量表长这样:

; 中断向量表示例(ARM Cortex-M)
__Vectors       DCD     __initial_sp          ; 0x00: 栈指针
                DCD     Reset_Handler         ; 0x04: 复位入口
                DCD     NMI_Handler           ; 0x08: 不可屏蔽中断
                DCD     HardFault_Handler     ; 0x0C: 硬件错误
                DCD     MemManage_Handler     ; 0x10: 内存管理错误
                DCD     BusFault_Handler      ; 0x14: 总线错误
                DCD     UsageFault_Handler    ; 0x18: 用法错误
                ; ... 后续中断向量

我曾经在一个项目里,因为中断向量表放错了位置,导致所有中断都跳到了HardFault。排查了整整两天,最后发现是链接脚本里向量表地址写错了。从那以后,我每次都会在启动代码里加一句校验:

; 启动时校验中断向量表
LDR     R0, =0xE000ED08    ; VTOR寄存器地址
LDR     R1, =__Vectors     ; 向量表基址
STR     R1, [R0]           ; 设置向量表偏移
; 校验第一个向量是否为有效栈指针
LDR     R2, [R1]
CMP     R2, #0
BEQ     HardFault_Handler  ; 栈指针为0,直接报错

个人经验:我建议在Bootloader阶段就把中断向量表重新定位到RAM里。这样后续升级固件时,可以直接修改RAM中的向量表,不用擦写Flash。这个技巧我在做OTA升级时屡试不爽。

2.3 启动模式选择——芯片的“人格分裂”开关

很多芯片支持多种启动模式。比如STM32,可以通过BOOT0和BOOT1引脚选择从Flash启动、从系统存储器启动,还是从SRAM启动。

为什么会这样设计?你想想看,如果芯片Flash里的程序坏了,你总得有个办法重新烧录吧?这时候就可以切换到系统存储器启动,运行芯片自带的Bootloader。

BOOT0 BOOT1 启动模式 说明
0 X 主Flash启动 正常启动模式
1 0 系统存储器启动 芯片自带Bootloader
1 1 SRAM启动 调试用,掉电丢失

我记得有一次做量产测试,发现一批芯片死活烧不进程序。查了半天,原来是产线上的工人把BOOT0引脚焊短路了,芯片一直卡在系统存储器模式。嗯,硬件上的坑往往比软件更难排查。

2.4 片内ROM与片外Flash的映射——内存的“乾坤大挪移”

芯片上电后,内存空间是怎么安排的?这里有个关键概念叫内存映射

大多数芯片会把片内ROM(就是固化在芯片里的Bootloader)映射到地址0。片外Flash则映射到另一个地址范围。但有些芯片支持“映射切换”,说白了就是把片外Flash的内容“映射”到地址0,这样CPU就能从片外Flash启动了。

典型的映射关系:

  • 地址0x00000000 - 0x0000FFFF:片内ROM(64KB,存放芯片出厂Bootloader)
  • 地址0x08000000 - 0x080FFFFF:片外Flash(1MB,存放用户程序)
  • 地址0x20000000 - 0x2001FFFF:SRAM(128KB,运行时数据)

我做过一个项目,需要在片外Flash里同时放两个固件:一个用于正常启动,一个用于紧急恢复。这时候就要用到“映射切换”技巧:

// 映射切换示例(伪代码)
void switch_boot_partition(int partition) {
    uint32_t *map_reg = (uint32_t *)0x40020000;  // 映射控制寄存器
    if (partition == 0) {
        *map_reg = 0x08000000;  // 映射到主固件区
    } else {
        *map_reg = 0x08100000;  // 映射到恢复固件区
    }
    // 切换后必须复位CPU,让新的映射生效
    NVIC_SystemReset();
}

避坑指南:我曾经在映射切换时犯过一个低级错误——切换后没有清空指令缓存。结果CPU还在执行旧地址的指令,系统直接崩溃。正确的做法是:切换映射后,先执行一段位置无关代码(PIC),然后清空ICache和DCache,最后再跳转到新地址。

2.5 完整的启动流程——把碎片串起来

好了,现在我们把上面这些碎片串起来,看看一个完整的嵌入式启动流程是什么样的:

  1. 上电复位:CPU从复位向量取出第一条指令地址。
  2. 硬件初始化:CPU内部寄存器复位到默认值,时钟开始振荡。
  3. 启动模式判断:读取BOOT引脚,决定从哪个存储器启动。
  4. 内存映射建立:根据启动模式,把对应的存储器映射到地址0。
  5. 执行启动代码:从地址0开始执行,通常是芯片自带的Bootloader。
  6. 初始化C运行时:设置栈指针、清零BSS段、拷贝数据段。
  7. 跳转到main函数:终于,你的代码开始跑了。

我个人习惯在启动代码里加一个“启动计时器”,记录从复位到main函数执行的时间。这个数据对调试启动速度特别有用。有一次我发现启动慢了200ms,查了半天,原来是BSS段清零时用了逐字节操作,改成4字节对齐后,速度提升了5倍。

实用技巧:如果你在做低功耗设备,启动时间很关键。我建议在Bootloader阶段就开启看门狗,防止启动过程中死机。另外,把不必要的外设初始化放到main函数里做,能缩短启动时间。

好了,这一章的内容就到这里。下一章我们会深入Bootloader的核心——如何从Flash加载并跳转到应用程序。到时候我会分享一个我实际用过的Bootloader代码,带你们一行一行地分析。

记住:启动流程是嵌入式系统的根基。根基不稳,后面全是白搭。我见过太多工程师在应用层写得飞起,结果连芯片为什么启动不了都搞不清楚。别做那种人。