时钟树配置: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()}) 把主频打印出来。
改错晶振倍频导致串口乱码的因果链
这个故障值得完整走一遍,因为它串起了时钟树的全部知识点:
- 板上晶振是 12 MHz,代码按 8 MHz 写了
.Mul9。 - 编译期
Tree.get_clocks以为 PLL 输出 72 MHz,Flash latency 设 2 WS、APB1 设 36 MHz。 - 实际 PLL 输出 12 x 9 = 108 MHz。F103 超 spec;HCLK 108 MHz 时 2 WS 不够,Flash 取指偶发出错——轻则 UART 波特率错、重则随机 HardFault。
- UART 的 BRR 按"以为的 72 MHz"算:115200 波特在 108 MHz 实际时钟下变成 172800 波特,误差 50%——接收端全是乱码。
- 定时器同理: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、优先级分组与三层覆盖的向量注册模型。
系列目录
- 系列总览:Zig 裸机开发的定位、生态与平台全景
- 开发环境搭建:Zig 0.17、microzig 与 probe-rs
- 工程结构剖析:build.zig、MicroBuild 与固件产物
- 第一个固件:启动流程、向量表与 GPIO 点灯
- 寄存器级编程:PAC、MMIO 与 volatile 语义
- GPIO 子系统:输入输出模式、上下拉与端口映射陷阱
- 时钟树配置:HSE、PLL 与总线分频
- 中断系统:NVIC、优先级分组与向量注册
- USART 串口通信:轮询收发与日志重定向
- 定时器基础:时基、PWM 与呼吸灯
- 输入捕获:频率与占空比测量
- 中断驱动串口:环形缓冲区与临界区设计
- DMA 传输:从外设搬运到内存
- I2C 总线:SSD1306 OLED 显示实战
- SPI 总线:W25Q64 Flash 读写与 ID 识别
- ADC 采样:单次、扫描与内部温度传感器
- 内部 Flash 编程:参数持久化与页擦除
- 独立看门狗:复位诊断与可靠性设计
- 低功耗模式:Sleep、Stop、Standby 与唤醒
- 内存布局与链接脚本:512 KB Flash 与 64 KB RAM 规划
- Zig 语言进阶:comptime、错误处理与零成本抽象
- 并发模型:中断与主循环的协作与临界区
- 协作式调度器:状态机与时间片轮转
- 实时性分析:中断延迟、抖动与 WCET
- RTOS 内核实现(上):任务控制块与上下文切换
- RTOS 内核实现(下):信号量、消息队列与优先级继承
- USB 设备开发:CDC 虚拟串口
- CAN 总线:bxCAN 过滤器与环回自测
- 调试与测试:probe-rs、RTT 与硬件在环
- 收官:分层架构、可移植性与 Zig 生态展望

浙公网安备 33010602011771号