ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FPGA实战:手写CORDIC旋转模式实现sin/cos运算

FPGA实战:手写CORDIC旋转模式实现sin/cos运算 1. 为什么要在FPGA里用CORDIC算三角函数1.1 一个让新手纠结的选型问题很多刚接触FPGA数字信号处理的朋友做到“在FPGA上算sin和cos”这一步时第一反应往往是去翻IP核手册看看能不能直接例化一个DDS Compiler或者CORDIC IP把输入输出接上就完事。这个思路本身没错Xilinx和各家厂商都提供了成熟的CORDIC IP配置界面点几下就能出结果。但如果你真的想搞明白“三角函数在硬件里到底是怎么算出来的”或者你手头的板子资源紧张、不想为一个简单运算引入一个黑盒IP那自己用Verilog把CORDIC旋转模式写一遍是性价比最高的学习路径。我这次拿到的题目是“基于FPGA的CORDIC旋转模式实现sin和cos运算采用EGo1板卡上板验证”。EGo1是Xilinx Artix-7系列的一块教学板芯片型号通常是XC7A35T逻辑资源对于CORDIC这种迭代结构来说绰绰有余。选它做验证一方面是因为板子便宜、资料多另一方面是它自带足够多的拨码开关和LED方便我们把输入角度和输出结果直接映射到物理引脚上观察。CORDIC的全称是Coordinate Rotation Digital Computer坐标旋转数字计算机。它的核心思想非常朴素用一系列固定角度的旋转去逼近任意角度。你给它一个目标角度θ它不去直接算sinθ和cosθ而是从(1, 0)这个向量出发每次旋转一个预先定好的角度转着转着就把向量转到了θ方向这时候向量的x分量就是cosθy分量就是sinθ。整个过程只用到加法、减法和移位没有乘法器没有查找表特别适合FPGA这种“乘法器金贵、逻辑资源管够”的器件。1.2 旋转模式到底在转什么CORDIC有两种工作模式旋转模式和向量模式。旋转模式解决的是“已知角度求坐标”也就是我们这里要做的sin/cos计算向量模式解决的是“已知坐标求角度和模长”常用于求反正切和开方。两者的迭代结构完全一样区别只在于每次迭代时判断旋转方向的条件不同。旋转模式的迭代公式是这样的x(i1) x(i) - d(i) * y(i) * 2^(-i) y(i1) y(i) d(i) * x(i) * 2^(-i) z(i1) z(i) - d(i) * arctan(2^(-i))其中d(i)是旋转方向取1或-1。判断依据是z(i)的符号如果z(i)大于0说明当前角度还没转到目标角度需要继续往正方向转d(i)1如果z(i)小于0说明转过了需要往回退d(i)-1。z(i)就是“还差多少角度”的累加器迭代到最后它会趋近于0。这里有个关键点每次旋转的角度是固定的就是arctan(2^(-i))。这些角度值可以预先算好存成一张小表迭代次数越多逼近精度越高。对于16位精度的输出通常迭代16次就够了如果只要8位精度迭代8次也能凑合。还有一个容易被忽略的细节CORDIC旋转有一个增益因子。每次旋转都会让向量的模长变长一点点所有迭代乘起来的总增益大约是1.64676。所以如果你直接拿迭代后的x和y当cos和sin用结果会比真实值大64.7%。解决办法有两个要么在迭代前把初始x预乘1/1.64676要么在迭代后统一乘一个补偿系数。我一般选择前者因为预乘只是一个常数乘法放在初始化阶段做掉不占用迭代周期。1.3 这个项目适合谁看如果你已经写过流水灯、跑过串口对Verilog的always块和时序逻辑有基本概念但还没碰过定点数运算和迭代算法那这个项目就是为你准备的。它不需要你懂复杂的数学推导只需要你理解“移位等于乘2的幂”和“有符号数加减法”这两件事。做完之后你会对FPGA里怎么处理小数、怎么设计状态机、怎么用板级资源做验证有一个完整的体感。反过来如果你连“阻塞赋值和非阻塞赋值的区别”都还没搞清楚建议先把基础时序逻辑过一遍再回来不然仿真波形会让你怀疑人生。2. 定点数、角度表和增益补偿的工程处理2.1 为什么不用浮点在FPGA里做三角函数第一个要做的决定就是用浮点还是定点。浮点运算单元在高端FPGA里确实有但Artix-7这种中低端芯片上浮点IP会吃掉大量DSP和逻辑资源而且时序收敛会变得很痛苦。CORDIC本身就是一个纯定点算法用定点数实现既自然又高效。定点的核心是Q格式。我习惯用Q3.13也就是1位符号位、2位整数位、13位小数位总共16位。为什么整数位留2位因为cos和sin的取值范围是[-1, 1]但中间迭代过程中x和y可能会略微超出这个范围留一点余量防止溢出。13位小数位意味着角度分辨率大约是1/8192对于教学演示和大多数控制类应用足够了。角度z也用16位定点但格式不同。角度范围是[-π, π]π约等于3.14159需要2位整数位才能表示到3所以用Q3.13。但这里有个坑π本身无法用有限位精确表示所以角度表里的arctan值都是近似值迭代到最后z不会精确等于0而是在0附近抖动。这是正常的只要抖动幅度小于最低有效位对结果就没影响。2.2 角度表的计算与存储角度表存的是arctan(2^(-i))i从0到15。这些值我用Python提前算好转成Q3.13的整数形式直接写成Verilog的case语句或者ROM初始化文件。import math for i in range(16): angle math.atan(2**(-i)) # 转成Q3.13乘以2^13 q_value int(round(angle * 8192)) print(fi{i:2d}, angle{angle:.6f}, Q3.13{q_value})算出来的第一个值是arctan(1)0.785398乘以8192约等于6434。第二个是arctan(0.5)0.463648约等于3798。依次递减到第15个已经非常小了。在Verilog里我用一个function或者case语句来返回第i次迭代的角度值。不建议用分布式RAM去存因为16个16位数据用LUT实现更省资源而且组合逻辑延迟更短。注意角度表的精度直接决定最终输出精度。如果你只迭代8次角度表只需要前8个值但精度会明显下降。我实测迭代12次时sin/cos的误差已经在千分之一以内迭代16次时误差小于万分之一。2.3 增益补偿的两种做法前面提到CORDIC旋转的总增益K≈1.64676。补偿方式有两种第一种是预乘在迭代开始前把初始x设为1/K≈0.60725初始y设为0。这样迭代结束后x和y自然就是cos和sin不需要额外处理。缺点是初始x不再是整数1而是一个定点小数。在Q3.13格式下1/K乘以8192约等于4975。这个值可以直接硬编码。第二种是后乘迭代结束后把x和y分别乘以1/K。这需要额外的乘法器或者用移位加法近似。不推荐因为多了一步运算还引入额外误差。我选预乘代码里初始x直接赋4975y赋0z赋输入角度。这样整个迭代过程干净利落输出直接可用。2.4 输入角度的范围映射CORDIC旋转模式有一个天然限制只能处理[-π/2, π/2]范围内的角度。因为当角度接近±π时迭代会不收敛或者收敛很慢。但sin和cos的周期是2π实际应用中输入角度可能是任意值。解决办法是利用三角函数的对称性做象限映射。具体来说如果角度在[-π/2, π/2]直接送进CORDIC。如果角度在[π/2, π]用π减去它得到[0, π/2]内的角度然后sin和cos的结果根据象限调整符号。如果角度在[-π, -π/2]用-π减去它类似处理。如果角度超出[-π, π]先对2π取模再按上述规则处理。在FPGA里做取模和象限判断用比较器和加减法就能实现不需要除法。我一般把输入角度先归一化到[-π, π]然后根据最高两位做象限选择。这部分逻辑虽然简单但很容易写错符号建议单独写一个模块用仿真穷举所有象限验证。3. Verilog核心模块的逐行拆解3.1 模块接口与参数定义先看顶层模块的接口。输入是系统时钟、复位、16位角度值输出是16位cos和16位sin以及一个valid信号表示结果有效。module cordic_sincos ( input wire clk, input wire rst_n, input wire start, input wire [15:0] angle_in, // Q3.13, [-π, π] output reg [15:0] cos_out, // Q3.13 output reg [15:0] sin_out, // Q3.13 output reg valid );参数方面迭代次数定义为16用localparam或者parameter都行。我习惯用parameter方便在testbench里覆盖。parameter ITER_NUM 16;内部信号包括x、y、z三个迭代寄存器以及一个计数器cnt。x和y用17位有符号数比16位多一位防止迭代过程中溢出。z用16位有符号数。reg signed [16:0] x_reg, y_reg; reg signed [15:0] z_reg; reg [4:0] cnt;3.2 状态机设计从空闲到输出整个模块用一个简单的三段式状态机控制。状态只有三个IDLE、ITER、DONE。IDLE状态等待start信号。一旦start拉高把初始值加载进寄存器x_reg 4975即1/K的Q3.13表示y_reg 0z_reg angle_incnt 0然后跳到ITER。ITER状态是核心。每个时钟周期执行一次迭代cnt递增。当cnt达到ITER_NUM时跳到DONE。DONE状态把x_reg和y_reg截取低16位赋给cos_out和sin_outvalid拉高一个周期然后回到IDLE。这里有个时序细节迭代是组合逻辑还是时序逻辑。如果每个周期做一次迭代那就是时序逻辑16次迭代需要16个周期。如果在一个周期内用for循环展开16次迭代那就是纯组合逻辑延迟大但吞吐量高。教学项目我推荐时序逻辑因为资源占用少时序容易收敛而且方便观察每次迭代的中间值。3.3 迭代核心移位、加减与方向判断迭代部分的代码是整个模块的灵魂。我把它写成always块里的case或者if-else结构。// 方向判断 if (z_reg[15] 0) begin // z为正往正方向转 x_next x_reg - (y_reg cnt); y_next y_reg (x_reg cnt); z_next z_reg - angle_table(cnt); end else begin // z为负往负方向转 x_next x_reg (y_reg cnt); y_next y_reg - (x_reg cnt); z_next z_reg angle_table(cnt); end这里有几个关键点第一算术右移。Verilog里的是算术右移对于有符号数会保留符号位。如果你用那是逻辑右移负数会出错。这是新手最容易踩的坑之一。第二移位位数是动态的。cnt从0到15移位位数也是0到15。Verilog支持变量移位但综合器可能会生成比较大的多路选择器。如果资源紧张可以改成固定移位加case选择但一般Artix-7不在乎这点资源。第三加减法要用有符号数。x_reg、y_reg、z_reg都声明为signed这样加减法自动按补码处理。如果你忘了加signed综合器会按无符号数处理结果全错。第四角度表的索引。angle_table(cnt)返回第cnt次迭代对应的arctan值。我用一个function实现function signed [15:0] angle_table; input [4:0] idx; begin case (idx) 5d0: angle_table 16sd6434; 5d1: angle_table 16sd3798; 5d2: angle_table 16sd2007; // ... 省略中间 5d15: angle_table 16sd1; default: angle_table 16sd0; endcase end endfunction这些值都是Q3.13格式的整数表示。注意最后一个值可能算出来是0或者1取决于舍入方式不影响最终精度。3.4 输出截取与valid信号生成迭代结束后x_reg和y_reg是17位有符号数需要截取成16位输出。直接取低16位即可因为高位的符号扩展位在数值范围内是冗余的。但要注意如果迭代过程中发生了溢出截取会出错。所以我在仿真时会检查x_reg和y_reg的高位是否全0或全1确保没有溢出。valid信号在DONE状态拉高一个周期告诉下游模块“结果准备好了”。如果你要把结果送到DAC或者显示器这个信号就是握手的关键。DONE: begin cos_out x_reg[15:0]; sin_out y_reg[15:0]; valid 1b1; state IDLE; end实操心得valid信号一定要在时钟同步下产生不要用组合逻辑直接输出。否则下游模块采样时可能遇到毛刺导致数据错位。4. EGo1上板验证从仿真到物理引脚4.1 Testbench怎么写才有意义很多人写testbench就是给几个输入看看输出不为X就过了。这种仿真对于CORDIC来说远远不够。CORDIC的误差是累积的你必须穷举所有角度或者至少均匀采样几百个点才能确认精度达标。我的testbench思路是这样的第一步用Python生成一组测试向量覆盖[-π, π]范围内均匀分布的256个角度每个角度对应理论sin和cos值转成Q3.13格式存成文本文件。第二步Verilog testbench用$readmemh或者$fscanf读入这些向量依次送入DUT每次等待valid拉高后记录输出。第三步把输出和理论值比较计算最大误差和平均误差。如果最大误差超过2个最低有效位说明迭代次数不够或者角度表有误。// 简化的testbench片段 initial begin $readmemh(angle_vectors.txt, angles); for (i 0; i 256; i i 1) begin angle_in angles[i]; start 1; (posedge clk); start 0; wait(valid); $display(angle%d, cos%d, sin%d, angle_in, cos_out, sin_out); (posedge clk); end $finish; end跑完仿真后我把结果导出到CSV用Python画误差曲线。实测16次迭代时最大误差出现在角度接近±π/2的地方大约是±3个LSB。这个精度对于LED显示和大多数控制环路足够了。4.2 板级引脚分配与约束文件EGo1板子的引脚分配需要查原理图。我用的输入是拨码开关SW0到SW15对应16位角度输入输出是LED0到LED15但LED只有16个不够同时显示cos和sin。所以我用两个按键切换显示模式按KEY1显示cos按KEY2显示sin。约束文件XDC的关键部分# 时钟 set_property PACKAGE_PIN P17 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] create_clock -period 20.000 [get_ports clk] # 拨码开关 set_property PACKAGE_PIN ... [get_ports angle_in[0]] # ... 依次分配16个 # LED set_property PACKAGE_PIN ... [get_ports led[0]] # ... 依次分配16个 # 按键 set_property PACKAGE_PIN ... [get_ports key1] set_property PACKAGE_PIN ... [get_ports key2]注意EGo1的拨码开关和LED都是3.3V电平直接连FPGA引脚没问题。但按键需要消抖否则按一次可能触发多次。我在代码里加了一个20ms的计数器做消抖效果很稳。4.3 实际观察与精度验证上板之后我用拨码开关输入几个特殊角度验证输入0LED显示cos8192即1.0sin0。正确。输入π/2对应的Q3.13值约12868cos接近0sin接近8192。正确。输入π对应的值约25736cos接近-8192sin接近0。正确。但拨码开关只能输入整数角度分辨率有限。为了更精细地验证我写了一个简单的状态机让角度从0自动递增到2π每100ms加1LED实时显示cos和sin。这样能看到正弦波和余弦波的LED变化非常直观。实测发现当角度接近π/2时cos输出会在0附近抖动一两个LSB这是正常的量化误差。如果你需要更高精度可以增加迭代次数到20次但资源占用会增加约25%。4.4 资源占用与时序报告在Vivado里综合实现后看资源报告资源类型使用量可用量占比LUT312208001.5%FF198416000.5%DSP0900%BRAM0500%可以看到CORDIC实现几乎不占DSP和BRAM纯逻辑实现非常轻量。时序方面在100MHz时钟下WNS最差负裕量是正数说明时序收敛良好。如果你把时钟提到200MHz可能需要插入流水线寄存器但教学项目100MHz足够了。5. 踩过的坑与排查实录5.1 符号位错误导致输出全乱我第一次写的时候忘了给x_reg和y_reg加signed声明。仿真波形里当z_reg为负时方向判断正确但x_next和y_next的计算结果完全不对。排查了半天才发现Verilog默认是无符号数x_reg - (y_reg cnt)里的y_reg被当成无符号数右移负数变成了大正数。解决方法所有参与有符号运算的reg和wire都必须显式声明signed。如果某个信号是中间结果也要声明signed。不要偷懒。5.2 移位位数超过位宽当cnt15时y_reg 15对于17位数来说相当于只保留了符号位。如果y_reg是正数结果是0如果是负数结果是-1。这是正确的算术右移行为。但如果你不小心用了负数会变成1导致迭代发散。排查技巧在仿真里把每次迭代的x_reg、y_reg、z_reg都打印出来观察z_reg是否在逐渐趋近0。如果z_reg在某个值附近来回振荡不收敛多半是移位或符号问题。5.3 角度表精度不够我一开始为了省事角度表只保留了4位小数转成Q3.13后误差较大。结果迭代到最后z_reg在±10左右振荡导致sin/cos输出有肉眼可见的偏差。解决方法角度表至少保留6位小数转Q3.13时用四舍五入而不是截断。我后来用Python重新生成误差降到±1 LSB以内。5.4 上板后LED显示闪烁不定仿真通过后上板发现LED显示的数字一直在跳即使拨码开关没动。用示波器看时钟引脚发现时钟有毛刺。EGo1的板载晶振是100MHz但我的约束文件里没有正确设置时钟周期导致时序分析不准。解决方法在XDC里加上create_clock约束周期设为10ns100MHz。另外在代码里对输入信号做两级同步消除亚稳态。5.5 常见问题速查表现象可能原因排查方法解决措施输出全0复位未释放检查rst_n信号确保复位有效后释放输出全X未初始化仿真看初始值给reg赋初值或加复位sin/cos幅值偏大未做增益补偿检查初始x值预乘1/K或后乘补偿角度接近π/2时误差大迭代次数不够增加ITER_NUM迭代到16次以上上板后结果跳变时钟约束缺失看时序报告添加create_clock按键触发多次未消抖示波器看按键波形加20ms计数器消抖独家避坑CORDIC的迭代次数不是越多越好。超过20次后由于定点数精度限制再增加迭代次数误差反而可能变大。我实测16次是性价比最高的点。6. 后续可以怎么玩这个项目做完之后你手里就有了一个可综合的sin/cos计算模块。接下来可以往几个方向扩展第一做成DDS信号发生器。把CORDIC的angle_in接一个相位累加器输出接DAC就能产生正弦波。EGo1板子上没有高速DAC但可以用PWM加低通滤波实现低速波形输出用来驱动LED或者蜂鸣器。第二扩展到向量模式。把旋转模式的方向判断反过来就能算反正切和模长。这在电机控制里的Park变换和Clarke变换中非常有用。第三流水线优化。现在的设计是16个周期出一次结果吞吐量不高。如果每个迭代级之间插入寄存器做成16级流水线就能每个周期出一个结果适合高速信号处理。第四精度可配置。把ITER_NUM做成参数在综合时选择8、12、16、20次迭代观察资源占用和精度的权衡。这对于理解定点算法设计非常有帮助。我个人在实际操作中的体会是CORDIC看起来数学公式吓人但落到Verilog里就是移位、加减和一个查找表。真正花时间的不是写代码而是调符号位、对角度表、验证边界条件。把这几步走扎实了后面做任何定点迭代算法都会轻松很多。
返回列表