FPGA实现FSK调制解调:从DDS到正交鉴频的完整指南 做FPGA通信实验的人大概率经历过这种场面仿真里FSK调制器输出的正弦波干净又漂亮解调端的数据也和发射端对得上结果一出仿真、一上板误码率突然飙高甚至恢复出来的符号完全对不上。我最初做这个基于FPGA的FSK实现、从Verilog代码开始撸调制解调全链路时也在这个环节卡了好几天。这篇内容就是把整个过程从头到尾捋清楚调制端DDS结构怎么搭、频率控制字怎么算、解调端两种方案各自的优缺点以及仿真和上板调试里真正容易踩的坑。适合刚学数字通信、或者想在FPGA上跑通一个完整调制解调链路的同学参考。1. 先搞清楚FSK在FPGA里到底做什么FSKFrequency 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里生成正弦波通常用DDSDirect Digital Synthesis直接数字频率合成结构。核心是一个相位累加器每个时钟周期累加一个频率控制字Frequency Tuning WordFTW累加器溢出一次就代表正弦波走完一个周期。设系统时钟为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。两个频率分别算发1FTW1 round(45000 × 2³² / 50000000) 3865471发0FTW0 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:(1ADDR_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 32d1288490; // 15000Hz Fs50MHz localparam [PHASE_W-1:0] FTW_1 32d3865471; // 45000Hz Fs50MHz 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 32d0; data_d 1b0; 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 12sd0; zc_cnt 8d0; sym_cnt 16d0; 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 16d0; zc_cnt 8d0; 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 12sd0; q_d 12sd0; p_freq 24sd0; p_low 24sd0; out_d 1b0; 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) ? 1b1 : 1b0; 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声明并且在接口处强制转换避免这类隐蔽问题。每个上板项目我都会列一个固定的自检清单按顺序排除断开DAC给定值0x7FF测输出电平是否接近满量程给定值0x800测输出是否接近零电平确认编码方向给固定频率的正弦波用示波器看频谱是否有杂散给FSK双音信号看频率切换点是否抖动最后才接完整解调链路这套顺序特别管用。很多同学一上来就把调制接收全部接好结果分不清问题在数字逻辑还是模拟链路排查效率很低。4.3 误码率不稳时按顺序查这几类原因我总结过一套排查顺序在多个调试项目里都适用。首先是中心频率偏差FPGA的本地NCO频率和发射端的DDS频率如果分别来自两个参考时钟必然会有微小偏差偏差会导致DQ鉴频输出整体偏移最终判决误码集中在特定符号。解决办法是尽量让收发两侧使用同源时钟或者用锁相环锁定中心频率。其次是符号定时恢复。FSK解调输出数据虽然由采样时钟给出但符号边界不一定刚好落在判决采样点上。如果发射端每个符号持续5000个时钟周期但解调端没有做位同步只是固定每隔5000拍取一次判决值那么时钟偏差累积几个符号后采样点就会逐渐滑出符号中心误码率会呈现周期性变差。然后是滤波器带宽。混频后的低通滤波器如果带宽太窄会把有效频差衰减掉太宽又会残留二倍频分量。我的经验是先估算有效信号带宽再留出30%到50%余量然后在仿真里扫一遍带宽参数选择误码率最低的点。最后是AGC问题。DQ鉴频公式的幅度项是信号幅度的平方如果接收信号幅度波动很大判决阈值就会失效。实际信道里这很常见。最简单的处理办法是在ADC之后加一个定点AGC模块把信号归一化到固定幅度范围。这个模块也可以用指数平滑实现不必写得太复杂。整套FSK链路做完之后我对FPGA通信类项目最大的体会是代码不是难点链路才是难点。调制端波形出来很容易真正决定成败的是后面那些容易被忽略的细节相位连续性、编码格式、时钟同源、位定时恢复。建议你按这个顺序把每个模块单独验证透彻再拼完整链路。毕竟仿真里看不到的坑上板后一个都逃不掉。