☰
基于FPGA的2FSK调制解调:Verilog实现与Quartus仿真详解
2026/10/20 16:20:36 网站建设 项目流程

在通信类的FPGA项目里,基于FPGA的2FSK调制解调算是特别经典的一个练手题目。之前有同学拿着一套调不通的工程来问我,代码是Verilog、仿真在Quartus里跑,核心就是2FSK调制解调。表面上看起来模块不多,但真正捋下来,涉及载波生成、键控调制、过零检测、门限判决、同步恢复一整套链路,任何一个环节理解不到位,波形就是乱成一团或者解调出来全是错码。这篇文章就把这套方案完整讲透——原理怎么落地、Verilog代码怎么写、Quartus仿真怎么跑、波形怎么判读、常见坑在哪里,一次性梳理清楚。适合正在做通信原理课设、FPGA综合实验,或者想用数字逻辑快速上手调制解调的同学参考。

1. 2FSK调制解调原理与整体方案选型

1.1 再捋一遍2FSK在移什么频率

2FSK全称是二进制频移键控,本质就是用两个不同频率的载波分别表示数据0和1。比如规定f0=1MHz代表码元0,f1=2MHz代表码元1,调制端的工作就是根据输入的基带数据,在f0和f1之间切换输出。切换速度由码元速率决定,假设码元速率取20kbps,那么每个码元持续50微秒,一个码元内会输出很多个完整的载波周期。

很多教材喜欢从数学表达式讲起,s(t)=A·cos(2πf0·t+φ0)或s(t)=A·cos(2πf1·t+φ1)。但在FPGA实现时,更直观的理解是:把两个频率看成两种不同“节奏”的方波,调制就是做一个受数据控制的频率切换器。数据为0时输出低频段,数据为1时输出高频段,这一点直接决定了后续Verilog代码的结构。

1.2 三种实现路径对比:分频、模拟开关与DDS

网上搜2FSK的FPGA实现,方案五花八门,但归纳起来就三类。

第一类是“模拟开关/乘法器方案”,用模拟开关在外部切换两路模拟载波,或者用乘法器做混频。这种方案出来的波形是标准正弦,质量最好,但FPGA内部基本不参与信号生成,主要做控制,还需要外围模拟电路,不适合全数字实验,更不适合只写Verilog的场合。

第二类是“计数器分频+MUX方案”,用两个计数器分别对系统时钟分频,产生两路方波载波,再用基带数据做一个二选一MUX。这个方案代码简单,非常容易理解,也是很多入门教程的选择。但它有两个不太舒服的地方:一是分频法产生方波时,如果分频系数是奇数,占空比做不精确,频率会有偏差;二是两路载波由两个独立计数器产生,相位没有任何关系,切换瞬间会出现明显的相位跳变,解调端容易把跳变沿误判成一个额外信号沿。

第三类是“DDS相位累加器方案”,也就是数字频率合成。核心是一个N位累加器,每个时钟周期累加一个频率控制字,累加器溢出速率就是输出频率。把累加器输出的最高位当作方波信号,通过切换累加步进就可以实现2FSK。这个方案频率精度高,而且切换时累加器不复位、相位保持连续,工程可移植性也更好。

我在这个设计里选的是第三类。对比下来,分频方案适合演示原理,DDS方案适合真正跑仿真和后续扩展,既不会引入太多模拟器件,又避免了分频法频率不准的尴尬。下表是三类方案的直观对比。

实现路径核心思想资源/复杂度输出信号质量适用场景
模拟开关/乘法器外部切换两路模拟载波高,需模拟电路正弦,质量高射频/模拟实验
计数器分频+MUX分频产生方波后选通低,适合入门方波,切换有相位跳变教学演示
DDS相位累加器累加步进控制频率,切步进实现FSK中,逻辑简洁方波,频率精确且相位连续工程实践与扩展

2. Verilog模块划分与核心代码实现

2.1 顶层模块划分与端口规划

这个设计我拆成三个文件:fsk_top顶层、fsk_mod调制模块、fsk_demod解调模块。顶层只做例化,不放逻辑;调制模块负责根据data_in生成fsk_out;解调模块负责从fsk_out恢复data_out。端口方面,统一用50MHz系统时钟clk,低电平复位rst_n,data_in是基带数据输入,fsk_out是调制后的2FSK信号,data_out是解调输出。

有人可能会问,为什么要把调制和解调放在同一个工程里?因为做仿真时最方便。如果只做调制,看不到解调结果;只做解调,又需要单独构造2FSK激励。放在一起后,可以在同一张波形图里对比data_in和data_out,验证整条链路是否正确,后续做误码统计也方便。

2.2 调制模块代码:单累加器实现相位连续FSK

调制模块的核心是DDS相位累加器。累加器位宽取32位,主时钟50MHz,目标频率1MHz对应累加步进STEP_F0 = 85899346,目标频率2MHz对应STEP_F1 = 171798692。这两个值的计算方式后面会专门讲,先记住一个关键点:步进越大,累加器溢出越快,输出频率越高。

代码里只保留一个相位累加器phase_acc,不用两个独立载波。数据data_in先打两拍同步,然后作为选择信号决定当前累加步进。每次时钟上升沿,phase_acc累加对应的step,输出则取phase_acc的最高位。因为这个最高位本身就代表方波电平,累加器连续工作,切换步进时相位不会中断,所以输出就是相位连续的2FSK信号。

module fsk_mod #( parameter PHASE_W = 32 )( input wire clk, input wire rst_n, input wire data_in, output reg fsk_out ); localparam STEP_F0 = 32'd85899346; // 1MHz @ 50MHz localparam STEP_F1 = 32'd171798692; // 2MHz @ 50MHz reg [PHASE_W-1:0] phase_acc; reg data_sync1, data_sync2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin data_sync1 <= 1'b0; data_sync2 <= 1'b0; end else begin data_sync1 <= data_in; data_sync2 <= data_sync1; end end wire [PHASE_W-1:0] step = data_sync2 ? STEP_F1 : STEP_F0; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin phase_acc <= 32'd0; fsk_out <= 1'b0; end else begin phase_acc <= phase_acc + step; fsk_out <= phase_acc[PHASE_W-1]; end end endmodule

注意fsk_out用的是寄存器输出,而不是直接把phase_acc最高位引出去。这样做的目的是让输出信号和时钟完全同步,避免组合逻辑产生的毛刺被后端采到。调制端的data_in同步也很重要,如果data_in是异步信号,和时钟没有固定关系,直接拿来切步进可能出现亚稳态。

2.3 解调模块代码:过零检测与多数表决

解调端我用的是过零检测法,也叫鉴频法。原理很简单:2FSK信号频率高,过零间隔就短;频率低,过零间隔就长。在数字电路里,“过零”体现在方波上升沿上,所以只需要统计两个上升沿之间经历了多少个50MHz时钟周期,再跟门限比较,就能判断当前码元是0还是1。

如果载波频率是1MHz,两个上升沿之间的时钟周期数约等于50;如果是2MHz,约等于25。取中间值THRESHOLD=37,当场计数小于等于37就判为高频码元1,大于37就判为低频码元0。

单个周期的统计结果容易受抖动影响,所以我加了一个简单的多数表决:连续三次过零判决结果,如果至少两次为1,解调输出才判为1;否则判为0。这个滑窗思路能滤掉偶发毛刺,代价是输出延迟增加几个过零周期,但在这个码元速率下完全可接受。

module fsk_demod #( parameter THRESHOLD = 37 )( input wire clk, input wire rst_n, input wire fsk_in, output reg data_out ); reg fsk_in_d; wire edge_up = fsk_in & ~fsk_in_d; always @(posedge clk or negedge rst_n) begin if (!rst_n) fsk_in_d <= 1'b0; else fsk_in_d <= fsk_in; end reg [6:0] cnt; reg [6:0] period_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 7'd0; period_cnt <= 7'd0; end else if (edge_up) begin period_cnt <= cnt; cnt <= 7'd1; end else begin cnt <= cnt + 1'b1; end end reg bit_judge; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin bit_judge <= 1'b0; end else if (edge_up) begin bit_judge <= (period_cnt <= THRESHOLD) ? 1'b1 : 1'b0; end end reg [1:0] hist; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin hist <= 2'd0; data_out <= 1'b0; end else if (edge_up) begin hist <= {hist[0], bit_judge}; if (hist[0] + hist[1] + bit_judge >= 3'd2) data_out <= 1'b1; else data_out <= 1'b0; end end endmodule

这里有一个细节我最初也踩过:edge_up是在clk上升沿下采到的fsk_in边沿,而cnt此时还没有被清零,所以period_cnt保存的是刚刚结束的那个周期的计数值,逻辑上是自洽的。要注意rant_cnt计数位数。50MHz时钟下,两个上升沿最大间隔不会超过2FSK最低频率对应的周期,1MHz周期是50个时钟,留7位计数足够。

2.4 顶层例化与跨时钟域处理细节

顶层相对简单,把调制和解调两个模块用wire连接起来。fsk_mod的输出fsk_out直接接到fsk_demod的输入fsk_in上。

module fsk_top ( input wire clk, input wire rst_n, input wire data_in, output wire fsk_out, output wire data_out ); fsk_mod u_mod( .clk (clk), .rst_n (rst_n), .data_in(data_in), .fsk_out(fsk_out) ); fsk_demod u_demod( .clk (clk), .rst_n (rst_n), .fsk_in (fsk_out), .data_out(data_out) ); endmodule

这里强调一下跨时钟域问题。data_in如果来自外部异步设备,或者来自另一个时钟域,直接进调制模块先打两拍是最低成本的同步手段。fsk_out是内部调制模块的寄存器输出,本身就与clk同步,所以解调模块不用再次同步fsk_in,直接采样即可。如果以后把fsk_out送到外部引脚再由外部板卡回采,那就要重新考虑输入同步了。

3. Quartus工程搭建与Testbench仿真验证

3.1 工程创建、文件组织与仿真环境设置

我用的Quartus Prime 18.1标准版,器件选Cyclone IV系列一款常用型号。新建工程时直接选择Empty Project,工程名和顶层模块名保持一致,比如fsk_top。然后把fsk_top.v、fsk_mod.v、fsk_demod.v三个文件加入工程,编译一次确认语法没有问题。

编译通过只是第一步,RTL仿真还需要单独配置。Quartus标准版不自带原生仿真器,需要调用ModelSim。菜单路径是Assignments -> Settings -> EDA Tool Settings -> Simulation,把仿真工具设为ModelSim-Altera,然后在Test Benches栏里新建一个测试文件条目,填上Test Bench名字和顶层module名,比如fsk_top_tb。设置好后,点击Tools -> Run Simulation Tool -> RTL Simulation,Quartus会自动调用ModelSim并加载工程里的RTL文件和testbench。

如果你习惯手动仿真也可以。先把三个源文件和fsk_top_tb.v编译进ModelSim工程,再对fsk_top_tb做仿真,不需要额外加动作库,因为工程里没有调用器件原语。手动仿真的命令大概是vlib work、vlog、vsim、add wave、run -all这一串。

3.2 Testbench激励设计与发送序列规划

Testbench里需要做这几件事:生成50MHz时钟,释放复位,给出一段有规律的基带序列,然后拉长仿真时间让解调端把每个码元都恢复出来。50MHz时钟周期20ns,所以#10翻转一次clk。

码元速率我取20kbps,也就是每个码元持续50微秒,简写为#50000。这个速率比载波频率低很多,1MHz载波在一个码元里有50个周期,解调端足够完成多次过零检测和多数表决。如果码元速率取太高,单个周期统计来不及,解调就容易错。

`timescale 1ns/1ps module fsk_top_tb; reg clk; reg rst_n; reg data_in; wire fsk_out; wire data_out; fsk_top dut( .clk (clk), .rst_n (rst_n), .data_in (data_in), .fsk_out (fsk_out), .data_out(data_out) ); initial begin clk = 1'b0; forever #10 clk = ~clk; end initial begin rst_n = 1'b0; data_in = 1'b0; #100; rst_n = 1'b1; data_in = 1'b0; #50000; // 码元0 data_in = 1'b1; #50000; // 码元1 data_in = 1'b0; #50000; // 码元0 data_in = 1'b1; #50000; // 码元1 data_in = 1'b1; #50000; // 码元1 data_in = 1'b0; #50000; // 码元0 data_in = 1'b1; #50000; // 码元1 #100000; $stop; end endmodule

发送序列我用了0101101,这样既有单周期切换,也有连续两个相同码元的情况,能同时考察模块在码元切换点和高低电平持续段的响应。$stop结束仿真,ModelSim里会自动停在最后一条语句处。

3.3 波形判读与门限参数计算

仿真跑完后,重点看几根信号。第一,data_in为低时,fsk_out波形明显稀疏;data_in为高时,fsk_out波形明显密集。第二,data_out应当与data_in有相同的高低规律,但会滞后几百个纳秒甚至更多,这是正常的,不是设计错误。

关于门限参数,有必要单独算一笔账。系统时钟50MHz,1MHz载波的一个完整周期对应50个时钟周期;2MHz载波对应25个时钟周期。理论门限取中间值37比较合适。如果两个载波频率换了,比如f0=455kHz、f1=910kHz,那就先算各频率下的周期计数值,再取平均。公式是TH=(Fclk/f0+Fclk/f1)/2。

载波频率50MHz时钟下周期计数建议门限
1MHz5037
2MHz2537
455kHz11082
910kHz5582

由于DDS的相位累加器存在量化误差,实际输出频率不是严格整数,个别周期计数值可能有正负1的抖动。门限取在25和50的正中间,余量最大,不容易误判。

4. 常见问题排查、调试心得与扩展思路

4.1 解调输出滞后:是设计缺陷还是正常现象

几乎每个人都遇到过data_out比data_in慢的情况,慢半个码元、慢一个码元都有可能。首先明确一点,过零检测不是边采边出,它必须等一个完整的载波周期才能统计出本次过零间隔。多数表决又额外增加了几次判决的累加延迟,所以滞后是原理决定的,不是代码写错了。

如果只是观察波形,滞后不影响判断正确性。但如果要做误码率统计或者和发送序列对齐比较,就不能拿原始data_in直接比。比较稳妥的做法是在Testbench里把data_in打若干拍后再和data_out比较,或者把发送序列存到一个数组里,等待固定延迟后和data_out逐位比对。对于这个工程,因为每次判决要等边界到来,延迟大致等于几个载波周期,实际仿真时直接肉眼判定规律一致即可。

4.2 毛刺误判与相位跳变问题

如果调制模块用“两个独立载波+组合MUX”实现,在切换瞬间很容易出现毛刺。原因是两个载波相位不一致,MUX切换时输出电平可能在一个极短时间里出现非预期跳变,这个跳变会被解调端当作一个过零沿,导致周期计数偏小,进而误判成高频。

这也是我始终坚持用单累加器、切步进的原因。单累加器只有一个相位累加器,从1MHz步进切到2MHz步进时,相位值是连续累加的,不存在两路载波切换的相位差。输出端再寄存一拍,基本上杜绝了组合毛刺。

如果你手里的工程已经按MUX方案写好,又不方便改架构,至少要做到两件事:第一,两路载波都打一拍寄存器再进MUX;第二,MUX的输出再打一拍,把组合逻辑和外部完全隔离开。这能掩盖大部分毛刺,但治标不治本,真正做无线通信系统时还是要用相位连续方案。

4.3 综合告警、板级测试与资源优化

这个工程综合后资源占用很低,主要逻辑就是相位累加器和计数器,LUT使用量可以忽略不计。但有一些告警值得注意。常见的是“inferred latch”警告,通常是因为if语句缺少else分支,或者case没有默认分支。我在解调的多数表决那段就专门补了else,避免综合出锁存器。

板级测试时,把fsk_out引到空闲引脚,用示波器看频率切换;把data_out引到LED或者另一路示波器探头看恢复出来的基带信号。注意引脚电平一般是3.3V,示波器探头倍率要设置正确。如果直接在板上用按键产生data_in,务必要做按键消抖,否则边沿乱跳会让调试过程非常痛苦。

4.4 调试顺序建议与后续扩展方向

最后分享一个我实际踩坑后总结出来的调试顺序,建议按这个路径来。第一步,单独仿真调制模块,把data_in固定为0,看fsk_out是不是1MHz,再固定为1,看是不是2MHz,排除频率控制字计算错误。第二步,单独仿真解调模块,直接用一个理想的2FSK激励代替调制输出,看data_out是否还能恢复,排除解调逻辑本身的问题。第三步,再把调制和解调串起来跑完整链路,此时如果出错,问题基本集中在同步和接口上,而不是原理上。

这个顺序能帮人省下大量排查时间。如果你后续想把这个工程往更真实的方向扩展,可以考虑把方波改成DDS查找表输出正弦波,再加一级数字低通滤波;解调端可以尝试相关解调或锁相环解调,误码性能会比过零检测好一个档次。整套代码和思路都是围绕FPGA数字逻辑展开的,不需要额外的模拟芯片,也方便随时在Quartus里改参数重新跑仿真。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询