简介:基于STM32F103的SPWM信号生成工程包,面向嵌入式开发、电机控制与逆变电源设计场景,利用高级定时器TIM1实现输出频率和幅度可调、载波频率保持不变的调制信号,适合需要快速验证SPWM算法的工程师学习。压缩包共含219个文件,大小约5.72MB,除H头文件和C源程序外,还提供uvprojx工程配置、hex/axf烧录文件、map映射以及文本说明等,可直接导入开发环境编译下载,也可逐项查看外设初始化逻辑。目前已有615人学习下载,代码中重点展示了TIM1互补输出的引脚配置、占空比与预分频/自动重装载值的动态调整方法,并包含时钟初始化、PWM模式选择和运行时参数修改的中断处理思路。通过这套工程,可以清楚对照SPWM理论在STM32上的落地实现,并在此基础上扩展三相逆变或电机驱动等实际应用,具有一定参考价值。 前阵子帮朋友救一块单相逆变器板子,现象很典型:用STM32的TIM1做SPWM输出,想从50Hz切到60Hz,直接在中断里把定时器的ARR改小一截,结果正弦波频率确实上去了,但LC滤波器后面的电感也开始哼叫了,摸上去微微发热。用示波器一看,PWM的载波频率跟着ARR一起变了,原来20kHz的开关频率漂到了26kHz左右,整个滤波器设计基础全被掀了。这种“调个输出频率把载波也带跑”的问题,在逆变器、变频电源、D类功放的项目里特别常见。这篇文章就来讲清楚一套我一直在用的做法:以STM32的TIM1为中心对齐模式生成SPWM,通过相位累加器调节正弦波频率、通过调制比调节输出幅度,而载波频率完全保持固定。内容涉及原理、代码和调试方法,适合正在做电机驱动、逆变器、航模电调或者D类功放的同学参考。
1. 为什么有人会“改个输出频率,把载波也带跑了”
1.1 载波频率不是性能参数,而是系统的地基
SPWM的原理说起来很简单:把正弦调制波和三角载波比较,比较结果就是PWM波形。但很多人忽略了一点——三角载波的频率决定了PWM的开关频率,而这个开关频率在系统设计阶段就已经确定好了,不是一个可以随手调整的参数。
开关频率直接影响三样东西:滤波器的截止频率设计、功率管的开关损耗、以及整机的EMI特性。你改变开关频率,相当于把滤波器的设计基础改掉了,轻则输出波形纹波变大,重则电感饱和、管子过热。所以“载波频率保持不变”不是强迫症,是工程上的硬需求。尤其是用20kHz作为载波时,很多人是为了避开人耳可听范围,如果调到26kHz,噪声本身可能变小,但滤波器和开关损耗的问题全都冒出来了。
1.2 两种典型错误姿势:动ARR或动PSC
很多初学SPWM的同学会这样写:把正弦表一个周期对应到定时器ARR上,然后靠改变ARR来改变正弦波输出频率。看起来好像很直接——ARR大了,一个正弦周期占用的时间就长,正弦波频率就低;ARR小了,正弦波频率就高。但ARR本身恰好就是载波频率的分母,你动ARR的时候,三角载波的频率也在跟着动。这就是把调制波参数和载波参数耦合在一起的典型写法。
另一个常见错误是改PSC预分频器。有朋友觉得ARR是正弦波周期、PSC是载波周期,改PSC应该不影响正弦波频率。实际上PSC是整车计时器的分频,改了之后定时的时钟源频率变了,所有时间都会变,输出正弦波频率跟着变,载波频率也跟着变,压根分不开。
1.3 解耦思路:载波归定时器,频率归相位,幅度归调制比
正确的做法是把“产生PWM用的三角载波”和“调制波的正弦数据”分成两个独立轴来管理。三角载波由TIM1的PSC和ARR固定,一旦初始化就不再改变,载波频率恒定。正弦波的频率通过相位累加器的步长来调节,步长越大,走完一张正弦表越快,输出频率越高。正弦波的幅度通过调制比M来调节,M越大,占空比偏离50%的幅度越大,滤波后正弦幅度越高。这三者互不干扰,就实现了标题里“频率幅度可调、载波频率不变”的需求。
2. TIM1中心对齐模式:SPWM的天然载波发生器
2.1 三角载波 vs 锯齿载波,为什么选中心对齐
很多刚接触定时器的朋友习惯用向上计数模式,也就是锯齿波载波。锯齿波载波也能生成SPWM,但有一个缺点:它的PWM脉冲不是关于载波周期中心对称的,谐波里会多一些偶次分量。而中心对齐模式里,计数器先向上走到ARR,再向下回到0,形成的是三角波载波。三角载波和正弦波比较出来的PWM波形是对称的,低次谐波分量小一些,滤波起来更轻松。TIM1恰好就支持这种模式。
2.2 TIM1比普通定时器多出来的三招
既然TIM2、TIM3这些通用定时器也支持中心对齐,为什么非要用TIM1?主要原因有三条:互补输出、死区发生器、刹车输入。互补输出指的是TIM1的CH1可以同时输出CH1N,也就是一对互补的引脚,天然适配半桥、全桥的上下管驱动。死区发生器可以在硬件层面配置死区时间,避免上下桥臂直通,这在逆变器里是保命设计。刹车输入则提供了硬件级故障关断能力,做电源类产品时非常必要。如果你的SPWM只是带一个纯阻性负载,用普通定时器确实也能出波形;但面向逆变器这种真正需要SPWM的场景,TIM1的这几项能力基本是标配。
2.3 中心对齐模式下载波频率的算式
中心对齐模式下,计数器从0走到ARR算半个周期,再走回来才算一个完整PWM周期。所以公式是:
载波频率 = 定时器时钟 / ((ARR+1) × 2 × (PSC+1))
举例来说,STM32F103的TIM1时钟是72MHz,PSC=0,ARR=1799,则载波频率 = 72M / (1800 × 2) = 20kHz。这个20kHz一旦定下来,之后所有调频调幅操作都不允许再去碰PSC和ARR。很多人在初始化时把PSC和ARR写得很大或很小,没有意识到这两个值直接决定载波频率,导致滤波器的LC参数没法匹配,这是后续调试苦不堪言的根源。
2.4 比较值预装载:防止波形毛刺的关键
中心对齐模式下,CCR比较值寄存器的生效时机比较特殊。如果你直接往CCR写新值,它并不会立刻生效,而是在特定计数边界才装载到影子寄存器。实际工程中一般要打开OC预装载功能,也就是调用TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable)。这样做的好处是:中断里计算完新占空比后先写到预装载寄存器,等计数边界到了再一次性生效,波形不会在更新瞬间出现毛刺。这个细节如果不注意,SPWM输出会出现窄脉冲抖动,滤波后表现为高频噪声,很难排查。
3. 相位累加器:用“走步子”让频率调节变得丝滑
3.1 查表法:为什么不在中断里算sin
STM32F103这种M3内核跑72MHz,实时调用sin函数其实也够用,但中断频率是20kHz,意味着每50微秒就要算一次;而且sin函数库调用时间不固定,可能造成抖动。工程上更常用的是查表法:提前把正弦波一个周期离散成N个点,存成一张const数组,中断里直接按索引取数,耗时固定而且非常短。256点的表也就是512字节,对单片机的Flash来说微不足道。
3.2 相位累加器:把频率换算成“步子大小”
相位累加器可以理解成一个钟表的秒针:秒针每次走一格,走完60格就是一分钟。如果我们让秒针每次都走1.5格,那走完一圈的时间就变短了,转轮的频率就变高了。放到底层实现就是:
g_phase_acc += g_phase_inc; // 每次中断累加 index = (g_phase_acc >> 24) & 0xFF; // 取高8位作为查表索引其中g_phase_acc是一个32位无符号变量,g_phase_inc是相位增量。查表索引取累加器的高8位,表示把整个累加器分成256份,对应正弦表256个采样点,代表一个完整周期2π。这种写法最巧妙的地方在于:相位累加器即使溢出,也只是自然回绕,相当于正弦波走完一圈再从头开始,不会出现索引跳变。
3.3 调制比与CCR的换算:幅度调节的本质
确定频率之后,紧接着是幅度。SPWM里有一套标准的线性调制区公式:
CCR = ARR/2 + M × (ARR/2) × sin(θ)
当M=0时,占空比恒为50%,滤波器输出为零;M=1时占空比在0%到100%之间摆动,输出幅度最大。工程上M一般限制在0.8~0.9左右,防止进入过调制区放大谐波。对应代码实现,正弦表存成Q15格式,数值范围-32768到32767,即sin值乘以32768。调制比M也用Q15格式,比如80%就是26214。
3.4 Q15定点数运算,省下每一次除法
为什么不用浮点数计算调制信号?因为20kHz中断里每50微秒就要算一次,浮点乘法在M3内核上没有硬件加速,一次也要几十个周期,虽然能忍受,但没必要。用Q15定点数之后,两次乘法加两次移位,十几个周期就能算完。具体换算时要注意乘法溢出,我用的是分步计算:先把sin_val * g_amp_q15除以32768还原成一个有符号的Q15信号,再乘以g_half_arr除以32768得到最终偏离50%的增减量。这样每一步的结果都在int32范围内,既安全又省掉64位运算。
4. 工程代码:初始化与中断更新CCR的具体写法
4.1 硬件连接与环境准备
我这边用的芯片是STM32F103C8T6,TIM1_CH1映射在PA8引脚,用标准外设库开发。如果你用HAL库,配置思路完全一样,只是API名字要换一下。开发板上只需要一根杜邦线把PA8引出到示波器,再准备一个LC低通滤波器验证正弦波恢复效果。L取1mH,C取10uF,截止频率大约1.6kHz,对20kHz的载波有足够衰减。
4.2 TIM1初始化:标准库代码和最容易漏掉的一行
void SPWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure; TIM_OCInitTypeDef TIM_OCInitStructure; TIM_BDTRInitTypeDef TIM_BDTRInitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1 | RCC_APB2Periph_GPIOA, ENABLE); // PA8复用为TIM1_CH1 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 20kHz载波,SC音范围之外 TIM_TimeBaseInitStructure.TIM_Period = 1799; TIM_TimeBaseInitStructure.TIM_Prescaler = 0; TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_CenterAligned1; TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM1, &TIM_TimeBaseInitStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 900; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM1, &TIM_OCInitStructure); TIM_BDTRInitStructure.TIM_OSSRState = TIM_OSSRState_Disable; TIM_BDTRInitStructure.TIM_OSSIState = TIM_OSSIState_Disable; TIM_BDTRInitStructure.TIM_LOCKLevel = TIM_LOCKLevel_OFF; TIM_BDTRInitStructure.TIM_DeadTime = 0; TIM_BDTRInitStructure.TIM_Break = TIM_Break_Disable; TIM_BDTRInitStructure.TIM_BreakPolarity = TIM_BreakPolarity_High; TIM_BDTRInitStructure.TIM_AutomaticOutput = TIM_AutomaticOutput_Enable; TIM_BDTRInit(TIM1, &TIM_BDTRInitStructure); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); NVIC_InitStructure.NVIC_IRQChannel = TIM1_UP_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); TIM_ITConfig(TIM1, TIM_IT_Update, ENABLE); TIM_Cmd(TIM1, ENABLE); TIM_CtrlPWMOutputs(TIM1, ENABLE); }注意:高级定时器TIM1和TIM8在初始化时必须调用
TIM_CtrlPWMOutputs(TIM1, ENABLE)。这个细节漏掉的话,任你怎么配都不会有输出波形。网上很多“TIM1没波形”的帖子,九成都是栽在这一行。
4.3 更新中断:用方向位保证一个载波周期只改一次CCR
关键代码在中断服务函数里。这里有个容易模糊的细节:中心对齐模式下,更新中断不一定只在载波周期边界触发一次,具体要看CMS对齐模式的配置。为了让代码不依赖具体的模式配置,我在中断里读取CR1寄存器的DIR方向位,只在向上计数阶段更新CCR。这样无论STM32手册里的CMS模式怎么定义,最终效果都是每个载波周期在同一个相位位置刷新一次比较值。
void TIM1_UP_IRQHandler(void) { if (TIM_GetITStatus(TIM1, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM1, TIM_IT_Update); // 只在向上计数方向对应的中断里刷新比较值 if ((TIM1->CR1 & TIM_CR1_DIR) == 0) { uint16_t idx; int16_t sin_val; int32_t delta; int32_t ccr; g_phase_acc += g_phase_inc; idx = (g_phase_acc >> 24) & 0xFF; // 256点表,取高8位 sin_val = g_sin_table[idx]; // Q15格式,约±32767 // 幅度换算,分步计算避免32位溢出 delta = (int32_t)sin_val * g_amp_q15 / 32768; delta = delta * g_half_arr / 32768; ccr = g_half_arr + delta; if (ccr > g_arr) ccr = g_arr; if (ccr < 0) ccr = 0; TIM_SetCompare1(TIM1, (uint16_t)ccr); } } }代码里的限幅处理很重要。当调制比接近1时,CCR可能会超过ARR的上限,如果不限幅,定时器比较输出会进入异常状态,表现为半个周期输出恒高或恒低。实际工程中我一般把最大调制比限制在0.9左右,同时保留限幅作为最后一道保险。
4.4 调频调幅接口与256点正弦表生成
调频和调幅对外暴露的接口很简单:
void SPWM_SetFreq(uint16_t freq_hz) { g_phase_inc = (uint32_t)((uint32_t)freq_hz * 4294967296.0f / CARRIER_HZ); } void SPWM_SetAmp(uint8_t percent) { if (percent > 100) percent = 100; g_amp_q15 = (uint32_t)percent * 32768 / 100; }正弦表不需要手敲,我用Python生成后直接粘贴到C文件里:
import math N = 256 for i in range(N): v = int(32767 * math.sin(2 * math.pi * i / N)) print("%d, " % v, end="") if (i + 1) % 8 == 0: print()如果你想改变正弦波信号的相位起点,比如让索引0对应峰值,那么在生成表的时候给相位加上π/2偏移即可。逆变器项目里通常不需要这个偏移,但做PLL锁相的时候可能用得上。
5. 实测验证:怎样一眼看出“载波没变”
5.1 示波器先看PWM,载波稳不稳一目了然
调完代码上电,第一件事就是把示波器探头夹在PA8引脚,看PWM输出频率。正常情况下应该稳定在20kHz,脉冲宽度随正弦波缓慢变化。然后调用SPWM_SetFreq(60),再测一次PWM频率,如果仍然是20kHz,说明载波没有受影响,这一步就过了。很多人的问题在这一步就会暴露出来:如果PWM频率跟着变了,说明ARR或PSC被意外修改,代码逻辑还没真正解耦。
5.2 LC滤波后看正弦波,频率和幅度分别验证
接下来把PA8接上LC滤波器,示波器探头放到电容两端。这时候看到的应该是一个干净的正弦波。调SPWM_SetFreq(50)和SPWM_SetFreq(60),正弦波频率应该分别为50Hz和60Hz;调SPWM_SetAmp(30)到SPWM_SetAmp(80),正弦波峰峰值应该成比例变化。这两个验证做完,才算是真正实现了“频率幅度可调、载波频率不变”。
5.3 实测中踩过的四个坑
| 现象 | 原因 | 解决办法 |
|---|---|---|
| PA8没有波形 | 高级定时器主输出未使能 | 调用TIM_CtrlPWMOutputs(TIM1, ENABLE) |
| 正弦波畸变、相位突变 | 查表索引计算时用了有符号数 | 用uint32_t做相位累加和移位 |
| 输出波形不对称 | 上下两个方向的更新中断都在改CCR | 用DIR方向判断保证一个载波周期只更新一次 |
| 频率调节不连续 | 用步长直接改查表索引 | 用32位相位累加器+定点步长 |
这几个坑里,最隐蔽的是第二个。用有符号数做右移时,C语言会做算术移位,高位补符号位而不是补零,导致索引跳来跳去,波形看起来像毛刺,很难联想到是移位问题。后来我把累加变量改成uint32_t,问题立刻消失。
5.4 中断负载:20kHz虽然高,但可以省着用
20kHz的中断每50微秒进一次,CPU占用其实已经不小了。中断里做两次Q15乘法加一次查表,大概几十个周期,整体占用还不算夸张。但如果你在中断里加了浮点运算、打印调试、甚至阻塞延时,主程序基本就别想跑了。我的习惯是中断里只做SPWM计算,调频调幅的命令放在主循环里通过函数修改全局变量。
6. 从单相到三相:这套方案在逆变器里的延伸
6.1 死区时间计算:上下管不能同时导通
如果后面接的是半桥或全桥拓扑,TIM1的互补输出开始派上用场。上下两个管子如果同时导通,电源直接短路,轻则烧管子,重则炸模块。死区时间的计算方法是:先查功率管的栅极驱动延时手册,得到开通延时和关断延时,然后取开关时间差的两倍再加一点裕量。
举例来说,如果关断延时是200ns,开通延时是100ns,那么死区至少要取300ns以上,通常我会再放2到3倍余量,取1μs左右。TIM1的DTG寄存器按定时器时钟节拍计算,72MHz时钟下1μs对应72个节拍,写入72对应的死区值即可。具体换算要看BDTR寄存器DTG位的分段规则,不同范围的分辨率不同,配置前先查芯片参考手册。
6.2 互补通道:TIM1的价值在硬件层面
如果你用的还是只输出PA8的单端SPWM,那TIM1的互补输出能力其实还没真正用上。在逆变器场景里,上下管驱动必须是一对互补的PWM,中间还要插入死区。TIM1的强大之处在于,这些逻辑全部由硬件完成:你只需要维护CH1的CCR值,CH1N会自动产生互补波形,死区由硬件延时保证,不依赖软件时序。软件中断即使偶尔抖动一下,也不会造成桥臂直通,这是用通用定时器做软件死区完全比不了的。
6.3 从SPWM到SVPWM,载波固定的框架不变
这套方案再往前走一步就是SVPWM。SVPWM本质上还是用TIM1的三个互补通道输出三相互补PWM,只是调制波计算从正弦表变成了扇区判断和矢量作用时间。载波不变、死区不变、中断刷新CCR的框架完全一致,你只需要把中断里“查正弦表”的那段代码换成“查矢量作用时间表”,剩下的架构都能继续用。这也是我建议把这套固定载波SPWM方案学透的原因,以后做FOC或者变频器,都是同一个套路。
这套“载波固定+相位累加+调制比调幅”的方案,我用了好几年,从最早的单相逆变器到后来的三相变频器,核心框架一直在复用。最大的体会就是:SPWM的难点不在写代码,而在参数解耦的思维方式。很多问题比如滤波器发热、噪声大、波形畸变,追根溯源都是因为无意中改变了载波频率。我的建议是代码里把CARRIER_HZ定义成固定宏,任何情况下都不要去动它;把调频、调幅做成独立接口,让上层调用的人只关心目标正弦波的频率和幅度,是50Hz还是60Hz、是100V还是220V,跟载波没有关系。文章里的代码基于标准外设库,HAL库用户按同样的逻辑迁移即可。如果你们在实际项目中遇到了类似的问题,或者有更好的调幅调频思路,欢迎在评论区交流。
本文还有配套的精品资源,点击获取