资讯动态

FPGA实现FSK调制:从频率控制字到Verilog代码实战详解

发布时间:2026/10/20 8:08:22 来源:尧图企业网站定制
最近接了个小活儿要在现有数字平台上做一版FSK调制发射前端甲方给的方案里本来想用MCU加专用DDS芯片我琢磨了一下直接改成在FPGA上用Verilog把FSK调制整个做进去。做之前也觉得不就是“两个频率来回切”嘛真动手才发现里面有不少值得抠的细节频率控制字怎么算、相位怎么保持连续、基带数据怎么同步、板上防抖怎么处理。这篇文章就把我完整走通“基于FPGA的FSK实现”的过程整理出来从原理到Verilog代码、再到仿真和板上实测的问题排查全部是实战向的内容。如果你正在学FPGA/Verilog或者想搞一个低成本、可灵活改参数的FSK调制器这篇文章应该能帮你少踩不少坑。代码我尽量写成“拿来就能用”的风格参数也直接给计算过程方便你改成自己的频点和速率。1. 方案选型与核心参数设计1.1 为什么偏偏用FPGA做FSKFSK Frequency Shift Keying频移键控算是最基础的一种数字调制方式了二进制数据“0”和“1”分别对应两个不同的载波频率。以前用MCU做这种调制最方便的办法是外挂一片DDS芯片或者用定时器翻转IO模拟方波FSK。但这两个方案都有很明显的天花板。外挂DDS芯片的好处是模拟输出干净、频率准确但缺点是贵、占板面积大而且MCU要一直占用IO去写频率字调制速率一高就容易跟不上。用定时器翻转IO是最便宜的做法但翻出来的只是方波频谱质量很差而且频率分辨率受定时器位数和系统时钟限制想要精确切到几十kHz频点非常麻烦。FPGA方案的优势在于“一切都在数字域里自己造”。FSK调制器在FPGA里本质上就是一个DDSDirect Digital Synthesis直接数字频率合成器 一个数据选择器不依赖任何外部芯片。频率字想改就改调制速率只受FPGA时钟和DAC输出速率约束而这对于常见的几kbps到几Mbps的数据率来说完全够用。另一个隐性好处是如果你后续想升级成MSK、GFSK只需要在DDS外面加滤波和成型网络架构不用推翻重来。1.2 参数设计的起点波特率与载波频率开始写代码之前我先把这次项目的指标定下来因为后面所有频率控制字和分频系数都从这几个参数推出一个都不能错。本设计采用系统时钟50MHz基带码元速率9600bps载波频率f_mark28.8kHz、f_space38.4kHz输出8位并行正弦波数据给DAC。选f_mark和f_space不是随手定的有个关键原则每个码元周期内最好包含整数个载波周期并且两个频率对基带时钟的倍数关系要整齐。9600bps的码元周期是1/9600约104.17us。28.8kHz的周期约34.72us正好是码元周期的1/338.4kHz的周期约26.04us正好是码元周期的1/4。也就是说每个数据位期间“1”发3个完整的28.8kHz正弦波“0”发4个完整的38.4kHz正弦波。这样在示波器上观察时能清楚数出每个码元里的波形个数接收端做相干解调时误差也小。用这两个频率还有一个好处调制指数刚好为1。FSK的调制指数定义为(f_space - f_mark) / 码元速率这里(38.4k - 28.8k) / 9.6k 1。调制指数为1意味着两个信号在码元周期内正交解调性能比较好。如果要改成GFSK做频谱整形这个参数也方便对照。1.3 DDS频率控制字的计算逻辑DDS的核心思想是用一个N位相位累加器对“相位”进行累加累加步长就是频率控制字。累加器溢出一次相当于走过2π相位也就是一个完整的正弦波周期。频率控制字的计算公式是K F_out * 2^N / F_clk其中F_out是目标输出频率F_clk是DDS工作时钟N是相位累加器位宽。我这次取N32位因为32位累加器的频率分辨率很高算下来最小步进约0.0116Hz50MHz/2^32完全满足使用。用这个公式计算本次两个频点的频率控制字K_mark 28.8kHz * 2^32 / 50MHz 28.8k * 85.89934592 ≈ 2473901K_space 38.4kHz * 2^32 / 50MHz 38.4k * 85.89934592 ≈ 3298535这里的85.89934592就是 2^32 / 50MHz 这个常数为了保险我通常用计算器或Python算避免手写偏移。验证方式很简单把频率字代回公式 F_out K * F_clk / 2^32得到的值应该在目标值附近误差小于0.1Hz。频率控制字的值可以写成32位的整数参数放进Verilog里parameter FREQ_MARK 32d2473901; // 28.8kHz parameter FREQ_SPACE 32d3298535; // 38.4kHz1.4 正弦波ROM表相位到幅度的映射DDS查表法需要一张正弦波ROM表把相位累加器的高8位作为ROM地址ROM输出8位正弦幅度值。ROM深度取256点就够了因为配合50MHz的更新速率和28.8kHz载波一个载波周期内大约有1736个采样点ROM地址会经历多次重复扫描波形还原度完全没有问题。生成ROM表数据我推荐用Python几行代码搞定生成一个hex文件里面每行一个字节数据import math with open(sin_rom.hex, w) as f: for i in range(256): v int(round(127.5 127.5 * math.sin(2 * math.pi * i / 256))) if v 256: v 255 f.write(f{v:02x}\n)这样生成的数据范围是0到255直流分量在128附近。之所以用无符号0~255而不是有符号-128~127是因为多数8位DAC接受无符号二进制输入配合后面的R-2R网络或DAC芯片也更方便。如果你想直接喂给某些有符号输入的DAC把生成式改成v-128再存成补码即可。RTL里用$readmemh加载这张表reg [7:0] sin_rom [0:255]; initial begin $readmemh(sin_rom.hex, sin_rom); end注意使用$readmemh时你的仿真工程和综合工程里都要保证能访问到这个hex文件。最稳妥的办法是把hex文件放在工程根目录或者用绝对路径。综合时很多工具会自动把这种只读数组推断成Block RAM资源消耗很小。2. 模块划分与Verilog代码实现2.1 系统架构概览整个FSK发送链路我分成四个模块clk_div将50MHz系统时钟分频成9600Hz的码元时钟bit_clkpn_gen产生伪随机基带数据模拟真实的“0/1”比特流dds_fskDDS调制核心根据基带数据选择频率控制字并输出正弦波采样点debounce 同步器处理板载按键用于复位和启动基带数据发送模块之间的连接关系很简单码元时钟驱动PN序列产生器PN序列输出接到DDS的数据选择端DDS的8位输出直接接到外部DAC。2.2 码元时钟分频模块50MHz到9600bps分频系数是5208.33取整数5208就行带来的频率误差只有约0.006%对基带时钟来说可以忽略。module clk_div #( parameter DIV 5208 )( input wire clk_50m, input wire rst_n, output reg bit_clk ); reg [12:0] cnt; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt 13d0; bit_clk 1b0; end else if (cnt DIV / 2 - 1) begin cnt 13d0; bit_clk ~bit_clk; end else begin cnt cnt 1b1; end end endmodule这里注意一点我采用“计数器到DIV/2-1就翻转bit_clk”的方式这样bit_clk是占空比50%的方波。如果是只取上升沿作为码元节拍也可以用“计满DIV产生一个单脉冲”的写法但那样bit_clk的占空比会比较难看调试时容易产生误解。2.3 伪随机基带数据源LFSR做实验的时候最忌讳的就是拿手按按键当数据源。手按出来的电平变化没有规律、持续时间不固定你根本没法判断FSK输出波形到底是对是错。所以我加了一个13位LFSR线性反馈移位寄存器在码元时钟的节拍下持续输出伪随机序列这样实测时波形稳定、可重复接收端也能用来测误码率。LFSR的反馈多项式我选的是x^13 x^4 x^3 x 1对应抽头是第13、4、3、1、0位。初值设为全1避免进入全0的锁死状态。module pn_gen ( input wire clk_50m, input wire rst_n, input wire start_pulse, input wire bit_clk, output reg data_out ); reg [12:0] lfsr; always (posedge bit_clk or negedge rst_n) begin if (!rst_n) begin lfsr 13h1FFF; data_out 1b0; end else if (start_pulse) begin lfsr 13h1FFF; data_out 1b0; end else begin lfsr {lfsr[11:0], lfsr[12] ^ lfsr[3] ^ lfsr[2] ^ lfsr[0]}; data_out lfsr[0]; end end endmodule这段代码的一个细节是时序逻辑全部用bit_clk做时钟而不是用50MHz时钟。这样data_out的变化严格对齐码元边界不会出现基带跳变沿和码元时钟错位的现象。至于start_pulse这是从按键模块过来的一拍脉冲实际工程中如果要跨时钟域需要先做两级同步我这里因为bit_clk是从同一个50MHz时钟分频出来的相位关系固定所以直接使用问题不大。2.4 FSK调制核心模块DDS模块是整个设计的核心代码不长但每个信号都要理解清楚。module dds_fsk ( input wire clk_50m, input wire rst_n, input wire data_in, output reg [7:0] dac_out ); parameter FREQ_MARK 32d2473901; // 28.8kHz parameter FREQ_SPACE 32d3298535; // 38.4kHz reg [31:0] phase_acc; reg [7:0] sin_rom [0:255]; wire [31:0] freq_word; wire [7:0] rom_addr; reg data_d; initial begin $readmemh(sin_rom.hex, sin_rom); end // 对基带数据打一拍避免MUX输出出现毛刺影响相位累加器 always (posedge clk_50m or negedge rst_n) begin if (!rst_n) data_d 1b0; else data_d data_in; end assign freq_word data_d ? FREQ_MARK : FREQ_SPACE; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) phase_acc 32d0; else phase_acc phase_acc freq_word; end assign rom_addr phase_acc[31:24]; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) dac_out 8h80; else dac_out sin_rom[rom_addr]; end endmodule这里有几个值得展开的地方。第一freq_word为什么不做成寄存器而是用组合逻辑MUX因为频率切换发生在码元边界切换速度越快越好组合MUX没有时钟延迟data_d一变freq_word立刻切换。相位累加器在下一次时钟沿到来时就会加上新频率控制字几乎无损。第二为什么要把data_in打一拍成data_d直接让data_in控制MUX其实也能工作但如果是外部异步输入的基带信号跳变沿可能正好落在相位累加器时钟建立时间附近。打一拍之后频率切换时刻被严格同步到clk_50m的时钟沿上消除亚稳态风险。代价是基带数据延迟一个时钟周期20ns这对FSK调制完全没有影响。第三ROM地址为什么取phase_acc[31:24]相位累加器高8位的含义是“当前相位处于整个正弦周期的第几个量化区间”正好对应ROM的256个地址。低24位不参与ROM寻址但依然参与累加保证频率分辨率。这种操作叫相位截断会带来一定的杂散但8位DAC输出下这点杂散相对于量化噪声基本可以忽略。2.5 按键消抖与顶层例化板载按键用来控制和复位数据源。机械按键按下时会有几毫秒到十几毫秒的抖动如果不做消抖一次按键可能会被识别成好几次脉冲。我的消抖思路是检测到按键电平变化后启动一个20ms计数器计满后确认电平稳定再产生一个单周期脉冲。module debounce ( input wire clk_50m, input wire rst_n, input wire key_in, output reg key_pulse ); reg [19:0] cnt; reg key_sync0; reg key_sync1; reg key_stable; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin key_sync0 1b1; key_sync1 1b1; key_stable 1b1; end else begin key_sync0 key_in; key_sync1 key_sync0; if (key_sync1 ! key_stable) begin cnt 20d0; key_stable key_sync1; end else if (cnt 20d999_999) begin key_pulse 1b1; end else begin cnt cnt 1b1; key_pulse 1b0; end end end endmodule这个写法比“检测下降沿就立即产生脉冲”要稳得多因为计数器一直在跑消抖时间是20ms。需要注意的是20ms消抖对于9600bps的码元周期约104us来说太长了所以消抖脉冲只用于“复位/启动”绝不能用按键直接当基带数据。顶层模块把上面几个子模块串起来module fsk_top ( input wire clk_50m, input wire rst_n, input wire key_start, output wire base_data, output wire [7:0] dac_data ); wire bit_clk; wire start_pulse; wire pn_data; clk_div u_clk_div ( .clk_50m (clk_50m), .rst_n (rst_n), .bit_clk (bit_clk) ); debounce u_debounce ( .clk_50m (clk_50m), .rst_n (rst_n), .key_in (key_start), .key_pulse (start_pulse) ); pn_gen u_pn_gen ( .clk_50m (clk_50m), .rst_n (rst_n), .start_pulse (start_pulse), .bit_clk (bit_clk), .data_out (pn_data) ); dds_fsk u_dds_fsk ( .clk_50m (clk_50m), .rst_n (rst_n), .data_in (pn_data), .dac_out (dac_data) ); assign base_data pn_data; endmodule顶层里base_data引出来是为了方便示波器对比“基带数据”和“FSK波形”的时间关系。如果不需要可以去掉。2.6 写代码时最容易踩的三个坑第一是复位极性。很多开发板的复位按键按下时是低电平有效如果你在代码里写了if (rst_n)上电后模块不工作所有信号都处于不定状态。我习惯外部统一用一个低电平有效的复位信号代码里都写成if (!rst_n)。第二是组合逻辑里的freq_word。有的初学者会把freq_word写成always (*)里的reg并在里面同时判断data_d和phase_acc导致综合出奇怪的锁存器。正确做法是用一个简单的assign MUX两选一纯组合不涉及时序。第三是ROM初始化文件的路径。仿真时$readmemh(sin_rom.hex)相对路径没问题但有些综合工具在工程目录下找不到文件会静默不初始化结果就是下载到板上后输出全是直流。我的办法是先在本地仿真一遍确认dac_out波形正常再去做综合能省很多排查时间。3. 仿真验证从波形到参数确认3.1 仿真Testbench写法仿真这一步很关键FPGA开发最忌讳直接上板烧录然后对着示波器抓瞎。我先写一个简单的Testbench把顶层模块实例化虚拟出50MHz时钟和复位信号。timescale 1ns / 1ps module tb_fsk_top; reg clk_50m; reg rst_n; reg key_start; wire base_data; wire [7:0] dac_data; initial begin clk_50m 1b0; forever #10 clk_50m ~clk_50m; end initial begin rst_n 1b0; key_start 1b1; #100; rst_n 1b1; #20; key_start 1b0; #20; key_start 1b1; #20; key_start 1b0; end fsk_top u_top ( .clk_50m (clk_50m), .rst_n (rst_n), .key_start (key_start), .base_data (base_data), .dac_data (dac_data) ); endmodule复位释放后我模拟了一次按键动作先拉低再拉高触发消抖模块产生start_pulse然后PN序列开始输出。仿真时间可以设长一点比如跑50ms这样能看到多个9600bps码元。3.2 仿真波形怎么观察看波形时要注意三个东西第一base_data应该是类似随机但有规律的0/1序列每个bit的宽度约104us。如果base_data变成了恒定电平说明LFSR没有工作检查start_pulse有没有正确产生。第二dac_data应该是一个正弦波但仔细观察频率在base_data为“1”时载波周期是34.7us左右为“0”时是26us左右。两者必须能明显区分频率差距不够就说明频控字算错了。第三也是最重要的一点两个频率切换的瞬间波形相位必须连续。DDS天然能保证相位连续因为相位累加器不会清零只是改变了累加步长。如果波形在切换点出现跳变或毛刺多半是data_d同步出了问题或者ROM读出的地址有毛刺。仿真时建议打开数字信号的光标测量把码元周期和载波周期都测一遍。我测试时测到的码元宽度约104.1us和理论值104.17us吻合载波周期也分别在34.7us和26.0us附近说明频率控制字没算错。3.3 仿真通过后再转工程仿真通过不代表板上一定没问题但至少说明RTL逻辑对了。下一步再做引脚约束和综合实现。引脚约束的核心是把clk_50m分配到开发板晶振引脚把dac_data[7:0]分配到连接DAC的8个数据引脚把key_start分配到按键引脚。这里有个实际经验如果你的开发板DAC输出侧已经有运放和滤波器直接看dac_data就能在示波器上看到漂亮的正弦波。如果DAC模块输出直接引出的可能带载能力弱波形会有点变形这时需要在DAC输出端再接一级电压跟随器或低通滤波器。4. 板上实测记录与常见问题排查4.1 实测环境与接线方式我手头这块开发板的DAC是8位并行输入所以我直接把dac_data[7:0]接到DAC数据引脚DAC输出再接示波器。如果你没有并行DAC也可以用8个GPIO搭一个R-2R电阻网络阻值用10k和20k输出端并联一个100pF到1nF的电容做低通滤波也能还原出较好的正弦波。R-2R网络简单、便宜非常适合这种低速FSK实验。实测时我用示波器同时看两路信号一路是base_data另一路是DAC输出。触发方式设为下降沿触发等LFSR输出“1→0”切换时波形会清楚地从28.8kHz变到38.4kHz。4.2 从示波器上读到的关键信息实测中我抓到的波形和仿真几乎一致但有一个现象值得注意LFSR输出的PN序列里连续出现多个相同bit时比如“11111”这5个bit期间输出的是5个完整的28.8kHz正弦波波形连续没有任何中断或相位错位。这正是FSK调制“相位连续”的标志。如果看到某个码元边界处波形周期被切了一半那一定不是DDS的问题而是基带数据和频率切换不同步。如果手头有频谱仪把DAC输出接到频谱仪上看能更直观地看到两个谱峰一个在28.8kHz一个在38.4kHz。两个谱峰的幅度接近、宽度一致调制度就基本合格了。没有频谱仪的话用示波器的FFT功能也能看个大概只是分辨率差点。4.3 常见问题速查表我在调试过程中遇到过不少问题也帮别人排查过类似故障整理成表格方便你对照排查。问题现象可能原因解决方法输出全是直流无正弦波动ROM未初始化或初始化文件路径错误确认sin_rom.hex能被综合工具找到输出频率始终是某一个不随数据切换data_in没有正确连接到PN序列输出检查顶层连线确认base_data有电平变化输出波形在频率切换处有毛刺/台阶data_in没有打拍同步MUX切换毛刺进入相位累加器在DDS内部对data_in打一拍频率和目标计算值偏差较大频率控制字计算错误或系统时钟频率不是预期值验算KF*2^32/Fclk检查时钟引脚约束按键按下无反应消抖计数器位宽不够或按键极性接反确认按键按下时电平是0还是1码元宽度不是104us分频系数DIV写错用示波器测base_data确认bit_clk频率DAC输出幅度太小R-2R网络阻值不匹配或输出驱动能力不足加一级运放跟随器减小输出电阻4.4 板上测量时的几点实操心得第一务必先把base_data和dac_data一起引到示波器上。没有基带波形做参考光看DAC输出很难判断频率切换和码元边界的对应关系。我一开始只引了dac_data结果花了半天也没法确认LFSR是否工作正常。第二如果使用R-2R电阻网络阻值精度很重要。我用的是1%精度电阻搭出来的输出波形线性度还不错。普通5%电阻也勉强能用但正弦波的幅度会有点不均匀影响频谱质量。第三踩过最大的坑是板上复位信号和代码里的复位极性不一致导致上电后所有寄存器状态不确定DAC输出是乱码。后来我养成了一个习惯每块开发板到手先用万用表量一遍复位按键在高/低电平下的电压再写代码。第四如果要在FPGA内部观察中间信号比如phase_acc高8位或freq_word建议用在线逻辑分析仪抓波形。我把dac_data进来之前的那一级信号也留了测试点方便在线调试。信号越多越好别怕占用资源在这种小设计里资源根本不紧张。4.5 如果想让FSK信号真正“发出去”上面所有实测都是基于DAC输出的基带FSK波形频率在几十kHz量级。如果要做真正的射频发射还需要在FPGA外部加混频器和射频功放把FSK信号搬移到目标射频频段。FPGA部分的工作保持不变只需要调整DDS的频率控制字让基带FSK的中心频率落在你最终期望的中频频率上后面的模拟混频电路负责上变频。这个设计还有一个很自然的扩展方向把基带PRBS数据源换成UART接收模块FPGA直接读取串口数据并实时调制就可以做成一个基于FPGA的FSK数传发射机。UART模块在FPGA里也是现成资源加一个波特率发生器就搞定了。我在实际做这个项目中的体会是FPGA做FSK的价值不在于“能跑通”而在于你可以极其精确地控制每个码元里的载波频率和相位关系这在普通MCU方案里很难做到。代码层面其实就这几个模块但参数设计、时序同步、板上消抖这些细节才是真正决定成败的部分。如果你也想练手建议从这套参数开始跑跑通之后再试着改码元速率、改频率控制字每改一次都会对FSK有更深一层的理解。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑