先说结论:CIC抽取滤波器是FPGA数字信号处理里少见的“零乘法器”滤波器,特别适合ADC过采样数据的抽取、数字下变频前端、以及音频采样率转换这类场景。这次我完整走了一遍“原理推导 → Verilog建模 → 仿真验证 → MATLAB Simulink建模仿真 → 结果交叉比对”的流程,把中间遇到的位宽溢出、抽取时序、量化误差问题全部记录下来,直接照着做就能复用。
这个项目适合正在接触FPGA数字信号处理、需要实现数据抽取或者降采样功能的人,也适合被各种滤波器系数量化搞到头大的同学。如果你只想调个现成IP核,这篇文章也能帮你把后端参数、位宽配置这些东西弄清楚。
1. 先搞清楚CIC抽取滤波器到底在解决什么问题
1.1 抽取场景里的“笨办法”反而是最优解
数据抽取,其实就是把高采样率的数据按整数倍丢掉一部分,只保留需要的点。比如ADC输出100MHz的采样流,你只需要25MHz采样率的数据,那每4个点取1个就行。看起来简单,但直接抽点会出问题:频带里混入了超出新采样率一半的高频分量,这就是混叠。解决办法是先滤波再抽取,把不需要的高频滤掉。
CIC滤波器全称Cascade Integrator Comb,级联积分梳状滤波器。它的典型结构是一串积分器加上一串梳状差分器,中间夹一个抽取器。最核心的优势是没有乘法器,只有加法、减法、寄存器,这在FPGA里意味着极低的逻辑资源消耗和很高的吞吐能力,尤其适合处理100MHz以上的高速数据流。代价是通带响应不够平直,衰减也一般,但作为抽取前端的抗混叠滤波器绰绰有余。
1.2 为什么不直接用FIR,非要用CIC
FIR滤波器性能确实好,通带纹波小、线性相位,但是多抽头、高系数的FIR在FPGA里要用大量DSP乘法单元。如果你只是做32倍、64倍抽取,FIR的系数数量会非常可观;而CIC只用R、M、N三个参数就能控制抽取倍数、延迟和滤波性能,结构极其规整。我用过四级级联CIC做64倍抽取,整套逻辑消耗只有几百个LUT,这在资源紧张的工程里太重要了。
CIC的劣势也明显:通带内有频率倾斜(droop)。如果后续对信号平坦度要求高,就得再接一个补偿滤波器(一般用FIR)。所以工程上常见的做法是“CIC粗滤抽取 + FIR补偿整形”,把高性能和低成本两个优势同时拿到。
1.3 为什么要把Verilog仿真和Simulink仿真两条路都走一遍
开发FPGA数字信号处理,最怕RTL写完了才发现算法理解错了,到时候在板子上调试非常痛苦。我的习惯是先用MATLAB/Simulink把算法模型跑一遍,验证滤波效果、确认参数范围;再写Verilog RTL,让RTL仿真结果和Simulink结果对齐。
把两条验证路径都做完整的好处有三个:一是可以互相验证数学实现是否一致,二是Simulink能快速扫描参数(比如抽取值、阶数),找到最佳设计点,三是Verilog仿真能暴露位宽、时序、符号处理这些Simulink里根本看不到的问题。这套“算法模型左膀、RTL实现右臂”的验证方法,实际项目中几乎是标准动作。
2. 设计参数靠数学推,不能拍脑袋乱定
2.1 把传递函数拆成积分器加梳状器两级看
CIC滤波器的系统函数长这样:
H(z) = ((1 - z^(-R*M)) / (1 - z^(-1)))^N
其中R是抽取倍数,M是差分延迟(通常取1或2),N是级联阶数。这个式子看起来复杂,但拆开就清楚了:分子部分(1 - z^(-R*M))对应梳状滤波器,分母部分1/(1 - z^(-1))^N对应N级积分器。整个CIC就是“积分器 → 抽取 → 梳状器”的结构。
为什么积分器在前、梳状器在后?因为积分器有反馈回路,工作频率必须和输入数据率一致,放在抽取之前它还能继续在每个输入时钟周期工作;梳状器没有反馈,放在抽取之后,只处理抽取后的低速率数据。如果反过来,梳状器工作在高速率下,浪费资源,而且积分器接收的是抽过的数据,误差会很大。
2.2 增益和输出位宽的计算,这一节最容易被忽视
CIC是直流增益很高的滤波器,增益就是(R*M)^N。比如R=8、M=1、N=3,理论增益是512,也就是输入信号幅度会被放大512倍。如果不知道这个增益直接接后面模块,数据早就溢出了。所以工程上FIFO之后一般要么做右移截位,要么做饱和截位,把增益缩回去。
位宽增长的估算公式是:
B_out = B_in + N * ceil(log2(R*M))
举个例子,输入16位有符号数,R=8、M=1、N=3,log2(8)=3,所以内部位宽至少是16 + 33 = 25位。考虑到中间累加可能再要冗余,我通常再留1到2位余量,最终内部位宽设到26位或27位。注意Verilog里$clog2函数计算的是2的幂次数,RM的结果要提前算清楚。
2.3 CIC阶数和差分延迟选择的经验法则
N=1到2适合简单抽取保护,通带比较宽,混叠抑制差;N=3到4是常用折中,抗混叠够了,通带倾斜可以用补偿器救回来;N=5以上除非极端抑制需求,否则不建议随便上,因为位宽和资源都会涨,而且通带倾斜会非常严重。
差分延迟M的取值也影响响应形状。M=1是最常用的,主瓣宽度和旁瓣高度均衡。M=2时旁瓣衰减更好,但主瓣会变窄。对于窄带信号或者需要更强阻带衰减的场景可以用M=2,代价是要多做一些位宽预算。
注意:CIC级数N≠“积分器级数+N级梳状器”,实际结构里积分器有N级、梳状器也有N级,级联阶数指的是同时级联的积分/梳状对,不要混淆。
3. Verilog写出来和仿起来是两回事
3.1 先写一个单级CIC抽取核,弄懂数据流
下面是我写的单级CIC抽取器Verilog模块,R=8,输入16位,内部累加位宽24位。考虑到这是演示代码,重点看结构,多级扩展逻辑是在它基础上循环复制的。
module cic_single #( parameter DIN_WIDTH = 16, parameter ACC_WIDTH = 24, parameter R = 8 )( input wire clk, input wire rst_n, input wire ce_in, // 输入采样率时钟使能 input wire ce_dec, // 抽取后时钟使能 input wire signed [DIN_WIDTH-1:0] din, output wire signed [ACC_WIDTH-1:0] dout ); // 积分器:每个输入时钟都累加一次 reg signed [ACC_WIDTH-1:0] acc; always @(posedge clk or negedge rst_n) begin if (!rst_n) acc <= {ACC_WIDTH{1'b0}}; else if (ce_in) acc <= acc + din; end // 抽取寄存器:抽取点的瞬间锁存积分器输出 reg signed [ACC_WIDTH-1:0] dec_reg; always @(posedge clk or negedge rst_n) begin if (!rst_n) dec_reg <= {ACC_WIDTH{1'b0}}; else if (ce_dec) dec_reg <= acc; end // 梳状差分器:相邻两个抽取点做差 reg signed [ACC_WIDTH-1:0] comb_dly; always @(posedge clk or negedge rst_n) begin if (!rst_n) comb_dly <= {ACC_WIDTH{1'b0}}; else if (ce_dec) comb_dly <= dec_reg; end assign dout = dec_reg - comb_dly; endmodule这段代码的关键点是:积分器一直在跑,ce_in是输入采样使能,每个有效输入周期累加一次;ce_dec则是抽取点的位置,每R个输入周期来一个脉冲。comb_dly保存的是上一个抽取点的值,和当前的dec_reg做差,就是“积分器上连续两个抽取值之差”,对应系统函数的差分部分。
用这种方式写,读代码的人能一下子理解CIC数据流的节奏。实际工程中,ce_in、ce_dec这两个使能一般由一个采样率分频器产生,也可以用AXI Stream的tvalid信号驱动。
3.2 多级CIC的Verilog扩展:看到位宽增长就对了
三级级联的代码并不复杂,本质上就是把积分器、梳状器各复制三份。我用generate批量生成,这样参数N改起来很方便:
module cic_dec #( parameter DIN_WIDTH = 16, parameter N = 3, parameter R = 8, parameter M = 1, parameter ACC_WIDTH = 26 )( input wire clk, input wire rst_n, input wire ce_in, input wire ce_dec, input wire signed [DIN_WIDTH-1:0] din, output wire signed [ACC_WIDTH-1:0] dout ); genvar i; wire signed [ACC_WIDTH-1:0] int_chain [0:N-1]; wire signed [ACC_WIDTH-1:0] comb_chain [0:N-1]; // 积分器级联 assign int_chain[0] = int_data(0); generate for (i = 0; i < N; i = i + 1) begin : gen_int reg signed [ACC_WIDTH-1:0] acc_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) acc_r <= '0; else if (ce_in) begin if (i == 0) acc_r <= acc_r + din; else acc_r <= acc_r + int_chain[i-1]; end end end endgenerate // 抽取寄存器、梳状器同样用generate批量展开 endmodule写多级代码时最容易犯的错误是忘记逐级位宽增长。我的做法是直接把ACC_WIDTH设足够大,内部所有积分器、梳状器统一用ACC_WIDTH位,省去每级截位。虽然有点浪费寄存器,但仿真调试非常清楚——每一级的波形宽度一致,出了异常一眼就能定位。等资源紧张需要优化时,再逐级缩位也不迟。
3.3 testbench里那些容易翻车的细节
仿真必须包含完整的时钟和复位时序,还有参考数据。我给CIC写testbench时,习惯先把输入数据用MATLAB生成好,保存成十六进制的txt文件,再用$readmemh读进仿真,这样后续和Simulink对比就有统一的数据源。
module tb_cic_single; reg clk, rst_n; reg ce_in, ce_dec; reg signed [15:0] din; wire signed [23:0] dout; cic_single #( .DIN_WIDTH(16), .ACC_WIDTH(24), .R(8) ) uut (.*); integer file_id; reg [15:0] mem [0:2047]; initial begin clk = 0; forever #5 clk = ~clk; end initial begin $readmemh("input_data.txt", mem); rst_n = 1'b0; ce_in = 1'b0; ce_dec = 1'b0; #30 rst_n = 1'b1; repeat (2048) begin @(posedge clk); ce_in = 1'b1; din = mem[test_idx]; // 每8拍给一次抽取脉冲 ce_dec = (test_idx % 8 == 7) ? 1'b1 : 1'b0; if (ce_dec) $fdisplay(file_id, "%d", dout); test_idx = test_idx + 1; end $finish; end endmodule这里有个我踩过的坑:如果ce_dec和ce_in在同一个时钟周期拉高,抽取寄存器采样到的是积分器当前累加后的新值,还是旧值?这和代码里积分器用阻塞赋值还是非阻塞赋值、抽取寄存器在always块中的相对位置都有关系。为避免歧义,我统一用非阻塞赋值,并且让极性使能打一拍:抽取寄存器直接锁存上一个时钟周期积分器已经稳定下来的结果。换句话说,抽取点比累加点晚一个时钟周期采样,这样时序余量更宽,也不会出现读值不确定的情况。
3.4 仿真波形怎么观察才能确认功能正确
不要一上来就看输出值大小,先把三个关键信号放进去观察:acc、dec_reg、comb_dly。
- acc应该呈现单调递增/递减的阶梯状波形,输入为正弦波时它大致是“积分后的曲线”,幅度随直流漂移变化。
- dec_reg是每R个周期更新一次的采样值,波形比acc稀疏,但趋势一致。
- dout是dec_reg与comb_dly的差值,输出波形此时应该非常接近低通滤波后的正弦波。
观察dout时,如果看到明显的直流偏置,别慌,CIC本身是低通结构,对直流和低频信号增益很大,直流分量会被放大是正常的。真正的异常是dout数据量比以前更大导致波形严重上下削顶,那就是位宽不够。
4. MATLAB/Simulink做真值表和参数扫描
4.1 Simulink里搭CIC抽取模型只要四步
我用的MATLAB版本自带DSP System Toolbox,它提供了CIC Decimation模块,可以直接配置R、M、N三个参数。搭建步骤如下:
- 新建Simulink模型,从“DSP System Toolbox → Filtering → Multirate Filters”里拖出CIC Decimation。
- 打开模块参数:Decimation Factor设8,Differential Delay设1,Number of Sections设3,Input/output bit widths按你的设计设好。
- 信号源用Sine Wave加Quantizer模块,量化成16位整数,保证和Verilog输入格式一致。
- 用Scope或者To Workspace模块把输入输出保存到工作区备查。
这一步的坑在于Simulink默认的CIC滤波输出位宽计算和你的Verilog位宽可能对不上。Simulink有“FPGA-oriented bit growth”选项,勾选后它会按公式自动配置内部位宽,如果你不勾选,输出位宽可能会按双精度浮点运算,结果和定点RTL对不上。对比的时候一定要把输出量化方式改成Fixed-point。
4.2 MATLAB脚本生成激励数据并读取仿真结果
我在做交叉验证时用了一套脚本流程,极其有效。先写一个MATLAB脚本生成输入txt和参考输出数据,再让Verilog仿真读取输入txt、生成输出txt,最后回到MATLAB里把两组输出画在同一个图上对比。
% 生成16位整数正弦激励 fs = 1e6; f0 = 20e3; N = 2048; n = 0:N-1; x = round(2000 * sin(2*pi*f0/fs * n)); % 写入txt,Verilog用$readmemh读取 fid = fopen('input_data.txt', 'w'); for i = 1:N if x(i) < 0 fprintf(fid, '%04X\n', x(i) + 65536); else fprintf(fid, '%04X\n', x(i)); end end fclose(fid); % 读取Verilog仿真输出 y_verilog = load('verilog_out.txt'); y_matlab = simout; % Simulink导出的结果 % 统一缩放并绘图 figure; plot(y_matlab(1:256), 'r-o'); hold on; plot(y_verilog(1:256), 'b--x'); legend('MATLAB/Simulink', 'Verilog仿真');输入数据格式这里有个细节:Verilog的signed reg配合$readmemh读文件时,文件里的16进制如果超过了32767,会按有符号数解释吗?实测下来不太稳定,所以我干脆把负数映射成补码形式写入,读取后仿真内部再用signed转换,这样两边数据就对齐了。另外,在读取使用16位十六进制值时要小心,如果超过7FFF要自动减65536再转有符号,这是MATLAB脚本里要补写的转换。
4.3 怎么判断两路仿真结果是否“一致”
对比两个仿真结果时,你会发现波形形状很接近,但幅度未必完全相等。原因很简单:MATLAB/Simulink的CIC Decimation模块输出默认可能带内部缩放,或者已经帮你做了增益归一化;而Verilog输出的是原始增益放大结果。
我的做法是先把Simulink的输出也按(R*M)^N的增益做一次“反缩放”,也就是右移9位(512),再看误差。如果误差在量化步长允许范围内(比如±1个LSB),就认为两路结果一致。如果差很多,优先检查三个地方:输入量化是否都是16位有符号定点、Simulink是否开了FPGA定点模式、Verilog的位宽设置是否合理。
5. 我在调试中踩过的一些坑
5.1 位宽截断与符号扩展问题
这个坑几乎是每个做CIC的新手必踩的。我在单级测试时为了省寄存器,把ACC_WIDTH直接设成20位,结果输出波形在信号幅度较大的地方出现削顶,像是被无理截断了。原因是输入16位有符号数,R=8的单级增益是8,输出峰值理论上需要19位(16+ceil(log2(8))=19),我用20位应该刚好够,但忽略了输入本身可能是负满幅,再加上多级累加的一点点余量,瞬间溢出。
解决方式很简单:要么内部统一用理论位宽加2位红色余量,要么在输出端做截位时加饱和逻辑。饱和逻辑的Verilog写法就是用比较器判断是否溢出,如果溢出就把输出拉满幅值。这个处理对信号链路的健壮性非常重要,尤其后面还有FIFO或者DSP模块的时候。
5.2 抽取时钟与数据同步问题
如果ce_dec和ce_in同时拉高,抽取寄存器锁存的值可能是积分器正在更新的值,也可能是一拍前的旧值,这取决于代码里积分器和抽取寄存器的顺序关系、非阻塞赋值的时间戳。我在仿真里遇到过“输出偶尔多跳一个数”的奇怪问题,排查半天发现是testbench的ce_dec逻辑写得太随意,和积分器更新时钟同沿了。
正确做法是让抽取寄存器的使能延后一拍。具体来说,计数到R的最后一个周期时,先让积分器累加完成,下一个时钟边沿再做抽取寄存器锁存。这样时序上天然晚一拍,但不会出现采样不确定。这个“晚一拍”的延迟是CIC标准结构允许的,因为梳状部分本身有延迟,不会影响最终功能。
5.3 量化误差和仿真结果对不齐的问题
有一回我把Verilog输出和Simulink输出对到小数点后,结果怎么都对不齐,差了一个固定偏移量。后来发现是Simulink的CIC Decimation模块里默认勾选了“Normalize Gain”,它自动把输出除以增益了;而我的Verilog是完全原始增益。把那个选项取消,或者两边都统一摆到归一化尺度,问题立刻消失。
还有一个容易忽略的点是输入数据类型。Simulink里如果信号源是double类型再接Quantizer,量化的舍入方式是四舍五入;而Verilog仿真里如果直接截位,就是向下取整。这两种舍入方式在正弦信号上的差异会导致波形幅值最大相差1个LSB,但不是系统性大误差。对比时接受1个LSB以内的差值是工程上的合理容差。
5.4 多级结构下差分延迟M理解错误
第三级梳状器如果写成“两个连续抽取点差值”,但M=2时就不是相邻抽取点差,而是相隔两个抽取点差。这个细小差别我一开始搞错了,导致输出响应和Matlab参考曲线差异非常明显。记住:差分延迟M代表的是梳状器延迟R*M个输入周期,也就是M个抽取周期。Verilog实现时,M=2需要两个延迟寄存器串起来,即延迟两个节拍再做差。
5.5 资源占用和时序收敛的小经验
虽然CIC没有乘法器,但它的关键路径是积分器的加法链。数据率高的时候,多级积分器串行加法可能会导致时序收敛困难。我的做法是给关键加法路径加流水寄存器:每级积分器输出打一拍再进入下一级。这会带来固定延迟,但换取的是更高时钟频率,实测下来用这种方法在200MHz时钟下跑3级CIC抽取64倍毫无压力。
6. 想进一步扩展这个设计的方向
做完基础的CIC抽取滤波,往后的路就很明显了。第一个方向是加补偿FIR滤波器,把CIC通带的droop拉平,这时候可以把补偿器系数用MATLAB设计好,转成Verilog乘法器结构,并跟原有的CIC输出级联。第二个方向是把抽取和插值都做出来,形成完整的采样率转换模块,可复用于变速调制解调器或音频接口设计。
第三个方向是尝试多通道并行CIC结构。当输入数据率极高时,单路逻辑跑不动,就需要用时分复用或并行多相分解的思路实现CIC结构,这是相对进阶的话题,但有了单路CIC的基础,理解会顺畅很多。
我在实际项目里把上面这套CIC抽取核直接接到了一个DDC前端,配合NCO混频器和后端FIR补偿滤波器,整个链路下来效果很稳。CIC这种结构适合“简单直接”地解决问题,但前提是前面把位宽、增益、时序这些基本功全部算清楚。希望这篇记录能帮你一步到位,少走我踩过的那几条弯路。