简介:面向电力电子与电机控制领域的嵌入式开发者,这份基于STM32的三相SPWM波形输出资源,以STM32高级定时器为核心,完整演示三相三臂逆变器驱动所需SPWM生成过程,内容涵盖中心对齐PWM模式、三通道比较值设定、同步更新、死区时间配置以及中断/DMA协作等关键技术环节。压缩包共174个文件,约2.81MB,以h与c源码、hex/axf烧录与调试文件为主,同时附有uvproj工程、map/list映射列表及bat辅助脚本,便于直接打开工程进行编译、下载与波形验证。工程内还保留了o/d/crf等中间文件,可结合map/lst快速核对链接映射与编译结果。已有4862人学习下载。通过阅读源码与对照实际输出波形,可快速掌握三相SPWM的定时器配置思路和逆变器死区处理细节,适合正在开发三相逆变器、电机驱动或UPS项目的开发者参考。 做电机驱动和逆变电源的朋友,对SPWM这个词肯定不陌生。最近我在做一套三相逆变器,核心就是用STM32的TIM1高级定时器输出三相互补SPWM波,去驱动IGBT模块。从原理推导到波形实测,前后折腾了一周,回头总结才发现,真正的坑不在SPWM算法本身,而在载波比选择、死区时间设置、定时器配置这些看起来不起眼但决定成败的细节上。这篇就把整套实现过程拆开讲清楚,从原理到代码再到实测波形,给正准备上手同类项目的你一份可以直接参考的实战记录。
1. SPWM原理拆解与方案选型
1.1 SPWM的核心思想与数字化实现
SPWM(Sinusoidal PWM)的本质是面积等效原理:用一系列宽度按正弦规律变化的矩形脉冲,去等效一个正弦波。把正弦波一个周期分成N等份,每一份对应的正弦曲线下的面积,用对应宽度的PWM脉冲去填充,滤波之后就能还原出正弦波形。
在STM32上实现SPWM,核心就是不断更新定时器比较寄存器(CCR)的值,让PWM占空比按照正弦规律变化。这里有个关键点:更新CCR的时机必须和PWM周期同步,否则波形会乱。所以工程上常用中心对齐模式(Center-aligned mode),在这种模式下,定时器计数到重载值再减到零,一个完整周期会产生两次更新事件,配合合理配置能生成对称的PWM波形,谐波分量更低。
我在这套方案里选的是STM32F103系列,72MHz主频,TIM1高级定时器自带互补输出和死区插入功能,做三相全桥驱动非常合适。你可以用F407甚至G4系列,逻辑是一样的,只是定时器资源更多、分辨率更高。
1.2 查表法到底怎么查
SPWM的数字化实现目前主流是两种方案:实时计算和查表法。
实时计算就是在每个PWM周期里去算正弦值,然后换算成占空比。这种方法优点是灵活,频率和幅值随时可调,缺点是对CPU占用高,尤其当载波频率到20kHz、三路独立计算时,中断里要跑三角函数,F103这种主频不高的芯片会明显吃力。
查表法则是提前把正弦波一个周期的值离散化成一张表,运行时直接查表取数。相当于把正弦函数换成了查数组的操作,一个周期只需要一次内存读取加一次比较寄存器写入,效率高一个量级。
我这边用的是256点正弦表,放在内部Flash里。之所以选256点,是因为它正好是2的8次方,用uint8_t做索引时天然不会越界,而且查表地址计算可以用移位代替除法。这个细节在后面代码部分会体现。
1.3 载波比与调制度的选择
载波比N是载波频率和调制波频率的比值,也就是一个正弦周期内的PWM脉冲个数。三相系统里,N最好取3的整数倍,这样才能保证三相波形严格对称,每相之间相位差正好是120度对应的采样点数。
我实测下来,20kHz载波、50Hz工频输出时,N=400,效果很不错。THD能控制在3%以内。如果N取得太小,比如低于30,输出波形会明显出现台阶感,谐波大幅增加。
调制度M是正弦波幅值与三角载波幅值的比值,直接决定输出基波电压大小。M在0到1之间时,输出与M近似线性;超过1就进入过调制区,波形开始畸变。工程上我一般取0.8到0.9,留一定裕量,给死区效应和母线电压波动留出补偿空间。这个选择直接关系到你最终能输出多高的线电压,计算方式后面会讲到。
2. 硬件平台与定时器资源分配
2.1 主控选型与外设评估
很多人一上来就纠结选哪颗芯片,其实做SPWM输出,核心就看三点:高级定时器数量、主频够不够、Flash容量能不能放正弦表。
STM32F103C8T6这颗经典芯片就完全够用。它有一颗TIM1高级定时器,72MHz主频,64KB Flash,256点的uint16_t正弦表才占512字节,完全不是问题。如果你的项目还要同时做闭环控制、通讯、显示,建议上F407,主频168MHz,多一颗TIM8也能做第二路三相输出。
我这边选的是F103VET6,因为手头正好有这块板子,而且引脚多,方便同时引出六路PWM和几路ADC采样。
2.2 高级定时器的互补输出和死区
TIM1的通道1、2、3分别对应三路PWM,同时每个通道还有一个互补输出(CH1N、CH2N、CH3N),一共六路,正好驱动三相全桥的六个开关管。
互补输出的意义在于:上下桥臂不能同时导通,否则母线直接短路。所以必须在硬件层面保证两路信号之间存在一个“死区时间”(Dead Time),也就是先关断上管、延迟一段时间再开通下管。
STM32高级定时器的死区插入是硬件完成的,只需要在初始化时设置一个寄存器值。死区时间可以精确到几个ns级别,比用软件延时可靠得多。我这边用的IGBT模块要求死区不小于1.5us,所以我在初始化里把死区配置成了2us左右,留足安全裕量。
还有一个容易忽略的功能是刹车输入(BKIN)。当功率电路出现故障时,硬件引脚只要拉低,定时器立刻封锁所有输出,不需要CPU干预。这个功能在电机驱动和逆变电源里是保命用的,一定要接好。
2.3 从控制板到功率管的信号链路
SPWM信号从单片机出来,不能直接驱动IGBT,中间要经过驱动电路。我用的方案是MCU输出3.3V PWM信号,经过IR2110驱动芯片,再推动IGBT模块的栅极。
信号链路的关键点在于隔离和地线处理。控制板和功率板之间我用的是光耦隔离,避免功率回路的电压尖峰通过地线干扰MCU。实测下来,如果省掉隔离,波形在开关瞬间会出现明显的振铃,严重时直接导致单片机复位。
另外,栅极驱动电阻的取值也影响开关速度。电阻太小,开关速度快但容易振铃;电阻太大,开关损耗高。我这边IGBT模块的建议值是10Ω,加了之后开关波形干净很多。
3. 核心代码实现与关键细节
3.1 正弦查找表的生成
正弦表可以直接用代码生成,不需要手工算。下面这段初始化函数,在系统启动时调用一次即可:
#define SINE_POINT_NUM 256U #define TIMER_PERIOD 2000U // 定时器重载值,决定PWM分辨率 uint16_t g_sin_table[SINE_POINT_NUM]; void SinTable_Init(void) { uint16_t i; for (i = 0; i < SINE_POINT_NUM; i++) { double angle = 2.0 * 3.14159265358979 * i / SINE_POINT_NUM; // 归一化到0~TIMER_PERIOD范围,占空比就是CCR/TIMER_PERIOD g_sin_table[i] = (uint16_t)((TIMER_PERIOD / 2 - 1) * (1.0 + sin(angle))); } }这段代码把正弦值映射到了0到TIMER_PERIOD之间,也就是占空比范围0%到100%。为什么要偏移到0以上?因为PWM占空比不可能是负值,正弦波负半周需要通过“占空比小于50%”来体现。
实际项目中我通常不直接调用标准库的sin函数,而是用DSP库的arm_sin_f32,速度更快,内存占用也更小。F103虽然不支持浮点硬件加速,但软件浮点算一次也才几微秒,初始化时算一次完全无压力。
3.2 TIM1初始化与三相互补通道配置
TIM1配置是整套代码的核心,这里用标准外设库做示范,HAL库逻辑完全一样:
void TIM1_SPWM_Init(uint16_t arr, uint16_t psc) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; TIM_BDTRInitTypeDef TIM_BDTRInitStructure; // 1. 使能时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1 | RCC_APB2Periph_GPIOA, ENABLE); // 2. 配置PA8(TIM1_CH1)、PA9(TIM1_CH2)、PA10(TIM1_CH3) // 以及PB13(TIM1_CH1N)、PB14(TIM1_CH2N)、PB15(TIM1_CH3N) GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15; GPIO_Init(GPIOB, &GPIO_InitStructure); // 3. 时基配置,注意计数器模式要选中心对齐 TIM_TimeBaseStructure.TIM_Prescaler = psc; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_CenterAligned1; TIM_TimeBaseStructure.TIM_Period = arr; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_RepetitionCounter = 0; TIM_TimeBaseInit(TIM1, &TIM_TimeBaseStructure); // 4. 通道配置为PWM1模式 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_OutputNState = TIM_OutputNState_Enable; TIM_OCInitStructure.TIM_Pulse = 0; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OCInitStructure.TIM_OCNPolarity = TIM_OCNPolarity_High; TIM_OCInitStructure.TIM_OCIdleState = TIM_OCIdleState_Set; TIM_OCInitStructure.TIM_OCNIdleState = TIM_OCNIdleState_Reset; TIM_OC1Init(TIM1, &TIM_OCInitStructure); TIM_OC2Init(TIM1, &TIM_OCInitStructure); TIM_OC3Init(TIM1, &TIM_OCInitStructure); // 5. 死区与刹车配置,这是高级定时器特有的 TIM_BDTRInitStructure.TIM_OSSRState = TIM_OSSRState_Enable; TIM_BDTRInitStructure.TIM_OSSIState = TIM_OSSIState_Disable; TIM_BDTRInitStructure.TIM_LOCKLevel = TIM_LOCKLevel_OFF; TIM_BDTRInitStructure.TIM_DeadTime = 160; // 对应约2us,具体值查手册 TIM_BDTRInitStructure.TIM_Break = TIM_Break_Disable; TIM_BDTRInitStructure.TIM_BreakPolarity = TIM_BreakPolarity_High; TIM_BDTRInitStructure.TIM_AutomaticOutput = TIM_AutomaticOutput_Enable; TIM_BDTRInitStructure.TIM_DeadTime = 100; // 根据系统时钟计算 TIM_BDTRConfig(TIM1, &TIM_BDTRInitStructure); // 6. 使能预装载和更新中断 TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC3PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM1, ENABLE); TIM_UpdateRequestConfig(TIM1, TIM_UpdateSource_Regular); TIM_ITConfig(TIM1, TIM_IT_Update, ENABLE); // 7. 使能TIM1主输出和定时器 TIM_CtrlPWMOutputs(TIM1, ENABLE); TIM_Cmd(TIM1, ENABLE); }这里有个容易被坑的地方:TIM1作为高级定时器,初始化完成后必须调用TIM_CtrlPWMOutputs(TIM1, ENABLE)才能让互补输出真正生效。很多人把这一步漏了,最后发现引脚上没有任何波形,还以为是代码逻辑出错。
死区时间值不是直接用微秒数填进去的,而是按系统时钟计算出一个离散值。72MHz主频下,死区时间分辨率约为13.9ns,所以要配2us死区,寄存器值大约是144。我写的是100,对应约1.4us,实际使用中要按你的驱动芯片和功率管重新算。
3.3 三路PWM的相位同步与刷新
三路SPWM要求相位差互为120度,在查表法里做起来很简单:定义三个指针,分别指向正弦表的不同位置,偏移量是表长的三分之一和三分之二。
更新方式我推荐用定时器更新中断,在中断里把三个比较值同时写入CCR寄存器。时序上保证三路在同一时刻刷新,避免因为逐路更新导致相位偏移。
uint16_t idx_a = 0; uint16_t idx_b = SINE_POINT_NUM / 3; // 120度 uint16_t idx_c = 2 * SINE_POINT_NUM / 3; // 240度 void TIM1_UP_IRQHandler(void) { if (TIM_GetITStatus(TIM1, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM1, TIM_IT_Update); TIM_SetCompare1(TIM1, g_sin_table[idx_a]); TIM_SetCompare2(TIM1, g_sin_table[idx_b]); TIM_SetCompare3(TIM1, g_sin_table[idx_c]); // 查表索引递增,达到表尾则回绕 idx_a = (idx_a + 1) % SINE_POINT_NUM; idx_b = (idx_b + 1) % SINE_POINT_NUM; idx_c = (idx_c + 1) % SINE_POINT_NUM; } }这段代码每次更新中断都取一个正弦点,载波频率就等于中断频率除以中断次数。50Hz输出、400点采样时,PWM频率为20kHz,中断也是20kHz,F103完全扛得住。
还有一个更高级的玩法是用DMA传输,把正弦表直接映射到定时器的CCR寄存器,DMA的传输完成事件触发下一次传输,这样CPU在PWM输出上完全解放,只需要更新DMA起始地址来调频。这个方案适合要求高、还要同时跑复杂控制算法的场景。
3.4 调频调压的实现方式
三相逆变器在工作时,经常需要同时调节输出电压的频率和幅值,这就是V/F控制的核心。查表法下两种调节方式非常优雅:
调频:改变查表索引的步长。频率翻倍,步长就翻倍;频率减半,步长就减半。因为查表索引是整数,步长可能会带上小数,实际实现时可以用定点数,高16位存整数、低16位存小数,这样能实现非常精确的频率调节。
uint32_t step_accumulator = 0; uint32_t step_fixed = 16; // 定点数表示的查表步长 // 每周期更新 step_accumulator += step_fixed; idx_a = (uint16_t)((step_accumulator >> 16) % SINE_POINT_NUM);调压:查表得到基础值之后乘以调制度M。在定点数下,相当于查表值乘一个0到32768之间的系数再右移15位。
这个过程的物理意义是:调频改变的是正弦波的角频率,调压改变的是正弦波的幅值。两者配合就是电机调速里最经典的恒压频比控制。如果你做的是并网逆变器,还需要在此基础上加锁相环和电流环,但输出PWM的核心机制完全一样。
4. 实测调试与波形分析
4.1 上电前的检查清单
很多人SPWM代码写完了就急着上电,结果不是炸管子就是波形不对。我这里总结一份上电前的自查清单:
确认死区时间设置是否满足功率管要求。用示波器看互补输出之间是否有明显的“凹陷”,没有死区的话绝对不能接功率板。
确认所有PWM引脚输出正常后再接驱动板。先用一个简单的常量占空比程序验证六路PWM输出和死区逻辑是否正确,再切换成SPWM运行模式。
接线顺序上,建议先把单片机信号断开,单独给驱动板和功率板上电,确认母线电容充电正常、无短路,再接上控制信号。我碰到多次因为驱动板电源没理顺导致IGBT误导通,一上电就放炮。
4.2 示波器实测波形解读
一切正常后,用示波器同时测A相上管PWM和A相下管互补PWM,会看到两路波形正好反相,中间有一段明显的死区时间。这时候可以把PWM信号经过RC低通滤波再看,应该能看到一个平滑的正弦波。
我测出来的波形非常干净,说明查表法配合中心对齐模式确实效果好。三路同时测时,用示波器的幅值光标能直接看到它们之间正好相差120度,每路峰值一致,说明三相对称性很好。这里有一个快速判断对称性的技巧:把三相正弦波叠加显示,如果三路波形完全重合,说明对称性极好。
上桥臂PWM经过滤波后的正弦波,直流分量应该在母线电压的一半左右。如果直流分量偏移明显,优先检查占空比更新是否正确、死区是否补偿到位。
4.3 异常现象快速定位
最常见的问题是输出正弦波上下不对称。发生这种情况时,先检查中断里更新CCR寄存器的时机,如果在中心对齐计数器的上升沿和下降沿都触发更新,占空比会多刷一次,导致波形异常。正确做法是只在特定事件(比如向上计数到达重载值)时更新。
还有一相输出正常、另外两相没有输出的情况。这种通常是某一路通道初始化遗漏了OCxInit调用,或者对应引脚被其他外设复用了。用调试器看一下通道配置寄存器就能定位,不需要挨个检查硬件。
还有一种看起来诡异的现象:用手碰一下板子,波形立刻畸变。这往往说明地线处理有问题,控制地和功率地没有在单点接地,导致功率回路的噪声耦合到了控制信号。把两个地之间用磁珠或者短粗导线连接,问题基本能消除。
5. 常见问题与避坑记录
5.1 定时器配置的几个经典坑
中心对齐模式的更新事件比边沿对齐多一倍,很多人没意识到这点,在中断里做了不该做的事,导致PWM周期翻倍或减半。
TIM1的重复计数器(Repetition Counter)是高级定时器独有的功能,可以控制每N次更新事件才触发一次中断。如果设置不当,会发现PWM已经开始输出了,但中断一直没有进去。
自动重载预装载(ARR Preload)一定要使能,否则在运行中修改周期值时会出现计数上限瞬间变化,导致一个异常宽的PWM脉冲输出。这个脉冲很可能直接让功率管过流。
5.2 死区时间的前后权衡
死区时间不是越大越好。我之前贪图安全把死区调到5us,结果发现输出正弦波的谐波明显增加,尤其是轻载时电流波形过零附近会出现明显的削顶现象。
原因是死区时间内上下桥臂都不导通,输出由负载电流方向决定,相当于插入了误差电压。死区越大,误差越大,谐波越严重。处理办法有两个方向:一是死区时间在满足功率管要求的前提下尽量取小;二是做死区补偿,在软件里根据电流方向调整占空比。
实测下来,2us死区配合软件补偿后,输出波形质量能提升一个档次。如果你的驱动器对时序要求严格,这一步省不了。
5.3 母线电压波动对波形的影响
SPWM输出的基波幅值直接和母线电压成正比。母线电压一旦波动,输出就会跟着波动。如果母线是直接整流得到的,会有明显的100Hz纹波,输出波形上就会叠加对应的包络抖动。
如果做的是恒压输出,就需要加入母线电压前馈:实时采样母线电压,用它去修正占空比。简单说就是母线高了,占空比就低一点;母线低了,占空比就高一点。这个功能在并网逆变和UPS里是标配,电机驱动里通常不做,因为电机本身对电压波动不敏感。
我在这个项目里留了ADC采样接口,通过三电阻采样母线电压,后面扩展前馈控制时直接就能用上。
5.4 启动冲击管理
很多人忽略启动时的冲击问题。直接以一个固定占空比启动,会在瞬间形成很大的冲击电流。我用的办法是软启动:启动过程中逐步增大调制度M,从0.1慢慢爬升到目标值,整个过程约500ms。这个实现只需要在中断里把M值按递增的斜坡曲线更新即可,代码量很小,效果却非常明显。
软启动的好处是避免了启动瞬间的过流保护误触发,也让负载端的电压建立过程变得平滑可控。如果你做的是电机驱动,软启动还能避免机械冲击,延长减速箱寿命。
6. 扩展方向与实际体会
做完了基础的SPWM输出,下一步可以往闭环方向扩展。三相逆变器的终极目标还是输出稳定的正弦交流电,不管接电机还是接电网,都需要电流环。电流环的采样频率可以做到和SPWM载波同步,用中心对齐计数器的特定时刻触发ADC采样,这样的电流反馈最平滑。
我这边下一步计划加上PID电流环和母线电压前馈,把一个开环的SPWM输出升级成完整的电压源逆变器。整个项目从原理到代码都是可以复用的,换芯片平台时只需要改底层定时器配置,查表法、死区补偿、软启动这些逻辑完全不用动。
最后说一个我踩过多次的坑:调SPWM这种高频信号,示波器的带宽和采样率一定要够。我之前用一台100MHz带宽的示波器测20kHz的PWM,完全没问题,但后来测死区时间时发现上升沿显示得太缓,白白浪费了很多时间排查。换个高带宽的探头就好很多了。波形细节都看不见,怎么谈调参数?工具跟不上,技术再好也白搭。
本文还有配套的精品资源,点击获取