FPGA+Verilog实现AM信号解调:从原理到上板调试全解析
简介:本资源面向FPGA开发初学者与通信工程实践者,提供一套完整的AM调幅信号数字解调Verilog实现方案,覆盖从MATLAB建模、Quartus II综合到ModelSim仿真验证的全流程。压缩包共620个文件,约243.27MB,包含149个Quartus编译数据库(cdb)、146个硬件描述数据库(hdb)、134个Verilog源文件(v)及20个VHDL封装文件(vhd),支撑AM解调核心模块(如同步采样、包络检波、低通滤波)的RTL级设计与功能验证;另有大量日志、报告、备份与脚本文件保障工程可复现性。资源已获616人学习下载,配套中文注释详尽、操作步骤清晰,并附带Windows Media Player可播放的仿真操作录屏视频,帮助读者快速理解AM信号在FPGA上的数字化解调原理与实现细节,特别适合课程设计、毕业设计及通信系统FPGA实践项目参考。 做FPGA无线通信方向的人,迟早会遇到一个经典题目:AM信号解调。我当年第一次把这套逻辑在板子上用示波器拉出完整波形时,最大感受是——它看似是通信书上的老知识,实际落地到Verilog里,处处是位宽、时钟、滤波这些硬功夫。这篇文章就把我基于FPGA+Verilog实现AM信号解调的完整过程整理出来,包含可直接参考的程序、逐行注释、仿真方法和上板操作步骤,适合正在入门FPGA数字信号处理,或者想在无线通信方向上做综合练习的同学。全文覆盖原理推导、参数计算、代码实现、调试排查四个方面,按顺序跟一遍基本能复现。
1. 项目整体设计思路:先搞清楚信号长什么样再写代码
1.1 AM信号的本质与两种解调路线
AM调制的数学表达式是:
s(t) = A_c * (1 + m * x(t)) * cos(2 * pi * f_c * t)其中A_c是载波幅度,m是调制指数(0到1之间),x(t)是音频或基带信号,f_c是载波频率。整条信号的特点就是:载波的幅度包络跟随基带信号变化。所以解调的本质很直接——把“包络”提取出来,再把直流分量去掉,就得到原始信号。
回到FPGA实现上,常见路线有两条:
- 相干解调:本地产生一个同频同相载波,用乘法器把载波搬回基带,再低通滤波。优点是理论上信噪比更好,但难点在于必须做载波同步,需要锁相环或者额外同步算法,工程复杂度明显上升。
- 非相干解调:直接提取包络,常用方法包括绝对值+低通、平方+低通+开方,或者直接过峰值检测。实现简单,不需要精确同步,适合广播接收这类对成本敏感的场合。
我做这个项目用的是绝对值+低通滤波的非相干方案。选它的原因很现实:AM本身在很多场景下对解调信噪比要求不高,而FPGA里做绝对值只要取符号位异或再翻转,资源开销几乎为零,后续低通用滑动平均或者CIC滤波器就能解决,整个数据通道不需要乘法器,逻辑资源占用极少,非常适合作为入门练习。
1.2 系统参数规划:采样率、载波频率和滤波器截止频率怎么定
参数选择直接决定代码写起来顺不顺手。我用了一个比较经典的组合:
- 载波频率
f_c = 100kHz - 采样率
f_s = 1MHz - 调制信号频率
f_audio = 1kHz - 调制指数
m = 0.5 - 解调输出速率约
62.5kHz,满足1kHz音频的奈奎斯特条件
采样率选1MHz的原因是:载波每周期刚好10个采样点,波形还原度足够,同时1MHz时钟在FPGA里非常容易生成,无论是PLL分频还是直接外部晶振都能满足。更关键的是,1MHz对应100kHz载波,带外镜像落在900kHz以上,后期滤波压力小。
低通滤波器截止频率的选择可以这样估算:AM信号经绝对值处理后,频谱包含基带分量(0到f_audio)、二倍载波分量(2*f_c附近)以及它们的组合分量。我的目标是保留1kHz调制信号,同时尽量抑制200kHz以上的高频分量。滑动平均滤波器(本质是梳状滤波器)的零点位置由窗口长度决定,窗口长度N = 16时,第一零点在1MHz/16 = 62.5kHz,虽然不能完全滤掉200kHz分量,但配合后端CIC抽取,衰减量已经完全够用。
这里有一个工程经验:不要试图在第一级就把所有高频分量滤干净,滑动平均的带外衰减只有约20*log10(N),大概是24dB,如果觉得不够,再加一级CIC或者直接提高抽取倍数。我实测下来,16点滑动平均再加16倍抽取,输出波形人眼看不出失真,示波器上看谐波分量也在可接受范围内。
1.3 模块划分与信号链走向
整个解调链路在FPGA内部按照数据流方向划分成四个模块:
ADC输入(1MHz采样) -> 绝对值检波 -> 16点滑动平均 -> 16倍抽取 -> 解调输出这样的划分有几个好处:
- 每个模块功能单一,便于仿真定位问题。
- 滑动平均和抽取可以合并成CIC结构,节省一个独立FIFO。
- 数据位宽逐级明确,方便做时序收敛。
我在顶层用了两组时钟:系统时钟1MHz直接驱动所有逻辑,输出端通过时钟使能信号控制62.5kHz的采样节奏,而不是真的切时钟域。这样避免多时钟域带来的亚稳态问题,代码写起来也简单。
2. 核心Verilog模块实现:代码、注释与设计细节
2.1 绝对值检波模块:三行代码解决的事别整复杂
绝对值检波的核心思想是:AM信号是双极性的,取绝对值后,载波的负半周翻到正半周,这样输出信号的直流分量就包含了载波幅度信息,而包络变化就叠加在这个直流分量上。数学上,|cos(w_c*t)|的平均值是一个常数2/pi,所以输出中始终包含一个与载波幅度成正比的直流项,后面用隔直或者减去均值就能把基带信号恢复出来。
代码如下:
module abs_detector #( parameter DATA_WIDTH = 16 )( input wire clk, input wire rst_n, input wire signed [DATA_WIDTH-1:0] data_in, output reg signed [DATA_WIDTH-1:0] data_out ); // 取绝对值:判断符号位,为1则取反加1 always @(posedge clk or negedge rst_n) begin if (!rst_n) data_out <= {DATA_WIDTH{1'b0}}; else if (data_in[DATA_WIDTH-1]) data_out <= ~data_in + 1'b1; else data_out <= data_in; end endmodule这段代码注意两点:
data_in必须声明为signed,否则data_in[DATA_WIDTH-1]判断符号位时虽然能工作,但取反加一的数值语义会出错。- 输出位宽保持和输入一致即可,不需要扩展位宽,因为绝对值不会超过输入范围。
2.2 滑动平均低通滤波:累加器位宽是第一道坎
滑动平均是FIR滤波器的一种特例,所有系数都是1,输出等于最近N个输入的平均值。它的传递函数是:
H(z) = (1/N) * (1 - z^(-N)) / (1 - z^(-1))在FPGA里实现时,最常用的方式是“累加器+减法”,而不是真的存N个点求和。核心思想是:维护一个累加器,每个时钟周期把新进来样点加进去,把N个周期前的样点减掉,这样累加器始终保存着最近N个点的和。这种结构对资源非常友好,复杂度跟N无关。
module moving_average #( parameter DATA_WIDTH = 16, parameter AVG_LEN = 16 )( input wire clk, input wire rst_n, input wire signed [DATA_WIDTH-1:0] data_in, input wire data_valid_in, output reg signed [DATA_WIDTH-1:0] data_out, output reg data_valid_out ); localparam ACC_WIDTH = DATA_WIDTH + $clog2(AVG_LEN); reg signed [ACC_WIDTH-1:0] acc; reg signed [DATA_WIDTH-1:0] shift_reg [0:AVG_LEN-1]; reg [$clog2(AVG_LEN)-1:0] wr_ptr; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin acc <= {ACC_WIDTH{1'b0}}; data_out <= {DATA_WIDTH{1'b0}}; data_valid_out <= 1'b0; wr_ptr <= 0; end else if (data_valid_in) begin // 加上新值 acc <= acc + data_in; // 减去最早的值 acc <= acc - shift_reg[wr_ptr]; acc <= acc + data_in - shift_reg[wr_ptr]; shift_reg[wr_ptr] <= data_in; wr_ptr <= wr_ptr + 1'b1; data_out <= acc[ACC_WIDTH-1:DATA_WIDTH-1] + acc[DATA_WIDTH-2]; // 输出均值,带舍入 data_valid_out <= 1'b1; end else begin data_valid_out <= 1'b0; end end endmodule这里有几个容易踩的坑:
- 累加器位宽:
ACC_WIDTH = DATA_WIDTH + ceil(log2(AVG_LEN)),因为N个数求和最大值是N * (2^(DATA_WIDTH-1)-1),需要额外位存放进位。我习惯用参数化方式写,防止改N时位宽忘了调。 - 移位寄存器指针:用环形指针写入,每来一个有效数据写一个位置,覆盖掉N周期前的数据。不要用
for循环在每个周期把整个寄存器搬移一遍,那样会消耗大量寄存器。 - 输出舍入:我写的代码里做了最简单的四舍五入,
acc[DATA_WIDTH-2]是舍弃部分的最高位,加进去实现舍入。如果要求不高可以直接截断,但波形在过零点会有轻微台阶感。
2.3 CIC抽取模块:用积分器+梳状器实现高速率转换
滑动平均的本质就是一阶CIC滤波器。如果需要进一步降低数据率、减轻后端处理负担,可以直接把CIC抽取单独做成一个模块。经典CIC结构是积分器(Integrator)级联、抽取器、梳状器(Comb)级联,传递函数为:
H(z) = ((1 - z^(-D*M)) / (1 - z^(-1)))^N其中D是微分延迟(通常取1或2),M是抽取倍数,N是级数。一阶CIC的频响就是滑动平均,多级可以提高阻带衰减。
我工程里用的是一阶CIC抽取,抽取倍数M=16。输入1MHz,输出62.5kHz。实现上就是上面滑动平均模块加一个采样使能计数器:
module cic_decimate #( parameter DATA_WIDTH = 16, parameter DECIMATION = 16 )( input wire clk, input wire rst_n, input wire signed [DATA_WIDTH-1:0] data_in, input wire data_valid_in, output reg signed [DATA_WIDTH-1:0] data_out, output reg data_valid_out ); localparam ACC_WIDTH = DATA_WIDTH + $clog2(DECIMATION); reg signed [ACC_WIDTH-1:0] integrator; reg signed [ACC_WIDTH-1:0] comb_reg; reg [$clog2(DECIMATION)-1:0] cnt; reg valid_comb; // 积分器 always @(posedge clk or negedge rst_n) begin if (!rst_n) integrator <= {ACC_WIDTH{1'b0}}; else if (data_valid_in) integrator <= integrator + data_in; end // 梳状器与抽取 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin comb_reg <= {ACC_WIDTH{1'b0}}; cnt <= 0; data_out <= {DATA_WIDTH{1'b0}}; data_valid_out <= 1'b0; end else if (data_valid_in) begin if (cnt == DECIMATION - 1) begin cnt <= 0; data_out <= integrator - comb_reg; comb_reg <= integrator; data_valid_out <= 1'b1; end else begin cnt <= cnt + 1'b1; data_valid_out <= 1'b0; end end end endmodule这个模块的典型问题是位增长:积分器里数据不断累加,位宽不够会溢出。解决方法是直接用全位宽累加,不做截断,最后输出时再截取高位。对于一阶CIC,位增长上限是DATA_WIDTH + ceil(log2(M)),这对M=16来说完全够用。
2.4 顶层模块整合:把数据流串起来
顶层模块把上述子模块串成完整信号链。我用一个使能信号en_1mhz表示ADC采样有效,数据进来后依次经过绝对值、滑动平均、CIC抽取,最后输出demod_out。为了调试方便,额外输出一个demod_valid,方便示波器或逻辑分析仪对齐。
module am_demod_top #( parameter DATA_WIDTH = 16 )( input wire clk_1mhz, input wire rst_n, input wire signed [DATA_WIDTH-1:0] adc_data, input wire adc_valid, output reg signed [DATA_WIDTH-1:0] demod_out, output reg demod_valid ); wire signed [DATA_WIDTH-1:0] abs_out; wire signed [DATA_WIDTH-1:0] avg_out; wire signed [DATA_WIDTH-1:0] dec_out; wire avg_valid, dec_valid; abs_detector #(.DATA_WIDTH(DATA_WIDTH)) u_abs ( .clk(clk_1mhz), .rst_n(rst_n), .data_in(adc_data), .data_out(abs_out) ); moving_average #( .DATA_WIDTH(DATA_WIDTH), .AVG_LEN(16) ) u_avg ( .clk(clk_1mhz), .rst_n(rst_n), .data_in(abs_out), .data_valid_in(adc_valid), .data_out(avg_out), .data_valid_out(avg_valid) ); cic_decimate #( .DATA_WIDTH(DATA_WIDTH), .DECIMATION(16) ) u_dec ( .clk(clk_1mhz), .rst_n(rst_n), .data_in(avg_out), .data_valid_in(avg_valid), .data_out(dec_out), .data_valid_out(dec_valid) ); always @(posedge clk_1mhz or negedge rst_n) begin if (!rst_n) begin demod_out <= {DATA_WIDTH{1'b0}}; demod_valid <= 1'b0; end else begin demod_out <= dec_out; demod_valid <= dec_valid; end end endmodule这样顶层代码非常干净,每个子模块都可以独立仿真。adc_valid如果在你的系统里是持续为高的,直接拉高就行,只要采样率确实是1MHz。
3. 仿真验证与板上调试完整流程
3.1 写一个能生成AM信号的Testbench
没有仿真直接上板,等于闭着眼睛调电路。Testbench的核心任务是模拟出符合公式的AM信号,然后喂给被测模块,同时把中间节点导出来看波形。
我用一个简单的频率累加器生成载波,然后用乘法器调制,具体代码:
`timescale 1ns / 1ps module tb_am_demod; parameter DATA_WIDTH = 16; parameter CLK_PERIOD = 1000; // 1MHz -> 1000ns reg clk; reg rst_n; reg signed [DATA_WIDTH-1:0] adc_data; reg adc_valid; wire signed [DATA_WIDTH-1:0] demod_out; wire demod_valid; // DUT am_demod_top #(.DATA_WIDTH(DATA_WIDTH)) dut ( .clk_1mhz(clk), .rst_n(rst_n), .adc_data(adc_data), .adc_valid(adc_valid), .demod_out(demod_out), .demod_valid(demod_valid) ); // 载波生成参数 localparam FC_WORD = 100000 * 65536 / 1000000; // 载波100kHz,用16位累加器 localparam FM_WORD = 1000 * 65536 / 1000000; // 调制信号1kHz reg [15:0] carrier_phase; reg [15:0] audio_phase; wire signed [15:0] carrier = $signed({1'b0, carrier_phase[15:1]}) - 16384; wire signed [15:0] audio = $signed({1'b0, audio_phase[15:1]}) - 16384; always #(CLK_PERIOD/2) clk = ~clk; initial begin clk = 0; rst_n = 0; adc_data = 0; adc_valid = 0; carrier_phase = 0; audio_phase = 0; #(CLK_PERIOD*10); rst_n = 1; #(CLK_PERIOD*2); adc_valid = 1; #(CLK_PERIOD*50000); $finish; end always @(posedge clk) begin carrier_phase <= carrier_phase + FC_WORD; audio_phase <= audio_phase + FM_WORD; // AM调制:载波幅度 = A*(1 + m*audio) adc_data <= $signed(carrier) * (16384 + $signed({{8{audio[15]}}, audio[15:8]}) * 0) >>> 15; end endmodule注意:实际AM乘法可以用更简单的方式,比如16位载波乘以(常量+小比例音频)。上面代码里我保留了一个0系数,换成你需要的调制指数即可。重点是把audio按1:20的幅值叠加到载波幅度上,调制指数0.5相当于音频最大时载波幅度在原基础上下浮动50%。
3.2 ModelSim/Vivado仿真操作步骤
我用的是Vivado自带的仿真器,操作步骤如下:
- 新建工程,添加所有源文件
abs_detector.v、moving_average.v、cic_decimate.v、am_demod_top.v。 - 添加Testbench文件
tb_am_demod.v,设为仿真顶层。 - 点击
Run Simulation -> Run Behavioral Simulation。 - 在波形窗口添加信号:
dut.abs_out、dut.avg_out、dut.dec_out以及demod_out。 - 运行时间至少设置10ms以上,否则可能看不到完整几个周期的调制信号(1kHz周期是1ms)。
仿真结果应该看到:abs_out是一个频率为200kHz的整流波形,avg_out波形变平滑,dec_out是最终包络,形状与1kHz音频一致。如果发现avg_out平滑度不够,调大AVG_LEN或增加CIC级数。
如果用的是ModelSim/Questa,流程类似,只是需要手动编译库和设置vsim -L unisims_ver之类,Vivado直接一键运行更方便。我自己平时更喜欢Vivado的仿真器,对AXI总线支持也好,调试效率明显高。
3.3 上板调试:信号源、示波器还有ILA的实际操作
仿真通过后,上板调试是真正考验耐心的时候。我用的是Xilinx Artix-7系列(型号XC7A35T),开发板自带ADC的采样率2MS/s,我把它固定配置成1MS/s,和代码参数对齐。
具体步骤如下:
- 用信号发生器产生AM信号:载波100kHz,调制信号1kHz,调制深度30%。注意信号发生器的输出阻抗设为50欧姆,幅度控制在ADC满量程的70%左右,避免削顶失真。
- 把AM信号连接到ADC输入引脚。如果开发板上有SMA接口,用SMA线直连最稳,杜邦线在100kHz下虽然能工作,但容易引入噪声。
- 综合布局布线,生成bitstream并下载。
- 打开Vivado硬件管理器,添加ILA核。ILA的探针设置为顶层
demod_out(16位)和demod_valid(1位),采样深度4096。 - 触发方式设为
demod_valid上升沿,跑一次采集。 - 用ILA看到的波形应该是一个缓慢变化的包络。如果波形很平,检查ADC数据是否有有效位;如果波形噪声大,检查电源纹波和ADC参考电压。
我之前踩过的最大坑是:ADC数据位序反了。第一次上板时ILA里看到的输出是一个毛刺信号,后来发现ADC输出是MSB在前,而我的顶层代码里按LSB在前解析,导致数据完全错乱。Debug方法很简单:在ILA里看原始ADC波形,如果不加输入信号时低噪声看起来很“陡”,说明位序很可能反了,调一下即可。
4. 常见问题排查与实操经验
4.1 解调常见问题速查表
下面这几类问题是我在项目里真实遇到过的,整理成表格方便按图索骥。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 输出全是直流,没有交流包络 | 绝对值模块输入输出位宽不匹配;ADC没有真正采集到数据 | 先用ILA抓ADC原始波形,确认输入信号存在;再用仿真验证abs模块逻辑 |
| 输出波形有高频毛刺 | 低通截止频率太高,或者抽取前没滤波 | 增大滑动平均窗口长度到32或64;检查CIC抽取时序 |
| 解调波形幅度很小 | 调制指数设置过低,或输入信号幅度没有达到ADC满量程 | 增大信号发生器输出幅度到满量程的70%;检查ADC配置寄存器 |
| 输出波形有明显台阶 | 滑动平均输出截断过狠,或抽取计数器相位不对 | 保留舍入位,增加数据位宽,检查CIC计数器是否从0开始 |
| 仿真正常但上板无输出 | 引脚约束错误或时钟未锁定 | 检查xdc文件,确认PLL锁定信号;用ILA确认时钟存在 |
| 信号失真,包络有过冲 | 输入信号幅度过大导致ADC削顶或绝对值溢出 | 调低信号幅度,检查ADC满量程判断 |
排查思路的核心是“逐级定位”:先用ILA看ADC原始数据,再逐级看abs、avg、dec的输出,哪一级异常就从哪一级开始查。不要一开始就怀疑算法,先把数据通路打通。
4.2 资源优化与代码编写经验
这个项目的资源占用非常小,在XC7A35T上综合后逻辑单元不到200个,几乎不占资源。不过,做工程项目时依然有几点值得留意:
- 不用在滑动平均里例化大量寄存器。我见过有人用移位寄存器数组存N个值,每个周期整体搬移一遍,N=64时逻辑瞬间爆炸。采用环形指针或CIC结构,资源消耗与N无关。
- 尽量避免用浮点。FPGA做浮点除非有专门DSP单元,否则代价极高。像这里所有系数都化成整数移位或者常量,硬件实现简单,时序也容易收敛。
- 位宽宁可多留两位也不要少。特别是累加器,位宽不足的表现很诡异:平时波形正常,一到信号幅度大的时候突然跳变。我吃过这亏,后来习惯把中间级位宽按理论值再加2。
- 仿真时用
$dumpfile和$dumpvars导出VCD文件,可以用GTKWave打开,比自带波形工具更灵活,适合批量对比多组参数。
4.3 从AM解调延伸出去的几个方向
做完这个项目后,可以往几个方向扩展,技术路线是相通的:
- CIC多级级联与补偿滤波器:如果对带外衰减要求更高,用多级CIC,并在抽取后加一级CIC补偿FIR,这就是比较典型的数字下变频链路。
- DDC完整链路:把NCO混频、CIC、FIR结合起来,就是软件无线电里最常用的数字下变频器,能直接接收中频信号。
- 高阶调制解调:AM解调做熟了,再去碰DSB、SSB、FM会容易很多。FM解调核心是鉴频器,本质上也是先做CORDIC相位提取,再差分。
- 和高速接口结合:如果ADC速率上到几百MS/s,输出就需要和DDR3、PCIe、JESD204B这些接口联动,到时候你会用到类似高速串行收发器IBERT核调试通道,或者用JESD204B完成ADC到FPGA的物理层对接。这些我在后续项目中逐一踩过的坑,就是另一篇长篇分享了。
5. 一些小技巧和最后的建议
如果只让我给新手一个建议,那就是先仿真,再上板,一步一步来。我最初做这个项目时跳过仿真直接上板,结果ILA里看不到一个能解释的信号,花了整整一个晚上排查,最后发现只是ADC位序接反。有了仿真基础,上板后只需确认AD数据和时钟链路就基本稳了。
再分享一个调试技巧:给顶层模块加一个测试模式,用一个内部计数器的低几位作为伪随机或正弦数据源,直接注入到解调链路里。这样在开发板上不需要信号发生器也能验证逻辑是否工作正常,我当时就是靠这个功能在出差没有仪器的环境下把功能验证完的。这个思路对后续所有信号处理项目都适用。
AM解调虽然是个几十年前就成熟的技术,但作为FPGA数字信号处理的入门项目,它完整覆盖了采样、滤波、抽取、时序配合、仿真验证这些核心技能。把这套逻辑吃透,你会发现后面做DDC、DUC、甚至更复杂的调制解调系统时,很多东西都是相通的。如果你也正在做这个项目,希望这篇文章能帮你少走一些弯路。
本文还有配套的精品资源,点击获取
