☰
STM32定时器PWM输出全解析:从底层原理到配置调试避坑
2026/10/12 18:03:02 网站建设 项目流程

简介:STM32 HAL库PWM输出程序是一份面向入门开发者的定时器PWM例程,配套齐全、可直接编译运行。它着重解决新手在PWM应用中的常见问题:如何用STM32CubeMX选择TIM定时器并设置预分频器、自动重装载值和比较通道,如何调用HAL库完成初始化、配置占空比、启动与停止输出,以及如何处理定时器中断与溢出事件,适合用来学习PWM调速、调光、蜂鸣器控制等基础场景。压缩包共73个文件,以46个.h头文件和19个.c源文件为主体,另有.ioc图形化配置工程、.uvprojx MDK工程、启动文件及编译好的hex固件,整体仅517KB,体积小巧,解压后Src、Inc、Drivers等目录按驱动层、用户代码和HAL底层分离,层次清楚,便于对照学习代码组织方式。工程代码完整展示了从HAL_TIM_PWM_Init、HAL_TIM_PWM_ConfigChannel到HAL_TIM_PWM_Start/Stop的调用流程,并给出MSP回调与中断处理的代码框架,还包含定时器溢出、更新事件和电源管理方面的注意事项,为后续扩展多通道PWM、死区控制等高级功能打下基础。当前已有8834人浏览学习,是一份高效上手的实践参考。 玩STM32的人迟早会碰到PWM输出。我见过不少刚从标准库转HAL库的朋友,在CubeMX里把定时器通道勾成PWM Generation CHx,然后照着教程调用HAL_TIM_PWM_Start,结果板子上波形出不来,或者出来之后频率跟预期差一大截。问题多半不在API,而在对定时器底层机制理解不够——PWM输出的整个过程,其实就是定时器里一个计数器和几个寄存器在硬件层面反复“比大小”。这篇文章不打算一步步截图带你点CubeMX,而是把PWM输出这条链路从头到尾讲透,从原理到配置再到调参和进阶应用,适合刚入门想真正搞懂PWM、而不是只会抄配置的同学。

1. 先搞明白:PWM输出在定时器底层到底是怎么发生的

1.1 一个跑圈的计数器,一个硬件比较器

STM32的定时器(TIM)本质是一套硬件计数器系统,和CPU无关却能持续工作。核心有三个东西:CNT(当前计数值)、ARR(自动重装载值)、CCR(捕获比较寄存器)。CNT按照定时器时钟的节拍从0开始累加,数到ARR的瞬间归零重新开始,这个循环就是PWM的周期。

PWM模式就是在CNT累加过程中,持续把CNT和CCR做比较,比较结果直接控制引脚输出高低电平。比如最常见的PWM模式1:当CNT小于CCR时,引脚输出有效电平;当CNT大于等于CCR时,引脚输出无效电平。这个比较是硬件完成的,CPU全程不需要干预,所以一旦配置好,定时器就能自己一直输出PWM。

把定时器想成操场跑圈就很好理解:CNT是正在跑的运动员,ARR是一圈的总长度,CCR是设在跑道上的一个检查点,OCx引脚就是一面旗子——运动员在检查点之前,旗子就举起来,过了检查点就放下,一圈跑完重新再来。

1.2 占空比、频率、分辨率,三者是跷跷板

PWM的三个核心参数,对应着三个公式:

  • 频率 = 定时器时钟 / ((PSC+1) * (ARR+1))
  • 占空比 = CCR / (ARR+1)
  • 分辨率 = 1 / (ARR+1)

以STM32F103C8T6系统时钟72MHz为例,如果PSC=71、ARR=999、CCR=500,那么计数器的时钟是72MHz/(71+1)=1MHz,一次完整周期要数1000个脉冲,所以PWM频率=1MHz/1000=1kHz,占空比=500/1000=50%,分辨率=0.1%。

注意这三者是相互约束的,不是想调哪个就调哪个。分辨率由ARR决定,ARR越大分辨率越高;但ARR增大会拉低频率。想要频率高同时分辨率也高,就只能压缩PSC降低计数时钟。我见过很多人只改ARR去调频率,结果占空比从50%变成了别的数值——因为占空比是CCR除以(ARR+1),ARR变了分母就变了。这三个参数要放在一起联调,不能只动其中一个。

1.3 PWM模式1和模式2,以及极性到底怎么理解

CubeMX里PWM模式有两个选项:PWM Mode1和PWM Mode2。Mode1是CNT小于CCR时输出有效电平,Mode2正好相反。但很多人忽略了一个更隐蔽的概念——有效电平本身是由极性(Polarity)决定的。

Polarity选High时,有效电平是高电平;选Low时,有效电平是低电平。所以同样是Mode1,极选High和极选Low,输出的波形在相位上是反的。我遇到过有人做LED共阳接法,想用低电平点亮,直接把Polarity改成Low,结果发现CCR设得越小灯反而越亮,跟自己预期完全反了。原因很简单:Polarity Low之后,有效电平变成低电平,而Mode1下CNT小于CCR时输出有效电平,也就是CCR越小,低电平时间越短,灯当然就越暗。这种地方很容易把人绕晕。

实际调参建议:绝大多数场景用PWM Mode1 + Polarity High,然后通过CCR的值来决定占空比,这样最符合直觉。只有在外部电路需要反向逻辑时(比如共阳LED、驱动芯片的IN反相输入),才去动极性,而且动完要重新理解CCR和亮度的关系。

2. CubeMX里每一项配置到底在改什么寄存器

2.1 时基配置:先定分辨率,再反推分频值

打开CubeMX,在Timers里选中一个定时器,把Clock Source选为Internal Clock,然后在Parameter Settings里能看到Prescaler(PSC)、Counter Mode、Counter Period(ARR)这几个选项。Counter Mode一般选Up向上计数模式,除非你后续要做中心对齐的互补PWM和死区控制,才需要选Center Aligned Mode。

计算顺序有讲究。我的习惯是:先确定目标频率和可接受的分辨率,由分辨率反推ARR,再由ARR和频率反推PSC,而不是先拍脑袋填PSC。

举个例子:想要1kHz的PWM,分辨率要0.1%,那么ARR+1=1000,ARR=999;然后定时器时钟如果是72MHz,PSC = 72MHz/(1000*1kHz) - 1 = 71。这样算出来就是PSC=71、ARR=999。

这里有一个F103系列最容易踩的大坑:APB1总线分频系数不等于1时,挂在APB1上的定时器时钟是APB1的两倍。F103的APB1最高只能到36MHz,所以默认情况下APB1分频器是2,而TIM2/TIM3/TIM4这些通用定时器的时钟反而是72MHz。很多人在CubeMX的Clock Configuration里看到APB1显示36MHz,就以为TIM2/TIM3/TIM4也是36MHz,结果算出来的PSC全错,输出频率差一半。这个点能坑掉一半的初学者。

2.2 输出通道:模式、极性、初始脉冲

在CubeMX里把某个通道(比如TIM2的CH1)选为PWM Generation CHx之后,会出现几个参数:

  • Mode:选PWM Mode1即可,原因前面已经说过。
  • Pulse:这是CCR的初始值,也就是初始占空比,建议一开始设一个明确的值,比如500,方便确认波形。
  • Polarity:默认High,除非你有反向需求。
  • Fast Mode和Output Compare Preload:Fast Mode一般不勾,Preload建议保持Enable。

这里要特别注意GPIO的配置状态。CubeMX会自动把对应的引脚设置成复用功能,但你在GPIO设置里最好再确认一下:F103要选AF_PP(复用推挽),F4/H7要确认AF值选到了正确的定时器复用功能。我见过太多人波形出不来,最后发现CubeMX自动生成时引脚根本不是复用模式,而是普通GPIO输出——虽然CubeMX一般会自动处理,但如果你手动改过引脚状态,这个错误就非常隐蔽。

2.3 预装载:改占空比什么时候生效,这里有个坑

CubeMX的PWM配置界面里有两个跟预装载相关的选项:Output Compare Preload(OCxPE,对应CCR预装载)和AutoReloadPreload(ARPE,对应ARR预装载)。默认都是Enable,作用是让寄存器的更新发生在下一个更新事件(一个完整PWM周期结束)时,而不是写入的瞬间立即生效。

这样设计是为了防止毛刺:如果在计数器运行途中直接改ARR或CCR,电路输出波形可能被拦腰截断,出现一个不完整的高电平或低电平脉冲。预装载相当于加了一个缓冲,系统会等一个周期结束再切换参数。

但这个机制也会带来一个让人困惑的现象:代码里用__HAL_TIM_SET_COMPARE改了CCR,波形没有立刻变,而是等了一个周期才变。对于控制频率不高的场景(LED、舵机)完全无所谓,反正周期以毫秒计;但在需要精确同步的场景下,你就得用“强制产生更新事件”或者干脆关闭预装载来保证写入立即生效。我在做PWM触发ADC采样时就遇到过这个问题——我改了CCR想移动采样点,结果ADC采到的还是旧值,后来才意识到是预装载在“挡路”。

还有一个经常被忽略的点:Pulse的初始值会直接被写进CCR寄存器,但即使是Enable了预装载,这个初值是在初始化时写入的,之后的更新事件才会用新的CCR。这一点如果没搞明白,容易在调试时怀疑自己“代码没生效”。

3. HAL库API拆解:Start、SetCompare和看不见的默认状态

3.1 HAL_TIM_PWM_Start,一句话背后做了三件事

HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1)这个函数几乎人人会写,但很少有人看它到底做了什么。翻一下stm32f1xx_hal_tim.c源码,PWM_Start内部实际做了三件事:

第一,使能对应输出通道。它调用TIM_CCxChannelCmd,把TIMx->CCER寄存器里对应的CCxE位置1,这一步的作用是把OCx引脚的输出接通到定时器,让比较结果真正流到引脚上。

第二,对高级定时器处理主输出使能。如果是TIM1、TIM8这类高级定时器,还需要把TIMx->BDTR寄存器的MOE位置1,否则整个定时器的PWM输出全部是封锁的,引脚上什么都没有。这一步HAL库在Start函数内部已经做了。

第三,使能计数器。通过把TIMx->CR1的CEN位置1,让CNT开始跑起来。注意,CEN位置1之后,是从0开始计数,而不是从你上次停止的位置继续——很多人没注意到这个细节。

所以HAL_TIM_PWM_Start的语义是“打通输出链路并启动计数器”,它不负责重新配置占空比,占空比在初始化时已经写好了。

3.2 改占空比,三条路都能走

运行中改占空比,HAL库给的方式非常多,本质上都是在写CCR寄存器:

// 方式一:HAL库宏,性能和寄存器操作一样 __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 500); // 方式二:HAL库函数封装,带参数断言 HAL_TIM_PWM_SetCompare(&htim2, TIM_CHANNEL_1, 500); // 方式三:直接操作寄存器 htim2.Instance->CCR1 = 500;

我平时最常用__HAL_TIM_SET_COMPARE,因为它是宏,直接展开成寄存器写操作,快且没有额外开销,而且语义清晰。HAL_TIM_PWM_SetCompare本质上也是写CCR,但会带断言检查,适合代码审查严格的项目。

有一个很容易踩的边界问题:CCR的值如果大于等于ARR+1,占空比会饱和到100%,波形恒为高电平。如果后续你又手动把ARR改小了,而CCR没有跟着改,那么占空比可能一直是100%——这种“占空比突然不受控制”的情况,排查时先看CCR和ARR的关系,多半就是数值越界了。

3.3 为什么波形出不来:三个我实测最多的原因

第一,GPIO没有正确配置成复用功能。CubeMX自动生成的代码一般不会错,但如果你在别的工程里手写Init,或者中途调整过GPIO,引脚很可能还是普通GPIO模式,定时器信号根本没有接到引脚上。排查办法很简单:看MX_GPIO_Init里这个引脚是不是AF_PP,以及MX_TIMx_Init之后有没有执行HAL_TIM_PWM_Start。

第二,定时器的时钟源没有配置。CubeMX里如果没把定时器Clock Source设成Internal Clock,代码里就不会使能定时器的外部时钟,定时器外设可能根本没有时钟脉冲,CNT不走,比较逻辑永远不触发。这种情况HAL_TIM_PWM_Start执行了不报错,但引脚没有任何输出,排查费劲。

第三,高级定时器的MOE位被“偷偷”清了。前面提到HAL_TIM_PWM_Start会自动处理MOE,但如果你在Init之后、调用Start之前做了一些寄存器操作,比如读了BDTR寄存器、或者调用了HAL_TIM_PWM_Stop又改为手动Start,MOE可能在某个环节被清零。这时候引脚依旧没有输出,但CCER的CCxE已经置位了,看起来就是“一切正常但没波形”。遇到TIM1/TIM8输出异常,第一件事检查BDTR寄存器的MOE位。

另外有个新手高发问题:在CubeMX里改了配置之后没有重新生成代码,仍然在跑旧的初始化程序。这个问题看着低级,但真实存在,因为CubeMX生成的代码覆盖手写修改是另一个常见坑——所以我会在工程里把手动改动的部分尽量集中放在一个单独的函数里,避免被重新生成时覆盖。

4. 按场景调参:LED、舵机、电机对PWM的要求完全不同

4.1 LED呼吸灯:200Hz到1kHz,256级亮度梯度就够

LED对PWM频率的要求不算高,人眼对60Hz以上的闪烁就不太敏感了,但实际体验下来,低于100Hz在快速移动时还是会有频闪感,200Hz到1kHz是舒适区。频率再高就没有意义了,LED驱动电路(尤其是带恒流驱动的)反而可能因切换太快而产生噪声。

呼吸灯的关键是亮度梯度的平滑度。实测下来,占空比可以用256级就足够平滑,肉眼基本分辨不出分层;128级在某些低亮度区间会看到明显的台阶感。对应的做法就是ARR取255,CCR从0开始循环递增递减:

while (1) { for (uint16_t i = 0; i <= 255; i += 2) { __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, i); HAL_Delay(15); } for (uint16_t i = 255; i > 0; i -= 2) { __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, i); HAL_Delay(15); } }

这个代码CPU会一直忙在延时上,如果主循环还要干别的事,建议把渐变逻辑放到定时器中断里驱动,或者直接用DMA来更新CCR,后面会专门讲。

4.2 舵机:20ms周期是死规矩,脉宽才是关键

舵机控制最容易让人误解的一点:它不是按占空比来算的,而是按高电平的绝对脉宽来算的。标准模拟舵机的控制信号是:周期20ms(频率50Hz),高电平0.5ms到2.5ms对应0°到180°。所以配置时要让计数器的时钟是1MHz,这样CCR的值直接就是微秒数。

按72MHz系统时钟来算:PSC=71,得到计数时钟1MHz;ARR=19999,计数20000个脉冲,正好20ms。CCR=500对应0.5ms(约0°),CCR=1500对应1.5ms(约90°),CCR=2500对应2.5ms(约180°),非常直观。

舵机对PWM频率很敏感,有些数字舵机可以接受更高刷新率,但很多模拟舵机一旦频率超过50Hz就会发热、抖动、甚至失控。不要在舵机上追求“高刷新率”,20ms周期就是最稳的选择。另外,如果你用低分辨率的ARR(比如ARR=999)也能算出来对应脉宽,但周期就不是20ms了,有些舵机依然能转,但控制精度和稳定性会下降,不建议这样偷懒。

4.3 电机调速:频率低于15kHz容易啸叫

电机PWM调速是另一个典型场景,频率选择逻辑和LED完全不同。人耳能听到的声波范围是20Hz到20kHz,PWM驱动电机时,线圈在通断瞬间会产生机械振动,频率落在人耳可听范围内就会啸叫。实测10kHz以下啸叫很明显,15kHz以上基本听不到了,所以业界常见的电机PWM频率是16kHz到20kHz。

但频率也不是越高越好。20kHz以上虽然人耳听不到,但MOSFET的开关损耗会明显上升,驱动芯片发热量增大。做有刷电机调速,我一般先选16kHz,再根据实际发热和噪音情况微调。

电机控制还有一个容易被忽视的点:主控引脚的3.3V逻辑电平不一定能被驱动芯片正确识别。很多电机驱动芯片的逻辑输入是5V电平标准,直接用3.3V高低电平去驱动,有些芯片能识别,有些芯片在高电平阈值边缘徘徊,表现就是“有时候能转有时候不能转”。这种情况加一个电平转换电路,或者选支持3.3V逻辑的驱动芯片,比在代码里反复折腾强得多。

5. 进阶玩法:DMA、ADC触发、互补输出的实战经验

5.1 一个定时器能不能同时捕获和输出PWM

这个问题的答案是:可以,但有限制。我在项目里常用TIM3的CH1做输入捕获来读取遥控器的PWM信号,同时用TIM3的CH2输出自己生成的PWM,实测完全没问题。原因在于,定时器的每个通道都有独立的CCR和独立的捕获/比较逻辑,CH1的输入捕获会把捕获到的信号值写入CCR1,CH2的输出比较会把软件写入的CCR2拿去做比较,两者互不干扰。

但有一个必须明白的约束:同一个定时器的所有通道共用一个计数器和一个ARR。也就是说,输入捕获信号和输出PWM信号的频率尺度和ARR强绑定。如果输入的PWM是50Hz舵机信号(需要ARR=19999),输出的PWM也必须是接近50Hz这个量级;如果输入是20kHz的高频信号(需要ARR很小),而输出要1kHz,同一个定时器做不到,因为ARR只有一个。这种需要两个定时器或者硬件资源分开的场景,就得换方案。

另外,同一个通道不能同时做输入捕获和输出比较。CCR只有一个,输入捕获由硬件写入CCR,输出比较由软件写入CCR,二者直接冲突。如果非要用同一个引脚既接收又发送PWM,只能靠运行时切换定时器模式并重新配置,工程上不推荐,时序抖动的坑会非常深。

5.2 PWM+DMA,占空比自动批量更新

想让LED按一段预设的曲线平滑变化,或者让步进电机按S形加减速曲线运行,又不愿意让CPU一直去改CCR,可以用PWM+DMA。

核心思路:把一段占空比序列放在内存数组里,DMA每次在定时器更新事件时自动从数组搬运一个值到CCR寄存器,DMA设置为循环模式,数组里的值就能一遍遍重复执行。

CubeMX里的配置要点:

  • DMA请求源选择TIMx_UP(更新事件),这是每次CNT溢出时自动触发的。
  • DMA方向选MemoryToPeripheral,外设地址固定为要更新的CCR寄存器,内存地址指向占空比数组。
  • DMA模式选Circular循环,内存地址递增,外设地址不递增。
  • 数据宽度Memory和Peripheral都选Half Word(16位),因为CCR是16位寄存器。

实际效果就是:CPU配置好之后完全不用管,PWM占空比会自动按数组里的值走完一遍又一遍。我做呼吸灯时就把256级渐变数组放进去,主循环完全空闲。这个方案也有一个代价:DMA在搬运时会占用一点总线带宽,但对

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询