ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

FPGA实现AM解调:数字包络检波与CIC滤波器设计

2026/8/31 4:56:40 拓冰建站 浏览量
FPGA实现AM解调:数字包络检波与CIC滤波器设计 简介本资源面向FPGA初学者与通信工程实践者提供基于Verilog语言的AM调幅信号解调完整实现方案覆盖从信号生成、数字采样、同步检测到包络提取的核心流程适用于课程设计、综合实验及数字通信系统开发参考。压缩包共620个文件总计243.27MB包含149个Quartus编译数据库cdb、146个硬件描述备份hdb、134个Verilog源文件v以及仿真脚本do、测试激励vec、滤波器模块filter.v、AM信号发生器am_signal.v等关键代码辅以中文注释与配套MATLAB 2022A信号生成脚本m文件。已有616人学习下载资源附带详细仿真操作步骤说明并提供Windows Media Player可播放的操作录屏视频帮助用户快速复现ModelSim-Altera 6.5d与Quartus II 13.0 64位联合仿真流程显著降低FPGA数字通信实验门槛。 AM解调这东西放在FPGA上做说难不难但要把波形调干净里面全是细节。早年间调模拟解调电路中周电感、检波二极管、电容匹配随便一个温漂就让输出幅度变样后来转到FPGA上用Verilog做数字包络检波才真正体会到什么叫可复现、可调试、可参数化。这篇文章把我实现AM信号数字解调的完整过程整理出来包括模块怎么拆、代码怎么写、为什么这么写、Vivado里怎么跑仿真怎么上板抓波形最后还有几个我实际踩过、排查了大半天的坑。无论你是刚开始学FPGA还是已经在做无线通信接收链路这篇应该都能给你省点时间。1. 为什么在FPGA上用数字方式解调AM以及数字包络检波的数学依据1.1 从模拟检波到数字解调的转变AM调制是调幅信息完全承载在载波的幅度变化上。传统模拟收音机用二极管检波加RC低通滤波就能还原音频电路简单但问题也很明显二极管的正向导通压降会带来非线性失真小信号时检波效率低RC时间常数和调制信号带宽、载波频率之间必须精心配合温度变化还会让静态工作点漂移。这些在实验室搭电路时还算可控一旦涉及批量生产或者多通道一致性就很头疼。FPGA方案完全绕开了这些模拟器件的离散性问题。ADC采样进来的中频或射频信号先在数字域做幅度提取再用数字滤波器完成低通整个过程只有加法器、寄存器、少量乘法器一致性由时钟和逻辑保证。另一个好处是参数可调调制信号带宽变了改个抽取倍数就行载波频率变了重算滤波器参数就行不用换电容电阻。1.2 AM信号的数学形式与包络检波原理设载波频率为 fc调制信号为 m(t)为保证包络不失真需满足 A0 m(t) 0此时AM信号可以写为s(t) [A0 m(t)] · cos(2π·fc·t)幅度项 A0 m(t) 就是信号的包络m(t)是要恢复的调制信息。数字包络检波的核心就是把这个包络从载波中剥出来。包络检波在数字域实现有一个非常简单的近似路径先取绝对值再低通滤波。为什么取绝对值有效因为 s(t) 以0为中心上下对称摆动取绝对值后负半周翻到正半周高频载波分量被抬高但包络的低频趋势保留了下来接下来只要用低通滤波器把残余载波分量滤掉剩下的就是包络信号。另一种常见做法是平方律检波即 y(t) s²(t)。利用三角恒等式 cos²(θ) (1 cos(2θ))/2平方后会产生一个2倍载频分量和一个直流分量再用低通滤除2fc项也可以得到包络的平方。相比绝对值法平方律在低信噪比时调制度较深的情况下性能更好但缺点是平方运算会占用DSP乘法器输出信号幅度也与原信号成平方关系后续动态范围更难控制。我在这个项目里优先用绝对值法乘法器留给后面更复杂的处理解调链路本身保持足够的资源余量。1.3 为什么不用相干解调相干解调用本地载波和输入信号相乘再低通理论上能完美恢复调制信号且不存在包络检波的门限效应。但相干解调的前提是本地载波必须和发射端载波同频同相这意味着需要额外的载波恢复环路比如Costas环或者基于锁相环的载波同步。对AM这种携带完整载波分量的调制方式来说包络检波显然性价比更高——它不需要NCO、不需要环路滤波器、不需要考虑锁定时间。当然如果做的是DSB-SC或SSB那就必须上相干解调对应的是另一套完全不同的数字下变频架构。作为FPGA学习项目从包络检波入手是最稳妥的起点。2. 核心模块拆分从ADC采样到还原调制信号的数据流2.1 整体数据流与每级的作用按处理顺序整个解调链路可以拆成四个模块ADC接口模块完成外部ADC数据的同步采样、格式转换以及跨时钟域处理如果ADC时钟和FPGA逻辑时钟不同源。包络提取模块对12比特有符号数据做绝对值计算得到全正半波的包络信号。低通抽取模块用CIC滤波器滤除残留载波同时降低数据率减少后端处理压力。去直流模块CIC输出包含直流偏置这个直流对应AM信号中的A0项真正想要的调制信号m(t)是围绕在直流偏置上下的变化量所以需要把直流去掉。这个链路非常经典任何一个AM数字接收机几乎都能看到这个影子。数据流的方向是单向的没有反馈环路所以调试时可以一级一级看波形非常直观。2.2 为什么选CIC滤波器而不是FIR按常规思维低通滤波首先想到FIR。FIR性能好、线性相位但系数数量取决于过渡带宽度当载波频率远高于调制频率时归一化过渡带特别窄FIR阶数会跑到上百阶。以采样率40MHz、载波4.5MHz、调制信号1kHz为例如果想把4.5MHz滤掉而保留1kHz过渡带宽度相对采样率只有不到0.09这个指标用FIR至少要256阶甚至512阶每个时钟周期都要做这么多次乘加运算DSP48资源消耗非常可观。CIC滤波器则完全避开了乘法器只用加法器和延迟寄存器。它的传递函数在频域上是一个“梳状积分”的组合通过多级级联可以形成陡峭的低通特性。CIC还有一个天然优势可以和抽取器结合在积分之后、梳状之前做抽取这样梳状部分工作在较低的数据率上功耗和资源都更优。对AM解调这种抽取比适中的应用CIC是三到五级就足够。当然CIC也有代价通带内幅度响应不是平的会有下垂。不过这可以通过后级补偿滤波器修正对AM语音信号来说这点下垂对可懂度影响很小前期可以完全忽略。2.3 CIC参数计算与位宽扩展公式以实际系统为例ADC采样率 fs 40MHz中频载波 fc 4.5MHz调制信号最大频率 fmax 1kHz。CIC抽取倍数R的选择要满足两个条件第一抽取后的数据率2×fmax必须仍然大于奈奎斯特频率也就是 R 不能太大第二抽取后CIC的零点要落在载波频率附近才能有效抑制载波。CIC第一旁瓣抑制效果在频率达到 fs/(2R) 以后变得非常强所以我取 R32抽取后的数据率为40MHz/321.25MHz仍远大于2kHz调制带宽而CIC的第一个零点大约在 fs/R 1.25MHz对4.5MHz处已有很强的衰减。配合三级级联4.5MHz处的抑制可以达到数十分贝以上。CIC内部寄存器位宽不能按输入位宽来否则每级积分运算都会溢出。标准公式是Bmax Bin N · ceil(log2(R·M))其中 Bin 是输入位宽N 是CIC级数M 是差分延迟通常取1。本设计中 Bin12N3R32所以Bmax 12 3 × ceil(log2(32)) 12 15 27也就是说CIC内部所有积分器和梳状器的位宽都要开到27比特。这点非常关键少一位都不行。我在第5节的坑里会讲当初为省资源把位宽设成24比特结果输出波形在峰值处直接“平顶”失真。2.4 去直流的必要性包络检波和CIC滤波后输出信号近似为y[n] ≈ A0 m[n]其中A0是直流偏置数值上接近载波幅度。如果我们直接把这段数据送给DAC或者后级处理得到的只有直流电平上的微小波动。对于8位的调制信号m[n]的动态范围可能只占几十个LSB而直流偏置占了几千个LSB这样的信号既不好观察也不好送DAC。去直流模块的作用就是把A0这个常数项减掉让输出动态范围完全留给m[n]。3. 完整Verilog代码逐段解析绝对值模块、CIC滤波器、去直流模块3.1 顶层模块参数化设计是必须的写FPGA模块我习惯一开始就把参数化做好不然后面换参数要改四处代码。顶层参数有三个ADC数据位宽、CIC抽取倍数、CIC级数。数据位宽由ADC决定抽取倍数和级数是解调性能的关键旋钮。module am_demod #( parameter DATA_WIDTH 12, parameter DECIMATION 32, parameter CIC_STAGES 3 )( input wire clk, input wire rst_n, input wire signed [DATA_WIDTH-1:0] adc_data, output wire signed [15:0] demod_out, output wire demod_valid ); // 包络提取后的绝对值信号无符号 wire [DATA_WIDTH-1:0] abs_data; // CIC内部扩展后的位宽 localparam CIC_WIDTH DATA_WIDTH CIC_STAGES * $clog2(DECIMATION); // CIC输出 wire signed [CIC_WIDTH-1:0] cic_data; wire cic_valid; // 连接模块 abs_value #( .DATA_WIDTH(DATA_WIDTH) ) u_abs ( .data_in (adc_data), .abs_out (abs_data) ); cic_dec #( .DATA_WIDTH(DATA_WIDTH), .CIC_WIDTH (CIC_WIDTH), .DECIMATION(DECIMATION), .STAGES (CIC_STAGES) ) u_cic ( .clk (clk), .rst_n (rst_n), .data_in (abs_data), .data_out (cic_data), .data_valid(cic_valid) ); // CIC输出位宽27位截取高16位送给去直流模块 wire signed [15:0] cic_trim cic_data[26:11]; dc_blocker #( .DATA_WIDTH(16) ) u_dc ( .clk (clk), .rst_n (rst_n), .data_in (cic_trim), .data_out(demod_out) ); assign demod_valid cic_valid; endmodule注意abs_data是无符号12比特但CIC的输入应该是有符号处理所以CIC模块内部会对输入做符号扩展后参与积分。3.2 绝对值模块补码最小值处理是细节中的魔鬼绝对值看起来一句话就能写完但补码边界情况很容易忽略。对12比特有符号数取值范围是 -2048 到 2047当输入为 -2048 时取反加一的结果仍然是 -2048因为 2048 已经超出12比特能表示的正数范围。如果不做饱和处理这个点输出就会跳变产生毛刺。我的处理方式是在取反后检测是否落入最小值是则饱和到最大正数。module abs_value #( parameter DATA_WIDTH 12 )( input wire signed [DATA_WIDTH-1:0] data_in, output reg [DATA_WIDTH-1:0] abs_out ); wire [DATA_WIDTH-1:0] data_abs data_in[DATA_WIDTH-1] ? (~data_in 1b1) : data_in; always (*) begin if (data_in {1b1, {(DATA_WIDTH-1){1b0}}}) abs_out {1b0, {(DATA_WIDTH-1){1b1}}}; else abs_out data_abs; end endmodule这段代码里用了两个分支判断是否等于-2^(WIDTH-1)如果是就输出2^(WIDTH-1)-1。综合后只是一个比较器加一个二选一资源开销非常小但能避免一个很隐蔽的偶发毛刺。3.3 CIC滤波器三级积分器加三级梳状差分器CIC的结构分为积分器和梳状器两部分。积分器在原始采样率下每个时钟都做累加公式上是离散时间积分器对应频率响应的极点在直流附近梳状器在抽取后的低速率下做差分对应频率响应的零点均匀分布在低频段。级联级数越多过渡带越陡。module cic_dec #( parameter DATA_WIDTH 12, parameter CIC_WIDTH 27, parameter DECIMATION 32, parameter STAGES 3 )( input wire clk, input wire rst_n, input wire [DATA_WIDTH-1:0] data_in, output reg signed [CIC_WIDTH-1:0] data_out, output reg data_valid ); integer i; reg signed [CIC_WIDTH-1:0] integ [0:STAGES-1]; reg signed [CIC_WIDTH-1:0] input_ext; reg [5:0] dec_cnt; reg din_valid; // 符号扩展无符号数据从绝对值模块进来按有符号处理时高位补零 always (*) begin input_ext {{(CIC_WIDTH-DATA_WIDTH){1b0}}, data_in}; end // 积分器每个时钟累加 always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i 0; i STAGES; i i 1) integ[i] d0; end else begin integ[0] integ[0] input_ext; for (i 1; i STAGES; i i 1) integ[i] integ[i] integ[i-1]; end end // 抽取计数器 always (posedge clk or negedge rst_n) begin if (!rst_n) begin dec_cnt 0; din_valid 1b0; end else if (dec_cnt DECIMATION - 1) begin dec_cnt 0; din_valid 1b1; end else begin dec_cnt dec_cnt 1b1; din_valid 1b0; end end // 抽样保持抽取时刻锁存积分器最后一级输出 reg signed [CIC_WIDTH-1:0] sample_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) sample_reg d0; else if (din_valid) sample_reg integ[STAGES-1]; end // 梳状差分链三级 reg signed [CIC_WIDTH-1:0] x_d, diff0_d, diff1_d; wire signed [CIC_WIDTH-1:0] diff0 sample_reg - x_d; wire signed [CIC_WIDTH-1:0] diff1 diff0 - diff0_d; wire signed [CIC_WIDTH-1:0] diff2 diff1 - diff1_d; always (posedge clk or negedge rst_n) begin if (!rst_n) begin x_d d0; diff0_d d0; diff1_d d0; end else if (din_valid) begin x_d sample_reg; diff0_d diff0; diff1_d diff1; end end // 输出寄存与valid对齐 always (posedge clk or negedge rst_n) begin if (!rst_n) begin data_out d0; data_valid 1b0; end else if (din_valid) begin data_out diff2; data_valid 1b1; end else begin data_valid 1b0; end end endmodule代码里的差分链是三个连续的减法器。有人可能会问为什么不直接写成sample_reg - 2*sample_prev sample_prev2这种形式而是要用三级流水原因是CIC的梳状器在概念上是多级串联每一级都只和它前面一级的输出做差写成流水结构更符合Hogenauer原始结构时序上也更好收敛。三级差分链每个阶段都有寄存器打拍组合逻辑路径很短不会成为关键路径。3.4 去直流模块一阶IIR高通去直流可以用一阶IIR高通公式为y[n] x[n] − x[n−1] α · y[n−1]α越接近1截止频率越低滤除直流越彻底。但在定点实现里α是小数直接乘会引入浮点。工程上常用移位近似令 α 1 − 2^(-SHIFT)例如 SHIFT6 时 α0.984375。这样乘法可以转换为一次减法加一次移位运算。module dc_blocker #( parameter DATA_WIDTH 16 )( input wire clk, input wire rst_n, input wire signed [DATA_WIDTH-1:0] data_in, output wire signed [DATA_WIDTH-1:0] data_out ); localparam SHIFT 6; localparam ALPHA (1 SHIFT) - 1; // 0.984375 的定点表示 localparam ACC_WIDTH DATA_WIDTH 4; // 留一些内部余量 reg signed [ACC_WIDTH-1:0] x_prev; reg signed [ACC_WIDTH-1:0] y_d; wire signed [ACC_WIDTH-1:0] diff data_in - x_prev; wire signed [ACC_WIDTH-1:0] y_next diff ((y_d * ALPHA) SHIFT); always (posedge clk or negedge rst_n) begin if (!rst_n) begin x_prev d0; y_d d0; end else begin x_prev data_in; y_d y_next; end end assign data_out y_d[ACC_WIDTH-1 -: DATA_WIDTH]; endmodule这个模块输出位宽仍是16比特。y_d * ALPHA在综合时会调用一个DSP48不过位宽只有 20×6一个乘法器就够了。如果你所在平台DSP资源极度紧张也可以把ALPHA取成 1 - 1/64这样乘法可以变成减法加右移但精度损失也不大。这个权衡在资源受限的设计里值得考虑。3.5 Testbench在仿真里制造一个标准的AM输入信号仿真验证需要产生一个AM调制信号。最直接的方法是用两个相位累加器一个按载波频率累加一个按调制信号频率累加然后计算 s (A0 m·sin(θm))·cos(θc)再转成12比特有符号整数。下面这段测试平台可以直接用timescale 1ns / 1ps module tb_am_demod; reg clk 0; reg rst_n 0; reg signed [11:0] adc_data 0; wire signed [15:0] demod_out; wire demod_valid; am_demod #( .DATA_WIDTH(12), .DECIMATION(32), .CIC_STAGES(3) ) dut ( .clk (clk), .rst_n (rst_n), .adc_data (adc_data), .demod_out (demod_out), .demod_valid (demod_valid) ); // 40MHz时钟 always #12.5 clk ~clk; // AM信号参数 real phase_c 0.0; real phase_m 0.0; real A0 1.0; real m 0.5; // 调制度50% real s_real; always (posedge clk) begin phase_c phase_c 2.0 * $pi * 4.5e6 / 40e6; phase_m phase_m 2.0 * $pi * 1.0e3 / 40e6; s_real (A0 m * $sin(phase_m)) * $cos(phase_c); adc_data $rtoi(s_real * 2047.0); end initial begin rst_n 0; #100; rst_n 1; #500000; $finish; end initial begin $dumpfile(am_demod.vcd); $dumpvars(0, tb_am_demod); end endmodule注意如果使用Vivado仿真可以直接在仿真波形窗口里观察内部信号不需要$dumpfile如果你用iverilog这个命令就派上用场了。另外adc_data使用非阻塞赋值确保它总是和时钟沿对齐更接近真实ADC的时序行为。4. 从Vivado建工程到仿真和上板验证的完整操作链路4.1 工程创建与文件组织在Vivado里新建工程器件选择和你手头开发板一致的型号。文件组织建议建三个目录src放RTL源码sim放testbenchconstr放约束文件。这样项目大了以后不会乱。具体操作File - Project - New Project填工程名和路径。选择RTL Project勾选Do not specify sources at this time后面手动添加。添加源文件把abs_value.v、cic_dec.v、dc_blocker.v、am_demod.v四个文件加入Design Sources。添加仿真文件把tb_am_demod.v加入Simulation Sources。可以先做行为仿真验证功能再添加约束上板。如果你对模块接口比较熟悉也可以在顶层用Block Design方式连接但对于这种纯verilog项目直接手写顶层更高效。4.2 行为仿真从零到看懂波形行为仿真不需要任何硬件是验证算法正确性的第一步。在Flow Navigator里选择SIMULATION - Simulation Settings设置仿真时间比如500000 ns也就是0.5毫秒。按40MHz采样率相当于20000个采样点足够观察几十个调制信号周期。点击Run Behavioral SimulationVivado会启动仿真并打开波形窗口。把需要看的信号添加到波形窗口adc_data输入、abs_data绝对值后、cic_dataCIC滤波后、demod_out去直流后。观察demod_out是否是一个和调制信号同频同形的正弦波。如果波形幅度稳定、频率接近1kHz说明解调链路工作正常。仿真时要注意CIC输出有一个固定的流水延迟。CIC三级积分加三级梳状再加上抽样保持和输出寄存整体延迟大约是几个到十几个输入采样周期对应的时间只有几百纳秒在示波器时间尺度上可以忽略但如果你用严格的数据对齐逻辑就必须把这个延迟算进去。4.3 上板验证ILA在线调试抓取波形行为仿真通过后进入上板验证。这时需要解决两个问题一是ADC数据怎么接入二是怎么观察解调结果。ADC接口要根据你板卡的具体型号来写。多数开发板的ADC是并行接口输出12位或14位有符号数据同时给一个采样时钟。如果你的ADC时钟和FPGA主时钟是同源但相位不确定建议先用IDELAY或者简单的FIFO做一下跨时钟域处理。最稳妥的方法是用ADC输出的DCO时钟来采样ADC数据然后在FPGA内部再用主时钟打两拍同步。ILAIntegrated Logic Analyzer是Xilinx FPGA内置的逻辑分析仪IP通过JTAG把内部信号送到Vivado Hardware Manager实时显示。添加ILA的步骤在顶层模块中例化ILA IP配置数据宽度和采样深度。可以把adc_data、abs_data、cic_data[26:11]、demod_out都接到ILA探针上。设置触发条件比如demod_valid上升沿触发或者adc_data[11]变化触发。综合、实现、生成比特流下载到开发板。打开Hardware Manager选择设备运行ILA点击触发按钮观察抓到的波形。上板验证中典型的波形是demod_out呈现波浪形和仿真结果一致。如果出现噪声很大或者波形消失优先检查ADC输入是否超过满量程以及CIC内部是否溢出。4.4 约束文件时钟、引脚、以及容易被忽略的时序例外约束文件写的不好板子可能根本不工作。至少需要三部分约束创建时钟create_clock -period 25.0 [get_ports clk]如果你的主时钟是40MHz周期就是25ns。引脚约束set_property PACKAGE_PIN xxx [get_ports clk]ADC数据、复位、时钟都要绑定到板卡对应的物理引脚。时序例外CIC这类高抽取率结构内部不是每拍都有数据变化data_valid是脉冲使能信号但综合工具默认按每个时钟都活跃来约束如果你的设计在时序上紧张可以为使能信号路径添加set_case_analysis或者多周期路径约束。新手阶段可能遇不到这个问题但项目跑在更高主频时要注意。5. 我在调试中踩过的四个坑照着排查能省一周时间5.1 坑一补码取绝对值把 -2048 处理成自己第一次做绝对值模块时我想当然地写了一句abs_out data_in[WIDTH-1] ? (~data_in 1) : data_in;仿真看大信号没问题但后来把调制度加深到90%在信号负峰位置偶尔出现一个毛刺。排查了很久最后发现是输入刚好等于-2048时取反加一的结果还是-2048。这个点在二补码里没有对应的正数所以回绕了。解决办法就是我前面代码里写的饱和判断。这个坑在纯仿真时不容易发现因为正弦波经过ADC后很少恰好踩中-2048但实际噪声信号是随机的早晚会遇到。处理完以后把输入固定为-2048跑一次仿真确认输出恒定为2047这个模块就稳了。5.2 坑二CIC位宽没留够输出波形顶部削平有一次为了省几个寄存器我把CIC内部位宽从理论值27位降到24位仿真时解调出来的波形看起来也还能看只是感觉幅度略微偏小。后来对比不同调制度的输入发现调制度超过60%时输出波形顶部明显削平。原因不复杂CIC的增益和抽取倍数、级数直接相关三级32倍抽取的峰值增益高达32768倍12位输入经过三级积分累加后中间节点绝对值轻松超过24位。位宽截断不只是损失精度而是直接把数据截没了表现为输出饱和削波。建议严格按照 Bmax Bin N·ceil(log2(R·M)) 计算不要在这个地方节省寄存器。如果输出位宽太大可以在CIC输出后再截位比如只取高16位这是安全的因为此时数据已经完成滤波动态范围可以按信号实际幅度来定。5.3 坑三低通带宽过窄或过宽包络都救不回来CIC的抽取倍数R决定了低通等效带宽和第一零点位置。如果R取得过大抽取后数据率太低会把调制信号高频分量也滤掉恢复出来的波形变得圆润失真像蒙了一层纱布如果R取得太小残余载波没有滤干净解调输出上会叠着明显的高频纹波。我做对比实验时发现R16时4.5MHz衰减不够充分输出波形上还能看到载波本文还有配套的精品资源点击获取