☰
FPGA纯Verilog手写CORDIC算法:实现三角函数计算与上板验证
2026/9/30 6:19:00 网站建设 项目流程

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

1.1 一个真实的需求场景

做数字信号处理或者通信基带的朋友,迟早会遇到一个问题:我需要算sin和cos,但手头只有一块FPGA,没有浮点单元,也不想占用宝贵的DSP资源。你可能会想,直接调用Xilinx的CORDIC IP核不就完了?确实可以,但如果你连IP核内部在干什么都不清楚,一旦时序不对、精度不够、资源爆了,你连从哪儿查起都不知道。

这个项目要干的事情很明确:在EGo1这块板子上,用纯Verilog手写一个CORDIC旋转模式的模块,输入一个角度,输出对应的sin和cos值,并且上板验证结果。EGo1是Xilinx Artix-7系列的一块教学板卡,资源不算富裕,正好适合拿来练手这种对资源敏感的算法实现。

CORDIC的全称是Coordinate Rotation Digital Computer,中文一般叫坐标旋转数字计算机。它的核心思想特别巧妙:用一系列固定角度的旋转去逼近任意角度,每次旋转只涉及移位和加法,完全不需要乘法器。这意味着你可以在几乎不消耗DSP Slice的情况下完成三角函数运算,代价是需要多个时钟周期迭代完成。

1.2 为什么不用查找表或者泰勒展开

你可能会问,算sin/cos有那么多方法,为什么偏偏选CORDIC?我当初也纠结过这个问题,实际对比下来是这样的:

查找表法最直观,把0到90度的sin值提前算好存进ROM,输入角度直接查表。问题是精度和存储深度成正比,你要做到16位精度,ROM的深度会大到离谱。而且EGo1上的Block RAM虽然够用,但为了一个sin函数吃掉大量BRAM,性价比太低。

泰勒展开在FPGA上实现起来需要乘法器,阶数低了精度不够,阶数高了DSP Slice消耗直线上升。对于Artix-7这种中低端芯片,DSP资源是要省着用的。

CORDIC的优势在于:只用移位、加法和一张很小的角度查找表。16次迭代就能达到16位左右的精度,资源消耗极低。缺点是吞吐率不高,因为要迭代,但很多场景下我们并不需要每个时钟周期都出一个结果。

注意:CORDIC不是万能的。如果你的系统要求单周期出结果,那还是老老实实用IP核或者查找表。CORDIC适合那些对资源敏感、对延迟不敏感的场景。

1.3 旋转模式的数学直觉

CORDIC旋转模式的几何意义其实很好理解。想象你在一个二维平面上有一个点(1, 0),你想把它旋转到角度θ的位置。CORDIC的做法不是一次性旋转θ,而是把θ拆解成一系列预先定义好的小角度之和。

这些预先定义的角度是atan(2^-i),其中i从0开始。比如atan(1)=45度,atan(0.5)=26.565度,atan(0.25)=14.036度,以此类推。每次迭代,我们判断当前剩余角度是正还是负,如果是正就顺时针转,如果是负就逆时针转。

每次旋转的公式经过化简后变成:

x_new = x - d * y * 2^-i y_new = y + d * x * 2^-i z_new = z - d * atan(2^-i)

其中d是方向因子,取+1或-1。你看,乘以2^-i在硬件里就是右移i位,根本不需要乘法器。这就是CORDIC的精髓所在。

迭代完成后,x和y会有一个增益,这个增益是所有cos(atan(2^-i))的乘积,约等于1.6468。所以最终结果需要乘以1/1.6468≈0.6073来修正。这个修正可以在初始化时就把x设为0.6073来解决,省去最后一步乘法。

2. 整体架构设计与关键参数选择

2.1 模块划分与数据流

整个设计我分了三个主要模块:角度预处理模块、CORDIC核心迭代模块、以及输出修正模块。角度预处理负责把输入角度映射到CORDIC能处理的范围内,核心迭代模块完成16次旋转,输出修正模块处理增益补偿和象限调整。

数据流是这样的:输入角度先经过预处理,判断在哪个象限,把角度折叠到[-90°, 90°]范围内。然后进入CORDIC核心,迭代16次。最后根据象限信息恢复sin和cos的符号。

为什么要做象限折叠?因为CORDIC的收敛范围是有限的,大约在[-99.7°, 99.7°]之间。如果你直接输入一个200度的角度,迭代是不会收敛的。所以必须先把角度映射到这个范围内。

2.2 定点数格式的选择

定点数的格式选择直接决定了精度和资源消耗。我选的是Q2.14格式,也就是2位整数位加14位小数位,总共16位。为什么这么选?

首先,sin和cos的值域是[-1, 1],所以整数位只需要1位符号位就够了。但中间计算过程中x和y的值会超过1,所以留2位整数位比较安全。14位小数位意味着角度分辨率大约是2^-14≈0.000061弧度,对应到sin值的精度大约是万分之一左右,对于教学演示和大多数控制应用足够了。

角度输入我也用的16位定点,Q3.13格式,范围是[-4, 4),对应[-720°, 720°],足够覆盖所有可能的输入。

实操心得:定点数格式不是越精确越好。每增加一位小数位,所有加法器和寄存器的位宽都要增加,资源消耗是线性增长的。先确定你的精度需求,再反推位宽。

2.3 迭代次数的权衡

迭代次数决定了精度。理论上每迭代一次,精度提高约1位。16次迭代对应16位精度,但实际上由于定点数的舍入误差,有效精度大概在13-14位左右。

我试过12次、16次和20次迭代。12次迭代的资源消耗明显更低,但精度只能到10位左右,sin值在小角度区域误差比较明显。20次迭代精度提升有限,但资源消耗增加了25%。最终16次是一个比较平衡的选择。

这里有个细节:第0次迭代的角度是45度,这个角度比较特殊,因为tan(45°)=1,移位0位就是原值。从第1次开始才是真正的移位操作。

2.4 流水线 vs 状态机

CORDIC核心有两种实现方式:全流水线和状态机迭代。

全流水线是把16次迭代展开成16级流水线,每个时钟周期出一组结果,吞吐率极高。但资源消耗也大,因为每一级都需要独立的寄存器和加法器。

状态机迭代是只用一个加法器组,每个时钟周期完成一次迭代,16个周期出一个结果。资源消耗小,但吞吐率低。

考虑到EGo1的资源有限,而且这个项目主要是教学演示,我选了状态机方案。如果你在实际产品中需要高吞吐率,可以改成流水线,代价是资源消耗增加大约8-10倍。

3. CORDIC核心的Verilog实现细节

3.1 角度查找表的生成

角度查找表存储的是每次迭代对应的atan(2^-i)值。这些值需要提前算好,转换成定点数格式。

我用Python生成了这张表,核心代码如下:

import math for i in range(16): angle = math.atan(2**(-i)) # 转换为Q3.13定点数 fixed = int(angle * (2**13)) print(f"i={i}, angle={math.degrees(angle):.4f}°, fixed={fixed}")

生成的结果中,第0项是atan(1)=0.785398弧度,对应定点数6434。第1项是atan(0.5)=0.463648弧度,对应3798。以此类推,角度越来越小。

在Verilog里我用一个case语句或者ROM来实现这张表。考虑到只有16项,用case语句综合出来的电路更简单,不需要占用Block RAM。

function [15:0] get_atan; input [3:0] idx; case(idx) 4'd0: get_atan = 16'd6434; 4'd1: get_atan = 16'd3798; 4'd2: get_atan = 16'd2007; 4'd3: get_atan = 16'd1019; 4'd4: get_atan = 16'd511; 4'd5: get_atan = 16'd256; 4'd6: get_atan = 16'd128; 4'd7: get_atan = 16'd64; 4'd8: get_atan = 16'd32; 4'd9: get_atan = 16'd16; 4'd10: get_atan = 16'd8; 4'd11: get_atan = 16'd4; 4'd12: get_atan = 16'd2; 4'd13: get_atan = 16'd1; 4'd14: get_atan = 16'd1; 4'd15: get_atan = 16'd0; default: get_atan = 16'd0; endcase endfunction

注意后面几项的值已经很小了,定点化之后变成了1或者0。这是正常的,因为atan(2^-14)已经小于2^-13了,在Q3.13格式下无法表示。

3.2 迭代状态机的设计

状态机是整个CORDIC核心的控制中枢。我设计了四个状态:IDLE、ITERATE、DONE、以及一个可选的HOLD状态。

IDLE状态等待启动信号。当start信号拉高时,把初始值加载到x、y、z寄存器中。x初始化为0.6073对应的定点数,y初始化为0,z初始化为输入角度。

ITERATE状态执行16次迭代。每次迭代根据z的符号位决定旋转方向。如果z的最高位是0(正数),说明目标角度在当前角度之上,需要逆时针旋转,d=+1。如果z的最高位是1(负数),需要顺时针旋转,d=-1。

这里有个细节:z寄存器用的是补码表示,所以判断正负只需要看最高位。但移位操作要注意,算术右移才能保持符号位。

// 迭代核心 always @(posedge clk) begin if (state == ITERATE) begin if (z[15] == 1'b0) begin // 逆时针旋转 x_next = x - (y >>> iter_cnt); y_next = y + (x >>> iter_cnt); z_next = z - atan_table[iter_cnt]; end else begin // 顺时针旋转 x_next = x + (y >>> iter_cnt); y_next = y - (x >>> iter_cnt); z_next = z + atan_table[iter_cnt]; end end end

DONE状态输出结果。此时x寄存器里是cos值,y寄存器里是sin值。但要注意,由于象限折叠的原因,可能需要根据原始角度的象限来调整符号。

3.3 象限折叠的实现

象限折叠是保证CORDIC收敛的关键步骤。输入角度范围是[-720°, 720°],需要映射到[-90°, 90°]。

具体做法是:先把角度对360度取模,然后判断在哪个象限。第一象限直接使用,第二象限用180度减去角度,第三象限用角度减去180度,第四象限用360度减去角度。同时记录象限信息,用于最后恢复符号。

在定点数里,360度对应的是2^15=32768(因为Q3.13格式下,4对应32768,360度对应2π弧度,而2π≈6.28,超出了Q3.13的范围)。等等,这里需要重新考虑。

实际上角度输入我用的是另一种定点格式。为了简化,我直接把角度归一化到[-1, 1]对应[-180°, 180°]。这样360度对应2.0,在Q3.13格式下就是16384。180度对应1.0,即8192。

象限判断就变成了看角度值的范围:

  • [-8192, 8192]:第一或第四象限,直接使用
  • [8192, 16384]:第二象限,用16384减去角度
  • [-16384, -8192]:第三象限,用-16384减去角度

踩过的坑:象限折叠的边界条件特别容易出错。我一开始没处理好180度的情况,导致输出在边界处跳变。后来加了饱和处理才解决。

3.4 增益补偿的处理

前面提到CORDIC迭代会引入约1.6468的增益。补偿方式有两种:初始化时预补偿,或者输出时后补偿。

我选的是初始化预补偿,把x的初始值设为0.6073而不是1.0。这样迭代完成后直接就是正确的结果,不需要额外的乘法器。

0.6073在Q2.14格式下是0.6073 * 16384 ≈ 9950。所以x的初始值设为16'd9950。

但这里有个精度问题:0.6073是近似值,实际增益的倒数约等于0.607252935。用9950/16384=0.607299,误差在万分之一左右,可以接受。

4. EGo1上板验证与调试实录

4.1 硬件连接与引脚分配

EGo1板卡上有8个拨码开关、5个按键、8个LED灯、4位数码管,还有VGA和音频接口。我的验证方案是用拨码开关输入角度的高8位,用LED显示结果的符号位,用数码管显示数值。

具体的引脚分配需要在XDC约束文件里写清楚。EGo1的引脚定义在板卡手册里有,我这里列几个关键的:

# 时钟引脚 set_property PACKAGE_PIN P17 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] # 拨码开关 set_property PACKAGE_PIN G2 [get_ports {sw[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {sw[0]}] # ... 其他开关类似 # LED set_property PACKAGE_PIN J1 [get_ports {led[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {led[0]}]

时钟我用的是板载的100MHz晶振,经过分频后得到10MHz作为CORDIC的工作时钟。为什么要分频?因为100MHz下状态机跑得太快,数码管刷新跟不上,而且功耗也高。10MHz足够用了。

4.2 测试向量的设计

上板之前,我先在仿真里跑了一组测试向量。测试向量的设计很关键,要覆盖各种边界情况:

测试角度预期sin预期cos测试目的
0°0.00001.0000零点验证
30°0.50000.8660常规角度
45°0.70710.7071特殊角度
90°1.00000.0000象限边界
180°0.0000-1.0000符号处理
270°-1.00000.0000第三象限
-45°-0.70710.7071负角度

仿真结果和预期值的误差在±2个LSB以内,说明设计是正确的。

4.3 实际调试中遇到的问题

问题一:输出结果一直是0。排查发现是状态机的start信号没有正确同步。EGo1的按键没有消抖,按下去的时候产生了多个脉冲,状态机反复复位。加了一个简单的消抖模块后解决。

问题二:小角度时误差特别大。比如输入1度,sin值应该是0.0175,但输出是0.0198。后来发现是角度查找表在后期精度不够,atan(2^-13)和atan(2^-14)都变成了1,导致小角度时补偿过度。解决办法是增加迭代次数到18次,或者改用Q4.12格式给角度更多小数位。

问题三:资源利用率超预期。综合报告显示LUT使用率达到了45%,比我预估的高。分析发现是移位操作综合出了大量的多路选择器。后来把变量移位改成了case语句的固定移位,LUT使用率降到了32%。

实操心得:Vivado的综合策略对CORDIC的资源消耗影响很大。用Area Optimization策略比Default策略能省大约15%的LUT,但时序会差一些。如果你的时钟频率不高,强烈建议用Area Optimization。

4.4 资源消耗与性能数据

最终的综合实现报告:

资源类型使用量可用量利用率
LUT1247634001.97%
FF8921268000.70%
DSP02400.00%
BRAM01350.00%

可以看到DSP和BRAM完全没有使用,这正是CORDIC的优势所在。LUT和FF的使用量也很低,说明这个设计非常轻量。

时序方面,在10MHz时钟下,建立时间余量有95ns以上,非常充裕。我试过把时钟提到50MHz,也能正常工作,余量还有12ns左右。

5. 常见问题排查与优化技巧

5.1 精度不达标的排查思路

如果你发现输出精度不够,按照以下顺序排查:

首先检查角度查找表的值是否正确。用Python重新生成一遍,和Verilog里的值逐项对比。我遇到过因为手误把某个值写错,导致整体精度下降的情况。

其次检查定点数的舍入方式。Verilog的右移是截断而不是四舍五入,这会引入直流偏差。解决办法是在移位前加上半个LSB。比如要右移i位,先加上(1<<(i-1))再移位。

最后检查迭代次数是否足够。一个简单的判断方法:看第15次迭代时z寄存器的值。如果还大于2个LSB,说明迭代次数不够。

5.2 时序不收敛的解决办法

CORDIC的迭代路径上有一个加法器和一个移位器,组合逻辑延迟不算大。但如果你的时钟频率很高,还是可能时序不收敛。

最有效的办法是在迭代之间插入流水线寄存器。代价是延迟增加,但吞吐率不变。具体做法是把一次迭代拆成两个时钟周期:第一个周期算x和y,第二个周期算z。

另一个办法是降低迭代次数。从16次降到12次,关键路径缩短25%,时序会好很多。精度损失大约2位,看你能否接受。

5.3 如何扩展到其他函数

CORDIC不仅能算sin和cos,还能算很多其他函数。旋转模式下,如果你把输入角度设为atan(y/x),输出的x就是sqrt(x²+y²)的缩放值。这就是CORDIC算平方根的原理。

双曲模式下,CORDIC可以算sinh、cosh、exp和ln。但双曲模式的收敛范围更窄,需要重复某些迭代才能保证收敛。

线性模式下,CORDIC可以做乘法。把y初始化为被乘数,z初始化为乘数,迭代完成后z就是乘积。不过这种乘法方式效率不高,不如直接用DSP。

5.4 常见问题速查表

现象可能原因解决办法
输出恒为0start信号未同步加消抖或同步器
输出恒为最大值状态机卡死检查状态转移条件
小角度误差大查找表精度不够增加迭代次数或改定点格式
大角度不收敛象限折叠错误检查折叠逻辑的边界条件
资源消耗过高变量移位综合出多路器改用固定移位
时序不收敛组合逻辑路径太长插入流水线寄存器
输出有周期性跳变增益补偿不准确重新计算补偿系数

6. 从项目中学到的经验

这个项目做完之后,我对CORDIC的理解深入了很多。最大的体会是:定点数的格式选择比算法本身更重要。同样的CORDIC算法,用Q2.14和Q4.12实现出来,精度和资源消耗能差出一倍。

另一个体会是关于验证的。仿真通过不代表上板能跑,上板能跑不代表精度达标。我建议至少做三层验证:行为级仿真验证算法正确性,综合后仿真验证时序,上板实测验证真实环境下的表现。

最后说一个容易被忽略的点:CORDIC的输出是连续变化的,但如果你用数码管显示,刷新率不够的话会看到闪烁。我后来加了一个锁存器,每100ms更新一次显示,看起来就稳定多了。这个细节在文档里不会写,但实际做项目的时候一定会遇到。

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

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

立即咨询