公司动态
STM32C542上PWM频率与占空比在线调整的完整工程指南
把PWM输出做到频率和占空比都能随时调整很多人以为只是改两个寄存器的事但在STM32C542上实测下来还是有几处地方需要格外留神。这颗基于Cortex-M33内核的MCU定时器外设和F1/F4/G4系列同源又不完全一样不少习惯被直接搬过来会翻车。这篇文章记录我基于STM32CubeMX和HAL库从一个普通TIM生成PWM到实现频率、占空比在线可调的全过程包括CubeMX配置、HAL接口、运行中更新参数的坑最后给出一套能直接套用的控制接口顺便聊聊什么时候该外配PCA9685这类PWM芯片。1. 动CubeMX之前先把PWM的数学关系算清楚1.1 定时器怎么产生一束PWM波PWM的底层逻辑其实就是定时器在不停地数数。STM32C542的定时器内部有一个计数器CNT它会按设定的时钟一步一步往上加加到某一个设定值之后归零再重新开始。这个“设定值”就是自动重载寄存器ARR。CNT每完成一趟从0到ARR的计数PWM就走过一个完整的周期。输出引脚的电平翻转则交给比较寄存器CCR。计数器在从0往上数的过程中一旦CNT小于CCR输出有效电平当CNT大于等于CCR输出就翻成无效电平。于是引脚上形成高低电平交替的方波这就是脉冲宽度调制信号。我见过不少初学者改PWM参数时完全靠试占空比不对就改Pulse频率不对就改Prescaler改来改去也不知道哪个参数影响了谁。其实只要把三个寄存器之间的关系看清后面所有操作都是算术题不是玄学。简单说ARR决定PWM的周期也就是频率CCR决定一个周期内高电平持续的计数个数也就是占空比PSC是预分频器它决定计数器数数的“步长”同样会影响频率。1.2 三条公式和分辨率边界对于STM32常见的向上计数、边沿对齐PWM模式核心公式就三条PWM频率 定时器时钟 / ((PSC 1) * (ARR 1)) 占空比 CCR / (ARR 1) 分辨率 1 / (ARR 1)注意ARR和CCR都要加1因为计数器是从0开始数的。很多人第一次算频率时忘记加1算出来的值和示波器差了一截就是这个原因。举个例子假设定时器输入时钟是64MHz如果我想输出10kHz的PWM可以选PSC 63ARR 99那么频率 64,000,000 / ((631)*(991)) 64,000,000 / 6400 10,000Hz。如果此时CCR设为50占空比就是50/(991)50%一个周期中高电平占50个计数周期。如果CCR设为25占空比就是25%。这个关系非常直白。还有一个容易被忽略的边界当CCR等于ARR时占空比并不是100%而是ARR/(ARR1)也就是少了最后一个时钟的高电平。想要真正的0%或100%需要把CCR设为0或者把CCR设得比ARR还大让比较器永远不会触发翻转。这个细节在电机控制里很重要后面我会专门聊。1.3 先给STM32C542的定时器资源分个工STM32C542不只是一个定时器它同时有高级定时器、通用定时器可能还有低功耗定时器。不同定时器的能力不同PWM输出的场景也不同定时器类型典型用途注意点高级定时器电机驱动、半桥/全桥互补PWM支持死区、刹车、互补输出通用定时器LED呼吸灯、蜂鸣器、调速用的普通PWM大多数情况下够用低功耗定时器低功耗模式下的常亮指示、低频PWM时钟源和工作模式与普通TIM不同如果只是做一个简单的LED呼吸灯用通用定时器就够了。如果要做H桥电机驱动我建议用高级定时器因为必须有互补PWM和死区保护否则上下桥臂容易直通烧管子。STM32C542具备这类外设但你需要提前规划引脚别等到画板之后发现通道和电机驱动芯片不对应。2. CubeMX里把PWM输出“点”出来注意别漏掉这三个选项2.1 时钟树上的定时器分频很多人习惯性忽略CubeMX左侧的Clock Configuration直接跳到Pinout界面去选PWM通道。这样看起来很快实际埋了不少雷。定时器时钟并不是简单等于系统主频。在STM32C542上APB1和APB2上的定时器时钟倍频规则不太一样如果APB预分频不为1那么定时器时钟一般是APB时钟的2倍。所以你在Clock Configuration里看到一个外设时钟是32MHz定时器时钟可能是64MHz要以上面那个Timer Input Clock为准。我自己调试时会特意在CubeMX里点开Clock Configuration找到对应定时器所在的APB总线记下定时器输入时钟数值再在代码里用这个数值去算PWM。不要靠猜否则后面频率怎么调都对不上。2.2 PWM参数怎么填在Pinout界面选择一个定时器通道例如TIM2的CH1把Mode设置为PWM Generation CH1。然后进入Parameter Settings重点看这几个填法Counter Mode选Up最常用的向上计数模式。Prescaler即PSC先设成63。Counter Period即ARR先设成99。Auto Reload Preload建议设为Enable。Output Compare Preload建议设为Enable。Pulse即CCR先设成50。为什么我建议把两个Preload都打开因为这样可以避免PWM波形在运行中被写寄存器时出现“中间态”。比如你在一个PWM周期进行到一半时改变了CCR值如果不开预装载输出可能立刻跳变产生一个很窄或很宽的异常脉冲。对于LED无所谓但对于电机或可控硅这种异常脉冲会带来噪声甚至损坏。开了预装载之后写入的ARR和CCR会先在“预装载寄存器”里暂存等到下一次更新事件真正生效这样每次改变都发生在周期的边界上波形不会出现撕裂。2.3 GPIO复用模式与输出速度PWM通道对应的引脚在CubeMX里要选成Alternate Function Push Pull不是普通的Output Push Pull。这个很多人会搞错结果发现引脚上始终没有波形。还有一处容易被忽略GPIO Output Speed。STM32C542的引脚输出速度分为Low、Medium、High、Very High几档。低速PWM比如1kHz舵机信号Medium或High就够不需要Very High。如果频率很低的PWM却选了Very High上升沿会很陡反射和EMI问题会放大反过来如果做几百kHz的高频PWM输出速度太低会导致边沿变缓波形从方波变成梯形波影响驱动效果。我一般默认选High然后根据实测波形再调整。如果手头有示波器看一下上升沿和下降沿如果边沿时间超过周期时间的10%就继续提高输出速度。3. 生成代码之后HAL库的PWM启动其实有三步3.1 为什么调用HAL_TIM_PWM_Start之后波形才出现CubeMX生成代码之后定时器虽然初始化了但PWM并不会自动输出。原因是HAL库把PWM的输出过程分成了两步第一步是MX_TIM2_Init()它配置定时器的计数模式、分频、周期但此时输出通道还没有真正打开第二步是调用HAL_TIM_PWM_Start(htim2, TIM_CHANNEL_1)把PWM输出使能同时启动计数。我在刚开始用HAL库时经常只看到代码自动生成了MX_TIM2_Init()就以为定时器已经在输出PWM结果示波器上什么都没看到。后来才发现MX_TIM2_Init()只负责“计时器”不负责“输出PWM”。正确的启动顺序是这样MX_TIM2_Init(); HAL_TIM_PWM_Start(htim2, TIM_CHANNEL_1);如果你用的是高级定时器可能还要额外调用HAL_TIM_PWM_Start之后再调用HAL_TIMEx_PWMN_Start来启动互补通道。这也是为什么我之前说高级定时器不是随便拿来就能用的。3.2 在线改占空比CCR才是真正的主角需要在线调整占空比时大多数情况下只需要改CCR。HAL库提供了两种方式/* 方式一直接写寄存器 */ __HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_1, 75u); /* 方式二调用HAL封装 */ HAL_TIM_PWM_SetPulse(htim2, TIM_CHANNEL_1, 75u);两者本质一样都是往捕获比较寄存器CCR里写值。我习惯用__HAL_TIM_SET_COMPARE因为它更轻量在中断服务函数里调用也不会带来额外负担。这里有一个关键点如果你在CubeMX里开启了Output Compare Preload那么写入CCR的值不会立刻生效要等下一个更新事件。如果你期望“我写进去下一瞬间波形马上变”就会觉得好像没反应。实际这是正常现象不是BUG。只要你设计时意识到“当前周期跑完后才会变”完全可以用这个特性来避免波形毛刺。3.3 在线改频率PSC和ARR的配合以及影子寄存器陷阱频率调整比占空比调整要麻烦因为需要同时处理PSC和ARR两个寄存器。只改ARR会改变频率但也会导致占空比变化只改PSC会影响更多因为PSC影响计数步长。我封装了一个函数思路是先根据目标频率算出需要的总计数个数然后找一个合适的预分频值让ARR尽量落在一个合适的范围内再回算CCR保证占空比不变#define PWM_TIMER_CLOCK_HZ 64000000u // 以实际定时器时钟为准 void PWM_SetFreqDuty(TIM_HandleTypeDef *htim, uint32_t channel, uint32_t freq_hz, float duty) { if (freq_hz 0u) return; if (duty 0.0f) duty 0.0f; if (duty 1.0f) duty 1.0f; uint64_t ticks_per_period PWM_TIMER_CLOCK_HZ / freq_hz; uint32_t psc 0u; uint32_t arr; uint32_t ccr; /* 根据最大ARR65535找到一个合适的PSC保证ARR在16位范围内 */ psc (uint32_t)((ticks_per_period - 1u) / 65535u); if (psc 65535u) psc 65535u; arr (uint32_t)(ticks_per_period / (psc 1u)) - 1u; if (arr 65535u) { psc; arr (uint32_t)(ticks_per_period / (psc 1u)) - 1u; } ccr (uint32_t)((float)(arr 1u) * duty); __HAL_TIM_SET_PRESCALER(htim, psc); __HAL_TIM_SET_AUTORELOAD(htim, arr); __HAL_TIM_SET_COMPARE(htim, channel, ccr); __HAL_TIM_SET_COUNTER(htim, 0u); htim-Instance-EGR | TIM_EGR_UG; // 触发更新事件 }注意最后一行EGR | TIM_EGR_UG这一步很多人会漏。PSC和ARR都有影子寄存器直接写寄存器并不会立刻加载到实际工作寄存器中必须产生一次更新事件把预装载值搬运进去。如果不触发你可能会发现频率还是旧值等计数器自然溢出一两个周期后才会变。另外一个经验是在改完参数后把计数器清零再触发更新事件避免当前计数值已经超过新ARR导致第一个波形异常。4. 实测中的“看起来不对劲”多半是这几个原因4.1 波形频率和计算值差了一个系数如果你用示波器测出来PWM频率和理论计算值差两倍或者更多先别怀疑公式大概率是定时器时钟取值不对。CubeMX里看到的APB时钟和定时器时钟可能不同。比如APB1分频后是32MHz但定时器时钟可能是64MHz。如果你在代码里用了HAL_RCC_GetPCLK1Freq()但没做倍频处理那么计算结果就会差一半。我排查这类问题时会先用一个已知的低频参数测试比如把PSC设成0ARR设成999用示波器读频率再反推定时器实际时钟。这样能快速确认当前定时器时钟到底是多少之后再改代码里的宏定义。4.2 改了CCR但占空比没变出现这种情况先检查两件事第一是不是开启了输出比较预装载导致写入的值要等下一个周期第二是不是代码写错了通道比如初始化的是TIM2_CH1改占空比却写到了TIM2_CH2。还有一个小概率问题如果你在中断里同时调用了HAL_TIM_PWM_Start之类的函数它内部有临界区保护可能会把状态搞乱。一般初始化完成后运行时只写CCR、ARR、PSC这类寄存器就好不要反复调用Start函数。4.3 示波器测量PWM时容易误判的细节示波器不是万能的测量PWM时也有不少坑。第一地线夹太长会形成环路电感导致测出来的上升沿有明显振铃不是芯片的问题是探头的问题。第二采样率不够时高频PWM会出现混叠让波形看起来时快时慢。第三探头的带宽太差几十MHz的高频PWM会被严重衰减。所以在验证STM32C542的PWM输出时我建议用短地线探头打到1:1或10:1要心里有数先测一个几百Hz的低频PWM确认幅值再逐步提高频率。不要一上来就调几百kHz否则你会怀疑是自己代码写错了其实是测量方法的问题。4.4 频率调高后占空比分辨率会变差ARR越大占空比步进越小ARR越小占空比步进越大。比如定时器时钟64MHzPWM频率10kHz时ARR约为6399占空比分辨率大约是1/6400调整起来非常平滑但如果频率提高到1MHzARR只剩63那么占空比只能按约1/64的步进调整也就是1.5%左右一跳。这个物理限制决定了PWM并不是“频率越高越好”。如果你需要高频PWM同时对占空比精度要求极高普通定时器的16位ARR可能不够用就得考虑带高分辨率定时器的型号或者专用的开关电源PWM控制器。这也是为什么功率变换领域会专门看HRTIM这类外设。5. 从固定输出到在线可调封装一套PWM控制接口5.1 一个通用PWM控制模块的设计思路不要把PWM参数调整逻辑散落在各个业务代码里。我通常会在项目里定义几个简单接口void PWM_SetFreq(TIM_HandleTypeDef *htim, uint32_t channel, uint32_t freq_hz); void PWM_SetDuty(TIM_HandleTypeDef *htim, uint32_t channel, float duty); void PWM_SetFreqDuty(TIM_HandleTypeDef *htim, uint32_t channel, uint32_t freq_hz, float duty);其中PWM_SetFreqDuty负责底层计算和寄存器写入另外两个接口内部也调用它。这样你在应用层只需要关心“我要多少频率、多少占空比”不需要关心ARR和CCR怎么算。如果项目里同时用了多个定时器建议在接口里把定时器句柄和通道也一起传进去。不要搞一堆TIM2_开头的函数后面换到TIM3时改代码会很痛苦。5.2 加减速场景下的平滑调频如果你只是把PWM频率从1kHz直接改到10kHz虽然理论上代码没问题但在电机驱动或扬声器场景下会听到明显的“Pop”声或者电流冲击。更好的做法是让频率按阶梯逐渐调整每次只变化一小步等到当前PWM周期稳定后再调下一步。这个逻辑不复杂可以用一个定时器中断或者系统Tick来驱动if (target_freq ! current_freq) { if (target_freq current_freq) { current_freq step; if (current_freq target_freq) current_freq target_freq; } else { current_freq - step; if (current_freq target_freq) current_freq target_freq; } PWM_SetFreqDuty(htim2, TIM_CHANNEL_1, current_freq, current_duty); }这里步长step需要根据应用场景调整。步长太大依然会有冲击步长太小响应太慢。我做电机调速时一般每10ms调整一次每次变化不超过实际频率的5%体感会比较自然。5.3 高级定时器的互补PWM、死区和刹车保护如果你用来驱动半桥或全桥普通PWM不够必须使用高级定时器的互补输出。STM32C542的高级定时器在CubeMX里配置很简单选择TIM1或TIM8的通道后把Mode设置为PWM Generation CH1 CH1N然后设置死区时间。死区时间的含义是同一组上下桥臂的驱动信号都保持一段时间的低电平防止上桥臂还没完全关断、下桥臂已经打开形成直通短路。STM32的Dead Time单位是定时器时钟周期具体多少要根据驱动芯片的延时和MOS管的关断时间决定。常见的做法是先保守一点比如主板工作在64MHz定时器时钟时先设1us左右再用示波器看尖峰逐步减小。高级定时器还支持刹车输入也就是硬件故障保护。一旦刹车引脚触发PWM输出会被硬件强制拉到安全电平不需要软件干预。这个功能在电机项目中非常重要一定要接不要偷懒。6. 有些场景我不直接开定时器而是外扩PCA96856.1 多路独立PWM到底用MCU还是外扩芯片STM32C542再多定时器通道数量终归有限。如果你要控制十几个舵机或者几十路LED定时器资源会很紧张而且每路PWM都需要单独维护频率和占空比代码复杂度会直线上升。PCA9685是一个通过I2C控制的16路PWM芯片内部有独立输出寄存器占空比用12位分辨率控制。它的好处是MCU只需要通过I2C写几个寄存器就能同时控制16路输出非常适合舵机群、LED阵列这类应用。但PCA9685也有局限性。它支持的最高PWM频率不像MCU定时器那么高很多应用场景下只有1kHz到1.5kHz左右而且没有高级定时器那样的死区控制。所以如果你要做电机驱动还是老老实实用STM32C542的定时器如果只是控制舵机或灯光外扩PCA9685更合适。6.2 从软件PWM到模拟电压转PWM的思路有些场景不需要MCU直接输出PWM而是用一个电压转PWM芯片让MCU输出模拟电压或者动态基准芯片负责把电压转换成对应占空比的PWM。这样做的好处是PWM的精度和稳定性由专用芯片保证MCU只负责“给目标值”不参与高频翻转。比如温度控制、恒流驱动这类应用用PID调节后得到一个控制电压再通过电压转PWM芯片产生加热丝或LED驱动的PWM信号稳定性会比纯软件PWM更好。不过这个方案需要额外硬件成本并且要校准电压与占空比的对应关系不是所有项目都划算。6.3 低频用LPTIM高频用HRTIM不要指望一个定时器通吃STM32C542这类MCU上低功耗定时器可以在MCU进入低功耗模式后继续输出PWM适合做低频的指示灯呼吸、传感器激励等。低功耗定时器的时钟源通常来自LSI或LSE频率不高不适合高频输出。反过来如果你做的项目是开关电源、逆变器之类需要几百kHz甚至MHz级别的PWM且占空比精度要求极高那么普通TIM的16位比较器就会力不从心。这时候要选带HRTIM高分辨率定时器的型号或者外部专用PWM控制器。STM32C542定位是通用嵌入式控制不是高频电源专用SOC选型时要想清楚。最后说一点我自己的操作习惯我会在调PWM之前先用示波器确认定时器输入时钟再把预分频和自动重载值在计算器里过一遍算出来和示波器读到的对得上再写代码。频率和占空比在线调整的代码我也习惯把“更新参数触发更新事件”封装成一个函数而不是一堆宏散落在各个地方。这样后面做电机闭环、LED呼吸灯都能省很多事。如果你也在STM32C542上做PWM建议先拿最简单的LED呼吸灯验证再上电机驱动——前者波形错了不炸机后者可能真的会炸。