简介:面向嵌入式与电力电子初学者,这份工程代码演示了基于STM32的双极性SPWM波生成方法,支持通过修改滤波器参数调整输出频率,适用于逆变器控制、电机驱动及SPWM算法原理验证等场景。压缩包共142个文件,约5.84MB,核心包含STM32CubeMX配置工程(.ioc)、以.c/.h为主的源程序以及HAL库代码,并附有编译生成的hex、axf和map文件,可直接烧录到开发板运行,便于对照波形结果与中间文件深入理解定时器PWM输出和滤波环节的配合。工程代码采用典型的C语言项目结构,涵盖载波与调制信号比较、脉冲宽度变化到低通滤波还原正弦波的完整链路;用户可自行调整调制比、载波频率等参数观察波形变化,从而掌握用软件方式实现模拟信号数字控制的核心方法。已有784人学习,适合使用STM32库开发或研究SPWM技术的中初级开发者参考。
1. 一个PWM代码包,为什么值得拆开看
我拿到一个叫sjx_PWM.zip的源码包时,第一反应是“这玩意儿还需要单独发一个包?”做嵌入式的人谁没写过PWM:初始化定时器、配占空比、输出波形,看起来十分钟能搞定。但真正调过电机驱动、调光电源或舵机控制的人都知道,PWM是所有外设里最“看起来简单、翻起车来玄学”的模块。频率怎么选、极性怎么配、死区留多少、初始占空比定多少,每一步都有讲究。这个包适合两类人:一类是刚入手单片机、想把PWM从“会配寄存器”变成“能稳定输出”的开发者;另一类是已经写过不少PWM,但被相位、死区、噪声折磨过、想找一份能直接落地做参照的工程。这篇笔记就按我拆这个包的顺序,把代码结构、配置流程和踩过的坑一次说透。
2. PWM的选型与代码结构:从寄存器到封装的取舍
拿到源码包先别急着编译,先搞清楚这份代码到底属于哪条实现路径:硬件PWM、软件PWM还是定时器中断模拟PWM。这三条路的代码长得完全不一样,用错场景就是灾难。
2.1 硬件PWM、软件PWM与定时器中断怎么选
sjx_PWM.zip里我翻了一圈,核心是围绕某Cortex-M内核MCU的标准外设库写的,走的是硬件PWM通道:定时器产生基础频率,比较寄存器控制占空比,由硬件自动翻转引脚,CPU只在改占空比时介入。这是目前最省心、最稳的实现方式。
| 实现方式 | 频率上限 | CPU占用 | 适用场景 | 缺点 |
|---|---|---|---|---|
| 硬件PWM | 与定时器时钟相关,可达数十MHz | 极低 | 电机调速、LED调光、开关电源 | 通道数受定时器数量限制 |
| 软件PWM(GPIO翻转) | 受循环耗时影响,通常几十kHz以内 | 高 | 对成本敏感、临时测试场景 | 频率和占空比都不稳 |
| 定时器中断模拟 | 受中断频率限制,通常几十kHz以内 | 中 | 多路PWM但硬件通道不足 | 中断频繁时影响主循环时序 |
在普通电机调速和LED调光场景里,硬件PWM是首选,理由很直接:频率由定时器时钟和预分频系数决定,不随主循环负载变化。你用软件GPIO翻转跑PWM,主循环里多几行延时,频率跟着抖,示波器上波形宽度肉眼可见地漂,控制对象跟着抖。硬件PWM把“什么时候翻转引脚”这件事交给定时器硬件,主循环干别的事也不影响波形。
2.2 代码包里的核心模块拆解
这个包的文件结构不复杂,我拆给你看:
sjx_PWM/ ├── pwm.c # PWM初始化与占空比设置接口 ├── pwm.h # 对外暴露的配置宏与函数声明 ├── main.c # 一个演示用的渐变呼吸灯示例 └── readme.txt # 引脚分配与参数计算说明pwm.c里的核心初始化函数是整个包的地基,我直接贴出来:
void PWM_Init(uint16_t freq_hz, uint16_t duty_percent) { uint32_t clk = 72000000; // 定时器时钟72MHz,按实际芯片设 uint32_t prescaler, period; // 分两步计算:先保证PWM频率尽量精确 prescaler = (clk / (freq_hz * 1000)) - 1; // 粗调分频,让计数频率落在kHz级 period = (clk / (prescaler + 1)) / freq_hz - 1; // 配置定时器时基:计数周期为 period TIM_TimeBaseInitTypeDef tb; tb.TIM_Prescaler = prescaler; tb.TIM_Period = period; tb.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &tb); // 配置PWM通道模式:向上计数,低电平有效,输出极性翻转 TIM_OCInitTypeDef oc; oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_OutputState = ENABLE; oc.TIM_Pulse = (uint32_t)((uint32_t)period * duty_percent / 100); oc.TIM_OCPolarity = TIM_OCPolarity_Low; TIM_OC1Init(TIM2, &oc); TIM_Cmd(TIM2, ENABLE); // 启动定时器 }这段代码的逻辑是:先把72MHz的定时器时钟降到一个合适的计数频率,再根据目标频率计算整个周期要数多少个计数点,最后把占空比换算成比较寄存器里放的值。这里有两个细节值得注意:一是prescaler先除freq_hz * 1000,是刻意把计数频率压到kHz级再细算,避免直接用72MHz时period值太大导致16位寄存器溢出;二是oc.TIM_Pulse用period * duty_percent / 100,先乘后除,保证整数运算不丢精度。如果你把duty_percent放 uint8_t,这里记得强转成 uint32_t 再乘,否则溢出后占空比会突然跳到奇怪的值。
2.3 初始化参数怎么定:频率、占空比、死区时间
拆完代码,关键还是参数怎么落。我一般按这个顺序确定:
- 频率:先看控制对象。电机驱动常用20kHz左右,避开人耳可听频段;LED调光至少也要1kHz以上,否则用手机摄像头能看到频闪;舵机控制反而是固定的50Hz,周期20ms,脉宽500us到2500us。
- 占空比边界:确认硬件允许的最小和最大占空比。电机驱动里0%和100%意味着全关和全开,但很多驱动芯片要求PWM输入不能长期停在某个极限值,得在代码里做钳位。
- 死区时间:如果你用的是H桥或半桥电路,同一桥臂上下两个管子不能同时导通,必须在互补PWM之间插入死区。这个包里的
pwm.h暴露了一个宏PWM_DEADTIME_NS,按你选的功率管栅极电荷和驱动芯片的关断延时来算,一般取几百纳秒到几微秒。我习惯先按功率管数据手册里的关断延时上限的两倍设,再拿示波器测实际的上升沿和下降沿交叉点,慢慢往下压。
死区设置是典型的“抄作业不成功”参数。有人直接照搬别人的值,结果自己用的管子开关速度不一样,上下管直通发热,板子烫手才想起回头查。设置死区后一定要在满载电流下看波形交叉处的电流尖峰,尖峰大说明死区不够。
3. 把PWM代码跑起来:配置步骤与实测方法
代码结构和参数公式都清楚了,这一步从上电到出波形,把完整流程走一遍。这段流程我每次接手新板子都会强制自己走,省掉后面大量排错时间。
3.1 工程导入与引脚复用检查
sjx_PWM.zip里没有给你搭好的IDE工程,它给的是pwm.c、pwm.h和main.c这三个源码文件。常见做法是新建一个标准外设库工程,把这三个文件加进去,然后检查两件事。
第一件是引脚复用。PWM信号不是随便一根GPIO就能输出的,定时器通道对应固定的引脚,比如某MCU的TIM2_CH1映射在某个特定引脚上。你打开芯片数据手册的“Alternate Function Mapping”表格,确认你板子上实际连到驱动电路的引脚,能复用成你要用的定时器通道。这一步错了一步,后面示波器永远看不到波形。
# 检查GPIO复用:以某Cortex-M0内核芯片为例 # 在SystemInit之后、PWM_Init之前,配置复用功能 GPIO_PinAFConfig(GPIOA, GPIO_PinSource0, GPIO_AF_1); // 把PA0复用为定时器通道 GPIO_InitTypeDef gpio; gpio.GPIO_Pin = GPIO_Pin_0; gpio.GPIO_Mode = GPIO_Mode_AF; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio);这段配置的目的是把引脚从普通GPIO模式切到外设复用模式。很多新手卡在这:初始化代码写了,引脚也配了,但忘了GPIO_PinAFConfig这一步,结果引脚输出永远是低电平。注意GPIO_AF_1这个参数不是随便填的,必须查芯片手册确认该定时器通道在这个引脚上是第几号复用功能,填错编译不报错,但运行时没波形。
3.2 用一个最小示例验证PWM输出
导入成功后,直接用包里的main.c跑一个呼吸灯示例做最小验证。这个示例的思路不是接电机,而是接一个LED,让占空比从0到100再回到0循环变化,用肉眼就能判断PWM是否工作。
int main(void) { SystemInit(); // LED接在PA0对应的PWM通道上,通过1k电阻到地 PWM_Init(1000, 0); // 1kHz频率,初始占空比0% while (1) { for (uint8_t duty = 0; duty <= 100; duty += 2) { PWM_SetDuty(duty); DelayMs(20); // 每20ms调一次,肉眼可见渐变 } for (int8_t duty = 100; duty >= 0; duty -= 2) { PWM_SetDuty(duty); DelayMs(20); } } }这段示例里有两个用意。一是PWM_SetDuty这个接口被单独抽出来了,它的内部实现是把目标占空比先钳位在0到100之间,再换算成比较寄存器的值,这样上层代码永远不会把越界值写进寄存器。二是占空比每次只加减2%,而不是直接跳变,避免LED电流突变。如果这个示例跑起来LED亮度平滑变化,说明定时器、比较寄存器、引脚复用这一段链路是通的,接下来就可以进入真实负载调试。
3.3 示波器与逻辑分析仪的测量技巧
代码烧进去之后,别急着调参数,先上示波器量两件事:PWM频率是否精确、占空比是否准确。测量时注意以下要点:
- 探头接到PWM输出引脚时,地线夹要尽量短,直接夹在引脚旁边的地焊盘上。地线夹长了会引入环路电感,高频分量上会叠加振铃,看起来像信号质量问题。
- 示波器设置为上升沿触发,触发电平放在波形幅度的50%左右。别用自动触发,自动触发在信号没稳定时会乱触发,观察相位抖动时容易误判。
- 测量频率用示波器的频率测量功能,不要自己用周期的倒数去算,测量精度完全够用。
- 量占空比时打开示波器的占空比测量项。如果你发现实际占空比和代码里设置的差几个百分点,先检查是不是数学计算时整数溢出,再看
TIM_Pulse写入的寄存器值是否被其他代码意外修改。
逻辑分析仪在这个场景下比示波器更直观,尤其是看多路PWM之间的相位关系。把三路PWM信号分别夹到逻辑分析仪的通道上,能直接看到各路波形是否同步启动。某次我调一个三相电机驱动,发现三路PWM在使能瞬间有几十微秒的时间差,就是靠逻辑分析仪抓到起始沿不对齐,追查下来是三个定时器分别使能导致启动时刻不一致。
4. 常见坑与排查:极性、死区、边界占空比、噪声误判
这部分是血泪经验汇总。拆过几个PWM源码包、调过几块驱动板之后你会发现,PWM的坑很少在新手预想的地方。新手以为难的是“怎么配置寄存器”,实际上配置半小时搞定,真正烧时间的是下面这四类问题。
4.1 波形有输出,但电机方向反了或LED亮度不对
现象:初始化代码照抄,示波器上也看到方波了,但电机转速方向跟预期相反,或者LED在占空比50%时亮度明显偏暗。
原因:极性配置反了。代码里TIM_OCPolarity设的是Low,意思是计数值小于比较值时输出低电平,也就是说高电平时间对应的是计数周期减去TIM_Pulse。你以为是“占空比越大越亮”,实际可能变成了“占空比越大越暗”。
解决:把TIM_OCPolarity改成High,或者保留极性不变但在初始化后立刻取反逻辑。我一般会做一个PWM_SetPolarity接口,运行时动态切换极性,配合示波器看波形高电平宽度和占空比设置值是否方向一致,比改一次烧一次固件快得多。
4.2 死区时间设太小,板子发烫甚至冒烟
现象:驱动板空载调试一切正常,接上电机后运行不到一分钟,MOS管或驱动芯片异常发烫,有的板子直接烧了。
原因:互补PWM输出时,上下桥臂的导通信号之间有重叠窗口,叫直通。功率管开关有延迟,栅极驱动也有延迟,如果你的死区时间小于这些延迟之和,某个瞬间上下两个管子同时导通,电源直接短路。
解决:先查功率管数据手册里的td(off)和栅极驱动芯片的传播延迟,死区初始值设为这两个参数之和的两倍。用示波器两只探头同时量上管和下管的栅极信号,观察两个波形的下降沿和另一个的上升沿之间是否有重叠。我第二次调死区时的经验是:把死区时间做成可调参数,从2us往下每次减0.2us,同时盯着母线电流,一旦电流出现明显台阶就退回上一档。这个“从大往小压”的方式比一次到位稳妥得多。
4.3 占空比写到0%和100%,输出不是预期电平
现象:代码里把占空比设为0,输出引脚仍能测到微弱的脉冲;设成100%,输出引脚有几十毫伏的残压。电机在0%占空比时居然还在慢慢转。
原因:PWM输出在边界值时,部分芯片的定时器通道并没有完全拉低或拉高。比如在PWM1模式下,当TIM_Pulse等于0时引脚不翻转,但有些芯片的默认输出电平受极性位影响,不会干净地停在低电平;TIM_Pulse等于period+1时才认为是全开,否则总有一个计数周期的窗口是关闭的。
解决:写驱动接口时,在PWM_SetDuty里加边界钳位逻辑:目标占空比为0时直接调用GPIO输出低电平,100%时直接调输出高电平,不走PWM通道。代码里预留了一个PWM_SetOutputLevel弱函数,就是为了干这个。注意钳位逻辑必须在换算比较寄存器之前做,否则边界值会被整数运算抹掉。
提示:如果0%占空比下LED还有微弱发光,先排查是否通过限流电阻漏电。把PWM引脚的功能换成普通GPIO模式拉低,如果仍能测到电压,问题就不在PWM代码,而在硬件走线或引脚上拉电阻。
4.4 示波器看到毛刺和平台抖动,以为是PWM干扰
现象:PWM波形上叠加了一串高频毛刺,或者在高电平中部出现莫名其妙的凹陷。新手很容易怀疑驱动代码出问题,加了各种滤波电容都不管用。
原因:两种常见情况。第一种是探头地线过长形成环路天线,拾取了空间高频噪声;第二种是PWM输出引脚与感性负载(电机、线圈)之间的布局不合理,负载开关瞬间的di/dt在走线电感上耦合出电压尖峰。
解决:先把探头地线夹缩短,用地弹簧直接套在探头尖端,重新测量。如果毛刺消失,说明是测量方式问题;如果毛刺还在,看毛刺频率是否与PWM频率一致,如果一致,多半是负载端需要加续流二极管或吸收电路。我调无刷电机时遇到过波形高电平中间凹陷的情况,最后查出来是负载电流过大导致电源电压被拉塌,在电源端加了大容量电解电容才解决。这种情况下改代码里的占空比计算逻辑完全没意义,问题不在固件。
5. 把PWM代码变成可控的调速/调光工具:一个实用技巧
sjx_PWM.zip里的代码配置好之后,你得到的是一个“能输出精确PWM波形的工具”。但工具离“能用的控制”还有一步距离。这章说一个我每次调PWM都会用到的技巧,尤其是调光、调速这种非线性场景。
第3章里main.c用的是线性渐变:占空比每次加减固定数值,LED亮度的变化是前段突兀、后段迟钝。原因是人眼对亮度的感知近似对数关系,对低亮度变化极其敏感,越往高亮度变化越不明显。电机转速对电压也不是线性的——低速段死区、静摩擦影响大,高速段反电动势让转速变化变缓。如果直接用线性步进去调占空比,用户会感觉前半段还没怎么转、后半段突然加速。
解决方法是把步进值改成指数映射表。把占空比0到100分成若干档,每档对应的比较寄存器值按指数曲线分布:
// 生成16点指数渐变查找表:占空比从0到100按2^曲线分布 const uint16_t exp_lut[16] = { 0, 2, 4, 8, 12, 18, 25, 34, 45, 58, 72, 88, 100, 100, 100, 100 }; uint16_t PWM_ExpDuty(uint8_t index) { if (index > 15) index = 15; return exp_lut[index]; }这张表的前半段步进小,后半段步进大,实际效果是调光时低亮度区域变化细腻、高亮度区域响应快。你会发现用这张表之后,呼吸灯看起来“线性”了——虽然占空比本身不是线性的,但人眼感受到的亮度变化均匀了。电机调速时也可以按这个思路做一张更细的表,把每档的占空比间隔在低速区压到1%以内,高速区放宽到5%,这样低速控制不会一抖一抖,高速响应也不拖沓。
从那以后我每次调PWM,都先固定频率、确认极性、留足死区,最后再处理映射关系。这套流程帮我挡掉了至少半打“改了一下午参数还是抖”的折腾,希望帮到你。
本文还有配套的精品资源,点击获取