简介:面向FPGA学习者的Verilog实现资源,聚焦基于LUT查找表方法的Cordic算法,可输出正弦与余弦波形,适合本科、硕士及博士教研使用。压缩包共496个文件,约31.03MB,核心包括.v源码、.xpr工程文件、.vcd仿真波形、.tcl运行脚本及.avi操作录屏,另有.bat批处理与.mem查找表数据等辅助文件,便于完整复现算法流程。已有1776人学习下载,资源将查找表量化、角度迭代与正余弦输出组织为清晰工程结构,配套testbench测试程序与代码操作视频,可帮助快速掌握基于查找表的Cordic设计思路。建议使用Vivado 2019.2及以上版本,打开工程后参照录像操作,注意工程路径需为英文,避免中文引发编译问题。
1. 大实话:CORDIC 不直接查正弦表,角度表才是发动机
一提到在 FPGA 里算正弦余弦,很多人第一反应是查表,把 1024 个点的 sin 值塞进 ROM,按相位取地址。但角度一旦连续变化,直接查表的内存开销会直线上升,这就是 CORDIC 算法存在的理由。基于 LUT 查找表方法的 Cordic 算法实现,本质上是把“查表”用在另一个地方:表里存放的是一组预计算好的旋转角 arctan(2⁻ⁱ),每次迭代查一次表、判断一次符号、做一次移位和加减,十几拍后同时输出 sin 和 cos。它的精度由表长和迭代次数决定,不依赖浮点运算单元。这篇文章的目标是讲透这套原理,然后用 Verilog 把它写成一个可综合模块,配合 testbench 测试程序验证输出。代码操作视频作为辅助参考,但真正需要吃透的是定点格式、LUT 内容和迭代状态机的配合方式。
2. 从旋转公式到角度查找表:LUT 里存的不是 sin/cos
2.1 把普通旋转换成可迭代的加减法
直角坐标系里旋转一个向量 (x, y),标准写法是:
x' = x·cosθ − y·sinθ
y' = x·sinθ + y·cosθ
问题很明显,硬件直接算 sinθ 和 cosθ 本身就是我们要解决的问题,套娃。CORDIC 的思路是不一次转到位,而是把目标角 θ 拆成一串递减的小角 θ₀, θ₁, θ₂...,每次只旋转其中一个小角度,逐步逼近。
强制迭代收敛的一个关键技巧是,每一步选一个特殊角,使 tanθᵢ = 2⁻ⁱ。此时旋转公式里 sinθᵢ 和 cosθᵢ 的比例关系发生变化:
xᵢ₊₁ = xᵢ − dᵢ·yᵢ·2⁻ⁱ
yᵢ₊₁ = yᵢ + dᵢ·xᵢ·2⁻ⁱ
dᵢ 取值 +1 或 −1,由当前剩余角度符号决定。每步旋转之后还残留一个 1/cosθᵢ 的模长伸缩因子,多步连乘后是个常数,用 K 表示。于是在初始化时把 x 设成 1/K,y 设成 0,所有迭代结束后,x 和 y 就分别逼近 cosθ 和 sinθ。整个迭代过程只需要加法器、移位器和比较器,没有乘法器。
2.2 角度查找表的内容:atan(2⁻ⁱ) 的定点值和表长选择
既然每一步的旋转角是固定的,就没有必要每次去算 arctan。把这些角度事先算好放进查找表,就成了“LUT 查找表方法”这个名字的由来。这里的 LUT 内容不是 sin 值,也不是 cos 值,而是一排角度常数。每次迭代用迭代序号 i 去取第 i 个表项,再根据角度寄存器 z 的符号决定加还是减。
表长取多少取决于最终精度需求,常用的参考值是 13 项到 16 项。13 项对应的角度范围约为 99.88°,能够覆盖 ±π/2 的收敛区间。表太长意义不大,角度已经小到低于定点数最低位能表示的分辨率,再查也是白查。表长和定点位宽的匹配关系,我放到第 5 章单独说。
表项定点化时,需要选择一个小数位数。下文模块的角度寄存器用 Q2.13 格式,即 1 个符号位加 2 个整数位加 13 个小数位,乘以 8192 后取整:
| 迭代 i | atan(2⁻ⁱ) 理论值 / rad | Q2.13 定点值(十进制) |
|---|---|---|
| 0 | 0.7853981634 | 6434 |
| 1 | 0.4636476090 | 3798 |
| 2 | 0.2449786631 | 2007 |
| 3 | 0.1243549945 | 1019 |
| 4 | 0.0624188100 | 511 |
| 5 | 0.0312398334 | 256 |
| 6 | 0.0156237286 | 128 |
| 7 | 0.0078123411 | 64 |
| 8 | 0.0039062301 | 32 |
| 9 | 0.0019531225 | 16 |
| 10 | 0.0009765622 | 8 |
| 11 | 0.0004882812 | 4 |
| 12 | 0.0002441406 | 2 |
看到从第 5 项开始定点值就是 256、128、64 这类 2 的幂次,这和角度本身的二进制规律是有关系的。实际写代码时可以直接用十进制常数,编译器会把它解析成二进制补码。
2.3 迭代增益与定点格式:9950 是怎么定出来的
每一轮旋转都会带来模长变化,模长增益是 cosθᵢ 的倒数。全部迭代完成后,总增益 K 是每一项 sqrt(1 + 2⁻²ⁱ) 的连乘积:
K ≈ 1.646760258
因此初始化时要把初始向量从 (1, 0) 改为 (1/K, 0)。定点计算时先定 x/y 的数据格式为 Q1.14,即 1 个符号位加 1 个整数位加 14 个小数位。Q1.14 能表示的最大值是 1.9999,正好放得下 cos 和 sin 在 [-1, 1] 范围内的输出,迭代中间过程也不会溢出。
换算成定点就是:
x₀ = 1.646760258⁻¹ × 2¹⁴ ≈ 0.607252935 × 16384 ≈ 9950
这个 9950 直接写死在模块里当常量,不需要额外乘法器。输入角度 z₀ 用 Q2.13 格式,也就是把弧度乘以 8192。模块内部查表拿到的角度值同样按 Q2.13 预先存好,角度累加和输入保持同一个格式。
注意 x/y 用 Q1.14,角度用 Q2.13,两个格式的小数位不同,这是刻意的。x/y 只需要表示 [-1, 1],角度需要装下 [-π, π],所以多给一个整数位。混用时要保证查表角度和 z 寄存器格式一致,否则每轮角度累加都会偏差。
3. Verilog 实现 LUT-CORDIC 核心:状态机、移位、符号判断
3.1 CORDIC 模块端口约定:角度输入与输出的定点格式
先定接口。这是一个串行复用结构的 CORDIC,角度进来后经过 13 轮迭代输出结果。端口定义如下:
| 信号 | 位宽 | 方向 | 说明 |
|---|---|---|---|
| clk | 1 | input | 工作时钟 |
| rst_n | 1 | input | 异步复位,低有效 |
| start | 1 | input | 启动脉冲,至少保持一个时钟高电平 |
| angle_in | 16 | input | Q2.13 格式输入角度,带符号 |
| sin_out | 16 | output | Q1.14 格式正弦输出,带符号 |
| cos_out | 16 | output | Q1.14 格式余弦输出,带符号 |
| done | 1 | output | 迭代完成标志,持续一个时钟周期 |
输入角度是带符号数,仿真器里给正角度就是逆时针旋转,给负角度就是顺时针。输出不需要额外缩放,因为初始化时已经用 INV_GAIN 处理过模长增益。
3.2 LUT 用 function 还是 case:面积、时序和可读性取舍
角度表放在 function 里是最直接的做法,每次迭代调用一次atan_lut(cnt),综合工具会把它展开成一组组合逻辑。13 个表项规模很小,在 FPGA 里就是几十个 LUT,时序压力可以忽略。如果以后把迭代次数扩展到 20 次以上,或者位宽变大,再考虑用分布式 RAM 或者块 RAM 存表,那样地址访问更规整,也便于后期的时序收敛。
function 版本的可读性最好,适合作为模板改参数。它和 case 语句相比,综合结果基本一样,区别只在调用方式上。function 返回带符号数,方便后续$signed运算。
3.3 迭代状态机:一次启动、串行复用加减法器
整个模块只需要两个状态:IDLE 和 CALC。IDLE 收到 start 后装载初始值,x 设为 9950,y 清零,z 设为输入角度;然后进入 CALC,每拍处理一轮迭代。
每一轮迭代先判断 z 的符号位。z 是 16 位带符号数,最高位为 1 表示负角度,此时旋转方向是顺时针,相当于 dᵢ = −1;z 最高位为 0 则逆时针旋转,dᵢ = +1。旋转的同时更新 z,让剩余角度向 0 收敛。
移位操作使用算术右移>>>,保证负数移位后符号位不丢失。这是实现里最容易出错的地方,如果误写成逻辑右移>>,y 的负数部分会直接算错,仿真波形看起来就是输出完全不收敛。
计数器 cnt 控制迭代轮次,达到 ITER 后把 x_reg 和 y_reg 分别锁存到 cos_out 和 sin_out,然后拉高 done,回到 IDLE。
3.4 完整可综合的 cordic_lut 模块代码
module cordic_lut #( parameter WIDTH = 16, parameter ITER = 13 )( input wire clk, input wire rst_n, input wire start, input wire signed [WIDTH-1:0] angle_in, // Q2.13 output reg signed [WIDTH-1:0] sin_out, // Q1.14 output reg signed [WIDTH-1:0] cos_out, // Q1.14 output reg done ); // CORDIC 模长增益的倒数,Q1.14 格式 localparam signed [WIDTH-1:0] INV_GAIN = 16'd9950; // 角度查找表:返回 arctan(2^-idx),Q2.13 格式 function automatic signed [WIDTH-1:0] atan_lut(input integer idx); begin case (idx) 0: atan_lut = 16'd6434; 1: atan_lut = 16'd3798; 2: atan_lut = 16'd2007; 3: atan_lut = 16'd1019; 4: atan_lut = 16'd511; 5: atan_lut = 16'd256; 6: atan_lut = 16'd128; 7: atan_lut = 16'd64; 8: atan_lut = 16'd32; 9: atan_lut = 16'd16; 10: atan_lut = 16'd8; 11: atan_lut = 16'd4; 12: atan_lut = 16'd2; default: atan_lut = 16'd0; endcase end endfunction localparam S_IDLE = 2'b00; localparam S_CALC = 2'b01; reg [1:0] state; reg signed [WIDTH-1:0] x_reg, y_reg, z_reg; reg [$clog2(ITER+1)-1:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; done <= 1'b0; x_reg <= 16'd0; y_reg <= 16'd0; z_reg <= 16'd0; cnt <= 0; sin_out <= 16'd0; cos_out <= 16'd0; end else begin case (state) S_IDLE: begin done <= 1'b0; if (start) begin x_reg <= INV_GAIN; y_reg <= 16'd0; z_reg <= angle_in; cnt <= 0; state <= S_CALC; end end S_CALC: begin if (cnt < ITER) begin if (z_reg[WIDTH-1]) begin // z < 0:向顺时针方向旋转 x_reg <= $signed(x_reg) + ($signed(y_reg) >>> cnt); y_reg <= $signed(y_reg) - ($signed(x_reg) >>> cnt); z_reg <= $signed(z_reg) + $signed(atan_lut(cnt)); end else begin // z >= 0:向逆时针方向旋转 x_reg <= $signed(x_reg) - ($signed(y_reg) >>> cnt); y_reg <= $signed(y_reg) + ($signed(x_reg) >>> cnt); z_reg <= $signed(z_reg) - $signed(atan_lut(cnt)); end cnt <= cnt + 1'b1; end else begin sin_out <= y_reg; cos_out <= x_reg; done <= 1'b1; state <= S_IDLE; end end default: state <= S_IDLE; endcase end end endmodule代码里每个非阻塞赋值右侧用的都是更新前的寄存器值,这一点至关重要。x_reg 更新用旧 y_reg 移位,y_reg 更新也用旧 x_reg 移位,同一拍内没有数据依赖,所以两行赋值可以同时执行。如果在这里误用阻塞赋值,y 更新会先拿到新的 x,迭代公式就乱了,输出会发散。
>>>右边的 cnt 是变量移位,FPGA 综合时会映射成桶形移位器。13 次迭代范围内面积不大,资源吃紧可以改成固定移位加数据选择器的结构。信号atan_lut(cnt)每次调用都会经过组合逻辑译码,如果发现组合路径比较长,可以把它打一拍,代价是迭代周期多一个时钟。
模块的 ITER 参数控制迭代次数,但要注意 LUT 表项只有 13 项。如果 ITER 改成 16,前 13 项正常查表,后面 3 次查到的都是 default 值 0,结果相当于白跑 3 拍。想增加迭代,表项也要同步扩展。
4. testbench 怎么写才能证明 CORDIC 算对了
4.1 testbench 的基本骨架:时钟、复位和启动握手
testbench 要做的事情有三件:产生时钟和复位、向模块发起一次启动握手、收集结果并和数学参考值比对。最简单的结构是写一个 task,把角度作为参数传进去,task 内部负责拉高 start、等待 done、读输出。
`timescale 1ns/1ps module tb_cordic_lut; reg clk; reg rst_n; reg start; reg signed [15:0] angle_in; wire signed [15:0] sin_out; wire signed [15:0] cos_out; wire done; cordic_lut #( .WIDTH(16), .ITER (13) ) dut ( .clk (clk), .rst_n (rst_n), .start (start), .angle_in (angle_in), .sin_out (sin_out), .cos_out (cos_out), .done (done) ); initial clk = 1'b0; always #5 clk = ~clk; task run_test(input [15:0] angle_fix); real rad; real ref_sin, ref_cos; real sin_v, cos_v; begin @(posedge clk); start = 1'b1; angle_in = angle_fix; @(posedge clk); start = 1'b0; wait (done); @(posedge clk); rad = angle_fix / 8192.0; ref_sin = $sin(rad); ref_cos = $cos(rad); sin_v = sin_out / 16384.0; cos_v = cos_out / 16384.0; $display("angle=%0d (%.6f rad) | sin=%.6f (ref %.6f, err %+.6f) | cos=%.6f (ref %.6f, err %+.6f)", angle_fix, rad, sin_v, ref_sin, sin_v - ref_sin, cos_v, ref_cos, cos_v - ref_cos); end endtask initial begin rst_n = 1'b0; start = 1'b0; repeat (4) @(posedge clk); rst_n = 1'b1; run_test(16'd0); run_test(16'd4289); // 30 度,pi/6 run_test(16'd6434); // 45 度,pi/4 run_test(16'd12868); // 90 度,pi/2 run_test(-16'd6434); // -45 度 $finish; end endmodule这段代码里的 task 先等一个时钟上升沿再拉高 start,保证和后级模块的时钟沿对齐。wait(done)是异步等待,done 拉高的同时 sin_out 和 cos_out 也正好更新完。多等一拍再去取值是为了避开 done 出现沿附近的时序不确定性,在仿真层面更稳。
4.2 用 $sin/$cos 自动比对:误差和 Q 格式换算是重点
测试不能只看波形像不像,要用 Verilog 自带的$sin和$cos函数算出参考值,再和 CORDIC 输出比较。角度输入是 Q2.13,先除以 8192 转换回浮点弧度;输出是 Q1.14,除以 16384 得到实际小数。两者尺度不同,换算时不能搞混,这是新手最容易在 testbench 里犯的错误。
模型里$sin返回值是 real 类型,直接用浮点运算。打印误差的好处是能直观看到 13 次迭代大概能到小数点后第几位。一般 16 位定点下,误差在 0.001 到 0.003 之间属于正常范围,超过 0.01 就要检查定点格式或者迭代方向。
4.3 边界角度覆盖:0°、90°、-45° 怎么选
手工验证不需要把所有角度都穷举一遍,但必须有针对性地覆盖几个关键位置。参考测试矩阵如下:
| 输入角度 | 定点值 | 期望 sin | 期望 cos |
|---|---|---|---|
| 0° | 0 | ≈ 0 | ≈ 1.0 |
| 30° | 4289 | ≈ 0.5 | ≈ 0.866 |
| 45° | 6434 | ≈ 0.7071 | ≈ 0.7071 |
| 90° | 12868 | ≈ 1.0 | ≈ 0 |
| -45° | -6434 | ≈ -0.7071 | ≈ 0.7071 |
90° 是最容易出问题的点。它处于收敛域边界附近,如果 RTL 里 z 的符号判断写得不对,结果会差一个符号位。0° 用来验证初始化路径是否干净。负角度验证符号扩展和算术移位处理是否正确。这几个点过了,常规角度基本不会翻车。
5. LUT 尺寸、收敛域和三个能直接落地的调优技巧
5.1 表长和迭代次数必须匹配位宽
迭代次数不是越大越好。16 位定点下,每次迭代最多贡献约 2⁻ⁱ 弧度的角度修正,当 i 超过 13 或 14 时,单个表项已经小于量化步长,再加迭代只是空转。把 ITER 从 13 改成 16,前 13 轮正常,后面 3 轮从 LUT 里取到的都是 default 值 0,z 不再变化,输出不会有任何改善,反而多耗 3 个时钟周期。反过来,如果位宽扩到 24 位,表长不跟着扩到 20 项以上,精度会卡死在表长这个地方。
5.2 落入收敛域之前,先做象限映射
CORDIC 的收敛域只有约 ±99.88°,直接输入 180° 是转不过去的。工程里要在进 CORDIC 前把角度折叠到第一象限或第四象限,再对输出符号做修正。处理原则是:
- 角度在 [0, π/2],直接输入,输出不变。
- 角度在 (π/2, π],输入 π − θ,cos 取负。
- 角度在 [-π/2, 0],直接输入,sin 取负。
- 角度在 [-π, -π/2),输入 −π − θ,sin 和 cos 都要翻转。
这段折叠逻辑可以用纯组合逻辑写在 angle_in 和 z_reg 之间,也可以放在 CORDIC 外部。尽量放外部,保持 CORDIC 核心结构简单,也方便复用。
5.3 把 LUT-CORDIC 和其他替代方案摆在一起选型
连续角度输入时,LUT-CORDIC 比直接查 sin/cos 表省内存;离散角度固定时,直接查表反而更划算。比如只需要输出 0°、15°、30° 这类固定相位,直接一块 BRAM 存 64 个采样点的正弦表,延迟一拍出结果,功耗也更低。CORDIC 的独特优势是角度任意连续可算且资源固定。做 FPGA 工程选型时,先看角度集合是不是离散的,再看允许的流水延迟,最后决定用查表还是用 CORDIC。代码操作视频里如果演示了不同角度连续输入的仿真过程,重点观察 z 寄存器每一轮的变化,确认剩余角度在逐步减小而不是来回跳变。这个过程能看明白,LUT 内容和迭代控制就彻底理解了。
本文还有配套的精品资源,点击获取