AD9910 这颗芯片但凡做过信号源、雷达回波模拟或者任意波形发生器的朋友应该都不陌生。它是一颗 14 位、1GSPS 采样率的直接数字频率合成器DDS内部集成了 32 位频率调谐字、14 位相位偏移和 14 位幅度控制能输出从直流到 400MHz 以上的正弦波而且跳频速度极快。但很多人第一次拿到它的时候面对那 100 多页的数据手册和一堆寄存器往往不知道从哪里下手。官方倒是提供了一个评估软件可以图形化配置寄存器并导出配置文件但问题在于这个软件导出的东西怎么变成 FPGA 里能用的 Verilog 代码SPI 时序到底该怎么对齐寄存器写入顺序有没有讲究我前后用 AD9910 做过三个项目从最开始的照抄官方配置、到后来自己写状态机逐条写寄存器中间踩过的坑包括但不限于SPI 时钟极性搞反导致寄存器写不进去、忘记等待 IO_UPDATE 导致配置不生效、profile 寄存器地址算错导致输出频率偏差一倍、以及最经典的——读回校验时发现读出来的数据和写进去的完全对不上。这些问题在数据手册里其实都有答案但分散在各个章节而且有些细节手册写得比较含蓄不实际调一次很难理解。这篇文章我会从官方软件的使用讲起然后一步步拆解 AD9910 的 SPI 协议细节最后给出一个可以直接在 FPGA 上跑的 Verilog 实现方案。不管你是刚接触 DDS 的新手还是已经用过 AD9910 但想搞清楚底层细节的老手应该都能从中找到有用的东西。文章里涉及的所有代码和配置参数都经过实际硬件验证你可以直接拿去用也可以根据自己的需求修改。1. 官方软件到底帮我们做了什么1.1 评估软件的核心功能与使用流程ADI 官方为 AD9910 提供了一个评估板配套的图形化软件通常叫 AD9910 Evaluation Software。这个软件的功能其实比很多人以为的要强大它不仅仅是让你点一点看波形更重要的是它可以帮你生成寄存器配置文件。整个使用流程大致是这样的先连接评估板通过 USB 接口与 PC 通信然后在软件界面上设置各个寄存器的值比如系统时钟、PLL 倍频系数、输出频率、幅度、相位等软件会实时计算对应的寄存器数值并显示出来。设置完成之后你可以通过菜单导出寄存器配置格式通常是文本或者二进制文件。这个导出的配置文件非常关键因为它包含了所有寄存器的地址和对应的数值。很多人拿到这个文件之后就直接想办法把它塞进 FPGA 里但这里有个问题导出的文件格式和 FPGA 里实际需要的 SPI 写入时序之间还有一道鸿沟。软件导出的是“寄存器地址数据”的列表而 FPGA 需要的是按照 SPI 协议逐位发送的时序。所以你需要理解这个对应关系才能把配置文件转换成 Verilog 代码。另外评估软件还有一个很实用的功能它可以实时读写寄存器。你可以通过软件界面直接修改某个寄存器的值然后点击写入观察芯片输出的变化。这个功能在调试阶段非常有用比如你不确定某个寄存器的某一位是干什么的可以直接改一下看输出有什么变化。但要注意评估软件通过 USB 转 SPI 的方式与芯片通信它的时序和 FPGA 直接驱动 SPI 是有区别的尤其是时钟频率和片选信号的处理方式。1.2 从软件配置到寄存器映射的转换逻辑评估软件界面上你设置的是物理量比如“输出频率 100MHz”但芯片内部实际存储的是频率调谐字FTW。这两者之间的转换公式在数据手册里有明确给出FTW round(f_out × 2^32 / f_sysclk)。其中 f_sysclk 是系统时钟频率也就是经过 PLL 倍频之后的采样时钟。举个例子如果你的参考时钟是 25MHzPLL 倍频系数设为 40那么 f_sysclk 1GHz。要输出 100MHzFTW 100e6 × 2^32 / 1e9 429496729.6取整就是 429496730转换成十六进制是 0x1999999A。评估软件会自动帮你算这个值但你在写 Verilog 的时候需要自己算或者提前算好放在一个查找表里。这里有个容易出错的地方2^32 是 4294967296不是 4294967295差一个数看起来很小但在高频输出时会导致频率偏差。比如你要输出 400MHzFTW 大约是 1717986918.4取整误差带来的频率偏差大约是 0.23Hz看起来不大但如果你做的是相干系统这个偏差会累积。除了频率调谐字还有相位偏移字POW和幅度比例因子ASF。POW 是 16 位的计算公式是 POW round(phase_deg × 2^16 / 360)。ASF 是 14 位的范围 0 到 16383对应幅度从 0 到满量程。评估软件里你可以直接输入角度和幅度百分比它会自动换算。但在 Verilog 里如果你需要动态改变这些参数就得自己实现这些换算逻辑或者用预计算的方式。还有一点很重要AD9910 的寄存器分为几个大类包括控制寄存器CFR1、CFR2、频率调谐字寄存器、相位偏移字寄存器、幅度比例因子寄存器以及 8 个 profile 寄存器。Profile 寄存器的作用是让你可以预存 8 组不同的频率、相位、幅度配置然后通过外部引脚快速切换。这个功能在跳频或者多波形切换的场景下非常有用。评估软件里可以分别配置这 8 个 profile但导出的配置文件里会包含所有寄存器的值你需要自己区分哪些是全局配置哪些是 profile 相关的。1.3 评估软件导出文件的格式解析与常见陷阱评估软件导出的配置文件通常是文本格式每一行包含一个寄存器地址和对应的数据。比如类似这样的格式地址 0x00数据 0x12345678。但不同版本的软件导出格式可能略有不同有的用十六进制有的用十进制有的还会包含注释行。你在解析这个文件的时候首先要确认格式然后提取出有效的寄存器地址和数据对。这里有一个常见的陷阱评估软件导出的数据可能是小端格式也可能是大端格式取决于软件版本和导出选项。AD9910 的 SPI 接口是 MSB first也就是高位先发。如果你导出的数据是 32 位的但 SPI 一次只能发 8 位你就需要把 32 位数据拆成 4 个字节按照从高到低的顺序依次发送。如果搞反了字节顺序写进去的值就完全错了。我遇到过好几次明明按照配置文件写的但输出频率就是不对后来发现是字节顺序搞反了。另一个陷阱是评估软件里有些寄存器的值是软件自动计算的比如默认的 PLL 锁定时间、DAC 满量程电流等这些值在导出文件里可能是一个推荐值但不一定适合你的硬件设计。比如 DAC 满量程电流评估板上的外围电路和你的板子可能不一样如果你直接照搬可能导致输出幅度不对甚至损坏芯片。所以导出文件里的值只能作为参考关键参数还是要根据你自己的硬件设计来调整。还有一个细节AD9910 的 SPI 接口支持多种配置模式包括单字节传输、多字节传输、以及带地址自增的流模式。评估软件通常使用的是单字节或者双字节传输模式也就是每次传输都带上寄存器地址。但在 FPGA 里为了提高配置速度你可能会想用流模式也就是先发一个起始地址然后连续发送多个数据字节地址自动递增。这两种模式的 SPI 帧格式是不一样的你不能直接把评估软件的配置数据套用到流模式上需要重新组织数据格式。2. AD9910 SPI 协议的底层细节拆解2.1 SPI 模式选择与时钟极性的实际影响AD9910 的 SPI 接口支持 SPI 模式 0 和模式 3也就是 CPOL0/CPHA0 或者 CPOL1/CPHA1。这两种模式的区别在于时钟空闲状态和采样边沿。模式 0 下SCLK 空闲为低电平数据在 SCLK 的上升沿被采样模式 3 下SCLK 空闲为高电平数据在 SCLK 的下降沿被采样。数据手册里说两种模式都支持但实际使用中我强烈建议使用模式 0因为大多数 FPGA 的 SPI IP 核或者自己写的状态机默认都是模式 0调试起来更方便。这里有一个非常关键的细节AD9910 的 SDIO 引脚是双向的既可以作为数据输入MOSI也可以作为数据输出MISO具体方向由读写控制位决定。在写操作时SDIO 是输入在读操作时SDIO 是输出。这意味着你不能像标准的四线 SPI 那样同时有独立的 MOSI 和 MISO而是需要在一个引脚上切换方向。这个切换时机非常关键在发送完读写控制位和地址位之后如果是读操作需要及时把 SDIO 的方向从输出切换到输入否则会冲突。我刚开始做的时候用的是标准四线 SPI 的思维结果发现读回的数据全是 0xFF 或者 0x00后来才意识到 SDIO 是双向的需要在正确的时间点切换方向。具体来说AD9910 的 SPI 帧格式是第一个字节包含读写控制位R/W和 7 位地址的高 7 位第二个字节是地址的低 8 位实际上 AD9910 的寄存器地址是 8 位的但帧格式里分了两个字节来传输然后是数据字节。在写操作时SDIO 一直保持输出在读操作时发送完地址之后SDIO 需要切换为输入然后在接下来的时钟周期里读取数据。还有一个容易忽略的点AD9910 的 SPI 时钟频率最高可以到 70MHz但这是在特定条件下的。实际使用中我建议把 SCLK 控制在 10MHz 到 25MHz 之间这样既能保证配置速度又能留出足够的时序余量。尤其是在使用 FPGA 的普通 IO 引脚而不是专用时钟引脚时过高的频率可能导致信号完整性问题。我在一个项目里把 SCLK 设到了 50MHz结果发现偶尔会出现配置失败的情况后来降到 20MHz 就稳定了。2.2 寄存器地址映射与页切换机制AD9910 的寄存器地址是 8 位的但并不是所有地址都有效。数据手册里给出了完整的寄存器映射表包括 CFR1、CFR2、CFR3、FTW、POW、ASF、以及 8 个 profile 寄存器。每个寄存器的地址和位定义都需要仔细核对。比如 CFR1 的地址是 0x00CFR2 的地址是 0x01CFR3 的地址是 0x02FTW 的地址是 0x07POW 的地址是 0x08ASF 的地址是 0x09而 profile 寄存器的地址从 0x0E 到 0x15每个 profile 占用一个地址。这里有一个非常重要的机制AD9910 的寄存器分为“当前寄存器”和“缓冲寄存器”。你通过 SPI 写入的值首先进入缓冲寄存器只有在 IO_UPDATE 引脚上产生一个上升沿之后缓冲寄存器的值才会被更新到当前寄存器从而影响芯片的输出。这个机制的目的是让你可以一次性更新多个寄存器然后同时生效避免在更新过程中出现中间状态。很多人在调试时发现写入了寄存器但输出没变化就是因为忘记给 IO_UPDATE 信号了。IO_UPDATE 信号的处理也有讲究。数据手册要求 IO_UPDATE 的高电平持续时间至少一个 SYSCLK 周期但实际上为了可靠我通常会保持至少 10 个 SYSCLK 周期。另外IO_UPDATE 的上升沿应该与 SYSCLK 同步否则可能出现亚稳态问题。在 FPGA 里你可以用一个简单的状态机来产生 IO_UPDATE 信号在 SPI 传输完成后拉高 IO_UPDATE保持若干个时钟周期然后拉低。还有一个细节有些寄存器是“只写”的比如 CFR1 和 CFR2你无法读回它们的值。而有些寄存器是可读可写的比如 FTW、POW、ASF 和 profile 寄存器。在读回校验时你需要注意哪些寄存器可以读哪些不能读。我一开始不知道这个区别试图读回 CFR1 的值来校验结果读出来全是 0还以为是自己写错了后来查手册才发现 CFR1 是只写的。2.3 读写时序的逐位分析与状态机设计思路AD9910 的 SPI 写时序可以分解为以下几个阶段首先是片选拉低CSB 从高变低然后是在 SCLK 的上升沿模式 0逐位发送第一个字节这个字节的最高位是读写控制位0 表示写1 表示读接下来的 7 位是寄存器地址的高 7 位。然后是第二个字节也就是地址的低 8 位。注意AD9910 的寄存器地址实际上是 8 位的但帧格式里把它分成了两部分第一个字节的低 7 位是地址的高 7 位第二个字节是地址的低 8 位这里需要仔细看手册。实际上AD9910 的 SPI 帧格式是这样的第一个字节包含 R/W 位和 7 位地址但这个 7 位地址是寄存器地址的高 7 位而第二个字节是地址的低 8 位不对让我重新梳理一下。根据数据手册AD9910 的串行端口通信周期分为两个阶段指令阶段和数据阶段。指令阶段发送一个字节包含 R/W 位和 7 位地址。但这个 7 位地址对应的是寄存器的地址而 AD9910 的寄存器地址是 8 位的所以这里有一个矛盾。实际上AD9910 的寄存器地址是 8 位的但指令字节里只包含了 7 位地址这是因为 AD9910 的寄存器地址范围是 0x00 到 0x1F只需要 5 位就能表示但手册里用了 7 位。所以指令字节的格式是bit7 是 R/Wbit6 到 bit0 是地址。数据阶段则是根据寄存器的宽度发送 1 到 4 个字节的数据。比如 CFR1 是 32 位的需要发送 4 个字节FTW 是 32 位的也是 4 个字节POW 是 16 位的2 个字节ASF 是 14 位的但通常按 16 位处理2 个字节。所以整个 SPI 传输的字节数是1 个指令字节 N 个数据字节。对于 32 位寄存器总共 5 个字节对于 16 位寄存器总共 3 个字节。在 Verilog 状态机设计时你需要根据目标寄存器的宽度来决定发送多少个数据字节。一个比较通用的做法是用一个计数器来记录当前发送的是第几个字节当计数器达到预期值时拉高传输完成信号然后触发 IO_UPDATE。读操作的时序稍微复杂一些。在发送完指令字节之后SDIO 需要从输出切换为输入然后在接下来的时钟周期里读取数据。这里有一个关键点AD9910 在读操作时数据是在 SCLK 的下降沿模式 0或者上升沿模式 3输出的你需要根据选择的模式来正确采样。另外读操作的数据字节数和写操作一样取决于寄存器的宽度。读回的数据可以用来校验写入是否正确但要注意只读寄存器和只写寄存器的区别。3. Verilog 实现从状态机到可综合代码3.1 顶层模块接口定义与时钟域处理在开始写 Verilog 之前先要明确顶层模块的接口。AD9910 的 SPI 接口需要以下信号SCLK、SDIO、CSB、IO_UPDATE以及可选的 RESET。另外如果你要用到 profile 切换功能还需要连接 PS0、PS1、PS2 三个引脚。在 FPGA 侧你需要一个系统时钟比如 50MHz以及一个复位信号。顶层模块的输入输出大致如下输入包括 clk、rst_n、start、addr、data_in、wr_en 等控制信号输出包括 sclk、sdio、csb、io_update 等。时钟域的处理是一个容易被忽略但非常重要的问题。AD9910 的 SPI 时钟是由 FPGA 产生的所以 SCLK 是 FPGA 系统时钟的分频。在写状态机时你需要确保 SCLK 的边沿与系统时钟同步避免出现毛刺。一个常见的做法是用一个计数器来分频系统时钟产生 SCLK。比如系统时钟 50MHz要产生 10MHz 的 SCLK就用一个模 5 的计数器每计数到 2 时翻转 SCLK。这样产生的 SCLK 占空比是 50%而且与系统时钟同步。SDIO 的方向控制也需要仔细处理。在写操作时SDIO 是输出在读操作时SDIO 是输入。在 Verilog 里你可以用一个三态缓冲器来实现assign sdio sdio_oe ? sdio_out : 1bz; 其中 sdio_oe 是方向控制信号sdio_out 是输出数据。在读操作时sdio_oe 置 0SDIO 变为高阻态此时你可以从 sdio 引脚读取数据。注意在切换方向的瞬间要确保没有信号冲突通常需要在发送完最后一个地址位之后延迟一个时钟周期再切换方向。IO_UPDATE 信号的处理也需要注意。在 SPI 传输完成后你需要产生一个 IO_UPDATE 脉冲。这个脉冲的宽度至少是一个 SYSCLK 周期但为了可靠我通常保持 10 个系统时钟周期。IO_UPDATE 的上升沿应该与系统时钟同步避免亚稳态。在 Verilog 里你可以用一个简单的状态机来实现当 SPI 传输完成信号有效时进入 IO_UPDATE 状态拉高 io_update计数到 10 后拉低然后回到空闲状态。3.2 写操作状态机的完整实现与关键代码注释写操作状态机是整个 Verilog 实现的核心。我把它分成几个状态IDLE、SEND_CMD、SEND_DATA、WAIT、UPDATE。在 IDLE 状态等待 start 信号。当 start 有效时锁存地址和数据进入 SEND_CMD 状态。在 SEND_CMD 状态发送指令字节也就是 R/W 位加上 7 位地址。然后进入 SEND_DATA 状态根据寄存器的宽度发送 1 到 4 个数据字节。发送完成后进入 WAIT 状态等待几个时钟周期然后进入 UPDATE 状态产生 IO_UPDATE 脉冲。下面是一个简化的写操作状态机代码示例你可以根据实际需求修改module ad9910_spi_wr ( input wire clk, input wire rst_n, input wire start, input wire [7:0] addr, input wire [31:0] data_in, input wire [2:0] byte_num, // 数据字节数1, 2, 3, 4 output reg sclk, output reg sdio_out, output reg sdio_oe, output reg csb, output reg io_update, output reg done ); // 状态定义 localparam IDLE 3d0; localparam SEND_CMD 3d1; localparam SEND_DATA 3d2; localparam WAIT 3d3; localparam UPDATE 3d4; reg [2:0] state, next_state; reg [7:0] clk_cnt; reg [4:0] bit_cnt; reg [2:0] byte_cnt; reg [7:0] cmd_byte; reg [31:0] data_reg; reg [7:0] update_cnt; // 时钟分频产生 SCLK always (posedge clk or negedge rst_n) begin if (!rst_n) clk_cnt 8d0; else if (state SEND_CMD || state SEND_DATA) clk_cnt clk_cnt 1b1; else clk_cnt 8d0; end // SCLK 生成每 5 个系统时钟翻转一次 always (posedge clk or negedge rst_n) begin if (!rst_n) sclk 1b0; else if (state SEND_CMD || state SEND_DATA) sclk (clk_cnt 8d2) ? 1b1 : (clk_cnt 8d4) ? 1b0 : sclk; else sclk 1b0; end // 状态机主逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; csb 1b1; sdio_oe 1b0; io_update 1b0; done 1b0; end else begin case (state) IDLE: begin csb 1b1; io_update 1b0; done 1b0; if (start) begin cmd_byte {1b0, addr[6:0]}; // 写操作R/W0 data_reg data_in; byte_cnt byte_num; bit_cnt 5d7; state SEND_CMD; csb 1b0; sdio_oe 1b1; end end SEND_CMD: begin if (clk_cnt 8d0) begin sdio_out cmd_byte[bit_cnt]; if (bit_cnt 5d0) begin bit_cnt 5d7; state SEND_DATA; end else begin bit_cnt bit_cnt - 1b1; end end end SEND_DATA: begin if (clk_cnt 8d0) begin sdio_out data_reg[bit_cnt (byte_cnt-1)*8]; if (bit_cnt 5d0) begin if (byte_cnt 3d1) begin state WAIT; end else begin byte_cnt byte_cnt - 1b1; bit_cnt 5d7; end end else begin bit_cnt bit_cnt - 1b1; end end end WAIT: begin state UPDATE; update_cnt 8d0; end UPDATE: begin io_update 1b1; if (update_cnt 8d9) begin io_update 1b0; done 1b1; state IDLE; end else begin update_cnt update_cnt 1b1; end end endcase end end endmodule这段代码的核心思路是用一个计数器来控制 SCLK 的分频用 bit_cnt 和 byte_cnt 来跟踪当前发送的位和字节。注意在 SEND_DATA 状态数据是从高位开始发送的所以用 data_reg[bit_cnt (byte_cnt-1)*8] 来索引。这个索引方式假设数据是 32 位的对于 16 位或 8 位的数据你需要相应地调整。还有一个细节在 SEND_CMD 状态cmd_byte 是 {1b0, addr[6:0]}也就是写操作的控制位加上 7 位地址。但 AD9910 的寄存器地址是 8 位的这里只用了 7 位是因为地址范围是 0x00 到 0x1F7 位足够表示。如果你要访问的地址超过 0x7F就需要检查手册确认是否支持。3.3 读操作状态机的实现与 SDIO 方向切换时机读操作的状态机和写操作类似但多了 SDIO 方向切换的步骤。在发送完指令字节之后需要把 sdio_oe 置 0让 SDIO 变为输入然后在接下来的时钟周期里采样 SDIO 上的数据。这里的关键是切换时机必须在最后一个地址位发送完成之后SCLK 的下降沿模式 0或者上升沿模式 3之前切换方向。如果切换太早会干扰地址位的发送如果切换太晚会错过第一个数据位。下面是一个读操作状态机的关键部分// 读操作状态机简化版 localparam RD_IDLE 3d0; localparam RD_SEND_CMD 3d1; localparam RD_SWITCH 3d2; localparam RD_READ_DATA 3d3; localparam RD_DONE 3d4; // 在 RD_SEND_CMD 状态发送指令字节R/W1 // 发送完成后进入 RD_SWITCH 状态 // 在 RD_SWITCH 状态拉低 sdio_oe等待一个时钟周期 // 然后进入 RD_READ_DATA 状态在 SCLK 的上升沿采样 SDIO在读操作时数据是在 SCLK 的下降沿输出的模式 0所以你应该在 SCLK 的上升沿采样。在 Verilog 里你可以用 sclk 的上升沿来触发采样或者用系统时钟在 sclk 为高时采样。我通常用系统时钟在 sclk 为高时采样这样更稳定。读回的数据需要与写入的数据进行比较以校验配置是否正确。但要注意有些寄存器是只写的读回来可能是 0 或者不确定的值。所以在校验时只对可读的寄存器进行比较。另外读操作也会受到 IO_UPDATE 的影响但通常读操作不需要 IO_UPDATE因为读的是缓冲寄存器的值。3.4 多寄存器批量配置的顺序与依赖关系在实际项目中你通常需要配置多个寄存器而不是只写一个。比如先配置 CFR1 和 CFR2 来设置芯片的工作模式然后配置 FTW 来设置输出频率再配置 ASF 来设置幅度。这些寄存器的配置顺序是有讲究的。一般来说应该先配置控制寄存器再配置数据寄存器。因为控制寄存器决定了芯片的工作模式比如是否使能 PLL、是否使能 DAC、是否使用 profile 模式等。如果先配置数据寄存器再配置控制寄存器可能会导致数据寄存器被复位或者覆盖。另外有些寄存器之间存在依赖关系。比如 CFR2 里的 PLL 使能位和 PLL 倍频系数必须在配置其他寄存器之前设置好因为系统时钟频率会影响 FTW 的计算。如果你先设置了 FTW然后改变 PLL 倍频系数那么实际的输出频率就会变。所以正确的顺序是先配置 CFR2 设置 PLL等待 PLL 锁定然后配置 CFR1 设置工作模式再配置 FTW、POW、ASF 等数据寄存器最后配置 profile 寄存器如果需要。在 Verilog 实现时你可以用一个简单的 ROM 或者查找表来存储这些配置数据然后用一个状态机依次读取并写入。每个寄存器的写入完成后产生一个 done 信号触发下一个寄存器的写入。这样就能保证顺序正确。如果你需要动态改变频率可以在初始化完成后只更新 FTW 寄存器然后给 IO_UPDATE这样就能实现快速跳频。4. 调试实战那些手册不会告诉你的坑4.1 寄存器写入后无输出的排查链路这是最常见的问题你按照手册和评估软件的配置把寄存器都写了一遍但芯片就是没有输出。排查这个问题我通常按照以下链路来走。第一步检查电源和参考时钟。AD9910 需要 1.8V 和 3.3V 供电参考时钟幅度要足够通常要求峰峰值在 0.5V 到 1V 之间。如果参考时钟没有或者幅度不够芯片根本不会工作。第二步检查 SPI 通信是否正常。用示波器或者逻辑分析仪抓一下 SCLK、SDIO、CSB 的波形看看有没有信号时序对不对。如果 CSB 一直是高电平说明片选没有拉低SPI 传输根本没有开始。第三步检查 IO_UPDATE 信号。这是最容易被忽略的。如果你写入了寄存器但没有给 IO_UPDATE缓冲寄存器的值不会更新到当前寄存器芯片输出不会变化。用示波器看一下 IO_UPDATE 引脚有没有脉冲宽度够不够。第四步检查寄存器的配置值。特别是 CFR1 和 CFR2里面有很多使能位比如 DAC 使能、PLL 使能、输出使能等。如果这些位没有正确设置芯片也不会有输出。第五步检查 FTW 的计算是否正确。如果 FTW 算错了输出频率会不对但至少应该有输出。如果完全没有输出更可能是控制寄存器的问题。我遇到过一次所有寄存器都写了IO_UPDATE 也给了但就是没输出。后来用逻辑分析仪抓 SPI 波形发现 SCLK 的极性搞反了数据在错误的边沿被采样导致写入的值全是错的。改成模式 0 之后问题解决。所以逻辑分析仪是调试 SPI 的必备工具没有它很难定位问题。4.2 读回校验数据不一致的根因分析读回校验是验证 SPI 通信是否正常的重要手段但很多人发现读回的数据和写入的不一致。这个问题可能有几个原因。第一个原因是 SDIO 方向切换时机不对。如果在读操作时没有及时把 SDIO 切换为输入或者切换太晚读回的数据就会错位。第二个原因是 SCLK 的采样边沿不对。AD9910 在模式 0 下是在 SCLK 的下降沿输出数据你应该在上升沿采样。如果你在下降沿采样读回的数据可能不稳定。第三个原因是寄存器本身是只写的。比如 CFR1、CFR2、CFR3 都是只写寄存器你读回来的值可能是 0 或者随机值这不代表写入失败。只有可读的寄存器比如 FTW、POW、ASF 和 profile 寄存器才能用于校验。第四个原因是 IO_UPDATE 的影响。如果你在读操作之前给了 IO_UPDATE缓冲寄存器的值可能已经被更新读回来的值可能和你刚写入的不一样。所以读回校验最好在写入之后立即进行不要插入 IO_UPDATE。还有一个比较隐蔽的原因SPI 时钟频率过高。如果 SCLK 频率超过了 AD9910 的最大允许频率或者信号完整性不好读回的数据可能出现位错误。我建议在调试阶段把 SCLK 降到 1MHz 到 5MHz等通信稳定后再逐步提高。另外SDIO 上的上拉电阻也很重要通常需要 10kΩ 的上拉确保在空闲时 SDIO 为高电平。4.3 高频输出时的杂散与时钟馈通问题当输出频率较高时比如超过 200MHz你可能会发现输出频谱上有杂散或者时钟馈通。这个问题不完全是 SPI 配置的问题但和寄存器配置有关。首先检查 DAC 满量程电流的设置。如果电流设置过大输出幅度会饱和产生谐波。如果电流设置过小信噪比会下降。数据手册里给出了推荐值通常是 20mA 左右但具体要根据你的负载和输出滤波器来调整。其次检查 PLL 的配置。如果 PLL 倍频系数过高相位噪声会恶化导致杂散。比如参考时钟 25MHzPLL 倍频到 1GHz倍频系数是 40这个值比较高相位噪声可能不太好。如果可能尽量降低倍频系数或者使用更高频率的参考时钟。另外PLL 的环路滤波器参数也需要根据参考时钟频率和倍频系数来调整数据手册里有推荐值但实际使用时可能需要微调。还有一个常见问题是时钟馈通。AD9910 的系统时钟会通过芯片内部耦合到输出在输出频谱上表现为系统时钟频率的杂散。要减少这个杂散可以在输出端加一个低通滤波器截止频率略高于你的输出频率。另外确保 SYSCLK 的走线远离输出走线减少耦合。在 PCB 布局时模拟地和数字地要分开通过磁珠或者 0 欧姆电阻连接。4.4 多片 AD9910 同步的注意事项在一些应用中比如相控阵或者多通道信号源需要多片 AD9910 同步工作。同步的关键是确保所有芯片的 SYSCLK 同源同相以及 IO_UPDATE 信号同时到达。首先所有 AD9910 的参考时钟应该来自同一个时钟源经过时钟分配器后分别送到每片芯片。时钟分配器的输出 skew 要尽可能小通常要求小于 100ps。其次IO_UPDATE 信号也应该同源最好用 FPGA 的同一个引脚驱动经过缓冲后送到每片芯片。如果走线长度不同IO_UPDATE 到达的时间会有差异导致同步误差。另外AD9910 有一个 SYNC_CLK 引脚可以输出 SYSCLK 的 1/4 分频用于多片同步。你可以用这个信号来对齐各片芯片的相位。具体做法是配置一片为主片其他为从片通过 SYNC_CLK 和 SYNC_IN 引脚来同步。数据手册里有详细的多片同步流程但实际操作中PCB 布局和走线等长是关键。我做过一个四片同步的项目走线长度差异控制在 5mm 以内同步误差在 100ps 左右满足大多数应用需求。5. 从评估软件到 Verilog 的完整工作流5.1 配置数据的提取与格式转换脚本评估软件导出的配置文件不能直接用于 Verilog你需要一个转换脚本。我通常用 Python 写一个小脚本来完成这个工作。脚本的功能是读取评估软件导出的文本文件解析出寄存器地址和数据然后生成一个 Verilog 的查找表或者 ROM 初始化文件。比如生成一个 case 语句或者一个 $readmemh 格式的文件。下面是一个简单的 Python 脚本示例import re def parse_config(file_path): configs [] with open(file_path, r) as f: for line in f: # 假设格式为地址 数据 match re.match(r0x([0-9A-Fa-f])\s0x([0-9A-Fa-f]), line) if match: addr int(match.group(1), 16) data int(match.group(2), 16) configs.append((addr, data)) return configs def generate_verilog_rom(configs, output_path): with open(output_path, w) as f: for addr, data in configs: f.write(f{addr:02X} {data:08X}\n) if __name__ __main__: configs parse_config(ad9910_config.txt) generate_verilog_rom(configs, ad9910_rom.hex)这个脚本假设导出文件的格式是“0x地址 0x数据”如果你的格式不同需要调整正则表达式。生成的 hex 文件可以用 $readmemh 在 Verilog 里读取。注意AD9910 的寄存器宽度不同有的是 32 位有的是 16 位所以在生成 ROM 时要统一成 32 位或者根据实际宽度处理。5.2 初始化流程的状态机设计与上电时序上电之后AD9910 需要一定的初始化时间。首先是电源稳定然后参考时钟稳定然后 PLL 锁定。数据手册里给出了 PLL 锁定时间的典型值通常是几百微秒。在 FPGA 里你需要一个上电复位状态机等待足够的时间后再开始 SPI 配置。我通常的做法是上电后等待 10ms确保电源和时钟稳定然后开始配置寄存器。配置完成后再等待 1ms让 PLL 锁定然后给 IO_UPDATE。初始化状态机的流程大致是IDLE - WAIT_POWER - CONFIG_CFR2 - WAIT_PLL - CONFIG_CFR1 - CONFIG_FTW - CONFIG_ASF - CONFIG_PROFILE - UPDATE - DONE。每个配置步骤调用一次写操作状态机等待 done 信号后进入下一步。这样就能保证配置顺序正确而且每一步都有足够的时间。这里有一个细节CFR2 里的 PLL 使能位和倍频系数需要在配置其他寄存器之前设置因为 PLL 锁定需要时间。所以我在配置完 CFR2 之后会等待一段时间比如 1ms然后再配置 CFR1 和其他寄存器。另外CFR1 里的 DAC 使能位和输出使能位也要正确设置否则芯片没有输出。5.3 动态频率切换的 Verilog 实现方案在很多应用中你需要动态改变输出频率比如跳频通信或者扫频。AD9910 支持通过 profile 引脚快速切换频率也支持通过 SPI 更新 FTW 寄存器。如果切换速度要求不高毫秒级可以直接用 SPI 更新 FTW然后给 IO_UPDATE。如果要求微秒级切换就需要使用 profile 引脚。使用 profile 引脚的方式是预先在 8 个 profile 寄存器里存储不同的 FTW、POW、ASF 值然后通过 PS0、PS1、PS2 三个引脚选择当前使用的 profile。切换时只需要改变 PS 引脚的电平不需要 SPI 通信切换速度非常快通常在几十纳秒以内。在 Verilog 里你只需要输出三个引脚的电平即可。如果要用 SPI 动态更新 FTW你需要实现一个简单的接口当收到新的频率值时计算 FTW然后调用写操作状态机写入 FTW 寄存器最后给 IO_UPDATE。FTW 的计算可以用 FPGA 内部的乘法器和除法器实现也可以预先算好放在查找表里。对于大多数应用预先算好放在 ROM 里更简单也更节省资源。5.4 资源占用与时序收敛的优化建议在 FPGA 里实现 AD9910 的 SPI 配置资源占用其实很小。一个写操作状态机大概只需要几十个寄存器和几百个 LUT。如果你用 ROM 存储配置数据会额外占用一些 Block RAM。对于大多数 FPGA 来说这些资源都是绰绰有余的。但如果你需要同时控制多片 AD9910或者需要高速动态更新就需要考虑资源优化。时序收敛方面主要关注 SCLK 的输出时序。如果 SCLK 频率较高比如超过 25MHz你需要确保 SCLK 的输出延迟和 SDIO 的输出延迟匹配。在 FPGA 里可以通过设置输出延迟或者使用 ODDR 原语来调整。另外如果 SDIO 是双向的输入和输出的时序都需要约束。我通常会在 XDC 或者 SDC 文件里对 SCLK 和 SDIO 添加时序约束确保建立时间和保持时间满足要求。还有一个优化建议如果 SPI 配置数据是固定的可以把配置数据直接硬编码在 Verilog 里而不是用 ROM。这样可以节省 Block RAM而且综合工具可以做更多的优化。如果配置数据需要动态改变再用 ROM 或者寄存器组。另外状态机的编码方式也会影响资源占用用独热码one-hot可能比二进制码占用更多寄存器但组合逻辑更简单时序更好。具体选择取决于你的 FPGA 型号和时序要求。6. 几个实际项目中的配置案例6.1 单频点正弦波输出的最小配置集如果你只需要输出一个固定频率的正弦波配置其实很简单。最少的寄存器配置包括CFR2 设置 PLL 和参考时钟倍频CFR1 设置 DAC 使能和输出使能FTW 设置输出频率ASF 设置输出幅度。如果不需要相位控制POW 可以保持默认值 0。Profile 寄存器也不需要配置因为默认使用的是单频模式。具体的配置值取决于你的硬件。假设参考时钟是 25MHzPLL 倍频系数设为 40系统时钟就是 1GHz。要输出 100MHzFTW 100e6 × 2^32 / 1e9 429496730 0x1999999A。ASF 设为满量程也就是 0x3FFF。CFR2 的值需要设置 PLL 使能位和倍频系数具体位定义参考数据手册。CFR1 需要设置 DAC 使能位和输出使能位。这个最小配置集我用了很多次非常稳定。需要注意的是CFR2 的配置要在 CFR1 之前因为 PLL 需要时间锁定。另外IO_UPDATE 要在所有寄存器写入之后给确保所有配置同时生效。6.2 扫频模式下的寄存器动态更新策略扫频模式需要动态改变 FTW。有两种实现方式一种是用 FPGA 内部逻辑实时计算 FTW 并通过 SPI 写入另一种是预先计算好一系列 FTW 值存储在 ROM 里然后依次写入。前者的灵活性更高可以任意改变扫频范围和步进后者的速度更快适合高速扫频。如果扫频速度要求不高比如每秒几千个频点用 SPI 动态更新就足够了。在 Verilog 里你可以用一个计数器来产生频率索引然后从 ROM 里读取对应的 FTW写入 AD9910。每次写入后给 IO_UPDATE然后等待一段时间再写下一个频点。如果扫频速度要求很高比如微秒级切换就需要使用 profile 引脚。你可以把 8 个频点预存在 profile 寄存器里然后快速切换 PS 引脚。但 8 个频点可能不够这时候可以结合 SPI 和 profile用 SPI 更新 profile 寄存器的值然后用 PS 引脚切换。我在一个雷达回波模拟项目里用过扫频模式扫频范围是 50MHz 到 150MHz步进 1MHz每个频点停留 10us。用 SPI 动态更新 FTWSCLK 设为 20MHz每次更新大约需要 2us加上 IO_UPDATE 和等待时间总共大约 5us满足 10us 的停留时间要求。6.3 多片级联时的 SPI 片选与数据分发多片 AD9910 级联时SPI 总线的处理有两种方式一种是每片芯片有独立的 CSBSCLK 和 SDIO 共享另一种是每片芯片有独立的 SPI 接口完全独立控制。第一种方式节省引脚但需要确保在切换 CSB 时SCLK 和 SDIO 的状态正确。第二种方式更简单但占用更多 FPGA 引脚。我通常推荐第一种方式因为 AD9910 的 SPI 接口支持多片共享总线。具体做法是FPGA 产生 SCLK 和 SDIO然后通过一个译码器产生多个 CSB 信号。在写某一片芯片时只拉低对应的 CSB其他芯片的 CSB 保持高电平。这样就能实现分时复用。需要注意的是在切换 CSB 时要确保 SCLK 和 SDIO 处于空闲状态避免误触发。如果多片芯片需要同步更新可以在所有芯片的 SPI 传输完成后同时给 IO_UPDATE。这样所有芯片的配置会同时生效。IO_UPDATE 信号可以共享也可以独立控制。如果共享需要确保 IO_UPDATE 的驱动能力足够通常需要加缓冲器。6.4 低功耗场景下的寄存器配置取舍在一些低功耗应用中比如便携式设备需要降低 AD9910 的功耗。AD9910 的功耗主要来自 DAC 和 PLL。你可以通过配置 CFR1 和 CFR2 来关闭不需要的功能。比如如果不需要 PLL可以关闭 PLL直接使用参考时钟作为系统时钟这样可以节省 PLL 的功耗。如果输出幅度不需要满量程可以降低 ASF 的值减少 DAC 的功耗。另外AD9910 有一个省电模式可以通过 CFR1 的相应位来使能。在省电模式下芯片的功耗会大幅降低但输出也会关闭。这个模式适合在待机时使用。唤醒时需要重新配置寄存器并等待 PLL 锁定。还有一个技巧如果输出频率较低可以降低系统时钟频率从而降低功耗。比如输出 10MHz系统时钟可以设为 100MHz而不是 1GHz。这样 DAC 和数字部分的功耗都会降低。但要注意系统时钟降低后FTW 的计算公式也要相应调整。7. 写在最后的一些个人体会AD9910 这颗芯片我用了好几年从最开始的一头雾水到后来能够熟练配置中间走了不少弯路。最大的体会是数据手册一定要仔细看尤其是时序图和寄存器位定义很多问题的答案都在里面只是需要耐心去找。另外逻辑分析仪是调试 SPI 的利器没有它很多问题只能靠猜。还有一点不要迷信评估软件的配置它只是一个参考实际硬件可能需要调整。如果你刚开始接触 AD9910我建议先从评估板入手用官方软件配置观察输出然后用逻辑分析仪抓 SPI 波形理解时序。等对芯片有了直观认识之后再自己写 Verilog 代码。这样循序渐进比一上来就啃手册要高效得多。另外多和其他做硬件的朋友交流很多坑别人已经踩过了问一下就能省很多时间。最后分享一个小技巧在调试阶段可以把 SCLK 频率降到 1MHz这样即使时序有点问题也能通信成功。等确认配置正确后再逐步提高 SCLK 频率观察是否稳定。这个方法帮我定位过好几次时序问题。