前阵子帮一位刚入门的朋友排查一个问题:定时器配置得看起来没问题,启动函数也调了,但引脚上就是测不到PWM波形。我让他查一下芯片型号,然后提醒去看高级定时器里那个主输出使能位,问题三秒解决。这种"配了但没输出"的场景,在STM32定时器开发里太常见了。
这篇想跟你把STM32定时器与PWM原理从头到尾捋一遍。定时器是STM32里最常用的外设之一,PWM又是它最具代表性的应用形态。很多人用过PWM,但遇到频率偏差、占空比异常、波形不存在时,主要靠猜。我会从定时器家族的差异讲起,拆开时基单元的PSC、ARR、CNT三兄弟,再说明PWM的生成机制,最后给出从CubeMX到寄存器直写的完整配置流程,以及我实际踩过的一些坑。适合正在学嵌入式,或者准备用STM32做电机控制、电源、灯效驱动的朋友参考。
1. 定时器家族:先搞清自己手里有哪些资源
STM32的定时器不是"一个"外设,而是一整个家族。如果不先搞清楚手里的型号有哪些定时器,后面的很多问题都会让人摸不着头脑。
1.1 基本、通用、高级定时器的核心定位
以常见的F1和F4系列为例,定时器大致分成三类:
基本定时器:一般叫TIM6、TIM7。它们只有时基单元,没有外部的输入输出引脚,也就是说不能直接产生PWM波形。它的主要工作是内部定时、产生更新中断,以及触发DAC转换。不少新手想在TIM6上输出PWM,翻遍引脚表找不到对应通道,就是因为没分清类型。
通用定时器:一般叫TIM2、TIM3、TIM4、TIM5。这是最常用的一类。它们有时基单元,还有4个独立通道,支持PWM输出、输入捕获、输出比较,某些还可以做编码器接口。日常做LED呼吸灯、舵机控制、灯带调光,基本都是用它们。
高级定时器:一般叫TIM1、TIM8。在通用定时器的基础上,多出死区发生器、互补输出、刹车输入等功能。这些是为电机控制、逆变器、开关电源这类场景准备的。比如电机驱动里上下桥臂的互补PWM,就必须靠高级定时器来做,不然外部电路要额外处理防直通问题,麻烦得多。
| 定时器类型 | 时基单元 | 输出通道 | PWM输出 | 互补输出与死区 | 典型用途 |
|---|---|---|---|---|---|
| 基本定时器 | 有 | 无 | 不支持 | 无 | 定时中断、触发DAC |
| 通用定时器 | 有 | 4路 | 支持 | 无 | LED控制、舵机、频率测量 |
| 高级定时器 | 有 | 4路(含互补) | 支持 | 支持 | 电机控制、逆变电源 |
1.2 定时器位数与时钟来源的隐藏差异
除了类型,还要注意位数。同一个系列里,不同定时器的计数寄存器位数可能不一样。部分型号的TIM2和TIM5是32位,ARR最大能到40亿级别;而TIM3、TIM4通常是16位,ARR最大65535。这个差异在低频场景下会直接决定你能不能一次配出目标周期。
另一个容易忽略的是定时器时钟来源。定时器并不一定直接吃系统主频。在F1里,APB1总线上挂着通用定时器和基本定时器,如果APB1分频器不为1,定时器时钟会自动变为APB1频率的2倍。比如系统72MHz,APB1分频2得到36MHz,但TIM2的时钟不是36MHz而是72MHz。很多人按36MHz去算PSC和ARR,结果频率偏差一倍,半天找不到原因。这个细节后面还会再提。
建议:动手前先看参考手册里"时钟树"章节,确认当前用到的定时器输入时钟到底是多少MHz,这比猜可靠得多。
2. 时基单元三兄弟:PSC、ARR、CNT怎么配合
不管什么类型的定时器,核心都是时基单元。它由三个关键寄存器组成:CNT计数器、PSC预分频器、ARR自动重装载寄存器。
2.1 预分频器PSC:为什么实际分频系数是PSC+1
预分频器的作用是把定时器输入时钟放慢,让计数器以一个更低的速度工作。很多人不理解为什么写PSC=71得到的是72分频,而不是71分频。因为预分频器的实现方式是"计数到PSC就清零并输出一个脉冲",也就是说它内部从0数到PSC,整整经历了PSC+1个输入时钟周期才输出一次。所以:
分频系数 = PSC + 1
举个例子,定时器输入时钟72MHz,写入PSC=71,计数器CNT得到的工作频率就是:
72MHz / (71+1) = 1MHz
也就是每隔1us,CNT加1。这个1us就是我们后续计算所有时间的基础。如果你想要计数器每0.5us跳动一次,那就得让分频后是2MHz,推回去就是PSC+1 = 36,PSC=35。
2.2 自动重装载寄存器ARR:溢出边界的意义
CNT从0开始,每来一个计数脉冲就加1,一直加到ARR,然后下一个脉冲到来时溢出归零,同时产生更新事件。注意关键点:不是数到ARR就结束,而是"CNT等于ARR后再来一个脉冲才产生更新",所以一个完整的计数周期是ARR+1个脉冲。
继续用上面的例子,计数频率1MHz,每个脉冲1us。如果ARR=999,那么CNT从0到999,数了1000次,溢出周期就是:
1000 × 1us = 1ms
也就是1kHz的更新频率。很多人想在定时器中断里做1ms节拍,就这么配。但写着写着会把ARR填成1000,想着"1000个1us就是1ms",实际却变成1001us,还带一个计数周期的误差。这个+1的细节在PWM频率计算里同样关键。
溢出频率公式可以统一写成:
溢出频率 = 定时器时钟 / ((PSC + 1) × (ARR + 1))
如果你做的是1ms节拍这种场景,直接用这个公式反推参数,比手动心算边界条件更不容易出错。
2.3 更新事件、中断和重复计数
CNT溢出时会产生更新事件UEV,同时更新影子寄存器。如果使能了更新中断,还会在溢出时进入中断回调。默认情况下,每个溢出周期都产生一次更新中断。
有些应用希望中断频率比PWM频率低一些,比如PWM频率做到20kHz,但只想每1ms进一次中断做控制计算。直接开更新中断意味着20kHz的中断频率,CPU负载很重。这时可以用高级定时器里的重复计数寄存器RCR,设置溢出N次之后才真正产生一次更新事件,操作灵活很多。普通通用定时器没有这个功能,需要降低中断频率就只能把ARR整体调大,或者另开一个定时器来控制节奏。
2.4 16位定时器的时间上限
16位定时器的ARR最大只有65535。假设计数频率1MHz,最大溢出周期是65.535ms。如果需要1秒级甚至分钟级的定时,单靠一个16位定时器直接配ARR是配不出来的。常见做法有三个:
- 改用32位定时器,比如部分型号的TIM2;
- 在更新中断里用软件计数器累计,中断里每进一次就加一次软件计数,比如累加到1000就代表1秒;
- 用更低频的计数时钟,比如从外部引脚引入低频时钟源。
第三个方法在普通定时用途里用得不多,前两个足够覆盖大多场景。
3. PWM到底是怎么出来的:输出比较机制拆解
理解了时基单元,PWM就不再神秘。PWM的本质就是靠计数器CNT和一个比较寄存器CCR的持续比较,在比较结果翻转时改变引脚电平。
3.1 比较寄存器CCR 与输出引脚状态
每个PWM通道都有一个捕获/比较寄存器CCR。CNT不停累加,硬件持续把CNT和CCR做比较。在PWM模式1且向上计数时,规则是:
- CNT < CCR:通道输出有效电平,默认是高电平;
- CNT >= CCR:通道输出无效电平,默认是低电平。
于是每个溢出周期内,从0到CCR是有效,从CCR到ARR是无效,引脚上就出现一个方波。改变CCR就改变了高电平持续时间,也就是占空比。改变PSC或ARR就改变周期,也就是频率。
这里有个容易被忽略的"影子寄存器"概念。直接写CCR不会立即改变比较行为,除非关闭预装载功能。通常我们会开启CCR预装载,那么新值会在更新事件(溢出)时一次性生效,从而避免在波形周期中间出现毛刺。这个特性对电机和电源控制非常重要。
3.2 PWM模式1与模式2的差异
PWM模式1和模式2,可以简单理解为逻辑相反。
- 模式1(向上计数):CNT < CCR时输出有效;
- 模式2(向上计数):CNT >= CCR时输出有效。
如果固定用默认高电平有效来看,模式2输出的波形相位刚好和模式1相反。实际项目中如果发现波形对调了,检查两处:一个是CCMR里的OC1M位选的模式1还是模式2,另一个是CCER里的CC1P极性位。极性位CC1P可以再把输出整体翻转一次。总结一下就是:
模式决定比较逻辑,极性决定最终输出是否翻转。
很多初学者只注意到模式,忽略了极性位,导致高有效和低有效搞反,在控制舵机这种对脉宽有明确要求的场合会出问题。
3.3 频率和占空比的完整计算公式
PWM频率计算公式:
PWM频率 = 定时器时钟 / ((PSC + 1) × (ARR + 1))
占空比精确值是:
占空比 = CCR / (ARR + 1)
这个公式里又出现一次+1。很多人习惯写成CCR/ARR,当ARR足够大时误差很小,但较真起来,CCR=ARR时并不是100%占空比,而是99.9%。比如ARR=999, CCR=999,占空比是999/1000=99.9%。在绝大多数控制场景里这点差别无所谓,但如果做高精度电源,还是要按精确公式来。
举一个实际例子。目标是20kHz、50%占空比的PWM,定时器时钟72MHz。有两种配法:
方案A:PSC=71,得到计数频率1MHz;ARR=49,那么PWM频率 = 1MHz/50 = 20kHz。CCR取25,占空比 = 25/50 = 50%。这个方案计数精度低,50%对应CCR在25附近,每一步变化就是2%,如果要做精细调光就太粗了。
方案B:PSC=0,计数频率保持72MHz;ARR=3599,那么PWM频率 = 72MHz/3600 = 20kHz。CCR取1800,占空比 = 1800/3600 = 50%。这个方案里CCR每变化1,只改变约0.028%的占空比,调节精度高得多。
| 配置方案 | PSC | ARR | CCR | 频率 | 占空比调节精度 |
|---|---|---|---|---|---|
| 方案A | 71 | 49 | 25 | 20kHz | 约2% |
| 方案B | 0 | 3599 | 1800 | 20kHz | 约0.028% |
选参数时,要在ARR位数和调节精度之间做权衡。方案B精度好,但ARR也不能无限大,毕竟16位定时器上限65535。如果你用的是16位定时器,又想从1Hz到20kHz大范围变频,就经常需要动态切换PSC或者接受频率分档变化。
3.4 高级定时器的死区与互补输出
高级定时器比通用定时器多出来的核心能力是互补输出和死区。互补输出的意思是同一组通道,比如CH1和CH1N,两个引脚输出逻辑相反的电平,正好用来驱动半桥电路里的上下两个功率管。
死区时间则是防止上下管同时导通的保险:在切换电平的瞬间,硬件会强制插入一段两边都关闭的时间。比如设置死区2us,那么下管关断之后要等2us,上管才会导通。死区太短有直通风险,太长会增加开关损耗,实际调节需要根据功率管的开关参数来确定。
死区时间由高级定时器的DTG寄存器配置,不同数值区间对应不同的计算方式,细节比较多。这里只要记住:做电机控制或电源逆变,互补输出和死区是不可替代的功能。如果你只是驱动一个LED或者舵机,用通用定时器就完全够了,别为了高级功能多交学费。
4. 实操配置:CubeMX配置和寄存器直写两条路
理论讲完,落到写代码。实际项目里两种方式都存在:CubeMX图形化配置加HAL库,以及纯寄存器直写。下面分别说明。
4.1 五分钟在CubeMX里生成可用工程
用CubeMX配置PWM,核心步骤可以概括为:
- 选择一个定时器,比如TIM3,将Channel1设置为PWM Generation CH1;
- 在Parameter Settings里配置Prescaler、Counter Period、Pulse,以及PWM Mode;
- 确认引脚在复用功能状态,检查是否有冲突;
- 生成代码后在主函数里调用启动函数。
生成代码后,典型的主函数代码是这样:
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);这句调用必须执行,否则前面全部白配。之后需要改占空比时,使用HAL库提供的方式:
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, duty);duty就是新的CCR值。这个值应保持在0到ARR之间。有的HAL库例子会直接写CCR,那也可以,但推荐用宏定义的方式,代码更清晰。
需要说明一个高级定时器的差异。使用TIM1或TIM8时,HAL_TIM_PWM_Start内部会把高级定时器的主输出使能位MOE一并打开,所以你在CubeMX里正常用HAL库,不会遇到"高级定时器没有输出"的问题。但如果你用寄存器方式直写,忘了置MOE,PWM就会一直沉默。这个坑后面还会细说。
4.2 寄存器直写:一次把关键位看明白
寄存器方式的套路并不复杂,关键是每一步知道自己在做什么。以F4系列、通用定时器某个通道输出PWM为例,核心流程如下:
先使能定时器和GPIO时钟,然后配置GPIO为复用功能,并设置复用映射,把引脚接到定时器的输出通道。接着配时基:
TIM3->PSC = 71; TIM3->ARR = 999; TIM3->CCR1 = 500;配置PWM模式和通道输出使能:
TIM3->CCMR1 |= (0x6 << 4); // OC1M置110,选择PWM模式1 TIM3->CCMR1 |= TIM_CCMR1_OC1PE; // 开启输出比较预装载 TIM3->CCER |= TIM_CCER_CC1E; // 使能CH1输出最后启动定时器:
TIM3->CR1 |= TIM_CR1_CEN;如果是高级定时器TIM1或TIM8,还要多加一句,否则没波形:
TIM1->BDTR |= TIM_BDTR_MOE;MOE全称Main Output Enable,也就是主输出使能。如果不置位,高级定时器的输出通道在外部看来是禁用的。这几乎是寄存器开发里最常见的一个低级失误,但造成的现象很迷惑,因为代码检查了好多遍都看不出问题。
还要提一下预装载。CCMR1里的OC1PE位如果置1,CCR1写入的新值会等到溢出时才生效;如果置0,写入立即生效。高精度控制通常开启预装载,避免波形畸变。
4.3 运行中动态改占空比与频率
PWM参数往往不是配置一次就完事,比如呼吸灯、调速、闭环控制都要求运行时改占空比。改占空比最简单:
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, new_ccr);改频率则意味着改ARR。但注意,只改ARR会导致占空比也跟着变。比如原来ARR=999, CCR=500,占空比50%;你把ARR改成1999,CCR还是500,占空比就掉到25%了。所以变频同时要按目标占空比同步改CCR。有些应用场景对占空比精度要求高,改频率时需要两个参数联动:
__HAL_TIM_SET_AUTORELOAD(&htim3, new_arr); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, new_arr * target_duty / 100);另外,PSC也可以在运行时修改,但修改PSC一般会先触发一次更新事件,某些芯片上行为有差异,不如操作ARR那么直观,所以变频优先改ARR,避不开PSC时再查手册确认时序。
5. 常见问题与排查实录
这块内容全是实际操作中积累的,优先级很高。
5.1 PWM完全没有输出
我碰到最多的就是PWM没有一点波形。排查思路可以按这个顺序来:
- 是否调用了HAL_TIM_PWM_Start。CubeMX生成代码后忘了启动函数,这种低级出错率远超想象;
- 高级定时器的MOE是否使能。寄存器开发时尤其容易漏;
- GPIO是否真的配置成了复用功能。F1和F4的GPIO配置方式不同,F1有独立的复用重映射寄存器,F4则是把引脚功能写进AFR寄存器,搞混了引脚自然没信号;
- 引脚是否被其他外设占用。CubeMX的多引脚视图里如果出现红色冲突,就要先解决冲突;
- 定时器时钟是否开启。查RCC对应总线上的使能位。
一个典型的排查方法是:先用代码把该引脚掰成普通推挽输出,拉高拉低,看示波器上电平是否变化。能变化就证明GPIO和引脚没问题,问题在定时器侧;不能变化就查GPIO配置和硬件连接。
5.2 频率偏差:从计算错误到晶振翻车
频率不对理论上大家都会套公式,但实际坑很多。
最大的坑是时钟源本身不对。某次调试时,外部晶振虚焊,系统没检测到HSE就自动切回了内部HSI,所有频率全偏。你按72MHz算参数,实际只有8MHz或16MHz,PWM频率偏差几倍都有可能。判断方法很直接:查看RCC时钟相关寄存器,确认外部晶振标志是否置位。像这种硬件底层的坑,只看应用代码永远看不出来。
第二个常见坑是PSC和ARR忘记加1。少写一个+1,频率误差在低频时尤其明显,比如1kHz目标变成999Hz或1001Hz。当你明显感觉频率不对但又不至于离谱时,优先检查这两个+1。
第三个坑是被APB预分频误导。前面提到的F1系列里TIMxCLK可能是APB1的两倍。如果直接用APB1频率计算,所有结果差一倍。参考手册或CubeMX的时钟配置图都能帮你确认实际时钟数值。
5.3 占空比异常:高电平、反相、突变
占空比不对主要有几类现象。
- 输出一直为高或接近100%。最常见原因是CCR值超过ARR,或者CCR写成了ARR+1甚至更大。这时候CNT永远小于CCR,输出始终有效。反过来,如果CCR写0,输出几乎全程无效;
- 波形反相。检查PWM模式1/2和极性位,前文讲过的两处配置别漏;
- 波形中间出现毛刺或抖动。多半是CCR预装载没开,运行中直接改CCR导致的。解决方式是开启OC1PE,让新值在溢出时统一生效;
- 多个通道互相干扰。检查每个通道的CCER使能位是否独立,以及是否错误复用了同一个引脚。
有些人的项目里更新中断和PWM共用同一个定时器,中断回调里执行了耗时的浮点运算,占空比更新出现明显延迟。这不算PWM本身的故障,但会影响实时性。更合理的做法是控制计算放到主循环或优先级更高的中断里,PWM的CCR只留最终结果。
5.4 没有示波器怎么调PWM
示波器是最直接的调试工具,但不是每个人都随时有。我分享几个替代手段。
- 逻辑分析仪:多路、低价,测频率和占空比完全够用,只是看不到模拟噪声细节;
- LED亮度观察:把LED串联电阻接在PWM输出上,肉眼观察亮度变化。调占空比时亮度跟着变,说明PWM基本正常。如果想让肉眼看得清楚,把PWM频率降到1kHz以下,LED闪烁会更明显;
- 用ADC采集自己的PWM输出:设置RC低通滤波后接ADC,模拟一个简易DAC,ADC读数约等于占空比乘以参考电压。这个方法能定量验证占空比变化,只是精度有限;
- 用定时器的输入捕获功能,把同一个引脚或另一个引脚回接到通道上,软件里读捕获值算占空比。这算是嵌入式工程师的自我验尸法,能不动外部设备就完成基本验证。
我个人的习惯是,调试PWM先看频率再看占空比。频率不对优先查时钟树和PSC、ARR;占空比不对优先查CCR边界、模式和极性。如果完全不输出,先检查启动函数、MOE和GPIO复用。这三个顺序用熟之后,大部分问题都能在十分钟内定位。
再分享一个小技巧:遇到"看起来一切正常但波形差一点"的时候,把ARR故意设成一个整数,比如9999,PWM频率设置音频范围以外的低频,然后用逻辑分析仪或示波器数波形,这样更容易直观判断误差来自哪里。
定时器和PWM这块,表面看就是配置几个寄存器,实际水挺深。我做电机控制项目时,后来养成了固定习惯:每次写配置前先在纸上写下目标频率、目标占空比、定时器输入时钟,再反推PSC、ARR、CCR三个数值,最后才落到代码里。这套流程帮我避开了大量低级错误。如果你在项目中也遇到过类似困惑,不妨试试同样的习惯,把数学关系先算清楚,再看波形时心里就有底了。