2. 硬件初始化:时钟系统配置、内存控制器初始化、堆栈指针设置、外设模块的早期使能
Bootloader 启动的第一件事是什么?不是跳转到 main 函数,也不是打印日志。是让硬件先「活过来」。我见过不少新手,上来就写应用逻辑,结果板子压根不跑——说白了,芯片还睡着呢。
硬件初始化,就是给芯片做「心肺复苏」。时钟是心跳,内存是大脑,堆栈是临时工作台,外设是手脚。顺序错了,或者配置漏了,后面全白搭。这一节,我把这些年踩过的坑和总结的套路,掰开了讲给你听。
2.1 时钟系统配置——芯片的「心跳」
时钟是 Bootloader 的第一步。没有时钟,CPU 不跑,外设不动。我习惯把时钟配置分成三步:选源、设倍频、分频输出。
2.1.1 时钟源的选择
主流 MCU 通常有这几个时钟源:
| 时钟源 | 典型频率 | 特点 | Bootloader 常用场景 |
|---|---|---|---|
| HSI(内部高速振荡器) | 8 MHz / 16 MHz | 上电即用,精度一般 | 早期启动、应急恢复 |
| HSE(外部晶振) | 4–25 MHz | 精度高,需起振时间 | 正常运行时首选 |
| LSI(内部低速振荡器) | 32 kHz / 40 kHz | 低功耗,精度差 | 看门狗、RTC 唤醒 |
| PLL(锁相环倍频) | 可达数百 MHz | 高频,需配置分频系数 | 高性能模式 |
我个人习惯:Bootloader 启动时先用 HSI。为什么?因为 HSI 不需要等待外部晶振起振,上电就能跑。等我把 Flash 控制器、内存控制器初始化好了,再切到 HSE+PLL 的高频模式。这样即使外部晶振坏了,Bootloader 还能用内部时钟完成应急处理。
2.1.2 时钟树配置流程
时钟配置不是随便写几个寄存器就完事。你得按芯片手册的「时钟树」来走。我总结了一个通用流程:
- 复位后默认状态:芯片通常跑在 HSI 或内部 RC 振荡器,频率较低(比如 8 MHz)。
- 等待外部振荡器稳定:如果要用 HSE,必须等待时钟就绪标志位(HSERDY)。
- 配置 PLL 倍频系数:注意 VCO 输入频率范围,别超限。
- 配置系统时钟分频:AHB、APB1、APB2 的分频系数,要匹配外设的最大频率。
- 切换系统时钟源:从 HSI 切到 PLL 输出,等待切换完成标志。
- 调整 Flash 等待周期:频率高了,Flash 读取速度跟不上,必须插入等待周期。
嗯,这里要注意第六步。很多人忘了调 Flash 等待周期,结果代码跑飞了。我早期做 NXP LPC 系列时就吃过这个亏——PLL 切到 120 MHz,Flash 等待周期还是 0,程序直接跑进 HardFault。
2.1.3 代码示例:STM32 时钟初始化
void SystemClock_Init(void)
{
// 1. 使能 HSI
RCC->CR |= RCC_CR_HSION;
while (!(RCC->CR & RCC_CR_HSIRDY));
// 2. 配置 HSE(外部晶振)
RCC->CR |= RCC_CR_HSEON;
// 这里加超时处理,防止晶振失效死等
uint32_t timeout = 100000;
while (!(RCC->CR & RCC_CR_HSERDY)) {
if (--timeout == 0) {
// HSE 起振失败,保持 HSI 运行
Error_Handler(ERR_HSE_FAIL);
return;
}
}
// 3. 配置 PLL:HSE 8 MHz -> PLLM=8 -> PLLN=168 -> PLLP=2 -> 168 MHz
RCC->PLLCFGR = (8 << RCC_PLLCFGR_PLLM_Pos) |
(168 << RCC_PLLCFGR_PLLN_Pos) |
(0 << RCC_PLLCFGR_PLLP_Pos) |
RCC_PLLCFGR_PLLSRC_HSE;
// 4. 使能 PLL
RCC->CR |= RCC_CR_PLLON;
while (!(RCC->CR & RCC_CR_PLLRDY));
// 5. 配置 AHB/APB 分频
RCC->CFGR |= RCC_CFGR_HPRE_DIV1; // AHB = 168 MHz
RCC->CFGR |= RCC_CFGR_PPRE1_DIV4; // APB1 = 42 MHz
RCC->CFGR |= RCC_CFGR_PPRE2_DIV2; // APB2 = 84 MHz
// 6. 调整 Flash 等待周期(168 MHz 需要 5 个等待周期)
FLASH->ACR = FLASH_ACR_LATENCY_5WS | FLASH_ACR_PRFTEN | FLASH_ACR_ICEN | FLASH_ACR_DCEN;
// 7. 切换系统时钟到 PLL
RCC->CFGR |= RCC_CFGR_SW_PLL;
while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);
}
2.2 内存控制器初始化——给代码找个「家」
时钟跑起来了,接下来要搞定内存。Bootloader 的内存初始化主要分两块:内部 SRAM 和 外部 SDRAM/NOR Flash。
2.2.1 内部 SRAM 初始化
内部 SRAM 通常上电就能用,但有些芯片需要使能 SRAM 时钟或配置访问权限。比如 STM32F7 的 DTCM 和 ITCM 就需要单独使能。
我一般做这几件事:
- 使能 SRAM 时钟:在 RCC 的 AHB 外设时钟使能寄存器里打开。
- 配置 MPU(内存保护单元):给 SRAM 区域设置读写权限,防止 Bootloader 意外访问到保留区域。
- 清零 BSS 段:把未初始化的全局变量区域清零。这是 C 运行环境的要求。
2.2.2 外部 SDRAM 初始化
如果 Bootloader 需要从外部 SDRAM 加载应用,那 SDRAM 的初始化就至关重要。SDRAM 的时序参数非常敏感,配错了要么数据错乱,要么直接死机。
SDRAM 初始化的标准步骤:
- 配置 GPIO 复用功能:把 SDRAM 相关的引脚设为 FMC/FMC 功能。
- 配置时序参数:包括 CAS 延迟、刷新周期、行/列地址宽度等。
- 发送初始化命令序列:空操作 -> 预充电 -> 自动刷新 -> 设置模式寄存器。
- 启动自动刷新:配置刷新定时器。
- 做一次读写测试:写一个已知模式,读回来校验。这一步我强烈建议加上。
2.2.3 内存控制器配置示例(FMC 接口)
void SDRAM_Init(void)
{
// 1. 使能 FMC 时钟
RCC->AHB3ENR |= RCC_AHB3ENR_FMCEN;
// 2. 配置时序(以 W9825G6KH 为例)
FMC_Bank5_6->SDTR[1] = (7 << 0) | // TMRD: 7 个时钟周期
(2 << 4) | // TXSR: 2 个时钟周期
(2 << 8) | // TRAS: 2 个时钟周期
(7 << 12) | // TRC: 7 个时钟周期
(2 << 20) | // TRP: 2 个时钟周期
(2 << 24); // TRCD: 2 个时钟周期
// 3. 配置控制寄存器
FMC_Bank5_6->SDCR[1] = (1 << 7) | // 列地址宽度: 9 位
(1 << 9) | // 行地址宽度: 13 位
(2 << 11) | // 数据总线宽度: 16 位
(1 << 13) | // 内部 bank 数: 4
(0 << 15); // CAS 延迟: 2 个时钟
// 4. 发送初始化命令序列
// 延时 100 µs(等待 SDRAM 上电稳定)
Delay_us(100);
// 空操作
FMC_Bank5_6->SDCMR = (1 << 0) | (1 << 4);
Delay_us(1);
// 预充电所有 bank
FMC_Bank5_6->SDCMR = (2 << 0) | (1 << 4);
Delay_us(1);
// 自动刷新(8 次)
FMC_Bank5_6->SDCMR = (3 << 0) | (8 << 5);
Delay_us(10);
// 设置模式寄存器(CAS=2, 突发长度=1)
FMC_Bank5_6->SDCMR = (4 << 0) | (0x22 << 9);
Delay_us(1);
// 5. 配置刷新定时器(64 ms / 4096 行 ≈ 15.625 µs)
FMC_Bank5_6->SDRTR = (683 << 1); // 以 HCLK 周期计
}
2.3 堆栈指针设置——给函数调用搭个「脚手架」
堆栈指针(SP)是 CPU 运行 C 代码的基础。没有正确的 SP,函数调用、局部变量、中断响应全都会崩。
2.3.1 堆栈指针的初始化时机
SP 的初始化发生在两个地方:
- 复位向量表:CPU 复位后,硬件自动从地址 0x00000000 加载 SP 初始值。这个值由链接脚本指定。
- Bootloader 重定位:如果 Bootloader 需要把 SP 移到 RAM 的高地址(比如为了给应用留空间),就需要在 C 代码启动前手动设置。
我个人习惯:在启动文件里就把 SP 设好。链接脚本里定义好栈顶地址,启动文件里用一条汇编指令加载:
ldr sp, =_estack ; _estack 是链接脚本定义的栈顶地址
bl main
为什么不在 main 函数里设?因为 main 函数本身就要用栈。你想想看,main 的返回地址、局部变量都压在栈上,SP 不对,main 都进不去。
2.3.2 堆栈大小的确定
Bootloader 的堆栈大小怎么定?我一般按这个原则:
| 场景 | 建议栈大小 | 说明 |
|---|---|---|
| 最小 Bootloader(仅串口下载) | 256–512 字节 | 函数调用层级浅,无复杂协议栈 |
| 带 Flash 驱动和校验 | 1–2 KB | 需要缓冲区做擦写和校验 |
| 带网络或 USB 协议栈 | 4–8 KB | 协议栈有较大的临时缓冲区 |
2.4 外设模块的早期使能——先让「手脚」能动
时钟和内存就绪后,就该打开 Bootloader 必需的外设了。注意,这里说的是「早期使能」,不是把所有外设都打开。只开那些 Bootloader 阶段必须用的。
2.4.1 哪些外设需要早期使能?
我一般按这个优先级来:
- 调试接口:SWD/JTAG。万一 Bootloader 卡住了,还能用调试器救回来。
- 通信接口:UART、SPI、I2C、USB 等,用于接收固件数据。
- 看门狗:如果系统要求安全启动,看门狗必须在早期就开启,防止 Bootloader 死循环。
- GPIO 指示:LED 或蜂鸣器,用于指示 Bootloader 状态。
- Flash 控制器:用于擦写应用区。
2.4.2 外设使能的顺序问题
外设使能不是随便写的。我踩过一个坑:先使能了 UART,再配置 GPIO 复用功能。结果 UART 的 TX/RX 引脚还是默认的 GPIO 模式,数据发不出去。
正确的顺序是:
- 使能 GPIO 时钟:让引脚能工作。
- 配置 GPIO 复用功能:把引脚切到外设模式。
- 使能外设时钟:打开 UART/SPI 等模块的时钟。
- 配置外设参数:波特率、数据位、中断等。
- 使能外设:最后才打开外设的使能位。
2.4.3 早期使能的代码骨架
void Early_Peripheral_Init(void)
{
// 1. 使能 GPIO 时钟(以 UART 使用的 GPIOA 为例)
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
// 2. 配置 TX (PA9) 为复用功能,RX (PA10) 为复用功能
GPIOA->MODER &= ~(0x3 << 18); // 清除 PA9 模式
GPIOA->MODER |= (0x2 << 18); // 设为复用功能
GPIOA->MODER &= ~(0x3 << 20); // 清除 PA10 模式
GPIOA->MODER |= (0x2 << 20); // 设为复用功能
GPIOA->AFR[1] |= (0x7 << 4); // PA9 复用为 USART1_TX
GPIOA->AFR[1] |= (0x7 << 8); // PA10 复用为 USART1_RX
// 3. 使能 USART1 时钟
RCC->APB2ENR |= RCC_APB2ENR_USART1EN;
// 4. 配置波特率 115200(以 42 MHz APB2 时钟为例)
USART1->BRR = 42000000 / 115200;
// 5. 使能 USART1 的发送和接收
USART1->CR1 = USART_CR1_TE | USART_CR1_RE | USART_CR1_UE;
}
2.5 本章知识结构图
下面这张图把硬件初始化的四个核心模块串起来了。你可以看到,时钟是基础,内存和堆栈是运行环境,外设是交互手段。每一步都有依赖关系。
2.6 本章小结
硬件初始化是 Bootloader 的「地基工程」。时钟配错了,后面全是空中楼阁;内存没配好,代码没地方跑;堆栈设错了,函数调用直接崩;外设顺序乱了,数据发不出去。
我个人的经验是:每做完一步,就做一次验证。时钟配好了,用 GPIO 翻转看频率;内存配好了,写个模式读回来校验;外设配好了,发个 0x55 看看波形。别等到最后一起调,那时候根本不知道是哪一步出的问题。