
很多人拿到STM32C542这种新片子第一反应都是先去点个灯、跑个串口等真正要用到PWM的时候反而容易卡壳。一方面是因为新内核、新时钟树跟老F1系列差异不小另一方面是PWM这东西看似简单真调起来频率和占空比改不对电机抖、灯闪、蜂鸣器音调不对各种问题全来了。这篇就是接着之前的STM32C542开发系列往下写专门讲怎么用TIM定时器输出PWM以及在实际项目里最常用的两个操作动态修改频率、动态修改占空比。内容基于STM32CubeMX HAL库代码实测可用原理和寄存器层面也会讲清楚适合刚入手C542、或者从F1/F4迁移过来的朋友参考。1. 先说清楚PWM到底是什么C542定时器资源和F1有啥不一样PWM的全称是脉冲宽度调制核心思想就是让一个GPIO口按照固定的周期输出高电平和低电平通过改变高电平在一个周期内占的时间比例来模拟一个连续可变的模拟量。这个比例就是占空比。频率决定的是PWM波形的快慢占空比决定的是平均输出的大小。理解PWM最关键的一点是单片机本身不产生模拟电压它只是非常快地切换高低电平。用一个通俗比喻PWM就像一个人在快速开关水龙头。开关速度就是频率开着的时间占比就是占空比。如果把流出来的水收集起来看平均值占空比50%就是半管水占空比80%就是接近全开。STM32C542属于STM32C5系列内核是Cortex-M33主频可以跑到250MHz定时器资源相比F1系列丰富很多。一般型号都带多个16位高级定时器TIM1/TIM8和通用定时器TIM2/TIM3/TIM4/TIM5等其中TIM2/TIM5是32位定时器。高级定时器还支持互补输出、死区插入和刹车功能这在使用全桥驱动或者H桥驱动电机时是必不可少的。C542的定时器时钟来源在CubeMX里默认配置下通常是APB1或者APB2的定时器时钟。这里有个坑很多人以为APB分频是多少定时器时钟就是多少实际上当APB预分频系数大于1时定时器时钟是APB时钟的2倍详细时序关系在参考手册RCC章节有说明用CubeMX生成代码后也可以通过查看SystemClock配置里的参数确认。2. 用CubeMX配置一个基础PWM输出别在这几个地方翻车在正式写代码之前先把CubeMX的配置捋一遍。这里以TIM1的通道1作为示例因为高级定时器的配置相对完整通用定时器操作基本一致但少几个互补输出选项。新建工程选择STM32C542对应的型号在System Core里先配置RCC使用外部高速晶振HSE时钟树里把主频拉到250MHz。APB1和APB2的预分频器可以根据外设需求设置但要注意给定时器留出的时钟倍数关系。接下来在Timers里找到TIM1勾选Channel1作为PWM Generation CH1如果以后要用到互补输出再接CH1N。参数配置页里几个关键项PrescalerPSC预分频系数实际分频值是PSC1Counter Mode向上计数模式UpCounter PeriodARR自动重装载值决定PWM周期Auto-reload preload建议开启这样修改ARR时会在当前周期结束后才生效避免中间出现异常波形PulseCCR初始占空比对应的比较值PWM频率的计算公式是频率 定时器时钟 / ((PSC1) * (ARR1))。占空比 CCR / (ARR1) * 100%。举个例子假设定时器时钟是250MHz想要输出20kHz的PWM选择一个合理的PSC值让ARR保持在一个16位定时器能表达的范围内。如果取PSC249那么定时器计数频率就是250MHz/2501MHzARR49时周期为50us频率就是20kHz。CCR25时占空比就是50%。CubeMX生成的初始化代码在main函数里调用MX_TIM1_Init但要注意HAL_TIM_PWM_Start这个启动函数不会自动调用必须在用户代码里手动启动定时器的PWM输出。很多新手在这里卡住初始化了却没输出先检查是不是忘了启动。另外针对TIM1这样的高级定时器CubeMX里还有一个Break and dead-time management的设置区这些是刹车和死区参数。我们只做普通PWM输出时保持默认即可不需要勾选。3. 核心代码初始化、启动、修改占空比、修改频率配置完成后生成的代码骨架是这样的static void MX_TIM1_Init(void) { TIM_OC_InitTypeDef sConfigOC {0}; TIM_BreakDeadTimeConfigTypeDef sBreakDeadTimeConfig {0}; htim1.Instance TIM1; htim1.Init.Prescaler 249; htim1.Init.CounterMode TIM_COUNTERMODE_UP; htim1.Init.Period 49; htim1.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim1.Init.RepetitionCounter 0; htim1.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE; if (HAL_TIM_PWM_Init(htim1) ! HAL_OK) { Error_Handler(); } sConfigOC.OCMode TIM_OCMODE_PWM1; sConfigOC.Pulse 25; sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode TIM_OCFAST_DISABLE; if (HAL_TIM_PWM_ConfigChannel(htim1, sConfigOC, TIM_CHANNEL_1) ! HAL_OK) { Error_Handler(); } sBreakDeadTimeConfig.OffStateRunMode TIM_OSSR_DISABLE; sBreakDeadTimeConfig.OffStateIDLEMode TIM_OSSI_DISABLE; sBreakDeadTimeConfig.LockLevel TIM_LOCKLEVEL_OFF; sBreakDeadTimeConfig.DeadTime 0; sBreakDeadTimeConfig.BreakState TIM_BREAK_ENABLE; sBreakDeadTimeConfig.BreakPolarity TIM_BREAKPOLARITY_HIGH; sBreakDeadTimeConfig.BreakFilter 0; sBreakDeadTimeConfig.AutomaticOutput TIM_AUTOMATICOUTPUT_DISABLE; if (HAL_TIMEx_ConfigBreakDeadTime(htim1, sBreakDeadTimeConfig) ! HAL_OK) { Error_Handler(); } }注意这里的BreakStateCubeMX在某些版本里默认会把TIM1的刹车功能使能如果我们没有使用刹车功能这个使能可能导致PWM一直无输出因为刹车输入信号的电平状态不满足要求。实际操作中如果不做电机控制建议把BreakState改成TIM_BREAK_DISABLE。这个坑我踩过排查了很久才发现是刹车引脚的电平把输出锁死了。在main函数里PWM启动代码如下HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_1);如果是高级定时器还要确认主输出使能不过HAL_TIM_PWM_Start内部已经处理了MOE主输出使能位不需要额外操作。启动之后想动态修改占空比最直接的方法是调用__HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, newPulse);这里newPulse的范围是0到ARR也就是0到49。当newPulse25时占空比是50%newPulse10时占空比约20%。HAL库还提供了另外一个接口HAL_TIM_PWM_Stop来停止输出停止后引脚恢复为默认状态。如果只是临时关闭输出用Stop/Start配合即可不需要重新初始化。修改频率的方法稍微绕一点。因为频率由PSC和ARR共同决定而直接修改ARR会影响当前正在运行的计数周期。推荐的做法是先将定时器停掉然后修改ARR再将定时器重新使能。如果要求在运行中不停顿地修改频率情况会复杂很多因为PSC的修改需要计数器归零后才生效这就要涉及到定时器的更新事件处理。我常用的动态修改频率代码如下void set_pwm_freq(TIM_HandleTypeDef *htim, uint32_t freq_khz) { uint32_t timer_clock 250000000; // 根据实际时钟树填写 uint32_t arr, psc; // 简单策略先固定PSC算ARR如果ARR超限则调整PSC psc 0; arr timer_clock / freq_khz - 1; if (arr 65535) { psc arr / 65535; arr arr / (psc 1); } HAL_TIM_PWM_Stop(htim, TIM_CHANNEL_1); __HAL_TIM_SET_PRESCALER(htim, psc); __HAL_TIM_SET_AUTORELOAD(htim, arr); /* 修改ARR之后必须要重新设置比较值否则占空比可能异常 */ __HAL_TIM_SET_COMPARE(htim, TIM_CHANNEL_1, arr / 2); // 对应对50%占空比 HAL_TIM_PWM_Start(htim, TIM_CHANNEL_1); }这里面有一个细节容易忽略修改占空比时不会自动检查CCR是否超过了新的ARR。比如ARR从999改成199后如果CCR还是500那PWM会一直是高电平输出波形完全不是预期的占空比。我一般在修改频率后会同步重新设置CCR保证占空比合理。4. 高级玩法用DMA和定时器更新中断实现平滑调速基础版本的PWM修改频率和占空比有一个明显痛点如果通过主循环不断调用SET_COMPARE宏占空比变化会有明显的阶梯感和抖动尤其是用在LED呼吸灯这类场景时生硬感很明显。如果你要驱动电机进行平滑加减速这种阶梯变化还会带来噪声。更好的方案是把PWM修改操作放到定时器更新中断里在每次PWM周期结束时自动加载新的占空比值。虽然HAL库的PWM模式也支持影子寄存器自动加载但CCR的值是随时可以写的中断的方式比主循环轮询更实时。实现思路是使能定时器更新中断在中断回调里修改下次周期的比较值。CubeMX配置中在NVIC设置里勾选TIM1 update interrupt然后在代码中实现回调void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { static uint8_t dir 1; static uint16_t compare 0; if (htim-Instance TIM1) { if (dir) { compare 20; if (compare 1000) dir 0; } else { compare - 20; if (compare 0) dir 1; } __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, compare); } }这里我把ARR设置为1000这样ccr的取值范围就是0到1000。中断里每次改变一点就能实现平滑的呼吸灯效果。要注意的是更新中断频率如果设置的很高比如PWM频率本身是20kHz那中断每秒触发两万次在中断里不要做耗时操作简单改个寄存器值是没问题的。另外还有一种方式是用定时器的DMA突发传输。简单说就是让定时器事件触发DMA把内存里的一组数据搬到定时器的比较寄存器中适合批量生成特定波形序列。比如要做LED渐变可以预先定义一组CCR值的数组用DMA循环搬运这样CPU完全不参与效果非常流畅。CubeMX中在DMA Settings里为TIM1添加一个DMA请求方向是memory to peripheral外设地址是TIM1的CCR1寄存器。这种方式后续如果有需要可以单独写一篇这里先提个思路。5. 示波器和逻辑分析仪测出来的真实波形长啥样代码写完总得上板实测。手头没有高级示波器的朋友用逻辑分析仪也能看PWM但测频率和占空比这种基础参数逻辑分析仪完全够用。实测下来初始化代码如果按上面配置PSC249、ARR49示波器上看到的是20kHz、占空比约50%的方波单从波形看和理论值基本一致。不过这里有个实际误差值得注意C542的定时器时钟来自PLL分频后的时钟树如果时钟源本身存在微小偏差PWM频率也会有偏差。对于普通的LED调光、舵机控制、蜂鸣器驱动来说完全够用但如果要做精准的频率基准源需要进一步校准时钟。修改占空比的动态响应也是我关注的点。我在主循环里让CCR从0以固定步长递增到49再递减回0然后用逻辑分析仪连续采样。能看到的变化是这样的占空比不是线性变化的因为在16位定时器里ARR只有49CCR变化1格对应约2%的占空比变化分辨率不高。如果你需要更细腻的占空比调节建议把ARR设置得大一些比如设置到999或者9999然后同时调整PSC来保持目标频率。举个具体例子要输出20kHz如果ARR999那么预分频PSC应该是12定时器时钟250MHz / (13*1000) 约等于19.23kHz实际会偏一点所以要精确还是按公式算。关于PWM波形的上升沿和下降沿我还特意在示波器上看了看。C542的GPIO翻转速度很快在普通推挽输出模式下上升沿时间在几个纳秒量级用来驱动LED、蜂鸣器、舵机信号线都没问题。如果外部负载比较重建议在GPIO外部加一级缓冲或者通过三极管/驱动芯片再接负载直接驱动大功率器件容易损坏引脚。6. 常见问题速查频率不对、没输出、占空比不准逐个排查写代码五分钟查问题两小时这是做嵌入式开发的常态。下面是我在使用STM32C542输出PWM过程中遇到的问题和排查思路整理出来供参考。第一类问题是完全没有波形输出。排查顺序是先用示波器确认GPIO引脚是不是配置对了很多情况下CubeMX里Pinout图上的引脚和实际板子上的丝印对不上然后检查HAL_TIM_PWM_Start有没有被调用这个启动函数不会自动执行再查刹车引脚配置如果是TIM1/TIM8这类高级定时器确认BreakState是DISABLE最后查看代码执行流程是否卡死在某个外设初始化里可以用调试器打断点看看。第二类问题是频率偏差很大比如设置20kHz实测却是10kHz。这种情况通常是因为定时器时钟配置不对。C542的定时器时钟挂在APB总线上APB分频系数不为1时定时器时钟是APB的两倍很多人按照APB时钟直接算PSC就错了一半。建议先通过调试器查看定时器时钟的实际值或者用RCC_GetTimerClock函数读一下。另外如果PSC和ARR写反了也会出现奇怪的频率结果。第三类问题是占空比精度不够。如果ARR设置得过小比如只有49那占空比只有50个档位稍微想精确一点就调不了。解决办法是增大ARR同时按比例增大PSC来维持目标频率。还有一种情况是AutoReloadPreload没有使能导致修改ARR后波形在周期中间突然变化看起来频率和占空比都不稳定。第四类问题是修改占空比时波形出现毛刺。这通常是因为在输出过程中直接修改了CCR而CCR的加载时机不受控。HAL库的SET_COMPARE宏实际上是直接写CCR寄存器对于PWM1模式来说写入后不会立即生效而是要等计数器计数到CCR时才改变输出状态但如果在写入过程中发生了更新事件波形可能会有一个周期的异常。解决方法是把PWM周期适当拉长或者接收用更新中断的方式修改。第五类问题比较隐蔽就是TIM1和TIM8的互补输出死区配置错误导致输出通道锁定。如果只是在普通引脚上输出PWM保持刹车配置默认关闭状态即可。如果要用互补输出驱动半桥电路死区时间必须根据功率管的开关时间合理设置死区太长会降低效率太短会导致上下桥臂直通炸管。这个后续可以单独写一篇讲。7. 实操总结我建议你这样开始动手如果你是从零开始我建议你按照下面的步骤走一遍比直接看完整项目更容易建立手感第一步用CubeMX配置一个最简单的TIM3通道1输出1kHz、50%占空比的PWM用LED或者示波器确认输出正常。第二步尝试在main函数的主循环里通过按键中断修改CCR值感受占空比变化对LED亮度或者蜂鸣器音量的影响。第三步尝试修改频率写一个可以直接传参频率值的函数同时保证占空比不变。第四步用更新中断实现平滑调光体会一下中断方式和轮询方式在实时性上的区别。我在实际测试C542时的一个直观感受是它的GPIO翻转速率和定时器精度相比F1系列提升明显PWM在20kHz附近驱动MOS管时波形依然很干净不需要额外加RC滤波就能直接送到栅极驱动芯片。而且Cortex-M33内核跑250MHz用HAL库操作定时器寄存器时几乎没有可感知的延迟这在玩灯效或者舵机控制时体验非常顺滑。最后说一个容易被忽略的经验改完PWM参数后建议用逻辑分析仪多采几秒波形再下结论有时候肉眼直观看到的问题其实是负载端的干扰不是MCU端的输出问题。把PWM引脚设置为复用推挽、速度调到Very High能明显改善驱动长导线时的边沿抖动。之后再接负载问题往往就消失了。