在信号源、示波器等电子系统中,任意波形发生器几乎是所有电子工程师都会接触到的基础仪器,而其背后的核心思想——DDS(直接数字频率合成),也是数字系统与模拟信号之间非常经典的一座桥梁。
项目介绍
本次项目在单块 FPGA 训练平台上,实现了完整的 DDS 任意波形发生器本地控制。系统能够通过开发板上的 K1、K2 按钮与旋转编码器,实现对输出信号振幅与频率的独立控制。核心板上的四个拨码开关,则分别用于选择正弦波、三角波、方波和直流信号输出。同时,通过板载按键可以实现波形量程的快速切换。最终,信号经由高速 DAC 模块输出,而当前波形的所有参数则实时显示在 OLED 屏幕上。
设计思路
整个系统通过旋转编码器和按键进行参数调节,通过拨码开关实现波形选择。核心原理是相位累加器:通过改变输入到累加器的相位增量值,来实现输出频率的稳定与可调。幅度调节则通过一个独立的调幅因数乘法器来完成。其中,正弦波的生成采用了经典的查找表(LUT)方式。
在 Lattice Diamond 软件中综合后的电路原理图如下:

系统功能框图清晰地展示了编码器输入、OLED驱动和DAC数据输出三个主要流程:

下图展示了该设计在目标FPGA芯片上的资源使用情况报告:

简单硬件介绍
DAC模块:本次项目使用了10位分辨率、125Msps采样率的高速DAC模块。在实际设计中,提供给DAC的时钟(DAC_clk)频率为120MHz。该模块的核心是3PD5651E这款10位CMOS数模转换器,其输入信号为时钟信号与10位的并行数据信号。
OLED模块:项目采用了一块128*64分辨率的OLED显示屏,其驱动芯片为SSD1306。因此,从软件驱动角度,我们只需要与SSD1306芯片通信即可控制屏幕。由于开发板引脚已预先配置,本项目采用了4线串行通信方式(SPI),通过DC管脚进行数据/命令模式选择(DATA = 1‘b1, CMD = 1’b0),SCL为时钟线,SDA为数据线,RES用于复位清零。
功能实现
如下图所示,OLED屏幕清晰地显示了当前输出的波形类型(sin, square, triangle)。用户可以选择调节波形的幅值(A)或频率(f)。幅值调节范围为0~1.00V,频率调节范围则为0~20MHz。频率的读数由三部分相加构成:兆赫(M)、千赫(K)和赫兹(Hz),即 (xxM + xxK + xxx) Hz。所有操作均通过旋转编码器调整数值,通过K1/K2按键切换调整对象(频率/幅度),通过拨码开关切换波形。



板上按键功能分配说明图:

实际输出波形效果:
正弦波输出
硬件显示与示波器捕获波形:


方波输出
硬件显示与示波器捕获波形:


三角波输出
硬件显示与示波器捕获波形:


主要代码片段分析
旋转编码器解码
旋转编码器A、B两相之间存在90度相位差。判断逻辑是:若在A相的上升沿时刻B相为低电平,或在A相的下降沿时刻B相为高电平,则编码器为顺时针旋转;反之则为逆时针旋转。由此得到的旋转检测Verilog代码如下:
always@(posedge clk_in or negedge rst_n_in)begin
if(!rst_n_in)begin
Right_pulse <= 1'b0;
Left_pulse <= 1'b0;
end else begin
if(A_pos && B_state) Left_pulse <= 1'b1;
else if(A_neg && B_state) Right_pulse <= 1'b1;
else begin
Right_pulse <= 1'b0;
Left_pulse <= 1'b0;
end
end
end
这段代码可以准确记录编码器左旋或右旋的脉冲。我们需要根据这个脉冲来增减对应的参数(频率或幅度)。在数字电路设计中,参数的存储方式采用了“用16进制数模拟10进制数”的技巧。例如,若幅度数据为 8‘h056,它代表的实际幅度是0.56V。当每一位(每4个二进制位)加满或减满需要进位/借位时,需要编写模仿十进制运算的逻辑。项目中所有数据均按此方法存储和处理。
if(Right_pulse && num_A <= 25'd255) begin
if(num_A[3:0] != 9) begin
num_A<=num_A+25'd1;
end else begin
num_A[3:0] <= 0;
if(num_A[7:4] != 9) begin
num_A[7:4] <= num_A[7:4] + 1;
end else begin
num_A[7:4] <= 0;
num_A[11:8] <= num_A [11:8] + 1;
end
end
end else begin
if(Left_pulse && num_A >= 25'd1) begin
if(num_A[3:0] != 0) begin
num_A<=num_A-25'd1;
end else begin
num_A[3:0] <= 9;
if(num_A[7:4] != 0) begin
num_A[7:4] <= num_A[7:4] - 1;
end else begin
num_A[7:4] <= 9;
num_A[11:8] <= num_A [11:8] - 1;
end
end
end
end
DDS核心:相位累加器与波形生成
直接数字频率合成的核心是一个相位累加器。对于正弦波,我们使用查找表来实现;通过改变相位累加器的步进来实现任意频率输出,幅度调节则通过后续的乘法器实现。
相位累加器本身就是一个计数器,其高位截断输出作为正弦查找表的地址。查找表的每个地址对应正弦波0°到360°的一个相位点值。利用正弦波的对称性,实际上只需存储1/4周期的数据即可。
相位累加器的代码如下:
always @(posedge clk_120m or negedge rst) begin
if (!rst) begin
phase_acc<=0;
end else begin
phase_acc <= phase_acc + num_phase;
end
end
根据公式 Fout = M * Fclk / 2^N 可知,只需改变M值(即相位步进值 num_phase)即可改变输出频率。若M变为原来的两倍,输出频率也变为原来的两倍。因此,需要确定一个基准步进值ΔM,使得此时输出频率为1Hz。当需要N Hz的频率时,将M设为N倍的ΔM即可。本设计采用120MHz时钟,30位累加器,经计算ΔM为9。因此,相位步进值按9的整数倍进行调节,具体计算代码如下:
always@(posedge clk) begin
num_phase_1 = 9*(100*num_f0[11:8] + 10*num_f0[7:4] + num_f0[3:0])
+ 9*(100*num_f1[11:8] + 10*num_f1[7:4] +num_f1[3:0])*1000
+ 9*(10*num_f2[7:4] +num_f2[3:0])*1000000;
end
对于其他波形,方波和三角波可以直接对相位累加器的高位进行简单处理得到。
方波生成:直接取相位累加器的最高位(MSB)。
wire square_tap=phase_acc[29];
assign square_data={10{square_tap}};
三角波生成:取相位累加器的次高位段,并根据最高位进行取反操作,形成锯齿的上升沿和下降沿。
assign triangle_data=phase_acc[29] ? ~phase_acc[28:19]:phase_acc[28:19];
正弦波生成:这是最复杂的部分,涉及到利用1/4周期查找表和象限判断逻辑来重建完整正弦波。
assign sin_out = sine_onecycle_amp[9:0];
assign sel = phase[9:8];
sin_table u_sin_table(address,sine_table_out);
always @(sel or sine_table_out)
begin
case(sel)
2'b00: begin
sine_onecycle_amp = 9'h1ff + sine_table_out[8:0];
address = phase[7:0];
end
2'b01: begin
sine_onecycle_amp = 9'h1ff + sine_table_out[8:0];
address = ~phase[7:0];
end
2'b10: begin
sine_onecycle_amp = 9'h1ff - sine_table_out[8:0];
address = phase[7:0];
end
2'b11: begin
sine_onecycle_amp = 9'h1ff - sine_table_out[8:0];
address = ~ phase[7:0];
end
endcase
end
//查找表:
always @(address)
begin
case(address)
8'h0: sin=9'h000;
… …
8'hff: sin=9'h1FE;
endcase
end
本例中使用的查找表为10位地址、9位数据精度的1/4周期正弦表,共存储256个数据点。
OLED驱动显示
OLED驱动通过状态机循环刷新不同区域的显示内容来实现。每个状态(case)对应屏幕上的一行或一个特定区域的显示数据与坐标,在主循环中不断轮询这些状态,即可实现动态刷新。
if(cnt_main >= 5'd20) cnt_main <= 5'd4;
else cnt_main <= cnt_main + 1'b1;
case(cnt_main) //MAIN状态
5'd0: begin state <= INIT; end
5'd1: begin y_p <= 8'hb1; x_ph <= 8'h10; x_pl <= 8'h00; num <= 5'd16; char <= " ";state <= SCAN; end
5'd2: begin y_p <= 8'hb2; x_ph <= 8'h10; x_pl <= 8'h00; num <= 5'd16; char <= "A: V ";state <= SCAN; end
5'd3: begin y_p <= 8'hb3; x_ph <= 8'h10; x_pl <= 8'h00; num <= 5'd16; char <= "f: Hz ";state <= SCAN; end
... ...
5'd19: begin
... ...
end
default: state <= IDLE;
endcase
end
如上代码所示,通过设定显示起始坐标(y_p, x_ph, x_pl)和要显示的字符串(char),然后跳转到扫描发送状态(SCAN),即可完成一行的更新。在所有显示状态间循环,就能实现整个屏幕信息的实时更新。
项目总结与思考
在本次项目开发过程中,花费了相当精力去研究OLED与高速DAC的工作原理,并仔细阅读了示例程序以整合实现自定义功能。在数据表示上,没有采用常见的二进制转BCD码算法,而是直接使用十六进制数来模拟十进制存储。这样做的好处是,在送往OLED显示时,可以直接按4位一组取出作为显示字符,非常方便。但相应地,在计数(加减)逻辑上就变得较为复杂,需要编写模仿十进制进位/借位的代码。
另外,由于采用查找表法,在120MHz系统时钟下,要输出最高20MHz的信号而不失真,需要进行信号处理和滤波。为此,在DAC输出后端设计了相应的滤波器,确保了高频信号的输出质量。


未来的计划是继续学习开发板上尚未使用的ADC模块,并尝试实践一些更具挑战性的项目,例如闭环控制系统或更复杂的通信协议实现。
通过这个完整的项目,我们从理论到实践,跑通了DDS信号源设计的全部关键环节。对于想要深入学习FPGA和直接数字频率合成技术,或者正在准备电子设计竞赛的同学来说,这是一个非常不错的练手项目。你可以在云栈社区找到更多类似的开源实战项目分享和讨论,与广大开发者一起交流成长。