
从去年开始我一直在折腾无刷电机的位置闭环最开始用的是Arduino跑SimpleFOC电机是2804云台电机。板子和电机的接线半小时就弄完了代码也简单但Arduino那颗单片机在跑FOC算法的时候明显吃力——电流采样分辨率、PWM更新频率、浮点运算速度都卡在那里位置环响应总是慢半拍。后来我把平台换成了STM32F407用标准库而不是HAL库把SimpleFOC跑了起来效果完全不一样。说实话这个组合在网络上的完整教程不多。大部分人要么用ArduinoSimpleFOC要么用F407HAL库手写FOC。用F407标准库去接SimpleFOC看起来是一个混搭但在实际工程中却是不少人的真实需求手里已经有标准库的代码积累、不想迁到CubeMX、又想快速用SimpleFOC的矢量控制算法。这篇文章就围绕这套方案讲清楚从硬件选型到三环调试的完整链路适合已经玩过一点FOC、对STM32有基础、想自己搭一套位置闭环系统的朋友。1. 选型逻辑为什么是F407标准库SimpleFOC这个混搭组合1.1 SimpleFOC究竟帮你做了什么先简单对齐一下。SimpleFOC是一个开源的FOC磁场定向控制框架它把BLDC电机矢量控制里最麻烦的坐标变换、SVPWM生成、电流环速度环位置环的PID以及各种磁编码器的驱动都封装好了。你不需要自己推Clarke变换和Park变换的公式也不需要逐行写SVPWM的扇区判断调用motor.loopFOC()和motor.move()就能跑起来。但这也意味着它默认了你用的主控是Arduino那套生态。SimpleFOC的README里虽然写了支持STM32、ESP32、Teensy但深入去看代码stm32_mcu.cpp是用HAL库写的依赖HAL_TIM_PWM_Start、HAL_ADC_Start_DMA这些函数。如果你的项目是标准库工程直接把这几个文件拖进来是编译不过的。这正是这篇文章要解决的核心问题。我为什么不用手写FOC并不是手写不行而是调试成本太高。你从零实现一套FOC每一个环节都可能出错坐标变换的符号、SVPWM的扇区判断、电流采样点位置任何一处错都会让电机动不了而且很难定位。SimpleFOC把这些封装成模块出了问题你可以在已知正确的框架里去排查自己的接线和参数效率完全不一样。1.2 F407相对Arduino和F103的底气在哪里我把几个常用平台放在一起对比过差别主要在三点主频、FPU、ADC采样能力。平台主频FPUADC采样能力跑SimpleFOC的体验Arduino Uno (AVR)16MHz无10位慢勉强能跑位置环响应明显滞后STM32F10372MHz无Cortex-M312位1Msps左右能用软件浮点运算拖累整体性能STM32F407168MHz有Cortex-M4F12位多路并行最高2.4Msps余量充足可在20kHz PWM下稳定跑完三环位置闭环对主控的要求不只是算得完还得算得快。FOC每个PWM周期都要执行一次Clark变换、Park变换、两次PID和SVPWM的计算如果这些运算耗时过长电流环的实际执行频率就会掉。F407带硬件FPU浮点运算比F103快好几倍这个是跑SimpleFOC非常关键的一个优势。我实测用F407跑SimpleFOCPWM频率设在20kHz三环全部闭环CPU占用不到一半。Arduino那边到10kHz就已经满负荷了位置环还时不时丢几个周期。这种差距在位置闭环场景下直接体现在手感上F407控制下的电机停止干脆利落Arduino控制的电机总有一种软绵绵的感觉到位之后还会轻微晃动。1.3 用了标准库反而避开了CubeMX生成代码的重量感很多刚接触STM32的朋友会被CubeMX的图形化配置吸引但我个人在实际项目中更倾向标准库原因很朴素标准库的API直白一个寄存器操作一眼就能看懂在干什么。你在调FOC的时候需要频繁检查PWM输出、ADC触发、DMA搬运这些东西标准库里TIM_Cmd、ADC_RegularChannelConfig这些函数调用比HAL的层层封装更容易定位问题。HAL库本身没有错它的抽象做得很好尤其在跨芯片移植的时候很省事。但FOC这种对时序敏感的应用你往往需要精细控制ADC每次采样点在PWM周期的哪个位置HAL库的封装层会把这种精确控制变得很绕。标准库里你可以直接操作ADC_ExternalTrigConvCmd、TIM_SelectOutputTrigger心里很清楚每一个触发信号是从哪走到哪的。另外SimpleFOC在标准库下的移植并不需要全部重写底层文件集中在两三个源文件里改起来范围可控。后面的第三章我会把具体的改写过程展开。2. 2804电机的硬件闭环要素编码器、驱动板和电源的搭法2.1 2804电机和编码器的配合2804电机12N14P结构极对数为7内转子体积小、转速不算高、输出扭矩适中。它很适合做云台、小型机械臂、开源舵机这类对体积和重量敏感、同时要求位置精度的场景。要做位置闭环编码器是刚需。2804电机尾巴一般有预留的安装孔常见方案是贴一颗磁铁在电机转轴末端再把磁编码器芯片固定在正上方。我实测过两种编码器AS5600I2C接口12位分辨率4096步/圈价格便宜最常见。MT6701SPI接口14位分辨率抗干扰更好带ABZ输出。如果你刚开始做我建议先上AS5600原因不是精度够而是SimpleFOC对它的支持最成熟。MagneticSensorI2C类里直接填地址就能用不需要自己写协议。接线方面我这边用的是I2C1SDA接PB7SCL接PB6。注意AS5600的VCC必须和STM32共地否则I2C会随机读错。信号引脚说明电机三相线U/V/W驱动板三相输出顺序会影响转向调试时可交换任意两相驱动板PWM输入TIM1_CH1/CH2/CH3对应PA8/PA9/PA10驱动板使能EN任意GPIO我用PA15高电平使能编码器SDAPB7I2C1数据线编码器SCLPB6I2C1时钟线编码器VCC/GND3.3V/GND与STM32共地2.2 驱动板与电源匹配驱动板的选择直接决定电流环能跑多稳。2804电机正常工作电流通常在1~3A之间峰值可能到5A所以驱动板的持续电流能力最好不要低于5A。我用过的方案里有L6234和国产的基于EG2133/DRV8313的板子都能跑起来区别在于开关损耗和发热控制。注意驱动板的逻辑供电电压一般是3.3V或5V和电机电源一定要分开接。我见过有人图省事共用一个电源结果电机启动瞬间电压跌落MCU直接复位位置闭环根本没法稳定运行。电机电源方面2804我建议先拿12V跑通功能不要一上来就上24V。12V下FOC的三环调试参数会比较温和不容易烧驱动板。等参数都摸熟了再考虑提高母线电压来提升高速性能。电源的电流输出能力至少要能持续提供5A我用的是12V 5A的开关电源实测够用。另外要注意死区时间。标准库配置TIM1互补PWM时需要设置死区寄存器BDTR一般建议0.5~1us。如果死区时间太短上下桥臂直通MOS管就冒烟了。后面详细说这个。3. 标准库工程环境与SimpleFOC源码的搬移过程3.1 新建标准库工程最容易被忽略的两个点标准库工程网上模板很多但结合FOC场景有两个点必须确认一是FPU是否打开。F407的FPU默认是关闭的需要在启动文件里设置。如果你用Keil在Options - Target - Floating Point Hardware里选Single Precision如果是IAR或GCC要在启动代码里修改CPACR寄存器。FPU没打开FOC算法用的float运算就会退化成软件模拟性能直接掉一大截。二是时钟配置是否正确。F407的SYSCLK要到168MHz需要正确配置PLL。很多人照抄模板结果PLL配错外设时钟全部乱掉ADC采样率、PWM频率全部不对。位置闭环对这种系统性偏差非常敏感——你会看到电流波形始终有毛刺怎么调PID都没用。我建议新建工程之后先别急着写业务代码用一个GPIO翻转测试确认主频在main循环里翻转一个引脚用示波器量频率如果和理论值差很多系统时钟必然有问题先把这个问题解决再往下走。3.2 SimpleFOC源码裁剪与底层改写SimpleFOC源码的结构大致是核心的BLDCMotor、BLDCDriver、MagneticSensor等类加上针对不同平台的底层mcu实现。标准库工程只需要搬核心算法文件和传感器文件去掉Arduino相关的部分。具体来说把src/common、src/current_sense、src/drivers、src/encoders、src/modules这几个目录下的核心文件加进来加上src/communication/SerialCommand如果你要串口调试的话然后把Arduino依赖相关的Arduino.h包含和宏定义处理掉。底层改写集中在三个地方PWM输出原代码用HAL_TIM_PWM_Start和HAL_TIMEx_PWMN_Start开启互补PWM改为标准库的TIM_Cmd、TIM_OCxInit、TIM_BDTRConfig等。ADC触发与DMA原代码用HAL_ADC_Start_DMA改为标准库的ADC_RegularChannelConfig、ADC_DMACmd。I2C/SPI读取编码器原代码用HAL_I2C_Mem_Read改为标准库的I2C_SendData/I2C_GetFlagStatus。这里最关键的一步是让电流采样和PWM同步。SimpleFOC的思路是使用中心对齐PWM的TRGO事件触发ADC注入组采样保证采样点在PWM周期中点附近从而避免开关噪声。用标准库写的话核心配置是TIM1的TRGO设置成更新事件或OCxREF触发然后ADC的注入通道由外部触发启动。如果这个同步做不好电流采样得到的波形会带大量毛刺电流环会发抖。// 标准库下TIM1中心对齐PWM 互补输出 死区配置的关键代码 TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; TIM_BDTRInitTypeDef TIM_BDTRInitStructure; TIM_TimeBaseStructure.TIM_Prescaler 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_CenterAligned2; TIM_TimeBaseStructure.TIM_Period 4200; // 168MHz / (4200*2) 20kHz TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM1, TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM2; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_OutputNState TIM_OutputNState_Enable; TIM_OCInitStructure.TIM_Pulse 2100; TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OCInitStructure.TIM_OCNPolarity TIM_OCNPolarity_High; TIM_OCInitStructure.TIM_OCIdleState TIM_OCIdleState_Reset; TIM_OCInitStructure.TIM_OCNIdleState TIM_OCNIdleState_Reset; TIM_OC1Init(TIM1, TIM_OCInitStructure); TIM_BDTRInitStructure.TIM_OSSRState TIM_OSSRState_Enable; TIM_BDTRInitStructure.TIM_OSSIState TIM_OSSIState_Enable; TIM_BDTRInitStructure.TIM_LOCKLevel TIM_LOCKLevel_OFF; TIM_BDTRInitStructure.TIM_DeadTime 72; // 72 tick 168MHz ≈ 0.43us TIM_BDTRInitStructure.TIM_Break TIM_Break_Disable; TIM_BDTRInitStructure.TIM_AutomaticOutput TIM_AutomaticOutput_Enable; TIM_BDTRConfig(TIM1, TIM_BDTRInitStructure); TIM_Cmd(TIM1, ENABLE); TIM_CtrlPWMOutputs(TIM1, ENABLE);TIM_CtrlPWMOutputs这个函数在标准库里叫TIM_CtrlPWMOutputs它负责使能TIM1的PWM主输出千万不能漏掉否则你配置了PWM但引脚上没有任何波形。3.3 底层接口类如何对接SimpleFOCSimpleFOC在初始化的时候会调用driver.init()这个函数内部会调用_configurePWM和_configureADC。你要做的就是把这两个函数内部实现换成标准库版本。ADC部分我用的是双电阻采样ADC1和ADC2分别采集两相电流第三相电流通过基尔霍夫定律算出来。配置要点是让ADC1和ADC2的注入通道同时由TIM1的TRGO触发这样两路采样点严格同步。我用的配置是PA4和PA5作为电流采样引脚。// ADC注入组触发配置的标准库写法 ADC_ExternalTrigConvCmd(ADC1, ENABLE); ADC_ExternalTrigConvCmd(ADC2, ENABLE); ADC_ExternalTrigInjectedConvConfig(ADC1, ADC_ExternalTrigInjecConv_Ext_IT15_TIM1_TRGO); ADC_ExternalTrigInjectedConvConfig(ADC2, ADC_ExternalTrigInjecConv_Ext_IT15_TIM1_TRGO); ADC_ExternalTrigInjectedConvCmd(ADC1, ENABLE); ADC_ExternalTrigInjectedConvCmd(ADC2, ENABLE);同时要设置TIM1的TRGO输出为更新事件TIM_SelectOutputTrigger(TIM1, TIM_TRGOSource_Update);这样每个PWM周期结束时触发一次ADC注入组采样采样点天然落在中心附近。我自己实测这样做出来的电流波形基本看不到毛刺。4. 位置闭环调试链路从开环验证到三环串级整定4.1 开环转动测试与编码器角度读取位置闭环不是一堆PID怼上来就完事。我习惯按下面的顺序走第一步先不接电机单独读编码器角度。用手转动电机轴看串口打印的角度变化是否连续、是否有跳变。正常的话AS5600输出范围是0~4095正转时数值递增反转时递减中间不能有大段跳跃。如果出现从数字直接跳到4095这样的跳变通常是采样瞬间I2C读错或者磁铁没贴正先解决这些问题再往下走。第二步开环测试。让SimpleFOC进入voltage模式给定一个固定的电压矢量和方向电机应该平稳转动。这一步主要确认驱动板PWM输出、三相相位和电机绕组连接是否正确。如果电机转起来很卡顿、或者噪声大多半是三相接线顺序错了交换任意两相即可。我一般这样开环测试motor.controller MotionControlType::torque然后motor.move(2.0f)给一个2V的电压矢量。正常的话电机会以恒定力矩转动没有任何顿挫感。如果有顿挫基本是编码器角度和电角度不同步后面校准那步会纠正。4.2 电角度零点校准开环没问题之后要做电角度零点校准。SimpleFOC的校准原理很简单先给d轴通一个固定电压矢量让电机转子锁到一个确定的电角度位置然后读取当前编码器值把它记录为电角度零点。之后每次运行代码都会用当前编码器值减去这个零点偏移换算出实时电角度。这段校准代码在initFOC()内部执行唯一要设置的是电机极对数motor.pole_pairs 7。校准完成后电机应该处于锁定状态用手拧它会感到明显的阻力。如果没有阻力说明校准失败通常原因是编码器供电不稳或者I2C读数有毛刺。提示校准前一定要把电流限幅调低一点比如motor.current_limit 1.0f。否则校准瞬间电机会猛冲一下容易把机械结构撞坏。我第一个云台支架就是这么被撞断的。4.3 三环整定顺序与实测参数SimpleFOC的三环串级结构是最外层位置环输出给速度环速度环输出给电流环电流环直接控制电压矢量。调参顺序必须从最内环开始否则外面环路的误差会叠加进内环很难判断是哪个环节的问题。我的调参顺序是这样的先调电流环。把速度环和位置环都关掉直接控制目标电流。电流环给一个阶跃目标电流观察反馈电流的响应波形。响应应该快速且没有明显过冲如果震荡就减小P如果响应太慢就增大P和I。电流环稳定后调速度环。目标是让电机能平稳地以设定速度旋转用手抓住电机轴再放开看能否快速回到目标速度。速度环的积分项用于消除稳态误差但I太大会引起低频振荡。最后调位置环。位置环一般只用P除非有特殊的抗扰动需求才加很小的I。位置环的响应要快到到位即停同时又不能有来回震荡。环路建议P建议I输出限幅我的实测值电流环0.1~0.50.01~0.05电压幅值限制我这里设10VKp0.3, Ki0.03速度环0.05~0.50.05~0.5速度限幅rad/sKp0.2, Ki0.08位置环5~200位置限幅radKp12, Ki0这里有个细节位置环输出给速度环的是目标速度如果位置环P太大目标速度会瞬间打到限幅值电机就会猛冲。所以位置环的P值要结合速度环的响应能力来设。我的经验是位置环P先设成5观察电机到位情况再逐步往上加直到出现轻微的来回震荡就回调20%左右。5. 实测中的坑电机啸叫、角度跳变与负载波动5.1 电机啸叫的源头定位调试过程中电机啸叫是高频现象。第一次遇到时我以为是PWM频率不够把PWM从10kHz提到20kHz啸叫还在。后来用示波器量电机相线波形发现是互补PWM在过零切换的时候出现了毛刺原因是死区时间配得太小。把BDTR里的死区时间从0.2us调到0.5us左右啸叫明显减弱。如果啸叫不是死区引起的还有一种可能电流环参数不当导致环路振荡这时候电流波形会出现高频分量声音就从这儿来。排查方法是先用低P值让电流环退稳再逐步加上去。另外电流采样如果没和PWM同步也会引起类似的噪声。因为采样点落在开关毛刺上采到的电流值是脏的反馈进控制环路就会产生额外的谐波。我之前遇到过类似情况做了TRGO同步触发之后噪声就消失了。5.2 编码器角度跳变导致位置闭环震荡位置闭环最明显的症状是电机静止时能听到细微的嗡嗡声而且位置有小幅震荡。用串口打印位置反馈发现数值在目标位置附近跳来跳去跳变幅度远大于量化误差。我排查的时候发现AS5600的I2C读取偶尔会读到异常值尤其是电源线比较长、供电不稳的时候。解决办法有两个一是在编码器电源引脚附近加一个10uF陶瓷电容二是对I2C读取做软件滤波连续读三次取中位数。做了这两件事之后角度跳变基本消失。// 简单的三取中位数滤波适用于I2C读编码器 uint16_t readAngleFiltered() { uint16_t a sensor.readAngle(); uint16_t b sensor.readAngle(); uint16_t c sensor.readAngle(); if (a b) { uint16_t t a; a b; b t; } if (b c) { uint16_t t b; b c; c t; } if (a b) { uint16_t t a; a b; b t; } return b; }注意这个滤波函数只在SimpleFOC的底层传感器读取处使用不要在loopFOC()内部额外增加耗时。三取中位数本身很快但如果加入太多滤波算法会把整个控制周期拖慢反而得不偿失。5.3 负载波动时的对策位置闭环带负载后我发现一个典型问题负载增大时电机会出现低频抖动位置误差变大。原因是SimpleFOC默认的速度环和电流环参数是按空载调出来的带载后环路增益不足。对策有两个思路一是把速度环的I增大一些让速度环在静态时有更强的积分补偿能力。二是如果抖动依然存在就在机械层面查一下是不是有间隙或者共振。我遇到过一种情况负载板子没固定紧整个系统周期性晃动PID怎么调都压不住把螺丝拧紧就正常了。提示定位低频抖动时先把系统开环用手摸一下感受机械结构本身有没有松动。很多时候环路震荡其实是机械共振或者安装间隙造成的排除机械因素后再调参数才有意义。另一个实战技巧是用SimpleFOC的力矩前馈功能。在位置环输出到速度环的同时额外叠加一个根据负载估算的前馈力矩可以明显改善大负载下的动态响应。但这个需要对负载特性做建模我目前还在摸索阶段后面有结论了再单独写一篇分享。最后说一句调试中的体会吧。这套方案跑通之后我把同样的代码从2804换到了2204、2604等不同规格的电机上只需要改动极对数和电流限幅就能跑SimpleFOC的可移植性确实不错。但真正让我觉得值得的不是省了多少代码量而是它把FOC里最抽象的矢量控制变成了一个个可以直接用示波器验证的环节——开环验证接线、闭环验证参数。建议第一次接触的读者也按这个顺序来跳步调试往往是浪费最多时间的。如果你也正在折腾F407标准库跑SimpleFOC欢迎评论区聊聊你踩到的坑。