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 倍频后的高速时钟。
时钟配置的典型步骤:
- 等待 HSI/HSE 稳定 —— 检查就绪标志位
- 配置 PLL 参数 —— 分频系数 M、倍频系数 N、后分频 P/Q
- 使能 PLL 并等待锁定 —— 检查 PLL 锁定标志
- 切换系统时钟源 —— 从 HSI 切到 PLL
- 更新 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 启动流程全景图
下面这张图把整个启动流程串起来了。你对照着看,心里就有谱了。
这张图里,时钟配置和内存控制器初始化是最容易出问题的两个环节。我建议你在调试时,每完成一步就点一个 LED 或者打印一个字符,这样能快速定位卡在哪一步。
2.6 本章小结
启动初始化说白了就是给芯片“铺好路”:
- 复位向量告诉 CPU 从哪开始跑
- 堆栈指针给局部变量找个家
- 时钟配置让芯片跑出应有的速度
- 内存控制器把外部存储空间接入系统
这些步骤环环相扣,一步错步步错。我的建议是:写代码前先把芯片手册的时钟树和内存映射看透,别急着敲键盘。磨刀不误砍柴工,这个道理在嵌入式开发里尤其适用。