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 还能用内部时钟完成应急处理。

经验之谈: 我在 STM32F4 项目上遇到过一批板子,外部晶振虚焊导致起振失败。幸好 Bootloader 先用 HSI 跑起来,检测到 HSE 超时后自动切回 HSI,还通过 LED 闪码报了错。要是直接死等 HSE,那批板子全得返厂。

2.1.2 时钟树配置流程

时钟配置不是随便写几个寄存器就完事。你得按芯片手册的「时钟树」来走。我总结了一个通用流程:

  1. 复位后默认状态:芯片通常跑在 HSI 或内部 RC 振荡器,频率较低(比如 8 MHz)。
  2. 等待外部振荡器稳定:如果要用 HSE,必须等待时钟就绪标志位(HSERDY)。
  3. 配置 PLL 倍频系数:注意 VCO 输入频率范围,别超限。
  4. 配置系统时钟分频:AHB、APB1、APB2 的分频系数,要匹配外设的最大频率。
  5. 切换系统时钟源:从 HSI 切到 PLL 输出,等待切换完成标志。
  6. 调整 Flash 等待周期:频率高了,Flash 读取速度跟不上,必须插入等待周期。

嗯,这里要注意第六步。很多人忘了调 Flash 等待周期,结果代码跑飞了。我早期做 NXP LPC 系列时就吃过这个亏——PLL 切到 120 MHz,Flash 等待周期还是 0,程序直接跑进 HardFault。

核心原则: 时钟频率每提高一档,先确认 Flash/内存的访问速度能否跟上。跟不上就加等待周期,否则就是「车跑太快,刹车失灵」。

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);
}
注意: 上面的超时处理不是可选项。我曾经在量产项目中因为没加超时,导致一批晶振有问题的板子在 Bootloader 阶段就卡死了,连错误报告都发不出来。加个超时,天塌不下来。

2.2 内存控制器初始化——给代码找个「家」

时钟跑起来了,接下来要搞定内存。Bootloader 的内存初始化主要分两块:内部 SRAM 和 外部 SDRAM/NOR Flash。

2.2.1 内部 SRAM 初始化

内部 SRAM 通常上电就能用,但有些芯片需要使能 SRAM 时钟或配置访问权限。比如 STM32F7 的 DTCM 和 ITCM 就需要单独使能。

我一般做这几件事:

  • 使能 SRAM 时钟:在 RCC 的 AHB 外设时钟使能寄存器里打开。
  • 配置 MPU(内存保护单元):给 SRAM 区域设置读写权限,防止 Bootloader 意外访问到保留区域。
  • 清零 BSS 段:把未初始化的全局变量区域清零。这是 C 运行环境的要求。
一个小细节: 清零 BSS 段时,别用 memset。为什么?因为 memset 本身可能依赖 BSS 段里的变量。我习惯用汇编写一个简单的清零循环,放在 .text 段里,保证不依赖任何未初始化的数据。

2.2.2 外部 SDRAM 初始化

如果 Bootloader 需要从外部 SDRAM 加载应用,那 SDRAM 的初始化就至关重要。SDRAM 的时序参数非常敏感,配错了要么数据错乱,要么直接死机。

SDRAM 初始化的标准步骤:

  1. 配置 GPIO 复用功能:把 SDRAM 相关的引脚设为 FMC/FMC 功能。
  2. 配置时序参数:包括 CAS 延迟、刷新周期、行/列地址宽度等。
  3. 发送初始化命令序列:空操作 -> 预充电 -> 自动刷新 -> 设置模式寄存器。
  4. 启动自动刷新:配置刷新定时器。
  5. 做一次读写测试:写一个已知模式,读回来校验。这一步我强烈建议加上。
避坑指南: 我曾经在 SDRAM 初始化时漏了「等待上电稳定」这一步。SDRAM 上电后需要 100–200 µs 的稳定时间,然后才能发命令。我直接跳过了,结果第一批板子有 30% 在低温下启动失败。后来加上延时,问题全消。

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 协议栈有较大的临时缓冲区
警告: 栈溢出是 Bootloader 里最隐蔽的 bug。它不会立即崩溃,而是在某个深层函数调用时突然覆盖了全局变量。我建议在栈底放一个「哨兵值」(比如 0xDEADBEEF),在关键路径上检查这个值是否被改写。

2.4 外设模块的早期使能——先让「手脚」能动

时钟和内存就绪后,就该打开 Bootloader 必需的外设了。注意,这里说的是「早期使能」,不是把所有外设都打开。只开那些 Bootloader 阶段必须用的。

2.4.1 哪些外设需要早期使能?

我一般按这个优先级来:

  1. 调试接口:SWD/JTAG。万一 Bootloader 卡住了,还能用调试器救回来。
  2. 通信接口:UART、SPI、I2C、USB 等,用于接收固件数据。
  3. 看门狗:如果系统要求安全启动,看门狗必须在早期就开启,防止 Bootloader 死循环。
  4. GPIO 指示:LED 或蜂鸣器,用于指示 Bootloader 状态。
  5. Flash 控制器:用于擦写应用区。

2.4.2 外设使能的顺序问题

外设使能不是随便写的。我踩过一个坑:先使能了 UART,再配置 GPIO 复用功能。结果 UART 的 TX/RX 引脚还是默认的 GPIO 模式,数据发不出去。

正确的顺序是:

  1. 使能 GPIO 时钟:让引脚能工作。
  2. 配置 GPIO 复用功能:把引脚切到外设模式。
  3. 使能外设时钟:打开 UART/SPI 等模块的时钟。
  4. 配置外设参数:波特率、数据位、中断等。
  5. 使能外设:最后才打开外设的使能位。
核心原则: 先让引脚「认识」外设,再让外设「工作」。顺序反了,信号出不去。

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 本章知识结构图

下面这张图把硬件初始化的四个核心模块串起来了。你可以看到,时钟是基础,内存和堆栈是运行环境,外设是交互手段。每一步都有依赖关系。

Bootloader 硬件初始化流程 Bootloader 硬件初始化流程 时钟系统配置 HSI → HSE → PLL → 分频 → Flash 等待周期 内存控制器初始化 内部 SRAM → 外部 SDRAM → BSS 清零 堆栈指针设置 复位向量表加载 → 链接脚本定义 → 哨兵值保护 外设模块早期使能 GPIO → UART/SPI → 看门狗 → Flash 控制器 关键依赖 • 时钟是基础 • 内存决定运行环境 • 堆栈影响函数调用 • 外设用于交互 常见错误 • 忘配 Flash 等待周期 • SDRAM 时序配错 • 堆栈溢出无保护 • 外设使能顺序颠倒

2.6 本章小结

硬件初始化是 Bootloader 的「地基工程」。时钟配错了,后面全是空中楼阁;内存没配好,代码没地方跑;堆栈设错了,函数调用直接崩;外设顺序乱了,数据发不出去。

我个人的经验是:每做完一步,就做一次验证。时钟配好了,用 GPIO 翻转看频率;内存配好了,写个模式读回来校验;外设配好了,发个 0x55 看看波形。别等到最后一起调,那时候根本不知道是哪一步出的问题。