☰
STM32F103三相SPWM生成:互补PWM、死区与V/F调制的工程实现
2026/10/16 3:37:39 网站建设 项目流程

简介:面向三相电机驱动、变频调速与电源逆变等嵌入式应用开发者,内容基于STM32F103C8T6高级定时器TIM1查表法,实现相位互差120°的三相六路互补SPWM输出,并配置死区与恒压频比调频调压策略,可直接用于三相电机驱动。压缩包共149个文件,包含C源码、头文件、Keil5工程配置、hex/axf固件、PDF原理文档,以及xls正弦表生成工具和exe死区计算器等,整体约46.67MB,目录结构清晰,方便按模块查阅、修改和复用。已有8067人学习下载,可见其受关注程度较高。代码内注释详细,工程支持在线仿真观察SPWM波形;内容预览显示源码按定时器、PWM生成、串口、延时等模块拆分,配套SPWM生成原理文档、正弦表工具和死区计算器,适合逐步理解查表生成、互补输出、死区设置、调频调压的全流程,并为相关项目开发提供可运行的参考模板。

1. 三相六路互补SPWM:为什么“六路”比“三路”难十倍

三相逆变器的SPWM输出看起来是六路PWM,拆开之后其实是三对互补信号:CH1与CH1N、CH2与CH2N、CH3与CH3N,任意一对的上下管都绝不允许同时导通。难点不在生成三路正弦调制的占空比——那只要一张正弦表加三次查表就够了——而在处理上下管切换瞬间的“谁都不能导通”死区,以及让输出频率和输出电压成比例地同时变化。死区太短会直通烧管,死区太长会吃掉输出电压导致波形畸变;频率变了而电压不跟着变,电机不是磁饱和发热就是转矩不够。这些机制互相牵制,而F103C8T6上唯一能同时给你互补输出、死区发生器和刹车保护的就是TIM1。用KEIL5做软件仿真能验证占空比变化和120°相移,却验证不了纳秒级死区——理解了整套原理,你才不会在仿真通过之后第一次上板就翻车。

2. 用TIM1生成三相PWM:从一张正弦表到六个引脚

2.1 为什么只能选TIM1:F103高级定时器的不可替代性

F103里有8个定时器,但能直接输出互补PWM的只有TIM1(以及大容量型号才有的TIM8)。TIM2、TIM3、TIM4这些通用定时器只有OCx输出,没有OCxN输出,也没有内置的死区发生器。想把通用定时器拿来驱动三相桥,你只能把两路PWM接到GPIO上,在外部或中断里手动插死区,这既不精确又危险。

TIM1的高级之处在于这三个硬件单元是配套出现的:三对互补输出引脚(OC1/OC1N、OC2/OC2N、OC3/OC3N)、一个可编程死区发生器(BDTR寄存器)、一个刹车输入(BKIN)。在LQFP48封装的C8T6上,PA8/PA9/PA10复用到CH1/CH2/CH3,PB13/PB14/PB15复用到CH1N/CH2N/CH3N,刚好凑齐六路。这意味着你在初始化时只需要做一次引脚复用配置,之后定时器硬件自己负责“上下管不要同时开”这件事。

2.2 中心对齐模式:SPWM偏爱对称波形的原因

SPWM有两种常见的定时器计数方式:边沿对齐和中心对齐。边沿对齐模式下计数器从0递增到ARR,波形只有一侧有调制边沿,占空比变化引起的谐波不对称,电机绕组里的电流纹波偏大。中心对齐模式下计数器先从0递增到ARR,再从ARR递减到0,一个PWM周期内比较事件出现两次,输出脉冲关于周期中心对称,谐波能量更集中,这是驱动三相电机时更常用的选择。

中心对齐模式下PWM1的有效条件可以理解为:递增段和递减段都满足CNT小于CCR时输出有效。因此占空比就是CCR/ARR,这是这个模式最好的地方——你不需要在脑子里来回换算方向。72MHz定时器时钟、ARR=3600时,PWM频率等于72MHz除以两倍的ARR,也就是10kHz,单个计数步长13.89ns。一个载波周期100μs,这和后面的死区时间、正弦表点数都能对上。

初始化代码的关键部分长这样:

/* 72MHz 定时器时钟, 中心对齐模式, ARR=3600 -> PWM频率10kHz */ TIM_TimeBaseInitTypeDef tb; tb.TIM_Prescaler = 0; tb.TIM_CounterMode = TIM_CounterMode_CenterAligned1; tb.TIM_Period = 3600; tb.TIM_ClockDivision = TIM_CKD_DIV1; /* tDTS = tCK_INT = 13.89ns */ tb.TIM_RepetitionCounter = 0; TIM_TimeBaseInit(TIM1, &tb); /* OC1~OC3 都用PWM1模式, 同时使能OCx和OCxN */ TIM_OCInitTypeDef oc; oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_Pulse = 1800; /* 初始50%占空比, 对应CCR中点 */ oc.TIM_OutputState = TIM_OutputState_Enable; oc.TIM_OutputNState = TIM_OutputNState_Enable; oc.TIM_OCPolarity = TIM_OCPolarity_High; oc.TIM_OCNPolarity = TIM_OCNPolarity_High; TIM_OC1Init(TIM1, &oc); TIM_OC2Init(TIM1, &oc); TIM_OC3Init(TIM1, &oc);

这段配置把TIM1变成了三路互补输出框架,但此刻它还没真正输出——BDTR寄存器里的主输出使能位(MOE)还是0,所有引脚保持无效电平,这是刻意的。后面会看到,这个“先搭框架、最后开闸”的顺序是避免上电炸管的关键。

2.3 相位推进与查表:三个CCR如何在中断里同步刷新

SPWM的本质是让CCR值按正弦规律逐周期变化。基波频率50Hz、载波10kHz时,一个基波周期里有200个载波周期,所以把一张包含200个采样点的正弦表放进Flash,每个载波周期取一个点写入CCR,就能得到50Hz的正弦调制。U相直接用当前相位查表,V相偏移1/3个周期,W相偏移2/3个周期,三相120°相位差由此而来。

运行时的查表逻辑放在TIM1的更新中断里:

#define N_SAMPLE 200 /* 基波周期内采样点数 */ #define CENTER 1800 /* CCR中点, 对应50%占空比 */ #define PWM_HZ 10000 #define MIN_CCR 120 /* 给死区预留的最小脉宽 */ static uint16_t sin_table[N_SAMPLE]; /* 初始化时生成: 1800 + 1710*sin */ static uint32_t phase_q16; /* Q16定点相位累加器, 高16位查表 */ static uint32_t step_q16; /* 每载波周期的相位增量, 由频率决定 */ static uint16_t mod_q15; /* 调制比, 0~32768 对应 0~1.0 */ void TIM1_UP_IRQHandler(void) { if ((TIM1->SR & TIM_SR_UIF) == 0) return; TIM1->SR = ~TIM_SR_UIF; uint16_t idx = phase_q16 >> 16; /* 相位整数部分 */ uint16_t iv = (idx + N_SAMPLE / 3) % N_SAMPLE; /* V相滞后120° */ uint16_t iw = (idx + 2 * N_SAMPLE / 3) % N_SAMPLE; /* W相滞后240° */ /* 调制: CCR = CENTER + (表值-CENTER)*mod_q15/32768 */ TIM1->CCR1 = CENTER + (int32_t)(sin_table[idx] - CENTER) * mod_q15 >> 15; TIM1->CCR2 = CENTER + (int32_t)(sin_table[iv] - CENTER) * mod_q15 >> 15; TIM1->CCR3 = CENTER + (int32_t)(sin_table[iw] - CENTER) * mod_q15 >> 15; phase_q16 += step_q16; /* 推进相位 */ if (phase_q16 >= (uint32_t)N_SAMPLE << 16) phase_q16 -= (uint32_t)N_SAMPLE << 16; /* 绕回表头 */ }

三个寄存器在同一中断里顺序赋值,时间差只有几条指令,对相间关系的影响完全可以忽略。正弦表在初始化时用浮点sin计算一次存下来,运行时不调用任何浮点函数,这是F103这类没有硬件FPU的芯片上最常见的做法:把代价前置,把运行期的中断负载压到最低。

相位累加器用Q16定点格式,step_q16的高16位对应“每个载波周期走几个采样点”,低16位是亚采样点的相位余量。这么做的好处是低频时相位不会停滞——如果直接用整数步进,5Hz时可能连续好几个载波周期步进都是0,波形会出现肉眼可见的台阶。定点累加让频率分辨率不再受采样点数限制,这是DDS思想的直接迁移。

3. 死区机制:那625纳秒“谁都别导通”的时间是怎么来的

3.1 直通是硬伤:开关管暂态决定死区必须存在

所谓的互补输出,逻辑上已经保证了上下管不会同时导通:一个有效另一个必然无效。但真实功率管不是理想开关。MOS管有开通延迟、关断延迟和米勒平台,IGBT还有拖尾电流。你在程序里看到的“同步翻转”,反映到管子上是一条几十纳秒到几百纳秒的斜线。如果上管还没完全关断、下管就开始导通,母线正负极通过两个管子直接短路,这就是所谓的直通。

直通的后果不是逻辑错误,是物理损坏——管子瞬间过热,炸管或者烧驱动。死区就是在这条斜线里硬性插入的一段“两管都关断”时间。工程上的做法很简单:死区时间取功率管的关断延迟最大值,再留一倍以上的裕量。对于C8T6常见的小功率板,如果用的MOS管toff_max在200ns左右,死区设在500ns到1μs之间,既能覆盖暂态,又不至于明显影响波形。

3.2 死区发生器:BDTR寄存器如何按档位量化时间

死区不需要在中断里软件插入,TIM1的硬件死区发生器会在OCx和OCxN的每个边沿自动处理。你只需要往BDTR寄存器的DTG位段写一个值。DTG是8位,分成四档刻度,这个分档设计是为了覆盖不同功率等级的需求:小功率MOS管需要几十到几百纳秒的精细分辨率,大功率IGBT需要几微秒的粗粒度。

DTG[7:5]死区时间72MHz下的范围分辨率
0xxDTG × tDTS0 ~ 1.76μs13.9ns
10x(64 + DTG[5:0]) × 2 × tDTS1.78 ~ 3.53μs27.8ns
110(32 + DTG[4:0]) × 8 × tDTS3.56 ~ 7.0μs111.1ns
111(32 + DTG[4:0]) × 16 × tDTS7.11 ~ 14.0μs222.2ns

tDTS由TIM_ClockDivision决定,配置为DIV1时就是定时器时钟周期13.89ns。要得到625ns的死区,写DTG=45即可:45×13.89ns≈625ns,落在第一档里,分辨率足够精细。

/* 死区时间 45 x 13.89ns ≈ 625ns */ TIM_BDTRInitTypeDef bd; bd.TIM_OSSRState = TIM_OSSRState_Disable; bd.TIM_OSSIState = TIM_OSSIState_Disable; bd.TIM_LOCKLevel = TIM_LOCKLevel_OFF; /* 锁定级别为OFF, 后续可再改DTG */ bd.TIM_DeadTime = 45; bd.TIM_Break = TIM_Break_Disable; bd.TIM_AutomaticOutput = TIM_AutomaticOutput_Disable; TIM_BDTRInit(TIM1, &bd); /* 最后一步才打开主输出, 在此之前六路引脚保持安全电平 */ TIM_CtrlPWMOutputs(TIM1, ENABLE);

注意BDTR里的MOE位是整个输出链路的总闸。MOE为0时,即使CCR和CNT的比较逻辑在跑,引脚上也不会出现有效PWM。所以初始化顺序应该是:GPIO拉低安全电平 → 时基配置 → OC配置 → BDTR配置 → 最后置位MOE。把这个顺序反过来,很多板子第一次上电就烧驱动。

3.3 死区的代价:为什么“安全”和“波形质量”天然冲突

死区保护了功率管,但它不是免费的。死区期间两管全关,负载电流没有路径从母线取电,只能靠桥臂反并的续流二极管续流。此时桥臂中点电压不再由PWM指令决定,而是由电流方向和二极管压降决定——输出波形里出现了一段不受控制的区域。

我一般会这么理解死区误差:每个PWM周期里上下管换流两次,每次换流都损失一个死区时间。载波10kHz、死区625ns时,这个损失约为2×625ns/100μs,即1.25%的占空比。问题在于,这个损失对正负半周不对称——电流为正时电压被压低,电流为负时电压被抬高,宏观表现就是基波电压变小,同时叠加额外的低次谐波。死区越大、载波越高,这个畸变越明显。

这也是为什么正弦表的满幅不能直接用到3600(即100%占空比)。接近满幅时,有效脉宽可能比死区还窄,脉冲会被死区彻底吞掉。常见做法是把调制比上限限制在0.9~0.95,让CCR的最大值不超过CENTER+0.95×CENTER,留出足够的脉宽给死区消耗。这个限幅看起来是“浪费”了调制范围,实际是在安全性和波形质量之间取的平衡点。

4. 调频调压:为什么频率一变,电压就必须跟着变

4.1 从电机磁通说起:V/F恒定的物理依据

三相异步电机的定子绕组本质上是一个电感,电机内部的感应电动势和频率、磁通的关系可以写成E≈4.44×f×N×Φ。搬一下这个公式就能看出,要保持气隙磁通Φ不变,E/f必须近似恒定。变频调速时只改频率、不动电压,会出现两种后果:频率降低时E/f变大,磁通上升,铁芯进入饱和区,励磁电流暴涨,电机没转起来先发热;频率升高时E/f变小,磁通不足,电机的转矩输出跟着掉,带不动负载。

所以基频以下的调速策略是恒压频比,也就是V/F恒定,让磁通始终维持在额定值附近。反映到SPWM里,载波频率固定在10kHz不动,需要变的是两个量:相位累加器的步进量(决定基波频率)和调制比(决定输出电压幅值)。调频和调压这两个动作必须同步,不能只改其中一个。

4.2 代码层的调频与调压:一个步进量和一个调制比

调频在代码里就是改step_q16。每个载波周期推进多少采样点,直接决定基波频率:

void set_frequency(uint16_t freq_hz) { /* 相位步进: 每个载波周期推进的采样点数 */ step_q16 = (uint32_t)freq_hz * N_SAMPLE * 65536U / PWM_HZ; if (freq_hz >= RATED_HZ) { mod_q15 = 32768; /* 额定及以上: 满调制, 恒压 */ } else { /* 基频以下 V/F 恒定: 调制比与频率成正比 */ mod_q15 = (uint32_t)32768 * freq_hz / RATED_HZ; /* 低频补偿: 死区与管压降在低速时占比大, 需要额外抬电压 */ if (freq_hz < BOOST_HZ) mod_q15 += BOOST_Q15; } if (mod_q15 > MAX_MOD_Q15) /* 限幅, 给死区留脉宽 */ mod_q15 = MAX_MOD_Q15; }

假定额定频率50Hz,那么10Hz时mod_q15就是32768的1/5,CCR的摆动幅度等比例缩小,输出电压约为额定值的1/5。step_q16和mod_q15是成对更新的,这保证在任何频率点上都满足V/F曲线。

代码里MAX_MOD_Q15对应前面说的0.95调制比上限。即使freq_hz到了额定频率,调制比也只能逼近0.95而非1.0,原因就是死区要吃掉一部分脉宽——这是硬件机制对软件上限的约束,不是你不想输出满压,是物理上做不到还不产生畸变的满压。

4.3 低频段为什么要做额外补偿

V/F恒定是理想电机的法则,实际逆变器在低频段会“失守”。原因有两层:一是死区时间占总周期比例固定,但低频时输出电压本身很小,死区吃掉的那部分占比变得很大;二是功率管导通时还有压降,电流越大压降越明显,输出电压比指令值低一截。

实际中常用的是分段补偿:比如10Hz以下,把调制比按开环经验值额外抬升几个百分点,抵消死区和管压降造成的电压损失。更精确的做法是检测电流方向,按方向做符号相反的占空比补偿,这就是死区补偿算法。在C8T6这类小功率场景里,低频段用简单的BOOST_Q15加法已经能把空载和轻载调速做得比较平滑。若在重载或极低速下还感觉转矩不够,再考虑往矢量控制方向走,那已经是另一个量级的项目了。

5. 常见问题避坑:仿真能过、上板翻车的几类典型坑

5.1 软件仿真看不见死区,别把它当示波器用

现象:KEIL5里跑软件仿真,用逻辑分析仪窗口看PA8和PB13,两边看起来是严格互补的方波,完全找不到死区的痕迹。于是怀疑死区配置没生效。

原因:软件仿真模拟的是寄存器状态和引脚电平变化,采样粒度受指令执行步进限制,纳秒级的硬件死区在这个模型里不会被展开;而且仿真模型里没有功率管开关暂态,本来也不需要死区。再一个原因是C8T6这种中等容量芯片在纯软件仿真下,定时器外设模型本身就有简化。

解决:死区是否生效,最终要以示波器实测为准。临时验证的办法是先把DTG写成一个大值(比如0xFF对应约3.5μs档),再上板看波形;如果能看到明显的“两管都低”台阶,说明死区极性方向正确,然后改回正常值。软件仿真只用来验证相位关系、占空比变化和调频调压逻辑。

5.2 上电瞬间上下管同时导通,板子还没初始化就烧了

现象:刚接通母线电源,驱动芯片或功率管立刻冒烟,程序根本还没运行到PWM初始化。

原因:MCU复位期间GPIO处于浮空输入状态,引脚电平不确定。如果外部驱动电路对浮空电平的默认响应是“上下管都有效”,那么在程序接管引脚之前,桥臂已经直通了。这种情况和死区多大无关,纯粹是上电时序问题。

解决:启动代码里尽早把六个PWM引脚全部配置为推挽输出并拉低(或按驱动电路的安全电平拉高),然后再初始化TIM1,最后才置位MOE。MOE为0期间引脚始终由GPIO控制,不会输出PWM。要养成“先控脚、再控定时器”的顺序,这是血泪经验。

5.3 改了死区时间,实际波形纹丝不动

现象:把DTG从45改成180,期望死区从625ns变成更大的值,示波器上看完全没变化。

原因:BDTR寄存器在MOE置位后对某些位有保护逻辑;如果配置了LOCK位(TIM_LOCKLevel不为OFF),BDTR的关键位段还会被锁定,必须复位才能改。很多标准库例程把LOCK设成了OFF,但代码里之后又一次调用了BDTR写操作,覆盖了之前的值。

解决:在初始化阶段一次性写好死区,不要在运行中频繁改DTG。如果必须改,先清MOE,改完再重新使能MOE。同时检查TIM_LOCKLevel是否为OFF,以及整个工程里是否有第二个地方重新初始化了TIM1。

5.4 空载电流偏大,相电流波形有台阶凹陷

现象:电机能转,但空载电流接近额定值,用示波器电流探头看相电流,正弦波顶部或底部出现明显的削顶和台阶。

原因:调制比限幅没加,CCR被推到接近0或3600的位置。中心对齐模式下这部分区域的有效脉宽已经小于死区时间,死区发生器把整个脉冲吞掉,输出电压进入非线性区,电流波形自然畸变。

解决:给调制比设上限,MAX_MOD_Q15按0.9到0.95折算;同时在中断里检查CCR边界,低于MIN_CCR时钳位到MIN_CCR。从公式上算,MIN_CCR至少要大于死区对应的计数个数乘以2,目前代码里留的120个计数对应约1.67μs,大于死区的45个计数,是安全的。

5.5 调试暂停后再继续,PWM输出再也回不来

现象:KEIL里打了断点,单步看完某段代码后按全速运行,电机不动了,PWM引脚没有任何输出。

原因:仿真器暂停时默认冻结外设时钟,TIM1计数器停住;如果此时发生了刹车事件,或者MOE被调试器意外清除,恢复运行后MOE不会自动置位,输出始终处于关闭状态。从软件模型上看寄存器值都对,但就是没有输出。

解决:调试时把TIM1从DBGMCU的冻结列表里排除,或者在恢复运行的代码路径上重新调用一次TIM_CtrlPWMOutputs(TIM1, ENABLE)。更稳妥的办法是不要在主循环里打断点,把断点打在无关紧要的辅助函数里,避免触碰PWM输出路径。

6. 设计取舍:中断查表、DMA搬运与死区验证的选型判断

这套SPWM方案有两个关键取舍:三相CCR的更新方式,以及死区时间的验证方法。

中断查表方案的优势是灵活。调制比限幅、低频补偿、起动时的S曲线都可以在中断里顺手做,代码路径短,10kHz中断每次执行不超过几十条指令,对72MHz的C8T6来说CPU占用率不到2%。缺点是每个载波周期都要进一次中断,如果以后还要叠加PID、编码器采样和串口通信,中断负载会逐渐吃紧。

DMA搬运方案把三相数据在内存里交错排布,用TIM1的更新事件触发DMA把三个半字依次写入DMAR寄存器,定时器内部的Burst机制会自动把它们分发到CCR1、CCR2、CCR3。CPU零负载,但代价是补偿逻辑没法动态做了:调压、低频补偿都得提前算进表里,每次改变工况可能要切换整张表,灵活性差很多。

我的判断是:做产品原型和教学验证,中断查表是首选,逻辑透明、容易调参;做量产固件且已经确认控制算法收敛,再迁到DMA方案节约中断开销。

死区验证这件事,我一般用两个手段交叉确认。第一是代数核对:载波频率10kHz时,最小有效占空比约等于2×死区时间×载波频率,625ns死区对应1.25%的死区损失,因此调制比上限设0.95是合理的选择;第二是示波器实测:CH1和CH1N两路接A/B通道,把时基放到500ns量级,直接量两路同时为低的台阶宽度。空载首次通电时,在母线回路串一个限流电阻或灯泡,确认六路波形正常再撤掉——这个习惯救过我两次。

从那以后,我每次改死区都会先算一遍最小脉宽,再回头检查DTG值和调制比上限,把这三件事当成一个整体来调,而不是单独拧一个参数。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询