资讯动态

FFT的Verilog实现:从算法选型到FPGA上板验证全流程

发布时间:2026/10/14 19:08:08 来源:尧图企业网站定制
简介这份资源面向数字信号处理与FPGA硬件开发的学习者聚焦FFT算法在Verilog中的工程化实现适合已具备一定DSP基础、希望把理论转化为可综合硬件逻辑的中高级读者。内容围绕位反转模块、蝶形运算单元、分支级联结构等核心设计展开并涉及Quartus II下的语法检查、功能仿真、逻辑综合、时序分析与下载流程可用于通信、音频、图像处理等频率分析场景。资源包共175个文件约3.64MB以v与tdf源码、cdb与hdb综合库、qmsg与rpt报告、doc文档及pof、sof烧录文件为主另含mif、hex初始化数据与仿真波形文件完整保留了工程编译与验证痕迹。目前已有416人学习下载可帮助读者对照源码理解FFT硬件结构、复用综合报告与仿真结果快速搭建并验证自己的FFT工程。1. 从时域到频域为什么 FFT 的 Verilog 实现值得你花时间做 FPGA 信号处理的朋友多半有过这种经历算法工程师在 MATLAB 里跑通了 FFT波形漂亮得像教科书插图结果一到硬件实现就翻车——时序不收敛、资源爆掉、精度对不上。问题往往不在算法本身而在于从浮点仿真到定点硬件的这条鸿沟。数字信号处理中的 FFT 是频谱分析、滤波器组、通信解调等场景的核心算子而用 Verilog 把它落到 FPGA 上考验的是你对蝶形运算结构、旋转因子量化、流水线调度和存储器复用的综合理解。这份资源围绕 FFT 的 Verilog 实现展开覆盖从算法推导到 RTL 编码再到仿真验证的完整链路适合正在做 DSP 硬件加速的 FPGA 工程师、通信基带开发者以及需要把 MATLAB 定点模型搬到板子上的同学。如果你已经能写状态机但还没碰过复数乘法器的流水线设计或者写过 FFT 但资源占用始终压不下来接下来的内容应该能帮你少走几段弯路。2. FFT 算法选型与 Verilog 架构映射基-2、基-4 还是分裂基2.1 从 DFT 到 FFT 的复杂度压缩逻辑DFT 的定义是 ( X[k] \sum_{n0}^{N-1} x[n] \cdot W_N^{nk} )直接计算需要 ( N^2 ) 次复数乘法和 ( N(N-1) ) 次复数加法。当 N1024 时复数乘法超过一百万次在 FPGA 上根本不可接受。FFT 的核心思路是利用旋转因子 ( W_N^{nk} ) 的周期性和对称性把长序列分解成短序列将复杂度降到 ( N \log_2 N ) 量级。以基-2 按时间抽取DIT为例N 点序列先按奇偶拆成两个 N/2 点序列递归分解到 2 点蝶形。每一级蝶形的结构相同只是旋转因子不同这为硬件复用提供了天然条件。理解这一点对硬件设计至关重要FFT 的每一级运算结构一致意味着你可以用同一套蝶形运算单元Butterfly Unit配合不同的旋转因子和存储器地址调度来完成所有级。这正是流水线架构和迭代架构的共同基础。2.2 基-2、基-4 与分裂基的硬件代价对比选哪种基直接决定资源占用和吞吐率。基-2 蝶形最简单一个复数乘法器加两个复数加减法器就能搭出来但总级数是 ( \log_2 N )以 N1024 为例需要 10 级延迟大、存储器访问次数多。基-4 每级处理 4 个点级数减半到 5 级但单个蝶形需要 3 个复数乘法器控制逻辑也更复杂。分裂基Split-Radix在乘法次数上最优但蝶形结构不规则地址生成模块的设计难度陡增。架构类型复数乘法器数量级数N1024控制复杂度适用场景基-2 DIT110低资源敏感、低速场景基-4 DIT35中吞吐率与资源折中分裂基2混合约 5~6高乘法器资源极度受限流水线基-2每级 1 个10 级流水中高高吞吐连续处理我一般建议如果是第一次做 FFT 的 Verilog 实现从基-2 按时间抽取的迭代架构入手把地址生成和旋转因子存储搞明白再考虑升级到基-4 或流水线结构。一上来就搞分裂基大概率会在地址冲突上卡好几天。2.3 迭代架构 vs 流水线架构的取舍迭代架构Iterative Architecture只用一个蝶形运算单元通过多轮迭代完成所有级的计算资源占用小但吞吐率低适合低速、间歇性处理的场景。流水线架构Pipelined Architecture为每一级分配独立的蝶形单元数据像流水线一样逐级推进吞吐率可以达到每时钟周期输出一个结果代价是资源成倍增加。选择依据很直接看你的系统需不需要连续处理。如果是雷达脉冲压缩这种突发模式迭代架构够用如果是 OFDM 解调这种连续数据流流水线架构才能跟上。常见做法是先估算数据率和时钟频率的比值再决定架构。2.4 旋转因子的定点量化策略这是最容易翻车的地方。MATLAB 里旋转因子是双精度浮点硬件里必须定点化。量化位宽选多少整数部分几位小数部分几位旋转因子的幅度恒为 1所以整数部分只需要 1 位符号位或者用 Q1.15 格式1 位符号 1 位整数 14 位小数。但关键是小数位宽——位宽不够频谱泄漏和底噪会明显恶化。以 16 位量化为例理论信噪比约 96 dB但实际因为蝶形级联的误差累积输出信噪比会下降。我通常的做法是先在 MATLAB 里用定点工具箱仿真扫描不同位宽下的输出信噪比找到满足系统指标的最小位宽再写 Verilog。import numpy as np def quantize_twiddle(N, bits16): 生成定点旋转因子并评估量化误差 n np.arange(N // 2) twiddle_float np.exp(-1j * 2 * np.pi * n / N) scale 2 ** (bits - 1) - 1 twiddle_real np.round(twiddle_float.real * scale).astype(int) twiddle_imag np.round(twiddle_float.imag * scale).astype(int) # 量化后误差 twiddle_quant (twiddle_real 1j * twiddle_imag) / scale max_error np.max(np.abs(twiddle_float - twiddle_quant)) print(f位宽{bits}, 最大量化误差{max_error:.6f}) return twiddle_real, twiddle_imag for b in [8, 10, 12, 14, 16]: quantize_twiddle(1024, b)这段脚本的作用是对 N1024 的旋转因子做不同位宽的定点量化输出最大误差。你可以根据系统对信噪比的要求反推所需位宽。注意scale 2**(bits-1) - 1对应的是 Q1.(bits-1) 格式最高位是符号位。如果系统要求输出信噪比大于 60 dB一般 12 位以上比较稳妥。3. Verilog 核心模块拆解蝶形单元、地址生成与旋转因子 ROM3.1 复数蝶形运算单元的 RTL 实现蝶形运算是 FFT 的基本计算单元。基-2 DIT 蝶形的公式为[ A A B \cdot W_N^k ] [ B A - B \cdot W_N^k ]其中 A、B 是复数输入( W_N^k ) 是旋转因子。硬件实现时复数乘法需要 4 个实数乘法器和 2 个加减法器[ (a jb)(c jd) (ac - bd) j(ad bc) ]// 基-2 蝶形运算单元含复数乘法 module butterfly_unit #( parameter DATA_WIDTH 16, // 数据位宽 parameter TWIDDLE_WIDTH 16 // 旋转因子位宽 )( input wire clk, input wire rst_n, input wire valid_in, input wire signed [DATA_WIDTH-1:0] ar, ai, // 输入 A 实部/虚部 input wire signed [DATA_WIDTH-1:0] br, bi, // 输入 B 实部/虚部 input wire signed [TWIDDLE_WIDTH-1:0] wr, wi, // 旋转因子实部/虚部 output reg valid_out, output reg signed [DATA_WIDTH:0] ar_out, ai_out, // 输出 A 实部/虚部 output reg signed [DATA_WIDTH:0] br_out, bi_out // 输出 B 实部/虚部 ); // 复数乘法B * W (br*wr - bi*wi) j(br*wi bi*wr) wire signed [2*DATA_WIDTH-1:0] mult_rr br * wr; wire signed [2*DATA_WIDTH-1:0] mult_ii bi * wi; wire signed [2*DATA_WIDTH-1:0] mult_ri br * wi; wire signed [2*DATA_WIDTH-1:0] mult_ir bi * wr; // 截位保留高位丢弃低位注意符号扩展 wire signed [DATA_WIDTH-1:0] prod_r (mult_rr - mult_ii) (TWIDDLE_WIDTH - 1); wire signed [DATA_WIDTH-1:0] prod_i (mult_ri mult_ir) (TWIDDLE_WIDTH - 1); // 蝶形加减 always (posedge clk or negedge rst_n) begin if (!rst_n) begin ar_out 0; ai_out 0; br_out 0; bi_out 0; valid_out 0; end else if (valid_in) begin ar_out ar prod_r; // A A B*W ai_out ai prod_i; br_out ar - prod_r; // B A - B*W bi_out ai - prod_i; valid_out 1b1; end else begin valid_out 1b0; end end endmodule这段代码的关键点有三个。第一复数乘法的四个实数乘法是并行执行的综合后会映射到 DSP48 硬核上。第二截位操作 (TWIDDLE_WIDTH - 1)是因为旋转因子采用 Q1.15 格式乘完后需要右移 15 位恢复量纲。第三输出位宽比输入宽 1 位防止加法溢出。实际工程中还要加饱和截断逻辑否则溢出会导致频谱出现莫名其妙的杂散。3.2 旋转因子存储ROM 还是 CORDIC 实时计算旋转因子的来源有两种方案。第一种是预计算存 ROM把 ( \cos(2\pi k/N) ) 和 ( -\sin(2\pi k/N) ) 的定点值写入查找表。优点是简单、延迟固定缺点是 N 较大时 ROM 面积可观N1024 时需要存储 512 组 16 位数据约 2 KB。第二种是用 CORDIC 算法实时计算旋转因子省掉 ROM 但增加运算延迟和逻辑资源。我一般会这样选N ≤ 2048 时用 ROM综合工具会自动把 ROM 映射到 Block RAM 上不占逻辑资源N 更大或者需要动态配置点数时考虑 CORDIC。注意 ROM 的初始化可以用 Verilog 的$readmemh从文件加载也可以用case语句生成前者更灵活。// 旋转因子 ROM以 N8 为例Q1.15 格式 module twiddle_rom #( parameter ADDR_WIDTH 3, parameter DATA_WIDTH 16 )( input wire clk, input wire [ADDR_WIDTH-1:0] addr, output reg signed [DATA_WIDTH-1:0] cos_val, output reg signed [DATA_WIDTH-1:0] sin_val ); reg signed [DATA_WIDTH-1:0] rom_cos [0:(1ADDR_WIDTH)-1]; reg signed [DATA_WIDTH-1:0] rom_sin [0:(1ADDR_WIDTH)-1]; initial begin // Q1.15: 1.0 32767, cos/sin 值乘以 32767 后取整 rom_cos[0] 32767; rom_sin[0] 0; rom_cos[1] 23170; rom_sin[1] -23170; rom_cos[2] 0; rom_sin[2] -32767; rom_cos[3] -23170; rom_sin[3] -23170; // ... 后续地址按 cos(2*pi*k/8) 和 -sin(2*pi*k/8) 填充 end always (posedge clk) begin cos_val rom_cos[addr]; sin_val rom_sin[addr]; end endmodule这里用initial块初始化 ROM综合工具会将其识别为常量并映射到 Block RAM。实际项目中我通常用 Python 或 MATLAB 脚本生成$readmemh格式的初始化文件避免手算出错。3.3 地址生成与同址运算的读写冲突FFT 的地址生成是另一个容易踩坑的地方。以基-2 DIT 迭代架构为例每一级需要从存储器中读出两个数据做完蝶形运算后再写回同一地址同址运算。读写冲突怎么处理常见做法是用双端口 RAM一个端口读、一个端口写或者用乒乓 Buffer 交替读写。地址规律方面DIT 的输入是位反转顺序输出是自然顺序DIF 则相反。位反转地址可以用查表法或逐位交换逻辑实现。对于 N1024位反转需要 10 位地址的逐位翻转用 Verilog 写一个组合逻辑就能搞定// 10 位位反转地址生成 function [9:0] bit_reverse; input [9:0] addr; integer i; begin for (i 0; i 10; i i 1) bit_reverse[i] addr[9 - i]; end endfunction这个函数在综合时会展开成纯组合逻辑延迟很小。但注意如果地址位宽更大比如 N4096 需要 12 位组合逻辑的延迟会累积可能需要插入流水线寄存器。3.4 控制单元的状态机设计控制单元负责调度蝶形运算、地址生成和 ROM 读取的时序。典型的状态包括空闲、读取数据、等待蝶形运算完成、写回数据、级数递增、完成输出。状态机的时钟周期数直接决定 FFT 的总计算延迟。以 N1024 基-2 迭代架构为例每级需要 N/2 512 次蝶形运算共 10 级总计 5120 次蝶形。如果每次蝶形需要 4 个时钟周期读、算、写、等待总延迟约 20480 个时钟周期。在 100 MHz 时钟下约 205 微秒。如果你的系统要求更低的延迟就得考虑流水线架构或基-4 架构。4. 仿真验证与常见翻车点排查4.1 用 MATLAB 生成测试向量做联合仿真Verilog 仿真最怕的是自己造测试数据自己验错了也看不出来。稳妥的做法是用 MATLAB 生成输入数据和期望输出导出为文本文件在 Testbench 中用$readmemh读入仿真结束后把 Verilog 输出写文件再回 MATLAB 对比。% MATLAB: 生成 FFT 测试向量 N 1024; fs 100e6; t (0:N-1) / fs; % 构造多音信号 x 0.5*cos(2*pi*1e6*t) 0.3*cos(2*pi*5e6*t) 0.1*randn(1, N); % 定点量化 x_fixed round(x * 32767); x_fixed(x_fixed 32767) 32767; x_fixed(x_fixed -32768) -32768; % 写入文件 fid fopen(input_data.hex, w); for i 1:N fprintf(fid, %04x\n, typecast(int16(x_fixed(i)), uint16)); end fclose(fid); % 计算期望输出 X fft(x_fixed); X_mag abs(X);这段脚本生成了一个包含两个单音信号加高斯噪声的测试序列量化到 16 位后写入十六进制文件。注意typecast用于把有符号整数转成无符号十六进制表示方便 Verilog 的$readmemh读取。4.2 Testbench 结构与自动比对脚本Testbench 的核心任务是读入测试数据、驱动时钟和复位、等待 FFT 完成、收集输出、与期望值比对。module tb_fft_top; reg clk, rst_n, start; reg signed [15:0] din_r, din_i; wire signed [15:0] dout_r, dout_i; wire done; // 实例化 FFT 顶层 fft_top #(.N(1024), .DATA_WIDTH(16)) u_fft ( .clk(clk), .rst_n(rst_n), .start(start), .din_r(din_r), .din_i(din_i), .dout_r(dout_r), .dout_i(dout_i), .done(done) ); // 时钟生成 initial clk 0; always #5 clk ~clk; // 100 MHz // 测试流程 integer i, fid_in, fid_out; reg [15:0] mem_in [0:1023]; initial begin $readmemh(input_data.hex, mem_in); rst_n 0; start 0; #100 rst_n 1; #20 start 1; #10 start 0; // 逐拍送入数据 for (i 0; i 1024; i i 1) begin (posedge clk); din_r mem_in[i]; din_i 0; end // 等待完成 wait(done); $display(FFT completed at time %t, $time); $finish; end endmodule这个 Testbench 的结构很直白复位后拉高 start 信号然后逐拍送入 1024 个数据。wait(done)等待 FFT 完成标志。实际项目中我会在done拉高后把输出写入文件再用 Python 脚本与 MATLAB 的期望值做逐点比对计算最大误差和信噪比。4.3 常见翻车点排查现象一输出频谱出现对称的杂散分量。原因通常是旋转因子 ROM 的初始化数据有误比如 cos 和 sin 的符号搞反了或者地址索引偏移了一位。解决办法是用 MATLAB 打印出 ROM 中每个地址的理论值与 Verilog 初始化文件逐行对比。现象二输出幅度整体偏大或偏小。检查定点截位的位置和位数。常见错误是在复数乘法后没有右移导致结果放大了 ( 2^{15} ) 倍。另一个可能是旋转因子的 Q 格式与数据不匹配比如数据是 Q1.15 而旋转因子是 Q1.14。现象三仿真结果正确但上板后输出全为零。大概率是复位逻辑或时钟域问题。检查复位信号的极性是否与板级约束一致以及 Block RAM 的读延迟是否在地址生成逻辑中正确补偿。上板调试时先用 ChipScope 或 ILA 抓取内部信号确认数据是否正常流入蝶形单元。现象四时序不收敛综合报告显示建立时间违例。复数乘法器的组合逻辑路径太长是主因。解决办法是在乘法器输出后插入流水线寄存器把一条长路径切成两段。代价是增加一个时钟周期的延迟但换来时序收敛。现象五资源占用远超预期DSP48 不够用。检查综合工具是否把乘法器映射到了逻辑资源而非 DSP 硬核。可以在综合属性中强制指定use_dsp48或者检查代码中的乘法是否被识别为可推断的 DSP 模式。另一个可能是旋转因子 ROM 没有映射到 Block RAM而是用分布式 RAM 实现了这会消耗大量 LUT。5. 资源优化与进阶技巧从能跑到跑得好5.1 蝶形单元的时分复用与资源折中如果你的 FPGA 资源紧张迭代架构是首选。但迭代架构的吞吐率低怎么在资源和速度之间找平衡一个实用技巧是部分流水线把蝶形运算单元内部的乘法器和加减法器做成两级流水这样虽然单次运算延迟增加但时钟频率可以跑得更高总体吞吐率反而可能提升。另一个技巧是旋转因子的对称性利用。( W_N^{kN/2} -W_N^k )这意味着 ROM 只需要存储前一半的旋转因子后一半通过取反得到。对于 N1024ROM 深度从 512 降到 256节省一半的 Block RAM。5.2 块浮点与动态缩放策略定点 FFT 最大的问题是动态范围。输入信号幅度变化大时中间级的蝶形运算容易溢出。块浮点Block Floating Point是一种折中方案每一级运算后检测数据的最大幅度如果超过阈值就整体右移一位并记录移位次数。最终输出时根据总移位次数恢复量纲。这种策略的代价是需要额外的幅度检测逻辑和移位控制但换来的是更大的动态范围。在雷达和通信接收机中块浮点 FFT 是常见做法。5.3 用 Python 脚本自动生成旋转因子 ROM 初始化文件手算旋转因子不现实我一般用 Python 脚本生成$readmemh格式的初始化文件import numpy as np def gen_twiddle_hex(N, bits16, filenametwiddle.hex): 生成旋转因子 ROM 初始化文件Q1.(bits-1) 格式 scale 2 ** (bits - 1) - 1 with open(filename, w) as f: for k in range(N // 2): cos_val int(round(np.cos(2 * np.pi * k / N) * scale)) sin_val int(round(-np.sin(2 * np.pi * k / N) * scale)) # 转成补码十六进制 cos_hex format(cos_val 0xFFFF, 04x) sin_hex format(sin_val 0xFFFF, 04x) f.write(f{cos_hex} {sin_hex}\n) print(fGenerated {N//2} twiddle factors - {filename}) gen_twiddle_hex(1024, 16, twiddle_1024.hex)这个脚本生成的文件每行包含一对 cos 和 sin 的十六进制值可以直接被 Verilog 的$readmemh读取。注意 0xFFFF用于处理负数补码确保格式正确。生成后建议用几组已知值验证比如 k0 时 cos32767、sin0kN/4 时 cos0、sin-32767。5.4 验证清单上板前必须过的五道关在把 FFT 核集成到系统之前我习惯走一遍这个清单检查项方法通过标准定点精度MATLAB 定点仿真 vs 浮点参考输出信噪比满足系统指标功能正确性Verilog 仿真 vs MATLAB FFT最大幅度误差 1%时序收敛综合报告建立/保持时间无违例WNS 0资源占用综合报告 LUT/DSP/BRAM在目标器件容量 70% 以内上板实测ILA 抓取输出与仿真对比频谱峰值位置一致这份清单帮我省过好几次返工。有一次仿真全过上板后频谱却多了一根杂散后来发现是 Block RAM 的读延迟在地址生成逻辑中少补偿了一个周期导致数据错位。从那以后我每次改地址逻辑都会在仿真里加一个边界测试用例专门检查首尾数据的读写时序。希望这些内容帮到你少踩几个我已经踩过的坑。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑