☰
FPGA实现FSK调制解调:从DDS到正交鉴频的完整指南
2026/10/20 17:21:06 网站建设 项目流程

做FPGA通信实验的人,大概率经历过这种场面:仿真里FSK调制器输出的正弦波干净又漂亮,解调端的数据也和发射端对得上,结果一出仿真、一上板,误码率突然飙高,甚至恢复出来的符号完全对不上。我最初做这个基于FPGA的FSK实现、从Verilog代码开始撸调制解调全链路时,也在这个环节卡了好几天。这篇内容就是把整个过程从头到尾捋清楚:调制端DDS结构怎么搭、频率控制字怎么算、解调端两种方案各自的优缺点,以及仿真和上板调试里真正容易踩的坑。适合刚学数字通信、或者想在FPGA上跑通一个完整调制解调链路的同学参考。

1. 先搞清楚FSK在FPGA里到底做什么

FSK(Frequency Shift Keying,频移键控)用不同载波频率表示不同数字符号。二进制FSK最常见:用频率f1表示逻辑1,用频率f0表示逻辑0。FPGA里做这件事,本质上不是“模拟产生正弦波”,而是全数字化地控制一个数控振荡器(NCO),让它在两个目标频率之间切换。接收端再通过检测瞬时频率,把符号恢复出来。

1.1 从FSK表达式倒推硬件需求

一个二进制FSK信号可以写成:

[ s(t) = A cos(2\pi f_1 t + \varphi_1) \quad \text{发1时} ]

[ s(t) = A cos(2\pi f_0 t + \varphi_0) \quad \text{发0时} ]

这里最关键的问题不是频率本身,而是“切换频率时相位是否连续”。如果发1切到发0时,正弦波相位突然跳变,频谱就会大幅扩展,带外杂散变得很严重,接收端也更容易误判。FPGA里用DDS结构生成FSK信号,天然具备相位连续性:相位累加器一直累加,只是累加步长随数据变化。这一点比用模拟开关切换晶振的方案好得多,也是FPGA实现FSK的核心优势。

另外一个隐含需求是参数可配。不同项目需要的中心频率、频偏、码率不一样。如果代码里把所有频率相关参数都做成参数化、可动态修改,后续从2FSK扩展到4FSK时就非常方便。我在写代码之前会先画一张简单的数据流图:数据源进入映射逻辑,映射逻辑选择频率控制字,NCO生成正弦波,输出到DAC;接收端则是ADC采样进来,经过鉴频逻辑,输出判决结果。

1.2 采样率、相位累加器与频率分辨率

FPGA里生成正弦波通常用DDS(Direct Digital Synthesis,直接数字频率合成)结构。核心是一个相位累加器,每个时钟周期累加一个频率控制字(Frequency Tuning Word,FTW),累加器溢出一次,就代表正弦波走完一个周期。设系统时钟为Fs,相位累加器位宽为N,目标输出频率为f,则:

[ FTW = round\left(\frac{f \times 2^N}{F_s}\right) ]

频率分辨率就是(\frac{F_s}{2^N})。举个例子,我用50MHz系统时钟、32位相位累加器,分辨率大约是0.0116Hz,在音频频段做FSK完全够用。输出频率范围理论上小于Fs/2,但实际使用建议不要超过Fs/4,否则正弦波一个周期内采到的点数太少,重建波形质量会明显下降。

在这套参数下,如果我做中心频率30kHz、频偏15kHz的2FSK,那么发1对应45kHz,发0对应15kHz。两个频率分别算:

  • 发1:FTW1 = round(45000 × 2³² / 50000000) = 3865471
  • 发0:FTW0 = round(15000 × 2³² / 50000000) = 1288490

这个参数组合有个好处:一个符号周期100us内,45kHz有4.5个完整周期,15kHz只有1.5个完整周期,两者在零交叉次数上差别非常明显,解调端很容易区分。如果频偏太小,比如只有5kHz,肉眼和算法都会更吃力,对滤波器要求也更高。

1.3 码元时间、频偏和带宽的关系

FSK的带宽主要取决于频偏和符号速率。2FSK信号的两个主要谱峰分别在f1和f0附近,带宽大致为:

[ BW \approx 2\Delta f + 2R_b ]

其中(\Delta f)是频偏,(R_b)是符号速率。我的参数里频偏15kHz、码率10kbps,带宽大约40kHz。这个数值决定了DAC输出端低通滤波器要留多少余量,也决定了ADC采样的抗混叠滤波器带宽。很多人在FPGA里做完数字部分,却忽略了后级的模拟滤波,导致频谱上拖出很多不干净的分量,解调自然受影响。

2. 调制端Verilog实现:参数计算与DDS代码

调制端的任务很简单:根据输入数据比特,选择对应的频率控制字,让NCO输出连续相位的正弦波。下面这部分代码是我在实际工程里用过的核心框架,可以直接套用。

2.1 相位累加与查表结构

NCO输出正弦波有两种常见做法:一种是直接用CORDIC算法实时算sin/cos,另一种是查ROM表。对于固定窄带FSK信号,查表法资源更小、时序更宽松。相位累加器的高位作为查表地址,表中存放一个完整周期的正弦幅度值。

我的配置是:32位相位累加器、高10位作为ROM地址、正弦表深度1024点、输出12位有符号数。这样相位量化噪声和幅度量化噪声都在可接受范围。相位截断会带来很小的杂散,但在1024点深度下,杂散电平已经远低于信号本身,做音频或低速数据传输没有问题。

生成正弦表可以用脚本来做,方法也很简单:

import math depth = 1024 bits = 12 amax = (1 << (bits - 1)) - 1 with open("sine_table.mem", "w") as f: for i in range(depth): val = int(round(amax * math.sin(2 * math.pi * i / depth))) f.write(f"{val:04x}\n")

生成的文本文件正好可以被Verilog的$readmemh加载。下面是查表模块:

module sine_lut #( parameter ADDR_W = 10, parameter DATA_W = 12 )( input wire clk, input wire [ADDR_W-1:0] addr, output reg signed [DATA_W-1:0] dout ); reg signed [DATA_W-1:0] table [0:(1<<ADDR_W)-1]; initial begin $readmemh("sine_table.mem", table); end always @(posedge clk) begin dout <= table[addr]; end endmodule

注意这里输出寄存器打了一拍,所以从相位累加器到最终输出会存在一个时钟周期的流水延迟。后续如果级联其他模块,要保持时序上对齐。

2.2 FSK调制模块完整代码

调制模块接收串行输入数据data_in,输出12位有符号正弦波fsk_out。核心就是把频率控制字换成两个局部参数:

module fsk_mod #( parameter PHASE_W = 32, parameter ADDR_W = 10, parameter DATA_W = 12 )( input wire clk, input wire rst_n, input wire data_in, output wire signed [DATA_W-1:0] fsk_out ); localparam [PHASE_W-1:0] FTW_0 = 32'd1288490; // 15000Hz @ Fs=50MHz localparam [PHASE_W-1:0] FTW_1 = 32'd3865471; // 45000Hz @ Fs=50MHz reg [PHASE_W-1:0] phase_acc; reg data_d; wire [PHASE_W-1:0] step = data_d ? FTW_1 : FTW_0; wire [ADDR_W-1:0] addr = phase_acc[PHASE_W-1 -: ADDR_W]; wire signed [DATA_W-1:0] sin_out; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin phase_acc <= 32'd0; data_d <= 1'b0; end else begin data_d <= data_in; phase_acc <= phase_acc + step; end end sine_lut #( .ADDR_W (ADDR_W), .DATA_W (DATA_W) ) u_sine_lut ( .clk (clk), .addr (addr), .dout (sin_out) ); assign fsk_out = sin_out; endmodule

这里有一个细节:我用data_d打了拍,而不是直接用data_in。这是因为data_in可能是异步信号或者相对当前时钟域不同步,直接用它去选择step,容易在数据跳变的时钟沿产生毛刺。打一拍之后,频率切换点被同步到系统时钟沿,相位累加器只会从一个稳定步长平滑切换到另一个稳定步长。

还有一个细节在复位处理。这里复位后phase_acc清零,输出从正弦表的0点开始。实际发射链路里,接收端要做同步捕获,发射端的起始相位不需要特殊处理。但如果后续要做连续相位的音频信号,给DAC输出前可以先加一个直流偏置,避免双极性信号导致单极性DAC丢负半周。

2.3 代码写完之后先别急着写解调端

我自己的调试习惯是:每做完一个模块,立即单独仿真验证,而不是把所有模块全部写完再联调。调制端单独仿真时,主要看三点:波形是否连续、输出频率对不对、数据切换瞬间波形有没有明显毛刺。

波形连续性最好直接看时域波形:把连续0和连续1的比特流输入,观察两个频率段连接处的正弦波相位是不是自然的。如果波形出现“撕裂”或幅度跳变,通常是相位累加器被复位了,或者频率控制字切换时累加器被清零,那就不叫连续相位FSK了。输出频率是否准确,可以在仿真里数一个完整周期占多少个时钟周期,用系统时钟频率除一下即可估算。我遇到过正弦表地址位宽取错的情况,输出频率刚好是目标频率的2倍,看波形一时半会儿发现不了,用周期数验证立刻暴露。

3. 解调端两种可行方案:从过零检测到正交鉴频

解调端是FSK链路里最难的部分,因为接收信号会叠加噪声、幅度波动、频率偏移等因素。最简单的过零检测法适合低码率、高信噪比场景;正交混频加鉴频法则更接近实际通信接收机,抗噪性能更好,代价是资源占用更多。

3.1 过零检测/周期计数法

过零检测的思路非常直接:信号每穿越一次零点,就代表完成了半个周期。在一个符号窗口内统计过零次数,次数多就判为高频对应逻辑1,次数少就判为低频对应逻辑0。

实现时,先对ADC采样值做符号判断,然后检测符号变化。每检测到一次符号变化,计数器加一。一个符号窗口结束,比较计数值和阈值。以我的参数为例,一个符号周期100us内,发1时45kHz约产生90次过零,发0时15kHz约产生30次过零,阈值取60,很容易区分。

这种方案在FPGA里实现非常简单:

reg signed [DATA_W-1:0] adc_d; wire crossing = (adc_in[DATA_W-1] ^ adc_d[DATA_W-1]); reg [7:0] zc_cnt; reg [15:0] sym_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin adc_d <= 12'sd0; zc_cnt <= 8'd0; sym_cnt <= 16'd0; end else begin adc_d <= adc_in; if (sym_cnt < SYM_LEN - 1) begin sym_cnt <= sym_cnt + 1; if (crossing) zc_cnt <= zc_cnt + 1; end else begin sym_cnt <= 16'd0; zc_cnt <= 8'd0; end end end

但这里有一个前提条件:接收端必须知道符号边界在哪里。也就是说,你可能需要在FSK数据流前面先加一段固定码型做同步捕获,或者在判决输出后再做位同步恢复。如果没有位同步,过零计数会横跨两个符号的边界,判决结果就会频繁出错。所以这种方法适合“发射端同步帧 + 固定速率 + 信道干净”的场景,说它是能快速跑通的Demo可以,但作为通用解调方案不够鲁棒。

3.2 正交混频与DQ鉴频

更稳健的做法是把FSK当成FM信号处理,用正交解调恢复瞬时频率。接收信号进入解调器后,先和本地正交载波相乘,分别得到I路和Q路基带信号,再通过低通滤波器滤除高频分量。瞬时频率信息主要体现在I/Q两路的相位差变化上。

这里用了一个经典公式。如果记当前I/Q为I[n]、Q[n],前一拍为I[n-1]、Q[n-1],那么:

[ p[n] = I[n]Q[n-1] - Q[n]I[n-1] ]

这个结果近似正比于相邻两个采样点之间的相位差。当信号频率高于本地载波时,p[n]为正;低于本地载波时,p[n]为负。对2FSK信号,判决p[n]经过低通滤波后的极性,就能恢复出发射端的符号。

最方便的是,FPGA里做这个运算只需要两个乘法器和一个减法器,不需要算atan2,也不用做除法。如果信号幅度波动较大,可以在鉴频前后加AGC或者做归一化,不过对于固定幅度FSK信号,直接判极性已经够用。

下面是DQ鉴频的核心代码框架:

module fsk_demod_dq #( parameter DATA_W = 12 )( input wire clk, input wire rst_n, input wire signed [DATA_W-1:0] adc_in, output wire data_out ); wire signed [DATA_W-1:0] i_mix; wire signed [DATA_W-1:0] q_mix; // 本地NCO生成cos和sin,频率为中心频率30kHz // 混频、低通滤波后得到i_base、q_base reg signed [DATA_W-1:0] i_d; reg signed [DATA_W-1:0] q_d; reg signed [DATA_W*2-1:0] p_freq; reg signed [DATA_W*2-1:0] p_low; reg out_d; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin i_d <= 12'sd0; q_d <= 12'sd0; p_freq <= 24'sd0; p_low <= 24'sd0; out_d <= 1'b0; end else begin i_d <= i_base; q_d <= q_base; p_freq <= i_base * q_d - q_base * i_d; p_low <= p_low + (p_freq - p_low); // 一阶低通,等效系数可调 out_d <= (p_low >= 0) ? 1'b1 : 1'b0; end end assign data_out = out_d; endmodule

本地NCO的设计和调制端一样,只是输出两路正交正弦波。混频之后会产生二倍频分量,低通滤波器必须能把二倍频分量压下去。实际工程里,我一般用一个10阶左右的可配置FIR核,抽头系数在仿真时确定;如果想尽量少占资源,也可以用一阶IIR加多次迭代。这里没有给出NCO和FIR的完整代码,是因为不同开发平台的IP核接口差异很大,但DQ鉴频本身只需要上面的乘减结构,后面接一个判决器就完成了符号恢复。

3.3 两种方案的选型对比

我自己做实验时两种方案都写过,下面这个表可以帮你快速做选型:

对比项过零检测/周期计数正交混频+DQ鉴频
资源占用极低,只要计数器较高,需要NCO、混频器、滤波器
实现复杂度简单,半天能写通中等,链路模块多
抗噪能力一般,信噪比低时抖动大较好,接近实际FM接收机
对符号同步要求高,必须知道符号边界需位同步,但可用于连续鉴频
适用场景低速率、干净信道、入门验证通用FSK解调、后续扩频差分

如果目标是快速在FPGA上跑通一个Demo,过零检测很合适,波形效果直观。如果目标是把它当成一个可用的通信解调器,建议直接上正交+DQ鉴频,后面加位同步环,整体链路更完整。

4. 仿真、上板和调试中真正卡人的地方

最后这部分是经验集中的地方。FSK链路单看每个模块都不难,难的是把前后级串起来之后,问题会从各种意想不到的地方冒出来。

4.1 Testbench要有“发送-信道-接收”完整闭环

很多人写Testbench只给调制器喂一个静态比特流,然后用肉眼看波形对不对。这种做法在早期验证阶段可以,但到整机联调时就会发现,很多问题只有在长码型、随机数据、有噪声的情况下才会暴露。

我习惯在仿真环境里搭建一个完整闭环。Testbench里先定义一个伪随机比特生成器,产生一串足够长的数据;数据送入调制模块,得到12位FSK信号;然后叠加少量高斯噪声(仿真模型里可以用均匀分布近似);最后送入解调模块,同时把发射端数据和解调判决结果逐比特比较,用$display统计误码率。闭环中还可以设置几个测试点:

  • 调制输出点:看频谱和连续相位
  • 混频后I/Q点:看低通滤波效果
  • DQ鉴频输出点:看瞬时频率是否清晰可分
  • 最终判决点:统计误码率

这种分层观测可以快速定位问题到底出在哪一级。如果调制输出是好的,I/Q点也正常,但DQ鉴频发散,那通常是NCO中心频率没对准,或者低通滤波器带宽太宽导致二倍频分量没滤干净。如果DQ输出稳定但判决错,那大概率是阈值方向或者位定时的问题。

4.2 上板调试先解决“数据和时钟同步”的问题

上板之后,第一个要检查的往往不是FPGA内部逻辑,而是开发板上的ADC/DAC链路。我开始调试时,调制器输出加了一个直流偏置,然后用示波器看DAC输出的正弦波,结果波形上叠加了一个明显的台阶。检查后发现是DAC编码格式问题:12位有符号数直接给到了无符号DAC接口,负半周全被截掉了。正确的做法是先做一个有符号转无符号的偏置操作,把16位有符号数映射到12位无符号区间,再送DAC。

解调端也有类似问题。ADC采样回来的数据是二进制补码,如果FPGA内部把补码当普通无符号数处理,符号位就会变成幅值的一部分,整个I/Q混频结果全错。我在代码里统一用signed声明,并且在接口处强制转换,避免这类隐蔽问题。每个上板项目我都会列一个固定的自检清单,按顺序排除:

  1. 断开DAC,给定值0x7FF,测输出电平是否接近满量程
  2. 给定值0x800,测输出是否接近零电平,确认编码方向
  3. 给固定频率的正弦波,用示波器看频谱是否有杂散
  4. 给FSK双音信号,看频率切换点是否抖动
  5. 最后才接完整解调链路

这套顺序特别管用。很多同学一上来就把调制接收全部接好,结果分不清问题在数字逻辑还是模拟链路,排查效率很低。

4.3 误码率不稳时,按顺序查这几类原因

我总结过一套排查顺序,在多个调试项目里都适用。首先是中心频率偏差:FPGA的本地NCO频率和发射端的DDS频率如果分别来自两个参考时钟,必然会有微小偏差,偏差会导致DQ鉴频输出整体偏移,最终判决误码集中在特定符号。解决办法是尽量让收发两侧使用同源时钟,或者用锁相环锁定中心频率。

其次是符号定时恢复。FSK解调输出数据虽然由采样时钟给出,但符号边界不一定刚好落在判决采样点上。如果发射端每个符号持续5000个时钟周期,但解调端没有做位同步,只是固定每隔5000拍取一次判决值,那么时钟偏差累积几个符号后,采样点就会逐渐滑出符号中心,误码率会呈现周期性变差。

然后是滤波器带宽。混频后的低通滤波器如果带宽太窄,会把有效频差衰减掉;太宽又会残留二倍频分量。我的经验是先估算有效信号带宽,再留出30%到50%余量,然后在仿真里扫一遍带宽参数,选择误码率最低的点。

最后是AGC问题。DQ鉴频公式的幅度项是信号幅度的平方,如果接收信号幅度波动很大,判决阈值就会失效。实际信道里这很常见。最简单的处理办法是在ADC之后加一个定点AGC模块,把信号归一化到固定幅度范围。这个模块也可以用指数平滑实现,不必写得太复杂。

整套FSK链路做完之后,我对FPGA通信类项目最大的体会是:代码不是难点,链路才是难点。调制端波形出来很容易,真正决定成败的是后面那些容易被忽略的细节:相位连续性、编码格式、时钟同源、位定时恢复。建议你按这个顺序把每个模块单独验证透彻,再拼完整链路。毕竟仿真里看不到的坑,上板后一个都逃不掉。

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

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

立即咨询