☰
FPGA上CORDIC算法实现:移位与加法搞定三角函数与反正切
2026/9/29 1:26:45 网站建设 项目流程

做FPGA的人迟早会遇到CORDIC。不管是电机控制里实时算转子的反正切角度,还是信号处理里生成正弦波,又或者是图像旋转、数字下变频,几乎都绕不开这个老算法。我第一次正经接触CORDIC是在一个永磁同步电机的FOC控制项目里,要在一个小规模FPGA上同时处理电流环和角度估算,DSP资源紧张得很,查找表又觉得精度和面积的平衡不好把握,后来才把CORDIC真正捡起来用。这个算法最吸引人的地方在于:实现三角函数、反正切、极坐标转换这些看起来需要乘法器和ROM的东西,本质上只需要加减法和移位,这在FPGA上简直是天作之合。

这篇博文想聊的就是基于FPGA的CORDIC算法实现,用Verilog写,从原理推导开始,到定点数设计,再到可综合的流水线代码,最后附上仿真验证和实际工程里的坑。不管你是刚入门FPGA想找一个练手项目,还是已经在项目里被各种角度计算折磨,这篇文章都能给你一套直接能用的思路。

1. CORDIC为什么在FPGA里这么吃香

1.1 先搞清楚CORDIC到底在算什么事

CORDIC全称是Coordinate Rotation Digital Computer,坐标旋转数字计算方法,是1959年由J.E. Volder提出的。它的核心思路非常暴力:任何一个角度旋转,都可以拆成一系列固定小角度的旋转组合。每次旋转只做一件事——把坐标系的x和y按照某个特定角度旋转一下,而这个特定角度经过精心设计,使得旋转操作只涉及移位和加法。

举个例子,如果要把向量(x, y)旋转角度θ,传统做法是:

x' = x·cosθ - y·sinθ y' = y·cosθ + x·sinθ

这需要至少两个乘法器和一个sin/cos查找表,在FPGA上要么费DSP资源,要么费BRAM。CORDIC的做法是把θ拆成θ = d0·α0 + d1·α1 + ... + dn·αn,其中d_i是±1,表示第i次旋转的方向,α_i是预先选好的角度序列。关键来了:如果令α_i = arctan(2^(-i)),那么第i次旋转的公式就变成了:

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·α_i

乘上2^(-i)在二进制里就是右移i位,硬件上直接拉线就行,一个乘法器都不用。所谓“坐标旋转数字计算机”,就是用数字逻辑里的移位和加减法模拟坐标旋转,本质消耗的是逻辑单元LUT和寄存器,而不是DSP和BRAM。

1.2 为什么不用查找表或者直接调DSP

我知道很多人第一反应是:算个sin/cos而已,我直接用ROM存一个查找表多简单,或者干脆用乘法器搭,FPGA里DSP资源也没那么稀缺。这话对了一半,但分场景。

查找表方案在小范围、低精度场景确实简单粗暴。比如只算0到360度、1度步进的正弦值,360个点存进ROM,查表输出,逻辑简单到不行。但一旦遇到高精度或者大动态范围,查找表的面积会爆炸。你想要0.001度的分辨率就得存36万个点,BRAM吃得厉害。而且查找表只能解决单输入单输出的问题,遇到需要实时计算atan(y/x)这种双输入函数,查找表根本没法直接做。

DSP方案也一样。用DSP48算乘法确实快,但一个项目中同时要用FIR滤波器、PID控制器、FFT蝶形运算,DSP资源往往是第一个被抢光的。CORDIC用逻辑资源换DSP资源,在一些DSP吃紧的工程里是救命稻草。

还有一个常被忽略的点:CORDIC迭代结构天然适合流水线化。做一个N级流水线CORDIC,每级只做一次移位和一次加减,一级组合逻辑延迟很小,时钟频率很容易拉高。吞吐率能做到每时钟周期输出一个结果,延迟固定为N个周期,这对信号处理链路里的实时性要求太友好了。查找表虽然可以做到单周期出结果,但面积和精度之间的矛盾解决不了。所以CORDIC在FPGA领域几十年不过时,不是因为它有情怀,而是它在资源、速度、精度的三角权衡里找到了一个很好的平衡点。

2. 动手前必须想清楚的几个设计决策

2.1 旋转模式和向量模式:两种基本姿势

CORDIC有两种基本工作模式,用途完全不一样,很多人刚接触时容易混。

旋转模式(Rotation Mode)是求三角函数用的。输入一个角度z,初始坐标(x0, y0)通常设为(1/K, 0),每级迭代根据z的符号决定旋转方向,不断逼近目标角度。迭代完成后,x_n ≈ cos(z0),y_n ≈ sin(z0)。这里的K是CORDIC迭代带来的模长增益,迭代无限次时收敛到1.64676,所以初始值放1/K是为了把增益抵消掉。

向量模式(Vectoring Mode)是求反正切和模长用的。输入一个向量(x0, y0),初始z0通常设为0,每级迭代根据y的符号决定旋转方向,让y不断逼近0。迭代完成后,z_n ≈ arctan(y0/x0),x_n ≈ √(x0² + y0²)。

这两种模式的区别只在于判断旋转方向的依据:旋转模式看z的符号,向量模式看y的符号。代码实现上几乎一样,很多模块只需要改一个判断条件就能复用。我用一个parameter来控制模式选择,一套代码走天下。

2.2 迭代次数、数据位宽和角度量化怎么确定

这三个参数直接决定算法精度和资源消耗,是CORDIC工程化最关键的一步。

迭代次数N决定角度逼近的理论精度。CORDIC每次迭代的残差大约在2^(-N)量级,想做到10^(-4)精度至少要14次迭代,做到10^(-5)级别需要17次左右。但迭代不是越多越好,超过一定次数后,定点数的截断误差会盖过迭代误差,再加级数也没意义。工程上我一般取16到20级,配合适当的位宽扩展。

数据位宽是另一个关键。角度量化和坐标数据必须独立设计。角度如果范围是±π,用二进制补码表示,需要2个整数位(能表示到±2π),剩下的都是小数位。坐标数据要考虑迭代过程中的模长增益,最大绝对值可能接近1.647,所以至少需要2个整数位,再加一个小数位作冗余,防止中间运算溢出。

角度表arctan(2^(-i))的量化也要注意。每个角度值都要乘以2^F(F是小数位宽)取整,存成定点数。这里建议用四舍五入而不是直接截断,能减少大约半个LSB的误差。角度表可以用case语句硬编码,也可以用逻辑自动生成,我后面会给出Python生成脚本。

关于位宽选择还有一个经验之谈:CORDIC内部运算的数据位宽最好比输入输出位宽大3到4位,用来吸收中间迭代的截断误差。比如输入输出是16位,内部寄存器至少干到20位。这个余量看起来“浪费”,实际对精度提升非常明显,尤其是在迭代级数比较深的场景。

2.3 增益补偿:最容易翻车的一个环节

CORDIC每次旋转的旋转矩阵并不是纯正交的,它带的缩放因子是√(1+2^(-2i))。全部N次迭代完成后,向量模长会乘上一个总增益:

K = ∏√(1+2^(-2i)),i从0到N-1

当N足够大时,K ≈ 1.64676。如果不做补偿,旋转模式算出来的cos/sin会整体偏大1.64676倍,向量模式算出来的模长也会偏大这个倍数。这是个非常典型的新手错误:仿真波形看起来形状是对的,幅值却不对,查了半天发现忘了处理增益。

补偿方法有两种:一是迭代结束后把结果乘以1/K≈0.60725,需要一次乘法;二是更常用的小技巧,直接把初始x0设为1/K的定点值,让每次迭代都从被缩小的初始值出发,最后得到的结果天然就是正确幅值。第二种方法在旋转模式下特别好用,因为初始x0是固定常量,不增加任何额外逻辑。在向量模式下,初始x0、y0是外部输入的数据,没法预置,但模长的补偿可以在后续处理里乘回来,或者干脆放在系统的下一个处理级里。

有一点要注意:如果用预置初始值的方法,x0 = 1/K必须量化到位宽范围内。比如18位定点(2整数+15小数),1/K ≈ 0.60725,量化后是19900左右,远小于32767,不会溢出。但如果你把数据位宽压缩得很小,比如8位定点,0.60725量化后大约是78,也能放下,不过精度会损失不少,所以位宽别太小。

3. Verilog实现:从接口到流水线逐级拆解

3.1 模块接口定义与整体架构

我先给一个典型的旋转模式CORDIC模块接口,输入是相位角,输出是cos和sin。这个模块可以直接当数控振荡器NCO用,也可以作为底层IP集成到更大的系统里。

module cordic_rot #( parameter PHASE_WIDTH = 18, // 输入角度位宽,1符号+2整数+15小数 parameter DATA_WIDTH = 20, // 内部数据位宽 parameter ITER_NUM = 16 // 迭代级数 )( input wire clk, input wire rst_n, input wire en, // 输入有效 input wire signed [PHASE_WIDTH-1:0] phase_in, // 弧度定点数 output reg signed [DATA_WIDTH-1:0] cos_out, output reg signed [DATA_WIDTH-1:0] sin_out, output reg valid // 输出有效 );

内部采用16级流水线结构,每级有两组寄存器:一组是旋转后的x、y坐标,一组是剩余角度z。第i级的旋转方向由z_i的符号决定。输入使能信号en在第一级打入,然后跟随流水线逐级传递,同时用移位寄存器产生valid信号,这样就知道输出数据哪一拍是有效的。

这里有个很重要的设计点:valid信号的延迟必须和数据处理路径完全对齐。我见过不少工程因为valid延迟没对齐,导致后续模块采到无效数据,整个链路输出乱跳。最简单的方法就是写一个和流水线深度相同的移位寄存器,en信号进,valid出。

3.2 角度常量表的生成与量化

角度表是CORDIC的“灵魂”,每个角度值对应一次迭代的旋转步长。16级迭代需要16个角度:arctan(2^0)、arctan(2^-1)、arctan(2^-2)……一直到arctan(2^-15)。这些角度是固定常量,可以在Verilog里用localparam硬编码,也可以用case语句生成。

我习惯用Python先把角度值算好,再生成Verilog代码片段,避免手算出错。量化位数和数据位宽挂钩,比如PHASE_WIDTH=18位,去掉1位符号和2位整数,小数位F=15,角度值就乘以2^15四舍五入取整。

import math F = 15 # 小数位宽 ITER = 16 for i in range(ITER): angle = math.atan(2 ** (-i)) q = int(round(angle * (2 ** F))) print(f"15'd{q}, // i={i}, atan={angle:.6f}")

生成的定点角度表可以直接放到一个case语句里,或者放进一个只读数组。注意这些值是正数,不需要符号位。每次迭代根据z的符号决定加上还是减去对应角度。在Verilog里,case语句的形式如下:

function [PHASE_WIDTH-2:0] get_atan_table; input [3:0] idx; begin case (idx) 4'd0: get_atan_table = 15'd25736; // atan(1) 4'd1: get_atan_table = 15'd15193; // atan(0.5) 4'd2: get_atan_table = 15'd8026; // atan(0.25) // ... 其他迭代级 default: get_atan_table = 15'd0; endcase end endfunction

具体量化值会因为F取15略有不同,实际工程里用Python重新生成一份最稳妥。

3.3 流水线迭代级代码详解

下面是核心的单级流水线逻辑。以第i级为例,输入是x_in、y_in、z_in,输出是x_out、y_out、z_out。旋转方向由z_in的最高位决定:z_in为正(符号位为0)逆时针旋转,z_in为负(符号位为1)顺时针旋转。

wire z_neg = z_in[PHASE_WIDTH-1]; // 符号位,1表示负数 wire [DATA_WIDTH-1:0] x_shift = x_in >>> i; // 算术右移,保留符号 wire [DATA_WIDTH-1:0] y_shift = y_in >>> i; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin x_out <= 0; y_out <= 0; z_out <= 0; end else begin if (z_neg) begin // 顺时针旋转:x加y的移位项,y减x的移位项,z加角度 x_out <= x_in + y_shift; y_out <= y_in - x_shift; z_out <= z_in + angle_i; end else begin // 逆时针旋转:x减y的移位项,y加x的移位项,z减角度 x_out <= x_in - y_shift; y_out <= y_in + x_shift; z_out <= z_in - angle_i; end end end

有两点必须强调。第一,右移必须用算术右移>>>,因为x_in和y_in是有符号数,如果用逻辑右移>>,负数的高位会补0,整个数据会变成巨大的正数,结果彻底错误。第二,每级移位量i是不同的,第0级右移0位,第1级右移1位,第2级右移2位。流水线展开写时可以直接用generate-for循环参数化,避免手工复制16遍代码。

使用generate-for的完整流水线结构大致是这样:

genvar i; generate for (i = 0; i < ITER_NUM; i = i + 1) begin: cordic_stage cordic_stage #( .DATA_WIDTH(DATA_WIDTH), .PHASE_WIDTH(PHASE_WIDTH), .SHIFT(i), .ANGLE(get_atan_table(i)) ) u_stage ( .clk(clk), .rst_n(rst_n), .x_in(i == 0 ? x0 : stage_x[i-1]), .y_in(i == 0 ? y0 : stage_y[i-1]), .z_in(i == 0 ? phase_abs : stage_z[i-1]), .x_out(stage_x[i]), .y_out(stage_y[i]), .z_out(stage_z[i]) ); end endgenerate

实际上get_atan_table用function在generate里调用会有综合限制,更稳妥的做法是把角度表定义成常量数组,然后按循环变量i索引:

localparam [PHASE_WIDTH-2:0] atan_table [0:ITER_NUM-1] = '{ 18'd25736, 18'd15193, // 必须和实际位宽对齐 // ... }; // generate块内 wire signed [PHASE_WIDTH-1:0] angle_i = {{1{1'b0}}, atan_table[i]};

总之,流水线展开的关键是每级用不同的移位量和不同的角度值,其他逻辑完全一样。综合工具会复制16份相同的逻辑,资源消耗大约是串行结构的16倍,但换来的是每个时钟周期都能出结果。

3.4 象限映射预处理与初始值注入

之前说CORDIC迭代的收敛范围大约在±99.88度,超过这个范围算法不收敛。但实际输入角度可能覆盖±π甚至更大,所以必须在进入迭代之前做象限映射。常用的做法是把所有角度折叠到第一象限(0到π/2),用符号标志记录折叠方式,迭代完成后根据标志恢复cos/sin的符号。

象限映射逻辑不难,但要仔细。我写过一版:输入phase_in,先取绝对值,判断它落在哪个象限,然后映射到第一象限的角度。映射规则是:第二象限π-x映射成x,第三象限x-π映射成x,第四象限-x映射成x。cos和sin的最终符号根据原始象限决定。

初始值注入方面,旋转模式把x0预置为1/K的定点值,y0为0。1/K的量化和数据位宽相关,比如DATA_WIDTH=20,取2整数+17小数,1/K×2^17=79623,20位有符号数最大是524287,没问题。如果采用不预置而事后补偿的方案,初始x0就直接赋1,最后再加一个乘法器对结果乘0.60725。两种方案各有优劣,预置法省逻辑但限制初始值,事后补偿法灵活但要消耗乘法器。

4. 仿真验证与实测:误差到底能压到多低

4.1 Testbench怎么写才能真正发现问题

很多人写CORDIC的testbench就是给几个特定角度跑一遍看波形对不对。这样做不是不行,但对精度评估来说信息量太少了。我建议至少做两类测试:定点对比测试和全范围扫描测试。

定点对比测试是拿CORDIC输出和Matlab/Python计算的浮点参考值对比。把输入角度和输出数据都导出成文本,然后在Python里统一做误差统计。可以算最大绝对误差、均方根误差,还可以看误差的分布是随机噪声还是系统性偏差。系统性偏差通常来自增益补偿不精确或者角度表量化误差,随机噪声主要来自迭代截断。区分这两类误差对定位问题很有帮助。

全范围扫描测试是把输入角度从-π到π按一定步长扫描,把所有输出结果记录下来。重点检查两个东西:一是角度在边界0、±π/2、±π附近时输出是否正确,这些地方最容易出现符号翻转错误;二是检查整个范围内误差是否均匀,如果某个区间误差突然变大,很可能象限映射逻辑在那个边界出问题了。

Testbench里还可以加一个随机角度激励,用$urandom生成随机相位,持续跑几千个周期,统计valid输出和采样时刻是否稳定。这种随机测试特别适合找时序对齐的问题。

4.2 实测误差分析:迭代次数和位宽对精度的影响

我实际做过一组对比实验:固定输入输出位宽18位,分别用12级、16级、20级迭代,扫描全范围角度,测得最大绝对误差大致如下:

迭代级数最大绝对误差(cos)最大绝对误差(sin)备注
12约3.2e-3约3.5e-3迭代误差主导
16约2.1e-4约2.3e-4逼近理论精度
20约1.9e-4约2.0e-4受限于数据位宽,不再明显改善

从数据能看到两个规律:迭代从12级加到16级,误差降低了一个数量级;但从16级加到20级,误差几乎没有变化。原因就是内部数据位宽20位,截断误差已经盖过了迭代误差。想进一步提升精度,光加迭代级数没用,得同时加数据位宽。

另一个观察是误差在角度接近±π/2时略微偏大。这是因为cos在0附近比较平坦,对角度误差不敏感,sin在π/2附近导数接近0,同样不敏感,但CORDIC在迭代过程中z的残差总会留一点,表现在输出上就是靠近某些角度时会出现小的“台阶”。这个现象在要求高精度信号合成的场景需要注意,如果完全不能接受,可以后级加一个小的补偿修正,或者提高定点数位宽。

4.3 资源占用与性能实测

在Cyclone V上一块中等规模的FPGA上,16级流水线、数据位宽20位的CORDIC模块,实测资源占用大约是不到700个Logic Element(LE),基本不用DSP和BRAM。最高时钟频率可以跑到200MHz以上,每周期输出一个结果。这个资源占用和性能指标,对绝大多数中低端FPGA都是很轻松的负担。

作为对比,如果是串行迭代结构,资源占用会降到200个LE以内,但计算一次需要16个时钟周期,吞吐率只有流水线的1/16。所以架构选择完全看项目需求:如果正弦波发生器后面接的DAC采样率只有几十kHz,串行结构绰绰有余;如果要用在高速解调链路里,数据率是几十兆甚至上百兆,流水线结构才是正解。

5. 常见问题速查与避坑经验

5.1 精度上不去?先查这几个地方

遇到CORDIC精度不如预期,我总结了一套排查顺序。

第一查数据位宽。内部位宽如果和输出位宽一样,没有余量,那么每个中间级的截断误差一路累积到最后,精度通常会比理论值差很多。把内部位宽扩到输出位宽加3到4位,精度立刻改善。

第二查角度表量化。角度表如果直接截断而不是四舍五入,每个角度值会损失约0.5个LSB,累积起来是系统性的负偏。把角度表全部换成四舍五入量化,能明显减少系统性误差。

第三查增益补偿。如果算出来的cos/sin波形形状对但幅值整体偏大约1.647倍,那就是增益没补偿。用预置初始值1/K的方法最省事,但注意1/K量化后要检查是否超出位宽范围。

第四查象限映射。如果误差只在特定角度区间突然恶化,大概率是象限映射的边界情况处理错了。比如角度正好等于π/2时,符号位的判断和绝对值处理要特别小心。

5.2 时序收敛和资源优化经验

CORDIC流水线每一级的组合逻辑很浅,就是一次移位和一次加减,所以时序收敛一般都挺顺利。但如果你把16级全部写成一个大的组合逻辑块,中间不加寄存器,组合逻辑链会非常长,时序必挂。记得每一级之间要有一级寄存器,采用真正意义上的流水线结构。

如果资源很紧张,可以考虑半流水设计:把16级迭代拆成两组8级的反馈结构,每个时钟周期算半级,4个时钟周期算完一级迭代,总面积大约只有全流水的1/4,吞吐率降低4倍。这个方案在很多中低速率应用里性价比很高。

另一个优化技巧是角度表的实现。16个宽度18位的小常量,用LUT就能放下,不需要用BRAM。但如果你把它写成一个ROM,有些综合工具会为了优化把它放进BRAM,反而浪费了宝贵的存储资源。可以在综合属性里把角度表声明为luts来避免这种情况。

5.3 流水线深度带来的对齐问题

使用流水线CORDIC时,数据从输入到输出需要固定N个时钟周期的延迟。如果你的系统里还有其他并行路径,必须确保这些路径的延迟和CORDIC一致,否则后续模块会采到时间上没对齐的数据。这个问题的隐蔽性很高,因为单看CORDIC本身功能正确,但整个链路一配合就出错。

解决办法是给每个路径加上匹配延迟。用SRL16或者移位寄存器做延迟对齐是最常见的方式。工程上还要注意,延迟匹配要按周期精确对齐,哪怕差一个周期,输出的星座图或者波形都会有明显的相位跳变。

6. 项目实战:两个可以直接套用的应用场景

6.1 用CORDIC做高精度数控振荡器NCO

数控振荡器NCO在通信系统里几乎是标配。传统NCO用相位累加器加查找表实现,查找表的深度直接决定频率分辨率和杂散性能。用CORDIC替代查找表后,一个很大的优势是可以把ROM省掉,而且输出正弦/余弦是逐点实时计算出来的,不存在查找表的相位截断杂散。

我在一个数字解调项目里用过这个结构。相位累加器输出32位相位,取高18位送给CORDIC,CORDIC输出cos和sin给后级混频器。实测无杂散动态范围比同参数的查找表方案高了不少,关键是查表方案为了压低杂散需要存两万个点的ROM,而CORDIC方案几乎不占BRAM。把省下来的BRAM留给后面的滤波器,整体链路性能提升明显。

6.2 用向量模式做电机控制里的角度与速度估算

永磁同步电机的无感FOC控制里,经常需要根据αβ轴的反电动势估算转子角度和速度。这个本质上就是算atan(eβ/eα)。用CORDIC向量模式,把eα和eβ作为输入x0、y0,迭代完成后z输出就是转子角度,x输出近似幅值,一举两得。

实际工程里还有个细节:CORDIC向量模式在x0为负时需要特殊处理,否则求出来的角度范围会不对。我的做法是先判断x0的符号,如果为负就把输入向量旋转180度,得到一个正x0的等效向量,迭代完成后再把角度加回π。这样整个角度范围可以达到-π到π,满足FOC控制的全部需求。类似的处理在很多通信和伺服应用里都会遇到,可以作为一个通用技巧记住。

写到这里,关于CORDIC算法的原理、定点化设计、Verilog实现和工程实践经验基本都讲到了。我个人实际做下来的体会是,CORDIC这种“用移位和加减法做数学运算”的思路,在现代FPGA资源日益丰富的今天依然有着不可替代的价值。它不追求算法本身的花哨,胜在结构规整、时序友好、资源开销可控。如果你手头正好有角度计算、三角函数或者坐标变换的需求,不妨按这篇文章的思路搭一个出来试试,跑一遍仿真看看误差曲线,再放到板子上测一测实际效果,整个流程走通之后,你会对FPGA里的数值运算有一种完全不同的掌控感。

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

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

立即咨询