ARTICLE DETAIL

建站实战干货

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

FPGA实战:用Verilog实现16QAM调制器从原理到上板

2026/10/5 6:02:37 拓冰建站 浏览量
FPGA实战:用Verilog实现16QAM调制器从原理到上板 1. 为什么在FPGA里写16QAM频谱效率和硬件代价的权衡前阵子有个做通信方向的师弟问我网上关于16QAM原理的PPT一抓一大把但真正用Verilog在FPGA上把它调通的代码和实战记录却很少。我回想自己第一次在Vivado里把16QAM调制的星座图完整跑出来的过程确实有“原理全会、工程全废”的感觉。原理课上画星座图闭着眼睛都能画但真正落到RTL串并转换、Gray映射、成形滤波、位宽截位、时序对齐每个环节都能让人卡上半天。这篇文章就把我用Verilog实现16QAM调制的过程完整写下来从调制链路拆解、RTL模块设计到仿真验证和上板踩坑尽量还原一条从零到可用的真实路径给正在写调制代码的朋友做个参考。先说清楚一件事为什么要做调制以及为什么偏偏是16QAM。通信系统里基带数据不能直接扔到信道上传输因为低频分量在无线信道里衰减太快必须把它搬到适合传输的频段上。调制本质上就是让高频载波的某一个或几个参数幅度、相位、频率随着基带信号变化把信息“驮”到载波上。QAMQuadrature Amplitude Modulation正交幅度调制同时利用幅度和相位两个维度来承载信息比单独调幅度或调相位的方案更高效。16QAM每个符号承载4比特信息星座图上总共16个点排列成4×4的方格。相比QPSK每个符号只带2比特、8PSK每个符号带3比特16QAM在同样的符号速率下能把数据率翻倍。它相比64QAM或256QAM又更“皮实”星座点之间的距离更大同样信噪比条件下误码率更低对硬件实现的精度要求也没那么苛刻。对于FPGA实现来说16QAM是一个特别好的平衡点——性能和复杂度都过得去非常适合做基带信号处理链路的入门和工程落地。调制方式每符号比特数星座点数相对频谱效率抗噪声能力硬件复杂度QPSK241×强低16QAM4162×中中64QAM6643×弱较高256QAM82564×很弱高FPGA做16QAM调制的意义在于它把“原理理解”和“工程实现”之间那条沟给填平了。你可以在Matlab里一条命令画出完美的星座图但那不叫懂调制当你亲手写过串并转换的状态机、调过映射表、算过成形滤波器的系数才真正理解为什么Gray映射能降低误码率为什么成形滤波器能压住频谱旁瓣为什么位宽截位会直接影响EVM误差矢量幅度。这篇博文的内容就是围绕这个“从原理到RTL再到实测”的主线展开的。全文涉及的代码都基于标准Verilog-2001综合和仿真环境以Vivado和ModelSim为主但代码本身没有绑定特定厂商IP换到Quartus或者开源工具链Icarus Verilog GTKWave也能直接跑。2. 梳理调制链路从串行比特流到IQ星座点的三步拆解2.1 第一步串并转换把连续的比特流切成4比特符号16QAM调制的输入是串行的二进制数据流每个时钟周期进来1比特。但星座图上每个点对应4比特所以第一步必须做串并转换SIPOSerial In Parallel Out。这一步看起来简单实际上藏着两个容易出错的细节。第一个细节是比特分组的方向。很多教材画图时默认“最高位在前”MSB first即第一个进来的比特作为符号的最高位但实际工程里上游模块输出的比特流可能是LSB first 的如果没对齐映射出来的星座点就是乱的。我的做法是在模块接口里加一个参数BIT_ORDER默认支持 MSB first同时留好扩展位。第二个细节是valid信号的时序对齐。串并转换不只是攒够4个比特就输出还得告诉下游“这4个比特是有效的”。如果只是一直平移寄存器、每4拍拉一次valid那valid和数据之间天然就错开了半拍下游采数据时很容易采到旧值。module sipo_4bit #( parameter DATA_WIDTH 1 )( input wire clk, input wire rst_n, input wire bit_valid, // 输入bit有效标志 input wire [DATA_WIDTH-1:0] bit_in, output reg [3:0] symbol_out, // 4bit并行符号 output reg symbol_valid // 符号有效标志 ); reg [1:0] bit_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin bit_cnt 2d0; symbol_valid 1b0; symbol_out 4d0; end else if (bit_valid) begin if (bit_cnt 2d3) begin // 第4个bit进来组成完整符号拉高valid symbol_out {symbol_out[2:0], bit_in}; symbol_valid 1b1; bit_cnt 2d0; end else begin symbol_out {symbol_out[2:0], bit_in}; symbol_valid 1b0; bit_cnt bit_cnt 1b1; end end else begin symbol_valid 1b0; end end endmodule这段代码里有个经验点symbol_out的移位寄存器始终在bit_valid有效时更新但symbol_valid只在第4个比特到达的那一拍拉高其余时间强制拉低。这样下游模块只需要看symbol_valid就能判断数据是否有效不用自己去数拍数。2.2 第二步Gray映射让相邻星座点只差一个比特串并转换之后的4比特符号需要映射到IQ平面上的一个具体坐标。16QAM的星座图看起来是4×4的网格I轴和Q轴各4个电平。标准的Gray编码映射方式是I轴对应符号的高2位Q轴对应符号的低2位每个轴上的4个电平依次映射到 00、01、11、10注意这里跳过了10对应幅度分别是 -3、-1、1、3。为什么要用Gray码而不是自然二进制码关键在于误码特性。加性高斯白噪声信道里信号最容易误判到相邻的星座点。如果采用自然二进制编码00、01、10、11相邻星座点可能差2个比特甚至更多一个符号错误就会导致多个比特错误。Gray编码保证相邻星座点只差1个比特这样符号错误率到比特错误率之间只差一个系数不会出现“一个符号错、4个bit全错”的糟糕情况。具体到RTL实现最直接的方式是用查找表LUT或者case语句。下面这段代码把4比特符号拆成 I_symbol 和 Q_symbol 各2比特然后分别映射成带符号的4比特幅度值// Gray映射高2位 - I轴低2位 - Q轴 // 00 - -3, 01 - -1, 11 - 1, 10 - 3 reg signed [3:0] i_map; reg signed [3:0] q_map; always (*) begin case (symbol_in[3:2]) 2b00: i_map -4sd3; 2b01: i_map -4sd1; 2b11: i_map 4sd1; 2b10: i_map 4sd3; default: i_map -4sd3; endcase end always (*) begin case (symbol_in[1:0]) 2b00: q_map -4sd3; 2b01: q_map -4sd1; 2b11: q_map 4sd1; 2b10: q_map 4sd3; default: q_map -4sd3; endcase end这里有个容易被忽略的问题星座图理论上应该按能量归一化。16个星座点的平均功率需要归一化到1或者某个固定功率否则后续DAC、上变频、信道仿真都会对幅度产生误解。16QAM的星座点幅度为 ±1、±3平均功率计算为 (4×1² 8×(1²3²)/2 4×3²)/16 10所以归一化系数是 1/√10 ≈ 0.3162。如果直接拿整数 3、1、-1、-3 去送DAC每符号平均功率就是10很多系统会因此超标。正确的做法是把这个归一化系数通过乘法器乘到IQ数据上或者在后面成形滤波器的系数里一起折算进去。我在第一个版本里就忽略了这一步结果环路测试时发射功率比预期高了10dB排查了半天才发现是幅度没归一化。提示如果在FPGA里不想引入实数乘法可以把归一化系数拆成移位加法的形式。1/√10 ≈ 0.3162 ≈ 0.25 0.0625 0.0039对应 1/4 1/16 1/256用移位和加法就能实现且误差在可接受范围内。2.3 第三步脉冲成形滤波让信号在频带上“见得了人”映射出来的IQ信号是离散的符号序列每个符号持续一个符号周期 Ts。如果直接把这个序列送进DAC频谱会非常宽——因为理想的矩形脉冲在频域是sinc函数的无限扩展。在无线通信里这叫做频谱泄漏会让信号带宽远超应有的范围干扰相邻信道。脉冲成形滤波的作用就是把信号带宽限制在需要的范围内。最常用的是升余弦Raised CosineRC滤波器或者根升余弦Root Raised CosineRRC滤波器。发送端和接收端各用一个RRC滤波器时级联效果等于一个RC滤波器既能限带又能保证采样点无码间串扰ISI。升余弦滤波器的频域表达式是当 |f| ≤ (1-β)/(2Ts) 时H(f) Ts当 (1-β)/(2Ts) ≤ |f| ≤ (1β)/(2Ts) 时H(f) (Ts/2)·[1 cos(πTs/β·(|f| - (1-β)/(2Ts)))]当 |f| (1β)/(2Ts) 时H(f) 0其中 β 是滚降系数决定了频谱滚降的陡峭程度。β越大频谱越宽但旁瓣越低β越小频谱越窄但旁瓣衰减越慢。实际系统中β一般取0.2到0.5之间我们用的0.35在频谱效率和带外抑制之间取了一个平衡。在数字实现里成形滤波器的抽头系数由符号速率、过采样倍数、滚降系数共同决定。FPGA里通常的做法是先用Matlab的rcosdesign函数生成系数量化成定点数然后做成FIR滤波器。具体细节在下一节展开。3. RTL核心代码串并转换、Gray映射与成形滤波的实现细节3.1 模块划分与顶层互联按照上一节的链路拆解调制器在RTL层面分成三个核心模块串并转换模块sipo_top、星座映射模块qam_mapper、成形滤波模块rrc_filter。另外还有一个可选的DDS混频模块如果要做中频调制而不是零中频IQ输出需要用它把基带IQ信号搬移到中频载波上。顶层模块的接口设计如下module qam16_mod_top #( parameter DATA_IN_WIDTH 1, parameter SYMBOL_RATE 1_000_000, // 符号率 1Msps parameter SPS 4, // 过采样倍数 parameter SYS_CLK 16_000_000 // 系统时钟 16MHz )( input wire clk, // 系统时钟 input wire rst_n, input wire tx_data_valid, input wire [DATA_IN_WIDTH-1:0] tx_data, output reg signed [15:0] i_out, // I路输出 output reg signed [15:0] q_out, // Q路输出 output reg out_valid // 输出有效标志 ); wire [3:0] symbol; wire symbol_valid; wire signed [3:0] i_map; wire signed [3:0] q_map; // 模块1串并转换 sipo_4bit #(.DATA_WIDTH(DATA_IN_WIDTH)) u_sipo ( .clk (clk), .rst_n (rst_n), .bit_valid (tx_data_valid), .bit_in (tx_data[0]), .symbol_out (symbol), .symbol_valid(symbol_valid) ); // 模块2星座映射 qam16_mapper u_mapper ( .symbol_in (symbol), .i_out (i_map), .q_out (q_map) ); // 模块3成形滤波I/Q各一个此处省略例化细节 // ... endmodule这个顶层结构最值得注意的地方是数据流的方向是严格单向前进的没有反馈回路所以在时序上天然容易收敛。真正需要花心思的是成形滤波器内部它的数据通路里乘法器多、流水线长是整个调制器里最可能出时序问题的模块。3.2 成形滤波器的实现方案对比成形滤波器在FPGA里有三条路可以走各有取舍。第一条路是用厂商IP核比如Xilinx的FIR Compiler。优点是参数配置界面友好、自动做流水线优化、资源利用率高缺点是生成的是黑盒出了问题时很难排查而且换平台就得重新生成。第二条路是自己手写FIR。代码量不大一个对称FIR的RTL也就几十行关键是你能完全掌控每一步的时序行为这对于学习和排查都很有帮助。缺点是对乘法器的利用需要花心思写不好会比IP核多用不少资源。第三条路是用查找表实现分布式算法DA算法适合没有DSP48的小型FPGA把乘累加运算转换成查表和移位累加。但DA算法在抽头数很多时LUT消耗确实大设计起来也更绕。我自己做这个项目时选的是手写对称FIR原因很简单16QAM调制的成形滤波器抽头数通常在15到31之间这个规模完全在可控范围内手写既能让链路透明化又能在资源上通过对称性省掉一半乘法器。3.3 系数生成与量化Matlab工具链的配合RRC滤波器的系数用Matlab生成是最方便的。以符号率 1Msps、过采样倍数 4、滚降系数 0.35、抽头数 21 为例% Matlab: 生成RRC滤波器系数 sps 4; % 每个符号采样点数 beta 0.35; % 滚降系数 span 5; % 滤波器跨度符号数 sps_actual sps; % 生成根升余弦系数 rrc_coeffs rcosdesign(beta, span, sps_actual, sqrt); % 量化到12bit有符号数 quant_bits 12; max_val 2^(quant_bits-1) - 1; coeff_scaled round(rrc_coeffs / max(abs(rrc_coeffs)) * max_val); % 写成Verilog可读取的HEX文件 fid fopen(rrc_coeffs.hex, w); for i 1:length(coeff_scaled) if coeff_scaled(i) 0 fprintf(fid, %04X\n, coeff_scaled(i) 2^quant_bits); else fprintf(fid, %04X\n, coeff_scaled(i)); end end fclose(fid);抽头数21因为对称性实际只需要存11个系数中间那根自己对称。FIR的输出率是 4Msps1Msps符号率 × 4倍过采样所以FIR的工作时钟是16MHz时4个采样点分别在4个时钟周期里处理完。这里必须提醒一件事量化位宽直接决定滤波器阻带抑制的性能。12bit量化的RRC滤波器阻带抑制大概能做到70dB以上对于16QAM调制需求足够。如果只用8bit量化阻带会抬到40dB左右频谱上会出现可见的旁瓣泄漏。我在实验中发现8bit量化时相邻信道的带外辐射会明显超标换到12bit之后干净得多。3.4 对称FIR的RTL实现利用系数对称性FIR滤波器在硬件上可以先把对称的输入相加再乘系数乘法器数量直接减半。21抽头的滤波器只有11个乘法器。实现代码如下module rrc_filter #( parameter DATA_WIDTH 16, parameter COEF_WIDTH 12, parameter TAPS 21 )( input wire clk, input wire rst_n, input wire data_valid, input wire signed [DATA_WIDTH-1:0] data_in, output reg signed [DATA_WIDTH7:0] data_out, output reg out_valid ); localparam HALF_TAPS (TAPS 1) / 2; // 11 // 系数ROM实际工程中从文件加载 reg signed [COEF_WIDTH-1:0] coeff_rom [0:HALF_TAPS-1]; initial begin $readmemh(rrc_coeffs.hex, coeff_rom); end // 数据移位寄存器 reg signed [DATA_WIDTH-1:0] shift_reg [0:TAPS-1]; integer i; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i 0; i TAPS; i i 1) shift_reg[i] d0; end else if (data_valid) begin shift_reg[0] data_in; for (i 1; i TAPS; i i 1) shift_reg[i] shift_reg[i-1]; end end // 对称相加 乘加 reg signed [DATA_WIDTH:0] sym_sum [0:HALF_TAPS-1]; reg signed [DATA_WIDTHCOEF_WIDTH-1:0] mult_out [0:HALF_TAPS-1]; reg signed [DATA_WIDTHCOEF_WIDTH-1:0] acc; reg valid_d1, valid_d2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin acc d0; out_valid 1b0; end else if (data_valid) begin for (i 0; i HALF_TAPS; i i 1) begin if (i HALF_TAPS - 1 TAPS % 2 1) sym_sum[i] shift_reg[i]; else sym_sum[i] shift_reg[i] shift_reg[TAPS-1-i]; end acc 0; for (i 0; i HALF_TAPS; i i 1) begin mult_out[i] sym_sum[i] * coeff_rom[i]; acc acc mult_out[i]; end data_out acc; out_valid 1b1; end else begin out_valid 1b0; end end endmodule这段代码是可以直接综合的但有三个细节必须注意第一acc acc mult_out[i]这种写法在循环里会有多处赋值问题综合工具会报错。我上面用了阻塞赋值acc 0配合循环累加在组合逻辑里这是合法的写法但缺点是级联加法链比较长抽头多的时候组合逻辑延迟会变大。抽头21以内问题不大如果做64QAM需要更长的滤波器建议改成流水线加法树。第二data_out的位宽是DATA_WIDTH 7这是根据累加最大动态范围估算的。21个抽头的RRC系数绝对值之和约等于 1/√sps ≈ 0.5输入信号最大幅度 ±3那么滤波器输出动态范围大约在 ±(3×21×max_coeff/2)经过量化折算后留出8bit余量足够。原则是宁宽勿窄截位放到后面统一做。第三滤波器内部的乘法器全部是有符号乘法Verilog里reg signed声明必不可少。如果漏掉signed无符号乘法会得到完全错误的结果而且这种错误很难通过波形看出来。3.5 插值时钟的处理数据率与系统时钟怎么匹配成形滤波器的工作频率必须达到 符号率 × 过采样倍数。在我们这个例子里是 4Msps。但串并转换模块是每个符号周期才输出一个有效符号也就是说FIR的输入valid是稀疏的16MHz系统时钟下每4个时钟周期只有一个有效的符号数据。这个“稀疏valid”在FIR里怎么处理两种做法。做法一保持FIR在4MHz数据率下工作用时钟使能clock enable控制。16MHz时钟下每4拍才data_valid一次有效数据在FIR的移位寄存器里每次只移一格但乘法器在数据无效的周期里也空转白耗动态功耗。做法二用多相滤波器结构把4倍插值拆成4个并行子滤波器每个子滤波器工作在符号率1MHz输出交错合成为4Msps。这种方式资源增加了但工作频率低适合符号率本身很高比如超过100Msps的场景。对于入门项目做法一完全够用。FPGA里的DSP48乘法器在16MHz这种低频下空转几个周期能耗差距可以忽略。只有当符号率很高、功耗敏感或者时序紧张时才需要认真考虑多相结构。4. 仿真验证的关键怎样确认星座图画对了而不是“看起来像”4.1 搭建一个能“自检”的测试平台仿真验证16QAM调制器最直观的方式不是一个个信号去对波形而是直接看星座图和频谱。写一个testbench生成伪随机比特流灌入调制器把IQ输出写入文本文件然后用Matlab或Python读取画图。testbench的核心逻辑不复杂关键是数据源要足够长。如果只跑几十个符号星座图上一共16个点可能都没覆盖全看不出灰度层次。一般至少跑几千个符号让每个星座点都有足够的统计样本这样才能看出发散程度和偏移情况。// testbench片段生成随机比特流并写入文件 reg [7:0] lfsr 8hAB; always (posedge clk) begin if (bit_en) begin // 8bit LFSR产生伪随机序列 lfsr {lfsr[6:0], lfsr[7] ^ lfsr[5] ^ lfsr[4] ^ lfsr[3]}; tx_data lfsr[0]; tx_valid 1b1; end else begin tx_valid 1b0; end end // 采样FIR输出写入文件 always (posedge clk) begin if (out_valid) begin $fwrite(file_handle, %d %d\n, $signed(i_out), $signed(q_out)); end endLFSR伪随机序列比直接计数器仿真效果好得多它能让比特流在0/1之间频繁翻转避免出现“星座点集中在一两个位置”的假象。采样点数量建议至少4096个符号也就是65536个偶数采样点4倍过采样画出来星座图边缘的点会比较密集中心区域的点也有足够样本。4.2 从仿真数据画出真正的星座图Matlab里读取文件并生成星座图的代码很简单data load(qam16_out.txt); i_data data(:, 1); q_data data(:, 2); % 每个符号取一个采样点选择采样的相位要跟符号对齐 samples_per_symbol 4; i_sym i_data(1:samples_per_symbol:end); q_sym q_data(1:samples_per_symbol:end); figure; plot(i_sym, q_sym, .); axis equal; grid on; title(16QAM 星座图 (仿真));这里有个非常关键的工程细节取点相位必须跟符号对齐。FIR滤波器的群延迟不为0符号输出在时间轴上有一个固定的偏移。如果直接从第一个采样点开始每隔4点取一个很可能取到的是跨符号的过渡区导致星座图上每个点都拖出一条弧线看起来就像“16QAM变形成了一圈圆弧”。这不是调制器本身出错是采样相位不对。做法有两种一种是用testbench里的符号有效信号做触发每来一个symbol_valid之后的某个固定延迟点采样另一种是先在Matlab里做相关性对齐找到最佳采样点。第一种在仿真里更容易实现我建议直接用。4.3 常见仿真异常和根因分析我在调试过程中遇到过三种非常典型的异常星座图列在这里供大家对比排查。第一种星座点不是16个独立点而是16条“毛茸茸”的弧线段。根因通常就是采样点没对齐或者FIR输出还没稳定就开始采样。解法是调整采样位置让采样点落在每个符号的稳定区间眼图张开最大的位置。第二种星座点分布正确但是整体旋转了一个角度。这个往往不是调制器的问题是采样时钟和符号时钟之间存在固定偏差。检查testbench里采样时钟的相位关系确保数据valid和时钟沿是匹配的。第三种星座点正确但I路和Q路的幅度不一样比如横向是纵向的两倍。根因在于I/Q两路滤波器的系数加载或者位宽截位不一致。排查方法是将I/Q两路单独输入直流信号观察两路输出增益是否一致。异常现象可能根因排查方向星座点呈弧线/毛刺采样点未与符号对齐调整采样位置或延迟星座点旋转符号时序与采样时钟不匹配检查valid时序I/Q幅度不对称滤波器系数/IQ增益不一致单路直流测试对比星座点整体偏移未做幅度归一化/直流偏置检查映射表和DC偏置星座点发散严重位宽截位太狠/滤波器系数错误检查截位策略和系数4.4 频谱验证眼图、EVM与误码的初步评估星座图之外频谱观察同样重要。Matlab里对仿真输出的IQ数据做FFT观察频谱形状是否符合RRC滤波器的频率响应。正常的16QAM信号频谱呈钟形主瓣宽度约为 (1β)×符号率旁瓣快速衰减。EVM误差矢量幅度是最直观的调制质量指标。计算方法是把接收到的星座点与理想星座点做差误差矢量的均方根与理想星座点幅度均方根的比值。16QAM系统一般要求EVM低于10%实际工程做到5%以下是正常的。仿真阶段EVM受到的影响主要是量化噪声和滤波器逼近误差如果EVM超过12%基本可以断定位宽截位或者系数量化出了问题。“看起来像星座图”和“真正合格的调制信号”是两回事。我在仿真里看到的第一版星座图形状完全正确但EVM算出来有18%。后来逐步把滤波器系数从8bit提到12bit、把输出截位从直接截断改成带饱和的截断EVM才降到4.3%。这个教训说明星座图的形状只能说明宏观对错EVM才是衡量调制质量的关键指标。5. 上板与工程化位宽截位、时序收敛、资源优化这些避不开的事5.1 从仿真到上板最容易被坑的截位环节仿真的数据位宽可以无限宽但FPGA里的DSP48乘法器输出位宽是有限的RAM和寄存器也是有限的所以位宽截位避无可避。截位策略直接决定信号的量化信噪比处理不好之前所有努力都白费。先看最基础的问题直接截掉低几位。如果滤波器输出是 24bit16bit输入 × 12bit系数 8bit累加直接保留高16bit等于做了向下取整。对于正数这没问题但对于有符号负数直接截断是向负无穷方向取整会产生直流偏置。这就是为什么很多信号处理链路里截位之后出现直流分量的原因。正确做法是四舍五入加饱和// 截位加上舍入偏移再右移输出16bit wire signed [23:0] filt_out; // 滤波器原始输出 reg signed [15:0] data_out; always (*) begin // 带符号的四舍五入加上 0.5 * LSB wire signed [24:0] rounded filt_out (1 (8-1)); // 饱和保护防止超过16bit范围 if (rounded 16sh7FFF) data_out 16sh7FFF; else if (rounded -16sh8000) data_out -16sh8000; else data_out rounded[15:0]; end关于饱和很多人觉得多余不就是截位吗直接截就好了。但在闭环通信系统里信号峰值偶尔超出范围是常态不饱和就会发生“溢出回绕”——本来是 32767回绕成 -32768符号直接翻转。这种错误在星座图上表现为边缘点微弱扭曲在解调端则可能造成误码。养成加饱和的习惯能省掉大量排查时间。5.2 多比特/多符号连续输入的接口适配很多实际系统的FPGA工程里上游协议栈给的并不是单比特流而是一个字节8bit或一个完整帧的数据。串并转换模块如果一次只能吃1bit接口效率就很低。我在自己的工程里对sipo_4bit做了一个升级支持字节输入内部做滑动窗口解析每输入一个字节产生两个4bit符号。module sipo_byte_to_symbol #( parameter BYTE_ORDER MSB_FIRST )( input wire clk, input wire rst_n, input wire byte_valid, input wire [7:0] byte_in, output reg [3:0] symbol_out, output reg symbol_valid ); reg symbol_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin symbol_cnt 1b0; end else if (byte_valid) begin case (symbol_cnt) 1b0: begin if (BYTE_ORDER MSB_FIRST) begin symbol_out[3:0] byte_in[7:4]; end else begin symbol_out[3:0] byte_in[3:0]; end symbol_valid 1b1; symbol_cnt 1b1; end 1b1: begin if (BYTE_ORDER MSB_FIRST) begin symbol_out[3:0] byte_in[3:0]; end else begin symbol_out[3:0] byte_in[7:4]; end symbol_valid 1b1; symbol_cnt 1b0; end endcase end else begin symbol_valid 1b0; end end endmodule这个模块每次byte_valid拉高时连续输出两个符号且symbol_valid在每拍都拉高。下游映射模块如果每个时钟最多处理一个符号就需要加一个FIFO缓存这两个符号。这里要特别留意如果symbol_valid连续两拍拉高而下游模块在第二拍还没有处理完第一个符号就会丢掉数据。稳妥的做法是在中间加一个深度为2的异步或同步FIFO或者让下游模块每拍至少消费一个符号二者必须匹配。很多资料里这一层都被当作“太简单不值得讲”带过了但实际联调时上游数据到达速率和调制器处理速率不匹配是导致丢符号、星座图出现“断线”的常见原因。5.3 时序收敛乘法器链路的分割与流水线上板遇到时序违例时首先怀疑的几乎总是FIR那一长串乘累加链路。前面第3.4节那段代码里我把累加放在一个always块里用阻塞赋值完成这在仿真里没问题但综合工具会把21个乘法器的结果加到一个很深的组合逻辑上16MHz下也许没问题一旦系统时钟提升到100MHz以上这条链路必挂。解决办法是加法器树流水线化。把乘累加拆成三到四级第1级对称输入相加得到11个sym_sum第2级乘以系数得到11个乘法结果第3级两两相加11个结果变成6个第4级继续两两相加6个变成3个第5级3个相加得到最终结果写成代码每级之间用寄存器打拍对应的out_valid也要同步打拍。这样乘法和加法树每一级都只有一层运算时序收起来很容易。// 加法树一级示例11个乘法结果先加出6个中间值 reg signed [DATA_OUT_WIDTH-1:0] add_stage1 [0:5]; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (int j 0; j 6; j j 1) add_stage1[j] d0; end else begin for (int j 0; j 6; j j 1) begin if (j 5) add_stage1[j] mult_out[10]; // 奇数个最后一个直接透传 else add_stage1[j] mult_out[2*j] mult_out[2*j1]; end end end注意流水线每加一级out_valid就要跟着打一拍。很多人时序修好了却发现输出数据和valid错位原因就是流水线打拍时忘了同步valid信号。5.4 资源占用与优化空间在Xilinx Artix-7系列比如XC7A35T上这个16QAM调制器含I/Q两路21抽头RRC滤波器和DDS混频的资源占用大约为资源类型占用数量占比XC7A35TSlice LUT约5202.4%Slice Register约6801.6%DSP48E12222.9%Block RAM13.1%DSP48E1占用了22块其中I/Q两路滤波器各11块正好对应对称FIR的乘法器数量。如果资源紧张可以把系数对称性利用到极致——上面的实现里已经把21个乘法器缩减到11个进一步优化需要把滤波器系数做Canonical Signed DigitCSD编码把乘法器换成移位加法的组合但LUT消耗会上升是否划算看具体场景。5.5 上板实测最值得盯的三个指标上板之后用频谱仪或信号分析仪观察调制信号重点看三个指标。第一是频谱形状和带宽。正常16QAM信号在频谱仪上呈现平滑的钟形3dB带宽大约等于符号率频带外的衰减由RRC滚降决定。如果看到频谱上有不规则的突起通常是滤波器系数加载错误或者截位导致的杂散。第二是星座图和EVM。用信号分析仪的矢量模式观察四个角上的星座点应该清晰锐利EVM在5%以内是理想状态。如果发现星座点整体有拖影多半是时钟抖动或者电源噪声。第三是带外杂散。如果中频频谱上出现明显的单根谱线很可能是DDS相位累加器位宽不够杂散被搬移到了中频附近。DDS的相位累加器至少要32bit然后截取高14~16bit作为查找表地址这样无杂散动态范围SFDR才能做到80dB以上。6. 从16QAM到更高阶调制这个架构能复用什么要改什么16QAM调制器做完之后最常被问的是“怎么升级到64QAM或者256QAM”。从架构角度看通信链路骨架串并转换→映射→成形滤波→混频是完全不变的需要改的只有几处小地方。第一串并转换的宽度。64QAM每个符号6bit256QAM每个符号8bitSIPO模块需要相应拓宽。字节输入的场景下8bit数据正好对应一个256QAM符号或者对应64QAM的一个符号加两bit余量接口逻辑要重新设计。第二映射表。星座点数多了之后不建议再用case硬编码而是改用ROM存储映射表。I轴和Q轴的电平从4个变成8个或16个地址线相应加宽。Gray映射的规律也很明确每个轴上的电平按Gray码顺序排列可以用组合逻辑算出幅度而不必查表。第三幅度归一化系数。16QAM平均功率是1064QAM平均功率是42256QAM平均功率是170。功率归一化系数要重新计算否则发射功率会偏差很大。第四成形滤波器的滚降系数和抽头数。高阶QAM对ISI更敏感通常需要更长的滤波器、更小的滚降系数这意味着更宽的位宽和更多的DSP资源。实际项目从16QAM升级到64QAM滤波器资源几乎要翻倍。我在做64QAM升级时遇到一个坑因为映射表的幅度范围从 ±3 扩大到了 ±7固定位宽的I/Q数据范围不够导致星座图最外圈的点被削顶EVM直接从5%飙到了15%。查了半天才发现是上游数据位宽没有跟着映射表一起调整。所以升级高阶调制时建议把数据位宽检查放在第一步而不是等到星座图变形了再回头找原因。这篇分享写到这里基本把我在FPGA上用Verilog实现16QAM调制踩过的坑和验证过的方法都覆盖了。核心其实就一句话调制器的RTL代码本身并不难写难的是把位宽、时序、valid对齐这些细节处理好。建议各位在动手写代码之前先花点时间把本章第2节的三步链路在纸上画清楚尤其是valid信号的流动路径——这是我做了几个调制器项目之后觉得最值得投入时间的地方。