聊到FPGA的数学运算,CORDIC几乎是一道绕不开的菜。不管你是做信号发生器的NCO,还是搞图像旋转的坐标变换,甚至是在通信算法里算个反正切,只要硬件里需要三角函数、开方、幅度相位转换,CORDIC算法都是那个性价比极高的选择。它最迷人的地方在于,整个计算过程只需要加法和移位,不碰任何乘法器,这对资源敏感的FPGA来说简直太友好了。这篇文章我把这套东西完整拆一拆,从数学原理到RTL实现,再到仿真验证和踩坑记录,给初学的朋友一条能直接走通的路线。
1. CORDIC为什么能靠加法和移位算三角函数
1.1 旋转模式的数学直觉
CORDIC全称是Coordinate Rotation Digital Computer,坐标旋转数字计算机,1960年代就有人提出来了。它的核心思路特别朴素:把一个角度拆成一组固定小角度的组合,然后让矢量沿着这些小角度一次次旋转,最终逼近目标角度。
想象你站在原点,要朝着某个方向前进。你不一定直接转到位,而是先转45度,再转26.5度,再转14度,每次转的角度越来越小,最后角度误差小到可以忽略。关键是这些固定小角度有一个共同特点:它们的正切值都是2的负整数次幂,也就是1、0.5、0.25、0.125这些数。
为什么这个性质重要?因为在旋转公式里,如果旋转角θ满足tanθ=2^(-i),那么旋转过程中的乘法就退化成移位。比如某个矢量(x,y)旋转角度θ后,新坐标是(x cosθ - y sinθ, y cosθ + x sinθ),把cosθ提出来,就变成cosθ倍的(x - y tanθ, y + x tanθ)。当tanθ=2^(-i)时,y乘以tanθ就等价于y右移i位。
cosθ这一项所有级都有,最终会累积成一个固定增益,CORDIC迭代次数够了以后这个增益趋近于常数1.64676。所以整个算法天然分成两部分:一部分是不断旋转逼近目标角度,另一部分是最后乘一个常数把增益拉回来。
旋转模式CORDIC的迭代公式如下:
x_(i+1) = x_i - σ_i · y_i · 2^(-i)
y_(i+1) = y_i + σ_i · x_i · 2^(-i)
z_(i+1) = z_i - σ_i · arctan(2^(-i))
旋转模式下σ_i由z_i的符号决定,z_i大于等于0时σ_i取+1,z_i小于0时σ_i取-1。这样每一级都在把z往0的方向拧。迭代结束后,如果初始设置x_0=1、y_0=0,那么x_n就逼近cos(z_0)乘一个增益,y_n逼近sin(z_0)乘同一个增益。
这句话值得划重点:CORDIC不产生真正的角度,而是把角度z_0消耗掉,同时把初始矢量的坐标旋转到对应方向。这个视角对理解后面的数据格式和象限处理非常关键。
1.2 向量模式从坐标反推相位
CORDIC还有另一个常用模式,叫向量模式。公式形式上几乎一样,但σ_i的取值逻辑变了,这次不是看z_i,而是看y_i的符号。每级迭代都在把y往0的方向拧,也就是让矢量尽量靠近x轴。
向量模式的迭代公式:
x_(i+1) = x_i - σ_i · y_i · 2^(-i)
y_(i+1) = y_i + σ_i · x_i · 2^(-i)
z_(i+1) = z_i - σ_i · arctan(2^(-i))
这里的σ_i取y_i的符号。迭代结束后,z_n就是原始矢量相对于x轴的夹角,也就是atan(y_0/x_0),x_n则逼近矢量模长乘以增益。所以向量模式天然适合做坐标转换、相位检测、幅值计算。
我在实际项目里用向量模式做过QPSK解调里的相位误差提取,输入是I/Q两路基带信号,输出就是相位偏差,整个环路不需要任何乘法器,流水线一搭就能在很高时钟频率下跑。这是CORDIC在通信物理层里最常见的用法之一。
1.3 工程上最值钱的三个场景
CORDIC在FPGA里之所以地位高,是因为它能用极少的DSP资源完成大量数学运算。我自己归纳了三个高频场景,基本覆盖了大多数项目需求。
第一个是数字混频器和NCO。DDS直接数字频率合成里需要连续输出正弦和余弦波形,这种场景下CORDIC作为相位到幅度的转换器,比查表法省存储,又比多项式逼近法好控制精度。一个简单信号发生器用16级流水线CORDIC就能跑出很干净的正弦波。
第二个是坐标变换和图像旋转。雷达显示、机械臂运动学、图像处理里的旋转校正,都涉及平面坐标系的旋转。CORDIC不光能算单点旋转,还能配合流水线做批量坐标变换,吞吐量非常可观。
第三个是幅度相位计算。通信解调、电力系统谐波分析、电机控制里的Clark变换和Park变换,经常需要从两路正交分量算出模值和相角。向量模式CORDIC一套电路全搞定,而且没有除法,没有平方根,资源开销非常友好。
这里插一句,很多刚入门的同学分不清CORDIC和查表法的界限。查表法是拿存储换速度,ROM大小决定精度;CORDIC是拿迭代次数换精度,级数越多越准,但不吃存储。在FPGA里存储资源有时候比逻辑资源更金贵,尤其是要生成多路正交信号时,CORDIC的优势会特别明显。
2. 动手前先想清楚:IP核还是自己写RTL
2.1 用IP核五分钟跑通
Xilinx Vivado里有一个专门的CORDIC IP核,在IP Catalog里搜一下就能看到。Altera/Intel平台也有类似的CORDIC核。用IP核的好处非常直接:图形界面里选功能模式、输入输出位宽、迭代级数、流水线选项,点一下生成,然后实例化就能用,省去所有数学细节。
IP核内部的实现通常经过厂商优化,时序收敛有保证,文档里还会给出详细的延迟周期和精度指标。如果你的项目工期紧,或者连续做大量数学运算想快速验证系统功能,直接拉IP核是最稳妥的。
但IP核也有让工程师头疼的地方。第一,数据格式限制比较死,比如Xilinx CORDIC IP对输入角度格式有明确要求,输出结果的小数点位置也要按文档配,理解不透彻反而容易用错。第二,IP核配置固定后不好动态改迭代级数或数据位宽,想针对不同精度需求做切换比较麻烦。第三,一旦换芯片平台,比如从Xilinx换到高云、易灵思这些国产FPGA,IP核不能直接带过去,代码得重新适配。
2.2 手写RTL的优势和代价
手写CORDIC最大的优势就是可控。数据格式自己定,迭代级数做成参数,流水线级数想怎么切怎么切,甚至可以把CORDIC核心改造成半流水或者全并行结构,配合不同的时钟频率和资源约束。对于算法工程师和FPGA工程师双肩挑的人来说,手写一遍CORDIC能帮你把定点数、增益补偿、动态范围这些硬件基本功一次补齐。
另外在资源受限的场景里,手写RTL可以精细控制每一比特。比如某些应用中不需要完整16位精度,8位输出就够,IP核未必支持这么细的配置,手写代码把参数一改就能仿真。再比如你希望CORDIC输出与某个自定义外设接口对齐,手写代码可以随时插入打拍逻辑或者改变输出时序,IP核就很难这么灵活。
手写的代价也摆在那里,需要自己处理数据格式、符号扩展、象限映射、补偿乘法、流水线时序,每一个环节出错都得靠仿真和调试去抓。尤其是第一次写有符号数定点运算,Verilog里的算术右移、符号位扩展、中间位宽截断,这些细节够喝一壶的。
2.3 我的选型建议
我的习惯是这样:先评估项目需要的是快速交付还是长期维护。如果是给现有系统加个数学单元,验证整体链路,直接上IP核。如果是要做算法移植、芯片平台可能会换、或者整个模块要在一个工程里复用很多次,那手写RTL更划算。
还有另一个考虑维度是学习价值。如果一个人刚接触FPGA数字信号处理,我强烈建议手写一遍CORDIC,哪怕写完再换成IP核也不亏。因为只有自己写过一遍,你才知道定点格式为什么要扩位,增益补偿为什么不能省,atan表为什么量化到那个精度就够了。这些经验在调试其他算法模块时一样用得上。
真要手写,也不用从零造轮子。下面几节我把数据格式、精度分析、流水线设计、验证方法和常见坑一次讲透,你照着走基本能少踩一半雷。
3. 手写RTL前必须拿捏的三个关键问题
3.1 定点数格式怎么选,内部位宽为什么要扩
FPGA里没有浮点单元的话,CORDIC几乎都用定点数实现。最常见的是Q格式表示法,对CORDIC来说输入角度可以归一化到[-1,1)区间,对应弧度范围[-π,π)。为什么这么归一化?因为16位有符号数的最大值32767,直接表示弧度π约3.14159会超过范围,所以把角度除以π,让π对应32768,π/2对应16384,这样所有角度都能用16位有符号数表示。
这种格式在实际工程里很常见,很多NCO的相位累加器也是这么做的,相位字全范围对应2π,高位取模自然进位。CORDIC输入接相位累加器输出时,不需要任何额外的角度转换逻辑,非常顺。
数据格式定完之后,内部位宽是个容易踩坑的地方。CORDIC迭代过程中,矢量的模长会乘以累积增益约1.64676,也就是说迭代前x=1,迭代后x可能变成1.64。如果输入输出都用16位Q1.15格式,1.0对应的寄存器值是32768,已经是16位有符号数的上限附近,迭代过程中直接溢出。
解决办法是内部位宽额外扩展几位。我习惯在输入输出位宽基础上加3位,既照顾增益扩展,又留出中间加法进位和截断误差的空间。内部定点格式可以理解成保留16位小数精度,但整数部分多了3位,最大能表示约8.0,迭代过程中绝对不会爆。最终输出前再截回16位,把多出来的整数位丢掉。
3.2 迭代次数和输出精度的对应关系
CORDIC精度和迭代次数的关系,简单记忆就是每多迭代一级,大约多获得1比特精度。16级迭代配合16位输出,误差通常在几个LSB以内,这对大多数显示和通信场景都够用。
但要注意迭代级数和数据位宽不是孤立的。如果你的数据输出只有12位,却做了20级迭代,后面那些级对最后结果的贡献几乎被截断噪声淹没,纯属浪费逻辑资源。反过来,输出16位却只做8级迭代,低位误差会很大,波形上能看到明显台阶。经验上迭代级数取数据位宽相同或者略多一点就行。
还有一个容易被忽略的点是atan查找表的量化。每一级旋转对应的arctan(2^(-i))要存成固定点值,量化精度直接影响最终角度逼近程度。对于16位系统,atan表的值用16位表示足够,但如果内部z路径位宽扩了,表值参与加法时需要注意位宽匹配,最好把表值符号扩展后参与运算。
下面的表列出了16级迭代时前几级对应的角度量化值,角度格式是归一化到[-1,1)的表示法,1.0对应32768。
| 迭代级数i | 2^(-i) | 实际角度arctan(2^(-i)) | 角度归一化量化值(16bit) |
|---|---|---|---|
| 0 | 1 | 45.000° | 0x2000 |
| 1 | 0.5 | 26.565° | 0x12E4 |
| 2 | 0.25 | 14.036° | 0x09FB |
| 3 | 0.125 | 7.125° | 0x0511 |
| 4 | 0.0625 | 3.576° | 0x028B |
| 5 | 0.03125 | 1.790° | 0x0146 |
| ... | ... | ... | ... |
到后面几级,角度量化值基本就是2的幂递减,0x80、0x40、0x20这样,规律非常明显,写表的时候可以直接算出来,不用一个个手填。
3.3 增益补偿因子A到底怎么算
前面反复提到CORDIC迭代会引入一个约1.64676的固定增益。这个值是每一级sqrt(1+2^(-2i))的连乘结果,迭代次数越多越接近1.646760258。
补偿方法有两种。第一种是迭代前把初始矢量缩小1/A,也就是x_0取1/A而不是1,这样迭代完增益自然抵消。第二种是迭代结束后把结果乘以1/A,也就是约0.60725。工程上我更推荐第二种,因为迭代过程中x和y的数值更大,定点精度相对更高,最后一步再做补偿误差影响最小。
补偿因子怎么用代码实现?最直接的方式是定义一个常量K_SCALE,数值等于0.60725乘以2^15,算出来约19897,也就是16位下的0x4DBA。然后把迭代输出的x和y分别乘以这个常量,右移15位,就得到最终补偿后的结果。用一个DSP乘法器就能搞定,现代FPGA里DSP资源一般不像LUT那么紧张,该用就用。
如果项目要求不用乘法器,也可以把0.60725拆成移位加法的组合。0.60725约等于0.5加0.0625加0.03125加0.015625,也就是2^(-1)加2^(-4)加2^(-5)加2^(-6),通过几次移位再相加就能实现。这种纯逻辑实现的好处是DSP一块都不占,但代价是代码稍微绕一些,而且在资源非常紧张的老型号芯片上才有必要这么抠。
3.4 象限映射为什么绕不开
标准CORDIC迭代的收敛范围只有[-π/2,π/2],超过这个范围,旋转逼近策略就不收敛了。但实际输入角度经常是整个[-π,π],所以必须在进入核心迭代前做一次象限预处理。
预处理逻辑其实很巧。输入角度归一化后,最高两位就代表了它落在哪个象限。00表示第一象限,01表示第二象限,10表示第三象限,11表示第四象限。第二和第三象限的角度,通过加或者减π,总能映射回[-π/2,π/2]范围,代价是最终输出的sin和cos要翻转符号。
我画过一张象限映射的对照表,每次写代码都直接抄这个逻辑:
| 输入象限 | 角度范围 | 预处理后 | sin/cos符号变化 |
|---|---|---|---|
| 00 | [0, π/2) | 不变 | 不变 |
| 01 | [π/2, π) | 减π | sin取反,cos取反 |
| 10 | [-π, -π/2) | 加π | sin取反,cos取反 |
| 11 | [-π/2, 0) | 不变 | 不变 |
实现时用输入角度的高两位判断象限,生成象限标志信号,同时输出一个调整后的角度。核心CORDIC只处理调整后的角度,最后输出结果再根据象限标志决定是否取反。这个流程虽然多了一个组合逻辑块,但让CORDIC模块真正做到了全角度覆盖,用起来才不会被边界条件坑到。
4. 流水线架构与完整RTL实现
4.1 级间结构设计:每一级都在干什么
手写CORDIC最常用的架构是流水线结构,每一级完成一次迭代,并把自己的输出寄存到下一级。16级迭代就有16个流水级,每级的关键路径只是一次加法、一次移位和一次符号判断,所以时钟频率通常可以推得比较高。
第i级要做的事情很固定:根据当前z的符号,决定是加还是减atan表里的第i个角度;根据相同的符号,决定x和y之间是加还是减对方右移i位后的值。因为第i级移位量正好是i,所以级数越往后,右移位数越大,最后几级对数据的影响已经非常小了。
有一个细节很多初学者会忽略,就是z路径的位宽。z表示角度,它的数值大小不会像x和y那样因为增益而膨胀,但atan表值要和z做加减,所以位宽必须匹配。如果内部z位宽比atan表值窄,加法时符号扩展出错,整个旋转方向判断就乱了。我通常让z和x、y保持同一位宽,省心。
级间寄存器是否都要打一拍?是的,严格流水线设计里每一级输出都要打拍到下一级。这样做吞吐量最大,每个时钟周期都能进一个角度,同时出一个sin和一个cos结果。如果你希望减少时延而不是提高吞吐量,也可以用迭代式结构,只用一组寄存器反复迭代,但那样每个结果需要多个时钟周期才能出来,适合低速小面积的场景。
4.2 参数化RTL代码,可以直接抄
下面给一份我常用的旋转模式CORDIC核心代码,输入输出都是16位,迭代16级,内部位宽扩3位。代码里包含了象限预处理、核心迭代、增益补偿和输出修正,整体可以直接在Vivado或者Quartus里综合。
// CORDIC 旋转模式,输入角度归一化到[-1,1),对应弧度[-pi,pi] // 输出 sin_out/cos_out,16bit Q1.15 格式 module cordic_rot #( parameter DATA_W = 16, parameter ITER_N = 16 )( input wire clk, input wire rst_n, input wire [DATA_W-1:0] angle_in, output reg [DATA_W-1:0] sin_out, output reg [DATA_W-1:0] cos_out ); localparam INTER_W = DATA_W + 3; // 角度归一化到[-1,1),即弧度/pi // 用函数生成atan查找表,量化到DATA_W位 function [DATA_W-1:0] atan_lut(input integer idx); begin case (idx) 0: atan_lut = 16'h2000; 1: atan_lut = 16'h12E4; 2: atan_lut = 16'h09FB; 3: atan_lut = 16'h0511; 4: atan_lut = 16'h028B; 5: atan_lut = 16'h0146; 6: atan_lut = 16'h00A3; 7: atan_lut = 16'h0051; 8: atan_lut = 16'h0029; 9: atan_lut = 16'h0014; 10: atan_lut = 16'h000A; 11: atan_lut = 16'h0005; 12: atan_lut = 16'h0003; 13: atan_lut = 16'h0001; 14: atan_lut = 16'h0001; 15: atan_lut = 16'h0000; default: atan_lut = 16'h0000; endcase end endfunction // 增益补偿因子 1/1.64676 = 0.60725,量化到Q1.15 localparam signed [DATA_W+1:0] K_SCALE = 18'd19899; reg [1:0] quad_reg; reg signed [INTER_W-1:0] x_pipe [0:ITER_N]; reg signed [INTER_W-1:0] y_pipe [0:ITER_N]; reg signed [INTER_W-1:0] z_pipe [0:ITER_N]; reg [1:0] quad_pipe [0:ITER_N]; wire [1:0] quad_in; wire signed [INTER_W-1:0] angle_adj; // 象限预处理 assign quad_in = angle_in[DATA_W-1 -: 2]; assign angle_adj = $signed( (quad_in == 2'b01) ? angle_in - 16'h8000 : (quad_in == 2'b10) ? angle_in + 16'h8000 : angle_in ); // 第一级输入 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin x_pipe[0] <= 'd0; y_pipe[0] <= 'd0; z_pipe[0] <= 'd0; quad_pipe[0] <= 2'b00; end else begin // 初始矢量(1.0, 0.0),x初值为 2^15 x_pipe[0] <= {2'b0, 16'h8000, 1'b0}; y_pipe[0] <= 'd0; z_pipe[0] <= angle_adj; quad_pipe[0] <= quad_in; end end genvar i; generate for (i = 0; i < ITER_N; i = i + 1) begin: cordic_stage wire signed [INTER_W-1:0] x_cur = x_pipe[i]; wire signed [INTER_W-1:0] y_cur = y_pipe[i]; wire signed [INTER_W-1:0] z_cur = z_pipe[i]; wire sigma = z_cur[INTER_W-1]; wire signed [INTER_W-1:0] x_next; wire signed [INTER_W-1:0] y_next; wire signed [INTER_W-1:0] z_next; if (sigma) begin x_next = x_cur + (y_cur >>> i); y_next = y_cur - (x_cur >>> i); z_next = z_cur + $signed({{(INTER_W-DATA_W){atan_lut(i)[DATA_W-1]}}, atan_lut(i)}); end else begin x_next = x_cur - (y_cur >>> i); y_next = y_cur + (x_cur >>> i); z_next = z_cur - $signed({{(INTER_W-DATA_W){atan_lut(i)[DATA_W-1]}}, atan_lut(i)}); end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin x_pipe[i+1] <= 'd0; y_pipe[i+1] <= 'd0; z_pipe[i+1] <= 'd0; quad_pipe[i+1] <= 2'b00; end else begin x_pipe[i+1] <= x_next; y_pipe[i+1] <= y_next; z_pipe[i+1] <= z_next; quad_pipe[i+1] <= quad_pipe[i]; end end end endgenerate wire signed [INTER_W-1:0] x_cordic = x_pipe[ITER_N]; wire signed [INTER_W-1:0] y_cordic = y_pipe[ITER_N]; wire [1:0] quad_out = quad_pipe[ITER_N]; wire signed [INTER_W+15:0] cos_mult = x_cordic * K_SCALE; wire signed [INTER_W+15:0] sin_mult = y_cordic * K_SCALE; // 补偿并截回DATA_W位 wire signed [DATA_W-1:0] cos_comp = cos_mult[(INTER_W-1) +: DATA_W]; wire signed [DATA_W-1:0] sin_comp = sin_mult[(INTER_W-1) +: DATA_W]; // 象限符号修正 wire inv_flag = quad_out[1] ^ quad_out[0]; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cos_out <= 'd0; sin_out <= 'd0; end else begin cos_out <= inv_flag ? (~cos_comp + 1'b1) : cos_comp; sin_out <= inv_flag ? (~sin_comp + 1'b1) : sin_comp; end end endmodule4.3 代码细节逐行解读
这段代码有几个地方初次看容易懵,我挑重点解释。
第一是x初值的写法。{2'b0, 16'h8000, 1'b0}组合出来直接是18位。内部格式里1.0就是2^15,也就是16'h8000,放到18位宽度里就是18'd32768,所以全部位拼接出来等于18'b00_1000_0000_0000_0000_0。因为内部是18位有符号数,最高两位是符号和整数保护位,这个初值正好表示定点数1.0。
第二是atan表的位宽扩展。atan_lut(i)是16位,但z_pipe是18位有符号数,直接加减会位宽不匹配。所以用了{{(INTER_W-DATA_W){atan_lut(i)[DATA_W-1]}}, atan_lut(i)}做符号扩展,把16位表值变成18位。这里最容易写错,很多人直接拼零,结果负角度表值变成正数,整个旋转方向就反了。
第三是增益补偿那两行。x_cordic * K_SCALE的乘积位宽是18加18等于36位,但实际最关心的还是补偿后结果对应Q1.15的数值。cos_mult[(INTER_W-1) +: DATA_W]是取中间16位,效果等于乘积右移15位后截断。如果这里取位取错了,输出的sin和cos会整体偏大或偏小,仿真时一眼就能看出来。
第四是象限修正。inv_flag在第二三象限时为1,此时取补码实现取反。注意补码取反是~x + 1,不是简单按位取反,别写漏加1。
4.4 资源与时延估算
这段代码综合下来,16级流水线大约消耗几百个LUT和FF,外加两个DSP乘法器用于增益补偿。如果资源紧张,可以把补偿乘法改成移位加实现,DSP占用能降到0,但LUT会增加一些。整体时延是16级流水加输入输出寄存,一共18个时钟周期左右的latency。
时钟频率方面,每一级关键路径主要是一加法一移位一多选,在主流FPGA上跑到150MHz以上问题不大。如果希望上300MHz甚至更高,可以再做细粒度流水切分,比如把每一级的移位和加法拆到两个周期,代价是latency和FF占用增加。我的经验是,除非模块频率瓶颈卡在这里,否则没必要过度优化,CORDIC很少成为整个系统的时序短板。
5. 仿真验证和上板调试
5.1 快速搭建testbench做误差分析
代码写完必须先仿真,这一步千万别省。我的习惯是写一个简单的testbench,给模块灌几组典型角度,比如0、π/6、π/4、π/2、3π/4、-π/3,然后对比输出的sin/cos值和MATLAB计算的期望值。
testbench里角度输入要换算成归一化格式。比如π/4在归一化格式里是0.25,对应16位值0x2000。π/2是0.5,对应0x4000。π对应1.0,但16位表示不了1.0,实际用0x8000表示-1.0,正π在定点里会变成-1.0,这个边界要小心。
我经常在testbench里做自动比对,把模块输出和软件计算值相减,打印误差。16位系统下,sin/cos误差通常在几个LSB以内。如果误差到了几十个LSB,先查增益补偿;如果输出波形有明显断点,查象限符号修正;如果结果整体偏小数,查截位是否多移了位。
下面是一个简易的testbench骨架:
module tb_cordic_rot; reg clk = 0; reg rst_n = 0; reg [15:0] angle_in; wire [15:0] sin_out, cos_out; cordic_rot u_cordic( .clk(clk), .rst_n(rst_n), .angle_in(angle_in), .sin_out(sin_out), .cos_out(cos_out) ); always #5 clk = ~clk; initial begin repeat(5) @(posedge clk); rst_n = 1; // 测试角度 0 angle_in = 16'h0000; repeat(20) @(posedge clk); $display("angle=0, cos=%04x sin=%04x", cos_out, sin_out); // 测试角度 pi/4 angle_in = 16'h2000; repeat(20) @(posedge clk); $display("angle=pi/4, cos=%04x sin=%04x", cos_out, sin_out); // 测试角度 pi/2 angle_in = 16'h4000; repeat(20) @(posedge clk); $display("angle=pi/2, cos=%04x sin=%04x", cos_out, sin_out); $finish; end endmodule仿真时注意latency。每一组输入要等流水线全部走完再取输出,我的testbench里repeat了20个周期,足够16级流水线出结果了。
5.2 用数码管动态显示实测CORDIC输出
仿真过了只能说明功能基本对,真正上板验证才是硬道理。最直观的验证方式就是把CORDIC算出的数据送到数码管上显示。
我在一个初学板上做过这个实验:用按键切换输入角度,CORDIC输出sin值,再把二进制补码转成十进制数字,在四个数码管上动态显示。数码管动态扫描本身不难,关键是CORDIC输出是有符号Q1.15格式,要显示成小数需要先做二进制到十进制的转换。
具体做法是,先把sin值取绝对值,乘以一个显示缩放系数映射到0到9999的范围,然后拆成四个BCD码,再进数码管扫描显示。比如sin值0.707对应Q1.15的0x5A82,乘以10000再右移15位,得到7070,显示出来就是0.7070。这样就能直观看到角度从0转到90度时,数码管上的正弦值从0000一步步变成9999再回到0000。
如果手头有逻辑分析仪或者带ILA的板子,直接把CORDIC内部的x_pipe、y_pipe、z_pipe抓出来看波形,比看外部显示更快定位问题。我调试时习惯把输入的angle值和输出的sin同时抓到波形窗口,观察两者延迟关系是否和预期一致。
5.3 串口回传和SignalTap/ILA的使用技巧
除了数码管,串口回传也是一种很实用的验证手段。CORDIC模块跑起来后,把经过处理的sin值通过UART发送到PC,用串口助手按十六进制或者文本方式接收,就能连续观察输出数值序列。
串口回传的注意点是格式转换。CORDIC输出是补码,直接发十六进制字节没问题,但如果想用文本显示,需要在FPGA内部把16位补码转成ASCII码,这就涉及补码转原码、除10取余等操作,代码量会多不少。对于只想快速验证的场景,我建议直接发十六进制,PC端再写个小脚本解析。
ILA和SignalTap在线调试工具更强大。直接在CORDIC模块的输出端口上标记调试信号,触发条件设为输入角度改变,就能抓一整段波形。我看波形时特别关注z_pipe最后几级的值,如果z路径没有收敛到接近0,说明迭代方向判断有问题;如果x和y在中间级出现异常跳变,多半是符号扩展或者移位写错了。
6. 常见问题与排查技巧实录
6.1 高频踩坑速查表
我把这两三年带新人调试CORDIC时遇到的高频问题全部列成了一张速查表,每条都是真实场面。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 输出整体偏大约1.65倍 | 增益补偿没做或补偿因子忘乘 | 检查K_SCALE是否和内部位宽匹配 |
| 输出在±π/2附近跳变 | 象限预处理缺失或符号修正错误 | 检查quad信号和inv_flag逻辑 |
| 负角度输出完全不对 | 有符号数右移写成了逻辑右移>> | 把>>改成>>>,确认变量声明为signed |
| 输出低位噪声大 | 迭代级数不足或输出截断过早 | 增加ITER_N,检查截取位宽 |
| 波形有周期性的毛刺 | atan表值符号扩展错误 | 打印tan值,查负值扩展后是否仍是负数 |
| 输出延迟和预期不符 | 流水线打拍数算错 | 数一下各级寄存器,对照实际输出时刻 |
| 上板后偶尔出错 | 复位和时钟时序未处理 | 检查rst_n释放时机,确认输入信号跨时钟域做了同步 |
6.2 三个让我印象深刻的bug
第一个是增益补偿忘乘。当时做NCO输出正弦波,仿真波形看起来形状对,但幅度峰值到了1.64,正弦波直接顶到截幅。我当时以为是缩放系数的问题,排查了半天才发现补偿因子那行代码被注释掉了,乘回去之后波形立刻正常。这个错误很蠢,但CORDIC不同模式对补偿的依赖不一样,向量模式虽然也受影响,但输出atan角度是z路径,幅度不对不容易被发现,所以更容易漏。
第二个是Verilog的有符号右移。代码里x_cur和y_cur明明声明成signed,但当时写移位时用了>>运算符。综合出来以后负数的移位补的是0而不是符号位,导致迭代方向在负半轴错乱。后来统一改成>>>,并且确认所有参与移位的信号都声明为signed,问题才解决。Verilog这个坑特别隐蔽,因为看波形的时候负数路径出错不一定一眼看出来。
第三个是象限预处理只改了角度没改符号。写完代码后在π/2附近测试,发现sin值出现跳变,cos值也带着毛刺,感觉是角度边界不连续。后来把输入角度和输出修正放在一起检查,发现第二象限角度虽然减了π,但输出sin/cos忘了取反。加上inv_flag逻辑后,整个[-π,π]范围内的输出才真正连续起来。
6.3 我的CORDIC调试顺序建议
最后分享一套我先给团队的调试顺序。第一步,先不接补偿,直接验证迭代方向和角度收敛,把z_pipe信号拉出来看是否趋近0。第二步,确认z收敛正常后,检查x和y波形幅度,看是否出现溢出或异常截断。第三步,加上增益补偿,验证固定角度下的输出精度。第四步,测边界角度,特别是±π/2和±π附近。第五步,接入真实数据流做连续运行,观察长时间是否有偶发错误。
这套顺序的好处是每一步只验证一个变量,问题容易被隔离。如果一上来就直接看最终sin输出出不来,很难判断是迭代方向、补偿、还是象限处理出了问题。CORDIC这个算法本身不复杂,但和定点数、流水线、符号扩展这些硬件细节纠缠在一起后,调试起来还是需要一点耐心的。按这样的步骤走,大多数问题都能在半小时内定位。