4、硬件初始化(二):时钟系统初始化(PLL、分频器)、内存控制器初始化(SDRAM/DDR)

上一节我们把中断向量表、栈指针和关键外设模块的时钟门控都搞定了。这时候芯片其实还处在一种「低速安全模式」里——内部振荡器或者外部低速晶振在工作,但主频可能只有几十兆赫兹。

嗯,这显然不够用。你要知道,Bootloader 的核心任务之一就是把 DDR 初始化好,然后把应用程序从 Flash 搬到内存里跑。而 DDR 对时钟的稳定性要求极高,频率不够或者抖动太大,内存控制器根本没法正常工作。

所以这一节,我们得把两件大事办了:时钟系统初始化和内存控制器初始化。这两步是 Bootloader 里最依赖硬件手册的环节,也是我当年踩坑最多的部分。

4.1 时钟系统初始化

现代 SoC 的时钟树非常复杂。你想想看,CPU 核心、总线、外设、内存控制器,各自需要不同的频率。而且这些频率之间还有相位关系,不是随便设几个寄存器就能跑的。

我个人的习惯是:先看时钟树框图,再动手写代码。别一上来就翻寄存器手册,那样容易迷失。

4.1.1 时钟源的选择

大多数嵌入式 SoC 提供至少两种时钟源:

  • 内部振荡器(IRC):上电默认启用,精度一般,温漂大,适合安全启动阶段。
  • 外部晶振(OSC):精度高,稳定性好,但需要起振时间。

Bootloader 里,我通常会在第一阶段使用内部振荡器跑低速代码,等外部晶振稳定后再切换过去。为什么?因为外部晶振起振需要几百微秒到几毫秒,如果一上电就等它,那启动速度就慢了。

我的经验:外部晶振的起振时间跟负载电容、PCB 走线都有关系。我曾经在一个项目里发现,某批次板子晶振起振时间比数据手册长了 3 倍,排查了半天才发现是 PCB 寄生电容偏大。所以,起振超时检测一定要做,别死等。

4.1.2 PLL 配置

PLL(锁相环)是时钟系统的核心。它把低频输入倍频到高频输出。公式很简单:

Fout = Fin × (N / M) / K

其中:

  • Fin:输入频率(外部晶振或内部振荡器)
  • M:预分频系数(分频器)
  • N:倍频系数(反馈分频器)
  • K:后分频系数(输出分频器)

配置 PLL 时,有几个关键约束你必须遵守:

参数 典型范围 注意事项
Fin / M 1 MHz ~ 10 MHz 鉴相器输入频率,太高或太低都会导致 PLL 失锁
VCO 频率 100 MHz ~ 2 GHz 由 Fin / M × N 决定,超出范围会损坏 PLL
Fout 取决于芯片规格 不能超过芯片最高工作频率

配置步骤一般是:

  1. 将 PLL 置于旁路模式(Bypass),让时钟直接走输入路径
  2. 写 M、N、K 分频系数
  3. 等待 PLL 锁定(Lock)——通常轮询状态寄存器或等待固定延时
  4. 切换到 PLL 输出
我曾经踩过的坑:某次我在配置 PLL 时,忘了先切到旁路模式就直接改分频系数。结果 PLL 输出瞬间跳变,导致 CPU 跑飞。后来查手册才发现,动态修改 PLL 参数必须在旁路模式下进行。这个细节很容易被忽略,但后果很严重。

4.1.3 分频器与时钟树

PLL 输出之后,还要经过一系列分频器,才能送到各个模块。典型的时钟树结构如下:

典型 SoC 时钟树结构 外部晶振 内部振荡器 时钟选择 MUX PLL ×N / M / K CPU 分频 总线分频 外设分频 CPU 总线 外设 说明: 时钟树配置顺序:先选源 → 配PLL → 等锁定 → 设分频 → 切输出。 每个分频器都有最大频率限制,超出会导致模块工作异常。

配置分频器时,我建议你注意两点:

  • 分频系数必须是整数,有些芯片支持小数分频,但会引入抖动,DDR 时钟不建议用。
  • 总线频率不能超过外设频率,否则外设会丢数据。这个我在调试 SD 卡驱动时遇到过,总线跑 150 MHz,SDIO 外设只能跑 50 MHz,忘了配分频,结果数据全是错的。

4.2 内存控制器初始化

时钟搞定之后,下一步就是初始化内存控制器。这一步是 Bootloader 里最「硬」的部分——配置错了,板子直接黑屏,连调试信息都打印不出来。

我个人的经验是:先把内存芯片的数据手册和 SoC 的内存控制器手册通读一遍,把时序参数列成表格,再动手写代码。

4.2.1 SDRAM 与 DDR 的核心差异

特性 SDRAM DDR
数据采样 时钟上升沿 上升沿和下降沿(双倍速率)
参考电压 不需要 需要 Vref(通常为 VDDQ/2)
DQS 信号 无 有,用于数据选通
ODT(片上端接) 无 有,用于信号完整性
初始化流程 简单 复杂(需 ZQ 校准、DLL 锁定等)

说白了,DDR 比 SDRAM 多了很多「校准」步骤。这些步骤是为了补偿 PCB 走线延迟和芯片工艺偏差。你想想看,DDR3 的数据速率动辄 800 MHz 以上,信号在 PCB 上走 1 英寸就有约 150 ps 的延迟,不校准根本没法用。

4.2.2 DDR 初始化流程

以 DDR3 为例,标准初始化流程如下:

  1. 上电等待:VDD 和 VDDQ 稳定后,等待至少 200 μs
  2. CKE 拉低:时钟使能信号保持低电平
  3. 时钟稳定:提供稳定的差分时钟
  4. CKE 拉高:等待至少 500 ns
  5. 发送 NOP 命令:保持 400 ns
  6. 发送 MRS 命令:配置模式寄存器(突发长度、CAS 延迟等)
  7. 发送 ZQCL 命令:ZQ 校准,等待约 512 个时钟周期
  8. DLL 锁定:等待 DLL 锁定完成
  9. 发送 OCD 校准(可选):输出驱动校准
  10. 进入正常工作模式
关键点:初始化过程中,时序必须严格满足数据手册的要求。比如上电等待 200 μs,少 1 μs 都不行。我曾经在一个项目里,为了加快启动速度,把等待时间从 200 μs 减到了 150 μs,结果 10 块板子里有 3 块 DDR 初始化失败。后来老老实实改回去,问题消失。

4.2.3 内存控制器寄存器配置

SoC 的内存控制器通常包含以下几类寄存器:

  • 时序寄存器:tRCD、tRP、tRAS、tWR 等,这些值来自内存芯片的数据手册
  • 配置寄存器:数据位宽(8/16/32 bit)、Bank 数量、行/列地址宽度
  • 校准寄存器:DQS 延迟、ODT 阻抗、驱动强度
  • 控制寄存器:启动初始化序列、软件复位

配置示例(伪代码):

// 1. 配置时序参数
DDR_TIMING->tRCD  = 3;   // 行地址到列地址延迟
DDR_TIMING->tRP   = 3;   // 行预充电时间
DDR_TIMING->tRAS  = 8;   // 行激活时间
DDR_TIMING->tWR   = 4;   // 写恢复时间

// 2. 配置内存几何参数
DDR_CFG->DATA_WIDTH = 2; // 32-bit (0=8, 1=16, 2=32)
DDR_CFG->BANK_CNT   = 3; // 8 banks (0=2, 1=4, 2=8)
DDR_CFG->ROW_ADDR   = 15;// 15位行地址
DDR_CFG->COL_ADDR   = 10;// 10位列地址

// 3. 启动初始化序列
DDR_CTRL->INIT_EN = 1;
while (!(DDR_CTRL->STATUS & INIT_DONE)); // 等待完成

// 4. 校准
DDR_CALIB->DQS_SLAVE_DELAY = 0x20; // 调整DQS延迟
DDR_CALIB->ODT_IMPEDANCE   = 0x2;  // 40Ω ODT
注意:不同 SoC 的内存控制器寄存器布局差异很大。有的芯片把时序参数放在一个寄存器里,有的分散在多个寄存器。我建议你对照数据手册,逐位确认,不要想当然。我曾经因为看错了寄存器偏移地址,把 tRCD 写到了 tRP 的位置,结果内存读写时好时坏,排查了整整两天。

4.2.4 内存测试

初始化完成后,一定要做内存测试。我常用的测试方法:

  • 走马灯测试:写 0xAA、0x55、0x00、0xFF 等模式,验证每个 bit 都能正确翻转
  • 地址线测试:写地址到数据,读回来验证,检查地址线是否有短路或断路
  • 压力测试:全地址范围写随机数,再读回来比对

测试代码片段:

// 地址线测试:检查地址线是否连通
uint32_t *mem_base = (uint32_t *)DDR_BASE;
uint32_t test_pattern = 0xDEADBEEF;

for (int i = 0; i < 32; i++) {
    uint32_t *addr = mem_base + (1 << i);
    *addr = test_pattern;
}

for (int i = 0; i < 32; i++) {
    uint32_t *addr = mem_base + (1 << i);
    if (*addr != test_pattern) {
        // 地址线 i 有问题
        error_handler(i);
    }
}

嗯,这一步不能省。我见过太多「DDR 初始化成功但跑应用就挂」的案例,最后发现是某根地址线虚焊。内存测试能帮你把这类问题提前暴露出来。

4.3 小结

时钟系统和内存控制器是 Bootloader 硬件初始化的两大基石。时钟配错了,整个系统跑不起来;内存配错了,代码根本没法运行。我个人建议你在写代码之前,先把时钟树和内存时序图画出来,把每个参数的含义和取值范围搞清楚。

另外,调试这两个模块时,示波器和逻辑分析仪是你的好朋友。我曾经用示波器抓 PLL 锁定波形,发现锁定时间比预期长了 50%,最后查出是电源纹波太大。这种问题,光看代码是看不出来的。