
简介正弦波信号发生器的 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点查找表数据位宽8bitDAC位宽兼顾资源与精度累加器位宽选择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已知时钟50MHzN32反推FREQ_WORDFREQ_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对应0V0和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{1b0}}; 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:(1ADDR_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 1b0; forever #10 clk ~clk; end initial begin rst_n 1b0; #100; rst_n 1b1; #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 1b1); 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信号处理的核心思路自己从头搭一遍收获远不止一个正弦波。本文还有配套的精品资源点击获取