STM32 TIM1中心对齐SPWM:固定载波频率的相位累加器调频调幅方案
简介基于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周期。所以公式是载波频率 定时器时钟 / ((ARR1) × 2 × (PSC1))举例来说STM32F103的TIM1时钟是72MHzPSC0ARR1799则载波频率 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 查表法为什么不在中断里算sinSTM32F103这种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(θ)当M0时占空比恒为50%滤波器输出为零M1时占空比在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 硬件连接与环境准备我这边用的芯片是STM32F103C8T6TIM1_CH1映射在PA8引脚用标准外设库开发。如果你用HAL库配置思路完全一样只是API名字要换一下。开发板上只需要一根杜邦线把PA8引出到示波器再准备一个LC低通滤波器验证正弦波恢复效果。L取1mHC取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库用户按同样的逻辑迁移即可。如果你们在实际项目中遇到了类似的问题或者有更好的调幅调频思路欢迎在评论区交流。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →