2. 启动模式与硬件初始化:复位向量、堆栈指针设置、时钟与PLL配置、内存控制器初始化

芯片上电后,第一条指令从哪里来?

这个问题,我当年刚入行时也琢磨了很久。说白了,Bootloader 的第一步就是回答三个问题:从哪里启动?堆栈在哪?时钟多快? 这三个问题搞不定,后面的代码跑都没法跑。

2.1 复位向量:芯片的“第一行代码”

ARM Cortex-M 系列芯片上电后,CPU 会从 0x00000000 地址读取两个关键值:

  • 初始堆栈指针(MSP) —— 栈顶地址
  • 复位向量(Reset Vector) —— 第一条指令的地址

嗯,这里要注意:0x00000000 存的是栈顶,0x00000004 存的才是入口函数地址。我见过不少新手把这两个搞反,结果程序直接跑飞。

典型复位向量表布局(以 STM32F4 为例)

; 向量表起始地址 0x08000000(Flash 映射到 0x00000000)
0x00000000: 0x20020000    ; 初始 MSP(栈顶指向 SRAM 末尾)
0x00000004: 0x08000123    ; Reset_Handler 地址(注意 bit0=1 表示 Thumb 模式)
0x00000008: 0x08000127    ; NMI_Handler
0x0000000C: 0x0800012B    ; HardFault_Handler
...

我个人习惯在链接脚本里把向量表放在 Flash 开头,然后通过 SCB->VTOR 寄存器重映射。这样做的好处是:Bootloader 和 App 可以各自维护一套向量表,互不干扰。

避坑指南: 我曾经在移植 Bootloader 时,忘记把 App 的向量表偏移到 0x08010000,结果中断一触发就跑到了 Bootloader 的向量表里。排查了整整两天才发现是 VTOR 没配。记住:App 启动后第一件事就是重设 VTOR。

2.2 堆栈指针设置:别让局部变量“无家可归”

堆栈指针(SP)决定了函数调用、局部变量、中断压栈的位置。Cortex-M 上电后自动加载 MSP,但如果你要切到进程堆栈(PSP),就得手动操作。

为什么需要两个堆栈?

  • MSP(主堆栈):用于中断服务函数和内核代码
  • PSP(进程堆栈):用于任务级代码(RTOS 场景)

我建议 Bootloader 阶段只用 MSP,省心。等 RTOS 启动后再切 PSP。

注意: 堆栈大小要预留足够。我见过一个案例,Bootloader 里用了大数组做缓冲区,结果栈溢出把全局变量区给踩了,程序跑着跑着就 HardFault。建议在链接脚本里把栈大小设为至少 1KB,并加上栈填充模式(0xDEADBEEF)来检测溢出。

2.3 时钟与 PLL 配置:从“慢吞吞”到“飞起来”

芯片上电默认用的是内部低速振荡器(比如 HSI 16MHz),跑得慢。要发挥性能,必须切换到 PLL 倍频后的高速时钟。

时钟配置的典型步骤:

  1. 等待 HSI/HSE 稳定 —— 检查就绪标志位
  2. 配置 PLL 参数 —— 分频系数 M、倍频系数 N、后分频 P/Q
  3. 使能 PLL 并等待锁定 —— 检查 PLL 锁定标志
  4. 切换系统时钟源 —— 从 HSI 切到 PLL
  5. 更新 AHB/APB 总线分频 —— 确保外设时钟不超限

STM32F429 PLL 配置示例(主频 180MHz)

void SystemClock_Config(void)
{
    // 1. 使能 HSE 并等待就绪
    RCC->CR |= RCC_CR_HSEON;
    while (!(RCC->CR & RCC_CR_HSERDY));

    // 2. 配置 PLL: HSE(8MHz) / M(4) * N(180) / P(2) = 180MHz
    RCC->PLLCFGR = (4 << 0)  |   // M = 4
                   (180 << 6) |  // N = 180
                   (0 << 16)  |  // P = 2 (00 表示除以2)
                   (1 << 22)  |  // 主 PLL 选择 HSE
                   (0 << 24);    // Q = 4 (用于 USB/SDIO)

    // 3. 使能 PLL 并等待锁定
    RCC->CR |= RCC_CR_PLLON;
    while (!(RCC->CR & RCC_CR_PLLRDY));

    // 4. 配置 AHB/APB 分频
    RCC->CFGR |= RCC_CFGR_HPRE_DIV1;   // AHB = 180MHz
    RCC->CFGR |= RCC_CFGR_PPRE1_DIV4;  // APB1 = 45MHz
    RCC->CFGR |= RCC_CFGR_PPRE2_DIV2;  // APB2 = 90MHz

    // 5. 切换系统时钟到 PLL
    RCC->CFGR &= ~RCC_CFGR_SW;
    RCC->CFGR |= RCC_CFGR_SW_PLL;
    while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);
}

你想想看,如果 PLL 没锁定就切时钟,系统会直接挂掉。我在调试一块新板子时,就遇到过 HSE 晶振没起振,PLL 一直锁不定,代码卡死在等待循环里。后来用示波器一量,发现晶振负载电容焊错了。

经验之谈: 时钟配置最好放在汇编启动代码里,或者 C 代码最开头。因为很多外设(如 GPIO、UART)的寄存器访问需要时钟使能,如果时钟没配好,写寄存器等于白写。

2.4 内存控制器初始化:让 CPU 能访问“大仓库”

内部 SRAM 一般只有几十到几百 KB,跑复杂应用不够用。这时候就需要外部内存控制器——比如 FSMC(Flexible Static Memory Controller) 或 FMC(Flexible Memory Controller)。

内存控制器初始化要点:

  • 时序参数:地址建立时间、数据保持时间、总线周转周期——这些必须匹配外部芯片的 datasheet
  • 总线宽度:8/16/32 位,要和外部内存对齐
  • 映射地址:不同 Bank 对应不同的地址范围
  • 使能顺序:先配时钟,再配 GPIO 复用功能,最后配控制器寄存器

FMC 初始化示例(SDRAM,16位宽)

void FMC_SDRAM_Init(void)
{
    // 1. 使能 FMC 时钟
    RCC->AHB3ENR |= RCC_AHB3ENR_FMCEN;

    // 2. 配置 SDRAM 时序
    FMC_Bank5_6->SDTR[1] = (7 << 0)   |  // TMRD: 7 个时钟周期
                           (2 << 4)   |  // TXSR: 2 个时钟周期
                           (2 << 8)   |  // TRAS: 2 个时钟周期
                           (7 << 12)  |  // TRC:  7 个时钟周期
                           (2 << 16)  |  // TRP:  2 个时钟周期
                           (2 << 20)  |  // TRCD: 2 个时钟周期
                           (1 << 24);    // TRWR: 1 个时钟周期

    // 3. 配置 SDRAM 控制寄存器
    FMC_Bank5_6->SDCR[1] = (1 << 0)   |  // 列地址位数: 8 位
                           (1 << 4)   |  // 行地址位数: 11 位
                           (1 << 7)   |  // 数据总线宽度: 16 位
                           (2 << 10)  |  // Bank 数: 4
                           (0 << 13);    // CAS 延迟: 2 个时钟

    // 4. 发送初始化命令序列(预充电、自动刷新、加载模式寄存器)
    // ...(省略具体命令序列)
}

说实话,内存控制器初始化是 Bootloader 里最容易出 Bug 的地方。我曾经因为时序参数配得太紧,导致 SDRAM 在高低温下读写不稳定。后来放宽了一个参数,问题就解决了。

重要提醒: 外部内存初始化完成后,一定要做读写测试。写一个模式(比如 0xAA55AA55),读回来对比。我习惯在 Bootloader 里加一段内存自检代码,如果检测失败就通过 LED 闪烁报错。这样硬件问题能第一时间暴露。

2.5 启动流程全景图

下面这张图把整个启动流程串起来了。你对照着看,心里就有谱了。

Bootloader 启动与硬件初始化流程 Bootloader 启动与硬件初始化流程 上电复位 加载复位向量 & 设置 MSP 时钟与 PLL 配置 内存控制器初始化(FMC/FSMC) 外部内存自检 跳转 main() 或 Bootloader 主循环 关键检查点 • PLL 锁定标志 • 内存读写校验 • 堆栈溢出检测

这张图里,时钟配置和内存控制器初始化是最容易出问题的两个环节。我建议你在调试时,每完成一步就点一个 LED 或者打印一个字符,这样能快速定位卡在哪一步。

2.6 本章小结

启动初始化说白了就是给芯片“铺好路”:

  • 复位向量告诉 CPU 从哪开始跑
  • 堆栈指针给局部变量找个家
  • 时钟配置让芯片跑出应有的速度
  • 内存控制器把外部存储空间接入系统

这些步骤环环相扣,一步错步步错。我的建议是:写代码前先把芯片手册的时钟树和内存映射看透,别急着敲键盘。磨刀不误砍柴工,这个道理在嵌入式开发里尤其适用。