3. 启动流程剖析

硬件初始化,说白了就是给芯片“通电醒脑”的过程。Bootloader 跑起来的第一件事,就是让 CPU 能正常取指、让内存能存数据、让开发者能看到输出。这三步走不对,后面全是白搭。

我个人习惯把硬件初始化分成三个层次:时钟 → 内存 → 外设。时钟是心脏,DDR 是仓库,串口是眼睛。今天咱们就逐个拆解。

核心原则: 先保证 CPU 能跑起来,再让内存可用,最后才初始化调试外设。顺序错了,连打印都看不到。
硬件初始化核心流程 时钟系统初始化 PLL / 分频 / 使能 DDR 控制器配置 时序 / 刷新 / 校准 串口初始化 波特率 / GPIO / 输出 主频设置 外设时钟门控 时序参数配置 DDR 训练/校准 波特率配置 GPIO 复用设置 ⚠ 关键依赖关系 DDR 初始化依赖稳定的时钟源 → 串口输出依赖 DDR 或片内 SRAM 的栈空间

3.1 时钟系统初始化——给芯片一颗稳定的心脏

时钟系统是 Bootloader 里最先要碰的硬件模块。你想想看,CPU 跑多快、总线多宽、外设能不能工作,全看时钟配没配对。

我见过不少新手,上来就抄参考代码,结果板子死活不跑。后来发现是 PLL 锁定时间不够,CPU 在时钟还没稳定就去取指了。嗯,这里要注意:PLL 配置后必须等待锁定标志位。

时钟初始化的标准步骤

  1. 选择时钟源——外部晶振还是内部 RC 振荡器?Bootloader 阶段我建议用外部晶振,精度高、温漂小。
  2. 配置 PLL——设置倍频系数和分频系数,算出目标频率。
  3. 等待 PLL 锁定——轮询锁定标志位,超时处理不能少。
  4. 切换系统时钟——从默认时钟平滑切换到 PLL 输出。
  5. 使能外设时钟——按需打开 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);
}
避坑: 我曾经在一个项目里忘了配置 Flash 等待周期,CPU 跑到 72MHz 但 Flash 只支持 48MHz,结果程序随机崩溃。时钟提频后,一定要同步调整 Flash/SRAM 的等待周期。

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,参数可能差 10% 以上。我建议在 Bootloader 里预留一个参数表,方便换内存颗粒时快速适配。

DDR 训练——最容易被忽略的一步

很多 SoC 的 DDR 控制器支持自动训练,比如读写平衡、DQS 门控校准。但有些低端芯片需要手动写训练序列。我记得有一次,板子在常温下跑得好好的,一到高温测试就随机死机。查了两天才发现是 DQS 门控延迟没校准,温度漂移后采样窗口偏移了。

我的做法: 在 DDR 初始化完成后,写一个简单的内存测试函数——先写 0xAA55AA55,再读回来比对。如果不对,直接打印错误码,不要继续往下跑。这叫“早失败、早定位”。

3.3 串口初始化与调试输出——让 Bootloader 开口说话

串口是 Bootloader 开发者的“眼睛”。没有串口输出,你连芯片有没有跑起来都不知道。我刚开始做 Bootloader 时,第一件事就是让串口能打印一个字符。

串口初始化其实不复杂,但有几个细节容易忽略:

  1. GPIO 复用配置——TX/RX 引脚必须切换到 UART 功能,默认可能是 GPIO 模式。
  2. 波特率计算——根据系统时钟和 UART 模块的分频器算出正确的波特率寄存器值。
  3. 数据格式——8N1(8 数据位、无校验、1 停止位)是 Bootloader 最常用的格式。
  4. 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)——发送字符串,循环调用 putchar
  • puthex(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);
}
我的习惯: 在 DDR 初始化前后各打印一条信息。如果 DDR 初始化后打印正常,说明 DDR 没问题;如果打印卡住了,问题就在 DDR 配置上。这个“分界打印法”帮我定位过无数次硬件问题。

3.4 本章小结

硬件初始化是 Bootloader 的基石。时钟配错了,CPU 跑不起来;DDR 配错了,程序没地方放;串口配错了,你连错在哪都不知道。

我个人建议,每次移植 Bootloader 到新板子时,按这个顺序来:

  • 先让时钟稳定输出,用示波器量一下波形
  • 再配 DDR,跑内存测试通过后再往下走
  • 最后开串口,打印第一行 "Hello from Bootloader"

这三步走稳了,后面的外设初始化就是水到渠成的事。