简介:基于AD9767高速双通道DAC芯片与Cyclone IV E FPGA的DDS信号发生器例程,主要面向FPGA开发初学者、电子竞赛备赛者及数字信号处理实验者,帮助解决DDS波形产生、DAC双通道驱动与Quartus工程搭建等常见问题。整个工程基于Quartus 13.0开发,包含顶层模块、PLL锁相环时钟管理、按键参数控制、LED状态指示以及AD9767双路14位并行数据输出设计,可直接学习DDS频率合成核心逻辑。压缩包共148个文件,以Quartus编译生成的.cdb、.hdb等中间构建文件为主,同时提供.v、.tdf源文件、.qsf/.qpf工程配置文件、.sof比特流下载文件与.rpt编译报告,另含.mif初始化文件,可直接打开工程完成综合布局布线,并烧录至板卡验证。资源大小4.18MB,体积轻量,目前已有517人学习,很适合快速上手。通过学习该工程,读者可掌握频率控制字累加、相位累加器设计、DAC写时序控制、FPGA与高速DAC接口连接等方法,还能借鉴作者对工程文件的分层管理方式,将其迁移到任意波形发生器等自研项目中,为课程设计、毕业设计或竞赛准备提供参考。
1. 用Quartus 13.0打开这套AD9767高速双通道DAC与Cyclone IV E FPGA组成的DDS例程,光按一次按键,看示波器从1MHz跳到5MHz,就能把相位累加器、频率控制字、DAC数据锁存三件事全部串起来。以前总倾向于用现成的NCO IP核,但IP核会隐藏几个问题:输出默认是有符号数据,而AD9767的MODE引脚决定偏移二进制还是二进制补码,格式不匹配波形中心就偏。这套例程把PLL、累加器、查找表、按键切换、DAC时钟都放在一个顶层文件里,适合刚做完FPGA入门、想从头搭信号发生器的人,也适合在调AD9767驱动时序的工程师参考。工程基于Quartus 13.0,源码可编译复现。
2. DDS频率合成原理与AD9767双通道接口时序
2.1 频率控制字Fword与相位累加器的换算
DDS的输出频率由相位累加器溢出速度决定。每个D_CLK上升沿把Fword加到自身,累加器溢出意味着一个正弦周期完成。输出频率满足:
Fout = Fword × Fclk / 2^N
N是累加器位宽。代码里Fword声明为32位,因此N=32。假设PLL倍频到100MHz,频率分辨率就是100MHz / 2^32 ≈ 0.023Hz。这个精度意味着只要Fword取整数,实际输出频率与期望值的偏差不会超过半个分辨率,频率计在这个尺度上也测不出误差。
计算Fword最常用的方法是先确定目标频率、工作时钟和累加器位宽,再代入公式。用Python一步算出来。
def fword(fout, fclk, n=32): return int(round(fout * 2**n / fclk)) fclk = 100e6 for f in [1e6, 2e6, 5e6, 10e6]: fw = fword(f, fclk) real = fw * fclk / 2**32 print(f"{f/1e6:>3.1f}MHz -> Fword={fw:>10d} -> {real/1e6:.6f}MHz")这段脚本输出每个档位的Fword和实际输出频率,四个档位的结果是:1MHz对应42949673,2MHz对应85899346,5MHz对应214748365,10MHz对应429496730,实际输出频率都落在目标值附近,相对误差低于10的负七次方量级。把Fword换成十进制写进Verilog常量即可,不需要在FPGA里做浮点运算。
| 目标频率 | Fword | 实际输出频率 |
|---|---|---|
| 1MHz | 42949673 | 1.000000MHz |
| 2MHz | 85899346 | 2.000000MHz |
| 5MHz | 214748365 | 5.000000MHz |
| 10MHz | 429496730 | 10.000000MHz |
2.2 32位相位截断成14位查找表地址
32位累加器不会全部送进ROM,否则要存储2的32次方个点,毫无必要。AD9767只有14位数据总线,工程里取累加器高14位phase_acc[31:18]作为ROM地址,ROM深度16384点,每个点存一个14位正弦采样。低18位相位被截断后,理论上有相位噪声,但噪声幅度落在14位DAC量化噪声之下,实际观察不到。
如果想进一步降低ROM占用,可以改成12位地址,取phase_acc[31:20],ROM深度4096点,输出数据仍保持14位。相位截断误差会变大,在窄带频谱上能看到杂散略高。做信号发生器实验时,16384点是比较稳妥的选择,Cyclone IV E的块RAM只占几个M9K,容量压力可以忽略。
2.3 AD9767的时钟与写信号同源
顶层代码里DACA_CLK、DACB_CLK、DACA_WRT、DACB_WRT四个信号全部assign到D_CLK。AD9767在CLK上升沿锁存14位数据,WRT复用同一个时钟是数据手册允许的简化接法。这个接法省了一路信号,代价是时钟和数据从同一寄存器出来,PCB走线必须尽量等长。D_CLK走线比数据线长时,时钟先到DAC,数据后到,建立时间被压缩,高频档位会先出现毛刺。
PLL倍频到100MHz后,还要注意数据线上的跳变沿不能离时钟上升沿太近。用示波器同时看DACA_CLK和DAC_DATA1,数据翻转应该在时钟上升沿之前完成。看不到明显毛刺就不需要额外调整,这是实验板的常态。
2.4 MODE引脚与数据格式的匹配
AD9767的MODE引脚决定输入格式。MODE接低时按偏移二进制解释,接高时按二进制补码解释。两种格式的零电平点不同:偏移二进制的0x2000对应模拟0V,二进制补码的0x0000对应模拟0V。例程生成的正弦ROM是偏移二进制,因此DAC板上MODE必须接低。如果MODE接高,输出波形会整体平移一个直流偏置,波形幅度不变但中心不在0V。
如果板子设计时MODE已经固定接高,不想动硬件就要在FPGA内部把ROM输出转换为二进制补码,即每个样点减去8192再按有符号数送到DAC。这个转换只占一个减法器。难点在于:只有知道数据格式不匹配时才会去查MODE引脚,否则直流偏置很容易被误判成运放问题。
3. Verilog源码解析:PLL、按键、相位累加器与查找表
3.1 顶层端口与数据通路分工
顶层端口是固定的:CLK50M作为系统时钟输入,Key为按键输入,led[3:0]做档位指示,DACA_CLK/DACB_CLK和DACA_WRT/DACB_WRT是DAC侧的时钟与写信号,DAC_DATA1和DAC_DATA2是两条14位数据总线。
module AD9767_DDS( input CLK50M, input Key, // 按键默认高电平,按下为低 output [3:0] led, // 指示当前频率档位 output DACA_CLK, // DAC A 时钟 output DACB_CLK, // DAC B 时钟 output DACA_WRT, // DAC A 写使能 output DACB_WRT, // DAC B 写使能 output [13:0] DAC_DATA1, // DAC A 数据总线 output [13:0] DAC_DATA2 // DAC B 数据总线 ); wire D_CLK; assign DACA_CLK = D_CLK; assign DACB_CLK = D_CLK; assign DACA_WRT = D_CLK; assign DACB_WRT = D_CLK; assign DAC_DATA2 = DAC_DATA1; // 双通道输出同一波形 pll pll( .inclk0(CLK50M), .c0(D_CLK) ); wire [31:0] Fword; // 按键消抖、相位累加器、查找表和输出寄存器 // 在 3.2 ~ 3.5 节分别展开 endmoduleDAC_DATA2镜像DAC_DATA1,是例程的缺省策略:双通道输出完全一致的波形。如果需要两路独立波形,这里就要改成第二套累加器和ROM的输出。注意,DACA_WRT和DACB_WRT直接复用D_CLK而不是各自生成,这保证了两个通道锁存沿完全同步。
3.2 内部上电复位与按键消抖
原始代码把Rst_n注释掉,原因是不少开发板没有复位键。没有外部复位时,必须在FPGA内部生成一个上电复位信号,否则计数器、状态寄存器在上电后处于不确定状态。常见做法是用一个计数器在系统时钟下计数:
reg rst_n = 1'b0; reg [15:0] rst_cnt = 16'd0; always @(posedge CLK50M) begin if (&rst_cnt) begin rst_n <= 1'b1; end else begin rst_cnt <= rst_cnt + 1; rst_n <= 1'b0; end endrst_cnt计数到全1需要65536个时钟周期,在50MHz下约1.3ms。Cyclone IV E的PLL锁定时间通常在几毫秒到几十毫秒之间,这里只是覆盖了PLL未稳定的窗口,严格做法是把pll_locked接入复位释放逻辑,PLL锁定后再放行整个DDS。如果板子有独立复位键,直接用外部复位会更干净,但大多数下载器开发板没有。
按键消抖与边沿检测是另一组独立逻辑。按键默认高电平,按下变低。先用两级寄存器同步,避免亚稳态直接进入状态机,再检测下降沿:
reg key_d1, key_d2; always @(posedge D_CLK) begin key_d1 <= Key; key_d2 <= key_d1; end wire key_press = key_d2 & ~key_d1; // 检测按下瞬间两个时钟周期后key_d2变成低电平,key_press变成单周期脉冲。这个信号只作为触发,不会影响相位累加器的连续运行。如果按键抖动明显,可以在key_d2之前加计数器,等待稳定约10ms再更新电平。以下是一种补全的防抖写法:
reg [19:0] db_cnt; reg key_db; always @(posedge D_CLK) begin if (Key != key_db) begin db_cnt <= db_cnt + 1; if (db_cnt == 20'd999_999) // 100MHz下约10ms key_db <= Key; end else db_cnt <= 20'd0; enddb_cnt计数到接近100万时key_db才跟随输入,也就是说只有电平持续稳定10ms才认为按键真正按下,机械抖动产生的多个边沿都被过滤掉。
3.3 频率档位切换与相位累加器
按下按键一次,freq_idx加一,四档循环。Fword通过Fword_r组合逻辑选择后assign给顶层wire。注意Fword是wire型变量,不能在多个always块里直接赋值,用一个组合always给Fword_r赋值再assign,是避免多驱动的常用做法。
reg [1:0] freq_idx; always @(posedge D_CLK) begin if (!rst_n) freq_idx <= 0; else if (key_press) freq_idx <= freq_idx + 1; end reg [31:0] Fword_r; always @(*) begin case (freq_idx) 2'd0: Fword_r = 32'd42949673; // 1MHz 2'd1: Fword_r = 32'd85899346; // 2MHz 2'd2: Fword_r = 32'd214748365; // 5MHz default: Fword_r = 32'd429496730; // 10MHz endcase end assign Fword = Fword_r;Fword_r只在freq_idx变化时更新,正常工作时保持不变,因此assign出来的Fword不会在任意时刻跳变,避免了频率字在累加过程中被改一半的情况。四组频率字都按100MHz时钟计算,如果PLL改成125MHz输出,这四个常数必须重新算。
相位累加器本身只是一个32位无符号加法器。溢出自然回绕,不需要判断进位。每个D_CLK上升沿加一次Fword,输出的相位序列就是频率字对应的相位离散点。
reg [31:0] phase_acc; always @(posedge D_CLK) begin if (!rst_n) phase_acc <= 32'd0; else phase_acc <= phase_acc + Fword; end这里复位置零可以让上板后从一个已知相位开始,方便用示波器观察两个DAC通道的相位关系。单通道应用时复位与否不敏感,关键是复位释放前D_CLK必须已经稳定。
3.4 PLL锁相环参数配置
PLL核在Quartus 13.0里的配置路径是Tools → MegaWizard Plug-In Manager,选择ALTPLL。输入50MHz,c0输出100MHz,相移0度。关键参数如下:
| 参数 | 值 | 说明 |
|---|---|---|
| Reference clock | 50MHz | 板载晶振 |
| c0 multiply/divide | 2 / 1 | 输出100MHz |
| Phase shift | 0度 | 不延迟 |
| Duty cycle | 50% | 默认 |
选择100MHz而不是125MHz满速,是因为工程要为建立保持时间留裕量,且100MHz与2的32次方累加器配合计算频率字时数字更规整。若板载晶振是25MHz,倍频比改成4比1。改完PLL后,所有频率档位的Fword都要按新D_CLK重新算一遍,否则按键切频后实际频率和档位名对不上。
3.5 正弦查找表与输出寄存器级
正弦表可以用MIF文件配合LPM_ROM,也可以直接用$readmemh读入十六进制文件。工程目录下放一个sine14.hex,内容用脚本生成:
import math DEPTH = 16384 with open("sine14.hex", "w") as f: for i in range(DEPTH): v = int(round(8192 + 8191 * math.sin(2 * math.pi * i / DEPTH))) v = max(0, min(16383, v)) f.write(f"{v:04X}\n")脚本生成的是偏移二进制正弦值,范围0到16383,中心点在8192,正好对应AD9767偏移二进制模式的零电平。ROM模块如下:
module sine_rom( input wire clk, input wire [13:0] addr, output reg [13:0] q ); reg [13:0] mem [0:16383]; initial $readmemh("sine14.hex", mem); always @(posedge clk) q <= mem[addr]; endmoduleROM输出比地址晚一个时钟周期。地址来自phase_acc[31:18],所以读出的数据本身就和D_CLK有固定的一拍延迟。为了把数据对齐到D_CLK上升沿,输出到DAC之前再加一级寄存器:
reg [13:0] dac_data_r; always @(posedge D_CLK) begin if (!rst_n) dac_data_r <= 14'd8192; else dac_data_r <= sin_q; end assign DAC_DATA1 = dac_data_r;这样DAC_DATA1在D_CLK上升沿之后稳定跳变,与DACA_CLK同源同相位。多一级寄存器对单频正弦没有可闻影响,但在按键切换频率的瞬间,能避免数据和时钟错位造成的输出毛刺。
4. Quartus 13.0工程文件、引脚约束与上板排查
4.1 编译产物文件的角色
工程列表里的文件名很劝退,但它们都不是源码,而是Quartus 13.0在分析综合和布局布线过程中生成的中间文件。文件角色如下:
| 文件类 | 来源阶段 | 是否需要保留 |
|---|---|---|
| .vpr | 工程记录 | 保留 |
| .map.cdb / .map.bpm | 分析与综合阶段 | 可删除 |
| .cmp.cdb / .cmp.bpm | 适配阶段 | 可删除 |
| .root_partition.*.cdb | 根分区数据 | 可删除 |
| .ammdb | 数据库缓存 | 可删除 |
真正决定工程属性的是.qpf和.qsf,前者是工程入口,后者保存引脚约束、时序约束和器件型号。如果压缩包里有它们,双击.qpf即可打开;如果只有.vpr而缺.qpf/.qsf,需要新建空工程并添加所有.v源文件,再重新做引脚分配。拿到工程后先删除db文件夹、incremental_db文件夹、.qws、.qdf再编译,能避免大量莫名其妙的版本报错。
4.2 引脚分配与I/O电平匹配
AD9767的数据总线是14位,加上两组时钟和写信号,共约20个FPGA引脚。引脚分配在.qsf里指定,典型写法:
set_location_assignment PIN_E1 -to CLK50M set_location_assignment PIN_M1 -to Key set_location_assignment PIN_M2 -to DACA_CLK set_location_assignment PIN_M3 -to DACB_CLK set_location_assignment PIN_N1 -to DACA_WRT set_location_assignment PIN_N2 -to DACB_WRT set_location_assignment PIN_P1 -to DAC_DATA1[0] set_location_assignment PIN_P2 -to DAC_DATA1[1] # 其余数据引脚按板卡原理图逐脚补齐 set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to CLK50M set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to DAC_DATA1[0]CLK50M必须落在专用时钟引脚上,Cyclone IV E上通常是PIN_E1这类全局时钟输入,普通IO不能直接驱动PLL的输入。数据引脚和时钟引脚最好安排在同一个Bank,并确保该Bank的VCCIO与AD9767的DVDD电压一致。大多数AD9767开发板用3.3V数字电源,Quartus里选3.3-V LVTTL即可匹配。
DAC侧的空闲引脚也要处理:MODE接地选择偏移二进制,SLEEP接地保持正常工作,FSADJ接一个1kΩ到2kΩ的电阻设定满量程电流。这些引脚不在Verilog代码里,却在电路板上直接影响输出幅度和波形中心。
4.3 编译通过但上板无输出的排查顺序
编译通过只是第一步,上板没波形时,要按照信号链从FPGA往DAC推,不要一上来就怀疑代码。
- 用示波器测CLK50M引脚,确认晶振起振且频率是50MHz。
- 烧录sof后测D_CLK,若板上没有引出来,用SignalTap观测pll的c0。
- 测DAC板的MODE引脚电位,确认和ROM数据格式一致。
- 测IOUTA对AGND电压,静态值应该接近满量程的一半。
- 确认IOUTA引脚对地有负载电阻,通常板载50Ω到100Ω,没有的话要外接。
常见报错与处理方式:
| 现象 | 原因 | 处理方法 |
|---|---|---|
| 编译报Can't place all pins | 引脚分配冲突或时钟引脚被占 | 按板卡原理图重新分配 |
| PLL不锁定 | inclk0频率与PLL设置不匹配 | 核对输入时钟频率 |
| 下载后LED全灭 | 内部复位没有释放 | 检查复位计数器是否依赖D_CLK而D_CLK未起振 |
| DAC输出是直流 | MODE与数据格式不匹配 | 核对MODE引脚与ROM编码 |
| 高频档波形毛刺明显 | 数据与时钟偏斜过大 | 数据线加33Ω串阻或缩短走线 |
内部复位用系统时钟计数,如果PLL没有锁定,D_CLK不稳会让复位释放后的逻辑跑在错误状态。稳妥做法是把pll_locked与内部复位用与逻辑组合,等PLL稳定后再放行整个DDS。
5. 从单频正弦到正交输出的验证与扩展
先把第一组验证做完:示波器探头接IOUTA,开交流耦合,垂直档位放100mV/div,触发源选D_CLK所在引脚。按下Key,波形频率按1、2、5、10MHz循环变化,LED指示与档位对应。10MHz档时每周期只有10个采样点,正弦边缘会显得近似梯形,这不是设计错误,是采样点数不够。想要10MHz更平滑,把D_CLK提到125MHz,但要确认板级时序还能满足。
如果只看LED不放心,可以用SignalTap抓phase_acc高8位和Fword。添加探针后用Key上升沿触发,一次抓够4个档位的跳变,能看到频率字从42949673到85899346再到214748365的变化,这和示波器上的频率切换完全同步。SignalTap抓内部信号比示波器测量引脚更直接,尤其当板上没有引出数据线时。
扩展成双通道正交信号源是最有价值的改动。正交意味着DAC_A输出sin,DAC_B输出cos,相位差固定为90度。实现方法是给DAC_B增加第二个相位累加器,初值设为32'h40000000,也就是2的30次方,正好四分之一个周期:
reg [31:0] phase_acc_b; always @(posedge D_CLK) begin if (!rst_n) phase_acc_b <= 32'h40000000; // 相位初值90度 else phase_acc_b <= phase_acc_b + Fword; end wire [13:0] sin_q_b; sine_rom u_sin_b( .clk (D_CLK), .addr (phase_acc_b[31:18]), .q (sin_q_b) );再把DAC_DATA2从镜像assign改为sin_q_b对应寄存器,双通道就从同相变为正交。两个通道的相位差只取决于累加器初值,不会随频率切换累积漂移,这是DDS相比模拟移相器的明显优势。
频域验证时,10MHz输出的杂散主要来自相位截断与ROM量化。仅考虑量化噪声,14位DAC的信噪比极限约86dB,实际SFDR通常落在60到70dBc,主要限制来自DAC的微分非线性和时钟抖动。如果杂散峰离主频很近,多半是数据线与时钟之间的串扰,通过降低FPGA输出驱动强度或加串阻可以改善。
本文还有配套的精品资源,点击获取