FOC电流环优化:从7.8us到0.9us的实战拆解
2026/9/7 2:46:53 网站建设 项目流程

上个季度帮朋友调一台PMSM伺服驱动,PWM频率定在20kHz,主控是Cortex-M4F,速度环和位置环还没全部打开,CPU就已经被通信、日志和状态机吃掉不少余量。等把FOC电流环一跑起来,用调试器抓中断执行时间,一次电流环ISR居然跑了7.8us,占整个50us载波周期的15.6%。这个数字在单独看电流环时似乎还能忍,但后面再想塞进去无感观测器、速度环、位置环和上位机交互,就完全没有空间了。

当时对着代码看了一下午,问题其实很明确:这段FOC电流采集代码里,有大量“功能正确但代价离谱”的写法——软件启动ADC之后死等转换完成、每拍都调标准数学库算sin/cos、链路里还存在没必要的浮点运算和函数调用开销。这篇文章把当时实际的优化过程完整拆开,从7.8us一路压到0.9us左右,思路不仅适用于FOC电流环,只要是周期性采样+控制计算的场景,都可以参考。

1. 从一段“能跑”的电流采集代码说起

1.1 原始代码长什么样,问题藏在哪

当时拿到的代码是很典型的教材风格FOC中断处理函数,伪代码大概是这个样子:

void PWM_Period_IRQHandler(void) { // 软件启动 ADC,转换 U 相、V 相电流 ADC1->CR2 |= ADC_CR2_SWSTART; while (!(ADC1->SR & ADC_SR_EOC)); uint16_t adc_u = ADC1->DR; ADC1->CR2 |= ADC_CR2_SWSTART; while (!(ADC1->SR & ADC_SR_EOC)); uint16_t adc_v = ADC1->DR; float i_u = (adc_u - I_U_OFFSET) * I_U_SCALE; float i_v = (adc_v - I_V_OFFSET) * I_V_SCALE; float i_w = -(i_u + i_v); clarke_transform(i_u, i_v, &ialpha, &ibeta); park_transform(ialpha, ibeta, sin_theta, cos_theta, &id, &iq); pi_run(&pid_d, id_ref, id); pi_run(&pid_q, iq_ref, iq); inv_park_transform(vd, vq, sin_theta, cos_theta, &valpha, &vbeta); svpwm_update(valpha, vbeta); }

这段代码单独看每一步都没错,但凑到一起就非常低效:

第一,ADC转换是软件启动+轮询EOC标志。CPU进入中断后,先启动一次ADC转换,然后空转等待,转换完成读回数据,再启动第二次,再空转等待。这期间CPU什么正事都没干,纯粹在等外设。STM32F4/G4系列12位ADC的最快转换时间大约0.4~0.5us,两次等待加起来就是约1us,主频168MHz下相当于170个周期左右白白烧掉。

第二,标准数学库的sinfcosf虽然是M4F的FPU加速版本,但库函数实现里仍然有大量的精度处理和分支判断,实测一次sinfcosf调用大约200到400个周期。电流环里每拍都要做Park变换和反Park变换,两次调用就是几百周期,这在高频中断里是不可忽视的。

第三,中断里还存在函数调用开销。Clarke变换、Park变换、PI、SVPWM全是独立函数调用,每次调用都有压栈、跳转、出栈,编译器如果没做内联,这些开销会在20kHz下被放大。

1.2 采样时刻为什么要“锚定”在下桥导通中点

很多人调FOC电流环时,波形出毛刺,第一反应是换运放、加滤波电容,却忽略了采样时刻本身就是最大噪声源。电机驱动桥臂是上下桥交替导通的,低压FOC方案里电流采样电阻通常串在下桥MOS的源极和地之间。

下桥导通时,相电流流过采样电阻,电阻上电压正比于当前相电流;上桥导通时,相电流主要流过上桥MOS管,采样电阻上要么没有电流,要么只流过续流二极管的电流,完全不能反映真实相电流。所以FOC电流采集必须在下桥导通期间完成。

这里有个容易踩的细节:不能在下桥刚导通或即将关断时采样。MOS管开关瞬间存在死区和振铃,死区期间上下桥都不导通,电流走续流二极管,采样电压不可控;开关瞬间还有高dv/dt通过寄生电容耦合进采样回路,产生尖峰干扰。所以采样点必须放在下桥导通区间的中间位置附近,离两个开关沿都足够远。

这也是为什么论坛里常有人问“FOC电流采集为什么要设置在下桥”——答案不是某个芯片的偏好,而是由采样电阻物理位置和桥臂工作状态决定的。

1.3 一段代码吃掉了多少CPU周期

我当时用DWT计数器抓了中断内的周期数,按168MHz主频折算,各部分大概是这样:

项目周期数说明
两次ADC软件启动+等待约280硬等约1.4us
ADC数据读取与零漂、量纲换算约50寄存器访问+浮点乘加
Clarke/Park/反Park变换约180包含sinf/cosf调用
sinf/cosf标准库调用约400两次数值库开销
两个电流PI约150每拍两次PI运算
SVPWM计算约120扇区判断+矢量作用时间
中断进出与杂项约120压栈、清标志等
合计约1300约7.7us

7.8us是我实测到的,和这个分项估算是吻合的。20kHz周期是50us,一个电流环吃掉15.6%,如果能压到1us,CPU占用率只有2%,腾出来的资源足够跑速度环、观测器和更复杂的保护逻辑。下面按当时的优化顺序,把每一刀的思路和效果写清楚。

2. 第一刀:把死等的ADC转换变成硬件自动触发

2.1 定时器TRGO触发ADC注入组,不再软件启动

最先下手的就是那段“软件启动+while等待”。思路很简单:ADC转换完全可以在后台进行,CPU不需要一直死等。要让ADC自动跑起来,可以用PWM定时器的TRGO事件去触发ADC的注入组转换。

STM32的ADC有规则组和注入组,规则组适合周期性扫描,但容易被软件触发和中断打断;注入组适合高优先级的多通道同步采样,外部触发灵活,转换结果可以直接进DMA。电流采样用注入组就是为这个场景设计的。

我当时用的是TIM1作为PWM主定时器,配置思路如下:

// TIM1更新事件作为TRGO输出 TIM1->CR2 |= TIM_CR2_MMS_1; // MMS=010,选择Update事件作为TRGO // ADC1配置为外部触发注入组,触发源选TIM1_TRGO ADC1->CR2 &= ~ADC_CR2_EXTEN_Msk; ADC1->CR2 |= ADC_CR2_EXTEN_0; // 上升沿触发 ADC1->CR2 &= ~ADC_CR2_EXTSEL_Msk; ADC1->CR2 |= ADC_CR2_EXTSEL_0 | ADC_CR2_EXTSEL_1; // 对应TIM1_TRGO // 注入组序列:两个通道,IN1和IN4 ADC1->JSQR |= (1UL << 0) | (4UL << 5); // JSQ1=IN1, JSQ2=IN4 ADC1->JSQR |= (2UL << 20); // JL=2,两个注入通道

这样配置之后,PWM载波每个周期到了指定相位,ADC1自动开始转换U相和V相电流,转换完成硬件置JEOC标志,CPU完全不需要干预。中断里要做的只是读取结果并算FOC。

为什么不用规则组而是注入组?因为规则组转换序列在转换过程中可以被软件启动的转换打断,电流采样这种高实时性任务一旦被打断,采样窗口就错位了。注入组优先级更高,转换过程不会被规则组打断,适合电流环。

2.2 为什么“启动后立刻等待”是最浪费的做法

从表面看,软件启动ADC后等待转换完成似乎没多大问题,毕竟一次转换才0.4~0.5us。但在实时控制里,这种等待有两个隐性问题。

第一,CPU在等待期间是完全空闲的。虽然单次等待时间短,但FOC电流环频率通常是10~20kHz,一年下来就是几千万个空转周期,这些周期如果都解放出来,可以做大量其他事情。

第二,更关键的是采样时刻的控制。原来在PWM上溢中断里软件启动ADC,意味着ADC的转换开始时刻受中断响应延迟影响——中断里有压栈、寄存器保护,这些指令的执行时间会抖动,导致每次采样的相位点不完全一致。采样相位抖动会让电流环反馈信号里混入额外的噪声。而用定时器TRGO硬件触发ADC,转换开始时刻由硬件决定,和载波严格同步,采样点相当稳定。

实际优化时还有一层好处:硬件触发之后,触发时刻可以精确放在PWM周期的中点,也就是下桥导通区间的中心,而不是像以前那样在载波顶点附近启动。载波顶点正是桥臂切换后的噪声集中区,硬件触发天然把采样点挪到了更安全的位置。

2.3 坑:触发源配置错了,采到的全是上一周期的旧值

硬件触发配置有一个非常隐蔽的坑。如果你配置的触发边沿是双沿,或者触发源选错了,ADC会在一个PWM周期内被触发两次,第二次恰好落在上桥导通阶段,采回来的数值完全不对,但电机还是能转,只是电流波形会在固定位置出现规律性跳动。

我当时第一次配置后,用调试器看ADC注入组的转换结果,U相电流波形大体是正弦,但每个周期都有两处明显毛刺。一开始以为是运放问题,换了运放、加了RC滤波都没解决。后来用示波器同时测PWM输出和ADC注入组转换开始标志,才确认是触发源配成了Update事件和比较匹配事件双触发。

排查这类问题的正确做法,是先确认触发源和触发边沿,再用示波器看转换开始信号与PWM的相位关系,而不是直接怀疑硬件电路。采样相位对了,电流波形问题往往自己就消失了。

3. 第二刀:DMA接管搬运,CPU只算不算等

3.1 DMA双缓冲与循环模式的选择

去掉while等待之后,中断里仍然要在JEOC标志置位后去读ADC_DR寄存器。这一步看着不起眼,实际每次外设寄存器读取都可能插入总线等待周期,而且读取操作还要放在FOC计算的必经路径上。

更彻底的做法是让DMA把ADC注入组的转换结果自动搬运到内存数组。配置也不复杂:

// 注意4字节对齐,加volatile防止编译器优化 __attribute__((aligned(4))) volatile uint16_t adc_result[2]; // ADC1注入组DMA请求使能 ADC1->CR2 |= ADC_CR2_DMA; // DMA2_Stream4对应ADC1,循环模式,内存地址递增,外设地址固定 DMA2_Stream4->CR = DMA_SCR_DIR_0 // 存储器->外设方向?不,这里用外设到内存 | DMA_SCR_CIRC // 循环模式 | DMA_SCR_MINC // 内存递增 | DMA_SCR_PSIZE_0 // 外设16位 | DMA_SCR_MSIZE_0; // 内存16位 DMA2_Stream4->PAR = (uint32_t)&ADC1->JDR1; DMA2_Stream4->M0AR = (uint32_t)adc_result; DMA2_Stream4->NDTR = 2; DMA2_Stream4->CR |= DMA_SCR_EN;

这里有个容易被忽略的点:注入组的DMA请求使用的是JDRx寄存器,不是规则组的DR寄存器。如果配成了规则组DMA地址,即使ADC转换正常,DMA也搬不到正确数据。

说到双缓冲,不少朋友第一反应是配成乒乓buffer,一个buffer让DMA写,另一个buffer让CPU读。但FOC电流环里我建议保持单缓冲循环模式。因为电流环要求的是“这一拍的转换结果给这一拍用”,双缓冲天然引入一拍延迟,对电流环的动态响应没有帮助。乒乓缓冲更适合音频采集、连续波形记录这类CPU处理速度跟不上DMA写入速度的场景。

3.2 缓存一致性不是所有MCU都要处理,但别忽视

如果是STM32F4/G4这类没有D-Cache的Cortex-M4F,DMA搬运完成之后,CPU通过volatile访问内存数组,读到的是最新数据,不需要额外处理缓存一致性。很多人在F4上写习惯了,换到H7这类带D-Cache的内核后踩了大坑:DMA已经把数据写进内存了,但CPU读到的还是Cache里的旧值,电流波形完全不对。

解决方式有两种:要么把DMA缓冲区放到非缓存内存区(比如STM32H7的DTCM RAM或者MPU配置成Non-cacheable的区域),要么在每次读取前执行一次Cache无效化:

SCB_InvalidateDCache_by_Addr((uint32_t *)adc_result, sizeof(adc_result));

我的建议是,如果你准备在新项目里用Cortex-M7/M55系列,一开始就把电流采样缓冲区放到不被Cache缓存的区域,而不是每次读取都刷Cache。刷Cache操作本身也有几十个周期,在FOC这种高频中断里并不便宜。

3.3 实测:从等待到零等待,中断里省出多少

DMA方案配好之后,ADC转换完成到数据到达内存的全过程,CPU零参与。我把中断里的逻辑精简成:

void PWM_Period_IRQHandler(void) { uint16_t adc_u = adc_result[0]; uint16_t adc_v = adc_result[1]; float i_u = (adc_u - I_U_OFFSET) * I_U_SCALE; float i_v = (adc_v - I_V_OFFSET) * I_V_SCALE; float i_w = -(i_u + i_v); // FOC计算 // ... }

这一轮改造前后,单次ISR执行时间从7.8us降到了5.1us左右,省掉的2.7us基本就是ADC等待、读取和部分函数跳转开销。这时候剩下的主要时间其实都在数学运算上,下一刀指向的就是FOC算法本身。

4. 第三刀:采样点精调,电流波形毛刺从哪来

4.1 开关噪声窗口:为什么不能在下桥刚开或刚关时采样

DMA和硬件触发解决的是“采样效率”问题,但“采样准不准”是另一回事。电动机会转,并不代表电流采样精度够。我见过不少项目,FOC能跑,但示波器看电流波形全是毛刺,问题就出在采样点落在开关噪声窗口里。

MOS管在开关瞬间,栅极驱动会有死区,死区期间上下桥都不导通,相电流靠体二极管续流,采样电阻上电压不等于真实相电流。死区结束后,开关沿附近还有振铃,这个振铃通过寄生电容耦合进采样电阻回路,ADC采到的电压叠了一层高频干扰。

所以电流采样必须避开两个窗口:

  • 下桥刚导通的初始阶段——死区刚结束,开关振铃未衰减
  • 下桥即将关断的阶段——关断过程同样有振铃

安全的采样窗口是下桥导通区间中间那段,理想点就是导通区间的时间中点。

4.2 用死区时间反推安全采样窗口

实际项目里怎么选采样点?以20kHz PWM、死区1us、占空比50%为例算一下:

  • PWM周期:50us
  • 下桥导通时间:50% × 50us = 25us
  • 安全采样窗口:下桥导通后2us到下桥关断前2us,大概有21us宽
  • 采样点放在下桥导通中点附近,约12.5us处

这个窗口对12位ADC的1us转换时间来说非常充裕。但有个场景需要特别警惕:占空比很小时,下桥导通时间可能只有2~3us,安全窗口只剩1us左右。这时候ADC采样时间、转换时间、两次注入转换必须卡得很紧,稍不注意就采到开关沿上。

在深度弱磁区,PWM占空比会持续逼近极限,下桥导通窗口被压缩得厉害。这种工况下三电阻采样的窗口裕量最小,代码里必须根据实时占空比动态估算采样窗口是否够用,必要时切换采样策略,比如改用单电阻重构。

4.3 连续多拍采样+求平均值,和单点采样差在哪

很多人为了抗噪,会尝试在一个PWM周期内触发多次ADC转换,取平均值作为反馈电流。这个思路本身不坏,但有个物理层面的坑:电机电流不是恒定值,PWM开关过程本身就产生锯齿状纹波,多次采样取平均值相当于对时间做平滑滤波,会引入相位延迟。

在低转速场景,电流变化慢,几微秒的延迟无所谓,平均滤波还能顺便滤掉纹波,效果不错。但高转速下,电流矢量的角度变化很快,相位延迟意味着反馈电流落后于实际电流,电流环的相位裕度会下降,严重时直接导致电流环振荡。

我的做法是:主电流环反馈通道坚决不用多次平均,最多在ADC硬件层面适当加大采样保持时间,比如从0.5us加到1.1us,让采样电容充分充电。真要滤波,放在速度环输出侧或者专门做观测器校准,而不是放在电流反馈主线路上。

5. 第四刀:把FOC环路本身也加速

5.1 Clarke变换只需要两相电流的原因,以及相位顺序约束

这是FOC原理里老生常谈但又极其关键的问题。三相永磁同步电机的定子绕组是星型接法,没有引出中线,所以三相电流满足:

i_a + i_b + i_c = 0

也就是说,知道任意两相电流,第三相直接用负数相加就得到了。这就是为什么FOC电流采样只需要两路ADC通道,而不是三路。

因此:

i_w = -(i_u + i_v)

但“知道两相”有个前提:你采的必须是互不相同的两相,不能重复采同一相,也不能把同一相电流分两路接进ADC当两相用。相序搞反了,Clarke变换得到的合成矢量方向就反了,电流环会往错误的方向施加电压,表现出来就是电机发烫、转矩异常。

Clarke变换公式用U/V两相写作:

ialpha = i_u ibeta = (i_u + 2 * i_v) / sqrt(3)

如果ADC采的是U/W两相,公式里的相位顺序也要对应调整。这块出错不是代码逻辑问题,而是采样通道和实际绕组的对应关系弄错,排查起来非常费劲。建议在硬件设计阶段就把两个采样通道对应的相序固定下来,并在代码注释里写清楚。

5.2 三角函数查表法与定点Q格式改造

优化完ADC链路后,中断里占比最大的就是三角函数。我当时用DWT统计,标准库sinf/cosf联合调用占了将近400个周期。

M4F有FPU,但FPU只负责执行单精度浮点加减乘除,sin/cos这种超越函数仍然靠库程序实现。所以性能优化第一招就是丢掉标准库,用查表加线性插值。

表长4096点覆盖0到2π,查表加插值误差可以控制在0.001rad以内,对电流环来说完全足够。代码大约长这样:

#define SIN_TABLE_SIZE 4096 static const float sin_table[SIN_TABLE_SIZE]; static inline void sin_cos_q16(uint16_t theta, float *sin_val, float *cos_val) { // theta是Q16格式,0x0000对应0,0xFFFF对应2π uint32_t idx = ((uint32_t)theta * SIN_TABLE_SIZE) >> 16; float frac = ((uint32_t)theta * SIN_TABLE_SIZE) & 0xFFFF; float s0 = sin_table[idx]; float s1 = sin_table[(idx + 1) & (SIN_TABLE_SIZE - 1)]; *sin_val = s0 + (s1 - s0) * (frac / 65536.0f); *cos_val = sin_table[(idx + (SIN_TABLE_SIZE / 4)) & (SIN_TABLE_SIZE - 1)]; }

查表法的收益是立竿见影的:原来的400周期降到约80周期左右。如果你的主控是STM32G431这类内置CORDIC外设的型号,硬件算sin/cos更快,代码里直接用CORDIC就行。

有些朋友会继续做定点化,把整条FOC链路从float改成Q格式。我个人建议先评估MCU是否带FPU:带FPU的Cortex-M4F跑浮点FOC本身已经很快,定点化收益有限;真正需要定点化的是Cortex-M0这类连FPU都没有的低成本方案。如果你手里就是M0,Q格式是躲不掉的,需要把ADC结果当Q12、电流当Q15、角度当Q16,还要小心中间乘加的溢出。这个展开又是一大篇,这里不细说。

5.3 从1.8us到0.9us:编译器选项、内存对齐与内联函数

到这一步,ISR执行时间已经降到了1.8us左右。最后的0.9us是从编译器、存储访问和代码组织细节里抠出来的。

第一,内联关键函数。Clarke、Park、反Park、SVPWM这些函数全部加上__attribute__((always_inline)),避免每拍都压栈跳转。尤其SVPWM里的扇区判断和矢量时间计算,函数调用开销占比不小。

第二,合并常量和缩放系数。ADC原始码到电流值,原本是减零漂、乘比例两步,可以预先算成几个合并系数:

float i_u = (adc_u - offset_u) * k_scale_u;

其中offset_uk_scale_u在校准时算好,运行时只做一次浮点乘加。除法改成乘法,/4096变成* (1.0f/4096.0f),能省几条指令。

第三,内存对齐。电流环里用到的中间变量、查表数组、ADC缓冲区,全部按4字节或8字节对齐。Cortex-M4F的LDRD/STRD指令在数据对齐时效率更高,不对齐会导致总线访问拆分。

第四,编译器选项。GCC开-O2-O3,Keil里选优化时间和内联。注意开高优化后,volatile关键字不能丢,否则编译器可能把ADC缓冲区读取当成循环不变量优化掉,导致电流值不再更新。

优化后的中断代码主体就是一组紧凑的浮点流水线操作:

void PWM_Period_IRQHandler(void) { float i_u = (adc_result[0] - offset_u) * k_scale_u; float i_v = (adc_result[1] - offset_v) * k_scale_v; clarke_park(i_u, i_v, sin_val, cos_val, &id, &iq); pi_run(&pid_d, id_ref, id); pi_run(&pid_q, iq_ref, iq); inv_park_svpwm(vd, vq, sin_val, cos_val); }

实测上最终单次ISR执行时间稳定在0.9us左右,核心计算大约0.6us,其余0.3us是中断进出、ADC结果读取和标志处理。20kHz下CPU占用率只有约1.8%。

6. 优化全景回顾与踩坑清单

6.1 各轮优化效果对比

阶段单次ISR执行时间CPU占用率@20kHz主要手段
优化前7.8us15.6%软件启动ADC+while等待,标准库三角函数
第一轮5.1us10.2%定时器TRGO硬件触发ADC注入组
第二轮3.0us6.0%DMA搬运ADC数据,去掉寄存器读取等待
第三轮1.8us3.6%采样点精调,查表替代sinf/cosf
最终0.9us1.8%内联+预计算+编译器优化+内存对齐

不同主频、不同编译器下数据会有差异,但整体趋势是一致的。关键在于把“等待外设”和“重复计算”这两类无意义开销逐步剥离。

6.2 几个容易让项目“越优化越差”的陷阱

实测中踩过的坑,列出来给各位避一避:

  • 编译器优化把ADC缓冲读取优化没了:缓冲区声明里漏了volatile,开-O2后电流反馈变成固定值,电机直接失控。加volatile之后问题消失。
  • ADC采样时间调太短:为了压缩采样窗口,把采样保持时间从1.1us调到0.3us,结果采样电容没充满,电流反馈出现非线性失真,波形看着没毛刺但转矩脉动变大。ADC采样时间不是越短越好。
  • DMA缓冲区不对齐:数组没加aligned(4),DMA访问拆成两次总线操作,带宽和效率都受影响。小问题但白丢几个周期。
  • 多次平均滤波引发振荡:之前提到过,加了三拍平均之后电流环在高速段啸叫,去掉之后恢复正常。平均滤波不是不能用,要分清场合。
  • 中断优先级设置不当:ADC注入组完成中断优先级低于某个阻塞型外设中断,导致电流环偶尔被推迟几十微秒,转速波形出现周期性抖动。FOC电流环中断应该给到最高抢占优先级。

6.3 我在实际项目中最后留了哪些余量

优化完成后,电流环占用大约1.8%的CPU资源,后面又把速度环放在2kHz、位置环放在1kHz,加上无感观测器和CAN通信,整机CPU占用大概在40%左右,还有充足余量做故障诊断和日志记录。

以我个人经验,FOC代码的性能优化不是要把每个周期都榨干,而是要知道瓶颈在哪,给系统留出足够的设计余量。比如这次优化如果只做到第二轮,3us的ISR其实也能用,但后续加需求时会很被动;一次性把电流环压到亚微秒级,后面所有扩展都轻松很多。

如果你手头也有类似的FOC电流采样代码,建议先用DWT或者调试器把ISR里各部分的开销量化出来,再决定先优化哪块。大部分项目的优化顺序应该是:先解决等待,再解决重复计算,最后才是各种技巧性的压榨。不要一上来就定点化或者手写汇编,收益最大的一刀往往是最简单的“别让CPU死等”。

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

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

立即咨询