基于FPGA的AM调制度与FM频偏测量系统设计与Verilog实现
调制度测量这个东西放在几年以前怎么也得备一台台式调制域分析仪才敢说测得准。但真正到了产线测试、电台检修、教学实验这种场景需要的往往并不是实验室级的极限精度而是能快速、稳定、可自动化地把AM调制度调幅深度和FM频偏测出来。我这次就是从一个通信设备维修部的实际需求出发用一块Xilinx Artix-7把整套基于FPGA的调制度测量系统做了下来调制度分辨率做到0.1%测量结果同时通过串口和LCD输出工程代码全部用Verilog写完附带完整仿真环境。这篇手记会把系统怎么拆、核心代码怎么读、仿真和实测踩过哪些坑全部摊开讲透给正在琢磨FPGA信号处理项目的朋友一个能直接上手的参考。1. 调制度测量是什么FPGA为什么合适1.1 调制度测量到底在测什么先明确一个基础概念AM调制度也叫调幅深度数学定义是已调波包络最大值Vmax和最小值Vmin的差值比上它们的和即 m (Vmax - Vmin) / (Vmax Vmin)。举个例子载波幅度1V被音频信号调制后包络最大到1.3V最小到0.7V那调制度就是0.6也就是60%。这个比值和载波绝对幅度无关所以模拟前端哪怕有些增益波动只要ADC不饱和、量化噪声不过分最终读数基本稳定。FM调制度则不太一样它测的是最大频偏与调制频率的比值关系本质上要先把FM信号解调出来再测量基带信号的频率或者幅度。这个项目的主测量模式是AMFM模式我用了过零检测的简化版本原理在代码里有注释但整个架构的主处理链路还是围绕AM的包络检测和峰值统计来设计的。在工程上调制度测量结果直接影响发射机的故障判断广播发射机一旦过调m超过100%包络会削底产生严重的邻频干扰通信设备维修时调制度异常往往指向调制器增益失衡、偏置漂移等问题。所以仪器要做的不是复杂的花样而是把包络的最大最小值抓准把除法算对把结果稳定输出。1.2 为什么选FPGA而不上MCU或DSP市面上有专门的调制度分析仪但价格不低而且很多是台式仪器不方便集成到自动化测试线里。用MCU做呢ADC采样率是硬瓶颈调幅中频信号动辄10.7MHz或21.4MHz想要直接采中频做数字解调至少需要几十MSPS的采样率普通MCU片内ADC很难达到。用DSP呢处理能力够但外围器件多开发链路长而且灵活性不如FPGA。FPGA这边的优势其实很直接并行流水线架构天然适合高采样率实时处理采集中频信号、数字下变频、滤波、包络提取、峰谷统计可以做成一条完整流水线延迟可控每级处理只要几个时钟周期不像软件处理那样受中断和调度影响同一块板子通过改配置就能切换AM/FM测量模式后期升级非常方便Verilog工程在Xilinx和Intel两边都可以移植不必绑死在某一家的生态里。补充一句并不是说所有算法都必须硬写在FPGA里。像低调制频率下的峰谷统计、结果显示、按键扫描这些低速控制逻辑放在FPGA里只是为了统一整个数字链路不额外挂MCU。如果以后人机交互需求变大也可以把串口协议和界面部分拆给MCUFPGA专心做信号处理。2. 总体架构从天线口到串口数据链怎么拉通2.1 硬件选型与板级要点这套系统用的硬件是这样的FPGA选了Xilinx Artix-7 XC7A35T逻辑资源和DSP Slice足够跑DDC加CIC加CORDIC加串口成本也合理ADC用的ADI的AD924014位精度最高40MSPS并行CMOS输出接口非常干净不用在LVDS上多花时间模拟前端做了一级AGC和中频滤波把输入信号统一搬到10.7MHz中频幅度控制在ADC满量程的70%左右时钟方案是板载50MHz晶振进FPGA的PLL产生40MHz采样时钟和120MHz系统逻辑时钟两者同源且相位对齐避免了跨晶振时钟域带来的不确定性。ADC选型上有个经验供参考做调制度测量14位完全够用重点应该看SFDR无杂散动态范围而不是简单堆位数。有些16位ADC的SFDR反而不如高性能14位而对调制度测量影响最大的恰恰是杂散和交调分量它们会直接污染包络检测结果。2.2 数字链路模块如何拆分整个数字处理链路拆成下面几块每一块都是独立Verilog模块顺着数据流往外搭模块主要功能关键注意点clk_genPLL产生40M采样时钟和120M逻辑时钟同源、相位对齐adc_capture同步ADC输出并打入系统时钟域打两拍做跨时钟域同步dds_mixerDDS生成cos/sin本振与ADC数据乘法混频频率控制字精确计算cic_decCIC抽取滤波把40M数据率降下来位宽增长不能截错fir_compFIR低通补偿CIC通带滚降通带覆盖调制信号带宽envelope_magCORDIC求I/Q模值得到包络可用IP核注意输出延迟peak_detect在固定窗口内统计Vmax和Vmin窗口时长要覆盖最低调制周期mod_index计算调制度并做除零保护定点除法注意溢出uart_tx以可配置波特率输出测量结果波特率误差要控制住拆模块的时候最重要的原则是数据流方向清晰。每一级的位宽、有效信号、时序对齐都要提前规划好。尤其CIC每级滤波都会造成位宽增长后面一旦截位不当误差会沿着链路放大这部分后面单独讲。3. 核心工程代码放大看3.1 ADC采集与时钟处理ADC数据同步这一级很容易被低估。AD9240并行输出数据在采样时钟沿之后一般有几ns的建立保持窗口如果FPGA直接拿系统时钟去采很可能采到数据跳变沿。我的做法是40M采样时钟和120M系统时钟由同一个PLL产生且相位对齐所以ADC引脚数据在120M域直接打两拍同步代码很短但很稳。module adc_capture #( parameter ADC_WIDTH 14 )( input wire clk_sys, input wire rst_n, input wire [ADC_WIDTH-1:0] adc_pin, output reg [ADC_WIDTH-1:0] adc_d, output reg adc_valid ); reg [ADC_WIDTH-1:0] adc_meta; always (posedge clk_sys or negedge rst_n) begin if (!rst_n) begin adc_meta {ADC_WIDTH{1b0}}; adc_d {ADC_WIDTH{1b0}}; adc_valid 1b0; end else begin adc_meta adc_pin; adc_d adc_meta; adc_valid 1b1; end end endmodule打两拍之后adc_d就认为是系统时钟域的数据了。如果后面采样率提高比如ADC跑到125MSPS这种简单同步就不能硬套了得改用异步FIFO或者先降采样再跨域。40M对120M是整数倍且相位对齐所以这么干是合法的。3.2 DDC下变频与CIC抽取滤波ADC进来的10.7MHz中频信号不能直接做包络检测得先搬回零中频基带。这里用DDS Compiler IP生成一对正交本振频率字由公式计算得到频率控制字 f_out / fs × 2^32。以10.7MHz中频、40MHz采样率为例控制字是 10.7 / 40 × 4294967296约等于1149780907。把这个值写进DDS的相位累加器输出的cos和sin分别和ADC数据做乘法就得到了I/Q两路基带信号。module dds_mixer #( parameter ADC_WIDTH 14, parameter NCO_WIDTH 16 )( input wire signed [ADC_WIDTH-1:0] adc_d, input wire signed [NCO_WIDTH-1:0] nco_cos, input wire signed [NCO_WIDTH-1:0] nco_sin, input wire rst_n, output reg signed [NCO_WIDTH-1:0] mix_i, output reg signed [NCO_WIDTH-1:0] mix_q ); wire signed [ADC_WIDTHNCO_WIDTH-1:0] prod_i; wire signed [ADC_WIDTHNCO_WIDTH-1:0] prod_q; assign prod_i adc_d * nco_cos; assign prod_q adc_d * nco_sin; always (posedge clk_sys or negedge rst_n) begin if (!rst_n) begin mix_i d0; mix_q d0; end else begin // 算术右移保留符号位压缩到16位 mix_i prod_i (ADC_WIDTH - 1); mix_q prod_q (ADC_WIDTH - 1); end end endmodule乘法之后要右移截位这里必须用算术右移而不是逻辑右移否则负数会变成很大的正数包络检测直接完蛋。截位截到16位是因为后面CORDIC IP的输入宽度正好是16位。混频输出的I/Q信号里还带着2倍载波分量要用低通滤掉。工程里这里直接用Xilinx CIC Compiler IP核配置为三级CIC、抽取因子D4、微分延迟M1。CIC的好处是只用加法器和寄存器也能做到很窄的低通不需要乘法器但它的通带不是平的滚降比较明显所以后面再接FIR补偿。CIC输出位宽会比输入宽不少三级CIC、抽取4倍的情况下增益大致是(4×1)^364对应位宽增长6位左右。IP核一般会自动把输出位宽扩展取数的时候要保留足够的有效位但也不能余量太多否则后面计算模块位宽会被撑爆。3.3 CORDIC求包络与峰谷检测经过CIC和FIR补偿之后得到的是干净的I/Q基带信号包络就是磁矢量的长度即 sqrt(I² Q²)。这一步我用Xilinx CORDIC IP核的“Vector Translate”模式输入拼接I和Q输出就是模值。cordic_mag u_mag ( .aclk (clk_sys), .s_axis_cartesian_tvalid (fir_valid), .s_axis_cartesian_tdata ({fir_i[15:0], fir_q[15:0]}), .m_axis_dout_tvalid (mag_valid), .m_axis_dout_tdata (mag_data) );如果不方便调用CORDIC IP也可以用近似公式 mag ≈ max(|I|, |Q|) 0.375 × min(|I|, |Q|)误差在几个百分点以内。但既然工程里已经起了Artix-7用IP核更简单也更准。包络出来后关键一步就是峰谷检测。要注意调制度的计算依赖的是完整调制周期内的最大值和最小值所以统计窗口长度必须大于等于最低调制频率的一个周期。比如设备规定最低调制频率20Hz那么窗口至少50ms。CIC抽取后数据率如果是10MSPS那窗口就是50万个点需要一个20位计数器如果抽取后降到5MSPS就是25万个点。窗口太短会漏掉真正的峰值调制度读数偏低窗口太长则对调制频率变化响应迟钝。我这里的peak_detect模块把窗口长度做成参数灵活调整。module peak_detect #( parameter W 16, parameter CNT_W 20, parameter WINDOW_LEN 20d250000 )( input wire clk_sys, input wire rst_n, input wire [W-1:0] env_in, input wire env_valid, output reg [W-1:0] vmax, output reg [W-1:0] vmin, output reg result_valid ); reg [CNT_W-1:0] cnt; reg [W-1:0] cur_max; reg [W-1:0] cur_min; always (posedge clk_sys or negedge rst_n) begin if (!rst_n) begin cnt {CNT_W{1b0}}; cur_max {W{1b0}}; cur_min {W{1b1}}; vmax {W{1b0}}; vmin {W{1b0}}; result_valid 1b0; end else if (env_valid) begin if (cnt 0) begin cur_max env_in; cur_min env_in; cnt cnt 1b1; result_valid 1b0; end else if (cnt WINDOW_LEN) begin vmax cur_max; vmin cur_min; result_valid 1b1; cnt {CNT_W{1b0}}; end else begin if (env_in cur_max) cur_max env_in; if (env_in cur_min) cur_min env_in; cnt cnt 1b1; result_valid 1b0; end end else begin result_valid 1b0; end end endmodule这个模块每个窗口统计一次Vmax和Vmin然后拉高result_valid通知后面的计算模块取数。窗口结束的那一拍同时清零计数器开始新一轮统计中间不丢数据。还有一个容易被忽略的点CORDIC输出有固定延迟CIC滤波器也有群延迟这些延迟会打乱数据和valid的关系。所以工程里每一级都带着valid信号一起走而不是只搬数据不搬标志。只要对齐了数据通路和valid通路后面无论是仿真还是实测定位问题都能省一大半时间。3.4 调制度计算模块与UART输出拿到Vmax和Vmin后调制度的公式是 m (Vmax - Vmin) / (Vmax Vmin)。因为结果要显示到小数点后一位所以我在定点数里统一放大1000倍也就是计算 (Vmax - Vmin) × 1000 / (Vmax Vmin)输出范围0到1000对应0.0%到100.0%。这个除法发生在50ms才触发一次的低速率环节完全没有必要动用除法器IP核IP核延迟大、资源也不少。我用一个基于移位减法思想的长除法状态机实现只需要一个累加器和计数器。module div_uint #( parameter W 24 )( input wire clk, input wire rst_n, input wire start, input wire [W-1:0] dividend, input wire [W-1:0] divisor, output reg [W-1:0] quotient, output reg done ); reg [2*W-1:0] rem; reg [W-1:0] d; reg [W-1:0] q; reg [$clog2(W)-1:0] cnt; reg [1:0] state; localparam IDLE 2d0; localparam CALC 2d1; localparam DONE 2d2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; quotient {W{1b0}}; done 1b0; end else begin case (state) IDLE: begin done 1b0; if (start divisor ! 0) begin rem {{W{1b0}}, dividend}; d divisor; q {W{1b0}}; cnt 0; state CALC; end else if (start) begin quotient {W{1b1}}; // 除零保护输出全1 done 1b1; end end CALC: begin if (cnt W) begin quotient q; done 1b1; state DONE; end else begin rem rem 1; if (rem d) begin rem rem - d; q[cnt] 1b1; end cnt cnt 1b1; end end DONE: begin done 1b0; if (!start) state IDLE; end default: state IDLE; endcase end end endmodule除零保护一定要做否则输入无信号时VmaxVmin可能为零除法器会计算出乱七八糟的结果。这里简单处理成输出全1后面上位机看到全1就知道是“NO SIG”状态。结果输出用了一个标准的UART发送模块115200波特率8位数据位1位停止位。因为我只发送几个ASCII字符比如“AM:62.3%”或者“NO SIGNAL”所以不需要接收功能。如果未来要接上位机做参数配置再补一个uart_rx就行。module uart_tx #( parameter CLK_FREQ 120_000_000, parameter BAUD 115200 )( input wire clk, input wire rst_n, input wire [7:0] tx_data, input wire tx_start, output reg tx, output reg tx_busy ); localparam BIT_CNT_MAX CLK_FREQ / BAUD; reg [15:0] baud_cnt; reg [3:0] bit_index; reg [7:0] data_buf; reg [1:0] state; localparam IDLE 2d0; localparam SEND 2d1; always (posedge clk or negedge rst_n) begin if (!rst_n) begin tx 1b1; tx_busy 1b0; baud_cnt 0; bit_index 0; state IDLE; end else begin case (state) IDLE: begin tx 1b1; tx_busy 1b0; if (tx_start) begin tx_busy 1b1; data_buf tx_data; baud_cnt 0; bit_index 0; state SEND; end end SEND: begin if (baud_cnt BIT_CNT_MAX - 1) begin baud_cnt 0; if (bit_index 0) begin tx 1b0; bit_index 1; end else if (bit_index 8) begin tx data_buf[bit_index-1]; bit_index bit_index 1; end else begin tx 1b1; state IDLE; end end else begin baud_cnt baud_cnt 1; end end default: state IDLE; endcase end end endmodule使用UART模块时要注意tx_start信号必须保持至少一个时钟周期并且等到tx_busy拉低之后才能发下一字节。否则前一个字节还没发完后一个字节的数据已经把data_buf覆盖了。4. 仿真验证、实测调试与踩坑实录4.1 Testbench构造标准AM信号做仿真这个工程不能只靠上板调试上板之前必须先用ModelSim或者Vivado Simulator跑仿真。关键是Testbench要生成一个已知调制度的标准AM信号然后把整个数字链路跑通。我的Testbench是这样写的用一个real型变量生成AM信号载波10.7MHz、调制信号1kHz、调制度60%采样率40MSPS最后转成14位有符号数送给ADC引脚。因为PLL在仿真里需要时间锁定所以要先等locked信号拉高再开始激励。real t_real 0.0; real s_real 0.0; reg [13:0] adc_data; wire pll_locked; initial begin rst_n 0; #200 rst_n 1; wait (pll_locked); forever begin #25; // 40MSPS周期25ns t_real t_real 25e-9; // 载波幅度4000调制分量幅度2400m600/100060% s_real (4000 2400 * $cos(2.0*3.1415926535898*1000.0*t_real)) * $cos(2.0*3.1415926535898*10.7e6*t_real); adc_data $rtoi(s_real); end end仿真跑完之后重点看几个信号adc_d是否正常采样、mix_i和mix_q是否对称、fir_i和fir_q是否稳定、mag_data包络是否有明显纹波、peak_detect的vmax和vmin输出是多少。理想情况下vmax应该接近6400vmin接近1600算出来的调制度在600附近波动误差不超过1%。仿真时最容易出的问题是CORDIC和CIC这类的IP核在Testbench里需要初始化时间数据有效标志会在几百个时钟周期之后才真正为高。如果上游模块一直用env_valid做使能下游模块要耐心等待不能总盯着最初的几个周期看是否有数据。4.2 SignalTap实测与问题排查仿真过了不等于上板就能跑对。我习惯先在Vivado里用ILA对应ISE时代的SignalTap抓内部信号。这里不能贪多一次性塞太多信号会让ILA存储深度不够抓到的时长太短根本看不到一个完整的调制周期。我的策略是分两批抓第一批抓adc_d、mix_i、mix_q确认混频和采样对第二批抓fir_i、fir_q、mag_data、vmax、vmin确认包络和峰谷统计对。实测时用信号源输入10.7MHz的AM信号调制度30%调制频率1kHz。整条链路跑完后串口输出的调制度大约在29.5%到30.5%之间波动这个精度对多数维修场景够用了。4.3 调试中的高频坑与处理建议这一节把我在这个工程里真正踩过的几个坑整理成速查表供你排查时参考现象根因处理方式调制度读数系统性偏低NCO频率没对准实际载波基带出现低频差拍用未调载波测出差频修正DDS频率控制字包络检测结果有波动峰谷抖动大CIC通带补偿不到位残差2倍频分量漏进来增加FIR阶数或在包络后加滑动平均调制度读数偏高I/Q两路增益不一致镜像分量没有抑制做I/Q增益校准或者改用单路包络近似串口输出乱码波特率分频误差偏大或地线噪声干扰用累加器方式分频检查串口电平参考地结果一直是全1除零保护生效说明VmaxVmin异常检查ADC输入是否有信号模拟前端是否饱和结果跳变到最大最小值峰谷统计窗口太短未覆盖完整调制周期按最低调制频率重新计算窗口长度上电后一段时间内无输出PLL未锁定或IP核未初始化完成等pll_locked和IP核的valid信号都有效后再处理有个细节特别值得说NCO频率偏差这个坑很多时候不是软件算出问题而是中频滤波器的中心频率本身有误差标称10.7MHz实际可能是10.710MHz。如果直接用理论控制字去算混频之后会有10kHz差拍这个分量一旦进入包络通带调制度读数就会不断抖动。我在工程里留了一个频率校正寄存器可以在未调载波时手动把差拍清零然后锁定校正值一劳永逸。另外实测时ADC输入幅度一定要留余量。AD9240是±5V输入范围的设计但如果信号源的输出幅度不够干净别指望ADC后面能救回来。我调板时曾经因为信号源输出端有直流偏置导致ADC实际只能用到一半的动态范围包络被截断调制度读数直接掉了好几个百分点。先把模拟前端的底噪和偏置测干净再谈数字算法。最后再分享一点个人经验做这类FPGA数字信号处理项目我最大的体会是代码写对只是第一步把每一级数据的位宽、延迟、有效标志对齐才是真正的核心。CORDIC有固定延迟CIC有群延迟峰谷统计窗口有长度这些因素全部叠在一起如果不在设计阶段就规划清楚后面一定是反复查波形、反复改状态机非常折磨。还有不要害怕用IP核DDS、CIC、CORDIC这些Xilinx和Intel都调试得很成熟比自己拿逻辑硬写稳定得多。这个调制度测量系统后续还可以继续扩展比如加上Modbus协议输出接PLC或者做I/Q不平衡校准提升镜像抑制率。调制度测量本身只是一个切入点背后的数字下变频、包络检测、峰值统计这套东西做通了之后平移去做其他FPGA信号处理项目会顺手非常多。