做过数字信号处理的工程师应该都有感触:算个sin/cos、反正切、平方根,看似不起眼,但在FPGA里实现起来却有点麻烦。查表法简单直接,但精度一高,ROM资源就像流水一样往外淌;自己写CORDIC算法,迭代逻辑十来行就能写完,可真到了时序收敛、精度验证、多通道复用这些环节,坑一个接一个。后来我转到Vivado的CORDIC IP核上,才算是从算法细节里解放出来,配置加仿真不到半天就能跑通。这篇文章我就把从IP核配置到算法验证的完整过程拆开讲一遍,适合正在用Vivado做信号处理、通信基带、或者刚接触CORDIC IP核的工程师和学生参考。
1. 从算法到IP核:CORDIC到底解决了什么问题
1.1 为什么要在FPGA里算三角函数
先聊点背景。FPGA不像CPU,不能直接调用math库函数,所有数学运算都要靠自己搭逻辑。在数字下变频、锁相环、坐标变换、幅相解调这些经典场景里,sin/cos、arctan、平方根几乎是绕不开的。以前我见过不少项目组用查找表做正弦波发生器:先把一个周期的波形采样点存进ROM,再由相位累加器查表输出。这个方法在小位数、低精度时很香,但一旦需要高分辨率、低杂散,ROM容量就得翻几倍,而且频率分辨率也会受限于存储深度。
CORDIC(Coordinate Rotation Digital Computer)算法就完全是另一条路。它不靠存储,而是靠“旋转”逼近结果。每来一个时钟,就往目标角度转一步,转的步长越来越小,最终收敛到需要的三角函数值、模值或者相位。整个过程只有移位和加法,对FPGA来说极为友好——FPGA最擅长的恰恰是并行加法和硬移位逻辑,几乎不占DSP单元和BRAM。
1.2 CORDIC在Vivado里的定位
Xilinx把CORDIC算法封装成了一个标准IP核,支持旋转、向量变换、双曲变换、平方根、对数等常见运算。它在Vivado的IP Catalog里直接搜“CORDIC”就能找到。
用IP核最大的好处是省心:迭代次数、流水线级数、数据位宽、延迟这些原本需要你逐行计算的东西,图形界面里填好参数,生成的RTL代码和约束就已经是可综合状态。我在实际项目中用下来,它比手写CORDIC的时序好收敛得多,尤其是在500MHz以上的时钟域里,Xilinx对IP核做了充分的时序优化,比自己写迭代逻辑要稳定。
1.3 这篇实战要覆盖的内容
这篇文章不会只讲怎么在GUI里点几下生成一个IP核,那是Vivado自带文档就能解决的问题。我重点拆解三件事:第一,CORDIC IP核背后每种功能选项对应什么算法模式;第二,配置界面里那些参数到底改了什么、不改又有什么风险;第三,如何用Testbench快速验证输出结果,以及我在项目里踩过的几个典型坑。理解了这三点,你拿到任何需要三角函数运算的工程,都能快速判断该选哪个参数组合。
2. CORDIC算法原理:三个坐标系、两种模式、一个增益
2.1 迭代旋转的本质
CORDIC的核心思想看起来有点笨,但很有用:要旋转一个角度,不直接转,而是拆成一系列固定的、可被移位表示的小角度,逐步逼近目标。这些小角度的正切值设置为2的负整数次幂,即满足tan(αi) = 2^{-i},那么旋转该角度时,只需要做一次右移和一次加减法。
每次迭代的递推公式如下(圆周坐标系、旋转模式):
x_{i+1} = x_i - di * yi * 2^{-i} y_{i+1} = y_i + di * xi * 2^{-i} z_{i+1} = z_i - di * arctan(2^{-i})
其中di是旋转方向,由当前剩余角度z_i的符号决定。每迭代一次,剩余角度就减小一点,最终z趋近于0。这个过程的几何意义非常直观:把向量按预定的小步长旋转,越转越靠近目标角度,就像一个人从很远处走向靶心,步子越迈越小,最后收敛在靶心附近。
在实际工程里,我建议先别急着看IP核配置,先把这个迭代过程在纸上推一遍,哪怕就是手写十几次迭代,你也能明显感受到误差收敛的速度。CORDIC每迭代一次大概增加1位有效精度,所以16次迭代大约能到4.8位十进制精度,已经覆盖绝大多数通信系统需求。
2.2 旋转模式和向量模式
CORDIC有两种基本工作模式,搞清楚它们是理解配置界面的关键。
第一种是旋转模式(Rotation Mode)。输入是一个初始向量和一个目标角度,算法把初始向量旋转这个角度,输出旋转后的坐标。如果初始向量取(1, 0),那么输出坐标的x分量就是cos值,y分量就是sin值。Vivado里对应的功能选项是Rotate和Sin/Cos。
第二种是向量模式(Vectoring Mode)。输入是一个向量的x、y分量,算法通过迭代把y分量旋转逼近到0,此时z累积下来的角度就是向量与x轴的夹角,也就是arctan(y/x)。同时还能输出向量的模长。Vivado里对应的功能选项是Translate和ArcTan。
这两种模式可以在三种坐标系下工作:圆坐标系、线性坐标系、双曲坐标系。圆坐标系解决三角运算,双曲坐标系解决双曲函数、指数对数和开方,线性坐标系则用于乘除法。Vivado IP核把常用运算直接映射成功能选项,你选功能,它自动选择坐标系和模式,不需要自己动这些底层逻辑。
2.3 容易被忽略的增益补偿
CORDIC算法有一个几乎所有人初次使用都会遇到的小陷阱,就是旋转过程中向量模长不是恒定的。每次旋转一个小角度√(1+2^{-2i})倍,所有迭代乘在一起,收敛后总增益约为1.64676。如果不在后端补偿这个增益,你拿到的sin/cos输出会比真实值大1.64676倍,这在幅值敏感的系统中会造成持续偏差。
Vivado IP核的配置界面里有一项Compensation Scaling,就是干这个的。开启后,IP核内部会把输出除以这个增益系数,你拿到的输出就是正常的正弦余弦值。如果关闭,输出带增益,但好处是节省了一点资源。我在单音混频项目里第一次没开这项,结果下变频后的信号幅度一直偏大,查了整整半天才定位到这个隐秘的系数。
3. Vivado CORDIC IP核配置界面逐项拆解
3.1 功能选择:不同选项对应什么运算
打开IP核配置界面,第一栏就是Functional Selection,Vivado官方把这些选项划分得很直观:Rotate、Sin/Cos、Sinh/Cosh、ArcTan、ArcTanh、Translate、Square Root。很多初学者在这里就开始懵了,因为文档里都说是“功能选择”,但没说清楚各自背后对应的是哪种算法模式。
我做了一张对照表,把功能选项、实际算法模式和典型应用场景对应起来,用顺手之后可以参考:
| 功能选项 | 算法模式 | 坐标系 | 典型用途 |
|---|---|---|---|
| Sin/Cos | 旋转模式 | 圆周 | 正弦波发生器、混频器本振 |
| Rotate | 旋转模式 | 圆周 | 坐标旋转、矢量调制 |
| ArcTan | 向量模式 | 圆周 | 鉴相、频率估计、IQ解调 |
| Translate | 向量模式 | 圆周 | 直角坐标转极坐标,输出模值和相位 |
| Sinh/Cosh | 旋转模式 | 双曲 | 双曲函数运算 |
| ArcTanh | 向量模式 | 双曲 | 反双曲正切 |
| Square Root | 向量模式 | 双曲 | 平方根计算 |
这里有个经验:很多项目只需要算一个arctan,却选了Translate,结果多算了一个用不到的模值输出,白白增加资源消耗。按需选功能,输出端口只保留自己需要的字段,比什么都要强得多。
3.2 结构选择:并行还是字串行
配置界面里有一项Architecture Configuration,选项包括Parallel和Word Serial两种。它们的区别很直观:并行结构把每一级迭代都用一套独立的硬件实现,流水线全开,每个时钟周期都能进一个新数据、出一个新结果,吞吐率高但资源占用大;字串行结构多级复用一套迭代单元,资源少但延迟大,吞吐率也会下降。
我在实际选型时的判断标准很简单:数据率高于几十MSPS的一般直接上并行;低速控制环路、或者逻辑资源已经吃紧的工程,可以考虑字串行。需要特别提醒的是,字串行结构的结果不是每个周期都有效,你必须留意M_AXIS_DATA_TVALID信号,只有它拉高的时候读出的结果才有意义。
3.3 相位格式与数据位宽的设定
输入相位的数据格式是CORDIC IP核配置里最容易出错的一环。默认情况下,phase端口是定点有符号数,整个数据位宽对应的是2π的相位范围。也就是说,输入相位值的满量程被映射到了0到2π(或者-π到π),并不是直接填0到360。
举个例子:如果Phase Format选的是Signed,输入位宽16位,那输入相位0x4000就对应π/2,也就是90度。要是一个不小心直接填了90,出来的正弦值完全不对,而且这类错误在波形上很难一眼看出来,我是吃过这个亏的。正确写法是先把角度换算成满量程比例,比如90度对应的归一化值是90/360=0.25,再乘以2^15(因为是带符号数),得到8192,也就是0x2000。
输出数据位宽决定了结果的精度和资源消耗。输出位宽每增加一位,LUT占用大约线性增加,但精度提升也是实打实的。一般通信链路里,输入输出都设18位左右就够用;如果后续有滤波器或调制器,建议至少留2位整数位,防止溢出导致符号翻转。
3.4 AXI-Stream接口的握手细节
CORDIC IP核在Vivado里默认采用AXI4-Stream接口,输入和输出各有一组TVALID/TREADY握手信号。用这个IP核的工程师大多不太关注这套握手协议,因为它在简单场景下只要一直拉高TVALID、无条件接收TREADY就能跑通。但一旦接入真实数据通路,握手不规范就会导致数据丢失或卡死。
我的建议是,给CORDIC输入加一个简单的AXI-Stream FIFO,用上游的写侧对接IP核的输入侧,这样即使上游数据突发性好,也不会把IP核的输入卡顿传播到整条链路。输出侧如果下游处理速度慢,同样用FIFO缓冲。IP核本身内部的延迟是固定的,输入数据到输出结果会有固定的排队延迟加流水线延迟,这在系统设计时要提前算进去,否则时序上会莫名其妙地错位。
4. 从配置到验证:写一个Testbench把IP核跑通
4.1 测试激励怎么搭
这里我以Sin/Cos功能为例,演示怎么搭一个可复现的Testbench。我在Vivado里新建一个工程,添加CORDIC IP核,Functional Selection选Sin/Cos,输入数据位宽16位,输出数据位宽16位,架构选Parallel,Phase Format选Signed。生成完IP核后,写一个简单的Verilog测试文件。
Testbench的核心逻辑不复杂:复位后拉高输入握手信号,连续送入几个已知相位值,然后观察输出端的X_OUT和Y_OUT数据,换算成浮点数之后和标准正弦余弦值对比。
注意,这里不能直接随机送数据,一定要先用Matlab或者Python把期望值算好,否则仿真结果出来了你也不知道对不对。
4.2 一个可以直接跑的Verilog测试代码
下面这段代码我实际用过,去掉了一些无关的打印,功能是完整的,可以直接跑行为仿真。
`timescale 1ns / 1ps module tb_cordic_sincos; reg aclk; reg aresetn; reg s_axis_phase_tvalid; wire s_axis_phase_tready; reg [15:0] s_axis_phase_tdata; wire m_axis_dout_tvalid; wire [31:0] m_axis_dout_tdata; wire signed [15:0] cos_out = m_axis_dout_tdata[15:0]; wire signed [15:0] sin_out = m_axis_dout_tdata[31:16]; initial aclk = 0; always #5 aclk = ~aclk; initial begin aresetn = 0; s_axis_phase_tvalid = 0; s_axis_phase_tdata = 16'd0; repeat (10) @(posedge aclk); aresetn = 1; // 输入 30° 相位归一化 @(posedge aclk); s_axis_phase_tvalid <= 1; s_axis_phase_tdata <= 16'sd5461; // 30/360 * 65536 // 输入 90° @(posedge aclk); s_axis_phase_tdata <= 16'sd16384; // 90/360 * 65536 // 输入 150° @(posedge aclk); s_axis_phase_tdata <= 16'sd27306; // 150/360 * 65536 s_axis_phase_tvalid <= 1; wait (m_axis_dout_tvalid === 1); repeat (10) @(posedge aclk); $finish; end cordic_0 dut ( .aclk(aclk), .aresetn(aresetn), .s_axis_phase_tvalid(s_axis_phase_tvalid), .s_axis_phase_tready(s_axis_phase_tready), .s_axis_phase_tdata(s_axis_phase_tdata), .m_axis_dout_tvalid(m_axis_dout_tvalid), .m_axis_dout_tdata(m_axis_dout_tdata) ); endmodule有一点需要说明,输出结果m_axis_dout_tdata的高16位和低16位分别对应Y_OUT和X_OUT,但具体谁是cos谁是sin,不同Vivado版本可能有细微差别,务必查一下该版本IP核的Product Guide。我这里按常用版本的习惯写法,实际使用要学会自己翻文档确认。
4.3 仿真结果怎么换算验证
仿真跑完后,在Vivado的Waveform窗口里可以看到输出码值。以30度输入为例,如果输出位宽16位带符号,你看到的X_OUT大约是0x6EDA,Y_OUT大约是0x2000多一点。换算方法很简单:把二进制码值当成有符号整数,除以2^15,就得到浮点数的cos和sin近似值。
这里我建议你用这样的思路来算:输出数据如果是16位带符号,满量程范围是-32768到32767,对应的实际值范围是-1.0到约1.0。0.866乘以32768大概是28377,对应0x6ED9;0.5乘以32768是16384,对应0x4000。所以看到输出码值接近这两个数,说明IP核工作正常。以上结果我做过的工程里都能对上,误差很小,主要是迭代次数和舍入带来的量化误差。
4.4 关键延迟参数别忽略
CORDIC IP核是有固定延迟的。你在配置界面定好迭代数和架构之后,Summary页面会给你一个Latency值。并行结构下,这个延迟通常是迭代数加流水线寄存器数。仿真时,输入数据对应的输出会延迟若干个时钟周期才出现,所以一定要等到M_AXIS_DOUT_TVALID拉高后再采样输出,否则采到的是上一批数据或者无效数据。
我在项目里经常见到有人不看延迟,直接在输入后两个时钟采样输出,结果输出的波形完全错位。解决起来也简单:仿真里等TVALID,硬件里用FIFO对齐或者直接用IP核自带的valid信号去同步下游逻辑。
5. 实战中的典型问题与优化建议
5.1 收敛范围限制带来的输入预处理
CORDIC算法虽然好用,但有收敛范围限制。圆周坐标系下,它的相位输入范围只能覆盖[-π, π],也就是全角度范围内一半的区间。如果输入的相位超出这个范围,精度会急剧下降,输出完全不对。双曲坐标系的计算范围限制更严格,Sinh/Cosh和ArcTanh的输入参数必须落在特定区间内,超出即发散。
我遇到的最常见场景是相位累加器输出0到2π的范围,直接接到CORDIC IP核时,后半段输入超出π,出来的波形明显畸变。解决办法是做一个范围折叠:把输入相位减半到[-π, π]内,或者取模后映射到有效区间。硬件实现做法很简单:如果相位最高位为1,就减去固定值2π(按定点格式做一次减法),再做符号修正。记得在代码注释里写明这一步,不然三个月后你自己回来读代码也容易懵。
5.2 精度不够:迭代次数和输出位宽的平衡
有次我在做高精度鉴相时,觉得输出的arctan结果噪声偏大,最初怀疑是后端电路的问题,最后排查发现是CORDIC的迭代次数不够。Vivado IP核的迭代数默认和输出位宽挂钩,如果你输出位宽设得不高,迭代数也不会大。想提高精度,最简单的办法是同时提高输入输出位宽和迭代数。
但位宽和迭代数不是越大越好。输出位宽增加会线性增加LUT和FF占用,迭代数增加会线性增加Latency,两者叠加可能导致时序收敛困难。我的经验是:系统设计时先定精度需求,比如相位误差要低于0.1度,推出所需输出位宽,再通过仿真验证迭代数是否足够,不要一上来就把位宽拉满。
5.3 硬件上板后输出不稳定的排查思路
如果你在仿真里一切正常,上板后却发现CORDIC输出偶尔跳变,按下面的顺序排查通常能命中:先检查时钟是否干净,CORDIC IP核使用的时钟如果是PLL生成的,确认时钟约束有没有正确传递到IP核的输入引脚;再看复位时序,ARESETN至少要保证几个时钟周期的低电平,释放时不能和有效输入同时发生;最后检查跨时钟域,如果输入数据是从异步FIFO读出来的,确认FIFO读侧和CORDIC时钟是同源,否则亚稳态问题会直接体现为输出毛刺。
如果问题是偶发的,我建议在调试时把M_AXIS_DOUT_TVALID信号用一个IO拉出来,接到逻辑分析仪上观察,对比输入有效信号和输出有效信号的时间关系,能很快定位到是哪一端握手出了问题。
5.4 资源优化:并发多路复用的技巧
一块FPGA里往往不止一路信号需要三角函数运算,比如多通道解调系统,每个通道都要算arctan。最粗暴的做法是为每个通道例化一个CORDIC IP核,这在通道少的时候没毛病,但到了8通道以上,LUT占用就非常可观。
我常用的优化思路是分时复用:如果一个通道的数据率不高,把多个通道的数据送到同一个CORDIC核里做时分复用,用一个计数器轮询切换通道号,IP核输出侧再用解复用器把结果按通道分开。这样做的好处只有一个IP核的资源开销,但要注意CORDIC的流水线延迟会在通道切换时产生气泡,控制好调度时序,别让两个通道的数据在同一时刻争抢输入端口就行。
此外,如果只是做sin/cos查表,且频率分辨率不需要太高,可以不启用CORDIC IP核,改用DDS IP核,它内部就是用相位累加加查找表实现的,资源开销在同等精度下通常更小。CORDIC IP核最擅长的是那些无法简单查表解决的运算,比如arctan和平方根,这类场景用它性价比最高。
6. 产品级使用的一些额外心得
最后再分享一点我自己在多个项目里摸出来的体会。CORDIC IP核用起来难度不大,真正容易出问题的地方在于和整个系统其他模块的配合。比如在通信接收机里,CORDIC算出来的相位经过环路滤波器后,经常要反馈给NCO去调整本振频率,这时候CORDIC的延迟是环路稳定性分析里必须考虑的一环。延迟太大,环路增益过高就容易振荡。所以不要等系统联调时才去查CORDIC的latency,设计初期就要把它和滤波器、DDS这些模块的延迟一起加进环路模型里算。
如果只是在做算法验证,IP核生成的RTL和Xilinx官方文档足够用了;但要量产,我建议在CORDIC输出端加一个简单的饱和保护逻辑,防止定点数在极端输入下溢出后产生符号翻转。这个保护逻辑也就两三个比较器和选择器的事,却能避免很多现场环境里的偶发异常。
我在一个射频测向项目里用过CORDIC IP核做多通道相位差计算,当时最困扰我的不是算法本身,而是多路信号到达时间不一致导致相位差计算混乱。后来我在每个通道前加了校准延迟,把各路信号对齐后再进CORDIC,问题才彻底解决。这个例子想说明的是,IP核只是一个工具,系统层面的时序对齐和数据处理流程,往往才是决定项目成败的关键。