1. 项目概述:从零到一掌握STM32的PWM输出
搞STM32开发,PWM(脉冲宽度调制)绝对是一个绕不开的核心技能点。无论是驱动舵机让它精准地转到指定角度,还是控制电机的转速实现平滑加减速,甚至是简单地用呼吸灯效果做个设备状态指示,背后都离不开PWM。很多新手朋友一上来就被定时器复杂的预分频、自动重载值、占空比这些概念搞得头晕,在STM32CubeMX里配置一通后,下载程序却发现引脚上死活没有波形,或者波形频率不对,占空比调了没反应,非常打击信心。
我自己在带项目和教学的过程中,发现大家卡壳的地方往往很集中:一是CubeMX里定时器配置参数的理解和计算;二是HAL库那套略显“臃肿”的API到底该怎么正确调用;三是调试时没有波形或波形异常,该如何系统性地排查。网上资料虽然多,但要么过于零散,要么默认你已经会了,缺少一个从原理到配置、再到代码和调试的完整闭环。
这篇内容,我就以最常用的STM32F103系列为例,但原理通用所有STM32型号,带你彻底打通PWM输出的任督二脉。我们不只讲“怎么配”,更要讲清楚“为什么这么配”,以及“配错了怎么办”。目标是让你看完之后,能独立地在CubeMX里配置出任意频率和精度的PWM信号,并用HAL库熟练地控制它,同时具备解决常见问题的能力。
2. PWM核心原理与STM32定时器架构解析
2.1 PWM到底是什么?一个水龙头的比喻
你可以把PWM信号想象成一个高速开关的水龙头。我们无法连续、线性地控制水流的“强度”,但我们可以控制这个水龙头在一个固定周期内打开的时间比例。
- 周期(Period):就是水龙头“开-关”一个完整循环的时间。比如,我们设定这个循环为1秒。
- 脉冲宽度(Pulse Width):就是在这1秒的循环里,水龙头实际打开的时间。比如打开0.3秒。
- 占空比(Duty Cycle):就是脉冲宽度与周期的比值,通常用百分比表示。0.3秒 / 1秒 = 30%。占空比越大,等效的“水流强度”就越强。
对于电机,占空比越大,平均电压越高,转速就越快;对于LED,占空比越大,平均电流越大,亮度就越高。STM32的定时器,就是那个能极其精确地控制这个“水龙头”开关时序的硬件模块。
2.2 STM32定时器如何产生PWM:计数器与比较寄存器
STM32的通用定时器(如TIM1, TIM2, TIM3, TIM4等)和高级定时器,都支持PWM输出模式。其核心硬件是一个计数器(CNT)、一个自动重载寄存器(ARR)和一个捕获/比较寄存器(CCR)。
- 计数器(CNT):它像秒表一样,从0开始,每个时钟周期加1(或减1,取决于计数模式),不停地循环计数。
- 自动重载寄存器(ARR):它决定了计数器的上限。当CNT计数到ARR的值时,就会产生一个更新事件(UEV),然后CNT清零(或从ARR值开始递减),开始下一个周期。ARR的值直接决定了PWM波的频率(周期)。
- 捕获/比较寄存器(CCR):这是我们控制脉宽的关键。我们给CCR设定一个值。
- 比较与输出:定时器硬件会实时比较CNT和CCR的值。
- 当CNT < CCR时,硬件将对应的输出通道(比如CH1)设置为有效电平(高电平或低电平,可配置)。
- 当CNT >= CCR时,则将输出通道设置为无效电平。
- 这样,在一个计数周期内,输出引脚上就会产生一段高电平、一段低电平的方波。CCR的值直接决定了高电平的持续时间,即脉冲宽度。
计算公式(核心):
- 定时器时钟频率(TIMx_CLK):这是定时器工作的“心跳”,来源于系统时钟经过预分频器(APB Prescaler)和可能的倍频器。
- 预分频系数(PSC):定时器内部可以对TIMx_CLK进行进一步分频。分频后的时钟才是驱动计数器CNT累加的时钟。
计数器时钟频率 CK_CNT = TIMx_CLK / (PSC + 1) - PWM频率(Fpwm):
Fpwm = CK_CNT / (ARR + 1)也可以写成:Fpwm = TIMx_CLK / [(PSC + 1) * (ARR + 1)] - PWM占空比(Duty):
Duty = CCR / (ARR + 1)
注意:公式中是
(ARR + 1)和(PSC + 1),这是因为寄存器的值是从0开始计数的。例如,ARR设置为999,意味着计数器从0数到999,总共是1000个计数周期。这一点是很多初学者计算错误的根源。
2.3 STM32CubeMX与HAL库的角色
- STM32CubeMX:它是一个图形化的初始化代码生成工具。我们不需要去手动查阅上百页的参考手册来计算和设置每一个寄存器。只需要在界面上选择引脚、配置时钟树、设置定时器参数(PSC, ARR, PWM模式等),它就能为我们生成完整、正确的初始化C代码,极大降低了入门门槛和出错概率。
- HAL库:它是ST官方提供的一套硬件抽象层库。它用统一的函数接口(如
HAL_TIM_PWM_Start(),__HAL_TIM_SET_COMPARE())封装了对底层寄存器的操作。好处是代码可读性好,在不同STM32系列间移植方便;缺点是执行效率相对直接操作寄存器要低一些,但对于大多数应用来说完全足够。
3. 基于STM32CubeMX的PWM输出配置详解
我们以在STM32F103C8T6的PA8引脚(TIM1_CH1)上产生一个1kHz频率、50%占空比的PWM波为例。
3.1 工程创建与时钟树配置
- 新建工程:打开CubeMX,选择MCU型号STM32F103C8Tx。在
Pinout & Configuration视图,首先配置时钟。 - 配置时钟树(RCC):
- 在
RCC配置中,将High Speed Clock (HSE)选择为Crystal/Ceramic Resonator,因为我们通常使用外部8MHz晶振。 - 进入
Clock Configuration标签页。这是关键一步。STM32F103的APB2总线时钟(APB2 peripheral clocks)上挂载着TIM1。我们需要知道TIM1的时钟频率。 - 通常配置为:HSE 8MHz -> PLL倍频9倍 -> 系统时钟SYSCLK 72MHz -> AHB预分频器不分频 -> APB1预分频器2分频(36MHz),APB2预分频器不分频(72MHz)。对于定时器,如果APB预分频系数不为1,则定时器时钟会倍频x2。所以,此时挂载在APB2上的TIM1,其时钟
TIM1_CLK = APB2时钟 * 2 = 72MHz * 2 = 144MHz。这个144MHz就是上面公式里的TIMx_CLK。CubeMX会在时钟图上清晰地显示出来。
- 在
实操心得:时钟树配置是STM32性能的基石,也是PWM频率计算的基础。务必在CubeMX里确认最终
Timers下面TIMx显示的时钟频率是否正确。如果这里错了,后面所有计算都会错。
3.2 定时器参数化配置
- 引脚配置:在
Pinout视图,找到TIM1,展开通道1,选择PWM Generation CH1。可以看到PA8引脚被自动标记为TIM1_CH1。 - 定时器模式配置:在
Configuration标签页,点击TIM1进入参数设置。- Prescaler (PSC):预分频器值。我们的目标是1kHz,时钟源144MHz太高,需要大幅分频。我们先设定一个目标计数器时钟
CK_CNT。为了让ARR的值设置范围更灵活,通常先确定CK_CNT。例如,我们希望CK_CNT = 1MHz。 计算:PSC = TIMx_CLK / CK_CNT - 1 = 144,000,000 / 1,000,000 - 1 = 143。在CubeMX中填入143。 - Counter Mode:计数模式。选择
Up(向上计数),最常用。 - Counter Period (ARR):自动重载值。现在
CK_CNT=1MHz,要产生Fpwm=1kHz。 计算:ARR = CK_CNT / Fpwm - 1 = 1,000,000 / 1,000 - 1 = 999。在CubeMX中填入999。 - Internal Clock Division (CKD):时钟分频,用于数字滤波,通常保持默认
No Division。 - auto-reload preload:自动重载预装载。建议使能(Enable)。这样对ARR的修改会在下一个更新事件生效,防止当前周期波形出现毛刺。
- Prescaler (PSC):预分频器值。我们的目标是1kHz,时钟源144MHz太高,需要大幅分频。我们先设定一个目标计数器时钟
- PWM通道配置:在下方找到
Channel 1,点击进入PWM Generation CH1配置。- Mode:确认是
PWM Mode 1或PWM Mode 2。两者的区别在于输出极性。PWM Mode 1:CNT<CCR时有效电平(通常为高),反之无效。PWM Mode 2:相反。我们先选PWM Mode 1。 - Pulse (CCR):脉冲值,即初始占空比。我们想要50%占空比,
CCR = (ARR + 1) * 50% = 1000 * 0.5 = 500。填入500。 - Output compare preload:输出比较预装载。强烈建议使能(Enable)。这样对CCR的修改也会在下一个更新事件生效,保证PWM波形的完整性,避免在周期中间改变占空比导致产生不完整的脉冲。
- CH Polarity:通道极性。决定了有效电平是高(High)还是低(Low)。保持默认
High即可。如果后续驱动某些需要低电平有效的电路(如某些LED共阳极接法),可以改为Low。
- Mode:确认是
至此,一个1kHz,50%占空比的PWM硬件配置就完成了。CubeMX会帮我们生成初始化代码,自动将PA8引脚配置为复用推挽输出模式,并初始化TIM1。
3.3 生成代码与工程设置
- Project Manager:转到
Project Manager标签页,设置工程名称、路径、IDE(如MDK-ARM V5)。在Code Generator部分,建议勾选Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral,这样每个外设的代码会独立成对的文件,结构更清晰。 - 生成代码:点击
GENERATE CODE。CubeMX会生成完整的Keil工程(或其他IDE工程)。 - 打开工程:用Keil MDK打开生成的工程。在
main.c的/* USER CODE BEGIN 2 */和/* USER CODE END 2 */之间,是我们添加用户代码的安全区域。
4. HAL库PWM驱动代码编写与调试
4.1 基础驱动:启动与占空比调节
CubeMX生成的代码已经在MX_TIM1_Init()函数中完成了定时器的所有初始化。我们只需要在合适的地方启动PWM输出,并可以动态修改占空比。
/* USER CODE BEGIN 2 */ // 启动TIM1的通道1的PWM输出 HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); // 此时PA8应该已经有1kHz,50%占空比的PWM波了。可以用示波器查看。 // 动态修改占空比为75% __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, 750); // CCR = 1000 * 0.75 = 750 // 或者使用HAL库函数,效果相同 // TIM_OC_InitTypeDef sConfigOC; // sConfigOC.Pulse = 750; // HAL_TIM_PWM_ConfigChannel(&htim1, &sConfigOC, TIM_CHANNEL_1); // HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); /* USER CODE END 2 */代码解析:
HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1);这个函数启动了指定定时器和通道的PWM输出。它内部会启动定时器的计数器。__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, 750);这是一个宏,用于快速设置捕获/比较寄存器CCR的值,从而立即改变占空比(如果预装载使能,则在下次更新生效)。这是最常用、最高效的占空比调节方法。
4.2 高级应用:呼吸灯与舵机控制
呼吸灯实现:原理就是在一个循环里,让CCR的值从0递增到ARR,再递减回0,如此反复。
/* USER CODE BEGIN 2 */ HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); uint16_t pwmVal = 0; int8_t dir = 1; // 方向,1为递增,-1为递减 while (1) { HAL_Delay(10); // 延时10ms,控制呼吸速度 pwmVal += dir; if(pwmVal > 1000) { // ARR是999,CCR最大可设1000 pwmVal = 1000; dir = -1; } else if (pwmVal == 0) { dir = 1; } __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, pwmVal); } /* USER CODE END 2 */舵机控制:常见舵机控制信号是周期20ms(50Hz),高电平脉宽在0.5ms到2.5ms之间,对应0-180度。
- 配置:我们需要重新计算定时器参数,产生50Hz的PWM。
- 假设
TIMx_CLK = 72MHz(使用TIM2,挂在APB1上,36MHz*2)。 - 目标
Fpwm = 50Hz。 - 先设定
PSC = 7199,则CK_CNT = 72,000,000 / (7199+1) = 10,000 Hz。 - 再计算
ARR = 10,000 / 50 - 1 = 199。这样周期就是20ms。 - 此时,CCR值对应的脉宽时间:
Pulse_Time = CCR / CK_CNT。 - 0.5ms对应
CCR = 0.0005 * 10,000 = 5。 - 2.5ms对应
CCR = 0.0025 * 10,000 = 25。 - 因此,控制舵机角度时,只需将CCR值在5到25之间线性映射即可。
- 假设
注意事项:舵机对脉冲宽度要求比较精确,ARR和PSC的配置要保证计算出的
CK_CNT能精确地表示0.5ms和2.5ms这两个时间点,避免使用浮点数计算CCR带来误差。上面的配置中,CK_CNT=10kHz,每个计数代表0.1ms,0.5ms正好是5个计数,非常精确。
4.3 调试技巧与波形观测
- 软件仿真:在Keil中,可以使用逻辑分析仪功能。在
Debug模式下,打开View -> Analysis Windows -> Logic Analyzer。点击Setup,添加要观察的引脚,如PORTA.8。然后全速运行,就能看到模拟的PWM波形,可以测量频率和占空比,非常适合前期验证逻辑。 - 硬件调试:
- 示波器:这是最直接的工具。探头接地线接板子GND,探头接PA8引脚。可以直观看到波形、测量频率/周期、占空比、高电平时间。
- 万用表:用直流电压档测量PWM引脚,你会看到一个介于0V和3.3V之间的平均电压值。
V_avg = Vcc * Duty。例如50%占空比,平均电压大约是1.65V。这可以快速判断PWM是否在工作以及占空比的大致范围。 - LED观察:如果PWM频率较低(比如低于100Hz),接上LED可以看到闪烁。频率高于50Hz后,由于视觉暂留,会看到常亮但亮度不同。这是最简陋的定性判断方法。
5. 常见问题排查与实战经验汇总
5.1 无PWM波形输出
这是最常见的问题。请按以下清单逐一排查:
| 排查步骤 | 可能原因 | 解决方法 |
|---|---|---|
| 1. 引脚复用 | 引脚未正确配置为定时器复用功能。 | 检查CubeMX中该引脚是否显示为TIMx_CHx,并确认生成的GPIO_InitStruct中Mode为GPIO_MODE_AF_PP(复用推挽输出)。 |
| 2. 时钟未使能 | 定时器或对应GPIO端口的时钟未开启。 | CubeMX通常会自动使能。检查main.c中SystemClock_Config()函数和MX_GPIO_Init()函数是否被调用。 |
| 3. PWM未启动 | 忘记调用HAL_TIM_PWM_Start()。 | 在初始化后,确保调用了启动函数。 |
| 4. 主频配置错误 | 系统时钟或定时器时钟配置错误,导致定时器实际未工作。 | 用示波器检查定时器相关引脚(如ETR, 或另一个通道)是否有任何信号。或用调试器查看TIMx->CNT寄存器的值是否在变化。 |
| 5. 引脚冲突 | 该引脚被其他功能(如JTAG/SWD)占用。 | 例如PA15, PB3, PB4默认是JTAG功能。在CubeMX的System Core -> SYS里,将Debug改为Serial Wire,可以释放PB3, PB4。或在Pinout视图直接重映射引脚。 |
| 6. 硬件连接 | 示波器探头接触不良,或测量点错误。 | 确保探头接地良好,测量点确实是MCU的引脚本身,而非虚焊或断线。 |
5.2 波形频率或占空比不对
- 频率不对:一定是
TIMx_CLK,PSC,ARR这三个值计算有误。- 检查时钟树:确认
TIMx_CLK的值。这是根源。 - 复核公式:
Fpwm = TIMx_CLK / [(PSC+1)*(ARR+1)]。用计算器重新计算一遍。 - 检查代码:确认
htimx.Init.Prescaler和htimx.Init.Period的值与CubeMX中设置的一致。
- 检查时钟树:确认
- 占空比不对/不可调:
- CCR值计算错误:占空比 =
CCR / (ARR+1)。确保你设置的CCR值在这个范围内。 - PWM模式选错:检查是
PWM Mode 1还是Mode 2,以及极性Polarity是高还是低。不同的组合会导致有效电平相反。一个简单的测试方法是:设置CCR为0,如果输出常低(Low),则模式1+高极性;如果常高,则可能是模式2或低极性。 - 预装载未使能:如果你在运行时频繁修改CCR或ARR,但未使能
auto-reload preload和output compare preload,可能会导致波形出现异常毛刺或修改不立即生效。
- CCR值计算错误:占空比 =
5.3 高级定时器TIM1/TIM8的特殊配置
高级定时器功能更强大,但也更复杂。用于PWM输出时,必须额外使能主输出(Main Output),否则信号无法输出到引脚。在CubeMX中,高级定时器的配置页有一个Break and Dead-Time Generation选项,通常需要将Lock Level设为Level 1或更高,并勾选Automatic Output enable。在代码中,启动PWM输出后,有时还需要调用__HAL_TIM_MOE_ENABLE(&htim1);来使能主输出。这是使用高级定时器做PWM输出时最容易遗漏的一点。
5.4 资源冲突与优化建议
- 一个定时器多个通道:一个定时器可以产生多个同频率、不同占空比的PWM波(最多4个通道),因为它们共享同一个ARR和PSC。这非常适合控制多个舵机或LED。只需在CubeMX中使能多个通道,然后分别调用
HAL_TIM_PWM_Start(&htimx, TIM_CHANNEL_x)即可。 - 分辨率与频率的权衡:PWM的分辨率(即占空比可调节的精细度)取决于ARR的值。ARR越大,分辨率越高(例如ARR=999,分辨率约0.1%)。但根据公式,在时钟固定的情况下,ARR越大,能达到的最大PWM频率就越低。设计时需要根据应用需求(如电机控制的频率要求、LED调光的平滑度要求)来折中。
- 直接操作寄存器:如果对实时性要求极高(如高频开关电源),可以考虑在HAL库初始化后,直接操作
TIMx->CCRx寄存器来改变占空比,这比调用HAL函数或宏更快。但务必注意确保操作是原子性的,或在安全时机(如更新事件中断)进行。
最后,STM32的PWM功能非常灵活强大,除了基本的输出比较模式,还支持互补输出、带死区插入(用于驱动H桥)、刹车功能等。掌握本文的基础后,再去看这些高级功能的数据手册,就会更有条理。最关键的是动手实践,用示波器去观察每一个配置改变带来的波形变化,这种直观的反馈是学习嵌入式硬件编程最快的方式。遇到问题,就按照“时钟->配置->引脚->代码->硬件”这个链路去排查,大部分问题都能迎刃而解。