ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32上告别math.h:用CORDIC快速计算三角函数的完整方案

STM32上告别math.h:用CORDIC快速计算三角函数的完整方案 1. 为什么我决定在STM32上“踢开”math.h前阵子做一台两轮差速小车的闭环控制板跑在STM32F103C8T6上。控制周期到了1kHz中断服务函数里除了读编码器、算PID还要对目标航向角做sin/cos分解投影到轮速上。最简单的做法当然是直接#include math.h然后调sinf和cosf。第一次实车跑起来就发现不对劲中断服务函数占用的时间远超预算主循环里OLED刷新都开始掉帧。用示波器量GPIO翻转测出sinfcosf一次调用就要十几到几十微秒这还是在72MHz主频、启用了FPU的情况下。F103是单精度FPUmath.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因子可以直接预先算好也可以放在最后一步用一次乘法补偿。具体的迭代方程如下i0,1,2,...,N-1x_{i1} x_i - d_i * y_i * 2^(-i) y_{i1} y_i d_i * x_i * 2^(-i) z_{i1} z_i - d_i * atan(2^(-i))其中d_i是旋转方向为1表示顺时针转-1表示逆时针转。角度模式Rotation Mode下我们希望z最终逼近0所以每次看z的符号决定下一次往哪个方向转若z≥0d_i1若z0d_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.6468N→∞N12时为1.64676定点格式Q15或Q1.1440960等定点表示π/23. 在STM32上落地定点数格式、查找表与完整C代码实现上工程代码之前先谈定点表示法。我不想引入浮点因为F103的硬件浮点在中断里用起来便捷但不省时间而且带浮点库的math.h更臃肿。CORDIC的经典做法是把角度和坐标都放在Q格式定点下最常用的是Q15把一个[-1, 1]范围的小数映射到16位整数[-32768, 32767]。但我们的角度范围是[-π, π]直接放Q15会溢出最好先对角度做归一化。以π为比例尺把实际角度乘以2^14即除以π再乘Q这样π对应163842^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/π 4096atan(0.5)*16384/π 2432以此类推。我建议你写个Python脚本或用Excel算一遍严谨复制进数组。核心迭代代码如Listing 1所示。/** * CORDIC持续旋转模式Rotation Mode * 输入角度angle_q14Q14格式范围[-16384, 16384]对应[-π, π] * 输出*sin_val、*cos_valQ15格式范围[-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实测数据到底行不行代码写完不能只看能跑必须拿数据说话。我在STM32F103C8T672MHz主频MDK-ARM编译环境使用-O2优化等级下把CORDIC结果和标准math.h里的sinf/cosf做了一组对比。测试方法是在[- π, π]区间内均匀采样1000个点每个点调用CORDIC12轮迭代和math.h的sinf/ cosf记录结果、误差以及耗时。耗时测量采用DWT-CYCCNT这是Cortex-M3内核的周期计数器精度高且省事。项目math.hsinfCORDIC 12轮CORDIC 16轮平均误差约1e-7约2.4e-4约1.5e-5最大误差约3e-7约8.7e-4约5.1e-5平均耗时周期约260约48约64最大耗时周期约420约52约70Flash增量约4.2KB约0.3KB约0.3KBRAM增量000实测下来12轮迭代的CORDIC计算一次sin和cos总共只要约50个时钟周期。而math.h里的sinf一次调用就要200~400周期CORDIC直接快了5~8倍。如果你在中断服务函数里连续调用几十次累积收益非常可观。16轮迭代精度进一步提升到1e-5级别但已经逼近16位定点数的表示极限再增加轮数收益递减还会加剧周期开销。所以我最终在工程里固定用12轮迭代。图表形式我直接给几组典型点来帮助直观理解角度0CORDIC: sin0, cos32767math.h: sin0, cos32767。无偏差。角度π/630°CORDIC: sin16383, cos28377math.h: sin16383.3, cos28377.4。误差0.5 LSB。角度π/360°CORDIC: sin28378, cos16385math.h: sin28377.6, cos16384.8。误差在1~2 LSB内。角度π/445°CORDIC: sin23170, cos23171math.h: sin23170.0, cos23170.0。非常接近。角度0.1 radCORDIC: sin3270, cos32608math.h: sin3270.3, cos32608.0。可以看出12轮迭代最大误差在8e-4以内映射到电机控制、逆变器电流环这些典型场景已经足够用了。做PLL并网锁相环时角度误差小于0.05度也不会引起可见的谐波恶化。一个容易被忽略的坑是math.h里float的sinf在接近±π时结果可能微小于0或微大于0而CORDIC在象限折叠后输入是精确边界值时可能一下子就输出0不会产生跨界“脏”数值。这对控制逻辑而言反而是优点。工程上我最后选用12轮迭代是因为精度误差大约0.05°而常用IMU姿态解算、编码器角度换算的分辨率也就在0.1°附近不存在性能浪费。5. 手把手教你完成角度折叠与符号恢复CORDIC原生只处理第一象限所以实际工程里必须先把任意角度映射到有效收敛域再输出前恢复象限符号。这一步省事不得也直接决定最终结果的正确性。以[-π, π]为例做映射。我说下自己习惯的做法输入角度angle_q14先做模2π处理先把angle % (2 * 16384)得到在[-32768, 32767]范围里的值。判断原始角度所在象限。根据目标函数需求我只关心三角函数值所以可以借用“搬角度”的对称性质第一象限0 ~ π/2原样调用。sin正cos正。第二象限π/2 ~ π把角度减去π。调用CORDIC获得s1c1那么sincos(π/2 - (π - θ))简化后sins1、cos-c1。第三象限-π ~ -π/2角度加上π。调用CORDIC获得s1c1那么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×50200周期一下子省出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≈19898x坐标经过几轮迭代后会小幅波动且范围在[-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的符号若z0则将初始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这种资源有限、实时性敏感的环境里它是最可控的选择。
返回列表