1. 无感FOC里那个绕不开的坎:atan求解
搞电机控制的朋友,尤其是做PMSM无感FOC的,大概率都在一个地方卡过壳——转子角度估算。不管你是用滑模观测器、龙伯格观测器还是磁链观测器,最后一步总要把估算出来的反电动势或者磁链分量,换算成电角度。这个换算,本质上就是求反正切。
听起来很简单对吧?atan2(y, x)一行代码的事。但问题在于,你跑FOC的芯片往往是一颗几块钱的MCU,比如STM32F103C8T6这种,主频72MHz,没有FPU,跑的是定点运算。你让它去调标准库里的atan2,那是个浮点函数,背后是几十条甚至上百条指令的多项式逼近,一次调用可能吃掉几百个时钟周期。而FOC的电流环通常要跑10kHz到20kHz,也就是每50到100微秒就得算一次角度。你算算这笔账,光一个atan就把CPU啃掉一大块,剩下的时间还要做Clarke、Park、PID、SVPWM,根本不够用。
所以实际工程里,没人会在中断里直接调atan2。大家用的都是查表法或者Cordic算法。查表法简单,但精度受表大小限制,而且占Flash。Cordic算法就不一样了,它用纯粹的移位和加法就能把角度逼出来,不需要乘法器,不需要浮点单元,特别适合MCU。我最早接触Cordic是在做磁编码器解码的时候,后来转到无感FOC,发现这套东西简直是为电机控制量身定做的。
这篇文章就围绕Cordic算法求解atan反正切角度这件事,把我在无感FOC实战里踩过的坑、调过的参数、优化过的代码,从头到尾捋一遍。不管你是刚接触FOC的新手,还是已经调过几版但角度总有点抖的老手,应该都能从里面找到点有用的东西。代码我会给C语言版本,定点实现,直接能往STM32或者类似MCU上搬。
2. 为什么偏偏是Cordic:方案选型背后的账
2.1 三种atan求解方案的横向对比
在嵌入式里求反正切,能走的路其实就三条:库函数直接调、查表加插值、Cordic迭代。这三条路我都走过,各有各的脾气,咱们一条条说。
先说库函数。atan2f在ARM的DSP库或者标准C库里有,精度没得说,但代价是浮点运算。如果你的MCU带FPU,比如STM32F4系列,那用就用了,问题不大。但大量低成本FOC方案用的是F1、F0或者国产的M0核,没FPU,浮点全靠软件模拟,一次atan2f轻松超过500个周期。在20kHz的环里,这就是10%的CPU占用,太奢侈了。
再说查表法。思路很直接:把0到90度的atan值预先算好,存成一张表,运行时用y/x的比值去查,再根据象限补角度。表越大精度越高,但Flash占用也越大。比如你要0.1度的分辨率,90度就得900个点,每个点2字节,就是1.8KB。听起来不多,但对于只有64KB Flash的F103来说,也是块肉。而且查表法有个尴尬的地方:靠近0度和90度的时候,atan变化率极不均匀,均匀查表在两端精度差,非均匀查表又不好索引。
最后说Cordic。Cordic的全称是Coordinate Rotation Digital Computer,核心思想特别巧妙:用一系列固定角度的旋转,去逼近目标角度。每次旋转的角度是atan(2^-i),这些角度可以预先算好存成一张很小的表。关键在于,每次旋转只需要判断y的符号,然后做移位和加减。没有乘法,没有除法,没有浮点。迭代10到12次,精度就能到0.1度以内,对于FOC来说完全够用。而且这张角度表只有十几个元素,Flash占用可以忽略。
我列个表,把三种方案的关键指标摆在一起,你一看就明白为什么Cordic在低成本FOC里是首选。
| 对比项 | 库函数atan2f | 查表法 | Cordic算法 |
|---|---|---|---|
| 运算类型 | 浮点 | 定点/浮点 | 纯定点 |
| 单次耗时(72MHz M3) | 约400-600周期 | 约20-50周期 | 约60-100周期 |
| 精度 | 极高 | 受表大小限制 | 可调,10次迭代约0.1度 |
| Flash占用 | 库本身几KB | 1-4KB | 角度表约32字节 |
| 代码复杂度 | 低 | 中 | 中 |
| 是否依赖FPU | 是(软浮点极慢) | 否 | 否 |
| 适合场景 | 带FPU的高端MCU | 精度要求不高的场合 | 低成本无FPU的FOC |
从表里能看出来,Cordic在耗时和资源占用上取得了最好的平衡。库函数虽然精度高但太慢,查表法虽然快但精度和Flash两头不讨好。Cordic用几十个字节的角度表,换来接近库函数的精度和可接受的耗时,这笔账算得过来。
2.2 Cordic旋转模式的几何直觉
Cordic算法有两种模式:旋转模式和向量模式。求atan用的是向量模式,但理解它最好从旋转模式入手。
想象你手里有一根向量,初始指向x轴正方向,也就是角度0。现在你想让它转到某个目标角度θ。你不知道θ是多少,但你可以一次次地试:先转45度,看看过头没有;如果过头了就转回来一点,转22.5度;再过头再转回来,转11.25度……每次转的角度都是上一次的一半。转着转着,向量就逼近θ了。而你每次转过的角度加起来,就是θ的近似值。
这就是Cordic旋转模式的精髓:用一系列已知角度的旋转,拼出未知角度。每次旋转的角度是atan(2^-i),i从0开始。这些角度有个特点,它们的和会收敛到一个有限值,大约是99.7度。所以Cordic能覆盖的角度范围是-99.7到+99.7度,超出这个范围需要做预处理。
那向量模式又是什么?向量模式是把旋转模式反过来用。旋转模式是“我知道要转多少,我去转”,向量模式是“我不管转多少,我要把向量转到x轴上”。具体做法是:给定一个向量(x, y),我通过一系列旋转,让y逐渐变成0。每次旋转的方向由当前y的符号决定:y是正的,就顺时针转;y是负的,就逆时针转。转到最后y趋近于0,向量就躺在x轴上了。而这一系列旋转的角度累加起来,正好就是原始向量与x轴的夹角,也就是atan(y/x)。
这个几何直觉非常重要,因为它解释了为什么Cordic不需要除法。你不需要先算y/x,而是直接对(x, y)这对坐标做旋转,让y归零。角度信息藏在旋转方向的序列里,最后累加出来就行。
2.3 定点化带来的精度与溢出权衡
Cordic在纸上算的时候是浮点的,但搬到MCU上必须定点化。定点化有两个核心问题:定标和溢出。
定标就是确定小数点在哪。通常我们把x和y都定标成Q15格式,也就是用16位有符号整数表示-1到1之间的小数,范围是-32768到32767。角度呢,为了统一,也定标成Q15,用32768表示180度,或者用16384表示90度。我习惯用后者,因为Cordic的角度范围本来就在±90度附近,用16384表示90度,算起来直观。
溢出是个更隐蔽的坑。Cordic迭代过程中,x和y的幅值会变化。理论上,旋转不改变向量长度,但定点运算有截断误差,迭代次数多了,幅值可能会略微增长。更关键的是,初始的x和y如果本身就接近满量程,迭代几次后可能就溢出了。解决办法有两个:一是初始值右移一位,留出余量;二是在迭代中做饱和处理。我一般用第一种,简单可靠。
还有一个细节:Cordic的增益。每次旋转,向量长度都会乘以sqrt(1+2^-2i),所有迭代乘起来,总增益大约是1.6468。也就是说,如果你输入一个幅值为A的向量,迭代完它的幅值会变成1.6468A。这个增益是固定的,可以在最后统一乘一个1/1.6468来补偿,也可以在初始化时就把输入除以1.6468。我通常选择后者,因为除法只做一次,比每次迭代都管用。
3. Cordic求atan的定点实现细节
3.1 角度表的生成与定标
Cordic的核心是一张角度表,存的是每次迭代要旋转的角度。第i次迭代的角度是atan(2^-i),单位是弧度。我们要把它转成定点角度。
假设我们用Q15表示角度,16384对应90度,那么1弧度对应多少?90度是π/2弧度,所以1弧度对应16384/(π/2) ≈ 10430。这个数记下来,后面生成表要用。
角度表的元素个数取决于迭代次数。迭代次数越多,精度越高,但耗时也越长。对于FOC,角度精度要求大概在0.5度以内就够了,因为电角度误差1度,转矩波动也就1%左右。我实测下来,10次迭代就能到0.1度以内,12次更稳。表的大小就是10到12个元素,每个元素2字节,总共20到24字节,完全可以接受。
生成表可以用Python或者MATLAB算好,直接写成C数组。我习惯用Python,几行代码就搞定:
import math def gen_cordic_table(n, scale=16384): # scale: 90度对应的定点值 table = [] for i in range(n): angle = math.atan(2**(-i)) # 弧度转定点:1弧度 = scale / (pi/2) fixed = int(round(angle * scale / (math.pi/2))) table.append(fixed) return table table = gen_cordic_table(12) print(table)跑出来大概是这样的:
const int16_t cordic_angle_table[12] = { 8192, // atan(1) = 45度 4836, // atan(0.5) ≈ 26.565度 2555, // atan(0.25) ≈ 14.036度 1297, // atan(0.125) ≈ 7.125度 651, // ... 326, 163, 81, 41, 20, 10, 5 };注意第一个值是8192,正好是45度,因为16384对应90度。后面的值逐次减半,但因为是atan不是线性,所以不是严格的1/2关系,但趋势是对的。
3.2 迭代核心:移位、判断、加减
有了角度表,迭代逻辑就非常清晰了。伪代码大概长这样:
输入:x, y(定点Q15) 初始化:z = 0(角度累加器) for i = 0 to N-1: if y > 0: x_new = x + (y >> i) y_new = y - (x >> i) z = z + angle_table[i] else: x_new = x - (y >> i) y_new = y + (x >> i) z = z - angle_table[i] x = x_new y = y_new 输出:z(即atan(y/x)的定点角度)这里有几个关键点要展开说。
第一,>> i是算术右移,对于有符号数,右移会保留符号位,这正是我们想要的。但要注意,当i=0时,>> 0就是不移位,直接加。所以第一次迭代的旋转角度是45度,对应atan(1)。
第二,判断条件是y > 0还是y >= 0?理论上都可以,但y >= 0会让y=0时也做正向旋转,可能导致最后角度偏大一点点。我一般用y > 0,让y=0时走负向分支,这样收敛更稳。
第三,迭代过程中x和y会更新,但更新用的是旧值还是新值?必须用旧值同时算新值,不能先更新x再用新x算y,否则就错了。代码里要用临时变量,或者像上面伪代码那样先算x_new和y_new再赋值。
第四,z的累加方向要和旋转方向一致。y>0时,向量在第一象限,角度是正的,所以z加;y<0时,角度是负的,z减。这个逻辑不能反。
3.3 象限预处理与后处理
Cordic向量模式有个限制:它只能处理-99.7到+99.7度范围内的角度。如果你的输入向量角度超出这个范围,比如在第二象限(90到180度),直接跑Cordic会得到错误结果。
解决办法是象限预处理。根据x和y的符号,把向量映射到第一象限或者第四象限,跑完Cordic后再把角度补回去。
具体规则是这样的:
- 如果x > 0且y >= 0:第一象限,角度在0到90度,直接跑Cordic,结果就是答案。
- 如果x < 0且y >= 0:第二象限,角度在90到180度。把x取反,变成(-x, y),这个向量在第一象限,跑Cordic得到角度α,真实角度是180 - α。
- 如果x < 0且y < 0:第三象限,角度在-180到-90度。把x和y都取反,变成(-x, -y),跑Cordic得到α,真实角度是α - 180。
- 如果x > 0且y < 0:第四象限,角度在-90到0度。把y取反,变成(x, -y),跑Cordic得到α,真实角度是-α。
用代码写出来就是:
int16_t cordic_atan2(int16_t x, int16_t y) { int16_t angle; uint8_t quadrant = 0; if (x < 0) { x = -x; quadrant |= 1; } if (y < 0) { y = -y; quadrant |= 2; } angle = cordic_atan_core(x, y); switch (quadrant) { case 0: break; // 第一象限 case 1: angle = 32768 - angle; break; // 第二象限,180度-α case 2: angle = -angle; break; // 第四象限 case 3: angle = angle - 32768; break; // 第三象限,α-180度 } return angle; }这里用32768表示180度,因为16384是90度。注意第二象限的180 - α,在定点里就是32768 - angle。第三象限的α - 180,就是angle - 32768。这些加减都是定点整数运算,很快。
3.4 增益补偿的两种做法
前面提到Cordic有1.6468的增益。这个增益对求角度本身没影响,因为角度只跟旋转方向有关,跟幅值无关。但如果你在同一个Cordic模块里还要输出幅值,那就必须补偿。
补偿有两种做法。一种是在迭代前把输入除以1.6468,也就是乘以0.6073。定点乘法可以用移位加来实现,比如0.6073 ≈ 0.5 + 0.0625 + 0.03125 + 0.0078 + ...,但这样太麻烦。更简单的做法是直接用一次乘法:x = (x * 19898) >> 15,因为19898/32768 ≈ 0.6073。一次乘法在M3上也就几个周期,可以接受。
另一种做法是在迭代后补偿,把结果乘以0.6073。效果一样,但要注意迭代后的值可能已经溢出,补偿前得先检查。
我一般用迭代前补偿,因为输入值通常不会满量程,乘完更安全。而且求角度的时候其实不需要补偿,只有需要幅值的时候才补。所以我的代码里把补偿做成可选的,用宏开关控制。
4. 在无感FOC里怎么用:从观测器到角度
4.1 滑模观测器输出到Cordic的衔接
无感FOC的角度估算,常见的一条链路是:滑模观测器估算反电动势 → 提取αβ轴分量 → 用Cordic求角度。
滑模观测器的输出通常是αβ轴的反电动势估算值,记为Eα和Eβ。这两个量跟转子角度θ的关系是:
Eα = -ω * ψf * sin(θ) Eβ = ω * ψf * cos(θ)其中ω是电角速度,ψf是永磁体磁链。所以Eβ对应x,-Eα对应y,那么atan2(-Eα, Eβ)就是θ。注意符号,不同观测器的定义可能不一样,调的时候要拿示波器看,确保角度跟实际转子位置对得上。
把Eα和Eβ送进Cordic之前,要做两件事:滤波和定标。滑模观测器出来的反电动势通常带高频抖振,直接送Cordic会导致角度抖动。我一般加一个一阶低通滤波,截止频率设在电流环带宽的1/5到1/10。定标就是把Eα和Eβ缩放到Q15范围,避免溢出。具体缩放系数要根据实际反电动势幅值和ADC采样范围来算,没有固定值。
这里有个实操心得:Cordic的输入不要用满量程。我一般让输入幅值在Q15的50%到70%之间,留出余量。因为迭代过程中幅值会增长1.6468倍,如果输入是满量程,迭代几次就溢出了。留余量之后,即使有滤波残留的波动,也不会溢出。
4.2 角度补偿与延迟对齐
Cordic算出来的角度是当前时刻的角度,但FOC的PWM更新有延迟,电流采样也有延迟。如果不做补偿,角度会滞后,导致转矩波动和效率下降。
补偿量怎么算?假设你的电流环频率是f_pwm,从采样到PWM更新有1.5个PWM周期延迟,那么角度滞后就是ω * 1.5 / f_pwm。ω是电角速度,单位是弧度/秒。把这个滞后角度加到Cordic输出上,就是补偿后的角度。
代码里可以这样写:
// 角度补偿 int16_t angle_comp = (int16_t)((omega * 1.5f / PWM_FREQ) * 16384.0f / (PI/2)); angle_final = angle_cordic + angle_comp;注意omega要用浮点算,因为它是物理量。算完补偿量再转成定点加到角度上。这个补偿量随转速变化,低速时很小可以忽略,高速时必须加,否则角度误差可能到十几度。
我实测过,在3000RPM的电角速度下,如果不补偿,角度滞后大约5到8度,电流明显增大,效率掉几个点。加上补偿之后,电流波形干净很多。
4.3 与PLL方案的对比取舍
除了Cordic直接求atan,还有一种常见做法是PLL锁相环。PLL不直接算角度,而是通过一个PI调节器,让估算角度跟踪实际角度。PLL的好处是自带滤波,角度平滑,对噪声不敏感。坏处是动态响应慢,转速突变时角度跟踪有滞后。
Cordic直接求atan的好处是零延迟,算出来就是当前角度,动态响应极快。坏处是对噪声敏感,输入有抖振,角度就抖。所以实际工程里,我经常把两者结合:用Cordic求角度,后面再跟一个轻量的PLL或者低通滤波,兼顾动态和稳态。
具体怎么选,看你的应用。如果是风机、水泵这种对动态要求不高的,PLL就够了,简单省事。如果是电钻、无人机这种要求快速响应的,Cordic直接求角度更合适,但前端滤波要做好。
5. 代码实战:从零搭一个Cordic atan模块
5.1 完整C语言实现
下面是我在实际项目里用的Cordic atan代码,定点Q15,12次迭代,带象限处理和增益补偿选项。直接可以往STM32或者国产M0上搬。
#include <stdint.h> #define CORDIC_ITERATIONS 12 #define CORDIC_SCALE 16384 // 90度对应的定点值 // 角度表,atan(2^-i)的定点表示 static const int16_t cordic_angle_table[CORDIC_ITERATIONS] = { 8192, 4836, 2555, 1297, 651, 326, 163, 81, 41, 20, 10, 5 }; // Cordic核心迭代,输入x,y为正数,输出0到90度的定点角度 static int16_t cordic_atan_core(int16_t x, int16_t y) { int16_t z = 0; int16_t x_new, y_new; uint8_t i; for (i = 0; i < CORDIC_ITERATIONS; i++) { if (y > 0) { x_new = x + (y >> i); y_new = y - (x >> i); z += cordic_angle_table[i]; } else { x_new = x - (y >> i); y_new = y + (x >> i); z -= cordic_angle_table[i]; } x = x_new; y = y_new; } return z; } // 完整的atan2,输入x,y为Q15定点,输出角度为Q15定点(16384=90度) int16_t cordic_atan2(int16_t x, int16_t y) { int16_t angle; uint8_t quadrant = 0; // 象限预处理 if (x < 0) { x = -x; quadrant |= 1; } if (y < 0) { y = -y; quadrant |= 2; } // 防止输入过大导致迭代溢出,右移一位留余量 x >>= 1; y >>= 1; angle = cordic_atan_core(x, y); // 象限后处理 switch (quadrant) { case 0: break; case 1: angle = 32768 - angle; break; case 2: angle = -angle; break; case 3: angle = angle - 32768; break; } return angle; } // 如果需要幅值,用这个函数,带增益补偿 int16_t cordic_magnitude(int16_t x, int16_t y) { int16_t x_new, y_new; uint8_t i; // 增益补偿:乘以0.6073 x = (int16_t)(((int32_t)x * 19898) >> 15); y = (int16_t)(((int32_t)y * 19898) >> 15); for (i = 0; i < CORDIC_ITERATIONS; i++) { if (y > 0) { x_new = x + (y >> i); y_new = y - (x >> i); } else { x_new = x - (y >> i); y_new = y + (x >> i); } x = x_new; y = y_new; } return x; // 迭代完y趋近0,x就是幅值 }这段代码有几个地方值得说明。
第一,cordic_atan_core里用了>> i,当i=0时就是不移位。但要注意,如果x或y是负数,算术右移会保留符号位,但我们已经在预处理里把x和y都变成正数了,所以没问题。
第二,x >>= 1; y >>= 1;这一步是留余量。如果你确定输入不会超过Q15的一半,可以去掉。但加上更保险,代价是精度损失一点点,因为右移会截断低位。实测下来,12次迭代加右移一位,精度仍然在0.2度以内,够用。
第三,增益补偿只在cordic_magnitude里做,cordic_atan2不需要。因为角度只跟旋转方向有关,跟幅值无关。这也是Cordic求角度比求幅值更简单的原因。
5.2 在STM32上的移植与优化
把这段代码搬到STM32上,基本不用改。但有几个优化点可以让它跑得更快。
第一,把角度表放到Flash里,用const修饰。这样不占RAM,而且M3的Flash访问速度跟RAM差不多,不会拖慢。
第二,迭代次数用宏定义,方便调整。如果CPU实在紧张,可以降到8次,精度大概0.5度,对很多应用也够了。如果CPU富裕,可以加到14次,精度到0.05度。
第三,如果MCU支持硬件乘法器,增益补偿的乘法可以用__SMULL或者直接乘,比移位加快。M3和M4都有单周期乘法,(int32_t)x * 19898 >> 15也就两三个周期。
第四,中断里调用的时候,把cordic_atan2声明为__RAM_FUNC或者放到RAM里执行。有些MCU的Flash有等待周期,中断里从Flash取指会慢。放到RAM里能快一点。不过对于F103这种72MHz的,Flash等待周期影响不大,可以忽略。
我实测过,在STM32F103上,72MHz主频,cordic_atan2一次调用大约70个周期,也就是不到1微秒。在20kHz的电流环里,占用CPU不到2%。相比之下,atan2f要500多个周期,差了7倍多。
5.3 实测波形与精度验证
代码写完了,怎么验证它是对的?我一般用两种方法。
第一种,静态测试。给一组已知的(x, y),比如(1000, 1000),理论角度是45度,对应定点8192。跑Cordic看输出是不是8192附近。再试(1000, 0),角度0度,输出应该是0。(0, 1000),角度90度,输出16384。(-1000, 1000),角度135度,输出24576。把这些点都测一遍,确认象限处理没问题。
第二种,动态测试。在FOC运行的时候,把Cordic输出的角度和实际转子角度(用编码器或者霍尔)对比,看误差多大。我一般用DAC输出角度值,示波器上看两条曲线的重合度。实测下来,12次迭代的Cordic,角度误差在0.1到0.2度之间,波形基本重合。
有个细节要注意:Cordic的输出是-180到180度,而FOC的Park变换需要0到360度或者-180到180度,看你的实现。如果Park变换用的是0到360度,那Cordic输出负角度时要加360度。这个转换别忘了,否则角度会跳变。
6. 踩过的坑与排查实录
6.1 角度跳变:象限判断的边界问题
我最早调Cordic的时候,遇到一个诡异的现象:电机低速转的时候角度挺稳,一到高速就偶尔跳一下,电流也跟着抖。查了半天,发现是象限判断的边界问题。
问题出在x或y接近0的时候。比如x=0,y=100,按说角度是90度。但我的预处理里,if (x < 0)不成立,所以x保持0。然后x >>= 1还是0。跑Cordic的时候,y >> i没问题,但x >> i全是0,迭代逻辑就退化了。最后算出来的角度不是90度,而是接近90度但有点偏。
解决办法是加一个死区判断。如果x或y的绝对值小于某个阈值,比如Q15的1%,就直接返回对应的90度或0度,不走Cordic。这样既避免了边界问题,又省了计算。
#define CORDIC_DEADZONE 328 // 约1% of 32768 if (x > -CORDIC_DEADZONE && x < CORDIC_DEADZONE) { return (y > 0) ? 16384 : -16384; // ±90度 } if (y > -CORDIC_DEADZONE && y < CORDIC_DEADZONE) { return (x > 0) ? 0 : 32768; // 0或180度 }这个死区加进去之后,角度跳变就没了。
6.2 精度不够:迭代次数与定标的选择
有朋友问我,他的Cordic角度误差有2到3度,问我怎么回事。我一看他的代码,迭代次数只有6次。6次迭代的理论精度大概是0.5度,但加上定点截断误差,实际可能到1度以上。再加上他的输入定标太满,没有留余量,迭代几次就溢出了,角度自然不准。
迭代次数和精度的大致关系是这样的:
| 迭代次数 | 理论精度(度) | 实际精度(度) | 耗时(周期,72MHz) |
|---|---|---|---|
| 6 | 0.5 | 1.0-1.5 | 约40 |
| 8 | 0.2 | 0.3-0.5 | 约50 |
| 10 | 0.1 | 0.15-0.2 | 约60 |
| 12 | 0.05 | 0.1-0.15 | 约70 |
| 14 | 0.02 | 0.05-0.1 | 约80 |
对于FOC,我推荐10到12次。10次够用,12次更稳。再往上加,精度提升有限,耗时增加明显,不划算。
定标方面,输入x和y的幅值最好在Q15的30%到70%之间。太小了精度差,太大了溢出。我一般让输入幅值在10000到20000之间(Q15范围是-32768到32767)。
6.3 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决办法 |
|---|---|---|---|
| 角度跳变 | 象限边界x或y接近0 | 打印x,y和角度,看跳变时x,y的值 | 加死区判断 |
| 角度误差大 | 迭代次数不够 | 增加迭代次数看误差是否减小 | 加到10-12次 |
| 角度误差大 | 输入溢出 | 检查迭代中x,y是否超过32767 | 输入右移一位留余量 |
| 角度抖动 | 输入噪声大 | 示波器看输入信号 | 加低通滤波 |
| 角度滞后 | 未做延迟补偿 | 对比实际转子和估算角度 | 加角度补偿 |
| 高速时角度错 | 迭代溢出 | 高速时打印x,y | 减小输入幅值或增加余量 |
| 角度符号反 | 观测器符号定义不同 | 手动转电机看角度变化方向 | 交换x,y或取反 |
| 计算耗时太长 | 迭代次数太多 | 用GPIO翻转测耗时 | 降到10次或优化代码 |
这张表是我调Cordic的时候一点点攒出来的,基本上覆盖了90%的问题。遇到角度不对,先查表,再动手。
6.4 几个容易被忽略的细节
第一个细节:Cordic的角度输出是-180到180度,但你的Park变换可能需要0到360度。如果Park变换用的是sin(θ)和cos(θ),那-180到180度没问题,因为sin和cos是周期函数。但如果你的代码里用角度做索引查表,那就得转成0到360度。转换方法很简单:if (angle < 0) angle += 32768;,注意32768对应360度。
第二个细节:Cordic的输入x和y必须是同一时刻的。如果你从观测器里读Eα和Eβ,要确保它们是同一控制周期算出来的。如果Eα是上一周期的,Eβ是本周期的,角度就会错。这个在代码里要保证,别在中断里读全局变量读串了。
第三个细节:Cordic的迭代次数不要动态改变。有些朋友想省时间,低速时用6次,高速时用12次。这样角度精度会随转速变化,导致电流环参数不好调。我建议固定迭代次数,用滤波和补偿来应对不同转速。
第四个细节:Cordic的角度表要定期校验。如果你用Python生成的表,换了个编译器或者优化等级,定点舍入可能不一样。我一般把表打印出来,跟理论值对一遍,确认误差在1个LSB以内。
7. 还能怎么优化:进阶思路
7.1 混合方案:Cordic加查表
Cordic虽然快,但12次迭代还是要70个周期。如果你的电流环跑到40kHz甚至更高,70个周期就有点紧张了。这时候可以考虑混合方案:用Cordic算粗角度,再用查表做精细补偿。
具体做法是:Cordic只迭代6次,得到大约1度的精度。然后根据Cordic输出的角度,去查一张精细的角度修正表,把误差补到0.1度。修正表可以很小,比如64个点,覆盖0到90度,每个点存一个修正量。这样总耗时可能降到50个周期,精度还保持在0.2度以内。
这个方案我在一个高速电主轴项目里用过,效果不错。但要注意,修正表要根据实际Cordic的误差特性来生成,不能随便拍脑袋。
7.2 用DSP指令加速
如果你的MCU是Cortex-M4或者M7,带DSP指令集,那可以用__SMLAD、__SSAT这些指令来加速Cordic。比如__SMLAD可以一次做两个乘加,虽然Cordic主要是移位和加减,但增益补偿那一步可以用上。
M7还有双发射流水线,Cordic的迭代可以部分并行。不过这些优化比较底层,收益也就10%到20%,除非你实在缺CPU,否则没必要折腾。
7.3 角度跟踪观测器替代Cordic
最后说一个思路上的替代方案:用角度跟踪观测器直接输出角度,跳过atan。比如用锁相环或者扩展卡尔曼滤波,直接把角度作为状态量估算出来。这样就不需要Cordic了,但计算量可能更大,而且调参更复杂。
我个人的经验是:如果CPU够用,PLL更省心;如果CPU紧张,Cordic更直接。两者没有绝对优劣,看项目需求。我做过一个风机项目,用的就是PLL,因为风机对动态要求低,PLL的平滑性更好。但做电钻的时候,必须用Cordic,因为要快速响应负载突变。
8. 个人实操体会
Cordic这个算法,刚看原理的时候觉得挺绕,但真正写一遍代码,跑一遍波形,就发现它其实很朴素:用已知角度的旋转,去逼近未知角度。没有魔法,就是移位、判断、加减,反复迭代。它的美在于用最简单的运算,解决了嵌入式里一个很实际的问题。
我在无感FOC里用Cordic求atan,前后调了大概两周。第一周在搞明白原理和写代码,第二周在调参数和排查问题。踩过的坑主要是三个:象限边界、输入溢出、延迟补偿。这三个坑填上之后,角度就稳了,电机跑起来电流波形很干净,效率也比之前用查表法高了几个点。
如果你正在调无感FOC,角度总是不对,我建议你先别急着换观测器,先把Cordic的输入输出打印出来,看看x和y对不对,象限处理对不对,迭代次数够不够。很多时候问题不在算法本身,而在数据衔接和定标上。
代码我给的是最朴素的版本,没有做太多优化,因为我觉得可读性比那10%的性能更重要。你拿去之后,可以根据自己的MCU和项目需求,调整迭代次数、定标系数、滤波参数。调的时候用示波器看角度波形,跟实际转子位置对比,慢慢就能找到最合适的参数。
最后分享一个小技巧:在Cordic的迭代循环里,把>> i改成>> (i & 0x0F),可以防止i超过15时移位行为未定义。虽然我们的迭代次数不会超过15,但加上这个保险,代码更稳。另外,如果你用的是IAR或者Keil,开-O2优化,编译器会自动把>> i优化成LSR或者ASR指令,不用手动写汇编。