STM32C542 PWM实践:运行中修改频率与占空比的影子寄存器机制
2026/9/14 15:25:29 网站建设 项目流程

STM32C542是ST C0系列里的性价比担当:Cortex-M0+内核,最高48 MHz,一颗料就把LQFP48封装、CAN、多路UART/I2C/SPI和定时器资源堆齐了,价格却压得很低。这个系列前几篇我们走完了建工程、点灯和串口打印,这篇终于轮到嵌入式开发里跑不掉的功能——PWM输出,以及很多人一上手就懵的“运行中修改频率和占空比”。

为什么会把修改频率和占空比单独拎出来讲?因为单看输出固定PWM,CubeMX填几个参数就能跑。但实际项目里几乎没有固定PWM的场景:调光要变占空比,电机要变频率,舵机要变脉宽,甚至在运行中两者都要同时变。如果你只是把__HAL_TIM_SET_COMPARE()拿来改占空比,改频率时发现波形乱了,多半是没搞懂寄存器层面的“影子机制”。这篇文章就把这条链路完整走一遍:从选型、计算公式、CubeMX配置,到代码写法、运行中调参,再到上板实测的坑,全部展开。

1. 先从这颗芯片说起:C542做PWM输出的选型逻辑

1.1 为什么是C542而不是F103或者G031

很多初学者习惯性选F103,资源多、教程多,但放到今天的成本压力下,F103已经谈不上性价比。STM32C0系列是ST面向大批量、低成本场景推出的Cortex-M0+产品线,C542在C0里属于中高阶配置:最高48MHz主频,带CAN、带多路定时器,封装覆盖从QFN32到LQFP48。做LED调光、小型电机驱动、舵机控制这类PWM应用,完全够用。

我自己的选型习惯是:能在低主频芯片上完成的功能,绝不上高主频芯片。C542虽然只有48MHz,但内核跑PWM输出这类逻辑根本不需要多少算力,反而是定时器外设的丰富程度更关键。C0系列的定时器资源比一些人想象中厚道:高级定时器TIM1支持带死区的互补PWM,TIM2是32位计数器,TIM3、TIM14、TIM16、TIM17是16位计数器。也就是说,C542不仅能输出单路PWM,做多路、做H桥驱动都有硬件基础。

1.2 PWM输出能覆盖哪些典型场景

C542的PWM应用场景可以分三个层次看:

  • 轻量场景:LED调光、呼吸灯、蜂鸣器驱动。这类场景对频率要求不高,1kHz到5kHz都能跑,重点是占空比调节要平滑。
  • 中等场景:直流电机调速、加热器功率控制。电机驱动需要避开人耳可听范围,工程上常用10kHz到20kHz,既降低噪音又不至于让功率管开关损耗过大。
  • 进阶场景:舵机控制、半桥/全桥驱动。舵机要求固定50Hz周期,靠脉宽控制角度;全桥驱动需要TIM1输出互补PWM并插入死区,防止上下管直通。

这篇文章还是以最常用的“单路PWM输出+动态调频调占空比”为主线,把底层机制讲透。后面实验部分会覆盖呼吸灯和舵机,电机调速也会给一组可直接用的参数。

2. PWM频率与占空比的计算:三个寄存器值玩明白一半

2.1 频率公式:PSC和ARR到底怎么配

PWM输出的核心是定时器计数。标准向上计数模式下,计数器从0开始递增,到ARR时溢出归零,然后重新开始。溢出一次就是一个PWM周期。

公式只有一条:

PWM频率 = 定时器时钟 / ((PSC + 1) * (ARR + 1))

PSC是预分频器,ARR是自动重载值,两者都是整数寄存器值。为什么都要加1?因为这两个寄存器的值是“计数值减1”。PSC=47表示48分频,ARR=999表示计数1000次。

举个例子:C542的TIM2时钟是48MHz,想输出1kHz PWM,可以这么算:

  • 先定PSC = 47,则计数器时钟 = 48MHz / (47 + 1) = 1MHz
  • 再定ARR = 999,则PWM频率 = 1MHz / (999 + 1) = 1kHz

这两个值的分配没有唯一解。PSC=0、ARR=47999同样能得到1kHz(48MHz / 48000),但两者效果差别很大,原因在下一个小节讲。

2.2 占空比与分辨率:CCR是唯一控制占空比的地方

占空比由比较寄存器CCR决定。向上计数时,CNT小于CCR输出一种电平,CNT大于等于CCR切换另一种电平。

占空比 = CCR / (ARR + 1) * 100%

注意这个公式里也有ARR+1。所以当ARR改变时,即使CCR数值不变,占空比也会变。这是很多人改频率时发现“占空比跟着变了”的根本原因。

再说分辨率。占空比调节的最小步进是1 / (ARR + 1)。如果ARR=999,占空比步进是0.1%(千分之一);如果ARR=49,步进就是2%(五十分之一)。这就是为什么刚才的问题里,PSC和ARR的分配不是随便定的:在能实现目标频率的前提下,尽量用小的PSC,把ARR做大,分辨率才够细腻。

举个实际体验:同样输出20kHz,方案A用PSC=47,计数器时钟1MHz,ARR=49,占空比只能按2%跳;方案B用PSC=3,计数器时钟12MHz,ARR=599,占空比步进约0.17%。同样是20kHz,后者调光顺滑程度天差地别。

2.3 修改频率时占空比为什么会跟着变

因为占空比是CCR和ARR的比值,不是绝对值。改ARR相当于改了分母,CCR不变的情况下比值自然变化。

要保证改频率后占空比不变,必须同步更新CCR:

新CCR = (旧CCR / 旧ARR+1) * (新ARR+1)

举个例子:原先是1kHz,PSC=47,ARR=999,占空比50%对应CCR=500。现在要把频率提到2kHz,如果仍保持PSC=47,新ARR=499;想让占空比依然50%,新CCR就该等于 (500 / 1000) * 500 = 250。

这个换算逻辑不仅在代码里要用到,CubeMX图形化配置时也一样。先确定PSC和ARR,再把要的占空比换算成Pulse值填进去。

3. CubeMX初始化配置:分频、重载、脉宽先填对

3.1 时钟树:内部HSI还是外部晶振

C542内部HSI可以跑到48MHz,精度对标称值约±1%左右,普通PWM应用足够。如果对频率精度要求高,比如需要精准的时间基准,再接外部晶振HSE。

我个人建议:前期调试阶段直接用内部HSI,把时钟树里RCC的HSE设为Disable,少接两个引脚,板子布线和物料成本都能省一点。如果后续发现频率偏差影响业务,再切HSE不迟。CubeMX的Clock Configuration里把System Clock设为48MHz即可,HSI会自动调整倍频。

注意:定时器时钟未必等于系统时钟,取决于APB预分频。在C542上,如果你在CubeMX里看到APB总线是48MHz,定时器时钟一般就是48MHz。如果你把系统时钟设为更低频率,一定要去Clock Configuration里的APB1 Timer Clocks确认实际值,后面所有计算都以这个值为准。

3.2 定时器实例与GPIO复用选择

C542定时器资源有多路,但做PWM实验建议先用TIM2或TIM3,配置简单、通道多,以后扩展方便。这里以TIM2为例。

在CubeMX左侧引脚图中,点击TIM2,选择Channel1的PWM Generation CH1模式,对应引脚会自动分配。TIM2_CH1具体映射到哪个引脚,以你的封装和CubeMX自动分配结果为准。C0系列引脚复用通过AFR寄存器配置,CubeMX会自己生成,你不需要手动改。

有一点要自己检查:引脚被复用为TIM2_CH1之后,同时也会失去普通GPIO功能。如果这个引脚还接了其他外设,会冲突。CubeMX在引脚图上会用颜色区分复用状态,养成编译前扫一眼Pinout视图的习惯。

3.3 参数面板里PSC、ARR、Pulse到底怎么填

进入TIM2的Parameter Settings,有几个关键项:

  • Prescaler (PSC):填47,对应48分频,计数器时钟1MHz
  • Counter Mode:默认Up(向上计数)
  • Counter Period (ARR):填999,1kHz周期
  • Auto-reload preload:建议Enable,这个会影响后续修改ARR是否立即生效,后文详述
  • Pulse (CCR):填500,输出50%占空比

此时CubeMX右侧会自动预览:PWM Frequency显示1.0 kHz,占空比约50%。这个预览功能很实用,填完参数先看一眼,如果和预期不符,多半是时钟配错了。

然后切到GPIO Settings,确认TIM2_CH1引脚的模式是Alternate Function,速度建议选High。PWM频率不高时Low也行,但选High更稳妥,避免高速开关时边沿变缓。

4. 代码让PWM跑起来:启动、改占空比、改频率的完整写法

4.1 HAL库三行代码启动输出

CubeMX生成工程后,main函数里调用MX_TIM2_Init()完成初始化,但此时定时器还没开始输出波形。要真正让PWM从引脚送出去,需要在主循环前调用:

HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);

就这么一行。如果你用的是高级定时器TIM1,HAL_TIM_PWM_Start内部也会自动打开主输出使能(MOE),不需要额外操作。但如果你是手动操作寄存器模式,TIM1要记得设置TIM1->BDTR |= TIM_BDTR_MOE;,否则引脚上永远没波形,这是很多人踩过的坑。

4.2 修改占空比:首选HAL宏

HAL库提供了标准宏:

__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 300); // 占空比变为30%

第二个参数是通道,第三个参数是CCR值,范围0到ARR。CCR=0输出一直为无效电平,CCR=ARR输出一直为有效电平。注意不要超过ARR,超过以后PWM输出会变成恒高(PWM mode 1)或恒低(PWM mode 2),行为不可控。

底层本质是修改TIM2->CCR1寄存器。这个值在PWM模式下受预装载机制控制,写入后会等合适时机生效,不会在一个周期中间突然跳变。

4.3 修改频率:改ARR还是改PSC

修改频率有两个切入点:ARR或PSC。两者效果不同,适用场景也不同。

改ARR适用于小范围精调。比如1kHz要微调到1.1kHz,改PSC可能一档就跳太多,改ARR可以精确到小数。代码:

__HAL_TIM_SET_AUTORELOAD(&htim2, 899); // 频率变为1MHz/900 ≈ 1.111kHz // 同时重算CCR,保持占空比 __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 450); // 50%

改PSC适用于大范围粗调。比如从1kHz跳到10kHz,可以PSC从47改为4,计数器时钟从1MHz变成9.6MHz,ARR不变,频率直接提高9.6倍:

__HAL_TIM_SET_PRESCALER(&htim2, 4); // 计数器时钟 = 48MHz/5 = 9.6MHz,ARR=999时频率约9.6kHz // 同时需要重设CCR以调整占空比

这里有个关键点:PSC在STM32的定时器里是有预装载特性的,写入的新PSC值不会立即反应到计数器时钟上,而是要在**更新事件(UEV)**发生时才会真正加载。如果你的代码写完PSC立刻去读频率,发现没变,这是正常现象。同理,ARR是否立即生效取决于前文说的ARPE位。CubeMX默认把Auto-reload preload设为Enable,这时ARR也有影子缓冲,新值要等下一个更新事件才生效。

4.4 封装一个频率和占空比同时设置的函数

实际项目中,很少单独改频率或单独改占空比。更常见的需求是:“给我一个函数,输入频率和占空比,PWM直接输出对应波形。”我建议封装一个通用函数:

void PWM_SetFreqDuty(TIM_HandleTypeDef *htim, uint32_t Channel, uint32_t freq_hz, float duty_percent) { uint32_t psc, arr, ccr; uint32_t timer_clock = 48000000; // C542定时器时钟48MHz uint32_t counter_freq; if (freq_hz == 0) return; if (duty_percent < 0.0f) duty_percent = 0.0f; if (duty_percent > 100.0f) duty_percent = 100.0f; // 先尝试PSC=47,计数器时钟1MHz,分辨率最高 psc = 47; counter_freq = timer_clock / (psc + 1); arr = counter_freq / freq_hz - 1; // 如果算出来的ARR超出16位范围,说明目标频率过低,需要增大PSC if (arr > 65535) { psc = (timer_clock / freq_hz) / 65536; counter_freq = timer_clock / (psc + 1); arr = counter_freq / freq_hz - 1; } ccr = (uint32_t)((float)(arr + 1) * duty_percent / 100.0f); __HAL_TIM_SET_PRESCALER(htim, psc); __HAL_TIM_SET_AUTORELOAD(htim, arr); __HAL_TIM_SET_COMPARE(htim, Channel, ccr); }

调用方式:

PWM_SetFreqDuty(&htim2, TIM_CHANNEL_1, 1000, 50.0f); // 1kHz, 50% PWM_SetFreqDuty(&htim2, TIM_CHANNEL_1, 20000, 40.0f); // 20kHz, 40%

函数里固定timer_clock是48MHz,如果系统时钟改了记得同步修改。这个函数在绝大多数16位ARR场景下够用。连续调用时,由于PSC和ARR都有预装载机制,新参数会在下一个PWM周期整体切换,波形不会产生半周期毛刺,前提是你没有关闭ARPE。

5. 运行时改参不生效?预装载/影子寄存器才是关键

5.1 影子寄存器到底是个什么机制

很多初学者卡在这里:代码明明写了__HAL_TIM_SET_AUTORELOAD(),示波器一看,频率纹丝不动。这不是宏写错了,而是根本没理解“写寄存器”和“寄存器生效”是两回事。

定时器内部,ARR、CCR、PSC这些寄存器背后都有一层“影子寄存器”。你通过总线写进去的值,先进入预装载寄存器;影子寄存器里的值才是硬件实际使用的值。预装载寄存器到影子寄存器的搬运,发生在更新事件发生时。

用生活化类比:预装载寄存器是“候选名单”,影子寄存器是“实际值班表”。你在办公室里改了候选名单(写预装载),值班表(影子)要等下次排班周期(更新事件)才换。好处是:如果更新事件是周期性的,所有改动都在周期边界统一生效,不会在两个周期中间出现一个异常短或异常长的脉冲。

5.2 ARPE位:决定ARR改动能多快生效

ARR是否使用预装载机制,由ARPE位(Auto-reload preload enable)控制:

ARPE写入ARR后的行为适用场景
1(Enable)下一个更新事件才生效,周期边界切换运行中改频率,不容易出毛刺
0(Disable)立即写入影子寄存器需要立即响应的场合,但可能产生畸形周期

CubeMX里默认Enable,这是最推荐的。PSC则没有这个开关,它一直具备预装载能力,所以改PSC后必须等更新事件。

CCR也有类似机制。PWM模式下,修改CCR不一定等新的更新事件,同一次比较匹配发生后也会加载新值。但在使用预装载机制后,最安全的表述是:新值不会造成当前周期的占空比异常跳变,会在“合适的时刻”生效。

5.3 想让新频率立即生效:手动产生更新事件

有些场景你不想等当前周期结束,比如用户按键切换转速,希望立刻生效。可以通过软件置位UG位产生更新事件:

__HAL_TIM_SET_AUTORELOAD(&htim2, 499); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 250); __HAL_TIM_GENERATE_UPDATE(&htim2); // 强制产生更新事件

但要提醒:UG位会同时更新所有带预装载的寄存器,包括PSC、ARR、CCR,同时还会把计数器CNT清零,并可能触发更新中断。如果你的定时器中断里做了周期相关的事情,比如ADC采样时刻控制,强制UG会让那个逻辑提前跑一次。用之前想清楚副作用。

更推荐的做法是直接调用4.4节封装好的函数,连续设置PSC、ARR、CCR后,让硬件自动在下一个周期边界统一切换。对于电机、灯效这类应用,晚一个PWM周期(几十微秒)根本感知不到,没必要用UG强行立即生效。

6. 上板实测的典型问题与排查思路

6.1 引脚量不到波形:先查时钟,再查复用

这是最典型的问题,代码逻辑看起来没问题,示波器探针一放上去就是一条平线。我的排查固定顺序是:

  1. 查时钟:进入调试器,看TIM2->PSCTIM2->ARR是不是预期值,再看RCC寄存器里TIM2时钟是否使能。如果PSC读到的是0,说明CubeMX生成的初始化根本没执行,先查MX_TIM2_Init()是否被调用。
  2. 查GPIO复用:看引脚对应的AFR寄存器是否设置正确,GPIO模式是不是GPIO_MODE_AF_PP而不是GPIO_MODE_OUTPUT_PP
  3. 查PWM是否启动:确认执行了HAL_TIM_PWM_Start()。很多人只调用了MX_TIM2_Init()就去看波形,当然没有输出。
  4. 查通道:确认示波器探针接在TIM2_CH1对应的引脚上,别接错引脚。

用逻辑分析仪抓PWM比示波器更直观,几十块的小工具就能看频率和占空比。调试阶段我基本是逻辑分析仪常挂,波形一抓,频率对不对、占空比有没有跳变一目了然。

6.2 改了频率但波形没变:十有八九是没等更新事件

我在4.3节写过,PSC写入后要等更新事件才生效。如果你在主循环里连续写PSC和ARR,然后又立刻用示波器看波形,看到的仍然是旧频率。

更隐蔽的情况是:你改了ARR,但CubeMX生成代码把ARPE设为Disable,此时写ARR立即生效,但占空比同时变了。所以遇到“频率没变但占空比变了”这种怪现象,先检查ARPE。

此外,如果程序在定时器更新中断里频繁写ARR,要注意用户代码和中断之间的竞争。比如主循环定时写入频率,中断里也修改ARR,两个写操作可能相互覆盖。这种情况建议:只保留一个地方负责写ARR,或者用标志位传递转速变更请求,由中断统一执行。

6.3 高级定时器TIM1没波形:MOE没使能

TIM1是高级定时器,输出互补PWM前必须使能主输出MOE位。用HAL库时HAL_TIM_PWM_Start()已经帮你做了,但如果你的工程里手动控制寄存器,或者调用的是HAL_TIM_PWM_Stop()之后再用寄存器方式重新启动,就可能漏掉MOE。

// 手动操作TIM1寄存器时必备 TIM1->BDTR |= TIM_BDTR_MOE; // 主输出使能

另外,TIM1的引脚默认可能与调试端口复用,或者与其他外设冲突。C542的TIM1_CH1通常可以映射到特定引脚,具体看CubeMX分配,不要照搬F103的老经验。

6.4 停止再启动失败:PWM输出状态没复位

主循环里做这样的操作:HAL_TIM_PWM_Stop()然后过一会儿再HAL_TIM_PWM_Start(),第二次启动后波形不出来了。这在高级定时器上容易出现,原因是MOE或通道使能位没有完全复位,或者更新标志没有清除。

稳妥做法是停止后加一段小延时,再启动前调用:

__HAL_TIM_CLEAR_FLAG(&htim1, TIM_FLAG_UPDATE); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1);

如果是手动寄存器方式,检查TIM1->CCERTIM1->BDTR的MOE位确实被清掉了,再重新配置。

7. 三种典型负载的实验记录:呼吸灯、舵机与直流电机

7.1 呼吸灯:用占空比线性变化验证基本功能

最简单也最有效的PWM验证实验,就是驱动一个LED做呼吸灯。C542的GPIO驱动能力强一点,但直接推LED还是要串一个220Ω到1kΩ限流电阻,具体阻值按LED额定电流算。

1kHz PWM,ARR=999,主循环里CCR从0递增到999,再递减回0。占空比变化频率大约2秒一个来回,代码:

while (1) { for (uint16_t i = 0; i <= 999; i += 5) { __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, i); HAL_Delay(5); } for (uint16_t i = 999; i >= 5; i -= 5) { __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, i - 5); HAL_Delay(5); } }

跑起来你会发现一个现象:线性递增占空比,LED亮度变化并不线性,暗处变化明显,亮处变化很小。这是人眼对亮度的感知近似对数的特性,不是芯片或者代码的问题。想做出更舒服的呼吸效果,可以对CCR做gamma校正,把线性值映射成接近指数变化的序列。数据量不大时,可以直接在代码里放一张查表。

7.2 舵机控制:50Hz固定频率,只变脉宽

标准舵机(比如SG90、MG995)对PWM的要求和LED完全不同:频率必须稳定在50Hz左右,占空比本身不重要,重要的是高电平持续的时间,也就是脉宽。0.5ms对应0°,1.5ms对应90°,2.5ms对应180°。

用C542的TIM2配置50Hz:

  • PSC = 95,计数器时钟 = 48MHz / 96 = 500kHz
  • ARR = 9999,PWM频率 = 500kHz / 10000 = 50Hz
  • CCR = 脉宽时间 * 500kHz

各角度对应的CCR值:

角度脉宽CCR占空比
0.5ms2502.5%
90°1.5ms7507.5%
180°2.5ms125012.5%

写代码时直接用CCR控角度比用百分比直观得多:

// 舵机转到90度 __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 750);

舵机对频率抖动不敏感,但对周期稳定性有要求。C542的定时器由硬件产生PWM,不占用CPU,周期非常稳定,比用GPIO模拟PWM的方案可靠得多。这里也顺带说明一个误区:有人试图用主循环延时加翻转GPIO模拟50Hz,结果发现舵机抖动。原因是中断、循环长短都会影响周期。硬件PWM的伟大之处就在于此:配置好之后,CPU哪怕去跑别的任务,波形依然稳定。

7.3 直流电机调速:频率选择与死区问题

直流有刷电机调速是PWM频率选择最有代表性的场景。频率太低,能听到电机啸叫,电流纹波也大;频率太高,MOS管开关损耗上升,驱动芯片发热。工程上常用10kHz到20kHz。以20kHz为例:

  • PSC = 3,计数器时钟 = 48MHz / 4 = 12MHz
  • ARR = 599,PWM频率 = 12MHz / 600 = 20kHz
  • CCR = 占空比 * 600

这种参数下占空比步进约0.17%,调速足够细腻。

做电机驱动还回避不了一个词:死区。如果你用的是集成电机驱动芯片(比如DRV8833、TB6612),芯片内部已经处理了死区,MCU不需要管。但如果你用分立MOS管搭H桥,TIM1的互补PWM就派上用场了:

  • TIM1_CH1和TIM1_CH1N输出互补波形
  • CubeMX的Break and Dead-Time设置里填死区时间
  • 死区时间必须大于MOS管关断时间,否则上下管有一瞬间同时导通,轻则发热,重则炸管

C542的TIM1能直接硬件插入死区,不需要软件干预。死区时间的具体数值和高速时钟有关,CubeMX参数面板会以纳秒为单位实时显示,直观好用。这里提醒一句:拿示波器量互补波形时,先确认探头共地,双通道同时抓,否则看不到死区细节。

7.4 中心对齐模式:什么时候值得用

C542的定时器除了边沿对齐(向上计数),还支持中心对齐模式(向上/向下计数)。中心对齐模式下,计数器先从0数到ARR,再从ARR数到0,一个完整PWM周期包含两次溢出。这种模式有两个特点:

  • PWM频率 = 定时器时钟 / (2 * (PSC+1) * (ARR+1)),注意多乘了一个2
  • 输出波形的上升沿和下降沿相对周期中心对称

中心对齐模式在电机控制里很常见,特别是配合互补PWM使用,因为对称波形能减小输出谐波。同时,计数器的三角波形状也让ADC采样时机更容易安排——你可以在计数器到达ARR或到达0时触发ADC采样,获得固定的电气角度位置。这篇先不展开细节,但如果你以后要做FOC或者无刷电机控制,一定会回来用这个功能。

做电机驱动如果只想要简单调速,边沿对齐足够,中心对齐不是必须。我的建议是:先把边沿对齐玩熟,理解频率、占空比、预装载这些核心概念,再去碰中心对齐和死区。

最后说点我自己的调试习惯。PWM这种外设,逻辑不复杂,出问题大多出在配置细节上。按“时钟→复用→参数→时序”四步排查,基本能解决99%的问题:先确认定时器时钟频率,再确认引脚复用,再看PSC/ARR/CCR数值是否吻合,最后确认预装载和更新事件是不是按预期触发。千万别在波形不对的时候上来就怀疑芯片,C542的定时器外设很稳定,绝大多数问题出在拿着F1系列的经验来套C0系列。手边常备逻辑分析仪,抓一次波形,很多疑问就消失了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询