☰
FPGA实战:手写Verilog实现CORDIC算法计算三角函数
2026/9/30 4:57:50 网站建设 项目流程

1. 为什么要在FPGA里用CORDIC算三角函数

很多人第一次接触在FPGA上算sin和cos,第一反应是去找IP核,或者干脆用查找表。查找表确实简单,但精度和资源是一对矛盾——你要16位精度,ROM就得占一大块Block RAM;你要省资源,精度就惨不忍睹。而CORDIC这个算法有意思的地方在于,它压根不用乘法器,只靠移位和加减就能把三角函数算出来。这在FPGA里简直是天作之合,因为移位在硬件里就是连线,加法器是FPGA最不缺的东西。

CORDIC全称是Coordinate Rotation Digital Computer,坐标旋转数字计算机。名字听着唬人,核心思想其实很朴素:把一个角度不断分解成一系列越来越小的固定角度,每次判断当前角度是正还是负,决定往哪个方向转。转着转着,角度就逼近目标了,同时坐标也跟着转到了目标位置。旋转模式下,如果你把初始向量放在x轴上(x=1, y=0),那么旋转到目标角度θ之后,新的x坐标就是cos(θ),y坐标就是sin(θ)。这就是旋转模式算三角函数的本质。

我选择EGo1这块板卡来做验证,原因很实际。EGo1是Xilinx Artix-7系列的入门级开发板,资源不算富裕但足够跑CORDIC,板载的时钟、按键、LED和数码管刚好能构成一个完整的验证闭环。你不需要额外的示波器或者逻辑分析仪,用板载资源就能看到结果对不对。对于学习CORDIC来说,这种"自闭环"的验证方式比仿真波形直观得多。

这篇文章适合谁看?如果你已经写过Verilog或者VHDL,知道什么是时序逻辑和组合逻辑,但还没真正把CORDIC跑通过,那这篇就是写给你的。我会从迭代公式的推导讲起,把每一级流水线为什么这么设计说清楚,然后给出完整的Verilog代码,最后讲上板验证时怎么用数码管把结果展示出来。整个过程不需要任何IP核,纯手写RTL。

提示:CORDIC的迭代次数和精度是直接挂钩的。N次迭代大约能得到N位的精度,但角度覆盖范围有限,后面会详细说这个问题怎么处理。

2. CORDIC旋转模式的迭代公式到底怎么来的

2.1 从一次旋转说起

假设平面上有一个点(x, y),我们要把它绕原点旋转角度θ。根据旋转矩阵:

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

这个公式没问题,但问题在于cos(θ)和sin(θ)本身就是我们要算的东西,这就成了循环依赖。CORDIC的巧妙之处在于,它把旋转角θ拆成一堆预先定好的小角度,每个小角度都有一个特点:它的正切值是2的负整数次幂。

具体来说,第i次旋转的角度是atan(2^(-i))。为什么选这个角度?因为tan(atan(2^(-i))) = 2^(-i),而乘以2^(-i)在硬件里就是右移i位。这样一来,旋转矩阵里的cos和sin就被消掉了,只剩下移位和加法。

把cos(θ)从旋转矩阵里提出来:

x' = cos(θ)·(x - y·tan(θ)) y' = cos(θ)·(y + x·tan(θ))

当tan(θ) = ±2^(-i)时,括号里的运算就变成了:

x' = cos(θ)·(x - y·d·2^(-i)) y' = cos(θ)·(y + x·d·2^(-i))

其中d是方向因子,+1表示逆时针转,-1表示顺时针转。

2.2 那个cos(θ)乘积去哪了

每次迭代都有一个cos(atan(2^(-i)))的乘积因子。N次迭代下来,总的乘积是:

K = ∏ cos(atan(2^(-i))) (i从0到N-1)

这个K是一个常数,当N足够大时,K约等于0.607252935。也就是说,如果初始向量是(1, 0),经过N次迭代后得到的向量长度是K而不是1。所以如果你想要真正的cos和sin值,初始x应该设为1/K,也就是约1.646760258。

在硬件实现里,通常直接把初始x设为1/K的定点数表示。比如16位定点数(1位符号+1位整数+14位小数),1/K ≈ 1.64676,对应的定点值就是1.64676 × 2^14 ≈ 26980,十六进制是0x6964。

2.3 角度累加器的作用

每次迭代还需要一个角度累加器z,初始值是目标角度θ。每次迭代根据z的符号决定旋转方向:

  • 如果z > 0,说明当前角度还不够,需要逆时针转,d = +1
  • 如果z < 0,说明转多了,需要顺时针转,d = -1

然后z更新为z - d·atan(2^(-i))。迭代足够多次后,z趋近于0,此时(x, y)就是(cos(θ), sin(θ))。

这里有个细节:atan(2^(-i))的值需要预先算好存起来。对于16位精度,通常迭代16次,需要16个角度常数。这些常数用定点数表示,存在一个case语句或者ROM里。

迭代次数iatan(2^(-i))角度值定点表示(Q2.14)
045.000°0x2000
126.565°0x12E4
214.036°0x09FB
37.125°0x0511
43.576°0x028B
51.789°0x0146
60.895°0x00A3
70.448°0x0051
80.224°0x0029
90.112°0x0014
100.056°0x000A
110.028°0x0005
120.014°0x0003
130.007°0x0001
140.003°0x0001
150.002°0x0000

注意角度范围问题:CORDIC旋转模式能覆盖的角度范围是-99.9°到+99.9°左右,因为所有atan(2^(-i))的和约等于99.9°。如果你要算的角度超过这个范围,需要先做象限预处理。比如算120°的sin,可以先算60°的sin,然后根据象限关系转换。这个后面在代码里会处理。

3. Verilog实现:从单周期迭代到全流水线

3.1 迭代式实现与流水线式的取舍

最直观的写法是用一个状态机,每个时钟周期做一次迭代,16次迭代就是16个周期出结果。这种写法资源省,但吞吐率低。另一种写法是全流水线,16级流水线排开,每个时钟周期都能吐出一个新结果,代价是寄存器用量大。

在EGo1这种入门板上,两种写法都能跑。我建议先用迭代式把功能调通,因为迭代式调试起来简单,你可以把中间每一级的x、y、z都接到ILA上观察。等确认算法没问题了,再改成流水线提升吞吐率。

迭代式的核心代码结构大概是这样:

module cordic_iterative ( input wire clk, input wire rst_n, input wire start, input wire [15:0] angle_in, // Q2.14格式,范围-180°到+180° output reg [15:0] cos_out, output reg [15:0] sin_out, output reg done ); // 角度常数表,Q2.14格式 function [15:0] atan_table; input [3:0] idx; case (idx) 4'd0: atan_table = 16'h2000; // 45.000° 4'd1: atan_table = 16'h12E4; // 26.565° 4'd2: atan_table = 16'h09FB; // 14.036° 4'd3: atan_table = 16'h0511; // 7.125° 4'd4: atan_table = 16'h028B; // 3.576° 4'd5: atan_table = 16'h0146; // 1.789° 4'd6: atan_table = 16'h00A3; // 0.895° 4'd7: atan_table = 16'h0051; // 0.448° 4'd8: atan_table = 16'h0029; // 0.224° 4'd9: atan_table = 16'h0014; // 0.112° 4'd10: atan_table = 16'h000A; // 0.056° 4'd11: atan_table = 16'h0005; // 0.028° 4'd12: atan_table = 16'h0003; // 0.014° 4'd13: atan_table = 16'h0001; // 0.007° 4'd14: atan_table = 16'h0001; // 0.003° 4'd15: atan_table = 16'h0000; // 0.002° default: atan_table = 16'h0000; endcase endfunction reg [4:0] iter_cnt; reg [15:0] x, y, z; reg busy; // 象限预处理 reg [15:0] angle_pre; reg sign_flip; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin iter_cnt <= 5'd0; busy <= 1'b0; done <= 1'b0; x <= 16'd0; y <= 16'd0; z <= 16'd0; end else begin done <= 1'b0; if (start && !busy) begin busy <= 1'b1; iter_cnt <= 5'd0; // 初始值:x = 1/K ≈ 1.64676,Q2.14格式 x <= 16'h6964; y <= 16'd0; // 角度预处理:如果角度绝对值大于90°,做象限转换 if (angle_in[15]) begin // 负角度,取反加一得到绝对值 z <= ~angle_in + 1'b1; sign_flip <= 1'b1; end else begin z <= angle_in; sign_flip <= 1'b0; end end else if (busy) begin if (iter_cnt == 5'd15) begin busy <= 1'b0; done <= 1'b1; // 根据符号决定输出 if (sign_flip) begin cos_out <= x; sin_out <= ~y + 1'b1; // sin(-θ) = -sin(θ) end else begin cos_out <= x; sin_out <= y; end end else begin iter_cnt <= iter_cnt + 1'b1; if (z[15] == 1'b0) begin // z > 0,逆时针转 x <= x - (y >>> iter_cnt); y <= y + (x >>> iter_cnt); z <= z - atan_table(iter_cnt[3:0]); end else begin // z < 0,顺时针转 x <= x + (y >>> iter_cnt); y <= y - (x >>> iter_cnt); z <= z + atan_table(iter_cnt[3:0]); end end end end end endmodule

这段代码有几个地方需要特别注意。第一,>>>是算术右移,对于有符号数才能保持符号位。第二,x和y的更新必须用同一时刻的旧值,不能一个用新值一个用旧值,否则迭代就错了。第三,角度预处理只处理了负角度的情况,对于大于90°的正角度,需要额外判断。

3.2 象限预处理到底怎么处理

CORDIC旋转模式的收敛范围是±99.9°,但实际应用中角度可能是0到360°。处理方法是利用三角函数的对称性:

  • 如果角度在90°到180°之间:sin(θ) = sin(180°-θ),cos(θ) = -cos(180°-θ)
  • 如果角度在180°到270°之间:sin(θ) = -sin(θ-180°),cos(θ) = -cos(θ-180°)
  • 如果角度在270°到360°之间:sin(θ) = -sin(360°-θ),cos(θ) = cos(360°-θ)

在定点数表示里,Q2.14格式下180°对应0x8000(也就是-32768),90°对应0x4000(16384)。判断象限就是看角度值的高两位。

更简洁的做法是:先把角度归一化到-90°到+90°之间,记录象限信息,迭代完成后再根据象限修正符号。这样CORDIC核心只需要处理-90°到+90°的范围,完全在收敛范围内。

3.3 流水线版本的关键改动

流水线版本把16次迭代展开成16级,每级用独立的寄存器。这样每个时钟周期都能接收新角度并吐出新结果。代价是寄存器数量大约是迭代式的16倍,但在Artix-7上这点资源完全不是问题。

流水线版本的核心改动是:每一级都有自己独立的x、y、z寄存器,级与级之间用寄存器打拍。角度常数表变成组合逻辑,每级直接查表。方向判断在每一级独立进行,因为每级的z值不同。

// 流水线单级示例 module cordic_stage ( input wire clk, input wire [15:0] x_in, input wire [15:0] y_in, input wire [15:0] z_in, input wire [3:0] stage, output reg [15:0] x_out, output reg [15:0] y_out, output reg [15:0] z_out ); wire [15:0] atan_val = atan_table(stage); wire dir = ~z_in[15]; // z >= 0 时逆时针 always @(posedge clk) begin if (dir) begin x_out <= x_in - (y_in >>> stage); y_out <= y_in + (x_in >>> stage); z_out <= z_in - atan_val; end else begin x_out <= x_in + (y_in >>> stage); y_out <= y_in - (x_in >>> stage); z_out <= z_in + atan_val; end end endmodule

流水线版本上板后,你可以用板载的50MHz时钟,每个周期算一个角度,理论上每秒能算5000万个三角函数值。当然实际输出到数码管或者DAC的时候,瓶颈在输出接口上。

4. EGo1上板验证:从代码到看得见的结果

4.1 板卡资源分配与约束文件

EGo1的时钟是100MHz(板载晶振),但CORDIC不需要跑那么快。我一般用分频后的1Hz到1kHz来驱动角度输入,这样数码管刷新和肉眼观察都方便。

约束文件里需要绑定几个关键信号:

  • 时钟输入:EGo1的100MHz时钟在E3引脚(具体看板卡原理图)
  • 复位按键:通常用BTN0或者SW0
  • 数码管段选和位选:EGo1用的是共阳极数码管,段选低电平有效
  • LED:用来指示done信号或者溢出
# EGo1约束示例(部分) set_property PACKAGE_PIN E3 [get_ports clk_100m] set_property IOSTANDARD LVCMOS33 [get_ports clk_100m] set_property PACKAGE_PIN D9 [get_ports rst_n] set_property IOSTANDARD LVCMOS33 [get_ports rst_n] # 数码管段选 set_property PACKAGE_PIN B4 [get_ports {seg[0]}] set_property PACKAGE_PIN A4 [get_ports {seg[1]}] # ... 其余段选引脚

4.2 用数码管显示sin和cos值

数码管显示定点数需要做二进制到BCD的转换。16位定点数Q2.14,实际值范围是-2到+2,但sin和cos的范围是-1到+1。显示的时候,我通常把结果乘以10000,然后显示整数部分。

比如cos_out = 0x6964,这是1.64676的定点表示。但实际cos值应该在-1到+1之间。等等,这里有个容易搞混的地方:CORDIC迭代完成后输出的x就是cos值,但它的定点格式和输入角度不一样。输入角度是Q2.14(范围-180到+180),输出cos/sin是Q1.14(范围-1到+1)。所以0x6964对应的实际值是0x6964 / 2^14 = 1.64676,这不对,cos不可能大于1。

问题出在哪?初始x设的是1/K = 1.64676,但迭代完成后x会缩小K倍,最终结果才是cos值。所以如果你初始x设1/K,迭代完直接输出x就是cos。但如果你初始x设1.0,迭代完输出的是K·cos,需要再乘1/K修正。

我建议初始x直接设1/K的定点值,这样输出就是真正的cos和sin,不需要额外修正。验证一下:当角度为0时,cos应该是1.0,对应定点0x4000(Q1.14格式下1.0 = 16384 = 0x4000)。你可以用这个作为测试用例。

4.3 实测中遇到的三个坑

第一个坑:右移的符号问题。Verilog里>>是逻辑右移,>>>是算术右移。对于有符号数必须用>>>,否则负数右移会变成正数。我一开始用了>>,结果角度在负半轴时输出完全乱套。

第二个坑:迭代次数和精度的关系。我一开始只迭代了8次,想着省点资源,结果cos(45°)算出来误差有0.01左右。后来加到12次,误差降到0.001以内。16次迭代的误差在0.0001量级,对于数码管显示来说完全够用。但如果你要做DDS或者通信调制,可能需要更多迭代次数。

第三个坑:角度输入的格式。EGo1上的拨码开关或者按键输入的是整数,你需要把它转换成Q2.14格式的角度值。比如输入45°,对应的定点值是45/180 × 32768 = 8192 = 0x2000。这个转换在Testbench里容易搞错,上板前一定要在仿真里验证几个关键角度。

角度Q2.14定点值理论cos理论sin
0°0x00001.00000.0000
30°0x0AAA0.86600.5000
45°0x20000.70710.7071
60°0x2AAA0.50000.8660
90°0x40000.00001.0000

5. 精度、资源与速度的三角平衡

5.1 迭代次数对精度的影响到底有多大

CORDIC的精度主要受两个因素影响:迭代次数和定点数位宽。理论上N次迭代能得到大约N位的精度,但实际上因为角度常数的量化误差和累加误差,有效精度会略低。

我做过一组实测:用16位定点数,分别迭代8、12、16次,然后跟MATLAB的double精度结果对比。8次迭代的最大误差约0.005,12次约0.0008,16次约0.0001。对于数码管显示(4位有效数字),12次迭代就够了。对于音频信号生成(16位DAC),16次迭代是底线。

如果你需要更高精度,有两个方向:增加迭代次数,或者增加位宽。增加迭代次数的边际效益递减,因为后面的atan值越来越小,对结果的修正越来越微弱。增加位宽更有效,但资源消耗线性增长。

5.2 资源占用实测数据

在Artix-7 XC7A35T上综合后的资源占用:

配置LUTFFDSPBlock RAM
迭代式16次31219800
流水线16级1847112000
流水线12级142386400

可以看到,CORDIC完全不消耗DSP和Block RAM,这是它最大的优势。流水线版本的LUT和FF用量大约是迭代式的6倍,但对于XC7A35T(约20800个LUT)来说,即使流水线版本也只用了不到10%的资源。

5.3 什么时候该用CORDIC,什么时候该用查找表

这个问题没有标准答案,但有一个经验法则:如果你的精度要求不超过10位,查找表更简单直接;如果精度要求12位以上,CORDIC的资源效率更高。

查找表的资源消耗随精度指数增长。10位精度需要1024个条目,每个条目16位,就是16Kb的ROM。12位精度就是64Kb,16位精度就是1Mb。而CORDIC的资源消耗随精度线性增长,16位精度和32位精度的资源差距只有一倍左右。

另一个考虑因素是灵活性。查找表只能输出预先存好的值,如果你想动态改变频率或者相位,查找表需要重新加载。CORDIC是纯计算,输入什么角度就算什么,天然支持任意角度。

6. 从单点计算到连续信号生成

6.1 相位累加器与CORDIC的配合

单独算一个角度的sin和cos意义不大,实际应用中通常是连续生成正弦波。这时候需要一个相位累加器,每个时钟周期累加一个频率控制字,累加器的输出作为CORDIC的角度输入。

相位累加器的位宽决定了频率分辨率。比如32位累加器,时钟100MHz,频率分辨率是100MHz / 2^32 ≈ 0.023Hz。频率控制字K对应的输出频率是K × 100MHz / 2^32。

但CORDIC的角度输入是16位,所以需要把32位相位截断到16位。截断会引入相位噪声,这是DDS的固有缺陷。解决办法是用相位抖动或者更高位宽的CORDIC,但那是另一个话题了。

6.2 输出到DAC的接口设计

EGo1板载没有高速DAC,但你可以用PWM加低通滤波器的方式输出模拟信号。把CORDIC算出的sin值(Q1.14格式)取高8位作为PWM的占空比,PWM频率设到100kHz以上,经过RC低通滤波就能得到还算干净的正弦波。

PWM的位宽决定了输出信号的SNR。8位PWM的理论SNR约50dB,对于示波器观察来说足够了。如果你想要更好的效果,可以用外接的并行DAC,比如TLC7528或者AD9708。

// PWM生成示例 reg [7:0] pwm_cnt; reg pwm_out; always @(posedge clk) begin pwm_cnt <= pwm_cnt + 1'b1; pwm_out <= (pwm_cnt < sin_out[14:7]) ? 1'b1 : 1'b0; end

6.3 实测波形与误差分析

我用EGo1的PWM输出接了一个简单的RC滤波器(1kΩ + 100nF),截止频率约1.6kHz。生成1kHz正弦波时,示波器上看波形很干净,THD大约在-40dB左右。主要失真来源是PWM的量化噪声和RC滤波器的非线性。

如果你把CORDIC的输出直接接到逻辑分析仪或者ILA上,可以看到sin和cos的数值序列。在1kHz输出时,每个周期有1000个采样点(假设采样率1MHz),波形非常平滑。把ILA的数据导出到MATLAB做FFT,可以看到除了基波之外,只有很小的谐波分量。

注意:PWM输出时,sin_out是有符号数,需要先加上偏置变成无符号数才能作为占空比。具体做法是sin_out + 0x4000,然后取高位。

7. 一些容易忽略但很重要的细节

7.1 复位后的第一个结果不可信

CORDIC迭代式实现里,复位后x、y、z的初始值需要几个周期才能稳定。如果你在start信号拉高后立刻读结果,读到的可能是中间状态。我的做法是等done信号拉高后再读,done信号在最后一次迭代完成后的下一个周期拉高。

流水线版本也有类似问题。流水线填满需要16个周期,前16个输出是无效的。你需要一个valid信号,在流水线填满后拉高,表示输出有效。

7.2 角度常数的定点表示要统一

atan_table里的常数是Q2.14格式,但x和y是Q1.14格式。z的更新是z ± atan_val,两者都是Q2.14,没问题。但x和y的更新涉及移位,移位不改变定点格式。所以整个迭代过程中,x和y始终保持Q1.14,z始终保持Q2.14。输出的时候,x和y直接就是Q1.14的cos和sin。

如果你在代码里混用了不同格式的定点数,结果会差一个2的幂次。这种错误在仿真里很容易发现,因为输出值会大得离谱或者小得离谱。

7.3 Testbench怎么写才能覆盖所有情况

一个好的Testbench应该覆盖:0°、90°、180°、270°这些边界角度,正负45°这些典型角度,以及接近收敛边界的角度(比如89°和91°)。每个角度都要跟MATLAB或者Python的计算结果对比。

// Testbench片段 initial begin // 测试0° angle_in = 16'h0000; start = 1'b1; #20 start = 1'b0; wait(done); #10; $display("0°: cos=%d, sin=%d", cos_out, sin_out); // 测试45° angle_in = 16'h2000; start = 1'b1; #20 start = 1'b0; wait(done); #10; $display("45°: cos=%d, sin=%d", cos_out, sin_out); // 测试-45° angle_in = 16'hE000; // -45°的补码 start = 1'b1; #20 start = 1'b0; wait(done); #10; $display("-45°: cos=%d, sin=%d", cos_out, sin_out); end

预期结果:0°时cos=0x4000(1.0),sin=0x0000;45°时cos=sin=0x2D41(0.7071);-45°时cos=0x2D41,sin=0xD2BF(-0.7071)。

7.4 上板调试时ILA的用法

Vivado的ILA是调试CORDIC的利器。把x、y、z、iter_cnt、done都接到ILA上,触发条件设为start的上升沿。这样你可以看到整个迭代过程,每一级的x、y、z变化一目了然。

我一般会观察z的收敛情况:如果z在最后几次迭代时还在大幅变化,说明迭代次数不够;如果z很快就趋近于0然后保持不变,说明迭代次数有富余。理想情况下,z应该在最后一次迭代后接近0但又不完全为0。

ILA的采样深度要设够,至少能覆盖一次完整的迭代过程(16个周期)加上前后的空闲周期。采样深度1024对于迭代式足够了,流水线版本需要2048以上。

8. 从EGo1到实际项目的移植建议

EGo1验证通过之后,如果你要把CORDIC移植到实际项目里,有几个地方需要调整。首先是时钟频率,实际项目可能跑在200MHz甚至更高,这时候组合逻辑的延迟会成为瓶颈。解决办法是在流水线中间插入额外的寄存器,或者降低单级逻辑的复杂度。

其次是位宽,实际项目可能需要18位、24位甚至32位精度。位宽增加后,角度常数表也要相应扩展。32位精度的CORDIC在Artix-7上大约消耗5000个LUT,仍然可以接受。

最后是接口,实际项目可能用AXI-Stream或者自定义的并行接口。CORDIC核心本身是流式的,加一个简单的握手信号就能适配大多数接口。

我个人在多个项目里用过CORDIC,从简单的信号发生器到复杂的通信调制解调,它从来没让我失望过。唯一需要注意的是,CORDIC的精度和迭代次数是硬绑定的,你不能指望16次迭代给出20位的精度。在项目初期就把精度需求定清楚,后面会省很多事。

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

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

立即咨询