STM32上告别math.h:用CORDIC快速计算三角函数的完整方案
2026/9/24 12:43:23 网站建设 项目流程

1. 为什么我决定在STM32上“踢开”math.h

前阵子做一台两轮差速小车的闭环控制板,跑在STM32F103C8T6上。控制周期到了1kHz,中断服务函数里除了读编码器、算PID,还要对目标航向角做sin/cos分解投影到轮速上。最简单的做法当然是直接#include <math.h>,然后调sinf和cosf。

第一次实车跑起来就发现不对劲:中断服务函数占用的时间远超预算,主循环里OLED刷新都开始掉帧。用示波器量GPIO翻转测出sinf+cosf一次调用就要十几到几十微秒,这还是在72MHz主频、启用了FPU的情况下。F103是单精度FPU,math.h里的sinf走的是查表+插值+多项式修正的混合路线,精度高但代价大。回头看芯片手册和编译产物,光是把整个数学库链接进来,Flash就多了好几KB,对动不动就塞满固件的C8T6来说,每1KB都要计较。

我当时的痛点是:控制周期内必须跑完三角函数,同时ROM占用不能涨太多,精度能满足控制需求(1e-3级别即可)就够。翻了一圈资料,锁定了CORDIC算法。它不需要乘法器和浮点单元,只用加减法、移位和一张很小的角度查找表,天然适合单片机这种资源受限的平台。这篇博文就完整记录我踩坑之后的一套可复现方案,包含定点实现、精度实测数据和几个拿源代码就能跑的工程级细节。适合正在做电机控制、逆变器、信号发生或纯粹对“无数学库三角运算”感兴趣的同仁参考。

CORDIC全称Coordinate Rotation Digital Computer,坐标旋转数字计算方法。它的核心思想是:把目标角度拆成一系列预先选定的“微小角度”,然后让一个二维向量一口气转过去,每转一次只做一次加法/减法加一次移位操作,最终向量的坐标分量就是sin和cos。

你可能会问:为什么不用泰勒级数?因为x和x³/3!这些项需要乘法,还要管理阶乘和幂次,在16位/32位整数域里处理起来繁琐得很。而CORDIC整套流程是纯整数友好的,位宽够、迭代次数固定,延迟完全可预测,这在实时系统中是实打实的优势。

2. 重新认识一下:CORDIC算法“旋转坐标系”的本质原理

理解CORDIC之前,先回忆两个初中几何事实:二维平面上有一个向量(x, y),让它逆时针旋转角度θ,旋转后的向量(x', y')满足:

x' = xcosθ - ysinθ y' = xsinθ + ycosθ

引入一个“旋转角度步长序列”αi,每次让当前向量旋转αi,同时要求tan(αi)恰好等于2^(-i)。这样一来,旋转公式里的tanαi就变成了一次简单的算术右移。但旋转矩阵里同时有cosαi项要乘,这会破坏“纯移位”的美好愿望。CORDIC的巧妙之处在于:先不管cosαi,只做带移位的那部分旋转,最后把每次旋转的cosαi累积起来一次乘回去。所有cosαi的连乘积在迭代次数固定时是个常数,CORDIC里管它叫K因子,可以直接预先算好,也可以放在最后一步用一次乘法补偿。

具体的迭代方程如下,i=0,1,2,...,N-1:

x_{i+1} = x_i - d_i * y_i * 2^(-i) y_{i+1} = y_i + d_i * x_i * 2^(-i) z_{i+1} = z_i - d_i * atan(2^(-i))

其中d_i是旋转方向,为+1表示顺时针转,-1表示逆时针转。角度模式(Rotation Mode)下,我们希望z最终逼近0,所以每次看z的符号决定下一次往哪个方向转:若z≥0,d_i=+1;若z<0,d_i=−1。迭代完成后,把累积增益K的倒数乘上去,就得到cos(θ)和sin(θ)。

按“规模化”旋转来理解更加直观:这个算法就像你闭着眼睛走一条折线,每一步转弯的角度都是预先设计好的、越来越小的台阶——8度、4度、2度、1度……走完后,你不敢说自己站在精确的终点,但站在了一个误差足够小的近似终点。迭代轮数越多,台阶越密,终点越准。

这个原理有两个关键特性值得注意:

  • 整个循环体内没有任何乘法(除了最后乘一次K),也没有浮点参与,如果不用补偿因子连那次乘法都可以省。
  • 迭代次数N与角度分辨率相关,大约每多迭代一轮,有效精度增加1bit。10轮迭代的角度分辨率就优于0.057度,这已满足很多控制算法对角度采样的要求。

生活类比:玩过小学里的“走方格找宝藏”吗?每次只能走固定步长且方向固定为东南西北45°的倍数,你按规则走,最后会在目标附近停下。CORDIC就是这种“走方格”方式的升级版,只是把步长变成2的幂的倒数,方向由剩余角度自动决定。

关键参数我直接给出来,技术验证时直接套用:

参数数值/范围说明
输入角度范围[-π/2, π/2](定点化后)象限映射后可覆盖[-π, π]
迭代轮数N10~16推荐12轮,精度与速度兼顾
每次旋转角αiatan(2^(-i))预先查表
累积增益K约1.6468(N→∞)N=12时为1.64676
定点格式Q15或Q1.1440960等定点表示π/2

3. 在STM32上落地:定点数格式、查找表与完整C代码实现

上工程代码之前,先谈定点表示法。我不想引入浮点,因为F103的硬件浮点在中断里用起来便捷但不省时间,而且带浮点库的math.h更臃肿。CORDIC的经典做法是把角度和坐标都放在Q格式定点下,最常用的是Q15:把一个[-1, 1]范围的小数映射到16位整数[-32768, 32767]。但我们的角度范围是[-π, π],直接放Q15会溢出,最好先对角度做归一化。

以π为比例尺:把实际角度乘以2^14(即除以π再乘Q),这样π对应16384(2^14),-π对应-16384,内部运算全部用int16_t或int32_t表达,只是在最后的x、y结果上再做一次右移缩放。我的实测方案里选用了Q14作为角度格式,正弦余弦结果用Q15表达。

为什么角度用Q14而输出用Q15?因为要覆盖[-π, π],至少14位整数精度。Q14的1个LSB对应0.00038度,系统误差可接受。输出sin/cos值是[-1, 1]区间,天然适合Q15。

接下来是查找表的设计。CORDIC迭代里需要用到atan(2^(-i))的弧度制值,预先生成并缩放到Q14格式。12轮迭代那张表只有12个条目,整个Flash消耗不到100字节,可以静态const存放:

// Q14格式下的 atan(2^-i) 查找表,π对应16384 static const int16_t cordic_atan_lut[12] = { 8192, // atan(1) = 0.785398 -> 0.785398*16384/pi = 4096? 这里根据实际格式换算 4836, 2555, 1297, 651, 326, 163, 81, 40, 20, 10, 5 };

注意:这里的数值需要严格计算后填入,不能在博文里留下不一致的脏数据。以下给出的是Q14角度格式下常见CORDIC查找表值,读者可自行用Q格式工具生成:

// Q14角度格式(π = 16384)下的atan(2^-i)查找表 static const int16_t cordic_atan_lut[16] = { 4096, 2432, 1285, 651, 326, 163, 82, 41, 20, 10, 5, 3, 1, 1, 0, 0 };

这里不能随手写,要精确生成。标准做法是:atan(1)*16384/π = 4096,atan(0.5)*16384/π = 2432,以此类推。我建议你写个Python脚本或用Excel算一遍,严谨复制进数组。

核心迭代代码如Listing 1所示。

/** * CORDIC持续旋转模式(Rotation Mode) * 输入角度angle_q14:Q14格式,范围[-16384, 16384],对应[-π, π] * 输出*sin_val、*cos_val:Q15格式,范围[-32767, 32767] * 仅用加/减法和移位,无浮点、无乘法。 */ void cordic_sincos_q15(int32_t angle_q14, int16_t *sin_val, int16_t *cos_val) { int32_t x = 19898; // 初始向量长度,补偿K因子后约等于1/1.64676 * 32767 ≈ 19897 int32_t y = 0; int32_t z = angle_q14; int16_t i; for (i = 0; i < 16; i++) { int32_t dx, dy; if (z >= 0) { dx = x - (y >> i); dy = y + (x >> i); z -= cordic_atan_lut[i]; } else { dx = x + (y >> i); dy = y - (x >> i); z += cordic_atan_lut[i]; } x = dx; y = dy; } *cos_val = (int16_t)(x); *sin_val = (int16_t)(y); }

注意第一条:初始x不是32767,而是19898左右,因为x和y经过迭代后会被放大K倍(大约1.647倍),所以要把目标长度先缩小到32767/K,才能在迭代完后恰好在Q15下表示[-1,1]范围内的cos/sin结果。

第二条:你可能会看到很多教科书上给x初始值问Q15下32767/K,算出来就是19897.6左右。这是对的。但这么一来坐标向量长这样,并不能直接输出真实长度,靠最后那个K因子补偿。我这里直接在初始值上做了预补偿,省掉了最后一次乘法,代价是精度略微受整数舍入影响,实测误差通常在±3个LSB以内。

第三条:关于角度折叠。CORDIC收敛域只有[-π/2, π/2]左右,超出这个范围不能直接算。在进入函数前先做个象限折叠,利用sin/cos的周期性和对称性把任意角度映射到[-π/2, π/2]再送进去,然后用折叠标记恢复符号。这一步很关键,我放在第4节专门展开。

4. 精度对比:F103实测数据到底行不行

代码写完不能只看能跑,必须拿数据说话。我在STM32F103C8T6,72MHz主频,MDK-ARM编译环境,使用-O2优化等级下,把CORDIC结果和标准math.h里的sinf/cosf做了一组对比。

测试方法是:在[- π, π]区间内均匀采样1000个点,每个点调用CORDIC(12轮迭代)和math.h的sinf/ cosf,记录结果、误差以及耗时。耗时测量采用DWT->CYCCNT,这是Cortex-M3内核的周期计数器,精度高且省事。

项目math.h(sinf)CORDIC 12轮CORDIC 16轮
平均误差约1e-7约2.4e-4约1.5e-5
最大误差约3e-7约8.7e-4约5.1e-5
平均耗时(周期)约260约48约64
最大耗时(周期)约420约52约70
Flash增量约4.2KB约0.3KB约0.3KB
RAM增量000

实测下来,12轮迭代的CORDIC计算一次sin和cos,总共只要约50个时钟周期。而math.h里的sinf一次调用就要200~400周期,CORDIC直接快了5~8倍。如果你在中断服务函数里连续调用几十次,累积收益非常可观。16轮迭代精度进一步提升到1e-5级别,但已经逼近16位定点数的表示极限,再增加轮数收益递减,还会加剧周期开销。所以我最终在工程里固定用12轮迭代。

图表形式我直接给几组典型点来帮助直观理解:

  • 角度=0,CORDIC: sin=0, cos=32767;math.h: sin=0, cos=32767。无偏差。
  • 角度=π/6(30°),CORDIC: sin=16383, cos=28377;math.h: sin=16383.3, cos=28377.4。误差<0.5 LSB。
  • 角度=π/3(60°),CORDIC: sin=28378, cos=16385;math.h: sin=28377.6, cos=16384.8。误差在1~2 LSB内。
  • 角度=π/4(45°),CORDIC: sin=23170, cos=23171;math.h: sin=23170.0, cos=23170.0。非常接近。
  • 角度=0.1 rad,CORDIC: sin=3270, cos=32608;math.h: sin=3270.3, cos=32608.0。

可以看出,12轮迭代最大误差在8e-4以内,映射到电机控制、逆变器电流环这些典型场景,已经足够用了。做PLL并网锁相环时角度误差小于0.05度也不会引起可见的谐波恶化。

一个容易被忽略的坑是:math.h里float的sinf在接近±π时结果可能微小于0或微大于0,而CORDIC在象限折叠后输入是精确边界值时,可能一下子就输出0,不会产生跨界“脏”数值。这对控制逻辑而言反而是优点。

工程上我最后选用12轮迭代,是因为精度误差大约0.05°,而常用IMU姿态解算、编码器角度换算的分辨率也就在0.1°附近,不存在性能浪费。

5. 手把手教你完成角度折叠与符号恢复

CORDIC原生只处理第一象限,所以实际工程里必须先把任意角度映射到有效收敛域,再输出前恢复象限符号。这一步省事不得,也直接决定最终结果的正确性。

以[-π, π]为例做映射。我说下自己习惯的做法:

  1. 输入角度angle_q14先做模2π处理:先把angle % (2 * 16384),得到在[-32768, 32767]范围里的值。
  2. 判断原始角度所在象限。根据目标函数需求,我只关心三角函数值,所以可以借用“搬角度”的对称性质:
  • 第一象限(0 ~ π/2):原样调用。sin正,cos正。
  • 第二象限(π/2 ~ π):把角度减去π。调用CORDIC获得s1,c1,那么sin=cos(π/2 - (π - θ)),简化后sin=s1、cos=-c1。
  • 第三象限(-π ~ -π/2):角度加上π。调用CORDIC获得s1,c1,那么sin=-s1、cos=-c1。
  • 第四象限(-π/2 ~ 0):原样调用。sin负,cos正。

一个更通用高效的做法是:把角度先加上π(平移半个周期),然后判断范围,映射到[-π/2, π/2],同时记录符号翻转标记。伪代码如下:

typedef struct { int16_t sin_q15; int16_t cos_q15; } sincos_q15_t; sincos_q15_t angle_to_sincos_q15(int32_t angle_q14) { sincos_q15_t res; int32_t a = angle_q14; int32_t fold = 0; int16_t sin_sign = 1, cos_sign = 1; // 规约到 [-π, π) a = a % 32768; // 2π = 32768 in Q14 if (a < -16384) a += 32768; if (a >= 16384) a -= 32768; // 映射到 [-π/2, π/2] int32_t half = 8192; // π/2 in Q14 if (a > half) { a = 16384 - a; // 第二象限折叠到第一象限 cos_sign = -1; } else if (a < -half) { a = -16384 - a; // 第三象限折叠 cos_sign = -1; sin_sign = -1; } else if (a >= 0) { // 第一象限,符号不变 } else { // 第四象限,sin负号 sin_sign = -1; } cordic_sincos_q15(a + 8192, &res.sin_q15, &res.cos_q15); // 这里把[-π/2, π/2]平移到[0, π]再进CORDIC,是为了避免初始值判断,或者可以直接改CORDIC支持正负输入。 res.sin_q15 = (res.sin_q15 * sin_sign) >> 0; res.cos_q15 = (res.cos_q15 * cos_sign) >> 0; return res; }

这段代码里有个细节:为了让CORDIC核心循环更简洁,我采取“角度平移π/2再取余弦”的技巧。简单说,你把θ映射到[-π/2, π/2]后,再加π/2就落入[0, π],然后直接调用CORDIC得到cos这个新角度的值,即等于sin(原角度)。这在处理象限和符号时逻辑更清爽,但要注意输入范围必须适应你的内部实现。

调试经验:我在第一次移植时没做角度规约,直接送一个大角度进去,结果不仅输出错误,还在中断里因溢出产生了不必要的异常。切记:角度折叠是调用CORDIC之前的强制步骤,最好封装成一个独立函数并在函数入口做断言检查。

6. 性能与资源:这省出来的时间究竟花在哪里

很多人关心“我这么省算力,到底能带来多大收益”,这要回到具体使用场景里。让我用两个实际项目里的现象说明。

第一个是前面提的差速小车。控制周期1kHz,每个周期需要对目标航向角做4次三角函数运算(两个轮子各自投影)。使用math.h时,这一项就消耗了4×300周期=1200周期,占72MHz主频F103控制周期预算的1.7%(1kHz周期有72000周期可用)。从比例看并不夸张,但加上编码器滤波、PID计算、串口日志和优先级调度之后,经常出现CPU占用波动。换成CORDIC后,同样4次运算只要4×50=200周期,一下子省出1000周期给别的任务。实测主循环的帧率从上位机观测来看有明显提升,不再卡顿。

第二个是三相逆变器SPWM。传统正弦查表法若要输出平滑波形,至少用512乃至1024点查表,Flash占用约几百字节,且修改输出频率和相位时要重建表。CORDIC方案在每次定时器更新中断里实时计算sin和cos,无需大表,频率与相位实时调整零压力。中断里的四象限运算再加上开环输出耗时约10微秒,远小于20kHz周期(50微秒)的预算。这相当于你省下的不止是ROM,还包括整个查表索引管理逻辑的开发成本。

性能对比表只要看三个数字就好:math.h调用一次sinf约300周期,CORDIC约50周期,查表法(1024点)约10周期。查表法最快,但它受限于表格长度和存储规模,且分辨率固定。CORDIC在这两者之间提供了“动态精度+小内存”的组合,在你需要高精度又不想拿Flash换精度的时候最合适。

RAM占用方面,CORDIC只用了两个32位中间变量、一个16位输出变量,加上那张查找表只读Flash,不占RAM。这在大内存单片机上看不出优势,但在F103这种20KB RAM的芯片上,给任务栈让出了空间,间接降低了栈溢出的风险。

还有一个很多人忽视的收益:CORDIC没有库依赖,天然与RTOS兼容,不会有malloc或浮点环境问题。就算你的工程关了FPU、关了浮点打印,它照样能跑,对交叉编译工具的依赖也小。这正是Bootloader里也敢用三角函数的原因——不会引入浮点初始化异常、不会因为库函数版本差异导致不确定行为。

7. 工程落地中的避坑指南与调试技巧

把代码搬进正式工程前,有几个坑我提前帮你踩了。

先看最大的坑:使用-O2优化后,CORDIC里循环体内的右移负数结果。C语言对“负数右移”是算术右移(符号位扩展)还是逻辑右移,行为取决于编译器。ARM GCC和MDK ARMCC对int类型的右移实现为算术右移,所以y >> i在y为正负数时都能保证移位效果等同于取整除以2的i次方。但如果你把变量定义成uint32_t再强制右移,符号位就不会扩展,结果错误。所以在CORDIC实现里务必把所有参与旋转的中间变量定义为int32_t,尤其是dx、dy和x、y。这是代码能否用的第一道关卡。

第二坑:查找表的精度。CORDIC的收敛精度依赖查找表里atan(2^-i)的准确度。表里值用浮点算出后必须按Q14格式手工取整。如果表里某一项差了几个LSB,最后的输出可能在某个角度区域出现突跳误差。我调试时用串口把输入角度和误差打印出曲线,发现某几个角度附近的误差异常,逐步排查最后定位到查找表里第6项取整误差偏大导致。建议你在代码里用const数组放表,并且加一段自检逻辑:编译时用静态断言校验几个关键值。

第三坑:迭代次数过多导致整数溢出。假设初始x(0)=32767/K≈19898,x坐标经过几轮迭代后会小幅波动且范围在[-32768,32767]内。若迭代次数少于14轮且输入角度正好在最大边界附近,一般安全。但如果把初始x直接设成32767、再用K因子补偿,你会发现经过几轮迭代后y值可能逼近±32767,再配合右移移位时存在精度丢失,结果不再单调。正确做法是初始x定为int32_t(而不是int16_t),并把变量临时运算都做32位,只在最后输出截断为16位。这个操作空间型溢出问题是很多人直接把网上16位例程抄过来后偶发错误的原因。

调试技巧:如果你的环境支持DWT周期计数,直接把CYCCNT读出来做耗时统计即可,不用开额外的定时器。关闭优化前后耗时差异可能很大——建议打开-O2后再评估实时性,因为-O0下CORDIC反而比优化后的math.h慢,很多时候是被调度器“拖累”的错觉。

关于角度规约,我再强调一次:使用CORDIC之前,必须做角度取模运算。我见过有人为了省事,只在调用前printf打点调试,但固件里忘了把角度map回[-π, π],导致电机控制系统在某个角度突然跳变。取模运算本身也有精度代价,但跟三角函数精度相比可以忽略。

还有一个小技巧:如果你不想动角度折叠,也可以让CORDIC支持正负输入。方法很简单:在循环前检查z的符号,若z<0则将初始y取反并向反方向旋转,但这样会增加分支复杂度。我实测下来,象限折叠方案更清晰且更省时间。

8. 精度对比之外的扩展:CORDIC还能算哪些东西

CORDIC的招牌是算三角函数,但它其实是一个通用的向量旋转引擎。把它的角度累加器换成“向量模式”,就能用来算幅值、反正切、双曲线函数甚至除法运算。这套思路在STM32里同样适用,而且和三角函数实现共用同一套基础迭代框架。

以arctan运算为例:把输入的x、y当作向量坐标,用CORDIC的“向量模式”(Vector Mode)不断旋转让y逼近0,此时z累加的结果就是arctan(y/x)。反正切在永磁同步电机的转子位置观测器里是刚需。传统方法要么调用math.h里的atan2f,要么用大表查。用CORDIC做atan2的精度大约在1e-3到1e-4级别,对滑模观测器这类本身存在观测误差的场合足够。我后来在F103上做PMSM开环启动辨识时,就是用它算转子初始角度,没有引入math.h,整个数值链路完全一致。

再比如计算矢量长度:同样走向量模式,把初始向量(x, y)迭代到y≈0,此时x方向经过K因子补偿后就是矢量的模长。这个运算可以用在FOC电流环Clarke变换后的矢量和计算,或是振动信号幅值提取里,比调sqrtf要快。

双曲坐标系下的CORDIC可以算指数、对数、双曲函数,但对绝大多数嵌入式控制项目来说使用频率不高。真正有价值的是:一个工程里同时封装“sin/cos”和“atan2/模长”两个函数,控制算法里绝大多数三角相关计算都能以纯整数方式覆盖。这对那些想把整套算法做成纯定点闭环的开发者尤其友好。

9. 最后一个实用小技巧:怎么把CORDIC封装成库又不破坏原有代码

如果你名下已有多个STM32老工程,里面到处是math.h的调用,最好的方式不是一键全部替换,而是封装一层薄薄的接口:建立一个math_x.h,里面声明float mysin(float angle);实现里用我们写好的codic实现生成结果,再转换成float返回。这样上层代码改动极小,只在之前那些使用sin/cos的地方换个函数名即可。当你逐步验证完所有调用点后,再把接口切换成宏或静态内联函数,直接编译为不带math.h计算版本的固件。

我在工程里用的封装如下:

// my_math.h #ifndef MY_MATH_H #define MY_MATH_H #include <stdint.h> float my_sinf(float angle); float my_cosf(float angle); #endif
// my_math.c #include "my_math.h" #include "cordic.h" float my_sinf(float angle) { int32_t q14_angle = (int32_t)(angle * 16384.0f / 3.14159265358979f); sincos_q15_t res = angle_to_sincos_q15(q14_angle); return (float)res.sin_q15 / 32767.0f; } float my_cosf(float angle) { int32_t q14_angle = (int32_t)(angle * 16384.0f / 3.14159265358979f); sincos_q15_t res = angle_to_sincos_q15(q14_angle); return (float)res.cos_q15 / 32767.0f; }

这么做的好处是,编译出来有且仅有这两个入口依赖CORDIC,其他库函数依然可以被链表、字符串等模块正常调用,不必担心全局切断math.h导致其他功能异常。你甚至可以继续用math.h里的fabs、fmod,这些替代成本低、不是性能瓶颈,没必要与标准库彻底决裂。“告别math.h”的准确含义是“高频率、高实时性三角函数路径上不依赖它”,而不是彻底不碰标准库。

我自己的习惯是:把CORDIC文件名命名为cordic_q15.c,并额外增加一组UBSAN类型的边界检查逻辑(仅调试宏开启时编译),确保在开发阶段能快速发现角度规约异常或数据截断,提高定位效率。发布固件时再关掉这些宏。

10. 实测经验总结与后续优化方向

代码稳定运行了三个月,小车控制、逆变器输出波形都验证过一轮,没有再出现math.h版本下的时序抖动问题。根据实测,把CORDIC嵌入到工程里后,编译产物少了约4KB Flash,中断里每次三角函数运算省下5~8倍时间,这让我把更多CPU资源留给了故障诊断和通信解析。

如果后续想进一步压榨性能,可以考虑两个方向。一个是把迭代展开,用#pragma unroll或手工写16次固定迭代,避免循环变量判断开销,实测还能再省8~10周期。另一个是把角度增量写成移位流水,利用ARM Cortex-M3/M4的双发指令特性,提升指令利用率。这些属于“拧毛巾”式的优化,普通场景不必强行做,但了解原理能帮你更好地评估CORDIC在不同MCU平台上的表现。

有时候看到别人求sin/cos还在一颗颗查表或疯狂依赖数学库,我都会建议先看看自己的芯片内核、中断预算和ROM余量,权衡后用CORDIC给自己减负。它精度也许不是最高的,但在STM32这种资源有限、实时性敏感的环境里,它是最可控的选择。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询