FOC电流采集优化实战:从软件等待到硬件触发与DMA搬运 1. 先交代背景一段“能跑但很浪费”的FOC电流采集代码1.1 原始代码到底做了什么这一篇继续聊嵌入式性能优化把一个我实际踩过的FOC电流采集代码优化过程完整复盘一遍。项目是一套低压无感FOC电机控制器MCU用STM32G474PWM频率20kHz电流环在PWM更新中断里执行。整板跑起来后CPU负载比我预想的高不少低速轻载时还能接受高速满载时中断里待久了连带通信和监控任务一起被挤得喘不过气。最初版本的电流采集逻辑很“耿直”每个PWM周期进一次更新中断在中断里软件触发ADC注入组转换然后两条while循环干等转换完成再去读两个ADC的注入数据寄存器。读出来之后先过一层8点滑动平均滤波再做Clarke变换和Park变换把Iu、Iv换成Id、Iq给电流环用。简化后大约是这样的结构void TIM1_UP_IRQHandler(void) { ADC1-CR2 | ADC_CR2_JSWSTART; // 软件触发ADC1注入组 ADC2-CR2 | ADC_CR2_JSWSTART; // 软件触发ADC2注入组 while (!(ADC1-ISR ADC_ISR_JEOC)); // 等ADC1注入转换完 while (!(ADC2-ISR ADC_ISR_JEOC)); // 等ADC2注入转换完 int16_t iu_raw (int16_t)(ADC1-JDR1 0xFFFF); int16_t iv_raw (int16_t)(ADC2-JDR1 0xFFFF); // 8点滑动平均 g_iu_buf[g_idx] iu_raw; g_iv_buf[g_idx] iv_raw; g_idx (g_idx 1) 0x07; int32_t iu_sum 0, iv_sum 0; for (int i 0; i 8; i) { iu_sum g_iu_buf[i]; iv_sum g_iv_buf[i]; } float iu (float)iu_sum / 8.0f; float iv (float)iv_sum / 8.0f; float iu_f (iu - I_U_OFFSET) * I_U_SCALE; float iv_f (iv - I_V_OFFSET) * I_V_SCALE; // Clarke变换 float ialpha iu_f; float ibeta (iu_f 2.0f * iv_f) * 0.577350269f; // Park变换角度用浮点sinf/cosf float cos_t cosf(g_theta); float sin_t sinf(g_theta); float id cos_t * ialpha sin_t * ibeta; float iq -sin_t * ialpha cos_t * ibeta; g_ctrl.id_fbk id; g_ctrl.iq_fbk iq; ADC1-ISR ~ADC_ISR_JEOC; ADC2-ISR ~ADC_ISR_JEOC; }这套代码功能没问题在桌面上简单跑个电机也确实看不出大毛病。但一旦把示波器勾在某个IO脚上数一数中断高电平时间问题就全暴露了。1.2 性能瓶颈直观感受执行时间、中断占用和CPU占有率我用逻辑分析仪在TIM1更新中断开始时翻转一个GPIO在中断结束再翻转回来测出来的高电平宽度稳定在3.8us左右。主频170MHz算下来一个周期差不多640个cycle。一个20kHz的中断光这段代码就让CPU负载升高了大约7.6%如果电流环和速度环再压进同一个中断这个比例还会往上走。当时我按功能模块拆开把滤波、等待、变换一块块摘除掉逐一测耗时。结果比较意外最大的时间黑洞不是数学变换而是while等待ADC转换。软件触发注入组后ADC真正完成转换需要约1.6us这段纯等待时间占掉了整个中断的四成。紧随其后的是sinf/cosf两个库函数在Cortex-M4上用FPU也不算便宜加起来接近0.9us。再往后是8点滤波的循环累加和除法约0.5us。Clarke/Park本身都是几条乘加法反而占不了多少。这么一拆优化方向就清楚了砍等待、换掉三角函数、精简滤波。2. 拆解FOC电流采样的性能包袱时机、搬运、计算与滤波2.1 采样窗口的错位被迫加滤波的根源先聊采样时机。很多人只知道“电流采样要放在下桥”但实际并不只是“下桥导通”那么简单。FOC电流采样通常采的是逆变器下桥臂串联采样电阻上的压降采样窗口必须落在电流流过下桥MOS管或续流二极管的零矢量区间。对SVPWM七段式而言每个PWM周期会有两个零矢量一个是上桥全关、下桥全通的零矢量000态另一个是上桥全通、下桥全断的零矢量111态。只有前者才适合采样下桥电阻。这个区间出现在三角载波的波峰或波谷附近具体要看中心对齐的工作模式。原始代码是在PWM更新中断里手动触发ADC的触发时刻和PWM计数器状态没有硬件绑定很容易落在非零矢量时期。在非零矢量期间某一相或几相上桥开通下桥续流处于过渡状态采样电阻上的电流包含高频开关纹波。为了把这部分纹波压下去代码里上了8点滑动平均一个调制周期20kHz平均窗就有400us长度对电流环来说这个相位滞后相当可观。更麻烦的是不同占空比下采样点位置飘移有些角度会采到MOS管开关振铃的毛刺8点平均也压不干净。所以真正要做的是先把采样触发时机改对让ADC采到稳定窗口里的电流而不是靠滤波去硬扛。触发时机一旦正确哪怕后续滤波很轻波形也比原来干净得多。2.2 等待、搬运与变换计算每一微妙都花在哪继续拆耗时。实测里中断的最开始那段while等待本质上是ADC转换时间与CPU执行时间的串行开销。软件触发后CPU本来可以去干别的比如先做上一拍没算完的预处理或者至少准备好查表的前置索引但这个版本偏偏选择干等。更糟糕的是两个ADC是先后等待ADC1等完了还要等ADC2相当于两个转换周期全部暴露在中断里。如果用硬件触发和DMA搬运ADC转换时间可以和CPU执行时间重叠ADC在后台转换的同时CPU可以直接开始做上一轮已经采好的数据处理转换完成由DMA自动搬回内存CPU只需要在DMA完成中断里取现成数据。这一改相当于把约1.6us的等待时间清零。然后是sinf/cosf。Cortex-M4虽然有FPU但FPU只支持基本加减乘除和开方三角函数仍需要软件库函数展开编译器一般会调用__aeabi_sinf那套流程涉及多项式逼近、参数归约、查表等几百个cycle很正常。对于10kHz的电流环两份三角函数调用大约吃掉1us。替代方案是查表线性插值或者用CMSIS-DSP里的arm_sin_f32/arm_cos_f32这些函数内部用CORDIC或者查表速度比标准库快很多。不过最稳的还是直接做一张256或512点的Q15表定点查表最差也能控制在几个cycle。滤波部分更值得琢磨。8点平均看起来无害但每次中断都要重新累加8个样本还有一次浮点除法。更关键的是它还会在电流环反馈路径上引入滞后导致带宽上不去。真正适合FOC电流环的滤波应当是在保证采样点正确的前提下只保留一阶低通或者一个简单去毛刺逻辑而不是用大窗宽均值。3. 一步步把代码改快从硬件触发到定点化3.1 用PWM中心对齐的事件触发ADC不要软件等待第一步就是放弃在PWM中断里软件触发和等待ADC。我改成了让TIM1产生硬件事件直接触发两个ADC的注入组。具体做法是把TIM1配置成中心对齐模式启用TRGO2在STM32G4上TIM1有TRGO2输出将该事件连接到ADC1/ADC2的注入触发源。这样每当PWM计数器到达设定的采样点ADC自动开始转换转换完成后自动发出DMA请求完全不需要软件再插入while。关键配置思路大致如下具体位域以参考手册为准// TIM1中心对齐模式2重复计数为0溢出事件作为TRGO2 TIM1-CR1 | TIM_CR1_CMS_1; // 中心对齐模式2 TIM1-RCR 0; TIM1-CR2 | (0b0101 20); // MMS2选择更新事件具体编码看器件手册 // ADC1/ADC2的注入组触发源选择TIM1_TRGO2采样时间拉到最大避免源阻抗影响 ADC1-JSQR | (0b00100 16); // JEXTSEL选择定时器事件编码以手册为准 ADC2-JSQR | (0b00100 16);改完之后PWM更新中断里不再有任何等待代码。电流采样任务被硬件事件推着走CPU和ADC在时间上完全交错开。同样的20kHz周期中断里不再有1.6us的忙等。这一步是整个优化收益最大的部分后面所有微优化都是在这条正确的时间基线上做的。3.2 DMA搬运中断里只拿现成数据触发解决了但ADC转换结束后的数据搬运也要交给DMA。我将ADC1和ADC2的注入组数据通过DMA搬到一个两元素的结构体里按半字传输DMA循环模式。每个PWM周期DMA自动更新这两个值传输完成触发一次DMA中断FOC电流环直接在DMA中断里读取结构体并执行控制算法。注意这里说的“DMA中断”不再是原来的PWM更新中断两者完全可以独立。电流环的执行时机应当跟随ADC转换完成而不是PWM计数器的某个事件这样能进一步消除采样值和算法执行的时序误差。示例实现typedef struct { int16_t iu_raw; int16_t iv_raw; } CurrSample_t; __ALIGNED(4) CurrSample_t g_cur_sample; void DMA1_Channel1_IRQHandler(void) { if (DMA1-ISR DMA_ISR_TCIF1) { FOC_CurrentLoop((float)g_cur_sample.iu_raw, (float)g_cur_sample.iv_raw); DMA1-IFCR | DMA_ISR_TCIF1; } }这里还有一个容易被忽略的坑如果MCU带CacheDMA写入的内存区域可能会因为Cache一致性导致CPU读到旧数据。我的做法是把g_cur_sample放到非Cache的SRAM区域或者在DMA传输完成中断里加一遍__DMB()确保CPU看到的确实是最新的DMA结果。3.3 变换运算定点化与查表改造接下来处理Park变换中的sinf/cosf。我没有直接改成CMSIS-DSP而是用了最简单也最可控的查表方式。电角度g_theta原本是浮点弧度我改成用uint16_t表示角度0对应0°65535对应359.99°取高8位作为正弦表索引表长256就够用。如果要求更高精度可以取高9位加线性插值但256点表在实际FOC控制里已经能跑得很好。电流采样值我也没有继续用float而是用带缩放的定点数表示避免除法。假设电流传感器满量程对应±50AADC 12位那么每个LSB对应的电流分辨率大约24mA。用Q14格式保存电流反馈然后全部用整数运算完成Clarke和Park。// 角度以Q12格式表示0~4095对应0~2π查表表长256 #define TABLE_MASK 0xFF static const int32_t g_sin_table[256] { /* 预计算 sin(2π*i/256)*16384 */ }; // 查表得到cos和sin表内容可偏移半个索引来简化 static inline int32_t table_cos(uint16_t angle) { return g_sin_table[(angle 4 64) TABLE_MASK]; } static inline int32_t table_sin(uint16_t angle) { return g_sin_table[(angle 4) TABLE_MASK]; }Park变换就变成纯整数乘法和移位int32_t id (ialpha * cosv ibeta * sinv) 14; int32_t iq (-ialpha * sinv ibeta * cosv) 14;注意乘法可能超过32位所以中间结果要用int64_t或者控制好Q格式和输入范围确保ialpha、ibeta不超过±16384这样乘出来不超过±2^28加和也不溢出int32。实测这个改动让Park变换从大约0.9us降到0.2us以内。3.4 滤波器的减法从8点均值到一个IIR滤波部分我做了两个改动。首先是去掉8点滑动平均值换成带毛刺剔除的一阶IIR。电流环带宽设计在1~2kHz如果还用8点平均平均窗过长相位滞后太大。一阶IIR的截止频率取决于滤波系数我取了k 1/8也就是右移3位对应大约600Hz左右的截至频率对电流环的相位影响远小于原来的8点平均。#define FILT_SHIFT 3 int32_t iu_filt iu_filt ((iu_raw_center - iu_filt) FILT_SHIFT);如果偶尔还有开关噪声尖峰我会在做IIR之前加一个简单的去毛刺判断当新样本和当前滤波值偏差超过设定阈值例如额定电流的20%时直接忽略这一拍用上一拍滤波值继续。由于PWM开关频率20kHz电机相电流不可能在1拍内突跳20%所以阈值可以根据系统参数设置。这个判断只是两条比较指令成本可忽略。去掉了8点滑动平均后中断里少了一个8次循环也不再有浮点除法。更关键的是电流环反馈链路上的延迟明显减小动态响应变快了。4. 优化后我们到底获得了什么实测数据与波形对比4.1 执行时间与CPU占用所有优化做完我用同样的GPIO翻转法重新测量了整个电流采样变换滤波流程的执行时间。结果非常直接指标优化前优化后变化中断执行时间3.8 us1.2 us-68%ADC等待时间1.6 us0 usDMA后台搬运ClarkePark耗时1.3 us0.42 us查表定点化滤波耗时0.5 us0.15 usIIR毛刺剔除CPU占用20kHz7.6%2.4%-5.2个百分点中断函数本身现在短了很多主要工作变成从结构体里拿现成数据、算一遍整数Clarke/Park、再写反馈值清掉DMA标志剩余时间还能在主循环里做些其他事情。4.2 电流波形质量与THD优化不只是省了CPU采样点修正带来的波形改善比预想更明显。用电流探头测电机线电流并计算稳态THD优化前在低速带载工况下THD约8.2%而且波形上有周期性毛刺优化后下降到5.3%毛刺基本消失。这里贡献最大的其实是硬件触发ADC把采样点焊死在了零矢量区而不是滤波器的调整。零点漂移也从优化前的±0.15A压到了±0.04A以内主要是因为DMA搬运消除了软件读取时的时序抖动以及定点化后整个链路是确定性的整数运算不像浮点库函数每次可能有微妙的不一致。4.3 动态响应与稳定性验证优化效果不能只看静态波形我还做了动态阶跃测试。在额定转速下突加50%负载观察电流环Id/Iq的响应。优化前调节时间约1.2ms优化后约0.9ms原因是反馈路径上的滤波滞后明显减小。之后又在全速度范围内扫了一圈包括弱磁区没有出现电流振荡或过冲异常说明优化没有牺牲稳定性。5. 优化过程中的意外情况和解决思路5.1 采样毛刺尖峰触发点并非越准越好把采样触发改到零矢量之后我一开始用PWM计数器到0作为触发点结果在低速轻载时电流波形里出现了一串很窄的高频尖峰。排查后发现计数器归零的那一瞬间正好是三相下桥从部分导通切换到全导通的死区附近采样电阻上的电压含有死区振铃。解决办法不是放弃硬件触发而是把触发点从计数到0的时刻往后挪几十纳秒让采样避开MOS管开关振铃。在G4上可以通过配置比较通道在计数器到0后延时触发ADC或者把ADC采样时间从最短档加长让采样窗稳定落在零矢量中期。最终我选了比较匹配触发再配合ADC的采样时间12.5个ADC时钟干净很多。5.2 DMA缓冲切换的数据错位另一个坑在DMA循环模式上。最初我用了双缓冲环形模式由两个DMA通道分别搬运U相和V相数据结果偶发出现某一次采样里Iu是新的、Iv是上一拍的或者反过来。原因是两个ADC注入组的完成时间并不完全一致DMA的两个通道各自独立可能在不同时刻更新缓冲。解决方法是把两个ADC配置成同步注入模式让它们在同一个触发源驱动下同时转换并且只用同一个DMA通道按顺序搬运两个结果到同一个结构体里。这样要么两个值都更新要么都不更新不会出现半新半旧的数据。还要在传输完成中断里加一个__DMB()防止编译器和CPU对DMA写入的乱序访问。5.3 定点化累计误差与零点校准定点化除了算得快也带来一个新问题整数除法中的截断误差经过多次迭代后会形成几十mA的固定偏置。一开始我怀疑是硬件运放的零点漂移但重新标定后还是有。后来在Clarke变换里发现1/sqrt(3)我用16384/28377近似误差大约0.001%每次变换几乎感知不到但Park变换里查表角度只取8位角度阶梯误差最大有1.4°反映到Id/Iq上就是个周期性扰动。我把表长从256提到512并做了线性插值角度误差降到0.4°以内再把零点校准逻辑放在电机静止时自动执行Id/Iq的固定偏置从几十mA降到2mA以内完全满足控制需求。如果让我再重新做一遍我会把这次优化的顺序反过来先把采样时机和DMA路径搞定再考虑定点化。因为很多时候一个正确的采样点比一堆复杂滤波算法更值钱。这次优化真正省下来的CPU周期最终还是被我用到了更复杂的位置观测器上这才是性能优化该有的循环。