做FPGA开发的朋友,应该没少遇到需要生成正弦波和余弦波的场景。无论是信号发生器、锁相环里的NCO,还是电机控制里的坐标变换,只要涉及三角函数,都得有一个能算出sin和cos的模块。很多人第一反应是查表法,但波表大了浪费存储、小了精度不够,换个场景往往又要重新搪突。我自己在项目里实测下来,CORDIC算法配合Quartus II这套开发流程,属于那种“一次搭好、长期复用”的方案,资源占用低、精度可控、纯逻辑实现,特别适合FPGA这种天然并行的平台。
这篇内容就围绕“基于FPGA的CORDIC算法实现,输出sin和cos波形”来展开,用Quartus II作为开发工具,从原理、定点数设计、Verilog实现到ModelSim仿真验证,完整走一遍。适合正在学习FPGA数字信号处理的人,也适合手头正好需要生成三角函数波形、但不想在查找表和乘法器上纠结的工程师。读完你就能在工程里把它跑起来。
1. 内容整体设计与思路拆解
1.1 CORDIC是什么,为什么在FPGA里选它
CORDIC的全称是Coordinate Rotation Digital Computer,坐标旋转数字计算机。这个算法最早是上世纪五十年代末被提出来用于航海导航计算的,思路很直接:不直接计算sin和cos,而是通过一系列固定的、已知角度的微小旋转,让一个向量逐步逼近目标角度。
每次旋转都不涉及乘法,只需要移位和加法。这一点在FPGA里是致命的优势。FPGA虽然能做乘法,但乘一次要消耗DSP单元,如果只是生成一个波形,DSP用多了会影响其他模块的资源分配。CORDIC用ALU资源就能完成旋转,而ALU是FPGA里最不缺的资源。
从实现成本上看,一个16次迭代的CORDIC计算单元,在主流的中端FPGA上大概只用几百个逻辑单元,基本可以忽略不计。相比之下,查找表如果要做到同等精度,光存储容量就得翻好几倍。更关键的是CORDIC不依赖外部RAM,Block RAM能省下来留给真正需要它的地方。
1.2 系统架构与模式选择
CORDIC算法有两种常见模式:旋转模式和向量模式。旋转模式是给定一个角度,把初始向量转过去,输出旋转后的x和y分量,正好对应cos和sin;向量模式是给定x和y,反推角度,用于反正切计算。我们这里输出波形,要的就是旋转模式。
整体架构分三块:输入角度预处理、CORDIC迭代核、输出增益补偿。角度预处理负责把输入角度按照不同的象限范围做映射,避免迭代核处理过大的角度导致收敛变慢。迭代核是核心流水线,完成16次固定角度的旋转。增益补偿则是把迭代引入的幅度增益1.64676给修掉。
在FPGA里做这种多级处理,最自然的方式是流水线结构。每一级迭代占用一组寄存器和加法器,数据依次向下游流动,拍数固定,吞吐率稳定。好处是后续要接入更高频率的采样时钟也很从容,不会出现组合逻辑路径过长的问题。
1.3 精度与资源的初步权衡
这里需要做两个关键选择:迭代次数和位宽。迭代次数直接决定角度收敛的精度,理论上迭代越多误差越小,但收益是递减的。实测下来16次迭代已经能让误差衰减到1e-4量级,满足绝大多数波形生成场景。20次以后精度提升微乎其微,资源倒是实打实多了一半。
位宽则影响输出反馈的精度范围。按照16位有符号数来算,可以表示-32768到32767的整数范围。但CORDIC中间过程需要保留小数部分,否则迭代误差会被放大,所以我习惯把数据格式设计成Q1.14格式,最高位是符号位,整数部分占1位,小数部分占14位。
这样一来,输出值在-1到1之间变化时,分辨率能达到2^-14的精度,显示到波形上基本看不到阶梯感。如果你需要更高精度,把整体位宽从16扩展到24也是同理,只是资源的增加比例要提前算清楚。
2. 核心细节解析与实操要点
2.1 定点数格式与角度编码细节
FPGA里没有浮点运算单元,小数只能靠定点数来表示。我在工程里采用16位有符号数,其中最高位是符号位,接着是1位整数位,然后14位小数位,也就是Q1.14格式。角度变量同样采用定点表示,但角度还有一个归一化的问题。
比如角度范围设定为-π到π,映射到二进制就需要一个统一的编码方式。一个实数角度θ,编码成角度寄存器的值angle,满足angle = θ / π * 2^15。这样做的原因在于CORDIC迭代时只需要判断当前剩余角度的符号,比较大小比浮点计算简单得多。
迭代过程中的角度判别值也需要预先转换成定点数。CORDIC的固定角度序列是arctan(2^(-i)),这个在数学上是个无理数序列,但转换为Q1.14格式后,每个迭代角度都变成一个整数常量,存到ROM或者常量数组里。这里推荐直接做成参数数组,Quartus II综合时会自动优化成常量逻辑,不会额外消耗存储资源。
2.2 迭代方程与旋转方向的判别逻辑
CORDIC迭代的核心方程是所有FPGA实现的基础。如果把一个向量从(x0, y0)旋转角度θ,那么第i次迭代的公式如下:
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) * arctan(2^(-i))其中d(i)的值取决于z(i)的符号。在旋转模式下,如果当前剩余角度z(i)大于0,说明还没转够角度,d(i)取+1,向正方向旋转;如果z(i)小于0,说明转过量了,d(i)取-1,向反方向旋转。这种负反馈机制和数值求解中逐次逼近的思路类似,每一步都在修正前一步的行为。
实现上,x和y是级联的,后面的计算依赖前面的结果,所以迭代不能并行一次完成。但如果做成流水线,将16次迭代分成16级,每一级只做一次移位和加减法,那么数据进来后经过固定的16个时钟周期延迟,就能连续输出结果,吞吐率非常高。这也是CORDIC在FPGA里能跑高速的根本原因。
2.3 增益补偿与收敛误差控制
CORDIC迭代的副作用是旋转后的向量幅度会变大。n次迭代后的总增益是∏√(1+2^(-2i))。当迭代次数足够多时,这个乘积收敛到1.64676,也就是前面提到的那个常数。如果不补偿,输出的sin/cos波形幅度会比标准值大64.7%左右,表现为波形“高出一截”,完全是错的。
补偿策略有两种:一种是在迭代核输出后乘一个1/1.64676的定点数,另一种是直接把补偿系数预置到初值里。后者更划算,因为这样可以少做一次乘法。具体做法是把初始x置为1/1.64676的定点表示,y置为0,这样迭代结束后x和y都已经被补偿完毕。
误差方面,除了增益误差,还有角度量化误差。迭代次数决定了每种角度的最小判断步长,16次迭代的角度分辨率大约在1e-5弧度量级。如果你在最终波形上观察到某些角度点有微小的毛刺,多半是定点数舍入造成的,而不是算法本身的问题。这种误差可以通过在最终输出级增加舍入逻辑来改善,比盲目的增加迭代次数更有效率。
3. 实操过程与核心环节实现
3.1 Quartus II工程搭建流程
Quartus II虽然版本老,但胜在稳定,工程搭建步骤几乎没变过。我不建议上来就直接写代码,先把工程结构和文件规划好,后面调试会省很大力气。
工程搭建时说几个容易忽略的点。第一,器件型号最好提前选好,如果只是做逻辑验证或者仿真,不指定具体型号也可以,但Quartus II综合时会默认选一个型号,可能导致资源报告误导。第二,仿真工具要单独设置。Quartus II默认用自带的仿真器,但业界用ModelSim更普遍。在Assignments菜单的Settings里找到EDA Tool Settings,把仿真工具指定为ModelSim-Altera,仿真格式选Verilog,这样testbench写好后可以直接用ModelSim跑。
我的习惯是建立这么几个目录:src存放RTL源码,sim存放仿真文件和testbench,doc存放设计文档。工程文件本身放在根目录。这样做的好处是版本管理时很方便,换电脑或者换版本也不会丢东西。
3.2 Verilog核心代码实现与关键信号说明
下面是CORDIC迭代核的核心代码,数据位宽16位,迭代16次。为了保证代码可读性,我用了Generate语句按级展开,每一级做一个旋转移位和角度加减,这比用for循环综合出的逻辑更容易理解,排查问题也方便。
module cordic_sin_cos #( parameter DATA_WIDTH = 16, parameter ITER_NUM = 16 )( input wire clk, input wire rst_n, input wire valid_in, input wire signed [DATA_WIDTH-1:0] angle_in, // Q1.14格式角度 output reg signed [DATA_WIDTH-1:0] sin_out, // 正弦输出 output reg signed [DATA_WIDTH-1:0] cos_out, // 余弦输出 output reg valid_out ); localparam signed [DATA_WIDTH-1:0] GAIN_COMP = 16'sd19897; // 1/1.64676 // 迭代连接线 wire signed [DATA_WIDTH-1:0] x_wire [0:ITER_NUM]; wire signed [DATA_WIDTH-1:0] y_wire [0:ITER_NUM]; wire signed [DATA_WIDTH-1:0] z_wire [0:ITER_NUM]; wire [ITER_NUM-1:0] d_wire; // 迭代初值,x预置增益补偿 assign x_wire[0] = GAIN_COMP; assign y_wire[0] = 16'sd0; assign z_wire[0] = angle_in; genvar i; generate for (i = 0; i < ITER_NUM; i = i + 1) begin : cordic_iter assign d_wire[i] = z_wire[i][DATA_WIDTH-1]; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin x_wire[i+1] <= 16'sd0; y_wire[i+1] <= 16'sd0; z_wire[i+1] <= 16'sd0; end else begin if (d_wire[i] == 1'b1) begin x_wire[i+1] <= x_wire[i] - (y_wire[i] >>> i); y_wire[i+1] <= y_wire[i] + (x_wire[i] >>> i); z_wire[i+1] <= z_wire[i] + rotate_angle(i); end else begin x_wire[i+1] <= x_wire[i] + (y_wire[i] >>> i); y_wire[i+1] <= y_wire[i] - (x_wire[i] >>> i); z_wire[i+1] <= z_wire[i] - rotate_angle(i); end end end end endgenerate // 输出级 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sin_out <= 16'sd0; cos_out <= 16'sd0; valid_out <= 1'b0; end else begin sin_out <= y_wire[ITER_NUM]; cos_out <= x_wire[ITER_NUM]; valid_out <= valid_in; end end // 角度常量生成函数 function automatic signed [DATA_WIDTH-1:0] rotate_angle(input integer idx); real angle_rad; integer angle_q; begin angle_rad = $atan(1.0 / (1 << idx)); angle_q = $rtoi(angle_rad * 16384.0); rotate_angle = angle_q; end endfunction endmodule这里有两个容易写错的点。一是移位操作必须用>>>算数右移,不能简单的用>>。因为有符号数做右移时,如果用逻辑移位,负数的高位会补0,整个迭代就会出错。二是rotate_angle函数里面用了$atan系统函数,这个函数在综合时会被计算成常量,不会真的生成一个反正切计算单元,但仿真时需要确认你的仿真库支持$atan。ModelSim自带的库支持,Quartus II原生仿真在部分老版本里可能需要额外处理,这也是我推荐用ModelSim的原因。
3.3 仿真与波形验证:testbench怎么写
写testbench的核心目标是验证两个点:一是角度从-π扫到π时,输出是否满足cos和sin的关系;二是输出的幅度是否在正确范围内。
testbench里我建议不要只给几个固定角度,那样看不出全范围行为。比较直接的做法是用$signed和$rtoi函数在测试中生成一个从-32768到32767的线性递增角度,然后观察输出波形。为了让波形看起来连贯,可以用for循环以每个时钟周期一个角度的方式输入,并同时把标准库里的$cos和$sin计算结果转换成定点数,作为期望值对比。
testbench的关键代码如下:
integer i; reg signed [15:0] angle; reg valid_in_t; reg clk_t, rst_t; initial begin clk_t = 0; rst_t = 0; valid_in_t = 0; #100 rst_t = 1; angle = -16'sd32768; for (i = 0; i < 65536; i = i + 1) begin @(posedge clk_t); valid_in_t = 1'b1; angle = angle + 16'sd1; end end仿真时需要注意ModelSim的radix设置。在观察sin_out和cos_out时,如果直接看十六进制,很难判断波形的实际幅度水平。正确做法是在Wave窗口里把sin_out和cos_out的Radix设置为Signed Decimal,并且用模拟波形显示方式打开,这样就能直接看到想到的锯齿波形状。换到模拟波形显示后,还要注意设置Y轴范围,建议将显示范围设为-40000到40000,这样能看到完整幅度的同时也不会压缩幅度信息。
3.4 在Quartus II中完成综合与引脚约束
仿真通过之后,接下来就是综合。Quartus II的综合报告里有用的信息主要是资源占用和最高时钟频率。如果是第一次跑这款CORDIC模块,建议只看这两个地方就够了。资源方面重点关注ALUTs和Registers的数量,时序方面查看Fmax是否满足你的采样率要求。
引脚约束文件(.qsf)如果不做上板验证,其实可以只定义时钟引脚。如果有机会上板,注意把角度输入口定义成按键或者拨码开关,sin和cos输出接到DAC,或者用片上逻辑分析仪观察。绝大多数情况下,CORDIC模块本身不会时序违例,但如果你把迭代次数拉高到20以上,同时主时钟跑200MHz,就要留意组合逻辑路径会不会变长。如果出现时序不收敛,可以尝试在迭代级之间再做一级打拍,用流水线延迟换取更宽松的时序路径。
4. 常见问题与排查技巧实录
4.1 输出波形幅度不对
这是最容易遇到的问题。如果波形出来是正弦形状,但幅度明显超出了预期范围,比如Q1.14的期望幅度应该接近16384,但实际输出到了27000左右,这就是典型漏算增益补偿。解决办法很简单,在初值中预置补偿系数即可。
但还有一个隐蔽的坑:补偿系数如果算错,也会导致幅度偏差。1/1.64676等于0.60725,乘以2^15后取整是19897。如果你把定点数格式换成Q2.13或者Q1.15,这个量化的整数值就不同了。我发现很多同学在改数据位宽时只改了模块位数,补偿系数沿用旧的,结果波形幅度飘出去。建议每次修改位宽后重新计算一遍补偿系数,不要想当然。
4.2 角度输入与象限处理错误
测试sin和cos时,给一个从0到π/2的角度范围,波形看着是正常的。一旦从-π扫到π,就发现sin的负半周和cos的正半周之间出现奇怪的折线,这个现象往往是CORDIC迭代次数不够导致的收敛问题。当输入角度接近±π时,剩余角度过大,有限的16次迭代可能来不及完全收敛。
有两种解法。一种是把迭代次数加到18或者20,但代价是资源上升。另一种是在模块前端增加简单的象限包装逻辑,核心CORDIC只处理0到π/2的角度,其余象限通过角度加减法映射回来。我推荐第二种方法,因为只增加很少的逻辑资源,而且能保证全范围精度,工程可靠性更高。
4.3 仿真时完美,合成后波形畸变
这种情况我遇到不止一次,原因大多出在时钟和复位上。仿真时clk和rst_n都是理想信号,但综合后的实际电路中,如果复位信号没有做异步复位同步释放处理,可能出现复位释放时寄存器没有稳定进入工作状态,导致迭代状态错乱。
解决方式是在testbench中模拟真实复位行为:拉高rst_n时先让复位释放,然后等待至少3个时钟周期再送数据。另外在顶层模块加一个两级同步器,将外部复位信号同步到时钟域内,这样可以去掉绝大部分复位带来的竞态问题。这个坑不太容易一眼看出来,但遇到波形畸变时一定要优先怀疑复位,其次是时钟极性。
4.4 资源占用与可移植性细节
CORDIC模块本身占用很轻,我实测16次迭代在Cyclone系列上约消耗不到800个逻辑单元,整体Fmax能轻松跑到200MHz以上。但如果你把它嵌入到一个大的通信系统里,要注意综合时是否把CORDIC内部的组合路径和外部逻辑合并得过于紧密,导致时序报告混乱。如果出现这种现象,可以在模块的输入输出边界都加一级寄存器,切割组合路径。
另外可移植性方面,Quartus II和Vivado对Verilog的支持有细微差异,主要在系统函数和integer使用限制上。我这套代码用Quartus II 13.0和18.1都综合过,没有问题。如果以后要迁移到Vivado,需要注意$atan函数在综合时有些版本可能不认识,建议先跑一遍逻辑综合。最好的做法是直接用常量表预定义那16个角度值,彻底摆脱系统函数依赖。
4.5 排查技巧速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 输出幅度过大 | 增益补偿系数错误 | 检查GAIN_COMP是否等于0.60725的定点量化值 |
| 输出为常数0 | 复位一直有效 | 检查rst_n时序和异步复位释放 |
| 正半周正常负半周异常 | 象限处理或迭代次数不足 | 增加迭代次数或添加象限映射 |
| 输出波形有尖刺毛刺 | 定点数舍入误差 | 输出级增加四舍五入逻辑 |
| 综合Fmax过低 | 迭代级联路径过长 | 迭代级间插入寄存器打拍 |
5. 进一步优化:多通道并行与流水线扩展
如果你只需要一路sin/cos波形,上面的方案已经完全够用。但在某些场景下,比如正交解调器、多相滤波器或者雷达信号模拟器,可能同时需要多路不同频率的正弦波。这时候做三四个独立的CORDIC模块就显得不划算了,更好的做法是时分复用同一组CORDIC流水线。
具体思路是,用外部计数器和旋转角度查表生成不同初始角度的输入,然后在一个固定时间片上复用同一个迭代核。比如迭代需要16个时钟周期,那么你可以每16个周期轮流送入不同角度的数据,输出侧同步切换对应的寄存器,这样一路CORDIC就变成了多路通道。由于CORDIC迭代核是一个纯组合展开的流水线,复用后时钟频率几乎不受影响。
这个技巧在我做某跨平台系统的频率合成模块时实践过,用同一份CORDIC逻辑同时服务4个通道,各个通道波形互不干扰。唯一要注意的是valid信号的时序对齐,输出寄存器必须和valid_out一起打拍,否则后续模块很容易抓到无效数据。
6. 最后再分享一点实操中的经验
在做CORDIC的过程中,踩得最多的坑其实并不是算法本身,而是仿真环境对系统函数的支持差异。把角度常量枚举出来是一劳永逸的办法,虽然代码看起来不如函数式写法简洁,但综合和仿真的兼容性都会好很多。
还有一点是关于中间位宽是否扩展。我在核心代码中只用了16位信号,没有扩展中间迭代的位宽。很多人会问,16次迭代中间过程中会不会有数值溢出?实际上CORDIC旋转有自动收敛性,向量模长会等比增长,只要正确定义了初值范围,在Q1.14下不会发生溢出。但如果你的输入角度包含超过±π的极端值,或者初值x设置得比增益补偿大很多,那就要考虑把迭代中间变量加到18位,最后输出前截断回去,会更稳妥一些。
按这套流程在Quartus II里跑通之后,你会发现CORDIC最大的价值在于它的确定性。每次给相同角度,输出严格一致,不依赖任何外部存储或模拟单元,这在工程复现和批量移植时非常重要。希望这篇内容能帮你少走几步弯路,把sin/cos发生器稳定地跑起来。