3. 启动流程剖析
硬件初始化,说白了就是给芯片“通电醒脑”的过程。Bootloader 跑起来的第一件事,就是让 CPU 能正常取指、让内存能存数据、让开发者能看到输出。这三步走不对,后面全是白搭。
我个人习惯把硬件初始化分成三个层次:时钟 → 内存 → 外设。时钟是心脏,DDR 是仓库,串口是眼睛。今天咱们就逐个拆解。
3.1 时钟系统初始化——给芯片一颗稳定的心脏
时钟系统是 Bootloader 里最先要碰的硬件模块。你想想看,CPU 跑多快、总线多宽、外设能不能工作,全看时钟配没配对。
我见过不少新手,上来就抄参考代码,结果板子死活不跑。后来发现是 PLL 锁定时间不够,CPU 在时钟还没稳定就去取指了。嗯,这里要注意:PLL 配置后必须等待锁定标志位。
时钟初始化的标准步骤
- 选择时钟源——外部晶振还是内部 RC 振荡器?Bootloader 阶段我建议用外部晶振,精度高、温漂小。
- 配置 PLL——设置倍频系数和分频系数,算出目标频率。
- 等待 PLL 锁定——轮询锁定标志位,超时处理不能少。
- 切换系统时钟——从默认时钟平滑切换到 PLL 输出。
- 使能外设时钟——按需打开 GPIO、UART、DDR 控制器的时钟门控。
来看一段实际代码。这是基于 ARM Cortex-M 系列的时钟配置片段:
void clock_init(void) {
// 1. 选择外部晶振作为 PLL 源
RCC->CR |= RCC_CR_HSEON;
while (!(RCC->CR & RCC_CR_HSERDY)); // 等待 HSE 就绪
// 2. 配置 PLL:8MHz 外部晶振 × 9 = 72MHz
RCC->CFGR |= RCC_CFGR_PLLSRC_HSE;
RCC->PLLCFGR = (8 << 24) | (72 << 6) | (2 << 0); // N=72, M=8, P=2
// 3. 使能 PLL 并等待锁定
RCC->CR |= RCC_CR_PLLON;
while (!(RCC->CR & RCC_CR_PLLRDY)); // 必须等到锁定
// 4. 切换系统时钟到 PLL
RCC->CFGR |= RCC_CFGR_SW_PLL;
while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);
}
3.2 DDR 内存控制器配置——让 CPU 有个像样的仓库
Bootloader 里 DDR 初始化是最容易翻车的地方。为什么?因为 DDR 控制器涉及大量时序参数,一个不对就全黑屏。
我个人习惯把 DDR 初始化分成三个阶段:控制器复位 → 时序配置 → 训练校准。其中训练校准这一步,很多芯片厂商的参考代码都藏着坑。
DDR 初始化关键参数
| 参数 | 说明 | 常见坑点 |
|---|---|---|
| CL (CAS Latency) | 列地址选通延迟 | 设小了读数据会错位 |
| tRCD | 行到列延迟 | 设小了激活失败 |
| tRP | 预充电时间 | 设小了刷新冲突 |
| tRFC | 刷新周期 | 设小了丢数据 |
| 刷新率 | 每 64ms 刷新所有行 | 温度高时要加倍 |
来看一段 DDR 控制器配置的伪代码,这是基于某 ARM SoC 的典型流程:
void ddr_init(void) {
// 1. 复位 DDR 控制器
DDRC->CTRL |= DDRC_CTRL_RESET;
delay_ms(10);
DDRC->CTRL &= ~DDRC_CTRL_RESET;
// 2. 配置时序参数(从 DDR 芯片数据手册获取)
DDRC->TIMING = (CL << 24) | (tRCD << 16) | (tRP << 8) | tRFC;
// 3. 配置刷新间隔
DDRC->REFRESH = (TREFI / TCLK) - 1; // TREFI = 7.8us @ 85°C
// 4. 启动 DDR 控制器
DDRC->CTRL |= DDRC_CTRL_ENABLE;
while (!(DDRC->STATUS & DDRC_STATUS_READY));
// 5. 执行 ZQ 校准(DDR3/DDR4 必须)
DDRC->ZQCTRL |= DDRC_ZQCTRL_START;
while (!(DDRC->ZQCTRL & DDRC_ZQCTRL_DONE));
}
DDR 训练——最容易被忽略的一步
很多 SoC 的 DDR 控制器支持自动训练,比如读写平衡、DQS 门控校准。但有些低端芯片需要手动写训练序列。我记得有一次,板子在常温下跑得好好的,一到高温测试就随机死机。查了两天才发现是 DQS 门控延迟没校准,温度漂移后采样窗口偏移了。
3.3 串口初始化与调试输出——让 Bootloader 开口说话
串口是 Bootloader 开发者的“眼睛”。没有串口输出,你连芯片有没有跑起来都不知道。我刚开始做 Bootloader 时,第一件事就是让串口能打印一个字符。
串口初始化其实不复杂,但有几个细节容易忽略:
- GPIO 复用配置——TX/RX 引脚必须切换到 UART 功能,默认可能是 GPIO 模式。
- 波特率计算——根据系统时钟和 UART 模块的分频器算出正确的波特率寄存器值。
- 数据格式——8N1(8 数据位、无校验、1 停止位)是 Bootloader 最常用的格式。
- FIFO 使能——如果 UART 有硬件 FIFO,建议使能,减少 CPU 开销。
来看一个简洁的串口初始化代码:
void uart_init(uint32_t baudrate) {
uint32_t divisor;
// 1. 使能 UART 和 GPIO 时钟
RCC->APB2ENR |= RCC_APB2ENR_USART1EN | RCC_APB2ENR_GPIOAEN;
// 2. 配置 TX(PA9) 为复用推挽输出,RX(PA10) 为浮空输入
GPIOA->CRH &= ~(0xFF << 4);
GPIOA->CRH |= (0x0B << 4); // TX: 复用推挽 50MHz
GPIOA->CRH |= (0x04 << 8); // RX: 浮空输入
// 3. 计算波特率分频值(假设 APB2 = 72MHz)
divisor = 72000000 / (16 * baudrate);
USART1->BRR = divisor;
// 4. 使能 UART、发送器、接收器
USART1->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE;
}
float 算波特率,结果串口输出全是乱码。
调试输出的实用技巧
串口初始化好后,我通常会封装几个简单的打印函数:
putchar(c)——发送单个字符,轮询 TX 空标志puts(s)——发送字符串,循环调用 putcharputhex(n)——打印十六进制数,调试寄存器值最常用
这里有个小技巧:在 Bootloader 早期阶段,栈可能还没完全准备好,所以打印函数里尽量不要用递归或大数组。我习惯用静态缓冲区,一次只处理一个字符。
void putchar(char c) {
while (!(USART1->SR & USART_SR_TXE)); // 等待发送缓冲区空
USART1->DR = c;
}
void puts(const char *s) {
while (*s) {
putchar(*s++);
}
}
void puthex(uint32_t val) {
char buf[11]; // "0x" + 8 hex digits + null
int i;
buf[0] = '0';
buf[1] = 'x';
for (i = 9; i >= 2; i--) {
uint8_t nibble = val & 0x0F;
buf[i] = (nibble < 10) ? ('0' + nibble) : ('A' + nibble - 10);
val >>= 4;
}
buf[10] = '\0';
puts(buf);
}
3.4 本章小结
硬件初始化是 Bootloader 的基石。时钟配错了,CPU 跑不起来;DDR 配错了,程序没地方放;串口配错了,你连错在哪都不知道。
我个人建议,每次移植 Bootloader 到新板子时,按这个顺序来:
- 先让时钟稳定输出,用示波器量一下波形
- 再配 DDR,跑内存测试通过后再往下走
- 最后开串口,打印第一行 "Hello from Bootloader"
这三步走稳了,后面的外设初始化就是水到渠成的事。