
第一次接触CORDIC算法的时候我正在为一个水下通信项目设计解调器需要实时计算信号的相位。查了查资料发现FPGA里实现反正切函数最优雅的方案就是CORDIC——坐标旋转数字计算机。这个算法厉害在只用移位和加法就能算出正弦、余弦、反正切、开方这些函数不需要任何乘法器和除法器。在FPGA这种资源要精打细算的地方这个特性简直完美。这篇博文我会把整个CORDIC算法的Verilog实现从头到尾拆开来讲包括旋转模式和向量模式分别怎么用、定点数格式怎么选、迭代次数和精度怎么权衡、流水线结构怎么写、仿真怎么验证。如果你是刚入门FPGA、想在项目里用CORDIC但不知道怎么下手或者已经写了代码但对精度和资源优化还有疑问这篇内容应该能帮到你。我会把代码架构、关键的参数计算过程以及我实际调试中踩过的坑一并整理出来。1. 项目整体设计与方案选型1.1 为什么在FPGA上用CORDIC而不是查找表很多人第一次接触到计算三角函数或者反正切的需求第一反应是直接用查找表把角度对应的函数值预先算好存进ROM里。这个方法在角度范围小、精度要求不高的时候确实简单粗暴但一旦你的输入范围覆盖整个360度而且精度要求到16位以上查找表的资源开销就非常可观了存储一个完整周期的正弦表每个点16位宽的定点数如果有4096个点就是8Kbit的ROM这还只是一个函数。如果你同时需要sin和cos又要翻倍。更尴尬的是查找表的精度受限于采样点数你存了4096个点相邻两个角度之间的值就只能靠插值去估。想让精度再往上走就得把表做得更大这在低成本FPGA上很快会遇到资源瓶颈。CORDIC算法把这个存储换成了计算每次迭代只需要一次移位和一次加法迭代次数决定了精度而非存储量。要在精度和资源之间做取舍CORDIC灵活得多。另外一个实际的应用场景如果你的系统里还需要算反正切比如解调器要提取相位信息查找表就完全不够用了——你没法用一个固定间隔的角度表去反向查输入值通常得设计复杂的搜索逻辑。CORDIC的向量模式天生就是干这个的把(x, y)坐标旋转到x轴累加旋转角度直接就得到了arctan(y/x)。同一个硬件结构既算三角函数又算反正切用模式信号切换就行这才是FPGA里最高效的架构。1.2 旋转模式与向量模式一个算法解决两类问题CORDIC算法的核心思想其实来自坐标旋转的几何关系。手工推导的话就是给定一个点(x, y)把它旋转角度θ得到新坐标(x‘, y’)的公式。但CORDIC聪明的地方在于它不一次旋转到位而是每次旋转一个特定的小角度而且这些角度被设计成tan值刚好是2的负整数次幂这样旋转操作里的乘法就变成了移位。有两种工作模式对应了不同的场景需求。旋转模式Rotation Mode是让坐标从初始角度逐步旋转到目标角度旋转方向由剩余角度决定最终得到的(x‘, y’)就是cos和sin的结果。向量模式Vectoring Mode相反是把坐标旋转到x轴上让y分量归零旋转角度累加起来就是arctan(y/x)。在做项目方案设计时我通常会根据系统需求先画一个大的功能框图确定CORDIC模块在整个数据通路中的位置和接口时序。比如在OFDM解调器里CORDIC输出的相位要送给后级的频偏估计模块那么输出的数据有效信号和数据位宽就要和后级匹配好。这些接口设计的问题越早想清楚后面联调越省事。模式初始条件迭代方向判据输出结果旋转模式输入角度θ初始(x0, y0) (1, 0)z剩余角度为正则逆时针为负则顺时针输出cosθ和sinθ向量模式输入x, y坐标y值为正则逆时针为负则顺时针输出arctan(y/x)和向量模长放大倍数2. 定点数格式设计与精度预算2.1 数据格式选择整数位宽、小数位宽怎么定CORDIC算法在FPGA里实现第一件必须想清楚的事就是数据格式。FPGA不像CPU里跑浮点方便虽然现在有的FPGA也带DSP硬核支持浮点但你为了一个CORDIC去消耗DSP块做浮点加法资源效率实在太低了。标准做法是用定点数。我的经验是输入输出信号首先考虑要不要支持负数。CORDIC的迭代过程涉及角度正负、y值正负的判断所以肯定需要符号位。最常用的格式是二进制补码整数部分用1位符号位加若干位整数位再加上若干位小数位。比如我们用S3.12格式含义就是1位符号、2位整数、12位小数总共15位能表示的数值范围从-4.0到3.9995精度为2^-12。但实际操作里有个容易被坑的点CORDIC内部迭代过程中增益是不断累积的。每完成一次迭代向量的模长会乘以一个固定的系数大概1.64676。这个增益在旋转模式下会在最终结果里体现出来所以要么在CORDIC内部做补偿要么在后续信号处理链路里做校正比如做相位检测反正切最终结果不需要模长这个增益对角度值没有影响可以不管。但中间迭代过程会带来数据的动态范围扩大如果整数位宽定得不够很容易溢出。2.2 迭代次数与角度误差的理论计算迭代次数和精度之间是什么关系可以直接用数学公式估算。每次旋转的剩余角度残差在理论上随着迭代次数n的增加会以2的负n次幂的速度衰减。16位精度的定点系统迭代16次理论上角度误差大约在0.003度左右完全够大部分工程用了。实际仿真结果也会验证这个规律。具体来说CORDIC每次迭代旋转的角度是arctan(2^(-i))i是迭代序号从0开始。如果迭代次数选14次角度分辨率大约就是arctan(2^(-14))也就是约0.0035度换算成弧度大概是6.1e-5。这个精度做16位以下的系统绰绰有余。需要说明的一点是定点数的量化误差和迭代舍入误差是两件事。量化误差取决于小数位宽选12位小数量化分辨率为2^-120.000244对应角度误差大约0.014度。迭代误差取决于迭代次数两者要匹配否则一个过设计一个欠设计都在白费资源。经验公式是迭代次数约等于小数位宽加2。用S3.12格式配14次迭代是比较均衡的选择。2.3 增益补偿的两种处理策略CORDIC的固定增益问题得展开说一下。在第n次迭代完成后向量的模长已经乘了n次的sqrt(12^(-2i))。当n足够大时总增益收敛到约1.646760258。处理这个增益有两种思路。第一种是把补偿乘到输入上比如旋转模式下输入初始坐标设为(1/1.64676, 0)而不是(1, 0)这样输出就自动是正确幅度的cos和sin了。第二种是把补偿放到输出后级在CORDIC模块外部用乘法器修正。前者的好处是CORDIC模块本身不消耗额外的乘法器代价是你需要预先把补偿因子计算好并量化成定点数后者的好处是CORDIC模块保持理想的无损耗旋转公式后续要用的原始幅度信号可以直接取但代价是多半要消耗DSP乘法器资源。我做相位解调项目时向量模式反正切输出的角度值根本不需要做增益补偿省了这部分逻辑。做信号发生器时旋转模式要输出标准幅值的sin/cos波形就需要在输入补偿或者输出乘法二选一。我的方案是预处理输入把x0赋成1/K的定点值这样CORDIC内部完全没有乘法器纯用移位和加法资源占用非常干净。3. Verilog核心代码实现与流水线架构3.1 参数化模块设计可复用的CORDIC IP写CORDIC模块的第一原则是参数化。数据位宽、迭代次数、迭代模式都作为参数传进去这样同一个模块既能做sin/cos生成又能做arctan计算换参数就行。我习惯的模块定义类似这样module cordic #( parameter DATA_WIDTH 16, parameter ITER_NUM 14, parameter MODE ROTATION // ROTATION or VECTORING )( input wire clk, input wire rst_n, input wire valid_in, input wire signed [DATA_WIDTH-1:0] x_in, input wire signed [DATA_WIDTH-1:0] y_in, input wire signed [DATA_WIDTH-1:0] angle_in, output reg signed [DATA_WIDTH-1:0] x_out, output reg signed [DATA_WIDTH-1:0] y_out, output reg signed [DATA_WIDTH-1:0] angle_out, output reg valid_out );port列表里x_in、y_in、angle_in三个输入旋转模式用angle_in作为目标角度x_in和y_in作为初始坐标向量模式用x_in和y_in作为输入坐标angle_in闲置或者作为角度累加的初始偏移。valid_in和valid_out是数据有效信号这个绝对不能省流水线数据通路上没有握手信号会非常痛苦。内部迭代用流水线实现每一级迭代是一个独立的组合逻辑级级间用寄存器缓存。这样数据可以每个时钟周期进一个吞吐率很高代价是输出延迟有固定的迭代周期数。对于大多数信号处理场景这个延迟完全可以接受。3.2 迭代角度查找表用case语句实现旋转角度常量CORDIC迭代过程中每一级需要的旋转角度是固定的arctan(2^(-i))。把这些角度的定点值预先算出来放到一个常量表里每个迭代级按需取用。Verilog里最简单的实现就是用case语句。注意这些角度常量必须根据你的小数位宽精确量化。我用Python的numpy算好角度值再转成二进制补码写出来。这里的关键是角度值的表示方式如果你用16位数据位宽12位小数那么角度范围只能覆盖大约-4到4弧度而CORDIC的输入角度范围是±π这个范围刚好能覆盖。但如果你的角度数据是全范围定标比如用16位表示-180度到180度那常量表就要按照相应的比例换算。下面是我常用的角度常量生成代码片段用Python脚本输出Verilog的ROM表import math # 假设用S3.12格式15位数据1位符号2位整数12位小数 # 角度值按弧度计算转换为定点数 for i in range(16): angle math.atan(2**(-i)) encoded int(round(angle * 4096)) # 2^12 print(f14d{i}: 15d{encoded};)生成后粘贴到case语句里每个迭代级作为一个独立的case分支。要注意的是迭代索引和角度索引必须对齐不是每次迭代都用arctan(2^(-i))里那个i从0开始而是第0次迭代用arctan(1)这个值等于π/4也就是45度量化为定点后数值是3217。3.3 流水线级间寄存与迭代核心逻辑流水线实现的核心写法是生成语句配合二维数组寄存器。每一级迭代的x、y、z三个数据各有自己的寄存器迭代序号不同对应移位量也不同。我先定义一组中间信号的二维数组reg signed [DATA_WIDTH-1:0] x_pipe [0:ITER_NUM]; reg signed [DATA_WIDTH-1:0] y_pipe [0:ITER_NUM]; reg signed [DATA_WIDTH-1:0] z_pipe [0:ITER_NUM]; reg valid_pipe [0:ITER_NUM];然后每一级的逻辑用generate循环来生成。下面是旋转模式的典型迭代逻辑genvar i; generate for (i 0; i ITER_NUM; i i 1) begin : gen_iter wire shift_neg; assign shift_neg !z_pipe[i][DATA_WIDTH-1]; // 旋转模式角度剩余为正则逆时针 always (posedge clk or negedge rst_n) begin if (!rst_n) begin x_pipe[i1] {DATA_WIDTH{1b0}}; y_pipe[i1] {DATA_WIDTH{1b0}}; z_pipe[i1] {DATA_WIDTH{1b0}}; valid_pipe[i1] 1b0; end else if (valid_pipe[i]) begin if (shift_neg) begin // 逆时针旋转 x_pipe[i1] x_pipe[i] - (y_pipe[i] i); y_pipe[i1] y_pipe[i] (x_pipe[i] i); z_pipe[i1] z_pipe[i] - angle_table[i]; end else begin // 顺时针旋转 x_pipe[i1] x_pipe[i] (y_pipe[i] i); y_pipe[i1] y_pipe[i] - (x_pipe[i] i); z_pipe[i1] z_pipe[i] angle_table[i]; end valid_pipe[i1] 1b1; end else begin valid_pipe[i1] 1b0; end end end endgenerate这段代码有三个细节必须强调。第一是移位操作必须用算术右移而不是逻辑右移因为x和y都是有符号的补码数逻辑右移会把负数的高位补0导致整个迭代直接错误。这个坑我见过太多次了仿真波形在迭代几次后完全发散基本就是移位的符号性问题。第二是判断旋转方向时取z_pipe[i]的最高位符号位。第三是角度的迭代更新旋转方向不同做加或减。3.4 输入映射与象限处理CORDIC的输入角度范围有明确的限制旋转模式只支持大约-99.7度到99.7度。这显然覆盖不了整个360度范围。为了处理任意角度需要在模块入口做象限映射把输入角度变换到第一象限或者第四象限的等价角度进行CORDIC迭代然后在输出端根据原始象限信息修正符号。实际实现中我通常在模块前面加一个象限处理级记录输入角度的象限标志。如果是旋转模式把角度统一折叠到[-π/2, π/2]范围内迭代完成后按照象限标志调整输出正负号。这个逻辑写起来不复杂但如果你跳过了这一步直接用CORDIC当输入角度在第二象限时输出结果会完全错误。顺带提一下有的资料会把角度输入范围扩展到[-π, π]用的是先做一次45度预旋转的改进型CORDIC。这个方案在多级迭代里可以减少一到两级迭代但实现更复杂。对于大多数项目用象限折叠就够用了简单可靠最重要。4. 仿真验证与常见问题排查4.1 testbench设计全角度扫描与随机数验证写完RTL验证是第一位的。除非你写的是那种极其简单的组合逻辑否则FPGA开发里百分之八十的bug都要靠仿真去发现CORDIC这种多级迭代模块尤其如此。我的testbench思路是设计两种激励模式。第一种是全角度扫描输入角度从0到360度每个角度都算一遍把输出保存到文本文件再和Python算的标准值做对比分析。第二种是随机数模式对向量模式输入x和y在合理范围内随机取值检查输出的arctan值和理论值是否一致。下面是我常用的testbench模板的关键部分initial begin // 扫描模式 for (integer k 0; k 3600; k k 1) begin angle_tb k * 0.1 * 3.1415926 / 180.0; // 0.1度步进 (posedge clk); valid_in_tb 1; #10; valid_in_tb 0; // 等待流水线输出 repeat (ITER_NUM 3) (posedge clk); // 读取输出并记录到文件 end end自动对比可以参考我下面的流程用Python做一次理想的CORDIC浮点运算得到数据文件Testbench里把FPGA输出写入文件之后用脚本逐点对比算出最大误差和均方根误差。这一步能非常直观地告诉你精度的整体水平。我在项目里对旋转模式做全角度扫描迭代14次、带符号16位定点时最大角度误差在0.01度以内完全满足系统要求。4.2 常见错误排查表为什么你的CORDIC输出不对把我在实际调试过程中遇到的典型问题整理成了一张排查参考表遇到波形不正常可以对号入座。现象可能原因排查方式与修正前几级迭代正常后续数据忽然全部发散移位用了逻辑右移而非算术右移检查所有移位操作是否使用符号扩展输出数值正确但符号整体相反输入角度量化格式不一致比如有的模块用弧度定标有的用角度定标统一角度定标格式检查常量表生成脚本输出比理论值偏大约1.65倍没有做增益补偿在输入预乘1/K或者在输出后级乘补偿系数输入角度在90到270度区间时输出错误缺少象限映射处理增加象限折叠逻辑并在输出端恢复符号输出结果在某个角度附近跳变厉害定点数量化误差较大或者迭代次数不足增加小数位宽或迭代次数比如从12位小数改到16位valid_in连续输入时出现了间隔无效数据流水线级间valid信号传递不完整检查每级valid_pipe[i]是否逐级寄存器传递多次调用CORDIC模块后资源占用异常高生成了多份角度常量表寄存器没有共享将角度常量提取为function或公共ROM存储后级数据时序不满足采样到的数据偶发错误流水线输出延迟未和后级握手匹配确认valid_pipe信号的拉高时刻必要时用fifo缓存4.3 从仿真到上板信号与测试的工程化经验仿真全部通过了不代表上板就万事大吉。CORDIC模块上板后最容易暴露的问题是时序收敛和复位问题。时序方面随着迭代级数增加组合逻辑链会被拉得很长。每一级的移位和加减法本身不复杂但14级串联起来如果不做任何处理关键路径延迟可能比较长导致主频跑不高。解决办法是让综合工具在关键路径上做寄存器重定时或者手动在中间某些级间再插入寄存器。对于100MHz以下的系统14级流水线通常能run下来但如果你要上200MHz就得在综合设置里打开retiming。复位方面CORDIC内部的二维数组寄存器很多如果复位信号存在异步释放可能导致流水线中某几级恢复工作、其他级还在复位状态输出的valid信号和数据错位。我在项目里统一采用异步复位同步释放的方式处理复位信号并且在valid_pipe链路上也加复位确保所有级同时开始工作。如果你用的是xilinx的FPGA复位信号最好经过全局时钟网络BUFG再分发。上板的调试过程也不需要太复杂。第一个例子我会用拨码开关手动输入一个角度值然后通过LED显示输出sin/cos结果的符号位和高几位。手工拨几个角度验证正常后再用串口或者逻辑分析仪批量采集输出数据和Python计算的参考值做一致性对比。这个方法不需要复杂的调试环境但对排查问题非常高效。5. 应用场景扩展与工程优化建议5.1 数字混频器、相位解调器中的CORDIC集成CORDIC做sin/cos生成最常见的用途是数字混频器。在数字下变频DDC架构里需要本地振荡器产生高精度的正弦和余弦信号与输入信号做乘法完成频谱搬移。用CORDIC替代查找表之后频率分辨率由相位累加器的位宽决定想做多细都行生成的波形相位连续不会出现查找表切换时的跳变。做相位解调就反过来用向量模式。输入是中频信号的I/Q两路分量CORDIC输出的角度就是瞬时相位对相位求一阶差分就可以解调出FM信号或者做QPSK的相位判决。我在一个FSK解调器项目里就是直接用CORDIC的向量模式提取瞬时相位再经过一个滑动窗滤波器平滑相位导数得到的频率序列干净利落整个链路里没有用一个乘法器只有移位和加法。5.2 资源占用与性能优化数据对比用Quartus和Vivado综合一下资源数据可以直观看到CORDIC在同规模设计里的资源开销。下表是我在Cyclone IV和Artix-7两个平台上综合的结果数据位宽16位迭代14次。目标平台逻辑单元ALM/LUT寄存器乘法器DSP最高时钟频率Cyclone IV EP4CE10約680 LEs約7000125MHzArtix-7 XC7A35T約520 LUTs約6200210MHz值得注意的是整个CORDIC模块从始至终没有消耗乘法器。对于乘法器资源紧张的应用场景这个优势也可能是压倒性的。如果你用并行结构实现资源占用与迭代次数几乎成正比如果改用串行迭代结构即只做一级迭代后循环复用资源可以降到大约十分之一代价是吞吐率也变成十分之一。两种结构各有用武之地数据流连续处理的用流水线控制信号偶尔触发一次计算的用串行节省资源。5.3 进一步优化角度归一化与自适应迭代项目做到后期我一般会做两个可选的优化。一个是角度归一化把输入角度的表示从二进制小数换算成2π比例的数字域表示好处是角度加法操作不会出现弧度定点格式下容易发生的溢出问题相位累加器的实现就自然起来了。另一个是自适应迭代终止根据中间结果的精度判断是否提前结束迭代时间要求严格的场合这个技巧能省出几个时钟周期。自适应迭代的实现要加一个比较器当残差角度的绝对值小于某个阈值时停止后续迭代并且把结果保持住。这个优化在数据位宽比较大、迭代次数多的时候效果明显。但要注意提前终止的级数不能太多否则后面的级间valid信号会出现空洞处理不好反而降低吞吐率。我个人的测试结果是17位数据位宽下平均可以提前2到3级迭代节省约百分之十五的逻辑翻转量对降低动态功耗有一定帮助。这个算法扩展的方向其实很多。比如CORDIC经过简单变形还能计算双曲函数、指数函数、对数函数和开方底层都是同一个迭代结构。如果你的项目里同时需要三角函数和开方运算不如把CORDIC设计成一个模式可配置的通用模块一次投入多次复用综合面积比分别例化多个独立模块要小得多。我后续打算把双曲模式也加进来文档里先做个标记方便自己和团队后面扩展。