简介:正弦波信号发生器的 Verilog 实现,面向数字电路课程学习者与 FPGA 入门人群,以查表法为主线,覆盖角度二进制化、查找表索引、时序计数等关键环节,可帮助理解如何在硬件层面生成连续正弦波。压缩包共 689 个文件、约 4.04MB,以 .v/.vhd 源码、.dat 波形数据、.mif 查找表以及 Quartus 工程文件为主,另有仿真脚本和报告文件,便于对照 RTL 设计、功能验证与工程实现。目前已有 3236 人学习下载,适合在 Quartus/ModelSim 环境下对照源码深入理解正弦波生成思路。资料内目录结构清晰,既有可综合的 Verilog 模块,也有完整的仿真验证环境,配合相位累加与量化误差处理等设计细节,能直观展示低速应用中查表法的完整落地路径。通过这套代码,读者可以掌握正弦波发生器的模块划分、时序控制方法以及从仿真到综合的工程化组织方式。 前几天调一个音频测试项目,需要给后级滤波器灌一路干净的1kHz正弦波当基准信号,结果实验室的函数信号发生器正好被同事借走。我翻出抽屉里的FPGA开发板,想着与其干等,不如直接用Verilog写一个正弦波信号发生器。从查DDS资料到上板出波,前后折腾了两个晚上,中间踩了不少坑。这篇文章把关键参数怎么算、代码怎么写、ModelSim怎么仿真验证、上板后怎么排查毛刺和频率偏差完整梳理一遍,适合正在学FPGA、或者临时需要一路可调正弦信号的工程师直接参考。
1. 动手前先想清楚:为什么用FPGA做正弦波发生器,以及DDS的基本玩法
1.1 FPGA方案和单片机方案的本质区别
很多人第一反应是拿单片机查表输出正弦波,这在低频段完全可行,但到了几十kHz以上就吃力了,原因在于中断响应和指令周期会带来时间抖动,波形上表现为严重的相位噪声。FPGA做这件事的优势在于,所有逻辑都在时钟沿驱动下并行运行,相位推进是确定性的,不会受到软件分支、中断优先级这些因素干扰。换句话说,FPGA适合做信号发生器,不是因为它算得快,而是因为它的时序绝对稳定。
而且FPGA方案修改频率非常方便,只需要改一个频率控制字参数,不需要动硬件。从0.1Hz到几MHz,同一套代码都能覆盖,范围非常宽。这正是DDS(直接数字频率合成)架构的典型应用场景。
1.2 DDS的核心思想:把正弦波看成匀速转圈
DDS听起来高大上,实际思想很朴素。你可以把正弦波想象成一个点在单位圆上匀速转动,它的纵坐标随时间变化就是正弦曲线。每过一个时钟周期,这个点转过的角度是固定的,转得越快,输出正弦波的频率就越高。
在数字电路里,这个"角度"用相位累加器表示。累加器位宽为N位,数值范围从0到2^N-1,恰好对应相位0到2π。每个时钟周期累加一个频率控制字FREQ_WORD,累加器溢出后自然循环,相当于点转完一整圈回到起点。所以DDS本质就一句话:相位累加器不断累加,用累加值的高位去查正弦表,查到对应的幅度输出。
1.3 架构确认:三段式,各司其职
我最终确定的架构有三个部分:
- 相位累加器:纯数字逻辑,每个周期加一次FREQ_WORD,输出相位值
- 正弦查找表:把相位值映射成对应的正弦幅度,用ROM/BRAM实现
- 输出寄存器:在时钟沿锁存查表结果,消除组合逻辑毛刺
这个结构非常经典,FPGA开发板上资源占用极小,可以把主要精力放在参数设计和验证上。
2. 参数计算:先把频率控制字和查找表尺寸定下来
2.1 系统时钟、累加器位宽、查找表深度的三角关系
我的开发板板载时钟是50MHz,所以系统时钟直接取50MHz。接下来要决定累加器位宽和查找表深度。这两个参数直接影响频率分辨率和波形质量,需要一起考虑。
设计参数经过权衡后如下:
| 参数 | 取值 | 说明 |
|---|---|---|
| 系统时钟 | 50MHz | 板载晶振 |
| 输出频率目标 | 1kHz | 测试基准信号 |
| 相位累加器位宽 | 32bit | 频率分辨率约0.0116Hz |
| 查找表深度 | 1024点 | 每个周期采样1024点 |
| 查找表数据位宽 | 8bit | DAC位宽,兼顾资源与精度 |
累加器位宽选择32位,是因为50MHz时钟下,32位累加器的频率分辨率是50000000/2^32,约0.0116Hz。也就是说,输出频率最小可以精细到0.01Hz级别,对于绝大多数信号源需求都绰绰有余。如果只需要粗调,用24位甚至20位也可以,但32位是折中方案,资源开销几乎可以忽略。
2.2 频率控制字计算过程
DDS的输出频率公式如下:
f_out = FREQ_WORD × f_clk / 2^N
其中N是累加器位宽。目标输出1kHz,已知时钟50MHz,N=32,反推FREQ_WORD:
FREQ_WORD = f_out × 2^N / f_clk = 1000 × 4294967296 / 50000000 = 85899.34592
取整后FREQ_WORD = 85899。这里不可能取小数,只能取整,带来的实际频率偏差大概在千分之几Hz级别,完全可以忽略。如果项目对频率精度有极高要求,可以通过增大N来降低取整误差,或者采用小数分频的思路进一步补偿。
2.3 查找表地址位的取舍
查找表深度1024,意味着地址需要10位。相位累加器是32位,到底取哪几位做地址?这个细节非常关键,我就曾在这里翻过车。
正确做法是取累加器的高10位,也就是phase[31:22]。原因是地址跨度必须均匀覆盖整个周期,高10位的变化是步进式的,每个时钟周期前进FREQ_WORD对应的高位增量,地址变化均匀。反过来如果取低10位,地址完全取决于累加器的低端抖动,波形会变成一团乱码。
至于相位截断带来的杂散,10位地址对应相位分辨率约0.35度,配合8位幅度精度,在示波器上已经看不出明显失真。如果后续想做更高精度的信号源,可以把查找表加深到12位或14位,代价是多用几个BRAM,思路完全一样。
3. 波形查找表的数据源:用MATLAB离线生成,别手算
3.1 为什么选择离线生成ROM初值
查找表内容本质上是正弦函数值的量化结果。虽然也可以直接在Verilog里用case语句硬编码,但1024个点手写显然不现实,而且后期如果想换波形(方波、三角波、任意波形),手工维护成本极高。更优雅的做法是用MATLAB生成初始化文件,在Verilog里通过$readmemh一次性加载到ROM中。
这样做的好处是数据和逻辑分离。数据文件独立于代码,换波形只换文件,代码一行不用改。这也是大多数FPGA信号处理项目的标准做法。
3.2 MATLAB生成HEX文件的具体步骤
MATLAB脚本如下:
depth = 1024; % 查找表深度 width = 8; % 数据位宽 t = 0:depth-1; s = sin(2*pi*t/depth); % 归一化正弦,范围[-1,1] % 映射到8位无符号整数,128对应0电平 lut = round(s * 127) + 128; lut(lut > 255) = 255; lut(lut < 0) = 0; % 写成十六进制文本,每行一个数据 fid = fopen('sin_lut.hex', 'w'); fprintf(fid, '%02x\n', lut); fclose(fid);这里有个细节需要解释:为什么要做128偏移。如果DAC输入要求无符号格式,那么8位数据范围是0到255,正弦波的自然中点是128。如果直接输出带符号的-128到127,还要额外做编码转换,不如在数据生成阶段一次搞定。后面接DAC时,128对应0V,0和255分别对应负满幅和正满幅,波形重心自然归零。
3.3 生成后的检查动作
写完数据文件后,我习惯先检查两个地方。第一个是文件行数是否为1024,确认没有漏数据。第二个是查看文件中间部分是否有128附近的数值,以及首尾是否呈现单调变化的规律。虽然sin函数理论上首尾不衔接,但因为1024个采样点中第1024点实际上是sin(2π×1023/1024),和0点不相等,中间存在一个最小量化台阶,视觉上几乎看不出来。
注意:$readmemh读取的HEX文件要求每行一个十六进制数,不带0x前缀,位数不够自动左补零。MATLAB的
%02x格式正好满足要求。
4. Verilog核心代码逐段拆解:相位累加、ROM查表与顶层例化
4.1 相位累加器模块
相位累加器是整个DDS的心脏,代码非常简短,但设计意图要讲清楚。
module phase_accumulator #( parameter N = 32, parameter FREQ_WORD = 85899 // 50MHz时钟下输出约1kHz )( input wire clk, input wire rst_n, output reg [N-1:0] phase ); always @(posedge clk or negedge rst_n) begin if (!rst_n) phase <= {N{1'b0}}; else phase <= phase + FREQ_WORD; end endmodule很多人第一次写到这里会问:累加器溢出怎么办?答案是根本不用管。Verilog里reg的位宽是有限的,加法溢出后自然回绕,这种回绕恰好对应相位从2π回到0的循环。这是数字电路天然的优势,不需要额外判断。如果把溢出当成错误去处理,反而画蛇添足。
复位信号这里用了异步复位,实际上在FPGA里也可以同步复位,看个人习惯。关键是复位时要清零相位,保证启动瞬间从0相位开始输出。
4.2 ROM查找表模块
ROM这里直接用二维reg数组模拟,通过`$readmemh`加载初始化文件。代码同样很短:
module sin_rom #( parameter ADDR_WIDTH = 10, parameter DATA_WIDTH = 8 )( input wire clk, input wire [ADDR_WIDTH-1:0] addr, output reg [DATA_WIDTH-1:0] data ); reg [DATA_WIDTH-1:0] rom [0:(1<<ADDR_WIDTH)-1]; initial begin $readmemh("sin_lut.hex", rom); end always @(posedge clk) begin data <= rom[addr]; end endmodule注意data用了时序输出,也就是地址变化后,数据在下一个时钟沿才稳定出现在输出端。这是一个故意的延迟,目的有两个:一是避免组合逻辑查表产生的毛刺,二是匹配BRAM的读时序。综合工具在识别出这种写法后,会自动推断成Block RAM,不会用寄存器堆实现,节省大量LUT资源。
4.3 顶层模块,让三个模块协同工作
顶层把相位累加器和ROM查找表连接起来,连接方式非常直观:
module sin_gen_top #( parameter N = 32, parameter FREQ_WORD = 85899, parameter ADDR_WIDTH = 10, parameter DATA_WIDTH = 8 )( input wire clk, input wire rst_n, output wire [DATA_WIDTH-1:0] sin_out ); wire [N-1:0] phase; phase_accumulator #( .N (N), .FREQ_WORD(FREQ_WORD) ) u_phase_acc ( .clk (clk), .rst_n (rst_n), .phase (phase) ); sin_rom #( .ADDR_WIDTH (ADDR_WIDTH), .DATA_WIDTH (DATA_WIDTH) ) u_sin_rom ( .clk (clk), .addr (phase[N-1:N-ADDR_WIDTH]), .data (sin_out) ); endmodule这里核心的一行是phase[N-1:N-ADDR_WIDTH],也就是取累加器的高10位作为ROM地址。为什么要取高10位而不是低10位,前文已经说过。这里再强调一次:相位累加器的高位代表一个周期内的粗位置,低位只是细化的余数。查表只需要粗位置就够了,高位取地址是最符合DDS原理的做法。
4.4 关于输出寄存器的设计选择
ROM模块内部已经在时钟沿打了拍,所以顶层直接输出就是寄存器输出,不需要额外再加一级。但有一种情况例外:如果后续接的DAC芯片对数据建立时间要求特别苛刻,比如要求数据在DAC转换时钟之前提前一段时间稳定,那么可以在顶层输出端再加一级流水寄存器。代价是多一个时钟周期的延迟,对于正弦波这种周期信号来说完全无感,但能明显改善极限情况下的时序余量。
5. Testbench与ModelSim仿真:验证正弦波真的对了
5.1 一个够用且结构清晰的Testbench
写Testbench的目标是尽可能自动化地验证输出是否符合预期。我用的Testbench如下:
`timescale 1ns/1ps module tb_sin_gen_top; reg clk; reg rst_n; wire [7:0] sin_out; // 50MHz时钟,周期20ns initial begin clk = 1'b0; forever #10 clk = ~clk; end initial begin rst_n = 1'b0; #100; rst_n = 1'b1; #2_000_000; // 仿真跑2ms,覆盖两个完整周期 $finish; end sin_gen_top dut ( .clk (clk), .rst_n (rst_n), .sin_out(sin_out) ); // 把输出数据写入文件,方便后续用脚本分析 integer fd; initial begin fd = $fopen("sin_out.txt", "w"); wait (rst_n == 1'b1); while (1) begin @(posedge clk); $fdisplay(fd, "%0d", sin_out); end end endmodule这里有一个操作细节:我把输出数据实时写入文本文件。为什么呢?因为ModelSim里直接看波形虽然直观,但想要精确验证频率、计算谐波失真,光靠肉眼是不够的。把数据导出来,用MATLAB或Python做FFT分析,可以得到定量的结论。这个习惯在调试更复杂的信号处理链路时特别有用。
5.2 ModelSim仿真步骤
ModelSim仿真这类工程的步骤非常固定:
- 新建Project,把三个源文件和一个Testbench文件添加进工程
- 编译全部Verilog文件,确认没有语法错误
- 在Library里找到work库,选中tb_sin_gen_top,右键Simulate
- 在Wave窗口添加信号,建议添加phase、sin_out、clk
- 设置仿真时间2ms,运行
运行结束后,sin_out应该呈现明显的正弦波形,占空比和幅值规律性变化。如果看不到波形,优先检查是否忘记勾选数据格式,把sin_out改为十进制显示,波形看起来会更直观。
5.3 自动验证频率:等一等,那不对怎么办
在仿真中可以用一个笨办法验证频率是否准确:测量同一相位点之间的时间间隔。在ModelSim波形窗口里,找到sin_out第一次达到最大值的位置,再找第二次达到最大值的位置,两者之间的时间差就是输出周期。1kHz信号的周期是1ms,对应50MHz时钟下的50000个时钟周期。
如果想要更精确的自动验证,可以写一段计数逻辑,统计相邻两次过128阈值之间的时钟周期数。这个数字越接近50000,说明频率误差越小。FREQ_WORD取整导致的偏差,实测仿真中的周期计数会在49997到50003之间波动,完全满足测试需求。
6. 上板实测:毛刺、频率偏差和后续扩展
6.1 接DAC的两种方案对比
上板以后最大的问题是:FPGA输出的8位数字信号怎么变成模拟正弦波。我试过两种方案。
最简单的方案是接R-2R电阻网络。用8个电阻搭一个简易DAC,成本低,适合验证功能,缺点是输出阻抗不恒定,接示波器探头时波形会有轻微变形。另一种方案是接真正的DAC芯片,比如AD9708这类并行接口的高速DAC,数据直接接FPGA的GPIO,时钟和控制信号一起连好。我最终用的是后者,因为项目对波形质量有要求。
并行DAC连接时要注意一个关键点:所有数据位在FPGA内部必须是同源寄存器的输出。如果有的位直接来自ROM输出,有的位又经过额外的组合逻辑,那么到DAC输入端时,各bit到达时间会有微小差异,会在波形上产生毛刺。解决方法就是前面多次强调的:输出统一打一拍。
6.2 实测中遇到的毛刺问题和排查链路
我第一次上板测试时,示波器上的正弦波看起来还行,但放大后发现每个周期边缘都有细小的毛刺。排查过程大概分了三步:
先看电源。FPGA开发板的3.3V电源纹波如果在50mV以上,DAC输出就会叠加噪声。给DAC单独加一个LC滤波后,毛刺幅度确实小了一些,但没有完全消失。
再看FPGA到DAC的连线。面包板或者杜邦线过长,会造成信号反射和串扰。我换成了20cm以内的杜邦线,并把8根数据线长度尽量拉齐,波形明显干净。这是经常被忽略但极其常见的问题。
最后排查的是DAC的转换时钟。如果DAC的时钟沿和FPGA输出数据沿正好重叠,DAC就在数据不稳的时刻采样,毛刺自然严重。解决办法是把DAC转换时钟改成反相时钟,或者用FPGA内部的DCM/PLL做一个小的相位偏移,让DAC在数据稳定后再采样。处理完后,示波器上的波形已经比较理想。
6.3 频率偏差的两个来源
实测中发现输出频率是999.96Hz,和理论值1kHz差了一点点。这个偏差主要有两个来源。
第一个来源是FREQ_WORD取整。85899.34取整为85899,带来大约万分之一量级的误差,完全可以接受。如果非要消除这个误差,可以用小数分频的思想,或者把累加器位宽从32提升到40,误差会进一步降低。
第二个来源更隐蔽,是板载晶振本身的误差。便宜的晶振频率偏差可能在±50ppm左右,也就是50MHz实际可能是49.9975MHz。如果项目对频率精度要求较高,就需要换温补晶振或者用FPGA开发板上精度更高的时钟源。
6.4 后续可扩展的方向
这套DDS架构的可扩展性很强,我最近正在做以下几个方向的改造:
一是按键调频。把频率控制字从固定参数改成可配置寄存器,加两个按键实现频率加减。注意按键消抖,否则按一下会跳好几档。二是多波形输出。只需要换MATLAB生成的HEX文件,方波、三角波、自定义任意波形都能出,代码结构完全不用动。三是输出端加FIR低通滤波器。用FPGA内部资源实现滤波,可以把量化噪声和高频杂散压低,波形质量再上一个台阶。
正弦波信号发生器这个题目看起来简单,但它把DDS原理、参数计算、Verilog建模、仿真验证、上板调试完整的链路串了一遍。从需求到代码,从波形到实测,每个环节都有值得琢磨的细节。如果纯从功能角度,网上确实有现成代码可以直接抄,但如果想真正掌握FPGA信号处理的核心思路,自己从头搭一遍,收获远不止一个正弦波。
本文还有配套的精品资源,点击获取