☰
AD9910 DDS例程解析:FTW/POW计算、IO_UPDATE同步与频相联动
2026/10/14 19:02:57 网站建设 项目流程

简介:这套基于STM32的AD9910例程主要面向需要学习DDS直接数字合成技术的嵌入式开发者,旨在演示如何通过寄存器配置实现输出信号频率与相位的灵活修改。压缩包共270个文件,约12.57MB,包含c与h源码文件、Keil工程文件(uvprojx/uvoptx)、编译生成的目标文件(o/axf/hex/lst)以及若干说明文本,属于可从工程打开到编译烧录完整使用的项目资料。目前已有259人学习。这一例程重点覆盖系统时钟设定与倍频系数计算,例如25MHz晶振配40倍频至1000MHz,若换用40MHz晶振则需将倍频设为0x32;同时结合STM32的SPI通信完成频率控制字FCW与相位偏移写入,帮助理解相位累加器原理、频率分辨率与寄存器操作流程。整套代码和工程文件可直接参考移植,适合正在调试AD9910信号发生器或需要快速上手DDS驱动开发的工程师与在校学生。

1. 9910例程:一颗DDS的频相可控到底能解决什么现场问题

做信号源、激励源或者射频前端模拟的时候,调频率往往容易,调相位经常翻车:改了频率,相位跟着跳;改了相位,输出频率又抖一下。9910例程要解决的正是这个痛点——基于9910这颗DDS芯片,把频率调谐字和相位偏移字的换算、写入、锁存全封装好,你只需要告诉它“我要多少MHz、多少度”,剩下的由驱动完成。它适合测试测量设备、超声/声呐激励、雷达回波模拟这些需要频率和相位独立可变的场景,也适合刚接触DDS想少走弯路的新手。理解这个例程的关键,是弄清FTW、POW和一次IO_UPDATE的关系。

2. 频率和相位在9910里如何被“修改”:FTW、POW与一次IO_UPDATE的完整链路

直接改输出频率和相位是做不到的,9910只认“字”。一个32位的频率调谐字决定输出波形的周期长度,一个14位的相位偏移字决定每个周期的起始角度。把这两个字算对、写对、同时锁存生效,就是把“修改频率和相位”这件事落地。下面先把链路拆开看。

2.1 先把两个字看懂:频率调谐字FTW与相位偏移字POW怎么算

DDS内部是一个相位累加器,每个系统时钟沿累加一次FTW,累加器溢出即完成一个波形周期。既然累加器是32位,那么输出频率就是f_sys乘上FTW与2^32的比值,反过来就得到频率字公式:

FTW = round(f_out × 2^32 / f_sys)

相位偏移字则直接叠加在累加器输出上,14位字对应360°,所以:

POW = round(phase_deg × 2^14 / 360)

举一组实际例子。假设系统时钟f_sys = 1 GHz,要输出10 MHz、相位90°,则FTW = 10e6 × 4294967296 / 1e9 ≈ 42949673,POW = 90 × 16384 / 360 = 4096。这两个数最终以字节形式写入对应寄存器。

目标输出(f_sys=1GHz)FTWPOW
10 MHz / 0°429496730
10 MHz / 90°429496734096
100 MHz / 180°4294967308192

顺带算一下分辨率:1 GHz系统时钟下,频率分辨率约0.233 Hz,相位分辨率约0.022°。如果你要做低到1 Hz级别的精确频点,FTW算到整数位仍然有误差,必须把误差折算成实际频率误差去评估能不能接受。换算时乘法要先用浮点或64位做,避免32位乘法溢出;我这里用llround做四舍五入而不是直接截断,因为截断带来的频率误差比四舍五入大将近一倍,在精密激励场景下会直接反映成输出频率偏差。

// 频率字换算:乘法让编译器用64位/浮点完成,别用32位乘 uint32_t ftw_from_freq(double f_hz, double f_sys) { double ratio = f_hz * 4294967296.0 / f_sys; return (uint32_t)llround(ratio); // 四舍五入,误差最小 } // 相位字换算:14位满量程对应360度 uint16_t pow_from_deg(double deg) { double ratio = deg * 16384.0 / 360.0; return (uint16_t)llround(ratio); }

逻辑上就是按比例映射到调谐字空间,唯一需要留意的是f_sys必须与你实际配置的PLL倍频结果一致,很多例程跑出来的频率差一半、差几倍,问题都出在f_sys和实际不符。参数方面,f_sys单位是Hz,f_hz单位也是Hz;POW函数接受的deg可以是0到360,也可以接受负角度,llround会帮你归整到最接近的14位值,超过360度时按周期性自动折算回有效范围。

2.2 例程里的写入路径:缓冲寄存器、IO_UPDATE与多字同步

9910内部寄存器分两层:缓冲区和活动寄存器。SPI写入的是缓冲区;只有IO_UPDATE引脚上来一个上升沿,整个缓冲区的内容才会一次性锁存到活动寄存器。这就是为什么改频率和改相位可以并行不冲突——先分别把两个新字写进缓冲区,再来一次IO_UPDATE,DDS核同一时刻切换,不会出现频率先变、相位后变的中间态。

很多初学者在这里容易产生一个误解:认为写一个寄存器马上就生效。实际不是。如果你在初始化时连续写CFR1、CFR2、CFR3,然后写FTW、POW,只要中间没有IO_UPDATE,所有数据都还堆在缓冲区里;最后一次性IO_UPDATE,它们才统一生效。因此例程里通常会准备两个层次的接口:一个负责“写缓冲区”,一个负责“提交”。只写不提交,频率相位纹丝不动;写了就提交,多个参数之间又会互相撕裂。正确的节奏是初始化阶段多次写、一次提交,运行阶段改频改相时各写各的、再一次提交。

还有一点容易被忽略:9910的寄存器长度并不一致。CFR1到CFR4是32位,控制状态寄存器是8位,FTW是32位,POW是16位。如果你不管什么寄存器都按4字节写,后面的数据会把相邻寄存器吞掉,常见现象就是“频率对了但相位乱”“改了相位输出直接没了”。所以例程里必须按寄存器实际长度组织字节,而不是无脑发4字节。这个长度表通常就放在驱动头部,写厂商驱动的人一般都会留一份,换芯片批次时对照手册核对一次。

2.3 单频还是Profile:根据“改频改相”需求选对模式

9910支持两种使用模式。单频模式最直接:FTW和POW直接作用于DDS核心,改一次立刻生效,适合“用户随时给一个新频率、一个新相位”的交互式场景,大多数“支持修改频率和相位”的例程选这个模式。

Profile模式则把寄存器扩展成8组,每组各存一套FTW、POW和幅度参数,通过PS0到PS2三个引脚的电平组合来切换。它的优势是切换速度极快、不依赖SPI实时写入,适合跳频通信、多频点预置这类场景,但使用前需要先把各profile参数一次性灌好。如果你只是做一个可调信号源,用单频模式开发量最小;如果后续要做跳频,先把数据写进profile寄存器、再用引脚切换,比每次临时写FTW要稳定得多。9910例程名里只说“支持修改频率和相位”,按惯例走单频模式,同时兼容把数据写进profile0,两种用法共用同一套FTW/POW换算函数。

3. 把例程跑起来:最小驱动代码与三处必调参数

光有公式还不够,得把它接进真实驱动里。下面这套是我常用的最小驱动结构:一个SPI写寄存器函数,两个上层设置函数,再加三个初始化时必须核对参数。不依赖具体单片机型号,拿到任何平台改一下SPI收发底层就能跑。

3.1 串行封装:写一个通用寄存器写入函数

9910的SPI写入通常是先发地址字节,再发数据字节,数据长度按寄存器决定。片选CS在事务期间拉低,结束拉高。下面这个函数就是最基础的寄存器写入封装,注意它不负责触发IO_UPDATE,因为提交动作需要统一控制。

static void spi_write_raw(uint8_t addr, const uint8_t *data, uint8_t len) { cs_low(); // 片选拉低,开始一次SPI事务 spi_exchange_byte(addr); // 发送寄存器地址 for (uint8_t i = 0; i < len; i++) { spi_exchange_byte(data[i]); // 按寄存器长度发数据 } cs_high(); // 片选拉高,结束事务 }

逻辑上就是把一次寄存器访问拆成“地址+数据”两个阶段,数据长度由调用方给定。这里故意不写SPI底层收发细节,是因为不同平台的SPI接口函数名和参数差异很大,你只需要把spi_exchange_byte换成自己平台里“发送一个字节同时接收一个字节”的接口即可。

参数方面的关键点有两个。一个是addr是寄存器地址,具体地址值以芯片手册的寄存器映射表为准,不同批次数据手册可能有差异,不要照抄网络上的老例程地址不做核对。另一个是len必须与寄存器实际宽度一致:8位寄存器传1,32位寄存器传4,FTW传4,POW传2。传错长度是后续排查时最隐蔽的问题之一。

实践中我更推荐再加一层封装:

void ad9910_write_buffered(uint8_t addr, const uint8_t *data, uint8_t len) { spi_write_raw(addr, data, len); // 只写缓冲区,不提交 } void ad9910_commit(void) { gpio_io_update_high(); delay_us(20); // 保证上升沿宽度满足手册要求 gpio_io_update_low(); }

这样就把“写缓冲区”和“IO_UPDATE提交”分开了。初始化时连续多次调用ad9910_write_buffered,最后调一次ad9910_commit;运行过程中修改频率或相位也一样,两个参数都写完再commit,保证同步。滞后时间不用太长,但IO_UPDATE高电平持续宽度要超过手册要求的最小时长,我曾经因为用一个10微秒的窄脉冲导致偶发不锁存,后来统一改成20微秒以上再没出过问题。

3.2 set_frequency 与 set_phase:从公式到两个可直接调用函数

下面这个函数把前面的公式和写入封装串起来,完成“目标频率→FTW→写寄存器→提交”的完整链路。

void set_frequency(double freq_hz) { double f_sys = get_sysclk_hz(); // 从初始化配置里取,别写死 uint32_t ftw = ftw_from_freq(freq_hz, f_sys); uint8_t b[4]; b[0] = (uint8_t)(ftw >> 24); b[1] = (uint8_t)(ftw >> 16); b[2] = (uint8_t)(ftw >> 8); b[3] = (uint8_t)(ftw); ad9910_write_buffered(REG_FTW, b, 4); // REG_FTW 按手册映射 ad9910_commit(); } void set_phase(double deg) { uint16_t pow = pow_from_deg(deg); uint8_t b[2]; b[0] = (uint8_t)(pow >> 8); b[1] = (uint8_t)(pow & 0xff); ad9910_write_buffered(REG_POW, b, 2); // REG_POW 按手册映射 ad9910_commit(); }

这两个函数就是例程的使用入口。逻辑上set_frequency做的事是:先拿到当前生效的系统时钟频率,换算FTW,按大端字节序拆成4个字节,写入FTW寄存器,最后IO_UPDATE提交。set_phase流程完全一样,只是数据宽度变成16位,14位有效字放在16位里。

参数方面有几个容易踩的地方。get_sysclk_hz千万别在函数里写死成某个常量,因为你改PLL倍频后这里会悄悄失效。POW的字节序在不同例程里有差异:有的把14位放在16位的高位,有的放在低位,写完后如果发现相位总差一个固定倍数,就调整一下这个字节序再试。最后要提醒,set_frequency和set_phase各自单独commit也能工作,但如果两个都要改,最好先分别写缓冲区、最后统一commit一次,否则在两次提交的间隙里,DDS会短暂输出“新频率旧相位”或“旧频率新相位”的中间态,这在相位敏感的系统里会造成误触发。

3.3 三处必调参数:系统时钟、PLL倍频与输出滤波器

运行例程之前,有三个参数必须确认,它们不在代码里,而在你的硬件配置里。

第一是系统时钟f_sys。它通常来自外部参考时钟经过内部PLL倍频后的结果,比如25 MHz参考时钟配40倍频得到1 GHz。这个值直接参与FTW换算,错了所有频点全偏。

第二是PLL配置。9910的倍频配置在CFR相关寄存器里,必须确认PLL使能位被设置、倍频系数符合期望。很多人初始化时只写了默认寄存器,PLL没使能,系统时钟停留在参考时钟频率上,输出频率低得离谱。

第三是输出端滤波器。DDS输出天然带fs−f_out的高次镜像分量,必须用低通滤波器压在预期频带内。滤波器截止频率要按最高输出频率来选,不是按采样率。比如最高输出50 MHz,就用截止60 MHz左右的低通,别用100 MHz截止,否则镜像频率会落在通带内。

参数建议检查方式出错典型表现
系统时钟f_sys对照参考时钟与PLL倍频相乘所有频点按比例偏移
PLL使能与倍频读回CFR确认输出频率比设定值低很多
输出滤波器看最高频点的镜像幅度高频段波形异常叠加

这三个参数里,前两个直接决定例程算出来的FTW对不对,第三个决定你看到的输出波形干不干净。我调试时习惯先在频谱仪上看一眼最大输出频率附近有没有镜像峰,如果有,先解决滤波器,再回头查FTW,顺序不能反。

4. 按这个顺序初始化:9910例程的上电流程与生效验证

例程能改频率和相位的前提是初始化配置正确。很多人拿到代码先改频率设置函数,结果输出乱套,问题往往出在初始化顺序上。这里把我常用的流程完整写出来,并说明每一步为什么必须在那个位置。

4.1 初始化顺序与完整C语言框架

初始化顺序我固定为五步:硬件复位、配置参考时钟与PLL、配置工作模式和DAC校准、统一IO_UPDATE提交、最后写默认频点并提交。

void ad9910_init(void) { // 1. 硬件复位:拉低复位引脚再释放,确保寄存器回到默认态 gpio_reset_low(); delay_ms(10); gpio_reset_high(); delay_ms(10); // 2. 配置PLL与参考时钟倍频,具体系数按系统时钟设计值换算 uint8_t pll_cfg[4] = { ... }; // 来自手册的PLL倍频配置 ad9910_write_buffered(REG_CFR3, pll_cfg, 4); // 3. 配置工作模式:单频模式、使能DAC校准,CFR1/CFR2按需写入 uint8_t mode_cfg[4] = { ... }; // CFR1/CFR2 数据按手册取 ad9910_write_buffered(REG_CFR1, mode_cfg, 4); // 4. 所有初始化配置统一提交,避免中间态 ad9910_commit(); // 5. 设置一个默认输出频点,验证链路是否通畅 set_frequency(10e6); set_phase(0.0); }

顺序上要注意:PLL必须先于工作模式配置,因为系统时钟不稳定时写CFR1可能被错误锁存;DAC校准使能位放在提交前最后写,校准过程依赖时序,过早使能可能在校准未完成时被后续写入打断。第5步的默认频点不要省,它可以作为后面所有验证的基线。如果上电后直接进入用户交互流程,一旦后续写入出错,你连“初始化是否成功”都分不清。

这套流程里有个细节值得注意:第2步和第3步都用了ad9910_write_buffered,而不是直接写寄存器,就是为了保证所有初始化参数在同一时刻生效。如果你在PLL配置单独commit一次、工作模式又commit一次,中间会有一段“PLL已生效但模式未正确配置”的不确定窗口,虽然大多数时候不会炸,但在要求严格的项目里我不建议赌这个小概率。

4.2 验证“修改频率和相位”生效:示波器与相位差测量

初始化完成后,建议按下面步骤验证,而不是直接接负载。用示波器两个通道,CH1接参考信号,CH2接9910输出,同步触发CH1。

第一步验证频率:把输出设为10 MHz,用示波器频率测量或FFT看频谱,确认主峰在10 MHz附近。注意直方图模式看频率比看周期更直观,周期测量在低信噪比时容易判断错误。第二步验证相位:保持频率不变,把相位从0°依次改成90°、180°、270°,观察CH2波形相对CH1的左右位移。90°对应1/4周期,180°对应半周期,如果位移方向相反,说明POW字节序需要调整。

第三步验证同步性:连续快速调用set_frequency和set_phase,观察波形是否有中间频率或中间相位闪现。正常的例程行为是波形平滑跳变到新状态,中间态极其短暂,肉眼不可见;如果能看到明显的“先变频率再变相位”或者出现抖动,说明两次commit之间出现了不希望的中间态,需要改成统一提交。

验证项操作通过标准
频率示波器频率测量读数与设定值偏差在测量精度内
相位0°/90°/180°/270°逐步改波形位移与理论值一致
同步连续改频改相无可见中间态、无跳变毛刺

4.3 改完不生效时先查这四处

如果验证不通过,优先按下面顺序排查。第一查IO_UPDATE引脚:确认它确实接到了GPIO且能输出上升沿,很多例程里这个引脚被误配置成开漏输出且没有上拉,导致上升沿根本到不了芯片。第二查写入长度:FTW和POW的字节数是否与寄存器宽度匹配,多一个字节或少一个字节都会造成后续寄存器错位。第三查SPI时钟极性和相位:9910对CPOL和CPHA有明确要求,配反了会导致地址或数据移位,表现出来就是写入后寄存器值完全不对。第四查片选时序:CS必须在整个事务期间保持低电平,不能在地址字节后提前释放,否则后面的数据字节全部无效。

我遇到过一个很隐蔽的情况:改动频率后偶尔生效、偶尔不生效,波形看着像随机跳。最后定位到是SPI时钟频率过高,数据线上的建立时间不够,把时钟从20 MHz降到10 MHz后彻底好了。如果你用的是高速单片机,先降速试试,往往比对着示波器抓波形更快。

5. 9910例程常见问题排查:5个让我翻过车的细节

很多问题不是例程逻辑有问题,而是细节配置和硬件接法上的坑。下面5种现象我都实际遇到过,每条按“现象、原因、解决”梳理,方便你对照排查。

5.1 输出频率正好是设定值的一半:PLL倍频没生效

现象:设定100 MHz,实测50 MHz,而且所有频点都是设定值的整数分之一,频率越低越难发现。

原因:系统时钟实际只有你预期的一半。FTW公式里的f_sys用的还是1 GHz,但硬件上PLL没有工作,系统时钟停在500 MHz,于是输出频率整体减半。

解决:先确认PLL配置是否写入成功、使能位是否置1,再确认参考时钟幅度是否满足手册要求。有时参考时钟信号太弱,内部振荡器起振但倍频器不工作,也会出现这种减半现象。建议在初始化后读回CFR寄存器,核对配置值再往下走。

5.2 改频后输出毛刺乱跳:IO_UPDATE与PS引脚的双重陷阱

现象:连续改频率时,输出频率偶尔跳到完全无关的值,过一会又自己回来,像是芯片“发神经”。

原因:两个可能。一是IO_UPDATE引脚悬空或受到干扰,产生了额外触发;二是PS0~PS2引脚悬空,噪声电平组合会随机选中一个profile寄存器,而那个profile里存的旧数据被切换出来了。

解决:IO_UPDATE引脚外接100 kΩ下拉电阻,确保安静电平为低;PS0~PS2引脚在单频模式下全部固定接GND,不要悬空。这个坑在开发板上特别常见,因为板子默认把这三个脚做成了跳线帽,没插跳线帽就等于悬空。

5.3 相位改了等于没改:POW写入时的位宽与字节序问题

现象:调用set_phase(90)后输出波形纹丝不动,或者相位变化幅度不对,比如设90°实际变化了约22.5°。

原因:POW是14位有效值,但在16位寄存器里存在高字节和低字节的放置方式问题。如果你按16位大端写入,但芯片实际期望14位放在高位,那么实际生效的相位值只有你期望的四分之一左右。

解决:把POW的14位分别试试“放高14位”和“放低14位”两种对齐方式。判断方法很简单:设180°,如果波形反相了,说明对齐正确;如果完全没变或变化量不对,就换另一种对齐方式重写。这一步确认后,把所有相位相关的写入都统一用同一种对齐方式,别再混用。

5.4 写入全失败但SPI波形正常:SDIO方向位没配

现象:逻辑分析仪上看SPI波形完全正常,地址对、数据对、时序也对,但读回寄存器全为默认值,写入根本没生效。

原因:9910的SDIO引脚在配置成三线模式时是双向引脚,如果初始化时没有把SDIO方向位配成“输入”也就是四线模式,芯片会把SDIO当作输出,数据字节被内部驱动拉走,写入不到内部寄存器。

解决:在CFR配置里设置SDIO为四线模式,或者在初始化第一步就写入这个方向位。部分例程还要求把SYNC_CLK引脚处理正确,否则SPI时钟域不对也会产生类似问题。排查时用示波器测量SDIO线上的电平,如果写数据期间SDIO上有明显的芯片驱动信号,基本就是这个原因。

5.5 频率漂移不锁定:REFCLK幅值和PLL锁定检查

现象:输出频率存在缓慢漂移,或者上电后一段时间频率才稳定,稳定后又会随机偏移几十赫兹。

原因:参考时钟REFCLK幅值不足或者波形质量差,导致内部PLL偶尔失锁;也可能是PLL配置的倍频系数超出了手册规定的VCO范围,环路无法稳定锁定。

解决:先检查REFCLK峰峰值是否满足手册要求,必要时加一级缓冲器再送入;再确认PLL倍频系数在允许范围内,不要凑一个中间值。锁定状态可以从芯片状态位读出,初始化后轮询等待锁定再继续,别直接开始写FTW。这个坑在低功耗设计里特别容易出现,因为为了省电把参考时钟幅度压得太低。

6. 把例程升级成“频相联动”:同步提交与RAM调制扫频

基础例程能跑通后,有两个升级方向特别实用。第一个是频相联动,也就是一次提交同时切换频率和相位,这在雷达模拟和相控阵激励里是刚需。做法已经在前面铺垫过了:先连续调用set_frequency和set_phase的缓冲区版本,把两个新字都写进缓冲区,最后调用一次ad9910_commit。这样做的好处是频率和相位在同一时刻切换,不会出现“频率已变但相位还没动”的中间状态。如果你直接用我前面写的set_frequency和set_phase,由于它们各自内部都带commit,就会产生两次提交,这个细节一定要改掉。

第二个升级方向是用RAM调制做扫频。9910内部有一段RAM,可以预先写入一长串FTW数据,然后使能RAM调制模式,让芯片自动按地址顺序输出频率,形成线性调频或三角扫频波形。这个方法的好处是扫频过程不占用MCU的SPI带宽,芯片自己按内部时钟跑,扫频速度可以很快。具体做法是:先把扫频序列换算成FTW数组,写入RAM区,配置RAM调制模式和相关控制字,最后IO_UPDATE提交。需要注意RAM区域大小有限,扫频点数太多时需要分段,或者改用主频控制配合profile切换实现粗粒度跳频。

RAM调制模式下,频率和相位不再是“改一个数”这么简单,而是“改一串数”。它的调试思路也从“看单个频点对不对”变成“看整段扫频曲线是否平滑、有无跳变点”。如果扫频曲线在某个位置出现台阶,多半是RAM数据边界处FTW衔接不对,检查相邻两个FTW的差值是否连续即可。

我自己在这个方向上有过一次教训:把IO_UPDATE接到一个默认带上拉的GPIO,上电时寄存器默认值还没配置好,那个高电平就先触发了一次锁存,导致初始化后的第一组配置被覆盖成随机值,调试了整整三天,最后把该引脚改成默认拉低并外部按下拉电阻才解决。从那以后我的原则是:IO_UPDATE引脚默认必须是低电平,所有触发都主动拉高再拉低,绝不依赖引脚复位后的默认状态。这个习惯帮我避开了后面好几个项目的类似问题,希望对你有帮助。

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

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

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

立即咨询