从代码到波形:手把手教你用STM32和SimpleFOC实现七段式SVPWM
搞电机控制的朋友应该都清楚,FOC(磁场定向控制)如今几乎是高性能电机驱动的事实标准,而SVPWM(空间矢量脉宽调制)又是FOC里最核心的输出环节。很多人一开始接触simpleFOC的时候,光看库里的setPwm()函数,以为简单配置几个引脚就能让电机转起来,真到自己写SVPWM、看波形才发现里面藏着不少门道。
这篇文章我会从实际工程的角度,把STM32F405上通过SimpleFOC框架实现七段式SVPWM的全过程拆开讲清楚。代码放在GitHub,链接我放在文末,工程可以直接编译烧录。适合两类人看:一是刚入门FOC、想搞懂SVPWM底层原理的,二是已经在用SimpleFOC、但想脱离库函数自己控制PWM波形的。
先说结论:七段式SVPWM并不神秘,本质就是“零矢量居中、两相邻矢量插在中间”的一种PWM序列安排方式。但要把这块从代码层面跑通,踩的坑不比写PID少。
1. SVPWM先搞懂再写代码:七段式到底“七段”在哪里
想从代码层面理解七段式SVPWM,先得把坐标系和矢量合成的概念理清楚。如果你已经滚瓜烂熟,可以直接跳到第二部分看工程实现。
1.1 从三相逆变桥到空间电压矢量
FOC控制的最终结果是得到一个期望的电压矢量,方向和大小都给定。这个矢量要落到三相逆变桥上去执行。三相桥说白了就是三个半桥,每个半桥有上下两个管子,同一时刻只能导通一个。
用“1”表示上管导通、下管关断,“0”表示反过来的状态,那么三个半桥一共能组合出2^3=8种开关状态:000、001、010、011、100、101、110、111。
其中000和111是零矢量,因为三相输出端全部短路到同一电位,负载上没有任何电压差。剩下6个非零矢量,分别对应6个空间方向,把360度分成6个扇区。这六个矢量的幅值都一样,互相之间间隔60度。
只要有六个固定方向的矢量和两个零矢量,就能合成任意方向和任意大小的电压矢量——只要它不超过六边形内切圆。这就是SVPWM的基本原理:通过相邻两个非零矢量加零矢量的时间组合,去“拼”出一个目标电压矢量。
1.2 为什么要用七段式而不是五段式
矢量合成的时间分配方式并不唯一。把相邻矢量和零矢量的作用顺序排一下,就得到了不同的波形模式。最常见的是七段式和五段式。
五段式也叫DPWM,每个PWM周期里只插入一个零矢量,开关次数少,开关损耗低,但谐波大、电流纹波明显。七段式每个PWM周期插入两个零矢量(000和111各一次),并且把零矢量放在周期的起始、中间和末尾,好处是对称性好、谐波含量低、电流波形更平滑。
从实现角度来说,七段式的比较值更新思路特别适合STM32的高级定时器,因为它天然就是“中央对齐PWM”的玩法——定时器向上计数再向下计数,比较寄存器在向上和向下两个阶段各比较一次,正好对应七段式波形的对称结构。
1.3 这里有一个新手特别容易绕晕的点:扇区判断
扇区判断有好几种做法,最常见的是根据Valpha和Vbeta的正负和大小区分。简单粗暴一点,可以直接用atan2()算出矢量角度,再除以60度判断在第几扇区。SimpleFOC官方算法里面用的是更高效的查表判断法,不用算三角函数。
自己写代码的时候,我建议先不要魔改,老老实实先按角度法把扇区算出来,跑通了以后再换查表法优化。上来直接写查表法,逻辑出错了很难排查。
2. 工程搭建:为什么是STM32F405 + SimpleFOC
标题里写了STM32和SimpleFOC,很多朋友自然会问:STM32型号那么多,为啥选F405?SimpleFOC是个库,怎么和STM32的标准外设库组合在一起?
2.1 F405的优点
F405系列主频168MHz,带FPU,浮点运算能力强。FOC的坐标变换、PI调节器里面全是浮点运算,虽然实时性要求没有高到微秒级,但是FPU跑起来绰绰有余,不会给主循环造成压力。
另外F405有多个高级定时器:TIM1和TIM8,都支持带死区插入的互补PWM输出,并且有刹车输入,做电机驱动很合适。我用TIM1的三对互补通道输出PWM,正好对应U、V、W三相。
SimpleFOC官方推荐的STM32板子里就有基于F405的版本(比如BluePill的F405版、或者一部分F407核心板),社区生态很成熟,踩坑资料多,用起来心里有底。
2.2 SimpleFOC在这个工程里承担什么角色
看着SimpleFOC是个库,实际上它只是帮我们把电机抽象的“人类友好”了一层:电流检测、坐标变换、PI调节器、运动控制这些模块都可以直接用。但SVPWM这个环节,SimpleFOC是下沉到了底层硬件驱动里面的,如果你用的是BLDCDriver3PWM这类驱动类,PWM波形生成由它内部的setPwm()统一完成。
我想把七段式SVPWM彻底搞明白,所以做了个大胆的决定:不用SimpleFOC的BLDCDriver3PWM,而是用自己的代码生成SVPWM比较值,直接驱动STM32的TIM1寄存器。SimpleFOC只负责上游的角度计算、坐标变换、PI调节,我把SVPWM这一层“偷”出来自己写。
好消息是SimpleFOC的架构是模块化的,这样做完全可行。你的代码里会同时看到SimpleFOC的BLDCMotor对象,也有直接操作TIM1->CCR1等寄存器的裸代码。混搭得很舒服。
2.3 关键准备工作清单
这个工程依赖的硬件如下:
- 一块STM32F405RGT6核心板(我用的WeAct那款,性价比很高)
- 一个DRV8301或者EG2134驱动板(我用EG2134,便宜且资料全)
- 一个带编码器的云台电机或低压无刷电机
- 一个24V直流电源
- 一个USB转TTL串口模块,用于调试输出
软件方面:
- Keil MDK 5.30及以上(或者STM32CubeIDE也可以)
- STM32CubeMX 6.x,用于生成外设初始化代码
- SimpleFOC库,我用的版本是2.3.3
- 一个趁手的串口调试助手
3. 七段式SVPWM的代码实现:从角度到比较值的完整链条
接下来是重点中的重点:代码怎么一步步把目标电压矢量转换成TIM1的三个比较寄存器值。这一段是我的工程里最核心的部分。
3.1 先搞清楚一个关键问题:SimpleFOC给的电压是什么形式
使用SimpleFOC时,你最终施加到电机的电压由motor.voltage_q和motor.voltage_d两个量描述,分别代表旋转坐标系下的q轴电压和d轴电压。经过反Park变换后,得到静止两相坐标系下的Valpha和Vbeta。
SimpleFOC内部有一个setPhaseVoltage(Valpha, Vbeta, electricalAngle)函数,这个函数就是坐标变换加SVPWM的核心所在。我把它单独摘出来,重新用寄存器方式实现了一遍。
这一步的关键是:SimpleFOC内部处理电压矢量合成时,使用了Ualpha和Ubeta,也就是归一化后的电压。具体归一化系数和你的母线电压有关,不能照抄默认值,必须根据你的硬件重新校准。这也是很多人移植SimpleFOC后电机转得“有气无力”的原因之一。
3.2 角度归一化与扇区划分
反Park变换得到的Valpha和Vbeta是正交分量,根据它们的正负和比例关系就能确定合成电压矢量落在哪个扇区。我参考SimpleFOC源码中的逻辑,做了一个简化判断。
// 输入:Valpha, Vbeta(静止两相坐标系下的电压分量) // 输出:扇区编号 1~6 float v1 = Vbeta; float v2 = -0.5f * Vbeta + 0.8660254f * Valpha; // sqrt(3)/2 ≈ 0.8660254 float v3 = -0.5f * Vbeta - 0.8660254f * Valpha; uint8_t sector; if (v1 > 0) sector = 1; else sector = 4; if (v2 > 0) sector = (v1 > 0) ? 2 : 3; if (v3 > 0) sector = (v1 > 0) ? 6 : 5;这里不需要atan2计算角度,仅仅通过三个中间量的正负就完成了扇区判断,执行效率高很多。至于为什么取0.8660254,因为它是sqrt(3)/2。这里的数学推导在几乎所有SVPWM文献里都有,不展开,但你写代码的时候必须清楚这三个中间量的几何意义,否则后面调参数会一头雾水。
3.3 相邻矢量作用时间的计算
确定扇区之后,需要计算两个相邻非零矢量的作用时间T1和T2。这个时间不是随便定的,它和PWM周期Ts以及直流母线电压Udc有关。
时间计算的核心公式在各个扇区略有不同,但可以统一写成一个通用的形式。SimpleFOC源码里用了一套巧妙的变量复用来处理,我摘了一段并加了注释给你参考:
// sector 1~6通用逻辑(以扇区1为例) // T1对应矢量U0(100),T2对应矢量U60(110) // Tpwm为PWM周期,单位与定时器计数周期对齐 float Tpwm = (float)(TIM1->ARR + 1); // 自动重装载值+1就是周期计数值 float Udc = 24.0f; // 母线电压,根据实际调整 float T1, T2; switch (sector) { case 1: T1 = ( 0.8660254f * Valpha - 0.5f * Vbeta) / Udc * Tpwm; T2 = ( Vbeta) / Udc * Tpwm; break; // case 2~6类似,不再一一列举 }结合对比值更新的理解:比较值不是绝对的PWM占空比,而是相对于定时器计数周期的一个“翻转点”。在中央对齐模式下,定时器向上计数的时候达到比较值翻转一次,向下计数的时候再翻转一次,形成对称的PWM波形。
3.4 七段式插入顺序与比较值生成
七段式波形的关键步骤在于:算出T1、T2后,需要把两个零矢量的时间均分到PWM周期的开头、中间和结尾,插入顺序有严格规律。
先放结论,以扇区1为例,七段式的开关序列是:
000 -> 100 -> 110 -> 111 -> 110 -> 100 -> 000
也就是说,一个PWM周期内,开关状态经历了七次变化,形成一个对称的序列。其他扇区类似,只是非零矢量的组合不同。
比较值的生成规则如下:先算三个中间量Ta、Tb、Tc:
float Ta = (Tpwm - T1 - T2) / 4.0f; float Tb = Ta + T1 / 2.0f; float Tc = Tb + T2 / 2.0f;然后根据扇区查表分配给三相:
switch (sector) { case 1: CCR1 = Ta; CCR2 = Tb; CCR3 = Tc; // A相上管、B相上管、C相上管 break; case 2: CCR1 = Tb; CCR2 = Ta; CCR3 = Tc; break; // 其他扇区不再展开 }到这里,三个比较寄存器的值就计算完了。接下来的事情就交给STM32的高级定时器去生成物理波形。
3.5 为什么比较值的计算能做“减法”而不是“除法”
有朋友可能注意到,我在作用时间计算里用了/ Udc,而在比较值里用的是/ 4.0f,这些都是浮点除法。实际上在性能敏感的地方,浮点除法很浪费时间。
F405有FPU,单精度浮点除法大概十几个周期,在168MHz下几乎可以忽略。但如果换到没有FPU的F103上,就要小心了,可以考虑用定点或者查表替换。这也是SVPWM实现中最高级也最容易出bug的优化方向。
我这里专门保留浮点计算,是为了代码可读性和可调试性。等你在逻辑分析仪上看到正确的波形之后,再优化不迟。
4. 从占空比到物理波形:定时器配置与死区处理
软件上算出了比较值,但PWM要真正发出互补且带死区的三相信号,还得靠STM32的高级定时器好好配置。
4.1 TIM1的中央对齐模式配置
七段式SVPWM需要中央对齐模式(Center-Aligned Mode 1或2)。这种模式下,定时器计数器先向上计数到ARR,再向下计数到0,比较输出在两种计数的过程中各发生一次。和向上计数模式相比,中央对齐模式天然产生对称的PWM波形,谐波含量低。
CubeMX里的关键配置项:
Counter Mode: Center-Aligned Mode 1Counter Period: 8400(这个值和PWM频率相关,后面算给你看)Prescaler: 0(不做预分频)Auto-reload preload: EnablePulse: 初始值随便给,后面代码会动态更新
PWM频率的计算公式:
PWM频率 = 定时器时钟 / (ARR + 1) / 2
为什么要除以2?因为中央对齐模式下,一个完整的PWM周期需要向上加向下两次计数。我把TIM1的时钟配置为168MHz,ARR设置为8400,那么PWM频率就是:
168MHz / 8401 / 2 ≈ 10kHz
对大多数电机控制应用来说,10kHz的PWM频率是够用的。如果后续想提高频率,把ARR值调小即可,但要注意:ARR太小会导致占空比分辨率下降,因为比较值可调整的档位变少了。
4.2 死区时间的插入
三相逆变桥中,同一桥臂的上下两个管子不能同时导通,否则直接烧毁。所以必须插入死区时间——下管延迟导通、上管提前关断,给管子留出安全切换的时间窗口。
CubeMX中TIM1的Break and Dead Time配置里,可以设置Dead Time值。这个值不是直接给纳秒,而是根据定时器时钟计算的一个系数。
我用的驱动器是EG2134,内置了死区电路,所以软件里我不再额外插入死区。如果你用的是纯MOS管+驱动芯片的方案,需要在CubeMX里配置死区,建议初始值给1us,再根据实际波形调整。
配置好后,只需要在代码里使能TIM1的互补PWM输出即可:
void MX_TIM1_Init(void) { // CubeMX生成的初始化代码中已经包含死区配置 HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_2); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_3); HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_1); HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_2); HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_3); }注意:PWM_Start开的是主输出,PWMN_Start开的是互补输出,两者必须都调用,否则会有一半的MOS管永远不动作。
4.3 一个容易导致电机狂震的坑:ARPE位
STM32高级定时器的比较寄存器和自动重装载寄存器都有预装载功能,也就是说你写入的值不会立刻生效,要等到更新事件才生效。
这个特性太重要了。SVPWM的输入电压是实时变化的,如果你去掉预装载,那么PWM周期中间改比较值,会导致波形瞬间错乱,电机出现明显的抖动和噪音。
CubeMX里默认会开启ARPE,除非你手工去关。如果你是自己手写寄存器配置,务必把TIM_CR1的ARPE位(第7位)置1,否则你会看到很多莫名其妙的波形毛刺。
5. 波形调试实录:从散乱脉冲到标准马鞍波
代码写完之后,最重要的环节就是看波形。只有真实示波器上的波形才是终极裁判。
5.1 先开环转起来,再谈闭环
带SimpleFOC做闭环之前,我建议先开环验证SVPWM的正确性。开环的意思是:不读编码器,也不做电流闭环,直接给一个固定的Valpha、Vbeta电气角度,让SVPWM模块输出一个等效的直流电压矢量,电机会被“吸”到一个固定位置并锁住。
那时候我的工程里写了这么一段测试代码:
// 开环测试:输出固定电压矢量 float test_angle = 0.785f; // 45度电角度 float volt_mag = 2.0f; // 2V左右的电压幅值 while (1) { float Valpha = volt_mag * arm_cos_f32(test_angle); float Vbeta = volt_mag * arm_sin_f32(test_angle); setSVPWM(Valpha, Vbeta, 0); // 第三个参数是电角度,开环时用不到 HAL_Delay(1); }这个测试如果成功,电机会锁定在一个固定位置,用手去扭它,会感觉到明显的阻力——这说明SVPWM在正常工作。如果电机毫无反应,或者剧烈抖动,说明你的SVPWM里面有问题,先排查扇区判断和比较值计算。
5.2 用DAC输出内部变量,事半功倍
别急着上示波器,先解决“怎么看到内部变量”的问题。F405板上没有DAC的话,用串口输出内部变量太慢。我这里采取了一个取巧的办法:把F405的DAC输出配置好,把内部变量通过DAC引脚以模拟电压的形式输出。
比如,我把扇区编号映射到DAC输出0~3V的电压,扇区1输出0.5V,扇区2输出1.0V,以此类推。这样就能在示波器上直接看到扇区的切换是否连续,有没有跳变。
这个调试方法真的能救命。有一次我把扇区判断逻辑写错了,电机在某个角度范围疯狂抖动,串口打印的数据根本看不出规律,但是用DAC输出扇区编号后,一眼就看出扇区切换顺序乱了。
5.3 逻辑分析仪看七段式波形
如果你的示波器只有两通道,建议再加一个逻辑分析仪,专门看PWM的时序。把逻辑分析仪的探头接到TIM1_CH1、TIM1_CH1N、TIM1_CH2三个引脚上,触发方式选为上升沿触发,抓一个PWM周期的波形。
七段式波形的典型特征是:每个PWM周期内,同一相的PWM波有两次翻转(上下沿各一次),且波形关于周期中心对称。
如果你发现波形不对称,或者中间有毛刺,多半是以下几个原因:
- 定时器配置成了向上计数模式而不是中央对齐模式
- ARPE预装载没有使能
- 比较值计算时用了整数除法导致的截断误差
5.4 电角度递进时波形周期对应
把开环测试代码里的test_angle改成每1ms递增一个固定步长,让电压矢量缓慢旋转,这时候观察三路PWM的占空比波形,你会看到一个非常漂亮的马鞍形波形——这就是SVPWM最经典的特征波形。
不要小看这个波形,等你能在示波器上看到它,你的SVPWM代码就成功了一大半。接下来再接入SimpleFOC的FOC控制逻辑,让电角度从编码器获取,就能完整跑起来。
6. 常见问题与排查技巧实录
最后分享几个我在实际操作中踩过并且排查了很久的坑,希望能帮你省下几个晚上的调试时间。
6.1 症状:电机抖动但完全不转
- 可能原因1:扇区判断错误。检查扇区编号是否连续,尤其注意代码里用了uint8_t还是int8_t。如果用uint8_t,
sector永远不会是负数,又恰好和你判断条件里的正数混淆,就会全乱。 - 可能原因2:比较值超出ARR范围。当目标电压矢量过大,T1+T2超过Tpwm时,需要做饱和限幅,否则比较值可能超过ARR,导致PWM输出异常。
解决方法是加一段饱和限幅:
if (T1 + T2 > Tpwm) { float scale = Tpwm / (T1 + T2); T1 *= scale; T2 *= scale; }这个饱和处理非常重要。当初我漏掉这一步,目标电压稍微大一点电机就发狂,掉进了“振幅过调制”的坑。
6.2 症状:能转但电流波形很难看
电流波形难看,最常见的原因不是SVPWM,而是PWM频率和电流采样时机没有对齐。SimpleFOC的默认电流采样点设置在PWM周期的中心点,这个中心点的电压矢量没有开关动作,地弹最小,采样效果最好。
如果你把ARR设置成奇数,可能导致PWM周期的中心点和定时器溢出点错开,电流采样时机不在最佳点。建议ARR设置成偶数,保证采样能在精确的中心时刻触发。
6.3 症状:跑半小时后芯片发热严重
- 驱动芯片发热,优先检查死区时间是否足够。
- 主控芯片发热,大概率是电压采样电阻分压电路发热,或者PWM频率设置过高导致开关损耗变大。
- 电机本身发热,检查你是否长时间堵转运行,以及PWM频率是否接近电机的机械共振频率。
6.4 症状:串口打印SVPWM变量,偶尔出现NaN
NaN绝大多数情况是浮点运算出现了除零。我的工程里最常见的场景是:Udc母线电压采样在开机瞬间是0,此时/ Udc计算直接产生NaN。
解决办法是给Udc加上下限保护:
if (Udc < 1.0f) Udc = 1.0f;这个小改动就能避免大量诡异问题。
6.5 症状:刚上电时波形瞬间错乱
刚上电时,SimpleFOC的初始化流程还没走完,motor.voltage_q是初始值0,但你的setSVPWM可能已经收到一个无效的角度或电压值。
建议在电机初始化完成之前,直接禁止TIM1的PWM输出,或者把比较值强制写到50%占空比(即零矢量状态),等主循环真正开始运行后,再切换到正常的SVPWM输出。
7. 完整工程文件说明与后续扩展思路
工程根目录下主要文件的功能如下,方便你对照阅读:
Core/Src/main.c:主循环,初始化所有外设,调用SimpleFOC和SVPWM模块Core/Src/svpwm.c:七段式SVPWM核心实现,包括扇区判断、作用时间计算、比较值更新Core/Src/foc_control.c:SimpleFOC相关封装,包括电机初始化、FOC计算入口Core/Src/dac_debug.c:DAC调试输出,用于输出内部变量到示波器
注意:直接用这个工程前,需要根据你的电机和驱动板修改svpwm.c里的母线电压值Udc,以及foc_control.c里的极对数和编码器线数。
这个工程跑通之后,后续还有几个方向可以扩展:
把SVPWM模块改造成支持五段式,对比两种调制方式的谐波和效率差异,这可以作为深入研究FOC的绝佳练习
把PWM波形的生成做到仅由寄存器中断触发,不依赖主循环堵塞,这样可以大大提升控制环路的稳定性和实时性
尝试在SVPWM内部加入零序注入,实现对母线电压利用率的进一步优化
根据我的经验,真正把SVPWM这一层吃透之后,再去看SimpleFOC源码里的其它细节,比如死区补偿、电流采样点偏移、弱磁控制等,都会顺畅很多。这远比直接抄一遍库函数有意思,也更有价值。