FOC电流采集优化实战:采样触发、DMA与计算压榨

发布时间:2026/9/8 3:42:44
FOC电流采集优化实战:采样触发、DMA与计算压榨 做电机控制这些年我一直觉得 FOC 是最能体现嵌入式功力的方向之一。算法本身不算难难的是把算法塞进一个资源有限的 MCU 里还能保证实时性、稳定性和精度。我之前优化过一段 FOC 电流采集代码整个过程挺有代表性的。今天聊的就是这件事从一段“能跑”但“跑不好”的电流采集代码入手逐步把采样时机、数据通路、数学运算和编译器配置都过一遍看看到底哪些地方在白白浪费 CPU又是怎么一步步把它压榨干净的。1. 先看原始实现问题出在哪1.1 原始代码到底长什么样当时项目用的是一颗 STM32G431三相 PMSM 电机三电阻采样方案电流环周期 10kHz。硬件上 ADC1 和 ADC2 分别采 U、V 两相电流W 相电流靠基尔霍夫定律算出来。看起来配置没毛病但实际跑起来电机噪音大、扭矩波动明显CPU 负载也偏高。那段代码是最典型的“HAL 库入门写法”void TIM1_UP_IRQHandler(void) { HAL_TIM_IRQHandler(htim1); if (htim1.Instance-SR TIM_SR_UIF) { htim1.Instance-SR ~TIM_SR_UIF; FOC_Current_Sample(); } } void FOC_Current_Sample(void) { // 手动触发 ADC1等待转换完成 HAL_ADC_Start(hadc1); HAL_ADC_PollForConversion(hadc1, 100); uint32_t rawA HAL_ADC_GetValue(hadc1); // 手动触发 ADC2再等一次 HAL_ADC_Start(hadc2); HAL_ADC_PollForConversion(hadc2, 100); uint32_t rawB HAL_ADC_GetValue(hadc2); // 零漂校准和缩放 float ia (rawA - offsetA) * scaleA; float ib (rawB - offsetB) * scaleB; // Clarke 变换 float iAlpha ia; float iBeta (ia 2.0f * ib) * 0.57735026919f; // 1/sqrt(3) // Park 变换 float cosT cosf(theta); float sinT sinf(theta); float id iAlpha * cosT iBeta * sinT; float iq -iAlpha * sinT iBeta * cosT; // 电流环 PI 调节 PI_Update(piD, idRef, id); PI_Update(piQ, iqRef, iq); // 更新 PWM 占空比 TIM1-CCR1 (uint32_t)vdOut; TIM1-CCR2 (uint32_t)vqOut; // ... }说实话这段代码给 FOC 初学者做原理演示挺合适逻辑清晰、分步明确。但在 10kHz 中断里这么写就是一场灾难。1.2 性能瓶颈到底卡在哪我用逻辑分析仪抓了一下中断执行时间再逐个做了性能剖析最后定位到四个核心问题第一CPU 被白白浪费在等待上。HAL_ADC_PollForConversion是忙等ADC 转换一次大约需要 1us 到 2us这段期间 CPU 什么都干不了。在 10kHz 中断里每一轮循环都有两次忙等累积起来相当可观。第二采样点的时间抖动严重。HAL_ADC_Start触发 ADC 后什么时候真正开始采样取决于 ADC 的启动时序再加上中断响应的不确定性导致电流采样点落在 PWM 周期内不断漂移。FOC 对采样时刻极其敏感时间抖动直接表现为电流噪声和谐波电机声音难听就是从这里来的。第三浮点运算不经济。cosf、sinf这类库函数在 Cortex-M4F 上看起来有 FPU 硬加持但实际上标准数学函数内部包含大量的精度处理和边界判断开销远高于理论值。在一个 168MHz 的 MCU 上调用一次可能就是 1-2us 没了。第四中断里做了太多非必须的事情。状态标志处理、HAL 封装函数调用、逐次判断转换完成…这些都是可以在设计阶段绕开的工作。搞清楚这几个点之后优化的思路就很清晰了先把采样时机交给硬件再把数据搬运交给 DMA最后把数学计算精简到不能再精简。2. 采样时刻与硬件触发先把时机抠对2.1 为什么要设置在下桥导通时先说背景知识。三电阻 FOC 电流重构的原理是在逆变器下桥臂 MOSFET 导通时通过三个低边采样电阻上的压降还原出三相电流。这个方案成本低、应用广但对采样时机有硬性要求。在中心对齐 PWM 模式下PWM 三角载波向上计数再向下计数当某相上桥臂关断、下桥臂导通时该相电流会从下桥臂 MOSFET 流过采样电阻此时才能拿到真实的相电流。上桥臂导通期间电流根本不走低边电阻采样得到的完全是无效数据。所以“FOC 电流采集为什么要设置在下桥”这个问题的答案本质上是三电阻拓扑的物理约束决定的。更关键的是三相 PWM 同时处于下桥导通的时间窗口非常窄。在中心对齐模式里这个窗口在三角载波的波峰附近也就是 PWM 周期的中点。理想情况下三相下桥都导通、采样窗口最宽。但如果占空比偏离 50% 太多窗口时间会迅速缩短甚至不够 ADC 完成一次转换。实际项目里同步整流 FOC 电调通常会结合上下桥臂导通状态动态选择采样点但这属于进阶玩法。我这次优化的主采样点选择仍然是基于“下桥充分导通时间段”也就是 PWM 周期中点附近。2.2 ADC 触发设计把采样点锁死在硬件上既然采样点需要精确锁定在 PWM 周期的某个位置那手动触发 ADC 的做法就必须废弃。正确的做法是用定时器的事件触发 ADC让硬件自己决定什么时候开始采样。我用的方案是 TIM1 的通道 4 比较事件来生成触发信号。TIM1 本身已经在产生三相互补 PWM通道 4 不参与电机输出只作为时间基准。当计数器值到达通道 4 的比较值时会生成 OC4REF 事件这个事件经过 TRGO 输出给 ADC 作为外部触发源。这样一来只要 CCR4 里配置好比较值每个 PWM 周期都会在完全相同的位置触发 ADC采样点的时间抖动就从软件不确定变成了硬件级精确。关键配置大致如下// TIM1 主输出配置TRGO 由 OC4REF 驱动 htim1.MasterConfig.MasterOutputTrigger TIM_TRGO_OC4REF; htim1.MasterConfig.MasterSlaveMode TIM_MASTERSLAVEMODE_DISABLE; // ADC1 注入通道外部触发源选择 TIM1_TRGO hadc1.Init.ExternalTrigConv ADC_EXTERNALTRIGINJECCONV_T1_TRGO; hadc1.Init.ExternalTrigConvEdge ADC_EXTERNALTRIGINJECCONVEDGE_RISING;注意这里使用的是注入组而不是规则组。注入通道的优势在于可以打断规则组转换并立即执行转换结果独立存放响应速度更快。FOC 电流采样要求高优先级、短延迟用注入组是合适的。然后计算 CCR4 的触发位置。中心对齐 PWM 模式下计数器在 0 到 ARR 之间上下计数PWM 周期中点对应的计数值就是 ARR/2。因为我们希望在下桥导通最充分的三角波波峰处触发采样所以 CCR4 设置为 ARR/2或者根据死区时间和 ADC 建立时间略微偏移。实际调试时我用示波器观察 ADC 触发的硬件输出和 PWM 波形确认触发点始终固定在下桥导通窗口内。2.3 最小窗口与序列约束这里有个容易忽略的约束ADC 注入通道如果在转换过程中硬件触发又来了新的请求会对当前转换进行排队甚至可能丢掉采样机会。对于三电阻方案U、V 两相电流必须基本同时采样。理想做法是配置 ADC 多路同时触发或者使用采样保持电路。我这次硬件方案是 ADC1 和 ADC2 分别采 U、V 两相两个 ADC 都配置为相同的定时器触发源这样触发同时发生采样几乎同步。同时要注意下桥导通最小时间必须大于采样开关建立时间 ADC 采样时间 ADC 转换时间。这部分在我的调试记录里有几个具体坑后面在“问题排查”章节详细说。3. 数据通路与计算优化从 ADC 到电流环核心3.1 DMA 接力把 CPU 从等待中解放出来采样时机解决后下一个优化重点是数据通路。原来的代码用 HAL 轮询等待转换完成这在 10kHz 中断里代价太高。优化方向是让 ADC 转换完成后直接把结果搬到内存CPU 完全不需要参与等待。实现方式很简单ADC1 和 ADC2 的注入通道转换完成后都通过 DMA 把结果搬到预设缓冲区。static volatile uint32_t adcBuffer[2]; // [0]U相电流, [1]V相电流 // ADC1 注入通道 DMA 传输 HAL_ADC_Start_DMA(hadc1, (uint32_t *)adcBuffer[0], 1); // ADC2 注入通道 DMA 传输 HAL_ADC_Start_DMA(hadc2, (uint32_t *)adcBuffer[1], 1);这里有个细节注入通道用 DMA 搬运时传输次数要配置为 1因为每次 PWM 周期只需要一次采样。如果配置成循环模式DMA 会在每次传输完成后重新装载计数保证每个 PWM 周期都能搬一个数据。另一个细节是缓冲区对齐。DMA 对内存地址有对齐要求我建议用__ALIGN_BEGIN或者编译器属性把缓冲区放到 4 字节对齐的位置这样 DMA 时钟周期最少不会出现总线 stall。DMA 搬完数据后会触发 DMA 中断但这个中断并不需要做太多事。我实际的做法是在 DMA 中断里只置一个标志位然后到主循环或者下一个控制周期再读取adcBuffer中的最新值把数据处理和电流环计算放在中断外。3.2 ADC 偏移校准和缩放ADC 原始值到真实电流值的换算是 FOC 里最容易出差错的地方之一。零漂和缩放系数算不对后面所有变换全是错的。零漂的来源有两部分一是硬件上运算放大器的输入失调电压二是 ADC 本身的偏移误差。校准方式是上电后先让电机处于零电流状态也就是三相下桥全部导通、上桥全部关断此时采样得到的 ADC 值就是零电流对应的偏移量。void CalibrateOffset(void) { // 强制三相下桥导通 TIM1-CCR1 0; TIM1-CCR2 0; TIM1-CCR3 0; HAL_Delay(10); adcOffset[0] adcBuffer[0]; adcOffset[1] adcBuffer[1]; }采样多次取平均更好能压制随机噪声。我这里是采样 16 次取平均实测零漂重复性在 ±1 LSB 以内。缩放系数的计算则要看硬件链路。假设 ADC 是 12 位参考电压 3.3V采样电阻 10mΩ运放放大倍数 50 倍那么 1A 电流对应的电压是 10mΩ × 1A × 50 0.5V对应 ADC 值约为 0.5/3.3×4096 ≈ 620。反向算ADC 值每变化 1 对应电流变化约为 1/620 ≈ 1.61mA。实际代码里为了减少除法更推荐直接存放大后的乘法系数float ia (float)(adcBuffer[0] - adcOffset[0]) * currentScale;currentScale在初始化阶段就算好运行中不再重新计算。如果 MCU 性能比较弱还可以用定点 Q 格式。例如把电流值左移 12 位存成 Q12 格式用整数运算完成后续变换最后再转浮点给 PI 调节器。这个方法在 Cortex-M0 这类没有 FPU 的平台上特别管用。3.3 Clarke/Park 变换的数学压榨数学变换是 FOC 的灵魂也是优化空间最大的地方。先看 Clarke 变换。假设只测了 U、V 两相电流根据基尔霍夫电流定律三相电流之和为零所以 W 相电流等于负的 UV。Clarke 变换公式中sqrt(3)相关常量完全可以预先算好#define SQRT3 1.73205080757f #define SQRT3_INV 0.57735026919f void Clarke(float ia, float ib, float *iAlpha, float *iBeta) { *iAlpha ia; *iBeta (ia 2.0f * ib) * SQRT3_INV; }这里的优化就是把除法替换成乘法。编译器在高优化等级下可能自己也会做这个替换但显式写乘法更保险也更容易移植到不同编译器。接下来是 Park 变换。这里有两个优化方向第一避免调用cosf/sinf。在 STM32G431 上标准库的三角函数开销在 1-2us 级别对于 10kHz 电流环来说占的比例非常大。工程上常用查找表或者 CORDIC 算法。G431 自带硬件 CORDIC 外设可以硬件计算三角函数一次调用只要几个周期。不过用硬件外设有迁移性差的问题如果代码要跑在不同平台上我更倾向于用查找表。查找表精度要求不高时 256 个点的正弦表足够。每步电角度 0-360 度对应 256 个索引线性插值能把误差压到 0.1% 以内对电流环完全够用。static const float sinTable[256] { ... }; float fastSin(uint16_t index) { uint8_t i index 8; // 整数部分 uint8_t f index 0xFF; // 小数部分 float y0 sinTable[i]; float y1 sinTable[(i 1) 0xFF]; return y0 (y1 - y0) * (f / 256.0f); }第二减少计算中的临时变量。Park 变换本质上就是一次二维旋转写成矩阵float id iAlpha * cosT iBeta * sinT; float iq -iAlpha * sinT iBeta * cosT;这已经是展开后的最优形式了编译器的优化能力也可以信得过。真正要避免的是在循环里反复计算不变量比如把电角度转成索引这类操作应该放在调用前统一做好。整个电流环的性能预算是这样分摊的ADC 数据读取 偏移校准 缩放约 0.5usClarke 约 0.2usPark 查表约 0.3us两个 PI 约 0.4usPWM 更新约 0.2us。加起来约 1.6us在 10kHz 周期100us中只占 1.6%和优化前的 20% 相比是天壤之别。4. 代码层面编译器、内存与指令4.1 编译选项与常量限定硬件通路理顺之后最后一道菜是编译器。很多嵌入式工程师不重视编译选项导致同样的代码在不同优化等级下性能差异巨大。FOC 电流环这种实时性要求高的模块非常适合开-O2甚至-O3。-O2是保守但可靠的选择它不会为了速度牺牲代码体积和可调试性。-O3多了向量化和更深的内联对 ARM Cortex-M4F 也不见得有显著收益反而可能因为函数内联导致 cache 和指令预取失效。我实测下来G431 上-O2和-O3的性能差异微小稳定压倒一切所以我最后保留了-O2。还有一个选项值得单独讨论-ffast-math。它假设浮点运算中不出现 NaN/Inf不保证 IEEE 754 严格语义从而允许编译器把浮点运算重排和简化。对于 FOC 这种没有溢出风险的场景收益明显。但注意如果你代码里有除法开了-ffast-math后编译器可能直接改成乘以倒数这在数值稳定性要求极高的积分器里会引入微小误差。我的实践是只对电流采样变换模块开-ffast-mathPI 和积分部分保持标准语义。4.2 中断服务程序开销的极限压缩DMA 把采样数据搬走后中断服务程序里剩下的工作已经很少。但即使这么少也要注意一些 ARM Cortex-M 的特性。首先不要在中断里调用 HAL 库函数。HAL 函数内部有大量参数检查、错误处理这些在中断上下文完全没必要。直接操作寄存器是中断服务的最佳实践。其次利用尾链技术。Cortex-M 内核支持中断尾链当一个中断退出时如果又有其他中断等待处理器不会恢复上下文再重进而是直接执行下一个中断。这意味着高优先级中断之间的切换开销很小。可以利用这一点把电流环处理放在一个优先级稍低的中断中用标志位触发而不是在主中断里全部做完。我的最终中断结构是这样// ADC 注入通道转换完成中断极短只置标志位 void ADC1_2_IRQHandler(void) { if (ADC1-ISR ADC_ISR_JEOC) { adcReadyFlag 1u; } } // 主循环中消费数据 while (1) { if (adcReadyFlag) { adcReadyFlag 0u; FOC_Current_Sample(); } }4.3 内存访问、对齐与 volatile内存访问优化经常被忽略但对 DMA 和外设交互来说很关键。ADC 缓冲区用volatile修饰是必须的防止编译器优化掉读操作。但volatile也不能乱用它会让编译器每次读都从内存加载阻止寄存器缓存所以只应该用在真正会被硬件修改的共享变量上。缓冲区地址对齐方面DMA 传输时如果地址不对齐总线会做拆分操作增加额外时钟周期。我用的是 4 字节对齐在 G431 上足够__ALIGN_BEGIN static volatile uint32_t adcBuffer[2] __ALIGN_END;Cortex-M7 系列还要考虑 cache 一致性问题需要SCB_InvalidateDCache之类的操作但 G431 没有 cache这一步可以跳过。如果你用的是 M7 或者带 cache 的高端芯片千万记得 DMA 和 CPU 共享的数据要处理好 cache 一致性。5. 常见问题与排查技巧实录5.1 采样点抖动导致电流噪声大现象优化之前电机噪音大电流波形毛刺多扭矩不平稳。用示波器抓下桥 MOS 的 Vds 波形和电流波形可以看到采样点位置在每个 PWM 周期都不一样。排查思路首先确认触发源是否为定时器 TRGO。如果用了软件触发或者 ADC 内部定时器触发抖动不可能完全消除。其次检查 CCR4 比较值是否设置合理如果触发点刚好落在死区时间附近此时下桥还没完全导通采样到的数据是无效的。处理结果把触发点锁定在 PWM 周期中点同时把死区时间考虑进去我当时的死区时间是 500ns采样点往后偏移了大约 1us抖动从原来的 ±2us 降到 ±50ns 以内。5.2 零漂不准导致低速性能差现象电机转速降到几百转以下时q 轴电流出现明显波动甚至出现电流反向跳变。排查思路多半是零漂校准不充分。原来的校准只采了一次而 ADC 有随机噪声单次采样误差可能达到几毫伏换算成电流就是几百毫安偏差。对于低速小电流场景这个量级的错误足以让电流环产生畸变。处理结果零漂校准改为多次采样取平均。同时校准前确保电机确实处于零电流状态否则校准的是“带偏流的零”怎么调都调不回来。5.3 触发信号与 PWM 同步问题现象ADC 触发了但采到的电流值和实际波形对不上特别是在负载变化时偏差更大。排查思路检查 TIM1 的重复计数器设置。如果RCR重复计数器不为零定时器每 N 次溢出才产生一次更新事件但 TRGO 的产生和更新事件计数有关。如果配置不对可能导致触发信号每几个 PWM 周期才出现一次采样率远低于预期。处理结果确认RCR0保证每个 PWM 周期都能产生触发。另一个坑是 ADC 注入通道的触发电平边沿选择。有的单片机文档写的是上升沿触发但实际是电平触发需要实测确认。我当时在这上面浪费了大半天。5.4 采样时间过长导致最小导通时间不够现象大占空比或小占空比附近电流重构波形出现明显的毛刺或缺口。排查思路这是三电阻采样方案的经典约束。ADC 采样时间比如 1.5us加上转换时间比如 2.5us再加上信号建立时间总时间必须小于 PWM 周期内下桥导通的最小时间。当占空比接近 0% 或 100% 时窗口就会不足。处理结果两个方向解决。一是减小 ADC 采样时间把采样周期从 1.5us 降到 0.75us前提是信号源阻抗足够低、运放驱动能力够强。二是在占空比极端时切换到其他采样策略比如偏移中点采样甚至单电阻重构。对于多数应用前者就够了。5.5 电流环带宽不足但找不到原因现象电流环带宽只能调到 1kHz 左右再往上就震荡但理论上应该能到 2-3kHz。排查思路带宽受限往往不是 PI 参数的问题而是采样链路延迟太大。ADC 采样时间过长、DMA 传输不及时、中断响应晚、计算滞后任何一个环节都会在整个环路里引入延迟压缩可用带宽。处理结果逐个环节测量延迟。用 GPIO 翻转观测代码执行时间发现原来 DMA 中断处理里居然还有 HAL 库的扫描逻辑删掉之后延迟缩短近一半电流环带宽也顺利提到了 2kHz 以上。6. 优化效果复盘与个人体会6.1 量化对比前后性能曲线为了直观说明优化效果我整理了一张对比表指标优化前优化后改善幅度中断内 CPU 占用率约 22%约 2%提升约 10 倍采样点时间抖动±2us±50ns接近硬件极限电流环计算耗时约 22us约 1.6us提升约 13 倍电流噪声峰峰值约 300mA约 80mA降低约 70%电流环可用带宽约 1kHz约 2.5kHz提升约 2.5 倍数据是在同一台电机、同样的 PI 参数下测的避免了变量干扰。性能上来之后控制策略也获得了额外红利。因为 CPU 占用率大幅度下降我可以在同一个 MCU 上再跑速度环、位置环和上位机通信根本不需要换更高端的芯片。对成本敏感的产品项目来说这就是实打实的物料成本节省。6.2 最后的经验分享做 FOC 电流采集优化我的体会可以浓缩成几句话。第一先看硬件再看软件。采样时机是硬件级的物理问题软件写的再漂亮也解决不了触发点抖动。反过来如果下桥导通窗口本身不够软件再怎么优化采样代码都是白搭。第二用好手头的调试工具。逻辑分析仪、示波器、代码执行时间统计一个都不能少。这次优化中几次关键判断都是靠波形特征得出的结论光靠肉眼盯变量值根本发现不了采样点抖动的问题。第三先让功能跑起来再追求极致性能。原始实现虽然慢但至少 FOC 能转起来这给了我们一个正确的基线。如果没有这个基线后面的优化很容易引入回归问题还查不出来。第四优化要按性价比排序。把采样改成硬件触发一次改动带来 80% 的收益DMA 搬运又省下 10%真正在三角函数上抠的那点时间只占很小一部分。别一上来就死磕查找表还是 CORDIC 这种细节先解决大瓶颈。这套优化思路不只适用于 FOC 电流采集几乎所有周期性采样控制算法的嵌入式系统都能套用。先保证采样时机和物理过程同步再用 DMA 把数据通路做顺最后用编译器和计算优化收尾。按这个顺序走性能都不会差到哪里去。