时钟树配置:HSE、PLL 与总线分频

本篇是《Zig 裸机开发实战:STM32F103ZET6 全栈解析》系列第 7 篇。上一篇:GPIO 子系统:输入输出模式、上下拉与端口映射陷阱,下一篇:中断系统:NVIC、优先级分组与向量注册,目录见文末。

时钟树配置:HSE、PLL 与总线分频

导读

时钟是裸机开发的"第一外设":所有波特率、定时器周期、延时精度的计算都以时钟树为分母。F103 的时钟树并不复杂(一个 PLL、两条外设总线),但 microzig 对它的封装方式很特别——rcc.apply(config) 的参数是 comptime 的,整棵时钟树在编译期算完。这一篇画出时钟树全图、逐字段解释三套可抄的配置、讲清 Flash 等待周期,并回答两个高频问题:APB1 为什么必须 ≤36 MHz、晶振不是 8 MHz 怎么改。

为什么需要时钟树

复位后 F103 跑在 HSI(内部 8 MHz RC 振荡器)上——这就是第 04 篇启动顺序图里"main 之前时钟是 8 MHz"的来源。8 MHz 不够:USART 要算准 115200 波特率、定时器要产生精确 PWM、USB 更是硬性要求 48 MHz。所以 main 的第一件事就是把时钟切到 HSE(外部晶振)+ PLL(锁相环倍频)的 72 MHz 满速配置。

F103 的时钟树节点(RM0008 第 7 章):

HSI 8 MHz ──────┐
                ├─► PLL 输入(HSI/2 或 HSE,HSE 可选再 /2,即 PLLXTPRE)
HSE 4-16 MHz ───┘        │
                         ▼
                    PLL x2..x16(PLLMUL)
                         │
                         ▼
                    SYSCLK(≤72 MHz)
                         │
                    AHB 分频 HPRE(/1../512)──► HCLK(≤72 MHz)
                         │
          ┌──────────────┼─────────────────┐
          ▼              ▼                 ▼
    APB1 分频        APB2 分频          ADC 分频
    PPRE1(≤36MHz)  PPRE2(≤72MHz)    ADCPRE(≤14MHz)
          │              │
    TIM2-7/I2C/     GPIO/TIM1/8/SPI1/
    SPI2/3/USART2-5 USART1/ADC1-3/EXTI

三处硬限制是面试与实战的常考点:APB1 最大 36 MHz(所以 72 MHz 系统下 APB1 必须 Div2);ADC 时钟最大 14 MHz(Div2/4/6/8 从 PCLK2 分);USB 需要 48 MHz(PLL 必须是 72 MHz,USBPRE 选 /1.5 才得到 48——这也是"USB 功能要求主频恰好 72"的由来)。

还有一个容易被忽略的消费者:Flash 本身。CPU 主频超过 Flash 的读取速度时要插入等待周期(72 MHz 下 2 WS),rcc.apply 内部会按编译期算出的 HCLK 自动设置 FLASH_ACR.LATENCY——这个自动化的前提是配置真实反映了硬件,晶振错了它也算错。

代码案例:三套可抄的配置

配置一:默认 HSI(什么都不写)

不调用 rcc.apply 就是 HSI 8 MHz 直通 SYSCLK、总线不分频。microzig 内部的当前时钟值来自 Tree.get_clocks(.{}) 的编译期初值。适合:验证最小固件、对精度无要求的小实验。

配置二:HSE 直通 8 MHz(不倍频)

_ = try rcc.apply(.{
    .SYSCLKSource = .HSE,
    .flags = .{ .HSEOscillator = true },
});

系统时钟 = 外部晶振频率。比 HSI 精确(晶振 ±20 ppm vs RC ±1%),适合"低功耗 + 精确波特率"的中速场景。注意此时 USART1 挂在 APB2 = 8 MHz 上。

配置三:HSE x9 = 72 MHz 满速(本系列标准配置)

_ = try rcc.apply(.{
    .SYSCLKSource = .PLL1_P,          // SYSCLK 选 PLL 输出
    .HSEDivPLL = .Div1,               // HSE 不分频进 PLL
    .PLLSourceVirtual = .HSE_Div_PREDIV, // PLL 源走 HSE(经 PREDIV)
    .PLLMUL = .Mul9,                  // 8 MHz x 9 = 72 MHz
    .AHBCLKDivider = .Div1,           // HCLK = 72 MHz
    .APB1CLKDivider = .Div2,          // PCLK1 = 36 MHz(上限)
    .flags = .{
        .HSEOscillator = true,        // 外部晶振存在,要等它起振
        .RTCUsed_ForRCC = true,       // 下面要用 RTC 做时基才写
    },
    // .RTCClockSelection = .LSI,      // 用 time.init_rtc() 时加这行
});

字段名全部来自仓库示例的逐字用法(usart1_hse.zig、gpio.zig、timer.zig),一字不差:PLLSourceVirtual(不是 PLLSource)、HSEDivPLL、PLLMUL(全大写)、APB1CLKDivider、RCC_MCOSource(带前缀)、flags.RTCUsed_ForRCC / flags.MCOUsed_ForRCC / flags.MCOConfig(大小写混排)。不要臆造其它字段名——Config 的完整字段集合在外部依赖 ClockHelper 里,只有示例中出现过的组合是可验证的。

换 12 MHz 晶振怎么改

板上晶振不是 8 MHz 时,只改倍频:12 MHz x 6 = 72,把 .PLLMUL = .Mul9 换成 .Mul6。目标永远是 PLL 输出 72 MHz(USB 依赖它,Flash latency 表也按它查)。x9 配 12 MHz 晶振会得到 108 MHz——超频,可能直接跑飞或随机 HardFault。

逐段解析

rcc.apply 是 comptime 的:这意味着什么

签名(hals/STM32F103/rcc.zig):

pub fn apply(comptime config: Tree.Config) ClockInitError!Tree.ClockOutput

第一个参数是 comptime config——配置结构体的每个字段都是编译期常量,Tree.get_clocks(config) 在编译期算出 PLL 输出、各总线分频、Flash latency,生成运行期要写的那几条寄存器序列。编译期算时钟树意味着你不能传运行期变量:const div = read_from_eeprom(); rcc.apply(.{ .APB1CLKDivider = div }) 编译不过。

运行期改时钟怎么办?apply 之外还有一组底层函数:enable_hse、config_pll、enable_pll、set_peripherals_prescaler、set_flash、config_rtc、config_mco——它们接收运行期参数,可组合出任意时钟切换(第 19 篇低功耗唤醒后的时钟恢复就是这条路的用户)。日常开发全部用 apply,运行期需求才下潜。

apply 返回 ClockInitError!Tree.ClockOutput。错误集只有两个:HSETimeout(外部晶振起振超时——晶振虚焊、频率超出范围、硬件故障)与 LSETimeout。注意 ClockInitError 是私有类型,你不能在函数签名里写它,只能 try 或 catch |err|(类型由编译器推断)。返回值 Tree.ClockOutput 是算好的各节点频率表,通常 _ = try rcc.apply(...) 丢弃;想打印主频用 rcc.get_sys_clk()。

get_clock 与波特率

const usart1_clk = rcc.get_clock(.USART1);  // APB2 时钟(USART1 挂 APB2)
const usart2_clk = rcc.get_clock(.USART2);  // APB1 时钟
const sys = rcc.get_sys_clk();

get_clock 内部按外设名前缀匹配分组:USART1/SPI1/GPIO*/AFIO 在 APB2 组,裸 USART(2/3)/裸 SPI(2/3)/I2C/TIM*(TIM1 除外)在 APB1 组。两个陷阱:一是 TIM1 必须在裸 TIM 之前判断(源码顺序如此),所以别以为所有定时器同总线——TIM1 挂 APB2;二是传入未映射的外设名会运行时 @panic("Unknown clock for peripheral"),不是编译错误。第 09 篇算波特率时 apply_runtime(.{ .clock_speed = rcc.get_clock(.USART1) }) 就是标准用法。

Flash 等待周期与 set_flash

HCLK LATENCY(等待周期)
≤ 24 MHz 0 WS
24–48 MHz 1 WS
48–72 MHz 2 WS

rcc.apply 编译期算出 HCLK 后自动调 set_flash(latency, prefetch) 写 FLASH_ACR。关键顺序问题:必须先设 LATENCY 再切 SYSCLK(升频时);降频时反过来。microzig 的 apply_internal 已按正确顺序编排,这也是"不要绕过 apply 手写时钟序列"的理由之一——手写时漏了 latency 设置,现象是"一开 PLL 就 HardFault、降回 8 MHz 就正常"。PREFETCH_ENABLE(预取缓冲)在 flags 里存在但示例未用过,默认行为已足够。

用 MCO 输出验证时钟

MCO(Master Clock Output)把内部时钟引到 PA8,是验证时钟配置的最快方法:

_ = try rcc.apply(.{
    .SYSCLKSource = .PLL1_P,
    .PLLSourceVirtual = .HSE_Div_PREDIV,
    .PLLMUL = .Mul9,
    .AHBCLKDivider = .Div1,
    .APB1CLKDivider = .Div2,
    .RCC_MCOSource = .SYS,          // MCO 输出系统时钟
    .flags = .{
        .HSEOscillator = true,
        .MCOUsed_ForRCC = true,     // 声明用到 MCO
        .MCOConfig = true,          // 配置 MCO
    },
});

// PA8 配成复用推挽输出,MCO 信号就从这出来
const mco = gpio.Pin.from_port(.A, 8);
mco.set_output_mode(.alternate_function_push_pull, .max_50MHz);

烧录后示波器接 PA8:应看到 72 MHz 方波(普通示波器带宽不够时看到的是幅度衰减的正弦样包络,周期仍可读)。逐步换 .RCC_MCOSource 可以验证 HSE(8 MHz)与 PLL 各级——这是"配置到底生效没有"的终审手段,比量 UART 波形精确得多。官方 rcc 示例还演示了 std.log.info("sys freq: {d}", .{rcc.get_sys_clk()}) 把主频打印出来。

改错晶振倍频导致串口乱码的因果链

这个故障值得完整走一遍,因为它串起了时钟树的全部知识点:

  1. 板上晶振是 12 MHz,代码按 8 MHz 写了 .Mul9。
  2. 编译期 Tree.get_clocks 以为 PLL 输出 72 MHz,Flash latency 设 2 WS、APB1 设 36 MHz。
  3. 实际 PLL 输出 12 x 9 = 108 MHz。F103 超 spec;HCLK 108 MHz 时 2 WS 不够,Flash 取指偶发出错——轻则 UART 波特率错、重则随机 HardFault。
  4. UART 的 BRR 按"以为的 72 MHz"算:115200 波特在 108 MHz 实际时钟下变成 172800 波特,误差 50%——接收端全是乱码。
  5. 定时器同理:PWM 频率、sleep_ms 全部偏 1.5 倍。

诊断顺序:量 PA8 MCO 看真实频率 → 核对晶振丝印 → 改 .PLLMUL。预防措施是"买板先看晶振":F103 核心板 8 MHz 是主流但也有 12 MHz/25 MHz 的货色,本系列全部按 8 MHz 计算。

运行现象

烧录满速配置 + MCO 输出的固件后:

  • PA8 示波器读出 72 MHz(误差 = 晶振精度,±20–50 ppm)。
  • PB5 的 100 ms 闪烁周期与第 04 篇一致(sleep_ms 基于 RTC/定时器节拍,与主频无直接关系,但若节拍用定时器则依赖 APB1 时钟正确)。
  • rcc.get_sys_clk() 返回 72_000_000(接串口后可打印验证)。
  • 故意把 .Mul9 改成 .Mul6(8 MHz 晶振 x6=48 MHz)再烧:一切仍工作,但 PA8 变 48 MHz、串口波特率错 1.5 倍出乱码——这个"可控的错配"是把时钟树玩明白的最好实验。

常见问题

Q1:rcc.apply 编译报错,说某个字段不存在。

成因:Config 的字段来自 ClockHelper 按芯片名生成的类型,字段名极易写错——常见错法:PLLSource(对,是 PLLSourceVirtual)、PLLMul(是全大写 PLLMUL)、RTCClockSelection 拼错、flags 里凭记忆编字段(只有示例验证过的那些存在:HSEOscillator、LSEOscillator、RTCUsed_ForRCC、USBUsed_ForRCC、MCOUsed_ForRCC、MCOConfig、USE_ADC1)。解决方案:从官方示例复制字段组合(本篇三套配置覆盖了最常见需求);用 @compileLog(@typeInfo(stm32.rcc.Config).@"struct".field_names) 打印真实字段列表。

Q2:串口乱码,波特率怎么算都不对。

成因:按概率排序——晶振频率与代码假设不符(12 MHz 晶振配 Mul9,详见上文因果链);用了 HSI 却按 HSE 算(uart_log 示例不调 apply 跑在 8 MHz HSI,恰好与 8 MHz 晶振板效果相同,换块板就翻车);APB1/APB2 分频记错(USART1 在 APB2 = 72 MHz,USART2/3 在 APB1 = 36 MHz,两者 BRR 差一倍)。解决方案:rcc.get_clock(.USART1) 的返回值就是该串口的真实时钟,交给 apply_runtime 让它自己算 BRR,不要手抄 C 教程的魔法数;根治是 MCO 验证 + 核对晶振。

Q3:一开 PLL 就 HardFault,降回 8 MHz 正常。

成因:Flash 等待周期不足或时钟序列错误。72 MHz 需要 2 WS;先切 SYSCLK 后设 LATENCY 的顺序会在切换瞬间取指失败。也可能是超频(晶振不匹配导致 PLL 输出超 72 MHz)。解决方案:用 rcc.apply 不要手写序列(它按正确顺序编排 LATENCY 与切换);核对晶振与 PLLMUL;若必须手写(极端定制),顺序为:设 FLASH_ACR.LATENCY → 等 HSE 就绪 → 配 PLL → 使能 PLL → 等 PLL 就绪 → 切 SYSCLK → 调整 APB 分频。

Q4:想运行时动态切时钟(比如低功耗唤醒后),rcc.apply 传变量编译不过。

成因:apply 的参数是 comptime,运行期值进不去。解决方案:用底层函数组合——enable_hse/config_pll/enable_pll/set_peripherals_prescaler/set_flash 都是运行期函数。注意唤醒后的时钟恢复 microzig 完全不管(第 19 篇),从 Stop 模式醒来时 SYSCLK 自动回 HSI 8 MHz,需要手动走一遍 HSE+PLL 切换;而 power 模块只有 apply/set_pvd 等配置函数,没有 enter_stop——这些边界在第 19 篇完整展开。

Q5:get_clock(.SDIO) 运行时 panic("Unknown clock for peripheral")。

成因:get_clock 的分组是名字前缀匹配的白名单,F103ZE 有 SDIO 这个外设但 HAL 的 get_clock 没有它的分支(F103C8 没有 SDIO,示例从不覆盖)。运行时 panic 而不是编译错误——Peripherals 枚举包含芯片所有外设,白名单只有一部分。解决方案:SDIO 时钟直接按"AHB = HCLK"手动推(RM0008 里 SDIO 挂 AHB);为其他外设写代码前先确认 get_clock 的分支里有它的名字(DMA/FLASH/CRC 在 AHB 组、FSMC 走 FSMC 分支、SDIO 没有)。

Q6:.RTCClockSelection = .LSI 和 .flags.RTCUsed_ForRCC = true 要不要一起写?

成因:这是两个独立动作——RTCClockSelection 选 RTC 时钟源(LSI/LSE/HSE/128),flags.RTCUsed_ForRCC 声明"时钟树里要启用 RTC 分支"并让 ClockHelper 生成相应配置。官方 gpio 示例两者同写;官方 rtc 示例还有个反面教材:选了 .LSI 却同时使能 LSE 振荡器(flags.LSEOscillator = true),逻辑冗余还可能因 LSE 起振超时失败。解决方案:用 time.init_rtc() 就成对写 .RTCClockSelection = .LSI + .flags.RTCUsed_ForRCC = true(LSI 无需外部器件,精度 ±1% 只够粗计时);要精确 RTC 时钟选 LSE 并加 .flags.LSEOscillator = true,接受 32.768 kHz 晶振起振的等待。

面试考点

Q1:画出 F103 从 HSE 到 USB 时钟的完整路径,说明每级的频率限制。

参考答案:HSE(4–16 MHz,典型 8 MHz)→(PLLXTPRE 可 /2)→ PLL ×2..×16 → SYSCLK ≤72 MHz → AHB HPRE 分频 → HCLK ≤72 MHz → APB2 PPRE2 ≤72 MHz(GPIO/TIM1/8/SPI1/USART1/ADC 输入);APB1 PPRE1 ≤36 MHz(硬上限,TIM2–7/I2C/SPI2/3/USART2–5);PCLK2 → ADCPRE /2/4/6/8 → ADCCLK ≤14 MHz;SYSCLK=72 MHz 时 USBPRE /1.5 → USB 48 MHz(USB 时钟只能由 PLL 分频而来,这决定了"要用 USB 主频必须 72 MHz")。Flash 等待周期按 HCLK 查表(>48 MHz 为 2 WS)。

Q2:为什么 APB1 的上限是 36 MHz?如果外设需要更快的 APB1 时钟怎么办?

参考答案:36 MHz 是 ST 的设计约束——APB1 外设(v1 外设 IP)在 36 MHz 工艺/时序下验证,超过不保证时序。72 MHz 系统下 PPRE1 必须 Div2。外设实际跑不满的问题由"定时器倍频器"缓解:APB1 分频不为 1 时,挂在其上的定时器时钟自动 x2(TIMCLK = 2 x PCLK1 = 72 MHz),所以 TIM2–5 在 36 MHz 的 APB1 上仍能拿到 72 MHz 计数时钟——这是"分频不为 1 则定时器时钟翻倍"规则,算 PWM 频率时必须用 TIMCLK 而不是 PCLK1。APB2 上的 TIM1/8 同理(PCLK2=72 且 Div1 时不倍频)。

Q3:microzig 的 rcc.apply 为什么把 config 做成 comptime?这带来什么限制与好处?

参考答案:comptime config 让 Tree.get_clocks 在编译期完成整棵时钟树的频率计算与合法性检查(PLL 输出、分频比、Flash latency 全是编译期常量),运行期只剩确定性的寄存器写入序列——没有运行时配置错误路径,错误在编译期暴露(字段名、非法组合)。代价:不能传运行期变量(动态调频做不了),ClockInitError 只剩 HSE/LSE 起振超时这两个真正运行期的错误。动态需求走底层函数(enable_hse/config_pll/enable_pll/set_peripherals_prescaler),它们收运行期参数。这与 STM32CubeMX 的运行时 HAL_RCC_ClockConfig 是两种哲学:编译期确定 vs 运行期灵活。

Q4:波特率计算:72 MHz APB2、115200 波特,USART 的 BRR 是多少?误差多大?

参考答案:USARTDIV = fPCLK / (16 x 波特率) = 72_000_000 / (16 x 115200) = 39.0625。BRR 的整数部分 39 写 DIV_Mantissa,小数 0.0625 x 16 = 1 写 DIV_Fraction,故 BRR 值 = 39 x 16 + 1 = 625(0x271)。实际波特率 = 72 MHz / (16 x 39.0625) = 115200,误差 0%。对比 APB1 的 USART2(36 MHz):USARTDIV = 19.53125,BRR = 19 x 16 + 8.5 → 小数部分 0.53125 x 16 = 8.5 取 8 或 9,误差 0.16%/0.16%——仍远低于 2% 容限。若时钟假设错 1.5 倍(12 MHz 晶振案例),误差 50%,必然乱码。结论:BRR 的误差取决于 fPCLK 与波特率的最小公倍性,时钟源错了怎么算都错。

Q5:板上晶振是 12 MHz 时,怎么把系统配到 72 MHz?写出改动的字段。

参考答案:PLL 倍频改 .PLLMUL = .Mul6(12 x 6 = 72),其余字段不变(.SYSCLKSource = .PLL1_P、.HSEDivPLL = .Div1、.PLLSourceVirtual = .HSE_Div_PREDIV、.AHBCLKDivider = .Div1、.APB1CLKDivider = .Div2)。要点:目标恒定是 PLL 输出 72 MHz(USB 48 MHz、Flash 2 WS、APB1 36 MHz 全由它派生);如果沿用 Mul9 会得到 108 MHz 超频,Flash 等待不足导致随机 HardFault、串口波特率偏 1.5 倍出乱码。诊断手段:PA8 MCO 输出 SYS 用示波器量实际频率。反向教训:所有"抄来的时钟配置"必须核对本地晶振频率——这是 STM32 项目最经典的移植事故。

Q6:rcc.get_clock(.USART1) 和 rcc.get_clock(.USART2) 在满速配置下分别返回什么?为什么?

参考答案:USART1 返回 72 MHz(挂 APB2,PCLK2),USART2 返回 36 MHz(挂 APB1,PCLK1)。实现上 get_clock 按外设名前缀匹配分组:USART1(以及 SPI1、GPIO*、AFIO)在 APB2 组,裸 USART(匹配 USART2/3)在 APB1 组。注意两个陷阱:TIM1 与裸 TIM 的分组也按此规则(TIM1 在 APB2);传入白名单外的名字是运行时 panic 而非编译错误。算波特率时必须用 get_clock 的返回值而不是硬编码——它随配置正确联动,晶振错了它也跟着错(这也是"错得一致"的另一种表现:BRR 与实际时钟一致时即使频率不对,波特率反而可能对)。

小结

  • 时钟树:HSI 8M / HSE 4–16M → PLL x2..x16 → SYSCLK ≤72M → AHB → APB1 ≤36M / APB2 ≤72M;ADC ≤14 MHz;USB 需 PLL 72M x (1/1.5) = 48 MHz。
  • rcc.apply 参数是 comptime:整棵树编译期算完,运行期错误只剩 HSE/LSE 超时;动态调频走底层函数。
  • 三套模板:HSI 默认、HSE 直通(.SYSCLKSource = .HSE + HSEOscillator)、满速 72 MHz(Mul9 + APB1 Div2);字段名照抄示例,不臆造。
  • Flash latency 自动设置(>48 MHz 为 2 WS);手写序列必须先 LATENCY 后切频。
  • get_clock/get_sys_clk 算波特率与验证;MCO 输出 PA8 是时钟的终审手段。
  • 晶振不是 8 MHz 时改 .PLLMUL(12 MHz x6);错配的因果链:超频 → Flash 取指错 + 波特率错 → 乱码/HardFault。
  • 下一篇中断系统:NVIC、优先级分组与三层覆盖的向量注册模型。

系列目录

  1. 系列总览:Zig 裸机开发的定位、生态与平台全景
  2. 开发环境搭建:Zig 0.17、microzig 与 probe-rs
  3. 工程结构剖析:build.zig、MicroBuild 与固件产物
  4. 第一个固件:启动流程、向量表与 GPIO 点灯
  5. 寄存器级编程:PAC、MMIO 与 volatile 语义
  6. GPIO 子系统:输入输出模式、上下拉与端口映射陷阱
  7. 时钟树配置:HSE、PLL 与总线分频
  8. 中断系统:NVIC、优先级分组与向量注册
  9. USART 串口通信:轮询收发与日志重定向
  10. 定时器基础:时基、PWM 与呼吸灯
  11. 输入捕获:频率与占空比测量
  12. 中断驱动串口:环形缓冲区与临界区设计
  13. DMA 传输:从外设搬运到内存
  14. I2C 总线:SSD1306 OLED 显示实战
  15. SPI 总线:W25Q64 Flash 读写与 ID 识别
  16. ADC 采样:单次、扫描与内部温度传感器
  17. 内部 Flash 编程:参数持久化与页擦除
  18. 独立看门狗:复位诊断与可靠性设计
  19. 低功耗模式:Sleep、Stop、Standby 与唤醒
  20. 内存布局与链接脚本:512 KB Flash 与 64 KB RAM 规划
  21. Zig 语言进阶:comptime、错误处理与零成本抽象
  22. 并发模型:中断与主循环的协作与临界区
  23. 协作式调度器:状态机与时间片轮转
  24. 实时性分析:中断延迟、抖动与 WCET
  25. RTOS 内核实现(上):任务控制块与上下文切换
  26. RTOS 内核实现(下):信号量、消息队列与优先级继承
  27. USB 设备开发:CDC 虚拟串口
  28. CAN 总线:bxCAN 过滤器与环回自测
  29. 调试与测试:probe-rs、RTT 与硬件在环
  30. 收官:分层架构、可移植性与 Zig 生态展望
posted @ 2026-10-27 08:52  navysummer_hardware  阅读(19)  评论(0)    收藏  举报