搞电机FOC控制,绕不开SVPWM。做FOC的都知道,电流环速度环那一串PID、Clarke、Park算完,最终都要变成三相逆变桥上六个开关管的开关动作,这个从“电压矢量指令”到“具体开关时序”的翻译过程,就是SVPWM(Space Vector Pulse Width Modulation)。我当年刚开始调FOC时,卡最久的地方之一就是SVPWM的扇区判断,以及最后那几行作用时间查表。公式推不明白,代码就不敢动,电机一动就怕烧管子。后来硬着头皮把整个推导从头到尾啃了一遍,才真正理解那些代码为什么这么写。这篇就把标准SVPWM从原理推导到工程实现完整过一遍,适合正在看FOC代码、准备把SVPWM真正跑起来的同学,也适合那些已经能出波形但想搞懂每个环节为什么这样设计的工程师。
1. 先理顺一件事:SVPWM在FOC里到底干了什么活
1.1 FOC不是“三相正弦波直接怼”
很多人刚开始学FOC的时候,容易有一个直觉:电机需要三相正弦电流,那是不是我用定时器生成三路SPWM正弦波给逆变器就行了?
理论上能转,但工程上不会这么干。SPWM的本质是用正弦波和三角波比较,产生占空比变化的方波,来逼近正弦电压。它实现起来简单,可有一个很大的短板:母线电压利用率低。三相SPWM输出的相电压幅值最大只能到 Vdc/2,也就是直流母线电压的一半。
而FOC走的是另一条路。FOC的核心思路是先把三相电流通过Clarke变换到αβ两相静止坐标系,再通过Park变换到dq旋转坐标系,在dq坐标系里把电流分解成励磁分量 id 和转矩分量 iq,分别控制。所以FOC真正算出来的,其实是一个电压矢量,也就是 Uα 和 Uβ 这两个正交分量。这两个分量合在一起是一个空间旋转电压矢量,它的幅值和相位都在不断变化。
这时候问题就来了:你算出来的是一个幅值、方向都会变的电压矢量,逆变器是三相桥,只有六个开关管,怎么把这个矢量转换成实际的开关动作?SPWM那种“每相独立生成正弦波”的思路在这时不直接匹配了。你需要一种能把“空间电压矢量”映射成“三相桥开关时序”的方法,这就是SVPWM的活。
一句话总结:FOC负责决策,告诉系统“当前该输出一个多大的电压矢量”,SVPWM负责执行,把这个矢量翻译成六个开关管在每个PWM周期内的开和关。没有SVPWM,FOC算出来的矢量就停留在数学世界里,落不到电机上。
1.2 和SPWM对比:母线电压利用率能提升15%以上
我再补一个大家常提的点:SVPWM相比SPWM,母线电压利用率更高。
SPWM的相电压峰值最多到 Vdc/2。而SVPWM因为用了空间矢量合成,相电压峰值可以到 Vdc/√3 。这两个数字对比一下:
- 母线电压 Vdc = 24V 时,SPWM最大相电压幅值约 12V;
- SVPWM最大相电压幅值约 13.86V;
- 提升比例: (13.86 - 12) / 12 ≈ 15.5%。
别小看这15%。对很多低压驱动场景来说,母线电压本来就紧张,电机高速区反电动势又大,能多挤出15%的电压,就意味着电机能跑更高的转速,或者在同样转速下留出更多电压余量。这也是为什么无感FOC、高速电机控制几乎全部采用SVPWM。
另一个角度是谐波。SVPWM实际上是在相电压里注入了一个零序分量,它输出的线电压更接近正弦,谐波能量主要集中在开关频率及其倍频附近,后级滤波和电机本身的电感都比较容易处理。你如果用示波器看SVPWM的相电压波形,它不是纯正弦,但线电压波形质量很高。
2. SVPWM核心原理:8个开关状态、6个扇区、公共量推导
2.1 三相桥的8个开关状态
SVPWM的起点是三相逆变器的开关状态。三相桥每相上下两个管子互补导通,所以每一相只有一个开关变量:上管导通记作1,下管导通记作0。三相合起来就是3位二进制数,一共 2^3 = 8 种状态:000、001、010、011、100、101、110、111。
这8个状态对应到空间电压矢量,前6个是非零电压矢量,后2个是零电压矢量。非零矢量的幅值都等于 (2/3)Vdc,方向如下图所示:V1指向0°,V2指向60°,V3指向120°,以此类推,每60°一个,把整个360°平面分成6个扇区。零矢量000和111位于原点,幅值为0。
这6个扇区就是以这个基础电压矢量为边界划分出来的:0°到60°是扇区I,60°到120°是扇区II,120°到180°是扇区III,180°到240°是扇区IV,240°到300°是扇区V,300°到360°是扇区VI。
SVPWM的“电压矢量合成”思想是这样的:任何一个目标电压矢量,如果落在某个扇区里,就可以用这个扇区相邻的两个非零电压矢量,再加上零矢量,按时间比例组合出来。这叫伏秒平衡,通俗讲就是“让两个基本矢量分别作用一段时间,使最终的平均效果等于目标矢量”。
这里有个细节必须注意:同一桥臂上下两个管子绝对不能同时导通,否则直接短路炸管子。所以实际控制里,开关状态切换时必须插入死区时间,让上下管先都关断一小段时间,再开通另一只。死区时间通常是几百纳秒到几微秒,具体看功率管参数。
2.2 扇区判断:不用atan2,用符号组合
知道了目标电压矢量 Uα 和 Uβ 之后,第一个问题就是:这个矢量落在哪个扇区?
最直观的方法是用反正切函数算出矢量角度,再判断角度落在哪个区间。但反正切计算量大,对没有FPU的单片机来说开销太高。工程上更常用的是符号判断法,不需要角度,只需要比较几个线性组合的符号。
具体做法是定义三个中间变量:
B1 = Ubeta B2 = (√3 / 2) * Ualpha - (1 / 2) * Ubeta B3 = -(√3 / 2) * Ualpha - (1 / 2) * Ubeta然后用这三个变量的符号组合出N值:
if (B1 > 0) A = 1; else A = 0; if (B2 > 0) B = 1; else B = 0; if (B3 > 0) C = 1; else C = 0; N = A + 2 * B + 4 * C;N值和扇区的对应关系,实际用下来是:
| N | 扇区 |
|---|---|
| 3 | I |
| 1 | II |
| 5 | III |
| 4 | IV |
| 6 | V |
| 2 | VI |
这个映射关系我在代码里直接用一个数组索引就完事,不需要写一堆if else。B1、B2、B3本质上代表的是目标矢量相对于三个边界(0°、60°、120°方向)的位置,组合起来就能唯一确定所在扇区。
注意:网上一些资料里的N值映射表可能不同,原因在于B2、B3定义时比例系数写法不同。赋值关系不同,映射自然不同。最稳妥的做法是拿到代码后用 Simulink 或者 Python 仿真验证一遍,把电压矢量扫一圈,看扇区判断是否连续无跳变。
2.3 两个非零矢量的作用时间:公共量X/Y/Z的完整推导
扇区判断完之后,需要计算这个扇区相邻两个非零电压矢量各作用多长时间。这是很多人的“劝退点”,因为对着表看一会儿就头晕。我在这里把推导过程完整走一遍。
以扇区I为例。扇区I由V1(100,方向0°)和V2(110,方向60°)合成目标矢量。设V1的作用时间是T1,V2的作用时间是T2,PWM周期是Ts。根据伏秒平衡,在αβ坐标系下有:
Ualpha * Ts = |V1| * T1 * cos(0°) + |V2| * T2 * cos(60°) Ubeta * Ts = |V1| * T1 * sin(0°) + |V2| * T2 * sin(60°)非零矢量幅值 |V1| = |V2| = (2/3)Vdc,代入得:
Ualpha * Ts = (2/3) * Vdc * T1 + (2/3) * Vdc * T2 * 0.5 Ubeta * Ts = (2/3) * Vdc * T2 * (√3/2)整理第二个式子:
T2 = (√3 * Ubeta * Ts) / Vdc把T2代回第一个式子:
Ualpha * Ts = (2/3) * Vdc * T1 + (1/3) * Vdc * T2解得:
T1 = (3 * Ualpha * Ts) / (2 * Vdc) - (1/2) * T2 = (3 * Ualpha * Ts) / (2 * Vdc) - (√3 * Ubeta * Ts) / (2 * Vdc) = (3 * Ualpha - √3 * Ubeta) * Ts / (2 * Vdc)到这里,如果每个扇区都这样解一次二元一次方程,代码写起来会很长。聪明一点的做法是定义一组公共量,把六个扇区的计算统一起来。工程上常用的是X、Y、Z三个量:
X = (√3 * Ubeta) / Vdc Y = (3/2 * Ualpha + √3/2 * Ubeta) / Vdc Z = (3/2 * Ualpha - √3/2 * Ubeta) / Vdc注意:这里算出来的X、Y、Z是“相对时间系数”,实际作用时间要再乘以Ts。但因为我们最后还要统一限幅归一化,很多工程代码里干脆不乘Ts,直接拿X、Y、Z来算占空比。
用我刚才在扇区I里推出来的T1、T2和X、Y、Z对照:
- 扇区I:T1 = Z,T2 = X。
用同样的方法把剩下五个扇区都推一遍,得到:
| 扇区 | T1 | T2 |
|---|---|---|
| I | Z | X |
| II | Y | -Z |
| III | X | -Y |
| IV | -Z | -X |
| V | -Y | Z |
| VI | -X | Y |
这里 T1 和 T2 分别对应扇区起始边和结束边的非零矢量。比如扇区II里,T1对应V2(60°方向),T2对应V3(120°方向)。写代码时,只需要算一遍X、Y、Z,然后根据扇区查表选择T1、T2,这就是“公共量”的意义:一次计算,六扇区复用。
强烈建议你自己动手推一遍至少三个扇区,特别是扇区II和扇区IV。推完你再看任何SVPWM代码,都不会再觉得那一串case是“魔法数字”。
2.4 七段式分配与比较值
算出T1、T2之后,还有一个零矢量时间T0:
T0 = Ts - T1 - T2零矢量有两种:000和111。把T0平均分给这两个零矢量,就形成了经典七段式SVPWM。
以扇区I为例,一个完整PWM周期内的开关序列是:
000 → 100 → 110 → 111 → 110 → 100 → 000
各段时间分别是:
T0/4, T1/2, T2/2, T0/2, T2/2, T1/2, T0/4为什么用七段式而不是五段式?七段式在每个载波周期开始和结束都是零矢量000,对称性好,谐波集中在开关频率及其倍频,电流纹波小;缺点是开关次数多,开关损耗大。五段式砍掉了一段零矢量,开关次数少,效率高,但谐波含量和电流纹波会变大。电机FOC一般用七段式,性能优先。
七段式的关键是把T0/4、T0/4+T1/2、T0/4+T1/2+T2/2这三个时刻对应到三相比较值。我习惯定义:
Ta = T0 / 4 Tb = Ta + T1 / 2 Tc = Tb + T2 / 2这三个值就是三个通道在载波周期内的切换时刻。但到底哪个时刻赋给A相、B相、C相,取决于当前扇区和定时器的极性设置。我在下一章写代码的时候会具体说明映射表。
3. 标准SVPWM代码实现:STM32工程落地
3.1 整体框架与变量定义
先说一下运行环境。我用的是STM32F103系列,72MHz主频,TIM1做中心对齐PWM,ADC触发采样。浮点运算在F103上偏慢,但SVPWM这个函数只算几次乘加法,用浮点也能扛得住。如果你用的是Cortex-M4或者M7,带FPU,那就完全不用纠结,直接上浮点,代码清晰第一。
SVPWM函数的核心输入是:
- Ualpha:FOC输出的α轴电压分量
- Ubeta:β轴电压分量
- Udc:直流母线电压
- Ts:PWM周期,也就是载波周期
输出是三个通道的比较值CCR1、CCR2、CCR3,直接写进定时器寄存器。
实际工程里,Ualpha和Ubeta来自哪里?常见有两条路。一条是电流环PI输出Uq、Ud,经过逆Park变换得到Ualpha、Ubeta;另一条是无感FOC里,速度环输出iq_ref,电流环算完再走逆Park。无论哪条路,到SVPWM这里都归一化成Ualpha、Ubeta。
还要注意一个“归一化”问题。很多代码里不直接输出电压值,而是先把Ualpha、Ubeta除以Udc,变成标幺值,后续计算全是相对比例,最后再乘上定时器的ARR寄存器的对应值输出。这样做的好处是适配不同的母线电压,而且便于观察过调制。我下面给的示例就采用这种思路。
3.2 关键代码逐段拆解
先看扇区判断的代码实现:
void svpwm_calc(float Ualpha, float Ubeta, float Udc, float Ts) { float B1, B2, B3; float X, Y, Z; float T1, T2, T0; float Ta, Tb, Tc; int sector = 0; int N = 0; float temp = 0; // 扇区判断用的中间量 B1 = Ubeta; B2 = 0.866025f * Ualpha - 0.5f * Ubeta; // √3/2 * Ualpha - 1/2 * Ubeta B3 = -0.866025f * Ualpha - 0.5f * Ubeta; // -√3/2 * Ualpha - 1/2 * Ubeta // 符号判定 if (B1 > 0) N += 1; if (B2 > 0) N += 2; if (B3 > 0) N += 4; // N值转扇区,关系见上一节 switch (N) { case 3: sector = 1; break; case 1: sector = 2; break; case 5: sector = 3; break; case 4: sector = 4; break; case 6: sector = 5; break; case 2: sector = 6; break; default: sector = 1; break; } // 公共量X、Y、Z X = (1.7320508f * Ubeta) / Udc; Y = (1.5f * Ualpha + 0.8660254f * Ubeta) / Udc; Z = (1.5f * Ualpha - 0.8660254f * Ubeta) / Udc; // 根据扇区选择T1、T2 switch (sector) { case 1: T1 = Z; T2 = X; break; case 2: T1 = Y; T2 = -Z; break; case 3: T1 = X; T2 = -Y; break; case 4: T1 = -Z; T2 = -X; break; case 5: T1 = -Y; T2 = Z; break; case 6: T1 = -X; T2 = Y; break; default: T1 = Z; T2 = X; break; } // 过调制处理:T1+T2大于Ts时按比例缩放 temp = T1 + T2; if (temp > Ts) { T1 = T1 * Ts / temp; T2 = T2 * Ts / temp; } T0 = Ts - T1 - T2; // 三个切换时刻 Ta = T0 * 0.25f; Tb = Ta + T1 * 0.5f; Tc = Tb + T2 * 0.5f; // 根据扇区映射到三相CCR // 以下为PWM1模式、高电平有效、中心对齐 float CCR_Ta = Ta / Ts * period; // period是定时器ARR值 float CCR_Tb = Tb / Ts * period; float CCR_Tc = Tc / Ts * period; switch (sector) { case 1: TIM1->CCR1 = (uint32_t)CCR_Tc; TIM1->CCR2 = (uint32_t)CCR_Tb; TIM1->CCR3 = (uint32_t)CCR_Ta; break; case 2: TIM1->CCR1 = (uint32_t)CCR_Tb; TIM1->CCR2 = (uint32_t)CCR_Tc; TIM1->CCR3 = (uint32_t)CCR_Ta; break; case 3: TIM1->CCR1 = (uint32_t)CCR_Ta; TIM1->CCR2 = (uint32_t)CCR_Tc; TIM1->CCR3 = (uint32_t)CCR_Tb; break; case 4: TIM1->CCR1 = (uint32_t)CCR_Ta; TIM1->CCR2 = (uint32_t)CCR_Tb; TIM1->CCR3 = (uint32_t)CCR_Tc; break; case 5: TIM1->CCR1 = (uint32_t)CCR_Tb; TIM1->CCR2 = (uint32_t)CCR_Ta; TIM1->CCR3 = (uint32_t)CCR_Tc; break; case 6: TIM1->CCR1 = (uint32_t)CCR_Tc; TIM1->CCR2 = (uint32_t)CCR_Ta; TIM1->CCR3 = (uint32_t)CCR_Tb; break; } }这段代码里有一个地方需要特别说明:CCR的映射表。网上能找到的SVPWM代码,这段映射经常都不一样,根本原因有两个:一是定时器输出的极性和PWM模式不同,二是扇区编号的起始位置定义不同。
我这里的用法是:TIM1配置为中心对齐模式1,PWM模式1,输出极性为高。在这种配置下,通道的CCR值越大,实际导通时间越长。对应关系我已经在前面2.4节推导过,三个切换时刻里,最早切换的相反而是导通时间最长的那相,所以Ta、Tb、Tc和CCR的对应不是直接一一对应,而是倒序或者交错。以扇区I为例,A相导通时间最长,所以它的CCR要填Tc,而不是Ta。
如果你把极性改成低电平有效,或者用了PWM模式2,CCR越大反而导通时间越短,那整个映射表要整体反过来。这正是很多人直接抄网上代码后电机波形不对的常见原因。
3.3 与定时器、ADC的联动
SVPWM函数算出来的CCR值,最终要靠定时器硬件变成真实的PWM波形。这一点配置错了,前面代码全对也没用。
我常用的是TIM1,因为高级定时器带互补输出和死区插入功能。关键配置:
- PWM频率:我一般用16kHz到20kHz。频率太低,电流纹波大,电机噪音明显;频率太高,开关损耗大,对功率管要求高。16kHz正好避开人耳听觉敏感区,是个折中选择。
- 中心对齐模式:选Center-aligned mode 1(CMS=01)。在这种模式下,计数器先向上计数到ARR,再向下计数到0,波形关于载波中心对称,这就是七段式SVPWM想要的对称效果。
- ARR周期:72MHz时,20kHz PWM对应ARR大约为3600。这个值决定了SVPWM输出的分辨率,ARR越大分辨率越高。
- 死区时间:我给F103配过典型的0.5us到1us死区。功率管规格书里有最小死区要求,留一点余量。死区太短,上下管直通风险大;死区太长,波形畸变和电流过零失真明显。
然后是电流采样。这个和SVPWM强相关。FOC需要采样相电流,采样点如果落在开关切换时刻,采样到的全是尖峰噪声,电流波形根本没法看。中心对齐PWM的好处是:在载波的顶点(计数到ARR)和谷底(计数到0)附近,三相都是零矢量状态,开关噪声最小,是天然的电流采样窗。
我用的是在载波顶点触发ADC采样,采样保持时间尽量靠近顶点。这样采出来的电流值能真实反映一个PWM周期内的平均电流,电流环的反馈质量好很多。如果你发现电流采样毛刺大,先别急着改滤波,看看你的采样点是不是和PWM边沿撞在一起了。
ADC触发方式用TIM1的更新事件或者TRGO事件都行。注意配置好ADC的采样时间和转换通道,不要占用太长时间,否则影响控制周期。
4. 调试经验:波形、限幅、常见坑
4.1 典型问题速查表
调SVPWM的过程中,我碰到过不少奇奇怪怪的现象,这里整理成一张速查表,方便卡住的时候快速对照。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 电机不转,母线电流大 | 扇区判断错误,三相开关时序不对 | 用示波器同时看三相PWM,检查每相导通时间是否按规律变化 |
| 电机能转但噪音大 | PWM频率偏低,或死区设置不当 | 提高PWM频率到16kHz以上;检查死区时间是否合适 |
| 波形不对称,某相明显异常 | 比较值映射表与定时器极性不匹配 | 改极性或换一套映射表,用仿真先验证顺序 |
| 高速时电压上不去 | 过调制处理不对,T1+T2被简单截断 | 确保按比例缩放T1、T2,而不是限幅到Ts |
| 电流波形有大量毛刺 | 电流采样点落在开关边沿 | 把ADC采样点移到载波顶点或谷底 |
| 启动瞬间抖动大 | 低速区SVPWM分辨率不足 | 提高PWM频率或降低最低转速限制 |
| 死区导致的电流过零畸变 | 死区时间太长,功率管较慢 | 用带死区补偿的FOC算法,或减小死区 |
4.2 这一路我踩过的漏
先说过调制。刚写SVPWM时候,我忽略了T1+T2可能大于Ts的情况,直接在代码里做限幅:超过Ts就截断。结果电机轻载没问题,一重载或者高转速就出问题,电压矢量幅值被非线性压缩,电流波形开始扭曲。
正确的做法是等比例缩放:如果T1+T2 > Ts,就把T1和T2同时乘一个Ts/(T1+T2)系数,让它们的相对比例保持不变。这样电压矢量的方向不变,只是幅值被饱和。这是过调制的基本处理,虽然不是最完美的过调制策略,但已经能覆盖绝大多数FOC应用。
再说死区。死区时间确实保护了功率管,但也会带来副作用。死区期间相电流会通过续流二极管续流,导致实际输出电压和指令电压不一致,电流过零附近会出现一段平台期,严重时低频电流谐波变大。对于要求高的应用,可以做死区补偿,但那是另一个大课题。入门阶段先把死区时间调到合适范围,避免过大即可。我见过的很多开发板默认死区1us以上,对低压小功率MOS来说偏大。
还有一个容易忽略的坑:扇区边界。当电压矢量正好落在扇区边界附近时,T1或T2可能趋近于0,此时扇区判断稍有抖动,就会在两个扇区之间跳变。但不用担心,因为SVPWM在边界两侧是连续的,跳变时T1/T2的取值会自然衔接,不会产生突变。真正需要留意的是N值判断一次只能映射到一个扇区,别在default分支里随便赋值,否则边界处会有异常。
最后是CCR映射表和示波器的验证方法。我强烈建议先用开环方式调试:给定一个固定的Ualpha、Ubeta,比如幅值0.3Vdc,旋转速度很慢,然后用示波器观察三相PWM的占空比变化。你会发现三相占空比是互差120°的正弦变化,这就是SVPWM正常的开环表现。如果哪一相占空比的顺序或者变化趋势不对,大概率就是映射表或极性配置错了。等开环波形正确了,再接电流环和速度环。
4.3 先仿真再上机,能省一整天
最后给还在SVPWM门口徘徊的朋友一个建议:先仿真,再上板。
我说的仿真不是要你用昂贵的仿真器,而是用Simulink或者Python先把SVPWM算法验证一遍。Simulink里搭一个简单的逆变器模型,输入一个旋转电压矢量,看它输出的相电流波形和线电压波形是否正确。Python写个脚本也行,把扇区判断、T1/T2计算、七段式分配全部仿真一遍,对比目标电压矢量和实际合成矢量的差距。
我自己的习惯是先在Python里把公式推一遍,看扇区编号和T1/T2选对没有,然后再改到STM32工程里。因为上示波器看波形虽然直观,但如果问题出在映射表这类逻辑错误上,调试效率很低。仿真环境下我可以直接把Ualpha、Ubeta设成任意值,瞬间看结果对不对。等你把仿真这关过了,再上板子,基本一次就能出正常波形。
我个人在实际操作中的体会是:SVPWM看起来公式多、表多,但核心其实就三件事——判断扇区,算作用时间,把时间映射成PWM比较值。推一遍公共量X/Y/Z,写一遍代码,再上示波器看一次波形,这个知识点就再也不会忘。后面你还会遇到带死区补偿的SVPWM、五段式SVPWM、甚至是没有电流传感器的SVPWM开环启动,但底子都是这里的东西。把标准SVPWM吃透了,后面这些工程优化才能接得住。