STM32 PWM输出实战:频率与占空比计算及HAL库实现
1. 项目整体设计与PWM基础拆解1.1 为什么PWM在嵌入式开发里这么重要做嵌入式开发的人迟早都要跟PWM打交道。PWM全称是Pulse Width Modulation脉冲宽度调制本质上是把一个方波信号的高电平持续时间脉宽按照一定规律拉长或者缩短从而改变这个信号的平均电压或功率。别看它原理简单实际应用极其广泛驱动LED亮度变化、电机调速、舵机角度控制、蜂鸣器发声、开关电源稳压甚至通信协议里的红外遥控解码背后都是PWM在干活。这次以STM32C5A3R为例来做PWM输出并不是因为这颗芯片特殊恰恰相反STM32系列的定时器PWM功能基本是一脉相承的。C5系列是ST面向工业控制推出的高性价比Cortex-M33内核产品主频可以跑到250MHz片内Flash和SRAM配置也比较灵活。它搭载的定时器资源跟F系列、G系列有很多相似之处所以你在STM32C5A3R上学到的PWM用法迁移到其他系列的STM32上几乎零成本。1.2 拆解PWM信号的关键参数在动手写代码之前必须先把PWM信号的两个核心参数搞透频率和占空比。频率决定了PWM波形的刷新快慢。比如1kHz的PWM一秒钟输出1000个完整的方波周期而占空比描述的是高电平时间占整个周期的百分比。所以一个周期10ms、高电平2ms的PWM占空比就是20%等效平均电压约为3.3V乘以0.2等于0.66V以3.3V供电计算。这里有个生活化的类比想象你用一盏灯做开关实验如果每秒开关50次人眼已经看不出闪烁了灯看起来是“常亮”的。你改变“开”的时间占比比如30%时间开、70%时间关这盏灯的亮度就会比50%开、50%关的时候暗一些。PWM控制LED亮度就是这个道理控制电机转速也一样只是电机对频率更敏感。选择PWM频率有几个经验值可以记一下LED呼吸灯常用1kHz到10kHz高了肉眼看不到闪烁太低会有明显跳动直流电机调速常用10kHz到20kHz太低能听到电机的滋滋声太高会增加MOS管的开关损耗舵机控制则是固定的50Hz对应20ms周期脉宽0.5ms到2.5ms映射到0度到180度。2. 硬件准备与开发环境搭建2.1 对应的引脚和定时器资源STM32C5A3R内部有多个通用定时器TIM2、TIM3、TIM4、TIM5这类和高级定时器TIM1、TIM8这类。通用定时器输出PWM最简单高级定时器多出了互补输出和死区插入等功能这主要用于电机控制中的H桥驱动场景。以TIM3为例它挂在APB1总线上有4个独立通道CH1到CH4每个通道可以映射到不同的GPIO引脚。在使用C5A3R时我常用的引脚分配如下定时器通道默认引脚映射复用功能TIM3_CH1PA6AF2TIM3_CH2PA7AF2TIM3_CH3PB0AF2TIM3_CH4PB1AF2这里要特别提醒一句不同封装的STM32C5A3R引脚映射会有差异。我的板子是LQFP64封装如果你用的是LQFP48或者更小的封装务必查阅对应的数据手册看看PA6、PA7、PB0、PB1这些引脚是否被封装引出。不少新手拿到芯片直接对着例程写代码却发现引脚没有波形输出排查半天才发现是复用功能没配置对或者引脚根本没引出。2.2 开发工具与初始化配置开发STM32C5A3R我习惯用STM32CubeMX先生成工程骨架再在IDE里补业务代码。打开CubeMX选中STM32C5A3R这颗型号配置RCC时钟树推荐使用HSE外部晶振倍频到250MHz主频然后找到TIM3定时器。在TIM3的配置页面里做这几件事Mode选择Internal Clock内部时钟源Channel1选择PWM Generation CH1让通道1工作在PWM输出模式Prescaler预分频设置决定PWM频率的基础Counter Period自动重装值配合预分频共同决定频率Pulse脉冲宽度决定占空比GPIO配置页面里把PA6引脚设成复用推挽输出复用功能选择AF2时钟树、定时器参数和GPIO配置完成后生成工程代码。CubeMX会自动生成MX_TIM3_Init初始化函数和HAL_TIM_PWM_Start启动函数接下来只需要自己写频率和占空比的修改逻辑。3. 核心代码实现输出PWM波形3.1 频率和占空比的计算逻辑写代码前先弄懂PWM频率的计算公式。STM32定时器的时钟源经过预分频器分频后喂给计数器计数器从0数到自动重装值ARR再归零重新开始。所以频率公式为PWM频率 定时器时钟 / (预分频值 1) / (自动重装值 1)比如定时器时钟是250MHz如果预分频值设为249那么分频后的计数时钟就是250MHz除以250等于1MHz。如果自动重装值设为999计数周期就是1000个计数时钟PWM频率就是1MHz除以1000等于1kHz也就是每秒1000个周期。占空比的计算公式是占空比 捕获比较值CCR / (自动重装值 1) × 100%还是上面的例子如果CCR设为500占空比就是500除以1000等于50%。如果设为250占空比就是25%。可以通过动态改变CCR来调节占空比这就是后文要讲的PWM调整核心机制。3.2 HAL库实现PWM初始化和输出用HAL库操作PWM非常简单。工程生成的初始化代码会包含定时器时基初始化和PWM输出初始化两层配置可以先看一眼MX_TIM3_Init的典型内容static void MX_TIM3_Init(void) { TIM_OC_InitTypeDef sConfigOC {0}; TIM_MasterConfigTypeDef sMasterConfig {0}; htim3.Instance TIM3; htim3.Init.Prescaler 249; htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 999; htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_PWM_Init(htim3); sMasterConfig.MasterOutputTrigger TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(htim3, sMasterConfig); sConfigOC.OCMode TIM_OCMODE_PWM1; sConfigOC.Pulse 500; sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; sConfigOC.QuickPulseEnable TIM_OCQUICKPULSE_DISABLE; HAL_TIM_PWM_ConfigChannel(htim3, sConfigOC, TIM_CHANNEL_1); HAL_TIM_MspPostInit(htim3); }几个关键配置我展开说一下Prescaler为249来源是250MHz除以250得到1MHz的计数时钟Period为999配合1MHz计数时钟得到1kHz的PWM频率Pulse为500占空比正好50%OCMode选PWM1模式含义是计数器值小于CCR时输出高电平大于等于CCR时输出低电平AutoReloadPreload设为ENABLE让自动重装值在更新事件时才生效防止运行中修改ARR值导致波形异常初始化完成后在main函数里启动PWM输出HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1);启动之后用示波器看PA6引脚应该能直接看到1kHz、占空比50%的方波。这一步能顺利跑通PWM开发就成功了八成。3.3 标准外设库的老写法参考有些朋友还在用标准外设库开发这里也补一份传统写法的核心代码方便对照理解寄存器的操作逻辑TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); GPIO_PinAFConfig(GPIOA, GPIO_PinSource6, GPIO_AF_TIM3); GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd GPIO_PuPd_UP; GPIO_Init(GPIOA, GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Prescaler 249; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period 999; TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 500; TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM3, TIM_OCInitStructure); TIM_Cmd(TIM3, ENABLE);这段代码做的事情跟HAL库完全一致只是操作方式更底一层。理解了标准库写法再看HAL库内部的寄存器操作会非常通透。4. 频率与占空比的动态修改方案4.1 修改占空比的核心方法PWM跑起来之后真正实用的场景是需要动态修改参数。占空比的修改最简单因为占空比就是比较值CCR决定的。HAL库提供了一行配置函数__HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, 750);把CCR从500改成750之后占空比立刻变成75%750/1000。如果你想实现LED渐亮渐暗的呼吸灯效果只需要在while循环里不断改变这个值uint16_t duty 0; int8_t dir 1; while (1) { __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, duty); duty dir; if (duty 999) dir -1; if (duty 1) dir 1; HAL_Delay(2); }这段代码让占空比从0.1%逐步增加到99.9%再逐步降低形成一个平滑的呼吸灯效果。每2毫秒更新一次CCR肉眼看到的就是柔和渐变没有闪烁感。实际验证下来CCR值的增加步长和延时时间会影响呼吸效果的质量步长太小呼吸太慢步长太大会有明显的阶梯感2毫秒延时加1单位步长是比较舒服的组合。4.2 修改频率的两种路径频率修改比占空比稍微麻烦一点因为频率同时由预分频PSC和自动重装值ARR共同决定。有两种修改策略第一种是只改ARR保持PSC不变。这种情况下的频率调整粒度取决于计数器时钟。比如之前的配置里计数时钟是1MHzARR从999改到1999频率就从1kHz变成500Hz。但这种方案的调节精度会受限于计数时钟如果你想精细调整到1.1kHz直接改ARR可能做不到很准。第二种是同时改PSC和ARR获得更灵活的调频范围。HAL库中修改预分频的宏是__HAL_TIM_SET_PRESCALER(htim3, 499);改完预分频后计数时钟变成250MHz除以500等于500kHz配合ARR仍为999输出频率就是500Hz。这种方案适合需要大范围调整频率的场景比如从1kHz切到10kHz。修改ARR和PSC时要注意自动重装值在计数器递增过程中被修改可能导致当前计数周期提前溢出或者延后溢出产生的波形有一个毛刺周期。规避办法是先停止定时器输出改完参数后再重新启动或者等待当前周期自然结束后再修改。CubeMX初始化里开启的AutoReloadPreload就是为了让ARR在更新事件时才生效可以显著减少异常波形。4.3 完整示例按键调速调占空比下面给一个更完整的实战例子用两个按键一个调节占空比一个切换频率档位。这样可以直接在实际项目中验证效果。先写频率档位表typedef struct { uint16_t prescaler; uint16_t period; } pwm_config_t; const pwm_config_t freq_table[] { {249, 999}, // 1kHz {124, 999}, // 2kHz {49, 999}, // 5kHz {24, 999}, // 10kHz {9, 999}, // 25kHz };按键处理逻辑如下extern TIM_HandleTypeDef htim3; uint8_t freq_index 0; uint16_t current_duty 500; while (1) { if (KEY1_Pressed()) { current_duty 50; if (current_duty 999) current_duty 999; __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, current_duty); } if (KEY2_Pressed()) { freq_index; if (freq_index 5) freq_index 0; __HAL_TIM_SET_PRESCALER(htim3, freq_table[freq_index].prescaler); __HAL_TIM_SET_AUTORELOAD(htim3, freq_table[freq_index].period); __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, (uint16_t)(freq_table[freq_index].period * current_duty / 999)); } HAL_Delay(20); }这段代码里有个容易忽视的细节切换频率后占空比的绝对CCR值必须重新计算。因为占空比是CCR除以ARRARR变了相同CCR对应的占空比就不一样了。所以要先把当前占空比换算成百分比再乘以新的ARR值才能保持切换前后占空比不变。这个坑我实际踩过直接沿用旧CCR会导致LED突然变亮或者电机转速突变。4.4 PWM波形的中心对齐模式通用定时器默认是边沿对齐模式计数器从0数到ARR再归零。高级定时器还支持中心对齐模式计数器先从0数到ARR再从ARR递减到0一个完整的PWM周期包含上升和下降两段。中心对齐模式有个显著的工程价值输出波形的对称性好谐波失真小。在电机控制中中心对齐模式产生的PWM中心点正好对应电流采样的最佳时刻。很多做电机控制的同行问我为什么采样电流总是有毛刺排查到最后发现是边沿对齐模式下采样点和开关噪声重叠了换成中心对齐模式后问题直接消失。配置中心对齐非常简单htim3.Init.CounterMode TIM_COUNTERMODE_CENTERALIGNED1;需要留意的是中心对齐模式下PWM频率计算会略有不同。因为一个完整周期包含两次计数轮回上行和下行在相同PSC和ARR配置下中心对齐模式的实际PWM频率是边沿对齐的一半。如果原来边沿对齐是1kHz改成中心对齐后测出来就是500Hz想要维持1kHz需要把ARR减半。这个换算关系务必记牢。5. 常见问题与排查技巧实录5.1 引脚没有波形输出的检查清单这是新手遇到最多的问题辛辛苦苦写完代码示波器探头一搭发现引脚干干净净没有一点波形。我按照排查优先级列个清单检查GPIO复用功能配置。PA6在C5A3R上必须配置为AF2且选择复用推挽输出模式。很多人只配置了GPIO输出模式没有选择复用功能这样定时器信号根本传不到引脚上检查定时器时钟是否使能。在CubeMX生成的代码中HAL_TIM_PWM_Init内部会调用定时器时钟使能但如果手动修改过RCC配置可能把时钟关掉检查PWM启动函数是否执行。有一个很容易忽略的细节高级定时器TIM1/TIM8输出PWM时必须额外调用HAL_TIM_PWM_Start函数同时还要使能主输出也就是调用HAL_TIMEx_PWMN_Start或操作BDTR寄存器的MOE位。通用定时器TIM3/TIM4则没有这个问题。很多从TIM3迁移到TIM1的开发者会栽在这里检查引脚是不是被其他外设占用。用万用表量一下引脚对地电阻如果被拉低很可能是板载外设占用需要把跳线帽拔掉5.2 占空比不准的原因排查输出波形有了但占空比偏差很大比如设置50%实测却是48%可以从两个方向排查。第一是计数器的预装载问题。AutoReloadPreload关闭时修改ARR会立即生效如果此时计数器已经计到了比新ARR更高的值计数器会先溢出一次产生一个异常宽或者异常窄的脉冲。将预装载开启后ARR在更新事件时才生效波形会更稳定。第二是pulse参数类型问题。HAL库里的Pulse是uint32_t类型传入超出ARR的值不会报错但输出会变成100%占空比。反之如果传入0输出就是0%。对于新手来说写一个值域检查的封装函数很有必要void set_pwm_duty(TIM_HandleTypeDef *htim, uint32_t channel, uint16_t arr, uint16_t duty_percent) { uint32_t ccr (uint32_t)arr * duty_percent / 100; __HAL_TIM_SET_COMPARE(htim, channel, ccr); }这个封装把百分比转换成CCR值并且自动处理边界不会再出现占空比超过100%的异常情况。5.3 PWM频率对电机控制的影响PWM频率不是越高越好也不是越低越好需要结合具体负载来选。做直流电机调速时频率太低比如几十Hz会导致电机转动一顿一顿的还可能发出明显的噪声。频率太高超过50kHz虽然安静但MOS管的开关损耗明显增大同时电机电感感抗变大电流纹波虽然小但平均电流会下降电机转速反而上不去。我用10kHz到20kHz这个区间做直流电机调速实测效果最好兼顾了噪声、效率和可控性。舵机控制又是一个极端普通航模舵机的控制频率固定为50Hz脉宽范围0.5ms到2.5ms。如果你的PWM频率不是50Hz舵机要么乱转要么发热严重。这里需要单独配置一组定时器参数PSC使得计数时钟为1MHzARR设为19999周期20msCCR设在500到2500之间对应0.5ms到2.5ms脉宽就可以精确控制舵机角度。5.4 死区时间与H桥电路如果PWM输出用于驱动H桥电路比如控制直流电机的正反转就要特别关注死区时间。H桥同一桥臂的上下两个MOS管如果同时导通会直接短路烧毁电路。解决方法是让上下管导通之间留一段空白时间这就是死区时间。高级定时器TIM1/TIM8的死区配置在CubeMX的Break and Dead Time配置页面里填入纳秒数即可。死区时间的大小取决于MOS管的关断延迟时间常见的取值在500ns到2us之间。死区太小起不到保护作用太大则会导致输出波形畸变电机换向时会有明显的顿挫感。实测中我用的是IR2104半桥驱动芯片配IRF540N MOS管死区设置在1us比较合适。用示波器双通道同时看上下管的栅极波形保证同一时刻只有一管导通并且过渡瞬间有清晰的时间间隔。5.5 移植到其他STM32型号的注意事项STM32C5A3R的PWM代码移植到F103、F407或者G474这些芯片上大部分代码可以复用但有几个地方必须按芯片手册调整。首先是引脚复用映射。不同系列甚至同系列不同封装的芯片同一个定时器通道对应的GPIO引脚都不一样。F103的TIM3_CH1是PA6F407也是PA6但G474可能就是PE2了。必须查阅对应芯片的引脚定义表确认复用功能编号。其次是定时器所在的APB总线不同APB1和APB2的时钟频率往往不一样直接沿用PSC值会导致频率偏移。最后是中断向量和DMA流编号这部分差异更大移植后要逐一检查。建议的做法是把PWM初始化、占空比设置、频率设置封装成独立模块只暴露接口函数。移植时只改底层配置和引脚映射业务逻辑代码一行都不用动。这种模块化的思路在多个项目间复用代码时能省下大量时间。6. 实测心得与后续扩展思路6.1 调试工具准备建议做PWM开发示波器是刚需。没有示波器的话很多问题只能靠猜效率极低。如果没有示波器条件可以用逻辑分析仪先顶上廉价的8通道逻辑分析仪采样PWM波形完全够用。再不行用万用表测平均电压也能大概判断占空比是否生效——万用表直流档测出来的电压约等于供电电压乘以占空比这个间接验证法在现场救过我不少次。LED呼吸灯、有源蜂鸣器这类纯数字负载直接用万用表没问题但电机驱动类项目负载是感性的波形质量直接影响控制效果还是建议备一台示波器。6.2 从PWM到更高阶的扩展方向PWM跑通之后可以尝试几个进阶方向用DMA配合PWM输出可以在不占用CPU的情况下更新CCR值实现任意波形生成。比如把一段正弦波数值表放到内存里通过DMA定时搬运到CCR寄存器就得到一个由PWM滤波后近似出来的正弦波。这种技术在逆变器、音频播放、步进电机细分驱动里很常见。用TIM1/TIM8做三相互补PWM输出设置好死区时间和刹车功能可以直接驱动BLDC电机或者永磁同步电机的逆变桥。再加上霍尔传感器或者编码器反馈就是一套完整的电机FOC控制方案。还有一个应用是PWM输入捕获模式定时器不仅可以输出PWM还可以测量外部PWM信号的频率和占空比。遥控接收机输出的就是PWM信号用STM32的输入捕获模式解析遥控器油门通道和方向通道是做航模飞控或者遥控车接收端的基本功。这些扩展方向的底层机制跟本文讲的PWM输出是同一个套路定时器时钟、预分频、自动重装、比较值把这四个参数玩明白了PWM相关的高级应用都能很快上手。6.3 个人操作体会做PWM开发这几年我最大的体会是一定要在动手写代码之前把参数计算清楚。先确定目标频率和占空比反推预分频值和自动重装值再写配置代码。很多人习惯打开CubeMX凭感觉填参数频率不对就乱试一通最后虽然能出波形但对参数之间的约束关系依然糊里糊涂一旦遇到需要精调的场景就无从下手。另外修改PWM参数的代码一定要加上边界保护。嵌入式开发中频率和占空比这类变量非法值导致的故障可能不立刻爆发而是积累到某个临界点才暴露。比如电机控制中如果占空比超出100%变成全开电机会直接全速运转如果带的是机械负载后果可能很严重。所以凡是涉及外部输入的PWM参数都要做限幅处理这是硬性要求。最后再分享一个小技巧调试PWM的时候别急着接真实负载先用LED串联一个1k电阻当观测器。LED的亮度变化非常直观异常情况一眼就能看出来。等LED的表现符合预期了再切换到电机或者其他真实负载这样可以大大减少故障排查的时间。