简介:这份资源面向FPGA开发者与数字电路学习者,提供基于Verilog实现的AD9910高精度DDS芯片驱动程序,可用于信号发生器、测试测量、无线通信及雷达系统等需要高质量模拟波形输出的场景。AD9910驱动程序涵盖SPI接口时序控制、配置寄存器读写、状态机管理、跨时钟域同步逻辑以及频率相位幅度参数设置等关键模块,并包含基本的错误检测思路,适合具备一定Verilog基础、希望深入理解FPGA与外设交互的工程师参考。压缩包共4个文件,约8KB,以2个Verilog源码文件为核心,另含1个XML组件描述文件与1个TCL脚本,分别用于逻辑实现、工程元数据记录及开发工具集成。目前已有3430人学习下载,读者可据此掌握DDS驱动设计流程、寄存器配置方法与同步通信要点,并在此基础上扩展适配不同应用需求。
1. 从时序波形到射频输出:为什么AD9910的驱动值得用FPGA硬扛
如果你手头有一块AD9910 DDS芯片,想让它输出稳定可调的正弦波、扫频信号或者调制波形,大概率绕不开一个核心问题:谁来产生那套精确到纳秒级的SPI时序?用MCU跑IO口翻转也能出波形,但一旦涉及多片同步、相位连续切换或者高频刷新,MCU的抖动和中断延迟就会变成玄学。这个项目做的事情很直接——用FPGA写一套AD9910的完整驱动程序,把配置寄存器、更新时钟、IO_UPDATE脉冲这些容易翻车的环节全部用硬件逻辑固化下来。它适合正在做信号发生器、雷达模拟前端、通信基带测试的开发者,也适合想学Verilog状态机与高速串行接口实战的人。整套代码围绕AD9910的串行编程模型展开,不依赖软核,纯RTL实现,上电即可配置。
2. 芯片与协议拆解:AD9910的SPI时序到底特殊在哪
2.1 从数据手册到状态机:AD9910串行端口的关键约束
AD9910的串行编程接口看起来像标准SPI,但有几个硬性约束决定了驱动状态机的结构。第一,它支持MSB优先的写操作,指令字节包含寄存器地址和读写方向,后面跟数据字节,数据长度由寄存器本身决定,1到4字节不等。第二,SCLK的最高频率虽然标称可达70MHz,但实际布线中超过25MHz就容易出现建立保持违例,尤其是经过电平转换芯片之后。第三,IO_UPDATE是一个异步脉冲,用来把串行端口缓冲区的数据搬到活动寄存器,这个脉冲的宽度和相对SCLK的位置有明确要求,太窄可能被忽略,太宽又可能触发多次更新。
我一般会把驱动拆成三个层次:最底层是时钟分频与SCLK生成,中间层是指令与数据的移位发送,最上层是寄存器映射与更新控制。这样拆的好处是,当你要换用不同主频的晶振时,只需要改分频参数,状态机本身不动。
// 串行时钟分频:假设系统时钟100MHz,目标SCLK 10MHz // 分频系数 = 系统时钟 / (2 * 目标SCLK) = 5 localparam CLK_DIV = 5; reg [15:0] clk_cnt; reg sclk_r; always @(posedge sys_clk or negedge rst_n) begin if (!rst_n) begin clk_cnt <= 0; sclk_r <= 0; end else if (clk_cnt == CLK_DIV - 1) begin clk_cnt <= 0; sclk_r <= ~sclk_r; // 翻转产生SCLK end else begin clk_cnt <= clk_cnt + 1; end end这段代码生成一个占空比50%的SCLK,分频系数CLK_DIV决定了SCLK频率。注意sclk_r的初始值是0,第一个上升沿出现在半个周期之后,这给数据建立留出了时间。如果你用的系统时钟不是100MHz,直接改CLK_DIV即可,不需要动后面的移位逻辑。
2.2 寄存器映射:哪些寄存器必须在上电后立即写入
AD9910有二十多个寄存器,但真正影响基本输出的只有几个。CFR1控制PLL倍频、反 sinc 滤波器和数字增益,CFR2决定扫频和调制模式,FTW是频率调谐字,POW是相位偏移字,ASF是幅度比例因子。上电后如果只写FTW和POW,芯片也能出波形,但幅度和相位可能不是你要的。
常见做法是先把CFR1的PLL使能位打开,等PLL锁定后再写其他寄存器。PLL锁定时间典型值在100微秒到1毫秒之间,具体看参考时钟频率和倍频系数。我一般会在FPGA里做一个计数器,上电后延时1毫秒再开始SPI配置,避免PLL没稳就写数据导致输出频率偏差。
// 上电延时与配置触发 reg [19:0] power_on_cnt; reg cfg_start; always @(posedge sys_clk or negedge rst_n) begin if (!rst_n) begin power_on_cnt <= 0; cfg_start <= 0; end else if (power_on_cnt < 20'd100_000) begin power_on_cnt <= power_on_cnt + 1; cfg_start <= 0; end else begin cfg_start <= 1; // 延时1ms后拉高,启动配置状态机 end end这里用20位计数器在100MHz时钟下计数100000次,正好1毫秒。cfg_start作为配置状态机的启动信号,高电平有效。实际项目中我会把这个延时做成参数,方便在不同参考时钟下调整。
2.3 指令字节与数据字节的拼接逻辑
AD9910的写时序是:先发一个字节的指令,bit7是读写方向,bit6到bit0是寄存器地址;然后发数据,数据长度由寄存器决定。比如写FTW寄存器,地址是0x07,数据是4字节。状态机需要根据寄存器地址查表得到数据长度,然后依次移位输出。
// 指令与数据拼接示例:写FTW寄存器 // 指令字节:0x00 | 地址(0x07) = 0x07 // 数据:32位频率调谐字,MSB优先 reg [7:0] cmd_byte; reg [31:0] ftw_data; reg [39:0] shift_reg; // 8位指令 + 32位数据 always @(*) begin cmd_byte = {1'b0, 7'h07}; // 写操作,地址0x07 shift_reg = {cmd_byte, ftw_data}; endshift_reg是一个40位的移位寄存器,高8位是指令,低32位是数据。发送时从bit39开始,每个SCLK上升沿移出一位。注意ftw_data需要根据目标频率计算:FTW = round(2^32 * f_out / f_sysclk),其中f_sysclk是DDS的系统时钟,通常是参考时钟乘以PLL倍频系数。
3. 驱动架构与状态机实现:从空闲到更新的完整链路
3.1 顶层模块划分与信号流向
整个驱动顶层我习惯分成四个模块:spi_clk_gen负责生成SCLK和片选信号,spi_fsm是核心状态机,reg_map存储要写入的寄存器值,io_update_gen产生IO_UPDATE脉冲。信号流向是:外部触发或上电信号进入spi_fsm,fsm从reg_map读取地址和数据,通过spi_clk_gen同步移位输出,写完所有寄存器后拉高cfg_done,io_update_gen收到cfg_done后产生一个宽度可调的脉冲。
这种划分的好处是每个模块职责单一,仿真时容易注入激励。比如你想测试某个寄存器的写入时序,只需要在reg_map里改初值,不用动状态机。
module ad9910_driver ( input wire sys_clk, input wire rst_n, input wire start_cfg, output wire sclk, output wire cs_n, output wire sdata, output wire io_update, output wire cfg_done ); // 内部信号 wire [7:0] reg_addr; wire [31:0] reg_data; wire reg_wr; wire spi_busy; // 实例化子模块 spi_clk_gen u_clk_gen ( .sys_clk (sys_clk), .rst_n (rst_n), .sclk (sclk), .sclk_en (spi_busy) ); spi_fsm u_fsm ( .sys_clk (sys_clk), .rst_n (rst_n), .start (start_cfg), .reg_addr (reg_addr), .reg_data (reg_data), .reg_wr (reg_wr), .sdata (sdata), .cs_n (cs_n), .busy (spi_busy), .done (cfg_done) ); reg_map u_reg_map ( .clk (sys_clk), .rst_n (rst_n), .rd_addr (reg_addr), .rd_data (reg_data), .wr_en (reg_wr) ); io_update_gen u_io_update ( .clk (sys_clk), .rst_n (rst_n), .trig (cfg_done), .io_update(io_update) ); endmodule顶层模块只做例化和连线,不包含任何时序逻辑。reg_addr和reg_data由fsm输出到reg_map,reg_map返回对应地址的数据。spi_busy指示当前是否在传输中,cfg_done表示所有寄存器配置完成。
3.2 状态机设计:IDLE、LOAD、SHIFT、UPDATE四段式
状态机我一般写成四段式:IDLE等待start信号,LOAD从reg_map取出当前寄存器的地址和数据并拼成移位寄存器初值,SHIFT按SCLK节拍移位输出,UPDATE在最后一个bit移完后拉高cs_n并产生内部完成信号。如果还有下一个寄存器要写,就跳回LOAD;否则回到IDLE并输出cfg_done。
localparam S_IDLE = 2'd0; localparam S_LOAD = 2'd1; localparam S_SHIFT = 2'd2; localparam S_UPDATE = 2'd3; reg [1:0] state, next_state; reg [5:0] bit_cnt; reg [39:0] shift_reg; reg [3:0] reg_idx; // 当前写到第几个寄存器 always @(posedge sys_clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; bit_cnt <= 0; reg_idx <= 0; end else begin state <= next_state; case (state) S_IDLE: begin if (start_cfg) begin reg_idx <= 0; next_state <= S_LOAD; end end S_LOAD: begin // 从reg_map读取当前寄存器的地址和数据 shift_reg <= {cmd_byte, reg_data}; bit_cnt <= 0; next_state <= S_SHIFT; end S_SHIFT: begin if (bit_cnt == 39) begin next_state <= S_UPDATE; end else begin bit_cnt <= bit_cnt + 1; end end S_UPDATE: begin if (reg_idx == REG_NUM - 1) begin next_state <= S_IDLE; end else begin reg_idx <= reg_idx + 1; next_state <= S_LOAD; end end endcase end endbit_cnt从0数到39,对应40位移位寄存器的每一位。reg_idx记录当前写到第几个寄存器,REG_NUM是寄存器总数。S_UPDATE状态里判断是否所有寄存器都写完了,如果是就回IDLE,否则递增reg_idx继续写下一个。
3.3 IO_UPDATE脉冲的宽度与位置控制
IO_UPDATE是AD9910用来把缓冲数据搬到活动寄存器的信号。数据手册要求它在SCLK的上升沿之后至少保持一个SCLK周期的高电平,并且在下一个SCLK上升沿之前拉低。我一般会在S_UPDATE状态里用一个计数器产生一个宽度等于SCLK周期的脉冲,确保满足时序。
// IO_UPDATE脉冲生成 reg [7:0] ioup_cnt; reg io_update_r; always @(posedge sys_clk or negedge rst_n) begin if (!rst_n) begin ioup_cnt <= 0; io_update_r <= 0; end else if (cfg_done) begin if (ioup_cnt < 8'd100) begin // 100个系统时钟周期 ioup_cnt <= ioup_cnt + 1; io_update_r <= 1; end else begin io_update_r <= 0; ioup_cnt <= 0; end end end这里用100个系统时钟周期作为脉冲宽度,在100MHz时钟下是1微秒,远大于一个SCLK周期,安全余量充足。如果你希望脉冲更窄,可以减小这个计数值,但不要小于SCLK周期对应的系统时钟数。
4. 避坑与排查:AD9910驱动调试中常见的五个翻车点
4.1 现象:输出频率是设定值的两倍或一半
原因通常是FTW计算时用了错误的系统时钟频率。AD9910的DDS系统时钟等于参考时钟乘以PLL倍频系数,如果你在代码里用的是参考时钟而不是倍频后的时钟,FTW就会算错。解决方法是先确认CFR1里的PLL倍频位设置,然后用实际系统时钟重新计算FTW。我一般会在reg_map里把系统时钟频率做成参数,改的时候只改一处。
4.2 现象:波形幅度很小或者几乎没有输出
先检查ASF寄存器是否被写入了默认值0。AD9910上电后ASF的复位值是0,如果不写这个寄存器,输出幅度就是零。另外检查CFR1里的反sinc滤波器使能位,如果参考时钟低于100MHz,反sinc滤波器可能会把高频分量压得太低。解决方法是把ASF设成0x3FFF(满幅),然后根据实际需要调整。
4.3 现象:SPI写入后读回寄存器值不对
AD9910的读操作需要把指令字节的bit7置1,然后SCLK继续输出,数据会在SDIO上返回。但很多FPGA驱动只实现了写,没有实现读,所以无法验证。如果你需要读回验证,状态机要增加一个读分支,并且SDIO要改成双向端口。注意读操作时SDIO的方向切换时机,太早会冲突,太晚读不到数据。
4.4 现象:多片AD9910同步时相位不一致
多片同步的关键是IO_UPDATE脉冲要同时到达所有芯片,并且SCLK的走线长度要匹配。如果FPGA的IO_UPDATE输出经过不同的缓冲器,延迟差异会导致相位偏差。解决方法是把IO_UPDATE和SCLK做成差分对或者等长走线,并且在FPGA内部用同一个时钟域产生这些信号。另外,所有芯片的参考时钟最好来自同一个源。
4.5 现象:配置完成后输出有杂散
杂散来源很多,最常见的是SCLK在非传输期间没有拉低。AD9910的SCLK在空闲时应该保持低电平,如果FPGA的SCLK引脚在上电后默认是高,芯片可能会误触发。解决方法是在spi_clk_gen里加一个使能信号,只有在传输时才输出SCLK,其他时候强制拉低。另外,电源去耦和参考时钟的抖动也会影响杂散指标,这些属于硬件层面,驱动层面能做的就是保证时序干净。
5. 二次开发与改造:从固定配置到动态扫频的进阶思路
5.1 把寄存器配置做成可参数化的查找表
原始驱动里寄存器值是写死的,如果你想做扫频或者调制,需要动态改FTW和ASF。我一般会把reg_map改成一个双端口RAM,一个端口给状态机读,另一个端口给外部逻辑写。这样你可以在运行时通过一个简单的写接口更新频率字,状态机在每次配置周期开始时读取最新的值。
// 双端口RAM实现动态寄存器更新 reg [31:0] ftw_ram [0:1]; reg [31:0] ftw_out; always @(posedge sys_clk) begin if (wr_en) begin ftw_ram[wr_addr] <= wr_data; end ftw_out <= ftw_ram[rd_addr]; endwr_addr和rd_addr可以做成乒乓切换,这样在更新频率时不会出现中间态。实际项目中我会用两个地址指针,一个给状态机读,一个给外部写,写完后再切换。
5.2 扫频模式:利用AD9910的数字斜坡发生器
AD9910内部有数字斜坡发生器,可以自动产生频率或相位斜坡,不需要FPGA频繁写寄存器。你只需要配置DRG相关的寄存器,然后触发一个斜坡使能位。这样做的好处是扫频的线性度由芯片保证,FPGA只需要在扫频开始时发一个脉冲。改造方法是增加一组DRG寄存器配置,并在状态机里增加一个扫频启动分支。
5.3 验证方法:用ILA抓SPI波形和IO_UPDATE
调试时我习惯在Vivado里例化一个ILA,抓sclk、cs_n、sdata和io_update四个信号。触发条件设成cs_n的下降沿,这样每次配置都能看到完整的40位数据。重点看sdata在sclk上升沿是否稳定,以及io_update是否在最后一个sclk上升沿之后才拉高。如果sdata有毛刺,检查移位寄存器的时钟域是否和sclk同步。
5.4 一个具体技巧:用状态机输出配置完成标志
很多人在配置完成后直接拉高一个done信号,但外部逻辑可能需要在done之后才能改频率。我一般会让done信号保持到下一次start_cfg到来,这样外部逻辑有充足的时间响应。另外,done信号最好经过一个同步器再输出到其他时钟域,避免亚稳态。
从那以后我每次写AD9910驱动,都会先把IO_UPDATE的脉冲宽度和SCLK空闲电平检查一遍,这两个地方翻车概率最高。希望帮到你。
本文还有配套的精品资源,点击获取