
做电机控制项目时PWM基本是绕不开的。最近在 STM32C5A3R 上调试伺服驱动需要一路频率和占空比都能实时调整的 PWM就顺手整理了这篇开发记录。STM32C5A3R 作为基于 Cortex-M33 内核的新一代 MCU定时器外设延续了 STM32 家族的风格用 HAL 库操作起来和 F1/G0 差异不大但细节上仍然有几个容易绊脚的地方。这篇博文会完整记录我怎么在 STM32C5A3R 上用定时器输出 PWM以及运行时如何安全地修改频率和占空比。适合刚拿到 STM32C5 系列评估板、或者之前玩过其他 STM32 想快速迁移的朋友。实验目标很直接先用一路 PWM 驱动 LED 调光再扩展成可动态调速的直流电机驱动最终通过串口指令实时改变输出波形。1. 动手前的3个选择定时器、引脚和PWM模式很多人拿到开发板第一件事就是打开 CubeMX 开始点鼠标先把 PWM 点亮再说。这个做法不是不行但如果一开始就没选对定时器和引脚后面调起来会很痛苦。我在 STM32C5A3R 上踩过一次引脚冲突的坑所以还是老老实实先把外设资源理清楚。1.1 定时器资源怎么分优先从项目整体盘STM32C5A3R 的定时器资源很丰富从基本定时器到高级定时器都有。做普通 PWM 输出时通用定时器 TIM2/TIM3/TIM4/TIM5 是最常用的它们都有独立的通道映射和比较寄存器足够应对 LED 调光、舵机控制、直流电机调速这些场景。如果你做的是全桥电路或 BLDC 控制那就要用到 TIM1/TIM8 这类高级定时器因为高级定时器支持互补输出和死区插入硬件上能直接避免上下桥直通。我这次实验的负载是直流电机和 LED所以选了 TIM3。选择理由有两条一是 TIM3 的通道引脚在板子上没有被其它外设占用二是 TIM3 挂在 APB1 总线上时钟配置比较直观。如果你的项目里还有编码器接口、输入捕获或者其它定时需求最好先画一张定时器占用表不要全部堆在同一个定时器上。下面是我常用的资源分配思路外设定时器用途电机PWMTIM3 CH1动态调频率和占空比LED呼吸灯TIM3 CH2低频PWM调光编码器TIM2正交编码模式舵机TIM5 CH150Hz固定频率PWM留给串口/中断TIM6/TIM7基本定时、系统调度节拍这样分下来定时器之间互不干扰调试的时候也能很快定位问题。1.2 PWM模式别只看“输出PWM”这三个字CubeMX 里配置 PWM 时在 Timer 的 Channel 选项里选 PWM Generation CHx然后下面会出现 Counter Mode、Pulse、Fast Pulse 等参数。很多人直接默认但有几个选项会影响最终波形。计数器模式Counter ModeUp 是向上计数也是最常用的边沿对齐模式。Down 是向下计数其实得到的波形和 Up 一样只是计数方向不同。Center Aligned 是中心对齐模式计数先向上再向下产生的波形是对称的谐波含量更低电机控制里经常用。PWM 模式PWM ModePWM1 是 CNT CCR 时输出有效电平PWM2 是反过来的。一般选 PWM1如果最后发现波形反了把极性换一下就行。极性PolarityHigh 表示比较匹配时输出高电平。如果你的驱动电路是高有效就选 High如果控制的是低有效 MOSFET 驱动比如某些 P-MOS 栅极驱动就选 Low。我这次用的配置是 Up 计数模式、PWM Generation CH1、PWM1、High polarity。简单稳定适合做基础实验。1.3 引脚复用和 GPIO 速度的隐藏影响CubeMX 里选好定时器通道后会自动分配引脚。比如 TIM3_CH1 可能会落在 PA6 或 PC6 上具体要看封装。我手上这块 STM32C5A3R 走的是 PA6引脚被自动配置为 Alternate Function。GPIO 速度这里有个常见误解PWM 频率不是靠 GPIO Speed 调上去的GPIO 速度只影响 IO 输出驱动器的压摆率。如果速率设得太低高频率 PWM 的边沿会变缓波形上能看到明显的梯形但设成 Very High 会引入更多 EMI。一般来说PWM 频率在 1MHz 以下Medium 或 High 就够用我习惯先设 High等板子做 EMC 测试时再降。另外如果你发现引脚配置成了 GPIO_MODE_AF_PP但波形一直出不来检查两个地方一是确认设置的 Alternate 编号是不是对应 TIM3比如 PA6 可能还要映射到 AF2 或 AF3不是随便一个 AF 都能工作二是确认时钟树里 TIM3 的时钟有没有打开。这两点最隐蔽。2. 在CubeMX里配置PWM参数频率占空比一开始就别拍脑袋CubeMX 最大的好处是能可视化管理时钟树和引脚复用省掉了一堆查找寄存器手册的时间。但如果你不清楚每个参数背后对应的寄存器后期在代码里动态修改时会一头雾水。所以这一节不仅讲配置还讲清楚数值是怎么算出来的。2.1 从时钟树到定时器时钟先搞清楚TIM3的时钟源PWM 频率不是随便写在 CubeMX 里的它和定时器输入时钟强相关。STM32 的定时器时钟一般来自 APB 总线但有个倍频规则当 APB 分频系数不为 1 时定时器时钟是 APB 时钟的两倍。这一点最容易看漏。我在 STM32C5A3R 上的时钟树配置是系统主频拉高APB1 分频为 /2那么 TIM3 的时钟就是 APB1 的两倍。假设 APB1 得到 40MHzTIM3 时钟就是 80MHz。不要去猜直接打开 CubeMX 的 Clock Configuration 页面点一下 TIM3 时钟那栏界面会显示具体的频率数值。2.2 频率和占空比公式为什么到处都是1一旦确定了定时器时钟频率接下来就是经典公式PWM 频率 定时器时钟频率 / ((Prescaler 1) * (Period 1))占空比 CCR / (Period 1) * 100%为什么要 1因为定时器的预分频器和自动重装载寄存器都是从 0 开始计数的。你写 Prescaler 为 4实际分频是 5写 PeriodARR为 999实际计数值是从 0 数到 999一共 1000 个计数单位。如果忘了 1计算出来的频率就会偏差一点波形和预期对不上时第一个就查这里。举个例子现在 TIM3 时钟 80MHz想要输出 20kHz PWM同时初始占空比 50%选择 Prescaler 0也就是不分频Period 80000000 / 20000 - 1 3999CCR 3999 * 50% 1999在 CubeMX 的 PWM 配置界面里Prescaler 填 0Counter Period 填 3999Pulse 填 1999。这样生成代码后启动 PWM 就能看到 20kHz、50% 的波形。2.3 CubeMX 关键参数和生成的代码结构打开 CubeMX 的 TIM3 配置界面找到 PWM Generation CH1 之后重点看这几个参数参数我填的值对应寄存器说明Prescaler0TIMx_PSC预分频值Counter ModeUpTIMx_CR1.CMS计数方向Counter Period3999TIMx_ARR自动重装载值AutoReloadPreloadEnableTIMx_CR1.ARPEARR预装载使能Pulse1999TIMx_CCR1比较输出值OC ModePWM1TIMx_CCMR1.OC1MPWM模式PolarityHighTIMx_CCER.CC1P输出极性其中 AutoReloadPreload 我建议在项目初期就设成 Enable。这样在运行时写入新的 ARR 值后不会立刻改变当前周期而要等一次更新事件才真正生效。这个特性在动态调速时能避免计数到一半突然跳变后面会说具体原因。生成代码后定时器初始化函数长这样void MX_TIM3_Init(void) { TIM_OC_InitTypeDef sConfigOC {0}; htim3.Instance TIM3; htim3.Init.Prescaler 0; htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 3999; htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_PWM_Init(htim3); sConfigOC.OCMode TIM_OCMODE_PWM1; sConfigOC.Pulse 1999; sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(htim3, sConfigOC, TIM_CHANNEL_1); }这里有个容易被忽视的点HAL_TIM_PWM_Init只是配置了定时器基础功能并不会初始化 PWM 通道真正把通道的比较模式、初始占空比写入的是HAL_TIM_PWM_ConfigChannel。在代码里动态改占空比时我们一般不去改这个初始化函数只在启动前调用一次。3. 启动PWMHAL库调用顺序和一些奇怪的现象配置完成后程序并不是从一开始就输出 PWM。你必须在 main 函数里显式启动通道这一步经常有人漏掉。启动函数和停止函数成对出现我先说最基础的用法。3.1 最小启动代码HAL_TIM_PWM_Start做了什么在 main 函数里的初始化流程之后加上HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1);这一行会做几件事使能 TIM3 的计数器使能通道对应的输出让 PWM 比较输出真正连接到引脚。如果你用的是高级定时器 TIM1/TIM8可能还需要HAL_TIM_PWM_Start配合HAL_TIMEx_PWMN_Start来打开互补输出否则只会有一边有波形。启动后默认输出应该是 CubeMX 里配置好的初始波形。我用示波器测 PA6能看到 20kHz、50% 占空比的方波。如果此时波形没有出现先不要急着改代码用下面的排查路径走一遍。3.2 无输出的排查路径先硬件后软件我见过太多人一发现没波形就反复重刷程序最后发现是示波器探头虚接。所以排查顺序很重要用万用表测引脚对地是否有电压跳变有跳变说明波形存在重点检查示波器设置。检查 PA6 是否真的被配置成 AF 模式。如果 CubeMX 里引脚被其它外设抢占软件里读到的寄存器可能不是定时器输出。调试器读一下htim3.State如果停在HAL_TIM_STATE_BUSY可能是初始化中断了。读 TIM3 的寄存器TIM3-CR1的 CEN 位是否为 1TIM3-CCER的 CC1E 位是否为 1TIM3-CCMR1的 OC1M 位是否为 110PWM1 模式。最后看时钟树TIM3 时钟使能位是否打开。CubeMX 生成代码不会错但如果你手动改过时钟函数可能把 GPTIM3 使能漏掉。之前遇到过一个诡异情况PWM 只在芯片复位后的几微秒内输出然后就变成低电平。查到最后是调试器把 PA6 当成 SWO 引脚复用了虽然 CubeMX 里没勾选但调试配置自动占用了它。换一个引脚或者在调试器里禁用 Trace 就好。3.3 一个容易被忽略的现象PWM默认输出电平很多人第一次测试时会把初始 Pulse 设成 0发现引脚一直是低电平以为没输出。实际上这是正常的PWM1 模式下CNT CCR 输出高如果 CCR 0那么整个周期 CNT 都不会小于 0输出就一直低。反过来如果把 Pulse 设置得比 ARR 还大输出会一直高。调试时想验证引脚是否可用可以先把 Pulse 设成 Period 的一半确认波形正常后再改回动态控制策略。4. 运行时修改占空比和频率直接改CCR与ARR是不够的静态 PWM 太简单不过大部分项目需要在运行中改变占空比和频率。比如电机启动时频率慢慢升稳定后调整转速这就涉及到对定时器比较寄存器和重装载寄存器的动态操作。4.1 修改占空比CCR通道比较值写到哪一层占空比的变化本质就是修改 CCR 寄存器。HAL 库提供了现成宏__HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, 1999);这个宏把值直接写入TIM3-CCR1。如果你已经通过HAL_TIM_PWM_Start启动了 PWM改完 CCR 之后波形会立刻变化。占空比的计算公式是CCR / (ARR 1)所以当你只改 CCR 不改 ARR 时占空比变化是精确且立即生效的。这里有个很多人会踩的坑CCR 的预装载有没有开启。在 STM32 中CCR 可以选择是否使用预装载如果使能了预装载写入 CCR 后要等更新事件才生效如果没使能写入后立即生效。CubeMX 配置 PWM 通道时默认情况下是使能比较预装载的但你在初始化结构体里可能看不到这个选项需要通过寄存器来确认if (TIM3-CCMR1 TIM_CCMR1_OC1PE) { // 预装载使能 }如果想要响应更快可以在配置后手动清除这个位或者用HAL_TIM_OC_ConfigChannel重新配置。日常做 LED 调光时预装载其实影响不大但在高频电机控制中不同步的装载可能导致单周期 PWM 脉冲宽度异常严重时会引起电机啸叫。4.2 修改频率ARR和PSC的先后顺序影响波形修改频率有两种做法一是改 ARR二是改 PSC最合理的是两个一起配合。修改 ARR 时如果使能了 AutoReloadPreload写入的 ARR 不会立即生效而在一次更新事件后才装载。这意味着你写入新 ARR 后当前正在进行的周期还会按旧周期走完下一个周期才按新频率走。这个特性很好能避免周期中途跳变造成的异常脉冲。但修改 PSC 的情况不太一样。PSC 寄存器也有一个影子寄存器写入的值要到更新事件才会被真正加载给预分频器。所以理论上 PSC 也不是“立刻生效”的但如果你没有正确触发更新事件就可能出现写入 PSC 后一个周期甚至多个周期才生效这才是“改了没反应”的根源。我在运行时调节频率和占空比时遵循一个固定的写入顺序先计算新的 ARR 和 CCR。用__HAL_TIM_SET_AUTORELOAD写 ARR。用__HAL_TIM_SET_COMPARE写 CCR。用__HAL_TIM_SET_PRESCALER写 PSC。手动产生一次更新事件强制所有影子寄存器同步__HAL_TIM_SetCounter(htim3, 0); __HAL_TIM_GenerateEvent(htim3, TIM_EVENTSOURCE_UPDATE);为什么要这样做因为 PSC 和 ARR 都受影子寄存器影响如果只写入而不触发更新新值要等下一次硬件更新事件才生效。手动触发更新可以让整个切换在明确的时间点完成避免“不确定拖了几个周期”的尴尬。需要注意的是GenerateEvent会同时更新所有预装载寄存器包括 CCR所以 CCR 也要先写好再触发更新。4.3 封装一个实用的PWM调整函数频率和占空比联动为了避免在业务代码里频繁写底层宏我封装了一个函数输入频率和占空比百分比自动计算 PSC、ARR、CCRvoid pwm_set_freq_duty(TIM_HandleTypeDef *htim, uint32_t channel, uint32_t freq_hz, uint8_t duty_percent) { uint32_t timer_clock 80000000; // 根据当前定时器时钟设置 uint32_t psc 0; uint32_t arr 0; uint32_t ccr 0; // 寻找合适的PSC使ARR落在16位范围内 for (psc 0; psc 65535; psc) { uint32_t tmp timer_clock / ((psc 1) * freq_hz); if (tmp - 1 65535) { arr tmp - 1; break; } } ccr (uint32_t)arr * duty_percent / 100; __HAL_TIM_SET_AUTORELOAD(htim, arr); __HAL_TIM_SET_COMPARE(htim, channel, ccr); __HAL_TIM_SET_PRESCALER(htim, psc); __HAL_TIM_SetCounter(htim, 0); __HAL_TIM_GenerateEvent(htim, TIM_EVENTSOURCE_UPDATE); }这个函数的问题在于在写入 PSC 的瞬间定时器可能还在跑旧周期会受影响。不过由于后面立刻产生更新事件影响窗口只有一个 PWM 周期对于大多数应用是可以接受的。如果你的系统对频率切换的相位要求特别严格那就不能用这种方法要改成“先停止定时器配置完参数再启动”的流程HAL_TIM_PWM_Stop(htim, channel); pwm_set_freq_duty(htim, channel, freq, duty); HAL_TIM_SetCounter(htim, 0); HAL_TIM_PWM_Start(htim, channel);停止期间会有一段无输出时间适合不需要连续输出的场景。电机调速其实更推荐前者因为短暂的不连续会导致转速波动。4.4 结合串口做动态调节的例子我实际项目里用串口接收类似F20K、D75这样的指令分别表示设置 20kHz 频率和 75% 占空比。串口中断中解析完字符串后调用上面的函数。关键代码void Serial_Debug_Process(uint8_t *buf) { if (buf[0] F) { uint32_t freq atoi((char *)buf[1]); pwm_set_freq_duty(htim3, TIM_CHANNEL_1, freq, current_duty); } else if (buf[0] D) { uint8_t duty atoi((char *)buf[1]); pwm_set_freq_duty(htim3, TIM_CHANNEL_1, current_freq, duty); } }注意不要在中断回调里跑比较大的 for 循环因为我的函数里为了找 PSC 会循环遍历。在串口中断里做这事会在高频接收时丢数据。更好的做法是串口只负责把数据放进环形队列主循环里再解析并调用pwm_set_freq_duty。这也是为什么我把函数体写得很“耗时友好”的原因之一。5. 实测中的5个坑为什么波形不动、占空比突变、PWM停止后无输出这部分是真正的经验积累。我在 STM32C5A3R 上调 PWM 时前前后后踩了不少坑整理出最典型的5个每一个都可能让你浪费半天时间。5.1 改ARR后CCR没跟着改占空比瞬间变了这是最常见的问题。你在代码里只调用了__HAL_TIM_SET_AUTORELOAD改了 ARR占空比没有重新计算。比如原来 ARR3999CCR1999占空比 50%现在把 ARR 改成 999CCR 还是 1999此时 CCR ARR输出变成 100% 占空比电机全速狂转LED 亮度突变。解决思路很直接任何时候修改 ARR都要同步计算一次CCR ARR * duty / 100哪怕占空比的目标值没有变。我在调试时用示波器看到波形占空比不对第一反应不是怀疑示波器而是检查刚才是不是改了频率。建议你在代码里维护一个全局的目标占空比变量改动频率后立即根据这个变量重算 CCR。5.2 改了寄存器但波形不变先检查预装载和更新事件有次我按上述顺序更新 ARR、CCR、PSC 后示波器纹丝不动。反复检查后发现我的定时器初始化时 AutoReloadPreload 是 Disable所以 ARR 确实是立即生效的但 CCR 的预装载位被之前的代码意外打开导致 CCR 一直没更新。后来我统一在初始化后强制关掉所有通道的预装载TIM3-CCMR1 ~TIM_CCMR1_OC1PE;或者反过来把 ARR 和 PSC 也放在同一个更新事件中同步生效。两种方式选一种保持一致就好。千万别混着用一会儿立即生效一会儿延迟生效排查起来很容易晕。5.3 调PSC时波形乱了一下如何平滑过渡调 PSC 是搞 PWM 最头痛的部分因为它会改变计数器的计数步长。如果你在电机运行中直接改 PSC可能会看到波形断了一拍或者出现一个特别宽的脉冲。原因是 PSC 的更新并不能保证和当前计数位置对齐新的分频系数装入后计数器继续从当前值向上计但时钟步长变了。我试过最平滑的方法是先把 ARR 调到一个中间值再把 PSC 调到目标值最后把 ARR 调到目标值。举个例子从 10kHz 切到 20kHz假设时钟 80MHz原配置PSC0ARR7999新配置PSC0ARR3999这种情况其实不需要调 PSC直接改 ARR 就行。但如果频率跨度特别大比如从 1kHz 切到 200kHz10 位 ARR 可能不够这时就要换 PSC。我会先设置一个接近目标 ARR 的临时 PSC等计数器稳定后再写入目标 PSC。这个技巧不是标准做法但实测波形没有出现明显毛刺。5.4 用示波器直流耦合看PWM占空比测量不准这不是代码问题是操作问题。之前看到一个群里的人说 STM32C5A3R 的 PWM 占空比有 5% 的误差测下来其实是他示波器探头补偿没调好。高频方波如果探头的补偿电容不准上升沿和下降沿会严重变形占空比读数自然不对。用示波器测 PWM先把探头接到 1kHz 方波校准信号上调好探头补偿再测。另外假如频率超过几 MHz建议用短地线夹不要用原装长鳄鱼夹那个地线电感会在边沿产生振铃。5.5 停止PWM后引脚电平不受控HAL 库的HAL_TIM_PWM_Stop会把通道输出关闭但引脚状态取决于停止前 CCR 和 ARR 的关系。比如停止前占空比 100%引脚是高电平停止后可能还是高电平电机依然带电。这在安全关键场合很危险。如果你期望停止后输出确定的低电平建议在停止前先把 CCR 清零或者将引脚手动拉低__HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, 0); HAL_TIM_PWM_Stop(htim3, TIM_CHANNEL_1); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_6, GPIO_PIN_RESET);更好的做法是使用自带 brake 输入的高级定时器配合刹车功能在异常时强制关闭输出。对于我现在的基础实验来说先清 CCR 再 Stop 已经足够。6. 顺手记一个进阶玩法DMA批量更新多路PWM占空比如果你只是控制一路电机上面那些内容已经完全够用了。但如果你的项目里要做 3 路 RGB 调光或者 6 路 BLDC 正弦波输出CPU 一个一个去写 CCR 会占用大量时间。这时候可以考虑用 DMA 自动更新比较寄存器。6.1 为什么DMA能让PWM更新更稳定HAL 库提供了HAL_TIM_PWM_Start_DMA这类函数它的原理是定时器产生更新事件或触发事件时DMA 自动从内存搬运新值到 CCR不需要 CPU 介入。因为 DMA 搬运是硬件行为时间确定、抖动小能让多路 PWM 的占空比在同一个事件点同步更新避免 CPU 在更新途中被中断打断造成的通道间错位。我之前做过一个 RGB LED 渐变效果3 路 PWM 频率都是 1kHz。如果用 CPU 在中断里依次写 CCR可能会因为中断嵌套导致 3 路更新时刻相差几十微秒人眼看不出但示波器能看到通道间相位差。改用 DMA 后3 路 CCR 在一次更新事件中同时装载效果干净很多。6.2 DMA更新占空比的坑缓冲区长度和循环模式配置 DMA 时最容易搞错的是缓冲区长度和模式。HAL_TIM_PWM_Start_DMA的第一个参数是定时器句柄第二个参数是通道第三个参数是数据缓冲区地址第四个是数据长度。如果你把所有通道的 CCR 放在一个数组里DMA 搬完一轮后必须设置成循环模式否则只更新一次就停了。只有在循环模式下数组里的值才会被周期性地搬到 CCR实现呼吸灯和动态调光。不过要注意DMA 连续搬运的是“固定数据”如果你想在运行中改变下一轮的占空比需要修改源数组里的数据然后手动同步一下而不是再调用一次 Start。这一点和直接写 CCR 的思路不太一样很多人刚开始会绕晕。我是这么理解的DMA 就是一条流水线你只需要往流水线入口处放新数据硬件会按节拍把它送进定时器不需要每个周期都去控制开关。另外STM32C5A3R 的 DMA 通道分配比较复杂同一个定时器事件可能只有指定的 DMA 请求线能到达。配置失败时先检查 DMA 请求映射是否正确别急着改代码。让我印象深刻的一次是TIM3 的更新事件只能走 DMA1 的某个请求线我配到了 DMA2 的通道上结果占空比一直不更新折腾了半个多小时才发现是__HAL_LINKDMA里的信息没对齐。再说回来DMA 只是 PWM 控制的进阶手段基础还是要先把 ARR、CCR、PSC 这几个寄存器的行为吃透。我曾经以为直接用 DMA 就能绕过所有底层细节后来遇到问题还是得老老实实去查寄存器手册。很多时候所谓的“坑”并不是芯片有问题而是我们对底层同步机制的理解不够准确。把一次更新事件、影子寄存器这些概念搞明白后PWM 的动态调节其实没有太多秘密。