资讯动态

FFT的FPGA实现:蝶形运算、IP核配置与位宽规划

发布时间:2026/10/14 12:32:21 来源:尧图企业网站定制
简介一份基于FPGA的512点FFT实现源码包面向电子工程、通信及计算机专业的学生和硬件工程师适合希望掌握快速傅里叶变换硬件实现、FPGA数字信号处理流程的实践者。资源包共358个文件5.45MB内含Verilog源文件.v、Quartus工程文件.qpf/.qsf、AHDL设计文件.tdf及符号定义.bsf等覆盖从逻辑设计、仿真到工程配置的主要环节。已有3351人学习。核心代码围绕位反转、蝶形运算与控制逻辑展开配合仿真激励可观察512点FFT运算与数据流走向理解Cooley-Tukey算法如何映射到并行硬件。同时可借助综合报告分析资源占用与时序收敛学习FPGA优化思路工程文件也便于直接打开、修改和上板验证是数字信号处理硬件实践的完整案例。1. FFT的FPGA实现不只是把IP拖进工程很多工程师第一次接触FFT的FPGA实现习惯是把EDI工具自带的FFT IP核拖进工程、连上时钟和复位然后等着频谱数据从输出端口冒出来。结果往往不是那回事要么仿真波形里峰值位置对不上要么上板之后动态范围差到没法看要么数据位宽配得太大导致时序收敛不了。FFT的FPGA实现核心价值是把离散傅里叶变换的实时计算能力放进硬件数据通路里让频谱分析、数字下变频、信道化接收这类信号处理系统不再依赖上位机算力。这篇笔记面向要把FFT跑进实时系统的人从蝶形运算的硬件映射出发覆盖IP核配置、手写RTL、位宽规划与上板排错帮你少走几趟弯路。2. 看懂FFT硬件本质蝶形运算、旋转因子与资源选型2.1 从DFT公式到蝶形图硬件看到的只有乘法器和加法器N点离散傅里叶变换的公式是 X(k) Σ x(n)·W_N^(nk)直接把N个点两两相乘求和总计算量是N²次复数乘加。一片主流FPGA在100MHz时钟下跑1024点DFT的暴力运算单帧延迟就要到毫秒级实时性根本谈不上。FFT把DFT分解成log2(N)级蝶形运算每级有N/2个蝶形总复数乘法量降到 (N/2)·log2(N)1024点只需要5120次比暴力算法少了两个数量级。硬件上看到的FFT就是一大堆复数乘法器和复数加法器按蝶形图连接起来。一个蝶形的运算内容是输入a和b乘上旋转因子W后与a做加减产生两个输出。用Verilog描述这个结构核心代码大致是这样// 单个蝶形运算单元复数乘法用四个实数乘法器实现 module butterfly #( parameter WIDTH 16 )( input signed [WIDTH-1:0] a_re, a_im, // 输入a 实部/虚部 input signed [WIDTH-1:0] b_re, b_im, // 输入b 实部/虚部 input signed [WIDTH-1:0] w_re, w_im, // 旋转因子 实部/虚部 output signed [WIDTH-1:0] out0_re, out0_im, output signed [WIDTH-1:0] out1_re, out1_im ); // b * W 的实部和虚部 wire signed [2*WIDTH-1:0] m1 b_re * w_re; wire signed [2*WIDTH-1:0] m2 b_im * w_im; wire signed [2*WIDTH-1:0] m3 b_re * w_im; wire signed [2*WIDTH-1:0] m4 b_im * w_re; wire signed [WIDTH:0] bw_re m1 - m2; // 截取高WIDTH位并做舍入 wire signed [WIDTH:0] bw_im m3 m4; assign out0_re a_re bw_re; assign out0_im a_im bw_im; assign out1_re a_re - bw_re; assign out1_im a_im - bw_im; endmodule这里把复数乘法拆成四次实数乘法和两次加减法是最直接的映射方式。WIDTH是定点位宽乘积用2倍位宽保存最后通过截位恢复到原始位宽。参数说明a和b来自上一级蝶形输出或输入缓存w是旋转因子由ROM表提供截位策略直接影响精度我一般会保留一位整数位避免求和时溢出。新建工程打算复用这套代码的话记得把乘法器用DSP单元推断综合工具会把*运算符映射到硬件乘法器上。2.2 旋转因子的存储与截位ROM表怎么设计才不拖累时序旋转因子W_N^(nk) cos(2πnk/N) - j·sin(2πnk/N)它是复数单位圆上的点。硬件实现中所有旋转因子存放在ROM里ROM地址由级数m和蝶形序号k共同决定。对于N1024只需要存储N/2512个复数值每个值用数据位宽等宽的定点数表示ROM容量很小。但ROM的读延迟会直接卡在蝶形运算的关键路径上因此设计时要把ROM输出寄存一拍或者用分布式RAM来缩短走线延迟。生成旋转因子表有两种常见做法一是用MATLAB或者Python把cos和sin值量化成定点数写成coe或mif文件二是在RTL里用case语句直接硬编码。第二种方式在小点数时很直观但点数超过64后代码会膨胀不好维护。我一般用脚本生成写一段Python把旋转因子量化成16位二进制补码并输出为Verilog可读的文本import math N 1024 WIDTH 16 scale 2**(WIDTH-1) - 1 lines [] for n in range(N//2): angle -2 * math.pi * n / N re int(round(math.cos(angle) * scale)) im int(round(math.sin(angle) * scale)) # 负数转16位二进制补码表示 re_bin re ((1WIDTH) - 1) im_bin im ((1WIDTH) - 1) lines.append(f16h{re_bin:04X}_{im_bin:04X}) # 输出时按每行一个地址排列 print(\n.join(lines[:8]))脚本里scale决定量化增益我固定为最大量化值保证旋转因子的幅度接近1。注意的点是旋转因子是浮点数的定点近似存在量化误差当数据逐级流过蝶形运算时这个误差会被放大。要降低影响ROM表位宽不要低于数据位宽常见选择是数据位宽16位时ROM也存16位。如果发现高点数FFT的频谱底噪偏高可以先怀疑旋转因子量化位宽不够。2.3 IP核还是手写RTL三种选型路线怎么选业界做FFT的FPGA实现主流的路线有三条调用商用FPGA平台自带的FFT IP核、改写开源基2或基4蝶形RTL、完全自己写控制器加蝶形单元。三者的差异集中在开发周期、资源占用、可控性三个维度上。我整理了一张对比表方便开工前拍板。选型路线开发周期资源占用可控性适用场景商用FFT IP核半天中等有额外AXI逻辑低参数固定点数可变、希望快速集成开源蝶形RTL改写2~3天较低中可以改位宽和结构点数固定、资源紧张完全手写一周以上最低高每个周期都可控学习、特殊帧格式、多通道复用从项目经验看如果系统只需要固定点数变换、数据吞吐很高我一般用手写基2结构如果前端是ADC采集、后面要接各种信号处理算法商用IP核的AXI-Stream接口能省去大量对接工作。即使是手写RTL也建议先跑通一次IP核版本用它的仿真结果作为参考再对比自己的蝶形输出可以快速定位数据通路里的错误。从工程角度没有绝对哪个更好的答案只有哪个更匹配当前系统的吞吐和时延需求。3. 用商用FFT IP核跑通1024点配置参数与仿真验证3.1 最小可运行配置从1024点开始第一步选一个最经典的配置变换点数1024、数据位宽16位、输入输出接口都用AXI-Stream缩放策略先选块浮点方式。块浮点会在所有级完成后自动统计动态范围并统一缩放省去手动干预适合第一次跑通。输出顺序选自然顺序这样后续核对频谱峰值不需要做位反转。IP核的配置界面里这些参数一目了然但很多人在第一次打开时会忽略一个入口变换长度需要按log2(N)填1024点对应10。配置项取值说明变换点数1024对应10级蝶形数据位宽16有符号定点数缩放策略块浮点自动逐级截位防止溢出数据顺序自然顺序输出直接按频率递增排列帧格式可变点数后续可以兼容其他点数接口AXI-Stream与DMA、ADC数据通路兼容配置界面确认后IP核会生成一个带AXI-Stream接口的模块。需要注意IP核里s_axis_config_tdata的最低两位用于选择帧长度和正/逆变换这个端口必须驱动否则IP核默认按配置界面的固定值运行。通常做法是在复位释放后拉高一次s_axis_config_tvalid、把配置字写到tdata上等tready拉高后再进入正常工作流。3.2 看懂数据接口s_axis_data、s_axis_config与tlast拉起的时机AXI-Stream接口的握手规则是整个FFT数据通路里最容易被忽略的一环。s_axis_data_tvalid拉高表示输入数据有效s_axis_data_tready拉高表示IP核准备好接收两者同时为高时数据被拍入。一帧1024点数据全部发送完毕后需要在最后一个数据上拉高s_axis_data_tlastIP核才认为这是完整的一帧。输出侧同样有tvalid和treadym_axis_data_tlast在输出最后一个频点时拉高一拍。这些信号之间的配合直接决定FFT能不能连续工作。用一个简单的Verilog状态机来驱动输入接口代码如下localparam IDLE 2d0, SEND 2d1, WAIT 2d2; reg [1:0] state; reg [9:0] cnt; // 1024点计数 reg s_tvalid; reg s_tlast; always (posedge clk) begin if (!rst_n) begin state IDLE; cnt 0; s_tvalid 1b0; s_tlast 1b0; end else begin case (state) IDLE: begin s_tvalid 1b1; cnt 0; state SEND; end SEND: begin if (s_tready s_tvalid) begin if (cnt 1023) begin s_tlast 1b1; // 最后一拍拉高 state WAIT; end else begin cnt cnt 1; end end end WAIT: begin s_tlast 1b0; s_tvalid 1b0; state IDLE; end endcase end end这个状态机把数据从SEND状态持续送出去直到1024个点发完。逻辑里的关键参数是cnt的位宽1024点需要10位计数器如果换2048点则要11位。tready必须和tvalid同时判断只在握手成功的周期里递增cnt否则数据没被IP核接收就丢帧了。WAIT状态给了IP核一级处理的时间实际工程里如果IP核内部有足够深度的缓存可以省略但保留会更稳。3.3 仿真里的第一轮验证正弦输入、峰值位置与幅度核对数据通路搭好后仿真验证是第一步。用一个频率落在整数bin上的正弦波作为输入1024点、采样率100MHz、信号频率10MHz那么峰值应该精确出现在第102个频点上102410e6/100e6 102.4所以实际取102或103为了不出小数信号频率用100MHz102/1024 ≈ 9.9609375MHz。核对输出时先观察m_axis_data_tvalid然后抓取输出实部虚部计算幅度最大值的位置和理论位置做比较。// Testbench中生成正弦激励 reg signed [15:0] din_re; reg signed [15:0] din_im; integer idx; always (posedge clk) begin if (!rst_n) begin idx 0; din_re 0; din_im 0; end else if (s_axis_data_tvalid s_axis_data_tready) begin // 9.9609375MHz正弦幅度为满量程的一半 din_re $signed(16384 * $sin(2*3.14159*102*idx/1024)); din_im 0; // 实输入时虚部填0 idx idx 1; end end仿真里用$sin系统函数要注意很多仿真器支持实数运算但最终赋给signed信号时会自动截位不需要手动转换。参数说明16384是16位数据的半量程留出峰值扩展余量输入虚部强制为0因为实信号的频谱是共轭对称的只需要看正频谱。第一轮仿真只要峰值索引落在102附近、幅度不低于输入幅值的90%就说明IP核配置基本正确。如果峰值位置偏差超过1个bin优先检查tlast是否在正确位置拉高以及输出顺序是否选了自然顺序。4. 手写基2 FFT的RTL框架状态机调度与位宽分配4.1 单蝶形迭代结构一个蝶形处理一级循环log2(N)次要完全掌控FFT的实现细节手写一个基2单蝶形迭代结构是最常用的方案。这里的思路是只实例化一个蝶形运算单元用状态机控制它反复处理每一级的N/2个蝶形。资源占用最小代价是计算时间拉长1024点需要10级 × 512次 5120个蝶形运算周期在100MHz时钟下约51us仍然满足大多数实时系统需求。状态机的核心是级数stage和组内序号k的循环。每级变换的旋转因子步进规律是第m级需要处理2^m个蝶形组每组内旋转因子步长为N/(2^(m1))。每次蝶形运算从双口RAM取出两个操作数和对应的旋转因子算完写回RAM的同一地址。下面的代码框架展示了单蝶形调度逻辑的最简实现always (posedge clk) begin if (!rst_n) begin stage 0; k 0; state READ; end else case (state) READ: begin // 根据stage和k生成读地址 addr_a calculate_a_addr(stage, k); addr_b calculate_b_addr(stage, k); state COMPUTE; end COMPUTE: begin // 蝶形单元组合逻辑输出在组合逻辑中完成 state WRITE; end WRITE: begin // 写回结果 write_addr addr_a; write_data result0; state NEXT; end NEXT: begin if (k butterfly_count - 1) begin k 0; if (stage N_LOG2 - 1) state DONE; else stage stage 1; end else begin k k 1; end state READ; end endcase end状态机里的关键参数是N_LOG21024点取10。addr_a和addr_b的计算公式要遵循蝶形图第m级中第s组的两个数据地址差为N/2^(m1)。我一般不在状态机里实时算乘法而是把各级的地址基址写成一个查找表省逻辑也省时序。这里走的每一步都是周期精确的仿真时用计数器对照蝶形图非常直观。4.2 存储与寻址输入输出共用RAM、位反转寻址与旋转因子地址手写FFT时数据存储我一般用一个双口RAM输入序列按自然顺序写入蝶形运算过程中结果不断覆盖同一地址最后输出前做一次位反转读出。位反转是基2 FFT特有的规律N1024时原始输入序列x[n]需要按n的10位二进制反转后的顺序参与第一级蝶形。实现方式有两种写入时直接按位反转地址存储或者读出时做位反转。前者逻辑简单因为输入顺序固定计算反转地址的电路只是一个位交换连线。// 位反转地址生成将10位地址bit-reverse function [9:0] bit_reverse; input [9:0] addr; begin bit_reverse {addr[0], addr[1], addr[2], addr[3], addr[4], addr[5], addr[6], addr[7], addr[8], addr[9]}; end endfunction旋转因子的地址由当前级数stage和蝶形序号k共同决定计算公式是 idx k × (N (stage1))。这个公式可以直接映射为右移运算综合后不消耗乘法器。需要留意ROM地址位宽同样是10位但高位的值不会超过N/2因为只需存储半个周期。写ROM初始化文件时从Python脚本生成的十六进制文本直接复制进去就行无需手算。4.3 位宽分配与饱和截位策略每一级加多少比特减不减小位宽是FFT实现里最容易踩坑的地方。数据每经过一级蝶形幅度理论上最多增加2倍也就是增加1比特有效位。如果不做任何截位16位输入经过10级后需要26位到最后寄存器位宽爆炸资源翻倍时序也难收敛。常见做法有两类一类是自动缩放策略每级都保留1比特增长最后统一截到16位另一类是手动缩放策略根据旋转因子模长略小于1的特点每隔几级人为截掉1位把位宽始终控制在16位。下面是一张位宽分配表以16位输入、1024点为例级数蝶形输出位宽截位策略有效位017保留不截16118保留16219截为1816319截为1816419截为1816519截为1816619截为1816719截为1816819截为1816919截为1816这种“每两级截一次”的做法是我在多次仿真对比后觉得性与资源最平衡的方案。截位时不要直接丢弃低位常见的做法是先加一个舍入偏置比如截掉低3位时先加上4再右移这样频谱底噪能降低几个dB。代码里对应地就是在蝶形输出赋值前插入一段移位加偏置的语句// 截位处理把sum结果从19位缩到18位 wire [18:0] sum_tmp; assign sum_tmp out0_re; wire [17:0] out0_q (sum_tmp[18]) ? sum_tmp[18:1] : ({1b0, sum_tmp[18:1]} 18d1);逻辑说明如果最高位是符号位直接算术右移如果不是则在右移结果上加1实现四舍五入。参数说明右移1位等价于除以2加1是为了减小舍入误差。位宽策略一定不要等到最后一起截逐级控制可以让中间变量不掉精度同时避免最后的饱和截位产生大误差。5. FFT FPGA实现避坑溢出、顺序、握手与仿真玄学的5个翻车点5.1 峰值位置对但幅度差3dB缩放策略的坑现象仿真时峰值索引完全正确但输出幅度和理论值差了正好3dB左右。原因块浮点策略会根据整帧数据动态计算缩放因子最终每个频点都被除以同一个缩放值。如果输入信号幅度比较小而动态范围检测又按满量程估计缩放因子偏大输出就被整体压低了。解决对比输入信号的峰峰值和配置界面里的“缩放位数”设置如果是块浮点就直接观察输出的scale端口看实际缩放了多少位再在验证时把这个缩放补偿回来。5.2 m_axis_data_tdata接反了实部虚部现象输入一个纯实正弦信号输出却看到明显的负频率分量并且正负频率不对称。原因AXI-Stream的tdata位宽是数据位宽的2倍低16位是实部、高16位是虚部不少人习惯把低16位当低16位忽略了厂商定义里高字是虚部。解决用位宽拼接方式显式赋值比如m_axis_data_tdata接到IP核时把输出拆成{out_im, out_re}的固定拼接不要依赖默认赋值。代码里检查一次波形如果试频和下变频结果同时出现基本就是这个问题。5.3 tvalid拉低导致丢帧现象在高吞吐模式下FFT输出偶发地缺少某些频点频谱上出现周期性缺口。原因上游数据源发送数据不是连续的每两个采样之间tvalid会拉低若干周期而接收侧没有把tvalid作为握手条件来计数。解决在输入状态机的递增条件里把tvalid tready作为唯一计数使能不要认为数据是背靠背连续送达的。处理完这个问题后再观察一帧数据和tlast之间的间隔确认是否稳定。5.4 仿真通过、上板就翻车跨时钟域与未复位的隐患现象Models/VCS仿真里一切正常写进FLASH上板后输出混杂噪声或直接状态机锁死。原因仿真默认所有寄存器在同一时刻复位而真实的FPGA上电后有异步复位释放时间差异另外FFT IP核的AXI-Stream输入可能来自另一个时钟域数据跨时钟域时没做同步。解决复位信号做至少两级寄存器同步并把FFT的复位释放时间延后100个时钟周期跨时钟域数据用异步FIFO过渡FIFO空满信号再回读做握手。这类问题高概率在调试阶段出现先查复位再查FIFO。5.5 高位宽输入下频谱底噪下不去现象输入16位数据频谱底噪在-70dBc附近就上不去了理论上16位量化底噪应该在-96dBc左右。原因旋转因子ROM位数不够或者蝶形输出截位太狠。我遇到过ROM表只有12位宽度直接把精度砍掉一截。解决把旋转因子ROM加宽到与数据位宽一致或再加2位同时用正弦输入做单频测试观察底噪变化。如果两者都调到位后底噪依然高检查RAM的读写冲突——同一个地址在写使能拉高时读出了旧值这种异步冲突在仿真里很难发现要在建模时就把读写仲裁写清楚。6. 把FFT做成工程连续数据流接口与在线自检技巧实际系统里FFT很少单帧独立工作更多是ADC不断采集、FFT一帧接一帧处理。让FFT核连续工作的关键是处理好帧与帧之间的间隔上一帧tlast拉高之后下一帧的tvalid最早可以在下一个时钟周期拉高但中间要保证输入缓存已经切换到新帧。商用IP核一般支持这种背靠背输入而手写RTL时需要在状态机里预留一个“帧间隔”状态通常留4个时钟周期就足够给写地址指针一个切换窗口。连续流模式下m_axis_data_tuser信号会随第一个输出点一起拉高用来标记新帧起始抓数据时一定不要漏掉它。验证FFT实现是否正确我还习惯在FPGA内部做一个在线自检模块用LFSR产生一个伪随机序列经过一个已知系数的FIR滤波器后作为FFT输入然后把FFT输出与预先在MATLAB算好的参考频谱做幅度误差统计。这样不需要外部信号源每次上电跑1秒钟统计最大误差和均方根误差低于0.5%就算通过。这个自检模块平时留着改位宽、改点数后重新综合就靠它回归。做这些工程化收尾的意义是把FFT从“能算”变成“算得准、能连续跑”。我在调一个2048点的手写FFT时图省事跳过了逐级截位直接最后统一缩放结果频谱底噪抬高了10dB折腾了一整天才定位到是溢出导致高位出现毛刺。后来老老实实按级分配位宽一次通过。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑