资讯详情

资讯详情

FOC电流采样优化:从43.6μs到4.6μs的工程实战

1. 从一团乱麻到可用这段代码的原始面貌与优化目标先交代下背景。我在做一套低压无感FOC驱动板主控是 Cortex-M7 内核400MHz功率部分用的是一颗集成预驱的栅极驱动芯片电流采样方案是双电阻低边采样。整个驱动板跑的是 20kHz 的 PWM电流环执行频率也是 20kHz——也就是说每个 PWM 周期都要执行一次电流环留给电流采集和 FOC 坐标变换的时间相当有限。FOC 的电流采集代码是整个控制链路的第一环这一环如果拖后腿后面算得再快也没用。我接手这块代码时的状态是一个中断服务函数里同时做了 ADC 结果读取、偏移校准、Clarke 变换、Park 变换中间还夹杂着一堆for循环和浮点运算函数体内局部变量堆了十来个默认开着编译优化等级 O0。整段代码跑完一次大约是 43.6 微秒看着好像不算太离谱但你要知道——20kHz 周期一共才 50 微秒。也就是说电流环中断里光这一部分就吃掉了接近 87% 的时间预算剩下给 PID 调节器、SVPWM 计算、速度环级联的时间几乎被挤干净了。问题还不止慢这么简单。这段采集代码在 ADC 中断触发时机上也埋了雷——它没有等转换完全结束就读取数据寄存器而是用了ADC_GetFlagStatus轮询配合软件延时来等待一旦 PWM 占空比过大或母线电压抖动采样时刻就会漂移导致电流波形上出现毛刺。这些毛刺在低速堵转测试时直接被放大成明显的转矩脉动电机转起来像是有人在一下一下地顿。这次优化的目标很明确在不改变硬件的前提下把采集链路的总耗时压到 10 微秒以内同时把采样时刻固定到 PWM 载波的谷底也就是下桥导通的时间中点提高采样信噪比消除电流波形上的周期性毛刺。另外顺手把代码的可移植性做出来——原代码里大量使用了厂商 SDK 自带的数据类型和寄存器级操作换一颗芯片等于重写这个不健康。2. 采样时刻问题为什么必须在下桥导通中点动手脚先说清楚一个 FOC 电流采集里最容易被人忽略的点。很多人问我FOC 电流采样的 ADC 触发时刻到底设置在什么时候最合适答案是在 PWM 周期内下桥臂全部导通的时间点附近通常取三角载波谷底。这个结论不是拍脑袋定的涉及电流重建的物理原理。以低边采样为例母线电流只有在某个下桥臂导通时才能通过采样电阻形成回路。三相逆变器在每个 PWM 周期内有不同的开关状态组合只有当下桥臂有电流流过时采样电阻上的电压才真实反映相电流。而 PWM 三角载波的谷底正好是三个下桥臂同时导通的时间窗口死区时间除外。在这个窗口内采样得到的母线电流才能经过重构还原出准确的相电流。如果在死区或切换动作发生前后采样电流波形上会叠加开关噪声和续流尖峰得到的电流反馈值能把你带偏十个百分点不止。更关键的是采样点不能设在随机时间而必须被硬件定时器精确触发。原代码的 ADC 触发方式是软件触发——中断里先判断 PWM 状态算出一个延时插进去再启动转换。这种做法的弊端有三个软件延时受中断嵌套影响时间基准不稳定占空比越大延时的计算偏差越明显每次触发时刻与 PWM 同步信号的相位抖动量达到几百纳秒反映到电流波形上就是白噪声级别的高频抖动。优化方案是用 PWM 定时器的周期更新事件或触发输出来硬件触发 ADC 转换。我在 STM32 类芯片上用的是定时器的 TRGO 事件更新事件直接连到 ADC 的注入组触发源这样 ADC 启动的时间精度完全由硬件保证和时间触发的 PWM 波形成固定相位关系。改完之后电流波形上的高频毛刺明显减少FFT 分析中的噪声底降低了大约 8~10dB。需要注意的一个小坑如果配置了互补 PWM 输出和死区插入ADC 触发时间点不能恰好落在死区边缘。我把采样保持时间设置成 72 个 ADC 时钟周期确保在死区结束后采样保持电容已经完全建立好电压。这个时间参数需要根据你的采样电阻值、电容值和 ADC 输入阻抗实际调整不能照抄别人的配置。3. 采样链路重构去掉轮询、缓存和浮点堵塞原代码里最大的性能黑洞其实不是 ADC 转换本身而是围绕 ADC 结果的获取——搬运——预处理流程。我拆成了三个独立的问题来解决每个问题都很典型。3.1 DMA 搬运代替中断轮询原代码用了 ADC 中断标志位轮询的方式等转换结束再读取数据。这种方式在 20kHz 的采样频率下看似够用但中断服务函数内的所有其他工作都要排队等这个查询结果。更麻烦的是如果在多通道序列采样模式下需要等待所有通道都转换完才能一次性读取等待时间呈线性增长。我的做法是让 ADC 工作在 DMA 传输模式ADC 转换完成会自动触发 DMA 搬运把结果直接存到内存缓冲区全程不需要 CPU 介入。DMA 传输完成标志触发中断ISR 里直接面对的是已经排好序的数组省去了逐个读取寄存器的时间。实测效果原来轮询读 ADC 结果需要 2.8 微秒4 通道DMA 版本只需要 0.4 微秒的中断开销。节省下来的 2.4 微秒看着不多但在 20kHz 频率下累积起来相当于每秒钟释放了 48 毫秒的 CPU 时间。3.2 数据类型裁剪该用定点绝不用浮点原代码的 Clarke 变换和 Park 变换全部是浮点运算。M7 虽然带有 FPU但浮点运算毕竟比定点慢一个量级尤其在做大量乘加操作时。我在不动算法结构的前提下把坐标变换的内部计算全部改成 Q13 格式的定点运算。选择 Q13 的原因很简单相电流归一化后的最大值大约是 ±55A对应 ADC 满量程折算到 Q13 格式可以精确到 0.00012A 左右足够满足 0.5% 的电流控制精度要求。而且 Q13 恰好在 16 位有符号整数的表示范围内乘加过程中用 32 位中间变量承接不会溢出。Clarke 变换的核心计算从三行浮点代码变成了几行整数乘法加法一次变换从 1.6 微秒降到了 0.35 微秒。Park 变换类似从 1.8 微秒降到了 0.4 微秒。3.3 三角函数的替代查表不是唯一的路在 FOC 里Park 变换和逆 Park 变换都要用到正弦和余弦值。原代码直接调用数学库的sinf和cosf在 M7 上一次调用大约需要 1~2 微秒。这个开销放在 20kHz 频率下相当可观。我评估了几种替代方案查表法提前算好 65536 点的正弦表也就是每 0.0055 度一个点。内存占用上一个 65536 点 Q15 格式的查找表是 128KB对于 Flash 和 RAM 都不紧张的 M7 来说完全能接受。查找一次的操作是取角度值的高 16 位作为索引直接从表里拿值。一次查表耗时约 10 个时钟周期也就是 25 纳秒比原生的数学库调用快了几十倍。相位精度上65536 点的表对应 16 位角度分辨率对电流环来说绰绰有余。电机控制领域的工程经验是 12 位角度分辨率就能保证电角度误差小于 0.15 电角度16 位完全够用。4. 当前代码的明显问题优化前必须拔掉的三根刺前几节讲的都是性能层面的优化但代码里还有几个问题不解决的话上面这些优化做了也是白做。这三个问题属于功能正确性层面是藏在代码里的定时炸弹。4.1 问题一中断嵌套导致采集抖动原代码的 ADC 中断服务函数没有任何临界区保护。如果有更高优先级的中断比如串口接收中断、外部 IO 中断恰好在 ADC 转换完成瞬间触发CPU 会先响应高优先级中断等它处理完才回到原本的电流环中断里继续执行。这段时间不可控可能是几十纳秒也可能是几百微秒如果高优先级中断里有延时函数的话。后果就是采样数据的时间戳发生了不可预测的偏移反映到 FOC 控制上就是电流环的相位滞后时大时小动态响应变差。修复方法ADC 中断优先级设置为当前系统最高并确保中断服务函数内禁止任何形式的阻塞调用包括systick延时。同时把电流环计算的核心代码放到临界区内执行这段时间内的所有其他中断一律挂起。实测临界区最坏情况约 8 微秒对通信任务的影响可以接受。4.2 问题二数组越界和未初始化变量原代码里有一段比较阴间的操作定义了一个 16 元素的数组但实际有效数据只有 4 个循环时错误地用了for(i0; i16; i)去遍历。虽然在当前芯片上数组后面正好有其他变量没造成实际崩溃但这种代码在换芯片、改编译优化等级之后极大概率会变成诡异的跑飞问题。另外还有三个局部变量在声明后没有初始化就参与了计算。你可能会觉得编译器会帮你把局部变量清零但事实是编译器在 O0 下不会做任何多余的初始化操作这些变量值是栈上的残留数据完全不可控。哪怕是最愚蠢的初始化操作比如赋 0都能让代码变得至少可预测。我在优化后的代码里给所有关键变量加了显式初始化并且在调试阶段开启了边界检测功能来排查越界。这个问题太基础了但恰恰是最容易被忽略的。4.3 问题三校准逻辑被放在了中断服务函数里原代码的主循环里有一个校准函数但它的运行机制非常离谱——校准程序通过一个全局标志位触发一旦触发会在下一次电流环中断里执行整段校准逻辑包括 500 次采样求平均等操作耗时大概 15 毫秒。在 20kHz 的中断里插一段 15 毫秒的任务对控制系统来说相当于 300 个周期完全没有电流环输出电机直接失步。修复方法校准操作全部搬到主循环里执行执行完再更新偏移量并且在校准期间封锁电流环输出。如果你需要在校准期间保持电机运行那还得做更精细的运行中校准方案——在校准窗口内将 PWM 输出置为高阻态采集零电流偏置然后恢复。这个方案我只在部分项目中用过风险更大不建议新手直接上。5. 优化后的实测数据与链路收益分析优化完成后我在同样的硬件上跑了一组对比测试。测试条件是20kHz PWM双电阻采样相电流范围 ±55A母线电压 48V电机参数为 6 对极永磁同步电机。先看总耗时对比优化项优化前优化后缩减幅度ADC 结果读取4通道2.8μs0.4μs85.7%Clarke 变换1.6μs0.35μs78.1%Park 变换1.8μs0.4μs77.8%三角函数计算2×1.5μs3.0μs2×0.025μs0.05μs98.3%偏移校准与预处理4.2μs0.9μs78.6%其他中断开销、变量处理等30.2μs2.5μs91.7%合计43.6μs4.6μs89.4%这里需要说明一下其他这一项的优化空间来源原代码在中断里做了大量无意义的类型转换、临时变量赋值和数据搬移操作。比如一个电流值在中断里被反复从float转成int又转回float这种代码削掉之后收益立竿见影。总耗时从 43.6 微秒压到 4.6 微秒是什么概念在 20kHz 的控制周期50 微秒里电流采集链路占用的时间比例从 87% 降到了 9.2%。剩下 45 微秒可以完整执行速度环 PID、SVPWM 调制、保护逻辑和通信处理任务整个系统的余量一下子宽裕了。从控制效果来看优化后的电流环带宽从原来的大约 1.2kHz 提升到了 3.5kHz 左右阶跃响应的超调量从 12% 降到 4.5%堵转转矩波动降低了约 40%。这些指标达到后整套驱动的力感控制阻抗控制模式才真正有了可用的手感。5.1 数据对比的另一面优化不是免费的午餐任何优化都有代价把话说全比较负责任。定点化改造换来的代价是代码可读性下降后续维护者需要理解 Q 格式的运算规则提高了上手门槛。查表法替代实时计算换来的是 128KB 的内存占用在 Flash 小的芯片上可能直接放不下。优化的核心就是做取舍。我在下面这几类情况下不会用这套优化方案Flash 存储空间小于 256KB正弦表放不进去控制频率低于 10kHz时间余量足够不需要冒定点化的风险芯片主频较低比如 72MHz 的 M3此时浮点运算和定点运算的速度差距没有在 M7 上那么悬殊优化收益有限。6. 几个隐藏很深但影响巨大的坑实测踩出来的教训这部分我想把优化过程中踩到过的、查了很多资料才搞明白的隐藏问题梳理出来。你能避开这些坑可以省下一两周的调试时间。6.1 坑一ADC 采样保持时间不足导致串扰采样保持电路有一个建立时间参数。如果你的采样保持时间设置得太短采样电容上的电压还没稳定到实际输入值ADC 就启动了量化结果会偏低或出现非线性。这种现象在相电流接近正弦波的峰值时更明显——因为这时候输入信号变化最快。我的解决方法是把采样时间设置到 72 个 ADC 时钟周期大约 600ns并串联一个 10Ω 的 RC 低通滤波截止频率约 1.6MHz。这个组合在保证采样的真实性的同时不会影响 20kHz 电流环需要的带宽。6.2 坑二DMA 传输方向配置颠倒ADC 的 DMA 有两种模式——正常模式和循环模式。循环模式更高效因为它不需要每次传输完成后重新初始化 DMA 配置。但循环模式如果和目标缓冲区长度不匹配会出现数据倒灌和缓冲覆盖问题。我的配置是DMA 循环模式传输数据宽度 32 位因为 ADC 是 12 位右对齐存成 32 位变量方便对齐缓冲区长度 4 个元素正好对应 4 个通道。如果你改成 8 个元素但 ADC 只有 4 个通道DMA 第二次传输会覆盖前 4 个最新值——这种情况下你的数据都是新的但通道对应关系会一直错位数据张冠李戴。6.3 坑三编译器优化等级对时序的影响原代码在 O0 优化等级下可以正常工作一旦调高到 O2某些循环会被自动展开变量到寄存器的分配也会改变导致时序出现微小的差异。如果你的代码里用了类似__NOP()这种空指令来做延时那么在高优化等级下这些空指令可能被编译器优化掉延时失效。我的做法是把关键时序逻辑全部改由硬件定时器控制不在软件里通过空指令做任何延时。需要时间约束的地方都用定时器或 DMA 的硬件特性保证彻底摆脱编译器行为的影响。6.4 坑四浮点转定点时的线性度陷阱把浮点转成 Q 格式定点数时有一个隐藏的精度损失点——除法运算。C 语言里整数除以整数得到的是整数如果把float类型的电流先转成 Q15 格式的int16_t然后再做归一化除法结果的舍入误差会被放大。正确的做法是先用浮点完成归一化和偏移校准最后的计算结果才转成定点数。或者在定点化时引入舍入补偿加上 0.5 再截断避免常规截断带来的系统性偏置。这个系统性偏置虽然单项很小但在积分环节里会被累积放大最终表现为稳态电流偏移。6.5 坑五断线检测误触发低边采样电阻在电机相线断开时会检测到零电流这个状态如果被误判为电流传感器故障系统会执行保护性停机。我在初始版本里设置了一个非常激进的断线阈值——低于 50mA 就判定为断线。结果在空载启动瞬间由于相电流本来就接近零导致频繁误报。后来把判断逻辑改为连续 20 个 PWM 周期1ms内相电流绝对值均小于 50mA且电机处于运行状态转速指令大于 100rpm才触发断线保护。这样既不会漏判真实断线又不会误伤正常启动过程。7. 扩展思路这轮优化还能延伸到哪里代码已经跑得很顺了但我个人觉得还有一些方向值得继续深挖如果你有精力可以从这几个角度继续往下做。第一在采集链路上加入过采样技术利用 ADC 的过采样功能把 12 位分辨率提升到 14 位甚至 16 位代价是更长的转换时间。对于低速高精度场景比如舵机控制很有价值但在高速电流环里会牺牲响应速度需要权衡。第二把坐标变换从逐通道计算改成查表 矩阵运算的向量化实现。M7 内核有 DSP 扩展指令利用 SIMD 指令可以同时对多个通道做乘加运算把 Clarke 变换再压缩 30% 到 40% 的时间。第三构建一套代码生成器脚本用 Python 自动生成不同下的 ADC 配置代码和坐标变换函数切换芯片或硬件时只需要改变配置文件的参数不用手工修改代码。我用 Python 脚本从统一的配置JSON生成过 STM32 和 GD32 两套驱动代码效果非常理想强烈建议嵌入式团队搞一个。第四在电流采样时导入采样窗口的概念——在一个 PWM 周期内不仅在谷底采一次还在特定时刻采第二次基于两次采样值做线预测从而消除电流环的一拍延迟。这个方法在自适应控制相关文献里讨论得比较多实现难度较高但收益很明显控制带宽能再提升一倍左右。8. 给你能直接用的代码模板经过优化的最小实现最后给出一段经过优化重构的电流采集核心代码的骨架。这段代码参考了我在项目中的实际实现做了一些抽象方便你移植到自己的平台上。这不是完整能直接编译的工程代码但核心逻辑和优化思路都在里面可以作为你重构的起点。// // FOC 电流采集优化模板基于 M7 DMA ADC 硬件触发 // 核心逻辑PWM 定时器硬件触发 ADC - DMA 搬运 - 定点坐标变换 // #include foc_current_sense.h // Q13 格式的 Clarke/Park 变换系数 #define SQRT3_Q13 (7095) // sqrt(3) * 8192 #define TWO_THIRD_Q13 (5461) // 2/3 * 8192 typedef struct { int16_t i_alpha_q13; int16_t i_beta_q13; int16_t i_d_q13; int16_t i_q_q13; } FOC_Current_t; volatile FOC_Current_t g_foc_current; volatile uint16_t g_adc_buf[4]; // DMA 目标缓冲区4 通道 void ADC_DMA_IRQHandler(void) { // 1. 读取 DMA 传输过来的 4 通道原始数据 uint16_t adc0 g_adc_buf[0]; uint16_t adc1 g_adc_buf[1]; uint16_t adc2 g_adc_buf[2]; // adc3 为母线电压等其他信息此处暂不使用 // 2. 偏移校准Q13 格式 int32_t ia_raw (int32_t)(adc0 - g_current_offset_a); int32_t ib_raw (int32_t)(adc1 - g_current_offset_b); int32_t ic_raw (int32_t)(adc2 - g_current_offset_c); // 3. Clarke 变换Q13 - Q13 // i_alpha ia // i_beta (ia 2*ib) / sqrt(3) // 定点化实现先用 32 位中间变量避免溢出 int32_t t1 ia_raw; int32_t t2 (int32_t)((ia_raw (ib_raw 1)) * SQRT3_Q13) 13; g_foc_current.i_alpha_q13 (int16_t)t1; g_foc_current.i_beta_q13 (int16_t)t2; // 4. Park 变换需要电角度 - 查表求 sin/cos uint16_t theta_idx g_electrical_theta_idx; // 16 位电角度 int16_t sin_theta g_sin_table[theta_idx]; int16_t cos_theta g_cos_table[theta_idx]; // i_d i_alpha * cos i_beta * sin // i_q -i_alpha * sin i_beta * cos int32_t id32 (t1 * cos_theta t2 * sin_theta) 13; int32_t iq32 (t2 * cos_theta - t1 * sin_theta) 13; g_foc_current.i_d_q13 (int16_t)id32; g_foc_current.i_q_q13 (int16_t)iq32; // 5. 清除 DMA 中断标志 DMA_ClearITPendingBit(...); } void FOC_CurrentSense_Init(void) { // 1. 配置 ADC硬件触发由 PWM 定时器 TRGO 事件触发 // 2. 配置 DMA循环模式数据宽度 32 位缓冲区长度 4 // 3. 配置 PWM 定时器更新事件连接到 ADC 触发输入确保采样时刻在下桥导通中点 // 4. 初始化偏移校准静止状态下采集 500 次取平均作为零偏 }这段代码里需要特别注意的是定点乘法后的移位操作。 13是因为乘数和被乘数都是 Q13 格式乘积是 Q26右移 13 位还原成 Q13。这里有读者可能会问为什么不是右移 26 位因为两个 Q13 数相乘结果的小数点位在 26 位处你只需要保留 Q13 精度所以右移 13 位同时把结果截断成 16 位存入int16_t变量。这个细节在调试时很容易踩坑。另外代码里的角度表我用的是一张合并的正余弦表把sin_theta和cos_theta放到同一个数组里每次用偏移地址取能少一次 cache miss。这个优化在表格较大时效果更明显属于细枝末节但顺手做掉不留遗憾。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →