news 2026/9/30 1:39:00

FPGA实战:CORDIC算法实现三角函数计算与EGo1上板验证

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
FPGA实战:CORDIC算法实现三角函数计算与EGo1上板验证

1. 为什么要在FPGA里用CORDIC算三角函数

做数字信号处理的朋友大概率都遇到过这个场景:需要实时算一个角度的正弦和余弦值,但手头的FPGA里没有硬核浮点单元,也不想为了几个三角函数就去例化一个占资源的IP核。这时候CORDIC算法就该登场了。

CORDIC的全称是Coordinate Rotation Digital Computer,中文一般叫坐标旋转数字计算机。它本质上是一种用迭代逼近的方式来计算三角函数、反三角函数、双曲函数、向量模长等数学运算的算法。它最大的特点就是:只需要加法、减法和移位操作,不需要乘法器,更不需要浮点运算单元。这对FPGA来说简直是天作之合,因为FPGA里最不缺的就是寄存器和加法器,而乘法器和DSP Slice是相对稀缺的资源。

我这次要聊的是CORDIC的旋转模式,用它来算sin和cos。具体做法是:给定一个目标角度θ,从(1, 0)这个初始向量出发,通过一系列预设角度的旋转逼近,最终让向量的方向对准θ,此时向量的x分量就是cos(θ),y分量就是sin(θ)。整个过程全部用定点数完成,适合在FPGA上做流水线实现。

上板验证用的是EGo1板卡,这是国内高校数字电路和FPGA课程里非常常见的一块开发板,搭载的是Xilinx Artix-7系列FPGA。板上有拨码开关、按键、LED和数码管,用来做输入输出验证非常方便。我会把角度通过拨码开关输入,然后把计算出来的sin和cos值显示在数码管上,形成一个完整的闭环验证。

这篇文章适合谁看?如果你正在学FPGA数字信号处理、想搞明白CORDIC到底怎么在硬件上跑起来、或者你手头正好有EGo1板卡想做个上板项目,那这篇内容应该能帮到你。我会从算法原理讲到Verilog实现,再到上板调试,尽量把每一步的“为什么”都说清楚。

2. CORDIC旋转模式的算法原理拆解

2.1 从几何旋转到迭代逼近的思维转变

要理解CORDIC,先得把“旋转”这件事想明白。假设平面上有一个向量(x, y),我们想把它逆时针旋转角度θ,旋转后的坐标是:

x' = x·cos(θ) - y·sin(θ) y' = x·sin(θ) + y·cos(θ)

这是标准的旋转矩阵。如果直接按这个公式算,需要四次乘法和两次加法,在FPGA里做乘法虽然不算太难,但精度和资源都是问题。CORDIC的巧妙之处在于:它把一次大旋转拆成了一系列小旋转,每次只旋转一个固定的角度。

具体来说,CORDIC预设了一组角度:45°、26.565°、14.036°、7.125°……每个角度都是前一个的一半(在正切值意义上),满足tan(αᵢ) = 2⁻ⁱ。这样一来,旋转公式里的cos(αᵢ)和sin(αᵢ)就可以用2的负幂次来表示,乘法就退化成了移位操作。

每次迭代的旋转方向由一个符号因子dᵢ决定:如果当前角度还没转到目标角度,就继续往目标方向转(dᵢ = +1),如果转过了就往回退(dᵢ = -1)。这就是“旋转模式”的核心思想——用一系列固定角度的正负旋转来逼近任意目标角度。

2.2 迭代公式的推导与增益因子的处理

把旋转矩阵里的cos(αᵢ)提出来,迭代公式可以写成:

xᵢ₊₁ = xᵢ - dᵢ · yᵢ · 2⁻ⁱ yᵢ₊₁ = yᵢ + dᵢ · xᵢ · 2⁻ⁱ zᵢ₊₁ = zᵢ - dᵢ · αᵢ

其中z是角度累加器,初始值设为目标角度θ,每次迭代减去当前旋转的角度,最终z趋近于0。dᵢ的符号由zᵢ的符号决定:zᵢ > 0时dᵢ = +1,否则dᵢ = -1。

这里有一个关键问题:每次迭代都提取了一个cos(αᵢ)因子,N次迭代下来,总的增益是:

K = ∏ cos(αᵢ) ≈ 0.607252935

也就是说,如果初始向量是(1, 0),经过N次迭代后得到的向量模长不是1,而是1/K ≈ 1.64676。所以在实际使用中,要么把初始值设为(K, 0),要么在最后对结果乘以K的倒数。我一般选择前者,把初始x设为0.607252935的定点表示,这样最终结果直接就是cos和sin,不需要额外的乘法。

迭代次数N的选择取决于精度需求。每迭代一次大约增加1位精度,如果要达到16位精度,N取16就够了。但实际工程中我一般会多迭代几次,取18到20次,留一点余量。角度分辨率方面,第i次迭代对应的角度是arctan(2⁻ⁱ),当i足够大时,这个角度已经小于定点数的LSB了,再迭代下去对结果没有贡献。

2.3 定点数格式选择与精度分析

在FPGA里做CORDIC,绕不开定点数格式的选择。我采用的是Q2.14格式,也就是2位整数位加14位小数位,总共16位有符号数。为什么选这个格式?

首先,sin和cos的取值范围是[-1, 1],用2位整数位(包括符号位)刚好能覆盖,不会溢出。其次,14位小数位意味着角度分辨率大约是2⁻¹⁴ ≈ 0.000061弧度,对应到角度大约是0.0035度,这个精度对于大部分应用已经足够了。如果你需要更高精度,可以扩展到Q2.30或者Q3.29,但资源消耗也会相应增加。

角度累加器z的格式需要特别注意。目标角度θ的范围是[-π, π],用弧度表示大约是[-3.14159, 3.14159]。如果用Q2.14格式,整数部分只有2位,最大能表示到1.999...,不够用。所以z我采用Q3.13格式,3位整数位加13位小数位,范围能覆盖[-4, 4),足够表示[-π, π]了。

这里有个容易踩的坑:x、y、z三个变量的定点格式可以不同,不要强行统一。x和y是三角函数值,用Q2.14;z是角度,用Q3.13。在Verilog里做移位操作时,要注意不同格式之间的对齐问题。

3. Verilog实现的核心细节与流水线设计

3.1 迭代单元的流水线架构

CORDIC的迭代结构有两种常见实现方式:折叠式和展开式。折叠式就是用一个迭代单元反复复用,每个时钟周期做一次迭代,N次迭代需要N个时钟周期。展开式就是把N个迭代单元级联起来,形成流水线,每个时钟周期都能输出一个结果,但资源消耗是折叠式的N倍。

我这次用的是全流水线展开式结构。原因很简单:EGo1板卡上的Artix-7 35T资源足够,而且流水线结构吞吐率高,适合做实时信号处理。如果你用的是资源紧张的小容量FPGA,折叠式更合适,但需要额外的状态机控制。

每个迭代单元的核心逻辑是这样的:

module cordic_stage #( parameter STAGE = 0, parameter ANGLE = 0 // arctan(2^-STAGE) 的定点表示 )( input wire signed [15:0] x_in, input wire signed [15:0] y_in, input wire signed [15:0] z_in, output reg signed [15:0] x_out, output reg signed [15:0] y_out, output reg signed [15:0] z_out ); wire sign = z_in[15]; // 判断z的符号 wire signed [15:0] x_shift = {{STAGE{1'b0}}, x_in[15:STAGE]}; // 算术右移 wire signed [15:0] y_shift = {{STAGE{1'b0}}, y_in[15:STAGE]}; always @(posedge clk) begin if (sign) begin x_out <= x_in + y_shift; y_out <= y_in - x_shift; z_out <= z_in + ANGLE; end else begin x_out <= x_in - y_shift; y_out <= y_in + x_shift; z_out <= z_in - ANGLE; end end endmodule

这段代码有几个细节值得展开说。第一,移位操作我用的是算术右移,因为x和y是有符号数,逻辑右移会破坏符号位。第二,ANGLE参数是每个阶段对应的arctan(2⁻ⁱ)的定点表示,需要提前算好。第三,z的加减方向跟x、y是反的:当z为正时,我们要减小z,所以z减去角度;当z为负时,z加上角度。

3.2 角度查找表的预计算与存储

每个迭代阶段需要的角度值是arctan(2⁻ⁱ),这些值需要在综合前算好,以常量的形式写进代码里。我一般用Python或者MATLAB提前算出来,然后转成Q3.13格式的十六进制。

以16次迭代为例,前几个角度值大概是:

迭代序号 iarctan(2⁻ⁱ) 弧度值Q3.13 十六进制
00.78539816340x1922
10.46364760900x0ED6
20.24497866310x07D7
30.12435499450x03FB
40.06241881000x01FF
50.03123983340x0100
60.01562372860x0080
70.00781234110x0040

可以看到,从第5次迭代开始,角度值已经非常小了,Q3.13格式下基本就是几个LSB的差异。这也是为什么迭代次数不需要无限增加——当角度值小于定点数的分辨率时,再迭代就没有意义了。

注意:角度查找表的精度直接影响最终结果的精度。如果你用的是Q3.13格式,角度值的量化误差大约是2⁻¹³ ≈ 0.000122弧度,对应到sin/cos的误差大约是0.000122,在14位小数精度下是可以接受的。

3.3 输入角度预处理与象限映射

CORDIC旋转模式有一个收敛范围限制:目标角度必须在[-99.7°, +99.7°]之间,也就是大约[-1.74, 1.74]弧度。这是因为所有预设角度之和约为99.7°,超出这个范围就无法收敛了。

但实际应用中,角度可能是任意值。解决办法是做象限映射:把任意角度映射到第一象限或者收敛范围内,算完之后再根据原始象限恢复符号。具体做法是:

  • 如果角度在[0, π/2],直接算
  • 如果角度在[π/2, π],用π - θ代替,算完后cos取反,sin不变
  • 如果角度在[π, 3π/2],用θ - π代替,算完后cos和sin都取反
  • 如果角度在[3π/2, 2π],用2π - θ代替,算完后cos不变,sin取反

在FPGA里实现这个映射,需要几个比较器和加减法。我一般把角度输入范围限制在[0, 2π),然后用一个简单的状态机做预处理。如果你只需要算第一象限的角度,那这部分可以省掉,代码会简洁很多。

4. EGo1上板验证的完整实操流程

4.1 硬件资源分配与管脚约束

EGo1板卡上的资源挺丰富的,我这次用到的外设有:

  • 拨码开关SW0~SW7:用来输入角度值,8位拨码可以表示0~255,我把它映射到0~360度
  • 数码管:显示计算结果的整数部分和小数部分
  • LED灯:指示计算完成状态
  • 按键:复位和启动信号

管脚约束文件(XDC)需要根据EGo1的原理图来写。拨码开关和数码管的管脚定义在板卡手册里都能查到。这里有个小技巧:数码管的段选和位选信号要确认共阴还是共阳,EGo1用的是共阳数码管,段选信号低电平有效,这个如果搞反了,显示会完全乱掉。

# 拨码开关输入 set_property PACKAGE_PIN P5 [get_ports {sw[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {sw[0]}] # ... 其他拨码开关类似 # 数码管段选 set_property PACKAGE_PIN G2 [get_ports {seg[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {seg[0]}] # ... 其他段选类似

4.2 顶层模块设计与数据流梳理

顶层模块的设计思路是这样的:拨码开关输入8位角度值,经过一个角度转换模块变成Q3.13格式的弧度值,然后送入CORDIC流水线,流水线输出cos和sin的Q2.14格式结果,最后经过一个二进制转BCD模块,把结果送到数码管显示。

数据流的时序是这样的:拨码开关变化后,需要一个时钟周期做角度转换,然后CORDIC流水线需要16个时钟周期出结果,最后BCD转换和数码管扫描是独立的时钟域。整体延迟大概在20个时钟周期左右,对于人眼观察来说完全是实时的。

这里有个设计决策值得说一下:要不要加输入寄存器做消抖?拨码开关是机械开关,切换时会有抖动。如果不加消抖,CORDIC可能会在抖动期间算出错误的结果。我的做法是在角度转换模块前加一个简单的计数器消抖,检测到输入稳定20ms后才更新角度值。

4.3 数码管显示驱动的实现细节

数码管显示这部分,我用的是动态扫描方式。EGo1板卡上有8个数码管,我用了其中6个:前3位显示cos的整数和小数,后3位显示sin的整数和小数。每个数码管的刷新频率大约是1kHz,人眼看起来是常亮的。

二进制转BCD我用的是一种简单的移位加3算法,也叫Double Dabble算法。这个算法适合在FPGA里实现,不需要除法器。具体做法是:把二进制数不断左移,每移4位就判断是否大于等于5,如果是就加3,最终得到BCD码。

// 简化的Double Dabble核心逻辑 always @(posedge clk) begin if (start) begin bcd <= 0; bin <= value; shift_count <= 0; end else if (shift_count < 16) begin // 对每个BCD位判断是否>=5 for (int i = 0; i < 6; i = i + 1) begin if (bcd[i*4 +: 4] >= 5) bcd[i*4 +: 4] <= bcd[i*4 +: 4] + 3; end bcd <= {bcd[19:0], bin[15]}; bin <= bin << 1; shift_count <= shift_count + 1; end end

提示:Double Dabble算法需要迭代16次(对于16位输入),每次迭代需要一个时钟周期。如果你对显示刷新速度有要求,可以考虑用查找表或者除法器替代,但资源消耗会大一些。

4.4 上板调试与结果验证

上板调试的时候,我建议先用几个特殊角度验证:0度、30度、45度、60度、90度。这些角度的sin和cos值都是已知的,方便对照。

实测下来,0度时cos显示为1.0000,sin显示为0.0000;45度时cos和sin都显示为0.7071;90度时cos显示为0.0000,sin显示为1.0000。误差在最后一位小数,也就是万分之一的量级,跟理论精度吻合。

如果显示结果不对,排查顺序建议是:先确认拨码开关输入是否正确(可以用LED直接显示拨码状态),再确认角度转换是否正确(可以引出一个测试信号到LED),最后确认CORDIC输出是否正确。这种分段排查的方法比一股脑看波形效率高得多。

5. 常见问题与排查技巧实录

5.1 结果始终为0或者溢出

这是新手最容易遇到的问题。最常见的原因是定点数格式搞混了。比如x和y用了Q2.14,但移位操作时按Q1.15处理,结果就会完全错误。排查方法很简单:在Testbench里给一个固定角度,看每个流水线阶段的x、y、z值是否合理。如果某一级的输出突然变成0或者跳变到最大值,那大概率是移位或者符号扩展出了问题。

另一个可能的原因是初始值没有设置正确。CORDIC的初始x应该是0.607252935的定点表示,不是1.0。如果你设成了1.0,最终结果会放大1.64676倍,看起来就像是溢出。Q2.14格式下,0.607252935对应的十六进制是0x26DD,这个值要记牢。

5.2 精度不够或者结果抖动

精度问题通常有两个来源:迭代次数不够和角度量化误差。如果你发现结果在小数点后第三位就开始不准了,那可能是迭代次数只有10次左右。增加到16次或者18次,精度会明显改善。

结果抖动一般是输入信号不稳定导致的。拨码开关的机械抖动会让角度值在切换瞬间跳变,CORDIC算出来的结果自然也跟着跳。解决办法就是前面提到的输入消抖,或者在CORDIC前面加一个寄存器,只在按键触发时才锁存输入角度。

还有一个容易被忽略的点:时钟频率太高导致时序不满足。CORDIC流水线里有多级加法和移位,如果时钟跑到100MHz以上,关键路径可能撑不住。EGo1的板载时钟是100MHz,我一般会分频到50MHz或者25MHz来跑CORDIC,这样时序余量更充足。

5.3 数码管显示乱码或者闪烁

数码管乱码最常见的原因是段选和位选的极性搞反了。共阳数码管的段选是低电平点亮,如果你按高电平有效来写,显示的就是反的。另外,位选信号的扫描顺序也要跟数码管的物理排列对应,否则数字会显示在错误的位置。

闪烁问题一般是扫描频率太低导致的。人眼的视觉暂留时间大约是20ms,如果每个数码管刷新间隔超过这个时间,就会感觉到闪烁。我的做法是把扫描频率设在1kHz左右,也就是每个数码管点亮1ms,8个数码管轮一遍是8ms,远小于20ms,看起来就很稳定。

5.4 常见问题速查表

现象可能原因排查方法解决方案
结果全0初始值错误或时钟未连接检查Testbench波形确认初始x=0x26DD
结果溢出定点格式不匹配逐级检查x/y/z值统一Q格式,注意符号扩展
精度差迭代次数不足增加迭代次数对比迭代次数加到16以上
结果抖动输入未消抖观察拨码开关波形加20ms计数器消抖
数码管乱码极性搞反用固定值测试共阳改低电平有效
显示闪烁扫描频率太低示波器看位选信号扫描频率提到1kHz
时序违例时钟频率过高看综合报告分频到50MHz以下

6. 资源占用分析与优化方向

6.1 综合后的资源报告解读

在Vivado里综合完之后,我习惯先看资源利用率报告。16级流水线的CORDIC,加上角度转换、BCD转换和数码管驱动,整体资源占用大概是:

资源类型使用量可用量利用率
LUT约1200208005.8%
FF约1800416004.3%
DSP0900%
BRAM0500%

可以看到,CORDIC最大的优势就是完全不消耗DSP和BRAM,只用了少量的LUT和FF。这意味着你可以在同一个FPGA里例化多个CORDIC核,做并行计算,而不用担心DSP资源不够。

6.2 折叠式与流水线式的取舍

如果你觉得16级流水线的资源占用还是太高,可以考虑折叠式结构。折叠式只需要一个迭代单元,加上一个状态机控制迭代次数,资源占用大概是流水线式的1/16。但代价是吞吐率下降,每16个时钟周期才能出一个结果。

我的建议是:如果数据率要求不高(比如每秒只需要算几千次),用折叠式就够了;如果需要连续实时计算,那就用流水线式。EGo1的Artix-7 35T资源足够跑流水线式,所以我没有犹豫。

6.3 精度与资源的平衡策略

精度和资源永远是一对矛盾。如果你需要更高的精度,比如20位或者24位,迭代次数要相应增加,流水线级数也要增加,资源占用会线性增长。我的经验是:16位精度(Q2.14)对于大部分显示和控制应用已经足够,如果要做通信或者音频处理,可以考虑18位或者20位。

另外,角度累加器的位宽可以比x、y少几位,因为角度的精度要求相对低一些。比如x、y用16位,z用14位,这样能省一点资源,对最终精度的影响很小。

7. 从CORDIC延伸出去的几个实用方向

CORDIC算sin和cos只是入门,这个算法的应用远不止于此。同样的旋转模式,如果把输入改成向量模式,就能算反正切和向量模长。向量模式下,初始向量是(x, y),经过迭代后z累加器输出的是atan(y/x),x输出的是模长乘以增益因子。这个在电机控制里的角度解算、通信里的载波同步都用得上。

还有一个方向是双曲CORDIC,把旋转角度改成双曲正切,就能算sinh、cosh、exp和ln。这个在神经网络激活函数、概率计算里有应用。不过双曲CORDIC的收敛范围比较特殊,需要做重复迭代,实现起来比旋转模式复杂一些。

如果你想把CORDIC做成一个通用的数学协处理器,可以考虑加一个模式选择寄存器,通过配置寄存器切换旋转模式/向量模式、圆形/双曲坐标系。这样一颗CORDIC核就能覆盖大部分初等函数运算,性价比很高。

我在实际项目里还试过用CORDIC做数字下变频:把ADC采样的中频信号乘以cos和sin,得到I/Q两路基带信号。这时候CORDIC的相位累加器就相当于一个NCO(数控振荡器),频率控制字决定输出频率。这种用法在软件无线电里很常见,比查表法节省BRAM,比直接DDS节省DSP。

最后分享一个小技巧:如果你在调试CORDIC时发现结果总是差那么一点点,不妨检查一下角度查找表的最后一个值。有时候因为量化误差,最后一个角度值可能变成了0,导致最后一次迭代实际上没有旋转,但符号判断还在进行,结果就会有一个LSB的偏差。把最后一个角度值强制设为1个LSB,往往能改善最终精度。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 1:38:57

大模型全生命周期开源评测工具链生态与实操全景横评

大模型全生命周期开源评测工具链生态与实操全景横评在 2026 年的大模型&#xff08;LLM&#xff09;研发与生产化落地中&#xff0c;“如何科学、客观、无偏地评估一个大模型究竟有多聪明、有多安全、有多可信”&#xff0c;已经成为决定企业技术路线与投资决策的核心胜负手。 …

作者头像 李华
网站建设 2026/9/30 1:37:01

QT+Halcon机器视觉实战:产线级测量系统搭建与调优

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 1:36:19

Linux 中文 man 手册安装指南:从查找机制到实战配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 1:35:40

YOLO11cls农作物病虫害分类实战:数据集构建、训练调参与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 1:34:31

STM32嵌入式开发:从芯片架构到外设实战的完整理论体系

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华