尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

FOC电流采集代码优化:从ADC触发到DMA搬运的完整实践

FOC电流采集代码优化:从ADC触发到DMA搬运的完整实践 做FOC的兄弟如果你点进这篇文章大概率是被那几行“看起来没问题跑起来就炸”的电流采集代码折磨过。这个系列我已经写到第11篇了前几篇聊了任务调度、内存池、log开销这些通用优化手段今天专门落到一个非常具体的场景上一段跑在STM32或类似MCU上的FOC电流采集程序怎么把它从“能跑”优化到“抗干扰、低延迟、CPU占用低”。这篇文章我会按照自己实际调试的路径来写——先看原始代码有哪些坏味道再用工具量化瓶颈然后给出整套优化方案最后是踩坑记录。不聊虚的每一步都对着代码和时间数字说话。1. FOC电流采集性能优化的前置认知1.1 电流采集在FOC环路里的核心地位FOC说到底就是一个“观电流-换坐标系-算电压矢量”的闭环。Clark变换把三相电流采样值投影到静止坐标系Park变换再把它转到旋转坐标系得到id和iq然后过电流环PID反Park变换生成电压矢量最后SVPWM输出给逆变器。这套链路里第一环就是电流采集。电流采集慢半拍整个电流环的相角裕度就会下降。具体来说电流环的延迟直接加进系统的相位滞后里。如果你采样延迟从2微秒涨到10微秒在20kHz电流环下相当于把系统相位裕度拖掉了大约9度左右。如果原来就只留了30到40度裕度这9度延迟完全可能让电机从“稳”变成“啸叫”甚至“炸管”。我见过不少现场问题电机跑起来低速抖、高速啸排查了半天不是PID没调好而是电流采样的时机和耗时根本不对。所以做FOC性能优化第一个要动的就是电流采集。1.2 为什么电流采样要放在下桥热搜词里有个高频问题FOC电流采集为什么都要设置在下桥这个要结合三相逆变器的开关状态来理解。FOC常用的是双电阻或三电阻采样方案。双电阻方案里采样电阻串在下桥臂MOSFET的源极和地之间只能在下管导通的时刻采集到对应的相电流。因为上管导通的时候相电流走的是母线上管到电机绕组的路径不经过下桥采样电阻你根本量不到东西。具体到“什么时候采”这个点业内通用做法是在PWM中心对齐模式的定时器上溢或下溢时刻触发ADC。因为在这个时刻三相下桥全部处于导通状态至少也要避开死区和窄脉宽的坑采样电阻上有完整的相电流流过采集到的值最稳定。所以“下桥采样”不是工程师拍脑袋定的而是由电流物理路径和SVPWM开关状态共同决定的。你只要理解了这一点后面配置ADC触发时就不会拧巴。1.3 性能优化前先搞清楚要优化什么指标看性能永远先定义指标。对于FOC电流采集代码我主要关注下面三个数字。第一是CPU占用率。电流环中断如果每50微秒跑一次一次执行10微秒那就是20%的CPU花在了电流环上。如果还能压到3微秒就能省出大量CPU给上位机通信、观测器或者其他控制任务。第二是采样延迟也就是从“电流真实流过采样电阻”到“电流环拿到这个数据”的时间差。这个延迟包含ADC转换时间、搬运时间、滤波时间和坐标变换时间。门槛就是刚才说的延迟越小相位裕度越充足。第三是采样噪声和抖动。ADC触发的时刻抖动会导致采样值抖动如果抖动和电机的电磁噪声叠加你最后看到的电流波形就是一团毛刺。这会影响电流环质量也会让转速波动变大。这三个指标互相牵制。CPU占用下去了如果采样时刻飘了也是白搭。所以优化不是单一维度而是整套链路一起优化。2. 原始代码的坏味道与性能基线测量2.1 一段很典型的“能用但难受”采集代码为了说明问题我把一份常见的电流采集代码简化后放在下面。这段代码杂糅了STM32标准库和部分HAL风格真实项目里往往比这个更乱。// 电流环中断PWM下溢触发频率20kHz void TIM_UP_IRQHandler(void) { // 手动触发ADC转换然后死等结果 ADC1_StartConversion(); while (!ADC1_GetFlagStatus(ADC1_FLAG_EOC)) ; // 阻塞等待 int16_t adc_val[2]; adc_val[0] ADC1_GetValue(); ADC1_StartConversion(); while (!ADC1_GetFlagStatus(ADC1_FLAG_EOC)) ; adc_val[1] ADC1_GetValue(); // 换算实际电流值 float ia (float)adc_val[0] * K_SCALE - I_BIAS; float ib (float)adc_val[1] * K_SCALE - I_BIAS; float ic -(ia ib); // 低通滤波用的还是double static double ia_flt 0.0, ib_flt 0.0; ia_flt ia_flt * 0.9 (double)ia * 0.1; ib_flt ib_flt * 0.9 (double)ib * 0.1; // Clark变换 float i_alpha ia_flt; float i_beta (ia_flt 2.0f * ib_flt) * 0.57735027f; // Park变换用了正弦和余弦库函数 float sin_t sinf(theta); float cos_t cosf(theta); float id i_alpha * cos_t i_beta * sin_t; float iq -i_alpha * sin_t i_beta * cos_t; // 电流环PID省略中间代码 pid_id_run(pid_id, id, id_ref); pid_iq_run(pid_iq, iq, iq_ref); // 更新SVPWM比较值 svpwm_update(); }你第一眼看这段代码可能觉得“该做的都做了”但真正跑起来就发现问题很多。最大的问题是阻塞等待ADC转换STM32F103这类MCU的ADC单次转换大约1微秒到几微秒不等如果你每个PWM周期要采两路一路等一次光这一项就吃掉好几微秒。第二个问题是中断里做低通滤波而且用的double在Cortex-M3/M4上double运算比float慢不少。第三是Park变换里的sinf和cosf虽然加了f后缀但库函数还是在中断里跑了几百个周期。这些问题单看每一项好像都“还好”累积起来就比较致命。我们实测过这段代码在一颗72MHz的Cortex-M3上跑一个20kHz中断的执行时间能到13.8微秒CPU占用率27.6%。电机不转的时候没问题一旦带上负载电流波形毛刺明显电机噪音也大。2.2 用IO翻转和逻辑分析仪测性能基线不测量就没有优化。每写一段优化代码之前我都会先在中断入口和出口拉高拉低一个测试IO用逻辑分析仪看高电平时间宽度。这个方法土但非常有效能干到几十纳秒的精度。测法很简单进中断先GPIO置高出中断前GPIO置低逻辑分析仪采样率设到100MHz以上抓几个PWM周期高电平时间就是中断执行耗时。当时测出的数据大致是项目数值PWM频率20kHz周期50微秒中断平均执行时间13.8微秒中断最大执行时间15.2微秒CPU占用率27.6%ADC触发方式软件手动触发阻塞等待采样抖动峰峰值约±1.2微秒看到采样抖动这行你可能就明白了为什么这个电机低速时会有周期性的噪声。因为电流采样点前后飘了1微秒以上在正弦波电流的过零点附近这个时间偏移对应不小的电流误差。这误差进了电流环等于给系统加了一个随机扰动。2.3 明确瓶颈优先级先时序后算法面对这段代码我曾经也想过先把sinf换掉、把double换float但实测发现这些还不是关键。核心瓶颈是先“什么时候采”的问题没解决。软件触发ADC意味着你必须等ADC转换完才能做下一步。你永远不知道前一次中断里的其他代码是不是拖沓了导致这次ADC触发晚了。这个机制本质上是串行依赖的天生就会引入抖动。所以第一优先级是改采样触发方式把ADC触发从“软件触发轮询等待”改成“硬件定时触发DMA搬运”。这样ADC转换和CPU计算可以在时间上重叠起来。等CPU真正拿到数据的时候ADC已经把该转的都转完了。算法层面的优化像滤波、坐标变换可以放到后面做。先把时序理顺往往能解决一大半问题。3. 优化方案逐步落地从ADC触发到DMA搬运3.1 用定时器硬件触发ADC替代软件触发针对上面的痛点第一步就是把ADC改成由定时器主动触发。STM32的高级定时器TIM1或TIM8在PWM输出模式上有个很方便的特性可以配置为在中心对齐计数的上溢或下溢事件时输出一个触发信号给ADC。这样ADC采样就和PWM载波严格同步了不需要软件干预。SVPWM的中心对齐模式下定时器计数值先增后减递减到0的时刻下溢正好是三相下桥全部导通的窗口中心在这里触发ADC采样是最理想的。配置的核心代码大致长这样// 以STM32F407为例使用TIM1的TRGO触发ADC1 TIM_SelectOutputTrigger(TIM1, TIM_TRGO_UPDATE); // 或者根据需求用TIM_TRGO_CC1IF等事件 ADC_InitTypeDef adc_init; // ADC独立模式12位采样时间尽量短但保证充足 adc_init.ADC_Mode ADC_Mode_Independent; adc_init.ADC_ExternalTrigConv ADC_ExternalTrigConv_T1_TRGO; adc_init.ADC_ExternalTrigConvEdge ADC_ExternalTrigConvEdge_Rising; adc_init.ADC_Resolution ADC_Resolution_12b; // 其他配置略配置好以后你每次PWM周期内不需要再调用ADC1_StartConversion()ADC硬件会在定时器事件到来时自动启动转换。这里有个细节触发边沿要选对。如果你的定时器配置为上溢和下溢都会更新那就需要根据实际配置选择沿。常用的做法是配置成仅在向下计数到0时触发一次这样每个PWM周期只采样一次。如果你的电流环需要每个周期采集两次比如在某些特殊工况下需要两个矢量作用时刻的电流用于低调制比下的重构再改成双触发。3.2 打开ADC的DMA模式让数据自动搬运光靠硬件触发还不够因为转换完成标志还是要软件去查。此时引入DMA就顺理成章了。ADC每个通道转换完成后DMA会自动把数据从ADC的数据寄存器搬到内存数组里不需要CPU参与搬运。配置DMA的要点有两个一是工作在循环模式这样每个PWM周期DMA都会自动搬运一组新数据永远不用重装二是数据长度要和ADC通道数对齐。DMA_InitTypeDef dma_init; dma_init.DMA_Channel DMA_Channel_0; // ADC1对应DMA2 Stream0按芯片手册选 dma_init.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; dma_init.DMA_Memory0BaseAddr (uint32_t)adc_buffer; dma_init.DMA_DIR DMA_DIR_PeripheralToMemory; dma_init.DMA_BufferSize 2; // 两路电流采样 dma_init.DMA_Mode DMA_Mode_Circular; // 循环模式 dma_init.DMA_PeripheralInc DMA_PeripheralInc_Disable; dma_init.DMA_MemoryInc DMA_MemoryInc_Enable; dma_init.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; dma_init.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; dma_init.DMA_Priority DMA_Priority_High; dma_init.DMA_FIFOMode DMA_FIFOMode_Disable;DMA模式下CPU完全不用等ADC。电流环中断触发时直接读adc_buffer[0]和adc_buffer[1]即可。注意要用volatile修饰缓冲数组否则编译器可能在优化时把两次读数组重排或直接缓存到寄存器里读到旧数据。内核里有一个非常反直觉的坑——你读DMA缓冲时DMA可能正好在搬运新数据。虽然循环模式下ADC的两个通道是顺序转换的但如果你在PWM中断里读到的两路数据分别属于两个不同的PWM周期那你用来算Clark变换的ia和ib就不是同一时刻的电流值会产生额外的相位误差。解决办法有两个要么在PWM中断里读数据时先关DMA再读再开DMA这种方法如果操作不当会丢失一个触发事件不一定好要么配置ADC为注入组单次扫描模式用注入组转换结束事件触发中断保证两个通道是同一批次转换出来的。我个人更推荐后者因为它从机制上保证了两个值的时间一致性。3.3 电流环中断瘦身只留真正必要的计算硬件触发和DMA解决的问题是“采集过程不占用CPU时间”。但中断里仍然存在一些不合理的计算。先看滤波部分。有些工程师习惯把低通滤波放在中断里但我们得想清楚FOC电流环本身就是一个带宽很高的环路你砍掉高频噪声是可以的但如果滤波太深会把有用信号也削掉。比如你用0.1的系数做低通等效带宽可能已经低于电流环带宽了这会导致相位延迟增大。更稳妥的做法是如果ADC采样毛刺不是特别严重直接用硬件层面优化。比如把ADC采样时间从1.5周期加到3个周期让采样电容充分充电毛刺往往就下来了。实在需要软件滤波优先用一阶IIR系数选0.7到0.9之间别低于0.7。再来看坐标变换时的sinf和cosf。我理解很多人图省事直接用数学库但它的实现里包含精度和兼容性考虑往往不是最快的。FOC里角度theta的变化是有规律可循的在很多项目里可以把sin/cos表做成512点甚至1024点的查找表查表加线性插值的误差足够小速度却快很多。3.4 用查表替代实时三角函数计算举一个具体例子。假设电机最高电频率是500HzPWM频率20kHz也就是每个电周期有40个PWM周期。如果用512点sin表每次查表之间角度步进是360/512约等于0.7度对电流环来说精度已经足够好了。查表法实现如下#define SIN_TABLE_SIZE 512 #define SIN_TABLE_MASK (SIN_TABLE_SIZE - 1) // 建表时一次性算好 static float sin_table[SIN_TABLE_SIZE]; void sin_table_init(void) { for (int i 0; i SIN_TABLE_SIZE; i) { sin_table[i] sinf(2.0f * PI * i / SIN_TABLE_SIZE); } } // 查表线性插值 float fast_sin(float angle_rad) { float index_f angle_rad * (SIN_TABLE_SIZE / (2.0f * PI)); int32_t index (int32_t)index_f; float frac index_f - (float)index; index SIN_TABLE_MASK; int32_t index_next (index 1) SIN_TABLE_MASK; return sin_table[index] (sin_table[index_next] - sin_table[index]) * frac; }实际项目中这个查表函数在Cortex-M4上大概几十个周期就能跑完比sinf快一半还多。3.5 电流环代码抽离紧急任务和非紧急任务分开电流环中断里除了坐标变换和PID往往还混着一些不太相关的东西。比如有的工程师会把转速估算、弱磁控制、过流保护逻辑全塞在一个中断里。这类问题在优化时要拆开。FOC中真正需要和PWM同频执行的部分只有电流采样读取、坐标变换、电流环PID、SVPWM输出。这些都是必须在载波周期内完成的“紧急任务”。转速估算这类东西很多时候不需要20kHz的频率。如果你的目标带宽在100Hz左右那么转速估算哪怕降到1kHz也没有问题。把它挪到低优先级定时中断或者主循环里跑能大幅减轻高频中断的负担。过流保护必须走硬件比较器或ADC模拟看门狗这是硬件级别的快速保护不能让软件延迟决定是否炸MOS管。软件层面的过流检测可以作为第二道防线不要把它和电流环PID混在同一个中断里。3.6 把高频中断放到RAM里执行这个优化点知道的人不算多但在Flash速度慢的MCU上非常有效。很多MCU从Flash取指令是有等待周期的尤其是高频时钟下。比如STM32F4在168MHz主频下Flash等待周期可能到5个周期这意味着每条指令都要多等好几个周期。解决办法是直接把电流环中断服务函数放进RAM执行。GCC环境下用__attribute__((section(.ramfunc)))即可IAR则用__ramfunc关键字。__attribute__((section(.ramfunc))) void TIM1_UP_IRQHandler(void) { // 快速执行电流环 }注意这段函数会被拷贝到RAM里运行如果RAM紧张就不能放太多内容。实际项目中我通常只把最关键的电流环ISR放进去其他普通中断保持原地不动。4. 优化后的代码长什么样4.1 结构清晰的电流环主流程经过了上面几步的优化电流环中断的代码就清爽多了。下面是我在项目中实际使用过的简化版本// 电流环中断TIM1下溢硬件触发ADCDMA搬运 __attribute__((section(.ramfunc))) void TIM1_UP_IRQHandler(void) { adc_dma_buffer_t adc read_adc_buffer(); // 原始电流还原 float ia adc.sample_a * K_SCALE - I_BIAS; float ib adc.sample_b * K_SCALE - I_BIAS; float ic -(ia ib); // 可选的弱滤波只在必要时启用 ia iir_lpf(ia, filter_a_state); ib iir_lpf(ib, filter_b_state); // Clark变换 i_alpha ia; i_beta (ia 2.0f * ib) * SQRT1_3; // Park变换查表法 float s fast_sin(theta), c fast_cos(theta); id i_alpha * c i_beta * s; iq -i_alpha * s i_beta * c; // 电流环PID pid_id_run(pid_id, id, id_ref); pid_iq_run(pid_iq, iq, iq_ref); // 反Park变换 vd id_out * c - iq_out * s; vq id_out * s iq_out * c; // SVPWM输出 svpwm_apply(vd, vq); }这段代码的精髓在于数据已经从DMA那准备好了滤波函数做了轻量化三角函数用查表整个中断里没有阻塞等待没有多余的判断分支。你甚至可以把这段代码的执行时间控制得很稳定这对FOC控制的一致性非常重要。4.2 ADC配置代码的完整示例光看中断代码还不够ADC和DMA的初始化也要配套。这里给一个相对完整的STM32F4系列初始化参考。void foc_adc_init(void) { ADC_InitTypeDef adc_init; DMA_InitTypeDef dma_init; GPIO_InitTypeDef gpio_init; NVIC_InitTypeDef nvic_init; // 使能ADC1时钟和GPIO时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2 | RCC_AHB1Periph_GPIOC, ENABLE); // PC0和PC1作为模拟输入对应ADC1的通道10和通道11 gpio_init.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1; gpio_init.GPIO_Mode GPIO_Mode_AN; gpio_init.GPIO_PuPd GPIO_PuPd_NOPULL; GPIO_Init(GPIOC, gpio_init); // DMA2 Stream0配置 dma_init.DMA_Channel DMA_Channel_0; dma_init.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; dma_init.DMA_Memory0BaseAddr (uint32_t)foc_adc_dma_buffer; dma_init.DMA_DIR DMA_DIR_PeripheralToMemory; dma_init.DMA_BufferSize 2; dma_init.DMA_PeripheralInc DMA_PeripheralInc_Disable; dma_init.DMA_MemoryInc DMA_MemoryInc_Enable; dma_init.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; dma_init.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; dma_init.DMA_Priority DMA_Priority_High; dma_init.DMA_Mode DMA_Mode_Circular; DMA_Init(DMA2_Stream0, dma_init); // ADC1配置 adc_init.ADC_Mode ADC_Mode_Independent; adc_init.ADC_ScanConvMode ENABLE; // 扫描模式多通道 adc_init.ADC_ContinuousConvMode DISABLE; // 超时触发不是连续转换 adc_init.ADC_ExternalTrigConvEdge ADC_ExternalTrigConvEdge_Rising; adc_init.ADC_ExternalTrigConv ADC_ExternalTrigConv_T1_TRGO; // TIM1触发 adc_init.ADC_DataAlign ADC_DataAlign_Right; adc_init.ADC_NbrOfConversion 2; ADC_Init(ADC1, adc_init); // 配置两个通道的采样时间 ADC_RegularChannelConfig(ADC1, ADC_Channel_10, 1, ADC_SampleTime_3Cycles); ADC_RegularChannelConfig(ADC1, ADC_Channel_11, 2, ADC_SampleTime_3Cycles); // 使能DMA和ADC ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); }初始化代码里需要注意的一个细节是扫描模式下ADC的通道顺序就决定DMA缓冲里的数据顺序。你必须清楚通道10对应dma_buffer[0]通道11对应dma_buffer[1]后面读数据时别搞反。4.3 优化后的性能参数实测代码改完之后我用同样的逻辑分析仪测了一轮结果对比如下指标优化前优化后中断平均执行时间13.8微秒4.2微秒中断最大执行时间15.2微秒4.5微秒CPU占用率20kHz中断27.6%8.4%采样抖动峰峰值±1.2微秒±0.15微秒电流波形毛刺明显带载时噪声大基本消除CPU占用从27.6%降到8.4%直接省出了19个百分点的算力。这个余量你可以用来做无传感器观测器、做在线参数辨识或者干脆降低主频以省电。采样抖动从1.2微秒压到0.15微秒电机低速时的周期性噪声基本消失。5. 高频FOC工程中的常见问题清单5.1 采样点正好落在死区或者窄脉宽区间电流采集要避开死区时间否则采样出来的电流不是真实相电流。这个问题在做小占空比、低调制比工况时尤其容易踩。SVPWM载波中心的下桥导通窗口如果太窄ADC采样时刻稍微偏一点就可能采到死区期间的回流状态。解决思路是加入最小采样窗口判断。比如计算当前PWM周期的有效下桥导通时间如果低于某个阈值比如3微秒就放弃本轮采样用上一次的电流值或者把ADC触发点往安全方向偏移。5.2 电流过采样滤波后出现了额外相位延迟有些工程师为了让波形更干净喜欢把多个PWM周期内的电流采样值做平均。比如四周期滑动平均这在软件上很好写但效果不一定好。因为滑动平均本质是一个FIR滤波器它本身会引入大约1.5个采样周期的延迟。在电流环里这个延迟直接转化为相位滞后。我见过有人把滑动窗口加到八个点结果电机跑高速时电流环开始振荡。滤波不是越强越好做电流环滤波时一定要拿示波器看阶跃响应确认没有额外相移。5.3 DMA循环模式下读到了“半新半旧”的数据这个问题我在3.2节提了一些实际项目中经常让人头疼。DMA循环模式是永远在跑的你读数组的时候数组里面可能既有前一周期数据又有本周期的数据。这在ADC扫描模式配置不当的时候尤其明显。我的建议是果断使用注入组转换。注入组转换可以配置为一次转换一组通道并把结果锁存到注入数据寄存器里。这样读到的两个值才是严格同一时刻的快照不需要用开关DMA这种粗糙的规避方案。5.4 PFC和电机驱动共用MCU时中断优先级怎么定很多项目是一个MCU同时管PFC和电机FOC。PFC通常也是高频控制两个电流环中断优先级一旦配错系统就会出现奇怪的震荡。一个基本准则是哪个环路带宽高、故障风险大哪个优先级就高。FOC的过流风险更大通常FOC电流环中断优先于PFC。PFC的电流环如果稍微滞后一拍一般不会炸机但FOC的电流环滞后就会导致电流失控。5.5 在中断里不要用printf这条听着像废话但真的有人踩坑。FOC电流环20kHz频率下你要是往中断里塞一个printf哪怕是重定向到数组缓存也会大量占用CPU时间甚至把中断栈顶爆掉。调试的时候可以用IO翻转逻辑分析仪或者DMA串口输出少量关键变量不要用阻塞式的printf。真要看波形把变量存到一个环形缓冲区然后让上位机通过慢速通道慢慢读这个通道放在主循环里执行就行。6. 优化思路的横向迁移不只适用于FOC这套优化方法并不是只能用在FOC电流采集上。数字电源的电压电流双环、伺服驱动的编码器采样、并网逆变器的电网电压锁相环它们本质都是“高速外设采样高频控制中断延迟敏感闭环”。你只要记住这条主线数据准备尽量用硬件自动完成CPU在中断里只做真正的控制运算最后用测量数据验证每一个环节的开销。哪里延迟大就把哪里往硬件卸载或者往低频搬。我自己在数字电源项目里也复用了这套ADCDMA定时器触发机制效果同样明显。所以这篇文章的内容与其说是“FOC电流采集代码优化”不如说是一套嵌入式高实时性闭环的通用套路。最后说一个个人心得做这类优化不要一上来就怀疑自己的控制算法不行。先把链路里每一段延迟都测出来、量出来让数据告诉你瓶颈在哪里。大多数情况下问题根本不在PID参数而在数据通路本身。把通路理顺了电流环自然就听话了。
返回列表