分布式 FIR 滤波器 FPGA 实现:用 LUT 替代 DSP 的 DA 算法详解

发布时间:2026/9/16 3:09:16
分布式 FIR 滤波器 FPGA 实现:用 LUT 替代 DSP 的 DA 算法详解 简介数字信号处理中有限脉冲响应FIR滤波器通过乘累加运算实现频率整形但在 FPGA 上传统乘法运算需消耗 DSP 单元当抽头数增加时资源压力显著。分布式算法Distributed Arithmetic将固定系数的乘累加转换为查表与移位累加用 LUT 资源替代 DSP 乘法器是一种经典的低硬件开销实现思路。在信号采集、通信预处理等场景中FIR 滤波器常需在可编程逻辑上高效部署分布式算法通过按 bit 重组运算将每个 bit slice 的部分积预计算为查找表大幅降低对专用乘法器的依赖。文章重点讲解纯 Verilog 实现 DA-FIR 的完整流程覆盖串行与并行架构、加法树优化以及跨 Vivado/Quartus/ISE 的工具移植要点为需要可移植 RTL 滤波器的 FPGA 开发者提供参考。1. DA分布式fir滤波器先弄懂查表怎么把乘法换掉ADC一开FIR抽头一多乘法器先被吃光。常见的直接写法是乘加树16bit输入配12bit系数单个乘法器就要占一个DSP4832阶FIR下去中低端FPGA的DSP单元很快见底时序还跟着恶化。DA分布式算法Distributed Arithmetic思路是绕开乘法器输入按 bit 拆开把“这一拍窗口内所有系数与输入比特的组合”预先算好放成一张表运行时查表再移位累加。DSP48基本不动纯LUT就能跑完整个滤波。这个标题的完整含义是在 Vivado 2019.2 里用纯 Verilog 把 DA-FIR 写出来不依赖 Xilinx IP代码仓库搬到 Quartus II 或 ISE 时只需要动约束和仿真库。适合系数固定、输入位宽 8~16bit、抽头数 4~64 的信号采集预处理场景也适合那些不想被厂商 IP 绑死、想保留一个可移植RTL滤波器的项目。下面按“原理 → RTL实现 → 跨工具移植 → 调优验证”的顺序来推进。2. DA算法拆解与位宽参数为什么能用LUT拼出FIR2.1 把乘加重新分组按bit提取公共部分先写FIR的直接型表达式y[n] c0·x[n] c1·x[n−1] … c(N−1)·x[n−N1]其中c是常量系数x是输入。常规实现是每条乘加路径单独做一次乘法N个抽头就要N个乘法器。DA的做法是把输入按二进制补码展开x[k] −2^(M−1)·x[k][M−1] Σ(2^j · x[k][j])j从0到M−2代入FIR表达式后按2^j重新分组得到y[n] Σ(2^j · [Σ(c_k · x[k][j])]) − 2^(M−1)·[Σ(c_k · x[k][M−1])]方括号里的内容值得盯住它是对“N个系数 × N个bit”做加权求和而每个x[k][j]只取0或1所以这个和只有2^N种组合。系数全部固定这2^N个结果可以预先算好理论上用一张深度为2^N的表就能在一次查表里得到当前bit-slice的部分积。这正是把乘法换成LUT查找的关键一步。理解这个分组逻辑的唯一难点在于符号位。式子最外层多出的“−2^(M−1)”是补码的符号权值实现时不能和普通bit-slice混在一个余额里。常见做法是最低M−1个bit slice做加法最高的符号bit slice做减法最后再对齐到输出位宽。这也是DA-FIR最容易写错的地方稍后RTL章节会专门处理。2.2 一张表算清资源抽头数、输入位宽、系数位宽怎么互相牵制表1是DA-FIR关键参数与资源影响的关系设计一开始就应该把这些参数定死而不是在写代码时随手改。参数符号典型值对资源和时序的影响抽头数N8 / 16 / 32完整ROM的深度是2^NN8时ROM体积极速膨胀一般转为按位选通加法输入位宽M8 / 12 / 16串行DA每个样本需要M拍完成全并行DA要复制M份LUT逻辑系数位宽W8 / 12 / 16决定内部向量位宽约等于Wceil(log2 N)输出位宽OMWceil(log2 N)累加器位宽按此设置输出截断位另算串行DA每来一个样本内部串行跑M拍每拍根据当前bit位置查询一组部分积最后在累加器里拼出完整结果。这种方式LUT数量很少但吞吐率降为1/M。如果系统时钟远高于采样率比如1MHz采样率配100MHz系统时钟串行结构完全够用。全并行DA则是复制M份LUT逻辑每个时钟出一次结果代价是LUT用量和加法树面积成倍上涨。两者之间的折中是半并行一次处理2~4个bit slice吞吐率和面积各取一半。用表格里的公式可以快速估算N16、M12、W12时完整ROM深度65536已经不适合用单张ROM改用选通加法后向量位宽约为12ceil(log2 16)16bit累加器位宽约121628bit。这个量级的LUT开销在XC7A系列上只占几个百分点设计余量很大。2.3 Vivado 2019.2里DA结构的综合倾向与纯Verilog边界Vivado 2019.2的综合器对case语句和连续赋值有较强的LUT宏合并能力。DA的查表段如果写成完整的caseXST和Vivado都能把它折叠成LUT资源但如果写成二维数组加$readmemh初始化Vivado倾向于推断成BRAMQuartus II又可能推断成MLAB或分布式RAM行为不一致。跨工具项目里我一般避免在RTL里直接声明“这是一个ROM”而是用case或generate选通把查表逻辑“摊”成组合逻辑让每个工具自己去选LUT还是寄存器的实现方式。另一个2019.2特有的注意点这个版本对SystemVerilog的支持还不完整像always_ff、logic这类语法在综合时偶尔会有兼容问题。纯Verilog-2001写法是最稳妥的。还有一个小坑是$clog2属于SystemVerilog系统函数Verilog-2001环境下不可用跨工具代码里要自己写一个integer型clog2函数避免在Quartus II的老版本综合器上报未知函数。function的写法在下一章随代码一起给出。3. 纯Verilog实现DA-FIR从串行查表到并行展开3.1 最小串行DA-FIR移位窗口、符号位、累加器时序先给一个可直接综合的串行DA-FIR核心输入输出都用补码抽头数、位宽全部参数化。为了便于演示这个版本假设N8输入位宽M12系数位宽W12。第一段是模块接口与移位窗口module da_fir_serial #( parameter N 8, parameter M 12, parameter W 12, parameter O M W 4 )( input wire clk, input wire rst_n, input wire signed [M-1:0] din, input wire din_valid, output reg signed [O-1:0] dout, output reg dout_valid ); reg [M-1:0] shift_reg [0:N-1]; integer i; always (posedge clk) begin if (!rst_n) begin for (i 0; i N; i i 1) shift_reg[i] {M{1b0}}; end else if (din_valid) begin shift_reg[0] din; for (i 1; i N; i i 1) shift_reg[i] shift_reg[i-1]; end end // bit_cnt 在 din_valid 有效后从 0 计数到 M-1 reg [$clog2(M)-1:0] bit_cnt; reg shift_phase; always (posedge clk) begin if (!rst_n) begin bit_cnt 0; shift_phase 1b0; end else if (din_valid) begin bit_cnt 0; shift_phase 1b1; end else if (shift_phase) begin if (bit_cnt M-1) shift_phase 1b0; else bit_cnt bit_cnt 1b1; end end窗口部分就是一个标准的滑动窗口din_valid打一拍后进入8级移位链。bit_cnt负责控制串行扫描的bit位置shift_phase表示当前样本的M拍内部处理正在进行。这里用$clog2(M)声明bit_cnt位宽在Vivado 2019.2中因为全局是Verilog模式可能不识别所以排版时我会把它替换成localparam BCNT_W clog2(M)其中clog2是我们自己写的function。接下来是LUT查表和累加两个部分// 取当前bit位置下8个抽头各自的数据bit拼成查表地址 reg [N-1:0] lut_addr; always (*) begin for (i 0; i N; i i 1) lut_addr[i] shift_reg[i][bit_cnt]; end // 部分积查表以4抽头示例列出部分行实际N8展开256行 reg signed [W3-1:0] vec; always (*) begin case (lut_addr) 8b0000_0000: vec {W3{1b0}}; 8b0000_0001: vec {{3{c[0][W-1]}}, c[0]}; // 其余抽头组合按同样规律展开 default: vec {W3{1b0}}; endcase end // 累加器减符号位 移位累加 reg signed [O-1:0] acc; always (posedge clk) begin if (!rst_n) acc {O{1b0}}; else if (din_valid) acc {{O-M{1b0}}, din}; // 不用乘直接把输入视为首个部分积 else if (shift_phase) begin if (bit_cnt M-1) acc (acc 1) - {{(O-W-3){vec[W2]}}, vec}; else acc (acc 1) {{(O-W-3){vec[W2]}}, vec}; end end这段代码逻辑说明每个bit slice先查表得到vecvec的位宽取W3多出的3位是为了累加8个系数组合时不溢出每个时钟把acc右移一位再把当前bit slice的vec加到高位区。右移等价于乘2^j符号bit对应的slice是最后一次处理所以用减法。dout_valid与dout在shift_phase回落时打一拍输出保证与串行周期对齐。参数说明里最容易被忽略的是符号扩展位。vec在参与加法前扩展成O位符号扩展的位数要刚好匹配累加器位宽如果少扩展一位负系数组合的表现会奇怪仿真中表现为输出波形整体偏了一个常数。这也是DA-FIR排查时的第一检查位。3.2 全并行DA与加法树generate展开M个bit slice串行结构节省LUT但每样本消耗M拍采样率要求高时就换成全并行。全并行DA把M个bit slice的表和累加逻辑全部复制出来每个时钟同时处理M个部分积再由加法树合并。下面是generate展开的核心function integer clog2; input integer n; integer r; begin r 1; while ((1 r) n) r r 1; clog2 r; end endfunction localparam VW W clog2(N); localparam TREE_LEVELS clog2(M); genvar g; wire signed [VW-1:0] part_prod [0:M-1]; generate for (g 0; g M; g g 1) begin : gen_bit_slice always (*) begin integer k; for (k 0; k N; k k 1) shift_addr[k] shift_reg[k][g]; // 第g个bit slice的N位地址 end assign part_prod[g] da_lut(shift_addr, coeff_mem); // 查表函数 end endgenerate这里的da_lut是一个function内部对系数数组做选通累加避免写2^N行case。function的输入是N位地址和系数数组输出是Wclog2(N)位部分积综合时Vivado会把它映射成LUT加进位链。加法树再用两级或三级for循环生成每级把上一级的两个向量相加层数为TREE_LEVELS。全并行版本的核心收益是吞吐率等于时钟频率din_valid每拍都能接收新样本。代价第一是part_prod数组占用M×(Wclog2N)个寄存器第二是加法树路径较长时钟频率会被最后一级加法限制。实际工程里我通常把加法树切两段中间插一拍流水输出延迟增加一拍但时序更好。3.3 仿真验证用$readmemh和$fwrite对比手算FIR写DA-FIR最怕的是逻辑对了但波形错得不明不白。我的常规做法是先用脚本Python或MATLAB生成一组输入序列和对应FIR输出存成hex文件再由testbench读入。timescale 1ns/1ps module tb_da_fir; reg clk 0; reg rst_n 0; reg signed [11:0] din 0; reg din_valid 0; wire signed [27:0] dout; wire dout_valid; da_fir_serial #( .N(8), .M(12), .W(12) ) dut ( .clk(clk), .rst_n(rst_n), .din(din), .din_valid(din_valid), .dout(dout), .dout_valid(dout_valid) ); integer fd; initial begin $display(current file: %s, __FILE__); fd $fopen(out.hex, w); // 复位与激励逻辑 end always (posedge clk) if (dout_valid) $fwrite(fd, %04x\n, dout); initial begin #100 rst_n 1; #1000 $fclose(fd); $finish; end endmodule这段testbench里有两个值得留意的地方$display(current file: %s, \FILE)可以在仿真日志里直接打印当前文件路径对定位多工程复用的仿真环境很实用$fwrite 按sum每拍输出一个四字节hex方便与Python算出的结果逐行比对。注意输出比较要掩盖低位截断把dout对齐到预期的位宽再对比。4. 从Vivado 2019.2搬到Quartus II或ISE该留什么该改什么4.1 代码层可移植边界哪些RTL能带走哪些必须剥离先说结论上一章的DA-FIR核心代码可以直接搬到Quartus II和ISE前提是把三个东西从代码里剥离出去。第一是Xilinx原语比如BUFG、IDELAYE2、DSP48E1这些只在Vivado综合器里能识别。第二是xpm_memory、FIFO Generator这类官方IP不同厂商的IP核接口完全不同跨工具必然重写。第三是调试相关逻辑Vivado里用ILA抓波形Quartus II对应SignalTapISE对应ChipScope这三者的例化方式互不兼容。表格整理如下方便移植前一并检查组件Vivado 2019.2Quartus IIISE时钟原语BUFG / MMCMALTCLKCTRL / PLLIBUFG / DCM调试抓波ila_0 IPSignalTapChipScope块内存xpm_memory / blk_mem_genaltsyncramRAMB16综合属性(* ramstyle block *)(* ramstyle M9K *)(* ram_extract yes *)前面RTL用的是case和generate选通不属于上述任何特定原语所以能直接移植。唯一需要确认的是综合属性不要带ramstyle这类厂商词汇否则Quartus II会提示不认识这个attribute在ISE上直接报warning。保持裸Verilog让工具自己推断。4.2 命令行编译与约束写法XDC、QSF、UCF三套怎么对应三个工具都支持命令行批处理。Vivado 2019.2用batch模式Quartus II用quartus_map加quartus_fitISE用xst。同一条逻辑在不同工具下的编译命令对比如下# Vivado 2019.2 vivado -mode batch -source synth_da.tcl # Quartus II quartus_map --read_settings_fileson top_da quartus_fit top_da quartus_asm top_da # ISE xst -ifn top_da.xst ngdbuild top_da.ngc约束文件差异更直接。同样一条100MHz主时钟三种工具写法完全不同# XDC (Vivado 2019.2) create_clock -period 10.000 [get_ports clk] set_input_delay -clock clk 2.0 [get_ports din] set_output_delay -clock clk 2.0 [get_ports dout] # QSF (Quartus II) set_global_assignment -name FMAX_REQUIREMENT 100 MHz -section_id top_da set_global_assignment -name TSU_REQUIREMENT 2 ns -from din -to clk # UCF (ISE) NET clk TNM_NET clk; TIMESPEC TS_clk PERIOD clk 10 ns HIGH 50%;XDC在综合和实现阶段都会生效UCF几乎是只用于实现阶段QSF则由Fitter直接读取。移植时最容易出错的是input/output delay的写法不一致导致同一份RTL在Vivado上时序收敛、搬到Quartus后setup或hold出问题。我会先把时钟约束对齐再逐个加data delay不要一次性贴全套复杂约束。4.3 仿真库与license报错不同工具的仿真器各管各的Vivado 2019.2默认用XSim仿真时不需要额外编译库但如果要在ModelSim或Questa里跑同一份testbench就必须先用对应工具编译Vivado的仿真库。Quartus II集成的ModelSim一般不认Vivado的库路径ISE 14.7带的是ISim也不通用。所以“同一份testbench在三套工具里跑”这个想法不成立我通常在Vivado里做功能仿真验证完再把RTL放进Quartus或ISE只做综合与时序分析。老工具链还有一个高频报错用户在ISE或老版本ModelSim里看到“failure to obtain a verilog simulation license”一类的提示这个错误名在ISE 14.7用户群里出现率极高原因不是仿真器本身坏了而是环境变量LM_LICENSE_FILE没有指向正确的lic文件路径。Vivado 2019.2则对应Xilinx License配置问题检查环境变量和lic路径即可。VSCode写Verilog时配好语法高亮和lint再用Vivado 2019.2做最终综合开发效率会比在IDE里直接写RTL更高。5. 最后一节加法树“按高度排序”与批量对比脚本5.1 并行DA加法树不要按数组下标顺序累加全并行DA把M个bit slice部分积直接相加代码写成for循环顺序累加最直观但综合后的路径是一条M级加法链M16时路径已经明显变长。常见做法是两两分组、逐级向上合并但更细一步的优化是每轮重新排列待加向量优先合并位宽接近的两个向量。加法树每一级的位宽随向量数量和系数位宽增长不同宽向量放在后面加能减少中间级数。我用generate生成树时会先把所有part_prod按位宽排序再两两配对M不是2的幂次时多余的向量延后一级再加入。这个做法的收益在综合报告里看LUT和路径延迟最直接8抽头、12bit输入的DA-FIR从顺序累加改为平衡加法树路径延迟一般能降低一到两个LUT级时序余量明显改善。Vivado 2019.2里用report_timing_summary查看WNS变化验证这个排序是否真的有效。5.2 用Tcl脚本切换DA展开模式量化LUT和时序收益同一个DA-FIR设计串行、半并行、全并行三种结构应该抽成同一个顶层用宏控制展开方式。在Vivado 2019.2的batch模式下可以用-verilog_define从命令行切换宏再分别收集资源报告# run_da_comp.tcl set part xc7a35ticsg324-1L read_verilog ./rtl/da_fir_top.v synth_design -top da_fir_top -part $part -verilog_define MODE_PARALLEL report_utilization -file util_parallel.rpt report_timing_summary -file timing_parallel.rpt # 改动后重新读取或换用 -verilog_define MODE_SERIAL-verilog_define会把宏传给综合器相当于在代码里临时加一条define MODE_PARALLEL。代码内部用ifdef包住并行展开和串行展开两段这样一次综合只编译其中一段。跑完三组宏对比util_.rpt和timing_.rpt里的LUT数量、WNS、TNS就能明确知道当前资源约束下该选哪种结构。这个方法在Quartus II下对应set_global_assignment -name VERILOG_DEFINEISE则用-define参数三套工具都能用同样的ifdef代码结构不需要改RTL。本文还有配套的精品资源点击获取