2. 硬件初始化:CPU模式设置、时钟系统初始化、内存控制器配置、堆栈指针设置

Bootloader 启动的第一步,说白了就是让芯片“活过来”。

芯片上电后,内部逻辑会从复位向量指向的地址取第一条指令。但这时候,CPU 处于什么模式?时钟跑多快?内存能不能用?堆栈在哪?——全是未知数。所以硬件初始化的任务,就是把一个“裸片”武装成能执行 C 代码的环境。

我个人习惯把这四个步骤看作一个递进链条:先定身份(CPU模式),再定心跳(时钟),然后搭舞台(内存控制器),最后摆好椅子(堆栈指针)。顺序不能乱,少了哪一步,后面都可能直接死机。

核心原则:硬件初始化必须在进入 main() 之前完成。Bootloader 的启动代码(startup.S)里,这些操作通常由汇编语言直接控制,因为此时连堆栈都还没设好,无法调用 C 函数。

2.1 CPU 模式设置:先让处理器知道“我是谁”

ARM Cortex-M 系列上电默认处于 Thread Mode + Privileged Level,但很多 SoC 的 BootROM 或一级 Bootloader 会先切换到 Supervisor Mode(SVC)或 System Mode。为什么?因为这些模式能访问所有寄存器,而且有独立的堆栈指针。

我记得第一次调一个基于 Cortex-A8 的板子,上电后直接跑飞。查了半天,发现是 CPU 还在 IRQ Mode,堆栈指针指向未初始化区域。后来我在启动代码开头强制设成 SVC Mode,问题就解决了。

典型的 CPU 模式设置步骤:

  1. 读取当前 CPSR(Current Program Status Register)
  2. 清除模式位,写入目标模式对应的位域
  3. 确保中断关闭(FIQ 和 IRQ 位都置 1)
  4. 如果是多核处理器,还需要确认当前核是主核还是从核
@ ARM Cortex-A 系列启动代码片段
reset_handler:
    /* 切换到 SVC Mode,关闭中断 */
    MRS     r0, cpsr
    BIC     r0, r0, #0x1F          @ 清除模式位
    ORR     r0, r0, #0x13          @ SVC Mode (0b10011)
    ORR     r0, r0, #0xC0          @ 关闭 IRQ + FIQ
    MSR     cpsr, r0
    /* 此时 CPU 处于特权级、无中断的安全状态 */

小技巧:设置 CPU 模式时,顺手把中断关了。等所有初始化完成、堆栈稳定后再开中断。我曾经见过有人先开中断再配时钟,结果一个定时器中断进来,堆栈还没设好,直接 HardFault。

2.2 时钟系统初始化:给芯片一颗稳定的“心脏”

时钟是芯片的脉搏。上电后,芯片通常跑在一个低速内部振荡器上(比如 8MHz RC 振荡器)。但 Bootloader 要搬代码、初始化 DDR,这点频率根本不够用。所以我们需要切换到外部晶振 + PLL 倍频,把主频拉到几百 MHz 甚至 GHz。

时钟初始化的典型流程:

  • 等待外部晶振稳定——晶振起振需要时间,通常几百微秒到几毫秒。不能一上电就切,否则时钟源不稳定,系统直接乱掉。
  • 配置 PLL 倍频系数——根据目标频率计算分频比、倍频比、后分频。这里最容易踩坑:PLL 输出频率不能超过芯片规格,否则芯片会过热甚至烧毁。
  • 切换系统时钟源——从内部振荡器切换到 PLL 输出。切换前要确认 PLL 已经锁定(Lock 标志位置 1)。
  • 配置外设时钟分频——AHB、APB1、APB2 等总线时钟需要单独设置。外设跑太快会挂,跑太慢浪费性能。
时钟源 典型频率 用途 注意事项
内部 RC 振荡器 8~16 MHz 上电默认、低功耗模式 精度差(±1%~±3%),不适合高速通信
外部晶振 4~50 MHz PLL 参考源 需要匹配负载电容,起振时间较长
PLL 输出 几百 MHz ~ GHz CPU 核心、DDR 控制器 必须等待 Lock 标志,防止频率跳变
/* STM32F4 时钟初始化示例(简化) */
void SystemClock_Config(void) {
    /* 1. 使能 HSE 外部晶振 */
    RCC->CR |= RCC_CR_HSEON;
    while (!(RCC->CR & RCC_CR_HSERDY));  /* 等待稳定 */

    /* 2. 配置 PLL:HSE 8MHz -> PLL 168MHz */
    RCC->PLLCFGR = (8 << 0)  |  /* PLLM = 8 分频 */
                   (336 << 6) |  /* PLLN = 336 倍频 */
                   (2 << 16)  |  /* PLLP = 4 分频 -> 168MHz */
                   RCC_PLLCFGR_PLLSRC_HSE;

    /* 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);
}

避坑指南:我曾经在一个项目里,PLL 配置完直接切时钟,没等 Lock 标志。结果芯片跑在未锁定的 PLL 上,频率忽高忽低,UART 通信全是乱码。后来加了等待循环,问题消失。记住:PLL 锁定时间通常需要几百个参考时钟周期,别省这一步。

2.3 内存控制器配置:让 CPU 能访问“大仓库”

Bootloader 通常运行在内部 SRAM 里,但内部 SRAM 很小(几十 KB 到几 MB)。要加载更大的程序(比如 Linux 内核),必须初始化外部内存控制器——DDR Controller 或 SDRAM Controller。

内存控制器配置是硬件初始化里最复杂的一环。为什么?因为 DDR 颗粒的时序参数非常多:CAS 延迟、RAS 到 CAS 延迟、预充电时间、刷新周期……任何一个参数设错,内存读写就会出错,而且这种错误非常隐蔽——可能跑 99 次都对,第 100 次突然挂掉。

配置内存控制器的核心步骤:

  • 设置时序参数——根据 DDR 颗粒的 datasheet,填入 tRCD、tCL、tRP、tRAS 等参数。这些参数通常以时钟周期为单位。
  • 配置地址映射——行列地址宽度、Bank 数量、芯片使能信号。错了会导致内存地址错位。
  • 校准 ZQ——DDR3/DDR4 需要阻抗校准,确保信号完整性。
  • 执行初始化序列——NOP、预充电、自动刷新、设置模式寄存器。这个序列必须严格按 JEDEC 标准来。
  • 验证内存可用性——写一个已知模式(如 0xAA55AA55),读回来对比。我习惯做三次不同地址的读写测试。
/* DDR 控制器初始化伪代码 */
void DDR_Init(void) {
    /* 1. 配置时序寄存器 */
    DDR_TIMING->tRCD = 5;   /* RAS-to-CAS 延迟 */
    DDR_TIMING->tCL  = 7;   /* CAS 延迟 */
    DDR_TIMING->tRP  = 5;   /* 预充电时间 */
    DDR_TIMING->tRAS = 20;  /* 行激活时间 */

    /* 2. 配置地址映射 */
    DDR_MAP->ROW_BITS = 15; /* 行地址宽度 */
    DDR_MAP->COL_BITS = 10; /* 列地址宽度 */
    DDR_MAP->BANK_NUM = 8;  /* Bank 数量 */

    /* 3. 执行初始化序列 */
    DDR_CMD->NOP();         /* 空操作 */
    DDR_CMD->PRECHARGE();   /* 预充电所有 Bank */
    DDR_CMD->AUTO_REFRESH();/* 自动刷新 */
    DDR_CMD->SET_MR();      /* 设置模式寄存器 */

    /* 4. 等待初始化完成 */
    while (!(DDR_STS->INIT_DONE));

    /* 5. 简单读写验证 */
    *((volatile uint32_t*)0x80000000) = 0xDEADBEEF;
    if (*((volatile uint32_t*)0x80000000) != 0xDEADBEEF) {
        while(1); /* 内存故障,死循环 */
    }
}

个人经验:DDR 初始化完成后,我习惯做一个“走步测试”——往整个 DDR 地址空间写递增模式,再读回来校验。这个测试能暴露地址线短路、时序不稳等问题。有一次我发现高 4MB 区域读写总出错,最后查出是 PCB 上地址线 A12 虚焊。如果没有这个测试,后面加载内核时可能随机崩溃,查都查不到。

2.4 堆栈指针设置:给 C 代码一个“落脚点”

堆栈指针(SP)是 C 语言运行的基础。局部变量、函数调用、中断现场保存,全依赖堆栈。在汇编启动代码里,我们必须手动设置 SP 寄存器,指向一块可读写的内存区域。

设置堆栈指针有两个关键点:

  • 堆栈地址必须对齐——ARM 要求 8 字节对齐,x86 通常 4 字节对齐。不对齐会导致访问异常。
  • 堆栈大小要预留足够——Bootloader 的堆栈通常不需要太大(几 KB 足够),但如果你要调用复杂的 C 函数(比如文件系统解析),建议至少 4KB。

对于多模式处理器(如 ARM Cortex-A),每个异常模式都有独立的堆栈指针:

CPU 模式 堆栈指针寄存器 典型堆栈大小 用途
SVC (Supervisor) R13_svc 1~4 KB 主程序、系统调用
IRQ R13_irq 256~512 B 外部中断处理
FIQ R13_fiq 128~256 B 快速中断处理
ABT (Abort) R13_abt 128 B 数据/指令异常
UND (Undefined) R13_und 128 B 未定义指令异常
@ 设置各模式堆栈指针(ARM Cortex-A)
    /* 先进入各模式,设置 SP,再切回 SVC */
    MSR    cpsr, #0xD3          @ SVC Mode, 关中断
    LDR    sp, =_svc_stack_top

    MSR    cpsr, #0xD2          @ IRQ Mode
    LDR    sp, =_irq_stack_top

    MSR    cpsr, #0xD1          @ FIQ Mode
    LDR    sp, =_fiq_stack_top

    MSR    cpsr, #0xD7          @ ABT Mode
    LDR    sp, =_abt_stack_top

    MSR    cpsr, #0xDB          @ UND Mode
    LDR    sp, =_und_stack_top

    /* 最后切回 SVC Mode,准备跳转到 main */
    MSR    cpsr, #0x13

注意:堆栈是向下生长的(满递减堆栈)。所以堆栈顶地址要设在内存区域的最高地址。比如你分配了 4KB 堆栈区,起始地址是 0x20000000,那么 SP 应该设为 0x20001000。设反了,堆栈会覆盖代码区,程序跑着跑着就飞了。

2.5 本章知识体系总览

下面这张图把四个初始化步骤的依赖关系串起来了。你可以看到,每一步都为下一步做准备,最终目标就是构建一个能运行 C 代码的稳定环境。

硬件初始化流程总览 CPU 模式设置 SVC/System Mode 时钟系统初始化 PLL + 时钟树 内存控制器配置 DDR/SDRAM 初始化 堆栈指针设置 SP 指向 RAM 说明: 1. CPU 模式设置必须在最前面,因为后续操作需要特权级访问。 2. 时钟初始化后,内存控制器才能获得稳定的工作频率。 3. 堆栈指针必须在内存初始化完成后设置,否则 SP 指向无效地址。

嗯,到这里,硬件初始化的四个核心步骤就讲完了。你可能会问:为什么顺序是固定的?因为每一步都依赖上一步的结果。CPU 模式不对,你连时钟寄存器都访问不了;时钟没配好,DDR 控制器根本跑不起来;内存没初始化,堆栈设在哪?——所以这个顺序是硬约束,不是习惯问题。

总结一句话:硬件初始化的本质,就是给芯片一个确定的初始状态。CPU 模式、时钟、内存、堆栈——这四个要素缺一不可。任何一个环节出问题,Bootloader 都走不到下一步。我个人建议,每次移植 Bootloader 到新板子时,先单独验证这四个步骤,再往下走。别问我怎么知道的……