DDS是一种用于通过单个固定频率的参考时钟信号生成任意波形的频率合成器,被广泛用于测试测量仪表和通信系统中。也是学习FPGA大学生电子设计竞赛备赛必学的原理性技能,在这里我们通过FPGA Verilog代码编程示例一步步让大家理解一下DDS的基本概念、构成以及各项指标的含义。

硬禾学堂专门设计了一款通过小脚丫FPGA来学习数字系统应用的平台:

基于小脚丫FPGA的综合训练口袋实验系统沟通

以及一款针对高校电子设计竞赛的训练平台:

基于小脚丫FPGA的电赛训练平台

这两款平台可以支持任意一款的小脚丫FPGA核心板,第一款平台的DAC部分都是由R-2R网络构成的,第二款平台上使用通用的高速DAC IC,它们的代码基本上是一致的,只是通用的高速DAC IC会需要转换时钟进行同步,而R-2R只需要数据线,无需时钟。

它们都能通过FPGA中的Verilog逻辑编程实现以下的功能并能达到相应的性能:

  • DDS的主时钟选择为12M(案例中前面的例子)和120MHz(案例中后面的例子,由小脚丫外部的12MHz输入时钟,通过内部PLL倍频到120MHz),使用120MHz的时钟能够生成0 - 15MHz(用8个点构成一个周期波形),甚至更高频率的正弦波波形,输出的信号波形可以是正弦波、三角波、锯齿波、方波等;
  • 由于R-2R后面的运算放大器的性能限制,为保证在不同的频率上实现恒定的信号幅度 ,这两款板子的最高输出频率做了一定的限制 - 第一款平台的输出信号带宽保证到2MHz,信号幅度为0.5-3V,电赛训练板的输出信号带宽可以达到15MHz,信号幅度为2Vpp。
  • 第一款板子都可以通过UART同PC连接,通过PC上的软件(比如LabView或用QT等自编)对FPGA中的参数进行设置,进而调节DDS输出信号的波形、频率、幅度和直流偏移等。

下面我们来看看在这两款平台上通过DDS能够实现的任意波形及实现方法、相关的Verilog代码。


将数字波形表转换为模拟电信号需要数模转换器 - DAC

DDS需要一个DAC将数字合成的波形转化为模拟电信号,DAC的构成可以有多种形式:

  • 高速并行的DAC;
  • 低速串行的DAC;
  • R-2R构成的低成本DAC;
  • 通过PWM + RC构成的简易DAC。

无论使用任何一种DAC,DDS的内核 - 逻辑构成代码都是一样的,差别在于最终驱动DAC时需要不同的时序逻辑、不同的DAC方式能够支持的输出信号频率上限不同。

在我们推荐的两款平台上,都使用了R-2R的10位并行DAC,主要是成本低、速度高、连接简单,R-2R的DAC和商用的高速并行DAC的区别主要有两点:

  • R-2R无需与并行数据同步的时钟,直接将FPGA内部的被时钟同步好的并行数据输出到10个电阻节点上就可以,因此PCB的布线以及电阻值的偏差会影响到输出波形,这个在高速转换的时候一定要注意,R-2R上的10个数据节点到FPGA管脚的长度不同也就意味着每个节点发生电压变换的时间点是有差异的,这也就导致了波形上出现一定的失真(可能用肉眼观察示波器的波形是分辨不出来的);
  • 基准电压为FPGA芯片I/O的电压,比如3.3V,因此在R-2R上得到的模拟波形的峰峰值也就是3.3Vpp,商用的DAC芯片一般都有专门的参考电压管脚,比如自带的1.024V或2V的参考电压,或者连接外部的更高质量的参考电压源,使用起来相对更灵活。

R-2R DAC的工作原理示意图

在我们的两款平台上,通过R-2R模拟输出信号频率在内部主时钟为120MHz时可以达到15MHz并能够得到比较好的波形,实际测试在主时钟216MHz的情况下R-2R也能正常运行,完美得到25MHz的正弦波波形,只是在这两个平台上没有对末级的运算放大器做更复杂的设计,简单的一阶低通滤波以及所选器件的带宽限制了其频率上限,25MHz的正弦波经过末级的调理后幅度变小。

无论采用任何一款DAC,第一步就是要验证从FPGA的逻辑到DAC是否正常工作(尤其是FPGA和R-2R网络连接时管脚的分配),最简单的就是输出固定的数字,看输出的直流电压是否是你换算出来应该得到的模拟量,如下图所示:

产生直流电压用来验证FPGA到DAC的链路是否能够正常工作

细心的朋友可能观察到了,上面的图中用到了反向放大器且为单电源供电的结构。

FPGA到DAC的连接工作正常,下一步就可以开始各种波形的输出了。


3.1 生成方波

DDS常被用来产生周期性的信号,第一步我们先看看如何产生一个方波信号,下面是代码:

module dds_main(clk, dac_data, dac_clk);
input clk;                     //12MHz的外部时钟送给FPGA;
output [9:0] dac_data;            //10位的并行数据输出到R-2R DAC; 
output dac_clk;                   //输出给DAC的并行时钟,在R-2R的DAC中不需要这个时钟信号
 
//创建一个24位的自由运行的二进制计数器,方便取出最高位(0.7秒一个周期)送给LED用作心跳灯指示用, 在此程序中不列出LED的部分
//后面也会讲到为什么DDS波表的地址只有8~12位,而我们选用24位或32位相位累加器的原因
reg [23:0] cnt;
always @(posedge clk) cnt <= cnt + 24'h1;
 
//用它来产生DAC的信号输出
wire cnt_tap = cnt[7];             // 取出计数器的其中1位(bit 7 = 第8位)
assign dac_data = {10{cnt_tap}};   // 重复10次作为10位DAC的值 
assign dac_clk= clk; 
 
endmodule

在这个例子中,我们使用了计数器的第8位来输出,计数器的时钟为12MHz(未使用内部锁相环)的时候,第8位的翻转频率为12MHz/2^8 = 46.875KHz, 所以DAC的输出为46.875KHz的方波,使用计数器的其它位数,得到的方波的频率也会发生改变。

当然,也可以直接将第8位引出来,得到一个同样频率的方波信号,你不妨试一下,并体会一下这两种方式的差异。

3.2 生成锯齿波

我们再看看锯齿波是如何轻松实现的,只需要将上面的代码的最后两行更换成这样:

assign dac_data = cnt[9:0];

cnt[9:0]从0计到1023然后再从0变到1023…..,一个周期的频率为12MHz/2^10 = 11.72KHz,由此可以输出11.72KHz的锯齿波信号。

3.3 生成三角波

根据下面的图示方法实现三角波也不难:

assign dac_data = cnt[10] ? ~cnt[9:0] : cnt[9:0];

频率又小了一半,由此得到的三角波的周期为5.859KHz。

自此,我们用数字合成的方式生成了几种简单的波形,好,但一个真正的DDS“任意信号发生器“能够让我们:

  • 可以生成任意形状的信号;
  • 可以生成任意频率的信号。

为了产生任意波形,DDS依赖两个技巧:

4.1 LUT - Lookup Table(查找表)

第一个技巧就是把“任意形状”的波形样点保存在一个LUT(查找表,类似组织有序的仓库)中,通过查找表格的方式来实现。

基于查找表的波表合成

用这种方法可以实现任何一种形状的波形,比如下面的心型波形,就是通过DDS的方式产生并在示波器上观察到的实际电信号。

用查找表的方式生成的心型模拟信号波形

参见用于小脚丫FPGA综合技能训练板的DDS培训代码,在这个页面中有一段lookup table的代码,示例中使用的为8位地址波表(出于演示的方便,采用了更少的位数)、10位精度的DAC。基于正弦波的对称性,在这个波表中我们只保存了1/4个周期的波形,其它3个1/4周期的波形可以基于对称性来实现,周期的选择采用地址的高两位 - 示例中的phase[7:6]。

module lookup_tables(phase, sin_out);
input  	[7:0] 	phase;
output 	[9:0] 	sin_out;
 
wire     [9:0]   sin_out;
 
reg   	[5:0] 	address;
wire   	[1:0] 	sel;
wire   	[8:0] 	sine_table_out;
 
reg     [9:0]   sine_onecycle_amp;
 
//assign sin_out = {4'b0, sine_onecycle_amp[9:4]} + 9'hff;  // 可以调节输出信号的幅度
assign sin_out = sine_onecycle_amp[9:0];
 
assign sel = phase[7:6];
 
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[5:0];
	     	end
  	2'b01: 	begin
			sine_onecycle_amp = 9'h1ff + sine_table_out[8:0];
			address = ~phase[5:0];
	     	end
  	2'b10: 	begin
			sine_onecycle_amp = 9'h1ff - sine_table_out[8:0];
			address = phase[5:0];
     		end
  	2'b11: 	begin
			sine_onecycle_amp = 9'h1ff - sine_table_out[8:0];
			address = ~ phase[5:0];
     		end
	endcase
end
 
endmodule
 

1/4周期正弦波的波表代码:

module sin_table(address,sin);
output [8:0] sin;         //实际波形表为9位分辨率(1/4周期)
input  [5:0] address;     //64个点来生成1/4个周期的波形,完整的一个周期为256个点
 
reg    [8:0] sin;
 
always @(address)
	begin
                  case(address)	
                      6'h0: sin=9'h0;
                      6'h1: sin=9'hC;
                      6'h2: sin=9'h19;
                      6'h3: sin=9'h25;
                      6'h4: sin=9'h32;
                      6'h5: sin=9'h3E;
                      6'h6: sin=9'h4B;
                      6'h7: sin=9'h57;
                      6'h8: sin=9'h63;
                      6'h9: sin=9'h70;
                      6'ha: sin=9'h7C;
                      6'hb: sin=9'h88;
                      6'hc: sin=9'h94;
                      6'hd: sin=9'hA0;
                      6'he: sin=9'hAC;
                      6'hf: sin=9'hB8;
                      6'h10: sin=9'hC3;
                      6'h11: sin=9'hCF;
                      6'h12: sin=9'hDA;
                      6'h13: sin=9'hE6;
                      6'h14: sin=9'hF1;
                      6'h15: sin=9'hFC;
                      6'h16: sin=9'h107;
                      6'h17: sin=9'h111;
                      6'h18: sin=9'h11C;
                      6'h19: sin=9'h126;
                      6'h1a: sin=9'h130;
                      6'h1b: sin=9'h13A;
                      6'h1c: sin=9'h144;
                      6'h1d: sin=9'h14E;
                      6'h1e: sin=9'h157;
                      6'h1f: sin=9'h161;
                      6'h20: sin=9'h16A;
                      6'h21: sin=9'h172;
                      6'h22: sin=9'h17B;
                      6'h23: sin=9'h183;
                      6'h24: sin=9'h18B;
                      6'h25: sin=9'h193;
                      6'h26: sin=9'h19B;
                      6'h27: sin=9'h1A2;
                      6'h28: sin=9'h1A9;
                      6'h29: sin=9'h1B0;
                      6'h2a: sin=9'h1B7;
                      6'h2b: sin=9'h1BD;
                      6'h2c: sin=9'h1C3;
                      6'h2d: sin=9'h1C9;
                      6'h2e: sin=9'h1CE;
                      6'h2f: sin=9'h1D4;
                      6'h30: sin=9'h1D9;
                      6'h31: sin=9'h1DD;
                      6'h32: sin=9'h1E2;
                      6'h33: sin=9'h1E6;
                      6'h34: sin=9'h1E9;
                      6'h35: sin=9'h1ED;
                      6'h36: sin=9'h1F0;
                      6'h37: sin=9'h1F3;
                      6'h38: sin=9'h1F6;
                      6'h39: sin=9'h1F8;
                      6'h3a: sin=9'h1FA;
                      6'h3b: sin=9'h1FC;
                      6'h3c: sin=9'h1FD;
                      6'h3d: sin=9'h1FE;
                      6'h3e: sin=9'h1FF;
                      6'h3f: sin=9'h1FF;
                   endcase
              end
endmodule

使用cnt[7:0]作为查找表的地址,可以得到同上述方波一样周期(频率为46.875KHz)的正弦波,因为cnt[7:0]遍历一个周期,也就正好输出一个周期的正弦波出来。

lookup_tables u_lookup_table(.phase(cnt[7:0]), .sin_out(dac_data));

这样我们就可以在DAC输出端获得一个非常漂亮的正弦波信号:

通过M2K的示波器功能观察到的46.875KHz的正弦波信号

在上面的示例中我们采用了地址长度为8bit(一个周期的波形共256个点)、幅度量化分辨率为10位的波表,这两个参数究竟如何选取呢?可以参见关于DDS的技术文章汇总 - DDS的原理、技术文章及常用器件

在这里不再赘述,只用两幅图来直观显示一下:

时间轴的量化精度对波形的影响

信号幅度的量化精度对波形的影响

在一个FPGA里面,LUT可以使用组合逻辑资源来实现,也可以通过其内部的BlockRAM(成块的RAM)来存储。在我们的几款小脚丫FPGA平台中,用到Lattice XO2-4000HC系列和Intel的MAX10M02/MAX10M08系列,这些器件内部的BlockRAM都足够保存很大容量的波形数据。

Lattice XO2 FPGA内部的资源,小脚丫用的是XO2-4000HC系列,内部有92kbit的BlockRam

Intel的MAX10 FPGA内部的资源,小脚丫用的是MAX10M02和MAX10M08,内部分别有108Kbit和378Kbits的BlockRam

FPGA的厂商在其工具中也都提供了用于产生正弦波表的IP核,可以直接调用来使用,比如:

1 Lattice在其Diamond中的Sin-Cos table

Lattice的工具Diamond中带的正弦波、余弦波生成IP

2 Intel/Altera在其Quartus中的NCO(数字控制振荡器)

Intel/Altera的工具Quartus带的NCO生成IP

其它形状的波形,乃至调制波形都可以通过一些工具(比如Matlab等)来生成数据,在编译的时候存储在例化好的ROM中,当成一个波形库来使用。

4.2 相位累加器

上面讲述了任意波形的实现,那如何实现“任意频率”?这就体会到用“长”相位累加器这个神器的作用了,它使得DDS输出的信号的频率非常灵活。我们还是用上面生成正弦波的例子来看一下:

lookup_tables u_lookup_table(.phase(cnt[7:0]), .sin_out(dac_data));

计数器本质上就是一个“相位累加器”,因为每一次它累加,它就会移动正弦波360°/256=1.4°

所以我们将计数器改为一个更直观的名字phase_acc,并且为了方便理解相位累加器的作用,我们先将相位累加器的位数设为同查找表LUT的地址相同 - 8位:

reg [7:0] phase_acc;   // 8位
always @(posedge clk) phase_acc <= phase_acc + 8'h1;
lookup_tables u_lookup_table(.phase(phase_acc[7:0]), .sin_out(dac_data));

现在,如果我们想让正弦波的输出信号频率翻倍(x2),只需要每次相位的累加值由“1”变成“2”

always @(posedge clk) phase_acc <= phase_acc + 8'h2;

以此类推,可以让输出信号的频率x3、x4….

但是,如果我们想将输出频率降为原来的一半,我们是不能将相位的累加值变成“0.5”的,因为Verilog只支持整数值,我们需要做的是 - 增加相位累加器的精度

我们来看看是如何通过给相位累加器增加更多的位数来实现的,目前先将相位累加器增加4为,调整为11位累加器,并将代码调整为如下:

reg [11:0] phase_acc; 
always @(posedge clk) phase_acc <= phase_acc + 12'd16;   // 每次增加16而不是1
lookup_tables u_lookup_table(.phase(phase_acc[11:4]), .sin_out(dac_data)); //移位的查找表地址

由于我们将每次的“累加值”增加到了16,而我们在查找表的时候用了phase_acc[11:4]作为地址,我们没有改变其输出频率,但有了多出来的4位,就使得我们能够让相位累加器实现更高的分辨率. 现在我们当然能够降低输出频率到原来的一半 - 通过将相位累加器的累加值从“16”降低到“8”。

将相位累加器的分辨率提高了16,我们可以以1/16的步长获得原始正弦频率的任意倍数, 我们当然可以在相位累加器中增加超过4位更多的位。典型的DDS系统中使用非常“长”位数的相位累加器,比如28位、32位,来实现极高精度和分辨率的输出信号频率。

比如,使用24位相位累加器和12MHz时钟,输出的频率分辨率可以达到0.7Hz!如果使用28位、32位的相位累加器,输出信号的频率分辨率可以达到更高,下面是一个24位相位累加器,用于从12MHz的时钟生成20KHz信号。

reg [23:0] phase_acc;   // 24位相位累加器
always @(posedge clk) phase_acc <= phase_acc + 27962;   //在12MHz的主时钟时输出20KHz频率的波形
lookup_tables u_lookup_table(.phase(phase_acc[23:16]), .sin_out(dac_data)); 

虽然这个例子中的20KHz是比较低,但DDS的输出频率可以达到主时钟的一半~6MHz(或接近6MHz),只需修改上面代码中的相位累加器的值就可以。 在主时钟为12MHz,采用24bit的相位累加器,可以生成频率范围在0.7Hz ~ 6MHz的信号,一般为使波形好看,频率的上限设定为一个周期~至少5个以上采样点,也就是12MHz的时钟,生成的模拟信号的上限频率最好在2MHz。

使用更高的主时钟频率,可以得到更高频率的模拟信号输出,在原来的代码中调用内部锁相环,得到120MHz的主时钟,修改DDS部分的代码如下:

wire clk_120m;                   //内部高倍时钟的名字cll_120m
 
clk_pll u_clk_pll(.CLKI(clk), .CLKOP(clk_120m)); // 以Lattice的IPCore为例产生高速内部时钟
                                                // 从12MHz产生120MHz,用以内部的逻辑以及DAC转换
reg [23:0] phase_acc;   // 24位相位累加器
 
always @(posedge clk_120m) phase_acc <= phase_acc + 2796;   //在120MHz的主时钟时输出20KHz频率的波形
lookup_tables u_lookup_table(.phase(phase_acc[23:16]), .sin_out(dac_data)); 

当然,120MHz的主时钟采用24bit的相位累加器,可调频率的精度则变成了7Hz,得到的模拟信号的频率范围为7Hz ~ 20MHz,如果想得到更精确的频率分辨率,可以:

  • 采用28位相位累加器:0.45Hz ~ 20MHz
  • 采用32位相位累加器:0.028Hz ~ 20MHz

所多付出的资源为几个寄存器而已,这在FPGA内部可以忽略不计。


虽然相位累加器可以实现非常高的精度,但其输出却受到查找表地址位数(内部存储资源决定)的限制,当从一个地址跳转到下一个地址时,输出值会发生“跳跃”,输出信号的频率低的时候这还是可以接受的,但对于较高的输出频率,就会在其输出频谱中引入不必要的频率。

我们将解决此问题。 为了更容易理解,让我们回到12位相位累加器。

// 采用线性内插的正弦波输出
reg [11:0] phase_acc;    //12位
always @(posedge clk) phase_acc <= phase_acc + 12'h1;
 
lookup_tables u_lookup_table(.phase(phase_acc[11:4]), .sin_out(dac_data)); 

上面的代码是每16个时钟从查找表的上一个地址移到下一个,这样使得输出每16个时钟“跳”一次。

一种有效改善的方式就是用相位累加器的最低4位(到现在还没有用的)来线性地对相邻的两个查找表的地址进行插值,实现起来也非常简单 - 使用两个查找表

//使用线性内插的正弦波
reg [11:0] phase_acc;
always @(posedge clk) phase_acc <= phase_acc + 12'h1;
 
//使用两个查找表来获得两个连续的查找表的值
wire [13:0] sine1_lv, sine2_lv;  
lookup_tables my_sine1(.phase(phase_acc[11:4]       ), .sin_out(sine1_lv));
lookup_tables my_sine2(.phase(phase_acc[11:4] + 8'h1), .sin_out(sine2_lv));
 
//现在相位累加器的最低4位需要做一下延迟,以匹配查找表带来的延迟
reg [3:0] phase_LSB_delay1;  always @(posedge clk) phase_LSB_delay1 <= phase_acc[3:0];
reg [3:0] phase_LSB_delay2;  always @(posedge clk) phase_LSB_delay2 <= phase_LSB_delay1;
reg [3:0] phase_LSB_delay3;  always @(posedge clk) phase_LSB_delay3 <= phase_LSB_delay2;
 
//在使用它们做内插之前
wire [4:0] sine1_mf = 5'h10 - phase_LSB_delay3;
wire [3:0] sine2_mf = phase_LSB_delay3;
reg [20:0] sine_p; always @(posedge clk) sine_p <= sine1_lv*sine1_mf + sine2_lv*sine2_mf;
 
assign dac_data = sine_p[20:11];

两个查找表有着相同的值,我们从一个查找表中获取一个值,从另一个表里获取相邻的值(the “phase_acc+1”),这样我们就可以在两个值之间进行线性的内插。

内插能够让我们从现有的DDS中获得更好的分辨率,同时还可以保持LUT比较合理。我们的查找表用256个值来生成正弦波功能,在每个值之间,我们内插15个点,我们就可以得到256*16=4096正线波点,相当于有了更大的查找表。

其它提升的办法:

  • 使用一个32位的相位累加器来覆盖更宽的频率范围,获取更高的精度
  • 降低查找表的需求(只使用一个,或者使用交织的方式,类似CORDIC的方式)
  • 使用sin(x)/x滤波器来取代线性内插
  • 使用“抖动”以增加DAC的分辨率

上面由浅入深讲述了用DDS生成任意波形的工作原理及实现方式,最后看一个完整的基于DDS的任意波形、信号发生器设计的小系统设计实验,它是基于小脚丫FPGA实验系统底板在此系统中我们使用了SPI接口的串行DAC - DAC081S10,并有旋转编码器来控制参数的调整。

信号发生器通常采用“DAC参考电压”配合“模拟通道信号调理”进行信号幅值的调节,市面上大多数信号发生器产品都是采用这种调幅方案。这样在调节的过程中信号的分辨率不会受影响,最大程度保证信号的性能指标,同时也需要额外的DAC等电路控制参考电压。

另外我们也可以在FPGA中增加对信号幅值调节的设计,例如我们将信号的数据乘以一个8bit的因数,然后再将结果右移8位(相当于除以256),然后再把结果输出给DAC电路。将8bit的因数作为变量可调,最后就实现了在FPGA中调节幅值的功能。这种方法不需要额外的DAC改变参考电压,即可实现对幅值的调节,但是由于采用量化后的数据进行,会造成信号数据分辨率的降低。

wire [9:0] signal_dat; //未调幅的波形数据
wire [7:0] a_ver; //用于调幅的因数
 
reg [17:0] amp_dat; //调幅后的波形数据
always @(posedge clk) amp_dat = signal_dat * a_ver;  //波形数据乘以调幅因数
 
wire [9:0] dac_dat; //输出给DAC电路的数据端口
assign dac_dat = amp_dat[17:8]; //取高十位输出,相当于右移8位