☰
AD9914 DDS编程指南:寄存器配置、SPI时序与扫频实现
2026/10/26 17:37:31 网站建设 项目流程

简介:面向AD9914直接数字频率合成器开发者的驱动示例工程,适合嵌入式工程师、FPGA/ARM开发者以及需要快速实现高精度频率合成控制的硬件工程师使用。资源以ADuC7026微控制器为控制核心,完整演示了AD9914的初始化、频率与相位控制流程,可用于通信系统、测试测量设备等场景的参考移植。压缩包内共24个文件,以C源程序、头文件、汇编启动文件及Keil工程文件为主:ad9914test.c为驱动主程序,ADuC7026.h定义寄存器映射,ADuC702x.s提供启动代码,uvproj/uvopt保存工程配置,hex/axf/map为编译输出与符号映射,crf/lst为编译中间文件,便于直接查看或烧录验证。整个压缩包仅59KB,结构紧凑,方便快速下载与研读。已有447人学习/下载,获得不少硬件开发者的实践参考。借助这份代码,可跳过繁琐的寄存器配置摸索,直接理解SPI/并行控制时序、频率步进寄存器设置等关键细节,并能参照工程框架将驱动移植到其他MCU或FPGA平台,是学习高性能DDS驱动很有价值的参考代码。

1. AD9914 的 DDS 代码包值不值得打开:先说结论再讲怎么读

AD9914 的 DDS 代码包,在信号源和跳频通信的项目里几乎人手一个。网上流传的 ExampleCode_DDS_AD9914.rar 这类资源,打开后里面通常是 SPI 写时序、寄存器初始化序列,外加一个 Verilog 或 C 的驱动文件,看起来烧进去就能出信号。实际上,AD9914 的编程难点不在 DDS 原理,而在寄存器依赖关系、I/O UPDATE 的生效沿,以及 VCO 校准这种和代码逻辑无关的时序。这篇文章按芯片编程模型的顺序拆开讲一遍:从寄存器层级、SPI 时序,到扫频和避坑,最后落到怎么移植到自己的板子。适合准备做信号发生器或跳频源、正在找 AD9914 例程的硬件工程师和学生。

2. AD9914 编程模型与寄存器层级:30 行代码跑通最小单音输出

拿到一个 AD9914 例程,最忌讳的就是把初始化函数从头抄到尾。你根本不知道哪一步是为了建立时钟、哪一步是为了切换模式、哪一步只是例程作者的习惯。AD9914 的寄存器看起来多,实际上按职责分成两层:一层是“工作模式开关”,一层是“数据内容”。把这两层分清,任何代码包在你眼里都只是一张填充表。

2.1 先分清三个控制字:FTW、POW、ASF 各自管什么

单音输出最核心的是频率调谐字 FTW(Frequency Tuning Word)。AD9914 的 FTW 是 32 位,它和输出频率的关系是固定公式:

/* 输出频率计算公式 */ f_out = (FTW / 2^32) * f_SYSCLK; /* 反过来,已知目标频率和系统时钟时 */ FTW = round(f_out * 2^32 / f_SYSCLK);

举个例子,AD9914 工作在 2.5 GHz 的 SYSCLK 下,想要 100 MHz 输出:

FTW = round(100e6 * 4294967296 / 2.5e9) = 171798692;

这个数就是你在代码包里经常看到的那些“看不懂的大整数”的来历。算一下频率分辨率:2.5 GHz 除以 2^32,约等于 0.582 Hz。也就是说,AD9914 的频率步进比普通锁相环细得多,这也是 DDS 在跳频和扫频应用里不可替代的原因。

除了 FTW,还有两个控制字容易被忽略:相位偏置字 POW(Phase Offset Word)和幅度控制字 ASF(Amplitude Scale Factor)。POW 直接控制输出相位偏移,位数是 14 位,对应 360° × POW / 2^14 的相移;ASF 是幅度比例字,位数是 12 位。单音程序里通常只写 FTW,POW 和 ASF 不上心,但只要涉及 FSK、PSK 或扫频调制,这三个字就要一起操作。代码包里如果出现三段几乎一样的 SPI 写序列,多半就是分别写 FTW、POW、ASF。

2.2 FR1/FR2:决定工作模式的“总开关”在哪儿

AD9914 的程序看起来啰嗦,是因为它把“数据”和“模式”分开存放。FTW、POW、ASF 这些属于数据层,而 FR1、FR2 属于模式层。FR1 和 FR2 里放的是一堆位开关:参考时钟倍频系数、SYSCLK PLL 使能、VCO 校准触发、Profile 引脚功能选择、DRG/RAM 模式使能、OSK 输出键控开关等。

代码包初始化序列里,绝大多数工程都是先写 FR1、FR2,再写数据类寄存器,原因就在这里:先把芯片的工作模式固定住,再填频率和相位数据,否则你写进去的 FTW 可能被当成无效数据丢弃。我一般会在读代码时先把 FR1、FR2 的写值抠出来,对照数据手册的位定义表逐个勾一遍,这一步很费时间,但能避掉后面一半的“玄学故障”。

寄存器组职责典型操作
FR1、FR2工作模式、PLL/VCO、Profile/DRG/RAM 开关上电后最先配置
FTW32 位频率调谐字按目标频率重算
POW、ASF相位偏置、幅度控制调制模式下才常用
DRG 相关扫频步长、步进间隔线性扫频时配置
RAM 相关预存 FTW/POW/ASF 波形表复杂调制序列时使用

注意,这里不写具体寄存器地址,因为不同版本的 AD9914 数据手册和评估板例程,地址宏定义略有差异。老代码包里的地址值不一定能直接用到你的板子上,拿手册 Register Map 对一遍是标准动作。

2.3 最小单音初始化序列:先 VCO 校准再写频率,最后 I/O UPDATE

一个最小可用的 AD9914 单音初始化,顺序是固定的:先配置 FR1/FR2 建立 SYSCLK,再做 VCO 校准并等待完成,然后写 FTW,最后拉一次 I/O UPDATE。下面这段代码参考了多数 AD9914 例程的写法,可直接移植到 STM32 这类 MCU 上做验证:

#include <stdint.h> /* 寄存器地址:老代码包里常见宏名,具体值以你手头手册的 Register Map 为准 */ #define REG_FR1 0x01 #define REG_FR2 0x02 #define REG_FTW_BASE 0x04 /* 32 位 FTW 占 4 个连续地址,高字节在前 */ static void spi_write_reg(uint8_t addr, uint8_t val); static void ad9914_io_update(void); /* 计算频率调谐字,四舍五入,注意用 uint64 防止中间结果溢出 */ uint32_t ad9914_calc_ftw(uint64_t fout_hz, uint64_t sysclk_hz) { return (uint32_t)((fout_hz * 4294967296ULL + sysclk_hz / 2) / sysclk_hz); } void ad9914_init_single_tone(uint64_t fout_hz) { /* 第 1 步:先把 FR1/FR2 清零,再按你的板子设置倍频和模式位 */ spi_write_reg(REG_FR1, 0x00); spi_write_reg(REG_FR2, 0x00); /* 这里应该按手册往 FR1/FR2 里写实际的 PLL 倍频、VCO 使能等位 */ ad9914_io_update(); /* 第 2 步:触发 VCO 校准后等待完成,常见做法是延时 10ms 再查状态位 */ delay_ms(10); /* 第 3 步:写入 32 位频率调谐字,高字节在前 */ uint32_t ftw = ad9914_calc_ftw(fout_hz, 2500000000ULL); spi_write_reg(REG_FTW_BASE + 0, (uint8_t)(ftw >> 24)); spi_write_reg(REG_FTW_BASE + 1, (uint8_t)(ftw >> 16)); spi_write_reg(REG_FTW_BASE + 2, (uint8_t)(ftw >> 8)); spi_write_reg(REG_FTW_BASE + 3, (uint8_t)(ftw >> 0)); /* 第 4 步:I/O UPDATE 上升沿把缓冲寄存器一次性锁存生效 */ ad9914_io_update(); }

代码的逻辑不复杂,但顺序是硬约束:FR1/FR2 没配置好就写 FTW,相当于房子地基没打就要装门;VCO 校准没完成就 UPDATE,输出频率可能偏出去几十兆赫兹。delay_ms(10) 是最保守的做法,更可靠的是轮询芯片状态位,但很多代码包图省事只做了延时,你能跑通就先用着。

参数上唯一要小心的是 ad9914_calc_ftw 里的 sysclk_hz:它不是你参考晶振的频率,而是 AD9914 内部 SYSCLK 的频率。如果板子用 100 MHz 参考钟,PLL 倍频到 2.5 GHz,这里必须填 2500000000,而不是 100000000。这个参数错一个零,输出频率就错一个数量级。

3. 从 SPI 时序到 Verilog 驱动:真正读懂 AD9914 例程代码

AD9914 程序包里的代码风格两极分化:MCU 工程普遍用 C 写软件模拟 SPI,FPGA 工程则用 Verilog 写状态机。不管哪种,底层都在做同一件事:按芯片要求的一帧格式,把一个指令字节和一个或多个数据字节送进去。这一章把帧格式和生效机制讲透,再看代码包就是透明的。

3.1 指令字节与寄存器寻址:为什么地址错一位整段白跑

AD9914 的串行接口是标准的 SPI 风格,每一帧开头是一个指令字节(Instruction Byte),最高位是 R/W 读写标志位,后面 6 位是寄存器地址,最低位在多数例程里被忽略或置 0。发完指令字节后,紧跟的就是该寄存器的数据字节。写 FTW 这种多字节寄存器,要按地址连续发多个数据字节。

/* AD9914 SPI 写一字节寄存器:指令字节 + 数据字节 */ void spi_write_reg(uint8_t addr, uint8_t val) { uint8_t instr = (1 << 7) | (addr & 0x3F); /* 最高位置 1 表示写 */ gpio_cs_low(); spi_tx_byte(instr); spi_tx_byte(val); gpio_cs_high(); }

判断代码包能不能直接用的第一个检查点就是地址位宽。AD9914 的寄存器地址是 6 位还是 7 位,不同批次手册的表述习惯不一样,但代码里 (addr & 0x3F) 这个掩码很常见。如果某个例程把地址左移了一位再发,你抄过来之后所有寄存器都会落到错误的地址上,表现出来就是“配置写进去了,输出完全不对”。我排查过一个项目,最后发现是例程作者的地址宏和手册差了 1 位,整整浪费了两天。

3.2 I/O UPDATE 是数据“生效”按钮,别把它当成普通 GPIO

这是 AD9914 程序里最容易翻车的地方。芯片内部寄存器分为 I/O 缓冲寄存器和有效寄存器,SPI 写入只改变缓冲寄存器,并不会立刻影响输出。只有 I/O UPDATE 引脚来一个上升沿,缓冲寄存器的值才会被整体锁存到有效寄存器。这个机制对代码包的影响是:你看到程序里写完一串寄存器后总会拉一下某个引脚,那就是 I/O UPDATE。

void ad9914_io_update(void) { gpio_update_high(); delay_us(1); /* 保持时间,至少满足手册要求 */ gpio_update_low(); }

I/O UPDATE 的时序要求比普通 GPIO 严格,它和 SYNC_CLK 之间存在建立/保持关系。严格做法是让 I/O UPDATE 与 SYNC_CLK 同步,但代码包里最常见的实现就是这种“拉高、延时、拉低”。在小信号验证阶段够用,但在跳频或扫频场景下,延时长短会直接影响第一跳的相位和起点,后面避坑章节会细说。

3.3 把写时序翻译成 Verilog 状态机:一个最小 SPI 写核

FPGA 工程里的 AD9914 驱动,核心是一个 SPI 写状态机。网上 Verilog 版本很多,但思路一致:先把指令字节和数据字节拼成一个 16 位移位寄存器,然后按分频时钟逐位移出。下面是一个最小可综合的参考实现:

// AD9914 SPI 写状态机(最小实现,SPI 时钟 = 主时钟 / (2*CLK_DIV)) module ad9914_spi_wr #(parameter CLK_DIV = 4) ( input wire clk, input wire rst_n, input wire [6:0] addr, // 7 位寄存器地址 input wire [7:0] wdata, input wire wr_en, output reg cs_n, output reg sclk, output reg sdo, output reg wdone ); localparam IDLE=2'd0, SEND=2'd1, FINISH=2'd2; reg [1:0] state; reg [4:0] bit_cnt; // 0~15,指令字节 + 数据字节 reg [15:0] frame; // {1'b1, addr[6:0], wdata} reg [15:0] div_cnt; // 分频计数 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; cs_n <= 1'b1; sclk <= 1'b0; wdone <= 1'b0; end else begin case (state) IDLE: begin if (wr_en) begin frame <= {1'b1, addr[6:0], wdata}; bit_cnt <= 5'd16; div_cnt <= CLK_DIV - 1; cs_n <= 1'b0; // CS 拉低,启动一帧 state <= SEND; end end SEND: begin if (div_cnt != 0) begin div_cnt <= div_cnt - 1; sclk <= ~sclk; // 分频产生半周期时钟 end else begin div_cnt <= CLK_DIV - 1; sclk <= 1'b0; sdo <= frame[15]; frame <= {frame[14:0], 1'b0}; bit_cnt <= bit_cnt - 1; if (bit_cnt == 1) state <= FINISH; end end FINISH: begin cs_n <= 1'b1; // 数据位结束后释放 CS wdone <= 1'b1; state <= IDLE; end endcase end end endmodule

逻辑说明:IDLE 里把“写标志 + 地址 + 数据”打包成 16 位帧,同时拉低 CS;SEND 状态用分频计数器翻转 SCLK,并在每个时钟周期把最高位移到 SDO;FINISH 状态释放 CS 并置位 wdone。注意 SEND 里数据是在 SCLK 低电平期间建立的,具体采样沿要以芯片手册时序图为准,板级验证时用示波器看 SCLK 和 SDO 的相对位置最保险。

参数上,CLK_DIV 决定 SPI 速率。不要一上来就追求高速,先用低频把通信调通,再逐步降低 CLK_DIV。我一般从 CLK_DIV = 8 起步,确认读回正确后才改成 4。这个习惯帮我避开了不少“自己写的时序其实没满足建立时间”的问题。

4. 从定频到扫频:DRG 与 RAM 模式下 AD9914 程序要改哪几处

单音程序跑通只是第一步,做信号发生器的人绝大多数要的是扫频或跳频。这时 AD9914 的两个模块就该登场了:数字斜坡发生器 DRG(Digital Ramp Generator)和可编程调制 RAM 模式。代码包里如果出现 DRG 或 RAM 相关的寄存器,多半就是在配置这两个模块。这一章从算参数开始,到怎么验收,给出一条能落地的路径。

4.1 数字斜坡发生器:算清步长和间隔,扫频没有想象中复杂

DRG 模式下的 AD9914 不需要外部持续干预,芯片自己按预设的步长从起始频率走到终止频率,到了端点还可以自动翻转方向做三角波扫频。设置 DRG 需要四组参数:起始频率、终止频率、频率步长、步长间隔。起始和终止频率用 FTW 表示,步长用 FTW 增量表示,间隔用 SYSCLK 周期数表示。

/* DRG 扫频参数计算示例:100 MHz 扫到 200 MHz,步进 0.1 MHz */ uint64_t f_start = 100000000ULL; uint64_t f_stop = 200000000ULL; uint64_t f_step = 100000ULL; /* 0.1 MHz */ uint64_t sysclk = 2500000000ULL; uint32_t ftw_start = ad9914_calc_ftw(f_start, sysclk); uint32_t ftw_stop = ad9914_calc_ftw(f_stop, sysclk); uint32_t ftw_step = ad9914_calc_ftw(f_step, sysclk); /* 扫频总步数 = (ftw_stop - ftw_start) / ftw_step */

这个计算逻辑和单音程序没有区别,区别只在于写完起始频率后,还要把终止频率、步进值和步进间隔写进 DRG 的对应寄存器,然后把 FR1/FR2 里的 DRG 模式位打开。扫频总时间可以用一组典型参数估算出来:

参数取值作用
起始频率100 MHz扫频起点
终止频率200 MHz扫频终点
频率步长0.1 MHz每步频率增量
步长间隔1 μs每一步的驻留时间
扫频总时间1 ms1000 步 × 1 μs

注意,步长间隔不是你想设成多少就是多少,它来自 SYSCLK 分频,最终写进寄存器的值要和 SYSCLK 对齐。例程里如果直接给一个十进制数,十有八九是原作者在他的板子频率下算好的,换到你的板子上必须重算。

4.2 RAM 调制模式:把频率/相位/幅度表写进芯片的基本流程

DRG 适合线性扫频,但如果你要的是任意波形序列,比如一段 FSK 混着一段 PSK,再用频点突跳,那就得用 RAM 模式。AD9914 内部有一段 RAM,可以把 FTW、POW、ASF 的序列预先存进去,芯片按顺序播放或由外部触发跳变。

常见做法是四步:先把 FR1/FR2 里的工作模式切到 RAM 调制;然后配置 RAM 段的起始和结束地址;接着用 SPI 把整张表格写入 RAM;最后拉 I/O UPDATE 激活并使能输出。最关键的一步是地址边界,老代码包里经常出现 RAM 段地址和实际表长度对不上的情况,表现为扫频序列播到一半就跳回开头或直接卡死。

我做过一个跳频序列,32 个频点写进 RAM 后只播了 16 个就循环。排查到最后发现是地址段结束位多写了一位,芯片把 32 个点当成了两个 16 点的段。RAM 模式对“段界限”的要求非常严格,改这个参数时一次只能动一个变量,别同时改地址和表数据,否则定位问题时会多一倍的干扰项。

4.3 用频谱仪做三步验收,确认扫频代码真的在扫

扫频代码写完,往板子上烧之前,先用频谱仪做三步验收,能省掉大量盲目调参时间。

第一步,固定频点验证:把 DRG 关掉,只写起始频率,频谱仪上应该只有一个干净的载波。这一步确认 FTW 计算和 SPI 通路是对的。第二步,只开 DRG 步进,不接终止频率,看频谱仪上频率是否按预期步长爬升,爬升速度是否符合步长间隔。第三步,接入完整扫频参数,观察频谱包络是否覆盖起始到终止频率的整个范围,以及端点是否有不该出现的过冲。

频谱仪上最常见的验收误判是:扫频太快,频谱仪显示出来的包络和预期形状不同。这时候不是代码错了,是扫频速度和频谱仪扫描速度不匹配,调慢步长间隔再看,不要急着改代码。这个习惯救过我很多次,每次都避免了对正确代码的“优化”。

5. AD9914 编程避坑:5 个让我翻车的细节

这一章写的都是我在实际项目中摔过的坑。每一条都按照“现象、原因、解决”来写,你可以直接拿去做排查清单。AD9914 的代码本身不长,真出问题基本都是硬件时序和寄存器状态的问题。

5.1 Profile 引脚悬空,输出在多个预设频率之间自动跳

现象:程序写的是 100 MHz 单音,但频谱仪上看到频率在几个点之间跳变,用手靠近板子时跳变规律还会变。

原因:AD9914 的输出频率由 Profile 引脚电平选通。引脚悬空时电平不稳定,芯片会在不同 Profile 寄存器组之间来回切换,而每组 Profile 里是预先存好的不同频率。

解决:不用的 Profile 引脚全部通过下拉电阻固定到地,或者用 FPGA/MCU 显式驱动为低电平。代码包里如果有 Profile 相关的引脚赋值,优先检查它是否在初始化后持续被占用,不能在配置完后就把引脚释放成高阻。

5.2 SPI 读回全是 0xFF,或者最后一个字节丢失

现象:写配置时感觉正常,但回读寄存器全是 0xFF;或者写 FTW 后输出频率差出一个固定比例。

原因:两个常见根源。一是 SDIO 双向口没有在读写之间切换方向,读模式时 SDO 还在输出,总线冲突;二是 CS 在最后一个 SCLK 沿之后立刻拉高,没有满足芯片要求的保持时间,最后一个数据位没锁存进去。

解决:优先用独立的 SDO/SDIO 分离接法,不要省引脚。CS 拉低后至少多留几个 SCLK 周期再释放。软件模拟 SPI 时,保证“先移完位,再延时,再拉高 CS”,把延时写足而不是靠运气。

5.3 VCO 校准没等完成就开输出,载频差出几十 MHz

现象:初始化流程完全按例程走,但输出频率和设置值偏差很大,而且相邻两次上电偏差还不一样。

原因:AD9914 内部 SYSCLK 靠 PLL 倍频产生,PLL 的 VCO 需要校准后才能稳定在目标频段。代码里如果触发校准后不等待完成标志,直接写 FTW 并 UPDATE,VCO 可能停在不正确的频段,载频自然对不上。

解决:初始化里在触发 VCO 校准后,轮询校准状态位,而不是只靠固定延时。老代码包的 delay_ms(10) 在多数情况下够用,但温度变化大的场景必须改成状态位判断。如果你不确定状态位在哪,至少把延时加大到 50 ms 并做上电验证。

5.4 I/O UPDATE 与 SYNC_CLK 竞争,扫频起点每次都不一样

现象:单音正常,但扫频模式下,每次启动扫频的起点频率都不同,示波器触发点也不稳定。

原因:I/O UPDATE 的电平变化和 SYNC_CLK 的边沿没有对齐。锁存动作发生在数据总线状态变化期间,部分控制字被锁存成了“半新半旧”的混合值,扫频起点自然漂移。

解决:让 I/O UPDATE 和 SYNC_CLK 同步。FPGA 里可以用 SYNC_CLK 打一拍再输出 UPDATE 脉冲;MCU 里至少保证从拉高 UPDATE 到释放之间有足够的建立时间。不要把这个引脚当成普通 GPIO 随手拉,扫频程序里这是最重要的时序点。

5.5 上电后寄存器“丢配置”,回读和写入对不上

现象:同一份代码,冷启动一次正常,再一次就完全没输出;回读寄存器时发现某些位自己变了。

原因:AD9914 有缓冲寄存器和有效寄存器之分,部分状态位在 I/O UPDATE 之后才更新回读值。如果代码在 UPDATE 之后立刻回读,读到的还是旧值;更隐蔽的是,上电复位后一些模式位默认值不是 0,而代码只写了要改的位,没先做整体清零。

解决:初始化开头加一段寄存器清零序列,按手册默认值把涉及的寄存器全部重写一遍,而不是只写要用的位。回读校验时,UPDATE 之后等几十微秒再读。养成“先清后写、写完回读”的习惯,能省掉大量这种“看起来像芯片坏了”的排查。

6. 移植到自己板子:三处必改与两个验证技巧

从网上下载的 AD9914 代码包,几乎不可能上电就能跑。移植到自己的板子时,下面三处是必改项,两个验证技巧能帮你确认改对了。

6.1 移植必改的三处:时钟宏、SPI 分频、引脚映射

第一处是 SYSCLK 宏。代码包里的 FTW 计算几乎都是拿“原作者的板子频率”算好的,你的参考晶振和 PLL 倍频只要不一样,所有频率常量就全错。我一般把 SYSCLK 抽成一个宏,所有 FTW 相关计算都引用它,这样换板子只改一处。

#define AD9914_SYSCLK_HZ 2500000000ULL /* 改成你板子的实际 SYSCLK */ uint32_t ftw = ad9914_calc_ftw(100000000ULL, AD9914_SYSCLK_HZ);

第二处是 SPI 分频。MCU 用软件模拟 SPI 时,把延时参数单独提出来,不要内联在读写函数里;FPGA 用 CLK_DIV 参数则保持可配置。

第三处是引脚映射。代码包作者用的引脚名基本不可信,对照原理图把 CS、SCLK、SDO/SDIO、I/O UPDATE、SYNC_CLK、Profile 引脚重新映射一遍,别被原工程里的命名带偏。

6.2 验证技巧:回读寄存器 + 看 SYNC_CLK/I/O UPDATE 沿

移植后第一件事不是测输出频率,而是回读寄存器。把指令字节最高位改成 0 重新发一遍地址,读回来的数据和写入值一致,再往下测。第二个技巧是用示波器同时看 SYNC_CLK 和 I/O UPDATE,确认 UPDATE 的上升沿和 SYNC_CLK 边沿的相对位置是稳定的。只要这两个沿抖动,扫频和跳频就一定出问题。

我现在移植任何 AD9914 工程,第一件事不是烧代码,而是把例程里的寄存器转储表和手头数据手册逐条对一遍,再留一个串口回读函数。这么干看着慢,但能少熬两个通宵,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询