
上个季度帮朋友调一台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个周期左右白白烧掉。第二标准数学库的sinf和cosf虽然是M4F的FPU加速版本但库函数实现里仍然有大量的精度处理和分支判断实测一次sinf或cosf调用大约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.4usADC数据读取与零漂、量纲换算约50寄存器访问浮点乘加Clarke/Park/反Park变换约180包含sinf/cosf调用sinf/cosf标准库调用约400两次数值库开销两个电流PI约150每拍两次PI运算SVPWM计算约120扇区判断矢量作用时间中断进出与杂项约120压栈、清标志等合计约1300约7.7us7.8us是我实测到的和这个分项估算是吻合的。20kHz周期是50us一个电流环吃掉15.6%如果能压到1usCPU占用率只有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; // MMS010选择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); // JSQ1IN1, JSQ2IN4 ADC1-JSQR | (2UL 20); // JL2两个注入通道这样配置之后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-M4FDMA搬运完成之后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对应00xFFFF对应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都没有的低成本方案。如果你手里就是M0Q格式是躲不掉的需要把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_u和k_scale_u在校准时算好运行时只做一次浮点乘加。除法改成乘法/4096变成* (1.0f/4096.0f)能省几条指令。第三内存对齐。电流环里用到的中间变量、查表数组、ADC缓冲区全部按4字节或8字节对齐。Cortex-M4F的LDRD/STRD指令在数据对齐时效率更高不对齐会导致总线访问拆分。第四编译器选项。GCC开-O2或-O3Keil里选优化时间和内联。注意开高优化后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%软件启动ADCwhile等待标准库三角函数第一轮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死等”。