☰
FPGA三角函数实现:CORDIC定点化五步落地与Verilog实践
2026/11/4 21:42:15 网站建设 项目流程

1. 需求拆解:三角函数在 FPGA 里到底卡在哪

1.1 从一次"查表查不动"的现场说起

前一阵帮人看一个图像处理的小 Demo,需要在 FPGA 里对一批像素做极坐标变换,每帧十几万个点,每个点都要算一次正弦和余弦。项目一开始图省事,直接堆了一张 4096 深度的 ROM,地址取角度高 12 位,输出 16 位定点正弦值。跑起来确实能用,但很快就撞墙了:第一,12 位地址对应的角度分辨率是 2π/4096 ≈ 1.5 毫弧,换算成相对误差已经到 1e-3 量级,做坐标旋转时边缘能看出明显的锯齿和抖动;第二,想把精度提到 16 位,ROM 深度直接涨到 65536×16 bit,一个块 RAM 塞不下,要拆成好几块,布局布线开始难受;第三,如果还想同时输出余弦,存储量再翻一倍。这时候"查表"这条路的性价比就崩了。

这就是典型的场景:FPGA 上算三角函数,难的从来不是"算不出来",而是在有限的 LUT、BRAM 和时钟周期里,把精度、资源、延迟三者的平衡点找准。CORDIC 算法之所以在 FPGA 圈子里被反复提起,就是因为它把"乘法"这个昂贵操作彻底干掉了——整个迭代过程只有加法、减法,加上一个随迭代序号变化的算术右移。右移在硬件里就是连线,不占逻辑资源。这意味着你可以在一个中等规模的器件上,用几百个 LUT,得到 12 到 14 位有效精度的正余弦输出,而且延迟是可预测的固定拍数。

这篇内容面向的是已经能写基本时序逻辑、但还没真正把 CORDIC 落到 RTL 里的朋友。我会把整个实现拆成五步:接口与象限折叠、增益预补偿、迭代核心与符号判决、输出还原与舍入、串行与流水的架构选择。每一步都会讲清楚为什么这么做、参数怎么算、代码怎么写,最后附上完整可跑的 Verilog 模块、验证方法、误差实测数据和一份踩坑速查表。看完你应该能直接在自己的工程里把这个模块挂上去用。

1.2 四条实现路线的横向对比

在动手之前,值得先把可选路线摆开对比一遍,因为选错路线后面全是返工。常见的四条路:直接查找表、多项式逼近(泰勒或切比雪夫)、调用浮点或定点运算 IP、CORDIC 迭代。这四条没有绝对优劣,关键看约束条件。

方案精度天花板资源构成吞吐/延迟适用场景主要痛点
直接查找表受 ROM 深度限制,16 位以上代价陡增大块 BRAM,少量地址逻辑1 点/时钟,延迟 1~2 拍精度 ≤12 位、角度范围固定存储随精度指数增长
多项式逼近分段后可到 16 位以上多个乘法器 / DSP 块流水后 1 点/时钟需要高精度且器件 DSP 富余乘法器占用大,系数整定繁琐
浮点运算 IP很高极大,通常不适合量产逻辑延迟长且不固定原型验证、算法参考资源与功耗不可接受
CORDIC 迭代与位宽和迭代次数线性相关,14~18 位很常见只有加法器和移位连线串行 1 点/N 拍,流水 1 点/时钟中高精度、资源敏感、需要确定性延迟需要处理收敛域和增益补偿

我选 CORDIC 的核心理由有两条。一条是资源形态友好:它吃的是 LUT 和寄存器,不吃 DSP,也不吃 BRAM(除了那张极小的 atan 表,通常直接用 case 语句综合成组合逻辑)。在一个 DSP 已经被滤波器占满的设计里,这一点很关键。另一条是延迟确定:串行版就是固定的 N+2 拍,不多不少,对于需要和其他流水线对齐的场合非常好安排。

反过来说,如果你的精度需求只有 8 到 10 位,角度范围又固定在一个窄区间,老老实实查表反而更省事,别为了用算法而用算法。我见过有人为了"显得专业"硬上 CORDIC,结果调试象限折叠调了两天,其实一张 1024 深的表就能满足需求。

1.3 五个步骤的全景与数据流

先把整体流程摆清楚,后面每一节都是对其中一步的展开。整个模块的输入是一个用定点表示的归一化角度,输出是相同定点格式的正弦和余弦值。

第一步解决"输入合法性":把任意角度折叠到 CORDIC 能收敛的区间内,同时记录一个符号翻转标志。第二步解决"幅度偏差":CORDIC 每转一次都会把向量拉长约 1.6468 倍,必须提前把这个增益除掉,方法就是给 x 通路装一个 1/K 的初值。第三步是核心:N 次迭代,每次根据 z 的符号决定转向,同时更新 x、y、z 三路寄存器。第四步把结果从内部表示还原成外部约定格式,处理符号翻转和截位舍入。第五步是架构层面的选择:是省资源用串行状态机,还是拼吞吐用全流水。

数据流大致是这样:i_phase进入折叠逻辑,输出phase_fold和fold_neg;phase_fold装入 z 寄存器,fold_neg存入一个标志位;迭代过程中 x、y、z 同步更新,迭代计数到 N 后进入输出状态;输出状态把 x、y 按fold_neg取反,写入o_cos、o_sin,同时拉高o_valid。整个过程没有乘法,没有除法,没有浮点。

值得提前说明的是,这里的角度采用"归一化到 π"的表示法,也就是数值 1.0 代表 π 弧度。这么做的原因在 2.2 节会详细讲,简单说就是能把 atan 表直接存成 atan(2^-i)/π,省掉一次乘以 π 的运算,同时角度比较的阈值也能取成 0.5 这种漂亮的整数。

2. 把数学掰开:CORDIC 的旋转内核与定点化

2.1 迭代公式与增益常数 K 的来历

CORDIC 旋转模式的本质,是把一个目标角度拆成一系列"预定角度"的和或差。这些预定角度是 atan(2^0)、atan(2^-1)、atan(2^-2)……每次只决定"加"还是"减",系数固定,所以旋转一个角度只需要判断符号。

写成迭代式就是:

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] * atan(2^(-i)) d[i] = +1 if z[i] >= 0 else -1

其中2^(-i)在硬件里就是右移 i 位,d[i]就是 z 的符号位取反。三路各一个加减法器,这就是全部运算量。

但这里藏了一个坑:每次旋转的模长不是 1,而是sqrt(1 + 2^(-2i))。因为旋转向量是(1, 2^-i),模长自然是sqrt(1 + 4^-i)。把所有 N 次旋转的模长乘起来,就得到总增益:

K = Π sqrt(1 + 2^(-2i)),i 从 0 到 N-1

我手算过前几项:i=0 时是 sqrt(2)≈1.414214,乘上 i=1 的 sqrt(1.25)≈1.118034 得 1.581139,再乘 i=2 的 1.030777 得 1.629801,继续乘下去会迅速收敛。到 i=9 的时候已经是 1.646759,i=14 时基本就是极限值 1.6467602581。也就是说,只要迭代次数超过 12 次,K 就可以当成一个常数处理,取 1.6467603 足够了,误差在 1e-7 量级,远小于定点量化误差。

于是补偿方法就明确了:在迭代开始前,把 x 的初值设成 1/K ≈ 0.607252935,y 设成 0,z 设成目标角度。这样经过 N 次旋转后,向量的模长正好被拉回到 1,输出就是(cos θ, sin θ)。如果懒得预补偿,也可以在最后乘一次 1/K,但那就得引入一个乘法器,白白浪费资源,不划算。

提示:1/K 的定点量化值必须和各通路的 Q 格式对齐。以 Q2.14(1.0 = 16384)为例,0.607252935 × 16384 = 9949.2,取 9949,引入的相对误差约 2e-5,对 14 位精度的系统完全可以忽略;但如果你的目标是 18 位以上精度,就得把位宽加宽到 22 位再取整。

2.2 角度归一化:为什么用 π 做单位

绝大多数 CORDIC 教程里的角度表都是以弧度为单位的,比如 atan(1)=0.785398、atan(0.5)=0.463648。照着写当然能跑,但在硬件里会多出两处麻烦:一是这些小数在定点表示下不"整",二是判断角度是否超出收敛域时要用到 π 的近似值,容易写错。

我习惯的做法是把角度归一化,令1.0 代表 π 弧度。这样带来几个直接好处:

第一,atan 表的值变成atan(2^-i)/π,i=0 时正好是 0.25,定点表示就是 4096(Q3.14),一个非常干净的数。第二,收敛域直接变成 ±0.555(因为 CORDIC 收敛范围是 ±99.88°,除以 180 得 0.5549),折叠到 ±0.5 就是 ±π/2,判断条件写成i_phase > HALF_PI即可。第三,输入输出都不需要乘除 π,外部模块只要按同样的约定给角度就行。

归一化之后,sin和cos的数值本身不受影响,因为它们只跟实际角度有关,跟你怎么表示角度无关。这一点很多人第一次会绕进去,觉得"归一化了是不是输出也要改",不用,输出还是标准的 [-1, 1] 范围。

需要注意角度通路的位宽不能太窄。如果只有 16 位 Q2.14,那么能表示的范围是 [-2, 2),1.0 这个值就表示不出来(16 位有符号最大是 1.99994)。所以我把角度通路定成18 位 Q3.14,范围 [-2, 2),1.0 = 16384 可以精确表示,折叠时做 ±1.0 的加减也不会溢出。这个细节在仿真里不一定暴露,但综合后如果位宽不够,边界角度会直接错。

2.3 位宽与迭代次数的定量预算

位宽怎么定、迭代多少次够,这是 CORDIC 落地时最常被含糊过去的部分。我的做法是先算误差预算,再反推参数。

迭代次数的选择有一个很干脆的判据:atan(2^-i) ≈ 2^-i,当2^-i小于角度通路的 LSB 时,再迭代下去已经无法改变 z 的符号判决,纯属浪费。角度通路 Q3.14 的 LSB 是2^-14 ≈ 6.1e-5(归一化单位),对应弧度6.1e-5 × π ≈ 1.92e-4。而 atan(2^-14)/π ≈ 3.9e-5,已经小于 LSB,所以迭代 14 次(i=0 到 13)就基本饱和了。再多做几级,理论上还能压一点残差,但收益远小于代价。

x/y 通路的位宽要比角度宽,原因是中间量会经历 N 次加减和右移,每次右移都会丢掉低位,误差逐级累积。如果 x/y 和角度同宽,累积误差大约会吃掉 2 到 3 个有效位。我一般取 x/y 比角度宽 2 到 4 位。14 位小数配 18 位总线(Q2.14)是个很稳的组合,实测误差能压在 2e-4 以内。

下面是 14 次级联的误差预算表,这个表我当时是真的拿 Excel 一项项算的,不是拍脑袋:

误差来源产生机理量级(弧度)备注
输入角度量化外部角度转 Q3.14 时的舍入9.6e-50.5 LSB,无法消除,只能加宽
角度残差迭代停止时 z 未归零≤1.2e-4上界约为 atan(2^-13)
atan 表量化表值四舍五入到 LSB3e-5~1e-4与迭代级数相关,末几级影响最大
增益常数失配1/K 定点截断<1e-6可忽略
x/y 舍入累积每级算术右移丢低位~1.1e-4与位宽强相关
输出截位Q2.14 转 Q1.15 时3e-5加 0.5 LSB 可转为无偏

把这些按 RMS 合成,总误差落在 2e-4 左右。对应到满幅正弦的 SNR,大约是 20×log10(0.707/2e-4) ≈ 71 dB,折合有效位数约 11.5 到 12 位。实测数据在 4.2 节给出,和这个估算基本吻合。

如果你需要 16 位有效精度,那就得把角度通路加到 Q3.18(20 位或 22 位),迭代 18 次,x/y 用 22 位。资源大概翻一倍。这里没有魔法,精度就是拿位宽和迭代次数换的。

3. 五步落地:从接口到输出的 Verilog 实现

3.1 第一步:接口约定与象限折叠

接口设计上我倾向于"简单粗暴":一个i_valid加一个角度,一个o_valid加两个结果,不做 ready/backpressure 握手。原因是 CORDIC 的延迟是固定的 N+2 拍,调用方只要按拍数对齐就行,加握手反而增加状态复杂度。

真正的第一步工作是象限折叠。CORDIC 的收敛域是 ±99.88°,换算成归一化角度就是 ±0.5549。而输入角度可能落在 [-1, 1](即 [-π, π])的任意位置,所以必须先折进去。

折叠策略我选的是"折到 ±π/2,整体取反"。判断逻辑很直接:如果角度大于 0.5,就减 1.0;小于 -0.5,就加 1.0;两种情况下输出都取反。数学依据是sin(θ±π) = -sin θ、cos(θ±π) = -cos θ,一次减法加一次取反就搞定。

为什么不做更精细的"折到第一象限"?因为那需要交换 sin 和 cos 的角色,还要按象限分别处理四种符号组合,逻辑分支变多,出错概率直线上升。折到 ±π/2 虽然让 CORDIC 在接近边界(±90°)的地方工作,但收敛域本身够用,精度损失可以忽略。这里的原则是能用一次减法解决的,别用查表加交换。

组合逻辑部分大概长这样:

localparam signed [17:0] PI_VAL = 18'sd16384; // 1.0 -> pi localparam signed [17:0] HALF_PI = 18'sd8192; // 0.5 -> pi/2 reg signed [17:0] phase_fold; reg fold_neg; always @(*) begin if (i_phase > HALF_PI) begin phase_fold = i_phase - PI_VAL; fold_neg = 1'b1; end else if (i_phase < -HALF_PI) begin phase_fold = i_phase + PI_VAL; fold_neg = 1'b1; end else begin phase_fold = i_phase; fold_neg = 1'b0; end end

注意:i_phase和HALF_PI都必须是 signed 类型,否则比较会退化成无符号比较,负角度全部被判成"大于 0.5",输出一片混乱。这个坑我在第一次写的时候踩过,仿真波形上表现为负半轴结果全部反相,查了半天以为是 CORDIC 内核的问题。

3.2 第二步:增益预补偿与初值装载

初值装载就三行:x <= INV_K、y <= 0、z <= phase_fold,外加把fold_neg存进寄存器。看起来简单,但有两个地方要留意。

第一个是INV_K的位宽匹配。INV_K是 Q2.14 格式下 1/K 的量化值,也就是 9949。如果 x 通路是 18 位 Q2.14,那直接赋 18'sd9949 就行。但如果后面为了提精度把 x 扩到 22 位 Q2.18,那INV_K就得改成0.607252935 × 2^18 = 159188。这两个数不能混用,混用之后的典型现象是输出幅值整体偏大或偏小一个固定倍数,很好识别但不好定位。

第二个是装载时机。装载只在i_valid && state == S_IDLE时发生,其他时候 x、y、z 保持迭代更新。如果状态机设计成"每拍都检查 valid",就会出现迭代到一半被新输入打断的情况。我的习惯是 busy 期间直接忽略i_valid,让上游自己保证不丢数据,或者加一个一级 FIFO 缓冲。

还有一种省资源的做法值得一提:把 1/K 的补偿合并到输出级。也就是初值直接装 1.0,迭代完再乘 0.607252935。这样初值装载更简单,但会引入一个乘法器。如果后端本来就要做缩放,可以合并进去,省一个乘法器;如果没有,还是老老实实预补偿。

3.3 第三步:迭代核心与符号判决

这是整个模块最核心的十几个周期。状态机我固定用三个状态:S_IDLE等输入,S_ITER迭代 N 次,S_OUT打一拍输出。

关键点在于符号判决用的是当前拍的 z,而不是下一拍的。代码里dir = ~z[W_ANG-1]是组合逻辑,直接取 z 的符号位取反。z 为正(符号位 0)时 dir=1,走"正转"分支;z 为负时 dir=0,走"反转"分支。这个映射关系一定要和 atan 表的方向对上,方向搞反的话结果会变成cos(-θ)和sin(-θ),也就是 sin 部分整体反号——这是个极易误判成"符号位处理错误"的假象。

另一个细节是算术右移。Verilog 里>>>对 signed 类型是算术右移,高位补符号位;>>是逻辑右移,高位补 0。x 和 y 都是有符号数,必须用>>>。如果误用>>,负的 y 值右移后会变成一个很大的正数,迭代直接发散。这个错误在综合时不会有任何警告,只有在仿真跑负角度时才暴露。

迭代核心的写法:

wire signed [W_XY-1:0] y_shr = y >>> iter; wire signed [W_XY-1:0] x_shr = x >>> iter; wire signed [W_ANG-1:0] atan_i = atan_lut(iter); wire dir = ~z[W_ANG-1]; S_ITER: begin if (dir) begin x <= x - y_shr; y <= y + x_shr; z <= z - atan_i; end else begin x <= x + y_shr; y <= y - x_shr; z <= z + atan_i; end if (iter == N_ITER-1) begin iter <= 5'd0; state <= S_OUT; end else begin iter <= iter + 5'd1; end end

atan 表用 function 加 case 实现,综合工具会自动展开成组合逻辑或者小 ROM。18 项的表大概占 40 到 60 个 LUT,非常便宜。表值如下(Q3.14,1.0 = 16384):

iatan(2^-i)/πQ3.14 值iatan(2^-i)/πQ3.14 值
00.2500000409670.002486341
10.1475836241880.001243420
20.0779791127890.000621710
30.0395827649100.00031085
40.0198719326110.00015543
50.0099444163120.00007771
60.004973181130.00003891

提示:表值一定要四舍五入,不能一律截断。截断会让所有 atan 值都偏小,误差单向累积,最终表现为输出在固定方向上系统性偏移几十个 LSB。我在早期版本里为了省事用了截断,结果测出来的平均误差是 -8e-5,明显偏离零均值,后来改成四舍五入,平均误差直接掉到 -3e-6。

3.4 第四步:输出还原与舍入处理

输出状态只做两件事:按fold_neg决定是否取反,然后把结果写到 `o_c

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

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

立即咨询