2. 启动模式与硬件初始化:复位向量、启动模式选择、最小系统初始化

芯片上电后,第一行代码从哪里开始执行?这个问题,我当年刚入行时也困惑了很久。说白了,Bootloader 的起点就是复位向量。今天咱们就把启动流程从头捋一遍。

2.1 复位向量——CPU 的第一口奶

CPU 复位后,会从一个固定地址取第一条指令。这个地址就是复位向量。不同架构的复位向量位置不一样:ARM Cortex-M 系列通常从 0x00000000 开始,而 ARM7/9 则从 0x00000000 或 0xFFFF0000 取向量表。

复位向量表结构(以 Cortex-M 为例)

偏移内容说明
0x00栈顶指针(MSP)复位后 SP 自动加载此值
0x04复位向量复位后 PC 跳转地址
0x08NMI 向量不可屏蔽中断入口
0x0CHardFault 向量硬件错误处理入口

我曾经踩过一个坑:某次移植 Bootloader 时,向量表地址没对齐到 512 字节边界,结果芯片复位后直接跑飞。嗯,从那以后我每次都会检查链接脚本里 .isr_vector 段的对齐属性。

// 典型的 Cortex-M 启动文件片段
__Vectors       DCD     __initial_sp          ; 栈顶指针
                DCD     Reset_Handler         ; 复位处理函数
                DCD     NMI_Handler
                DCD     HardFault_Handler
                ...
                AREA    |.text|, CODE, READONLY
Reset_Handler   PROC
                EXPORT  Reset_Handler
                LDR     R0, =__main
                BX      R0
                ENDP

2.2 启动模式选择——芯片从哪“醒”过来

现代 SoC 支持多种启动介质。你想想看,同一颗芯片既能从 NOR Flash 启动,也能从 SD 卡启动,甚至能从 UART 下载代码。这背后靠的是启动模式选择引脚(BOOT pins)。

我个人习惯把启动模式分为三类:

  • 本地并行启动:NOR Flash、SRAM,特点是速度快、可直接寻址执行(XIP)。
  • 串行启动:NAND Flash、SPI Flash、SD 卡,需要先加载一小段代码到内部 SRAM。
  • 调试/下载启动:UART、USB、CAN,通常用于工厂烧录或现场升级。

避坑指南:我曾经在一个量产项目中,发现同一批芯片有的能正常启动,有的却卡死。查了两天才发现——启动模式引脚的上拉电阻虚焊,导致芯片随机选择了启动介质。从那以后,我要求硬件工程师必须在 BOOT 引脚上加 10kΩ 的明确上下拉,绝不依赖芯片内部弱上拉。

下面这张图展示了典型的启动流程决策树:

启动模式选择与硬件初始化流程 CPU 复位 读取 BOOT 引脚电平 NOR Flash 启动 NAND / SD 卡启动 UART 下载启动 XIP 直接执行 加载 BootROM 到 SRAM 等待主机下发数据 最小系统初始化 无论哪种启动方式,最终都要进入最小系统初始化阶段

2.3 最小系统初始化——让芯片“活”过来

不管从哪种介质启动,Bootloader 的第一步都是初始化最小系统。我把它总结为三个核心任务:时钟、堆栈、看门狗。

2.3.1 时钟系统初始化

芯片复位后通常运行在内部低速振荡器上,频率可能只有几 MHz。你需要切换到外部晶振或 PLL 倍频到目标频率。

我个人习惯的时钟初始化顺序:

  1. 等待外部晶振稳定(检查时钟就绪标志)
  2. 配置 PLL 倍频系数和分频系数
  3. 等待 PLL 锁定
  4. 将系统时钟切换到 PLL 输出
  5. 配置外设总线时钟分频(AHB、APB1、APB2 等)

注意:切换系统时钟源时,一定要先确认新时钟源已经稳定。我见过有人直接写寄存器切换,结果 PLL 还没锁定就切过去了——芯片直接死掉。正确的做法是:先使能目标时钟源,轮询等待就绪标志,然后再切换。

// STM32 时钟初始化示例(简化版)
void SystemClock_Init(void) {
    // 1. 使能 HSE 外部晶振
    RCC->CR |= RCC_CR_HSEON;
    while (!(RCC->CR & RCC_CR_HSERDY));  // 等待就绪
    
    // 2. 配置 PLL:HSE 8MHz -> PLL 72MHz
    RCC->CFGR |= RCC_CFGR_PLLSRC_HSE;
    RCC->CFGR |= RCC_CFGR_PLLMULL9;      // 8MHz * 9 = 72MHz
    
    // 3. 使能 PLL 并等待锁定
    RCC->CR |= RCC_CR_PLLON;
    while (!(RCC->CR & RCC_CR_PLLRDY));
    
    // 4. 配置 AHB/APB 分频
    RCC->CFGR |= RCC_CFGR_HPRE_DIV1;     // AHB = 72MHz
    RCC->CFGR |= RCC_CFGR_PPRE2_DIV1;    // APB2 = 72MHz
    RCC->CFGR |= RCC_CFGR_PPRE1_DIV2;    // APB1 = 36MHz
    
    // 5. 切换系统时钟到 PLL
    RCC->CFGR &= ~RCC_CFGR_SW;
    RCC->CFGR |= RCC_CFGR_SW_PLL;
    while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);
}

2.3.2 堆栈指针设置

堆栈是 C 语言运行的基础。没有正确的堆栈指针,局部变量、函数调用全部会崩溃。

Cortex-M 系列在复位时会自动从向量表加载 MSP(主堆栈指针),但如果你用的是 ARM7/9 这类架构,就需要在汇编启动代码里手动设置 SP。

; ARM7 启动代码中的堆栈初始化
LDR     SP, =SVC_STACK_TOP    ; 设置管理模式堆栈
MSR     CPSR_c, #0x12         ; 切换到 IRQ 模式
LDR     SP, =IRQ_STACK_TOP    ; 设置 IRQ 模式堆栈
MSR     CPSR_c, #0x1F         ; 切换回系统模式

经验之谈:堆栈大小怎么定?我一般按实际需求的两倍来分配。比如预估最大调用深度需要 2KB,我就给 4KB。为什么?因为中断嵌套、异常处理都会额外消耗堆栈。我曾经在一个项目中堆栈只给了 1KB,结果跑几天就随机死机——查到最后是堆栈溢出覆盖了全局变量。

2.3.3 看门狗处理

很多芯片复位后看门狗默认是开启的。如果你不在初始化阶段及时喂狗或关闭它,芯片会反复复位,Bootloader 永远跑不完。

我的处理原则是:

  • 开发阶段:在最小系统初始化最开头就关闭看门狗,避免干扰调试。
  • 量产阶段:在跳转到主应用前再使能看门狗,并确保主应用有喂狗机制。

重要提醒:有些芯片的看门狗一旦使能就无法通过软件关闭(比如某些 PIC 和 AVR 系列)。这种情况下,你必须在启动代码里周期性地喂狗,直到完成所有初始化。我曾经在给一个工业控制器做 Bootloader 时,没注意看门狗是“熔丝位锁定”的,结果每次初始化到一半就被复位——折腾了一整天才发现是看门狗在捣乱。

2.4 最小系统初始化清单

为了方便你对照检查,我把最小系统初始化的关键步骤整理成了一张表:

步骤操作常见陷阱
1关闭全局中断中断向量表未就绪时响应中断会跑飞
2设置堆栈指针堆栈大小不足或地址未对齐
3初始化时钟系统PLL 未锁定就切换时钟源
4关闭或配置看门狗看门狗熔丝锁定无法软件关闭
5初始化内存控制器SDRAM 时序参数配置错误
6复制 .data 段并清零 .bss 段链接脚本中段地址定义错误
7跳转到 main()未正确设置主函数入口地址

嗯,到这里,启动模式与硬件初始化的核心内容就讲完了。记住一句话:复位向量是起点,启动模式决定路径,最小系统初始化是必经之路。这三者环环相扣,任何一个环节出问题,Bootloader 都跑不起来。