做电机控制这些年,我一直觉得 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 相电流等于负的 U+V。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.5us,Clarke 约 0.2us,Park 查表约 0.3us,两个 PI 约 0.4us,PWM 更新约 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-math,PI 和积分部分保持标准语义。
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 周期才出现一次,采样率远低于预期。
处理结果:确认RCR=0,保证每个 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 把数据通路做顺,最后用编译器和计算优化收尾。按这个顺序走,性能都不会差到哪里去。