FPGA纯RTL驱动AD9910:SPI时序、状态机与IO_UPDATE实战
简介这份资源面向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 20d100_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 {1b0, 7h07}; // 写操作地址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_fsmfsm从reg_map读取地址和数据通过spi_clk_gen同步移位输出写完所有寄存器后拉高cfg_doneio_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_mapreg_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 2d0; localparam S_LOAD 2d1; localparam S_SHIFT 2d2; localparam S_UPDATE 2d3; 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 8d100) 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空闲电平检查一遍这两个地方翻车概率最高。希望帮到你。本文还有配套的精品资源点击获取