定时器基础:时基、PWM 与呼吸灯
本篇是《Zig 裸机开发实战:STM32F103ZET6 全栈解析》系列第 10 篇。上一篇:USART 串口通信:轮询收发与日志重定向,下一篇:输入捕获:频率与占空比测量,目录见文末。
定时器基础:时基、PWM 与呼吸灯
导读
定时器是 STM32 外设之王:输入捕获、PWM、编码器接口、RTC 节拍,全靠它。这一篇先拆 TIM 的骨架——PSC/CNT/ARR/CCR/CCMR/CCER 六个寄存器如何配合出一条 PWM 波形,再用 HAL 的高层 API(GPTimer.init(.TIM3).into_pwm_mode())把 LED1(PB5)点亮成 1 kHz PWM,最后实现呼吸灯:占空比 0 → 1000 → 0 循环爬坡。你会遇到三个"必踩坑":通道号 0 基(CH2 是 1)、TimerGenealConfig 的上游拼写错误、以及"改了 duty 没生效"背后的影子寄存器——force_update 就是为此而生。
为什么需要定时器与 PWM
第 04 篇用 sleep_ms + toggle 闪过灯,但那是"人眼 PWM"——频率不到 10 Hz,无法调亮度。PWM(脉冲宽度调制)的秘密:当频率高到人眼分辨不出闪烁(>100 Hz),亮度就由占空比决定——高电平占 20%,亮度就是 20%。
F103ZET6 的定时器家族:TIM1/TIM8 高级(带互补输出与死区,电机驱动用)、TIM2–TIM5 通用(本篇主角)、TIM6/TIM7 基本(DAC 触发用)。ZET6 全都齐了,但 HAL 目前主要覆盖通用定时器(TIM2–TIM5,time.init_timer 也只认这四个)。
本篇还要解决一个资源冲突:第 04 篇以来 time.init_rtc() 一直用 RTC 做延时时基;如果改用 time.init_timer(.TIM4)(精度更高的方案),HAL 的 default_interrupts 会占住 TIM4 的中断向量做节拍。所以分工约定:TIM4 交给 time 模块做时基,TIM3 留给 PWM——两者互不干扰,这也是全系列后续篇章的标准布局。
代码案例:TIM3 驱动 PB5 呼吸灯
PB5 不是 TIM3 的默认引脚(TIM3 默认 CH1/CH2 在 PA6/PA7)。需要部分重映射:TIM3_REMAP = 01 时 CH1→PB4、CH2→PB5、CH3→PB0、CH4→PB1。所以完整链路是:使能 AFIO → 重映射 → PB5 配复用推挽 → TIM3 PWM → 呼吸爬坡。
const std = @import("std");
const microzig = @import("microzig");
const stm32 = microzig.hal;
const rcc = stm32.rcc;
const gpio = stm32.gpio;
const time = stm32.time;
pub const panic = microzig.panic;
pub const std_options = microzig.std_options(.{});
comptime {
_ = microzig.export_startup();
}
pub fn main() !void {
_ = try rcc.apply(.{
.SYSCLKSource = .PLL1_P,
.PLLSourceVirtual = .HSE_Div_PREDIV,
.PLLMUL = .Mul9,
.APB1CLKDivider = .Div2,
.RTCClockSelection = .LSI,
.flags = .{ .RTCUsed_ForRCC = true, .HSEOscillator = true },
});
// 时基交给 TIM4(time 模块负责),TIM3 留给 PWM
time.init_timer(.TIM4);
// PB5 = TIM3_CH2,需要部分重映射;AFIO 时钟不使能 MAPR 写不进去
rcc.enable_clock(.AFIO);
rcc.enable_clock(.GPIOB);
rcc.enable_clock(.TIM3);
gpio.apply_remap(.{ .TIM3 = .Partial_Remap_1 });
const ch2 = gpio.Pin.from_port(.B, 5);
ch2.set_output_mode(.alternate_function_push_pull, .max_50MHz);
const pwm = stm32.timer.GPTimer.init(.TIM3).into_pwm_mode();
pwm.configure(.{
.prescaler = 71, // 72 MHz / (71+1) = 1 MHz 计数频率
.auto_reload = 999, // 1 MHz / (999+1) = 1 kHz PWM
.counter_mode = .{ .up = {} },
});
pwm.configure_channel(1, .{ .fast_mode = true }); // 1 = CH2(0 基!)
pwm.set_channel(1, true); // configure_channel 不会自动使能通道
const period: u16 = 1000;
while (true) {
// 渐亮:占空比 0% → 100%
var duty: u16 = 0;
while (duty <= period) : (duty += 10) {
pwm.set_duty(1, duty);
pwm.force_update();
time.sleep_ms(5);
}
// 渐暗:占空比 100% → 0%
var down: u16 = period;
while (down > 0) : (down -= 10) {
pwm.set_duty(1, down);
pwm.force_update();
time.sleep_ms(5);
}
}
}
逐段解析
TIM 的六个寄存器:一条波形的诞生
通用定时器的核心是一条计数链:
TIMxCLK (72 MHz) → [PSC 预分频] → 计数器 CNT (0 → ARR) → 比较器 CCR → 输出控制 (CCMR/CCER) → 引脚
- PSC(预分频):16 位,TIMxCLK/(PSC+1) 得计数频率。写 71 → 72 MHz/72 = 1 MHz,即 CNT 每 1 µs 加一。注意是 PSC+1——0 表示不分频。
- CNT(计数器):实际计数值,从 0 数到 ARR 再归零(向上计数模式)。
- ARR(自动重装载):计数上限。计数频率/(ARR+1) = 溢出频率。
- CCR(捕获/比较):CNT == CCR 时触发比较事件。PWM 模式下决定电平翻转点。
- CCMR(捕获/比较模式):每个通道 8 位,定义该通道是输入还是输出、PWM 模式几、比较预装载是否使能。
- CCER(捕获/比较使能):每通道的使能位与极性位。
两个公式贯穿全篇:
PWM 频率 = fCK / ((PSC + 1) × (ARR + 1))
占空比 = CCR / (ARR + 1)
代入本例:72 MHz/((71+1)×(999+1)) = 1000 Hz;duty=500 时占空比 500/1000 = 50%。HAL 没有 set_frequency/set_duty_percent,这两个公式要自己算——set_duty(1, 50) 是把 CCR 写成 50(占空比 5%),不是 50%,这是官方示例注释都容易误导的地方。
一个容易踩的时钟细节:TIM3 挂 APB1,APB1 = 36 MHz,但定时器时钟规则是 APB 预分频 ≠ 1 时定时器时钟 ×2,所以 TIM3 实际拿到 72 MHz。用 prescaler = 71 而不是 35,依据就是这个规则。
AFIO 重映射:为什么 PB5 能变 TIM3_CH2
F103 每个定时器通道有默认引脚和若干重映射组,由 AFIO_MAPR 的 REMAP 位域选择。TIM3 的三档:
| TIM3_REMAP | CH1 | CH2 | CH3 | CH4 |
|---|---|---|---|---|
| 00(默认) | PA6 | PA7 | PB0 | PB1 |
| 01(部分重映射) | PB4 | PB5 | PB0 | PB1 |
| 11(完全重映射) | PC6 | PC7 | PC8 | PC9 |
HAL 的入口是 gpio.apply_remap(.{ .TIM3 = .Partial_Remap_1 })(Remap 是 union(enum),每档的枚举值对应位域编码)。两个前置条件缺一不可:rcc.enable_clock(.AFIO)(MAPR 不使能时钟写不进去)和 GPIO/TIM3 的外设时钟。PB5 本身配 .alternate_function_push_pull——复用模式下引脚的电平由 TIM3 驱动,你不再碰 put/toggle。
顺带一个引脚资源知识点:部分重映射组里的 PB4 默认功能是 NJTRST(JTAG 调试口的一部分)。本例只用 CH2/PB5 不受影响;若要用 CH1/PB4 输出 PWM,还需通过 MAPR 的 SWJ_CFG 释放 JTAG 引脚,否则 PB4 保持调试口功能、无波形。
PWM 配置链:configure → configure_channel → set_channel
HAL 的高层 API 分三步,职责各不相同:
GPTimer.init(.TIM3).into_pwm_mode():拿到 PWM 视图。init的参数是 comptime 实例枚举。pwm.configure(.{ ... }):写 PSC/ARR/计数模式等全局配置,且自动 start(CR1 CEN 置位)——源码timer_v1.zig:549-553,配置完定时器已在跑。pwm.configure_channel(1, .{ .fast_mode = true }):配置单个通道的 CCMR/极性,但不会使能通道输出——必须再pwm.set_channel(1, true)写 CCER 的 CC2E 位。漏了这步,一切正常就是没波形,PWM 排错第一案。
通道号是 u2 且 0 基:0..3 对应 CH1..CH4。本篇的 CH2 传 1。configure_ccr(1, ...) 同理操作的是 CH2。这个约定与数据手册的"CH1/CH2/CH3/CH4"编号差一位,是 APIs 里最高频的 off-by-one。
TimerGenealConfig 的完整字段(逐字):prescaler(u16,默认 0)、auto_reload(u16,默认 maxInt(u16))、auto_reload_mode(默认 .buffered)、counter_mode(默认 .up)、one_pulse_mode、event_source、clock_division、TI1_source、enable_update_interrupt、enable_update_dma_request、channel_dma_trigger、trigger_output、sync_config。类型名就是拼错的 TimerGenealConfig(少一个 n,上游 timer_v1.zig:185),写 TimerGeneralConfig 会报 no field or member function;同类拼写彩蛋还有 set_auto_relaod_mode(relaod)。别"顺手修正"上游拼写——修正即编译失败。
set_duty 与 force_update:影子寄存器的最后一公里
set_duty(channel, value) 的实现就是 load_ccr(channel, value)——把 CCR 写成计数值。但注意 auto_reload_mode 默认 .buffered:CCR(以及 ARR)有影子寄存器(preload),你写的是预装载寄存器,真正的比较寄存器只在更新事件(CNT 溢出归零或软件 UG 事件)时才同步。效果:改了 duty,波形要等下个周期才变——对 1 kHz PWM 就是最多 1 ms 延迟。
呼吸灯的爬坡步进是 10/1000、间隔 5 ms,1 ms 的影子延迟肉眼不可见,但如果你做相位敏感的应用(如舵机、移相全桥),这 1 ms 就是事故。pwm.force_update() 的作用是软件产生一次更新事件(EGR 的 UG 位),让新 CCR 立刻生效——这就是为什么示例里每次 set_duty 后紧跟 force_update。代价是它同时会把 CNT 清零,PWM 相位回卷,对呼吸灯无所谓,对多通道同步输出要留意。
极性:polarity 可用,invert 是死字段
想让波形反相(低电平有效),用 PWMChConfig.polarity = .low。不要用 invert 字段——PWMChConfig.invert 是上游死字段,timer_v1.zig:561 逐字:
.mode = if (config.invert) OCM.PwmMode1 else OCM.PwmMode1,
两个分支完全相同(正确实现应是一个 PwmMode2),所以 invert = true 静默无效——不报错、不翻转。官方示例 timer.zig 的 2、3 通道就用了 invert = true,实际波形与 0、1 通道只有 polarity 的差别。这是上游 bug(P-03),教程代码一律以 polarity 为准。
时基分工:为什么是 init_timer(.TIM4)
time.init_timer(.TIMx) 只支持 TIM2–TIM5(内部按实例名第 4 个字符判号,传 .TIM1 不报错但行为错误)。它使能所选定时器的 NVIC 中断并顺手打开全局中断,default_interrupts 里 HAL 占据 TIM2/3/4/5 四个向量跑节拍。呼吸灯用了 TIM3 做 PWM,时基就挪到 TIM4:HAL 的节拍中断照常跑(sleep_ms 正常),TIM3 的更新中断我们根本没开,两不相犯。若反过来把 PWM 放 TIM4,time 模块立刻失效(第 08 篇讲过的 overwrite 两难在这里不存在——我们没覆盖任何向量,只是共享了外设)。
运行现象
烧录后 LED1(PB5)循环渐亮—渐暗:升坡 101 步 + 降坡 100 步、每步 5 ms,全周期约 1 s。若觉得太快/太慢,调爬坡步进与 sleep_ms。
逻辑分析仪挂 PB5 验证三件事:
- 频率:1.000 kHz(72 MHz/72/1000),周期 1.000 ms;
- 占空比随时间爬坡:每 5 ms 抓一帧,高电平宽度从 0 递增到 1.000 ms 再回落,台阶 10 µs(duty 步进 10 × 1 µs 计数周期);
force_update生效:每次 duty 更新后波形在当帧改变,无 1 ms 滞后。
把 force_update() 注释掉再烧,分析仪上能看到 duty 变化滞后一个 PWM 周期以内——影子寄存器的铁证。
常见问题
Q1:编译通过、程序在跑,PB5 就是一点波形都没有。
按命中概率排查:① 漏了 pwm.set_channel(1, true)——configure_channel 不使能通道,CCER 的 CC2E 没置位,输出门是关的;② 漏了 gpio.apply_remap(.{ .TIM3 = .Partial_Remap_1 })——波形输出在默认引脚 PA7 上(拿示波器探 PA7 验证);③ 漏了 rcc.enable_clock(.AFIO)——MAPR 写不进去,重映射静默失败;④ 漏了 rcc.enable_clock(.TIM3)。前两条占九成。
Q2:算好的 1 kHz 实测是 2 kHz(或 500 Hz)。
两个 off-by-one 嫌疑:① PSC 忘了 +1——prescaler = 72 实际分频 73,prescaler = 71 才是 72 分频;ARR 同理是 ARR+1。② 忽略了 APB1 定时器时钟 ×2 规则——APB1 = 36 MHz 时 TIM3 输入是 72 MHz 不是 36 MHz,按 36 MHz 算 PSC 会差一倍。公式死记:PWM 频率 = 定时器输入时钟/((PSC+1)(ARR+1)),定时器输入时钟 = APB 预分频 ≠ 1 时为 APB×2。
Q3:编译报 no field or member function named 'TimerGeneralConfig'。
上游拼写错误:类型名是 TimerGenealConfig(Geneal,少一个 n),成员函数是 set_auto_relaod_mode(relaod)。看到这种"我明明照手册写的"编译错误,先怀疑上游拼写。不要试图"帮它改对"——你改的是自己的代码,上游符号名不会变。同类陷阱建议在项目里留一行注释提醒。
Q4:pwm.set_duty(1, 50) 期望 50% 占空比,实测约 5%。
set_duty 的 value 是 CCR 计数值,不是百分比。占空比 = value/(ARR+1):ARR = 999 时传 50 得 50/1000 = 5%。HAL 没有 set_duty_percent/set_frequency(不存在,别猜 API),要百分比自己换算:pwm.set_duty(1, period * percent / 100)。同理 set_period(ch, x) 改的是 ARR。
Q5:改了占空比,波形隔了一个周期才变。
影子寄存器(preload)机制:auto_reload_mode = .buffered(默认)下 CCR 写入先落在预装载寄存器,更新事件时才同步到有效寄存器。需要"立即生效"就在 set_duty 后调 pwm.force_update()(软件 UG 事件)。注意 force_update 会顺带把 CNT 清零(相位回卷),单通道呼吸灯无感,多通道同步应用要评估。
Q6:把 PWM 从 TIM3 挪到 TIM4 后,sleep_ms 不动了。
TIM4 是 time 模块的时基地(init_timer 选它并靠其更新中断节拍)。你把 TIM4 重新配置成 PWM(into_pwm_mode + configure 重写了 PSC/ARR),节拍中断的时序全乱,甚至更新中断没开,sleep_ms 永远等不到节拍。解法就是本篇的分工约定:时基 TIM4、PWM 用 TIM3(或其他空闲通用定时器)。若必须用 TIM4 做 PWM,时基退回 time.init_rtc()。另外记住:无论怎么选,别把 PWM 配到 TIM1——time.init_timer 只认 TIM2–TIM5,TIM1 的高级功能 HAL 也未覆盖。
面试考点
Q1:画出通用定时器 PWM 的寄存器级信号链,并写出 PWM 频率与占空比公式。
参考答案:信号链:TIMxCLK → PSC 预分频(÷(PSC+1))→ CNT 计数器(向上数到 ARR 归零)→ 与 CCR 比较 → CCMR 定义的 PWM 模式(PWM1:CNT<CCR 输出有效电平)→ CCER 使能与极性 → 引脚。公式:f_PWM = f_CK/((PSC+1)(ARR+1));占空比 = CCR/(ARR+1)。关键细节:PSC/ARR 都有 +1;APB 预分频 ≠ 1 时定时器输入时钟 = APB×2(APB1 36 MHz 时 TIM2–7 拿 72 MHz);ARR+1 是分母因为计数从 0 开始。例:72 MHz、PSC=71、ARR=999 → 1 kHz,CCR=500 → 50%。
Q2:影子寄存器(preload)机制是什么?buffered 与 immediate 模式的差异?force_update 的作用与副作用?
参考答案:ARR 与 CCR 都有"预装载寄存器 + 影子(有效)寄存器"双层结构。写寄存器写的是预装载层;影子层在更新事件(溢出或 UG)时才同步。buffered(ARPE/OCxPE 置位)下改 ARR/CCR 要等下个更新事件生效——好处是周期与占空比永不出现"半新半旧"的撕裂周期;immediate 则立即生效但可能产生畸形周期。force_update 置 EGR.UG 软件产生更新事件,让新值立刻同步;副作用是 CNT 清零(相位回卷),多通道同步场景需评估。呼吸灯每步 set_duty + force_update 就是为了消除影子延迟。
Q3:HAL 的 configure_channel 与 set_channel 各做什么?为什么必须两个都调?
参考答案:configure_channel(channel: u2, config: PWMChConfig) 配置 CCMR——通道的 PWM 模式、比较预装载、快速模式与极性;set_channel(channel, true) 置 CCER 的 CCxE 使能位——打开输出门。源码上 configure(全局配置)会自动 start 定时器,但 configure_channel 刻意不碰 CCER,所以漏调 set_channel 时定时器在跑、CCMR 配好了、输出门却关着,引脚无波形。这是"配置"与"使能"分离的设计(便于先配后开、避免毛刺),使用者必须知道这条两步约定。channel 参数 0 基:CH2 传 1。
Q4:TIM3 的部分重映射把通道挪到哪里?为什么 PB4 需要额外处理而 PB5 不用?
参考答案:TIM3_REMAP = 01(HAL 的 .Partial_Remap_1)时 CH1→PB4、CH2→PB5、CH3→PB0、CH4→PB1。PB5 的默认功能是普通 I/O(或 SPI1_MOSI 重映射档),无调试口冲突,配复用推挽即可输出。PB4 默认是 NJTRST(JTAG 的复位请求脚),上电后处于调试口控制之下,直接配 PWM 无效——必须通过 MAPR 的 SWJ_CFG 位释放(如 JTAG-DP 禁用 + SW-DP 使能),让 GPIO 控制权交还。原理教训:F103 的 B 口低四位(PB3/PB4/PA13/PA14/PA15)默认绑定 SWJ 调试口,用作普通功能前都要查 SWJ_CFG。
Q5:PWMChConfig 的 invert 字段为什么不能用来反相波形?正确做法是什么?
参考答案:invert 是上游死字段:timer_v1.zig:561 的实现是 if (config.invert) OCM.PwmMode1 else OCM.PwmMode1——两个分支相同(应为 PwmMode2),赋 true 与 false 编译产物一致,波形不变且无任何告警(Zig 惰性编译不会检查这种逻辑死代码)。正确做法:configure_channel 时用 polarity = .low(写 CCER 的 CCxP 极性位),或在输出端逻辑取反。方法论:对不熟悉的上游 API,用逻辑分析仪/示波器验证波形,不要只信字段名——"编译过 + 字段存在"不等于"功能生效"。
Q6:本系列把时基放 TIM4、PWM 放 TIM3,这个分工的依据是什么?如果两者都要 PWM 怎么办?
参考答案:依据有三条:① time.init_timer 只支持 TIM2–TIM5(内部按实例名字符判号),时基只能落在这四个里;② HAL 的 default_interrupts 占据 TIM2/3/4/5 四个向量支撑节拍,被用户重配外设功能会破坏时基;③ 约定俗成留 TIM3 给 PWM、TIM2 给后续输入捕获(第 11 篇),TIM4 做时基,TIM5 备用。若时基与 PWM 都需要定时器且都要自己控制:时基退回 time.init_rtc()(RTC 秒/唤醒中断,精度低但零定时器占用),或用 overwrite_hal_interrupts = true 接管时基定时器的更新中断自己维护节拍(代价是 HAL 的 sleep_ms 失效,需自己实现 sleep)。资源规划先行是裸机多外设项目的基本功。
小结
- 信号链:TIMxCLK → PSC(+1) → CNT 数到 ARR(+1) → 与 CCR 比较 → CCMR/CCER → 引脚;f_PWM = f_CK/((PSC+1)(ARR+1)),占空比 = CCR/(ARR+1)。
- APB1 挂的通用定时器输入时钟是 72 MHz(APB 预分频 ≠1 时 ×2),PSC=71、ARR=999 得 1 kHz。
- 三步配置链:
GPTimer.init(.TIM3).into_pwm_mode()→configure(全局 + 自动 start)→configure_channel(通道)+set_channel(ch, true)(使能,漏了就是零波形)。 - 通道号 0 基:CH2 传 1;类型名拼写就是
TimerGenealConfig、set_auto_relaod_mode,别"纠正"上游。 set_duty写的是 CCR 计数不是百分比;buffered 影子寄存器下新值下个更新事件才生效,force_update立即同步但 CNT 清零。- 极性反相用
polarity = .low;invert是死字段,true 静默无效。 - PB5 = TIM3_CH2 需
apply_remap(.{ .TIM3 = .Partial_Remap_1 })+ AFIO 时钟;PB4 还要释放 SWJ 调试口。 - 时基分工:TIM4 交给
time.init_timer,TIM3 做 PWM,互不侵犯。 - 下一篇输入捕获:用同一套寄存器反过来测频率与占空比。
系列目录
- 系列总览: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号