简介:AD9910直接数字合成模块驱动,是一份面向嵌入式开发者和电子设计竞赛参赛者的信号发生器工程。工程基于意法半导体Cortex-M4内核微控制器,通过串行外设接口控制高性能直接数字合成芯片,可输出高达1.6GHz的正弦波信号,覆盖初始化配置、频率控制字写入、相位偏移与幅度调节、波形切换等完整驱动逻辑,并附带Keil工程文件和HEX烧录文件,能够大幅缩短信号源类项目的开发周期。压缩包共94个文件,以43个C源文件和43个H头文件为主体,另有工程配置、十六进制固件、启动汇编脚本和调试配置等辅助文件,整体体积仅461KB,目录按User、Hardware、Libraries等模块划分,便于移植和二次开发。目前已有1688人学习,适合需要快速掌握AD9910驱动开发或备战电赛的开发者,可帮助深入理解DDS原理、STM32的SPI通信机制以及驱动分层设计思路。 最近在折腾一个小项目:用STM32F407当主控,驱动AD9910这个1 GSPS的DDS信号发生器模块,做一个能精确输出正弦波的信号源。老实说,AD9910的驱动代码并不难写,难的是寄存器体系、IO_UPDATE时序、频率控制字计算这些细节,任何一个没理清楚,输出就是乱码或者干脆没信号。这篇文章把我从零调通这个模块的过程、踩过的坑和最终能直接照抄的驱动方案整理出来,适合正在用F407搭配AD9910做信号源、扫频源或者跳频实验的朋友参考。
1. 整体方案设计:为什么是AD9910+STM32F407
1.1 核心需求拆解
这次项目的核心目标很直接:通过STM32F407的SPI总线控制AD9910模块,输出频率可变、相位可控的正弦波信号,并且后续能扩展到扫频输出。
为什么选AD9910?这颗芯片是ADI家的经典DDS方案,内置14位DAC,采样率最高1 GSPS,频率分辨率能做到0.23 Hz级别(SYSCLK=1 GHz时)。和直接用DAC芯片或者PLL方案相比,AD9910的优势在于频率切换速度快、频率字可以通过寄存器直接设定,配合内置RAM还能做线性扫频和任意波形序列输出,非常适合做跳频通信、雷达模拟、测试仪器前端这些场景。
主控选STM32F407也很容易理解:Cortex-M4内核带FPU,主频168 MHz,SPI外设速度快,而且探索者这类开发板资源丰富,资料多,调起来省心。F407的SPI1挂载在APB2总线上,配置成42 MHz时钟毫无压力,稍微分频一下就能满足AD9910的SPI时序要求。
1.2 接口选型:SPI还是并行?
AD9910本身支持串行SPI和并行数据两种编程接口。我看到不少朋友一上来就想用并行口,觉得速度快,但实际项目里强烈建议优先用SPI。
原因有三个:第一,AD9910的寄存器配置频率远低于数据输出频率,SPI的速度完全够用;第二,SPI只需要4根线(SCLK、SDIO、CS、IO_UPDATE),并行口要占十几根GPIO,接线和PCB布局都麻烦;第三,F407的SPI硬件外设直接可以驱动,代码量小,调试方便。除非你要做纳秒级频率切换,否则SPI是绝对的主流选择。
AD9910默认的SDIO是双向引脚,工作在3-wire模式(没有单独的SDO)。大多数情况下我们只需要向芯片写寄存器,所以F407侧只需要用MOSI接SDIO,不需要接MISO。如果后面要做参数回读校验,就得研究半双工通信或者用GPIO模拟,这个后面会聊。
2. 硬件连接与时钟配置
2.1 引脚分配与连接表
我这边的连接方式如下表,用的是STM32F407探索者开发板的SPI1接口:
| AD9910模块引脚 | STM32F407引脚 | 备注 |
|---|---|---|
| SCLK | PA5(SPI1_SCK) | SPI时钟 |
| SDIO | PA7(SPI1_MOSI) | 3线模式双向数据 |
| CSB | PA4 | 片选,软件控制低有效 |
| IO_UPDATE | PA6 | 更新信号,软件拉高触发 |
| RESET | PC0 | 复位,低有效 |
| PROFILE0-2 | PD12-PD14(可选) | 外部Profile切换 |
| 3.3V | 3.3V | 模块供电 |
| GND | GND | 必须共地 |
关于探索者开发板V2和V3怎么判断,简单说看板子丝印和芯片型号版本。V2的调试器通常是DAP,V3的调试器方案有改动,但对这个项目来说影响不大,因为SPI1和GPIO接口在V2/V3上都是默认引出的,直接按上面表格接线就行。
AD9910模块的IO电平是3.3V,F407也是3.3V,两者可以直接相连,不需要电平转换。如果用的是5V主控或者模块自带5V供电,那就要加电平转换芯片,别直接怼,容易烧。
2.2 STM32F407时钟树与SPI1配置
F407的时钟树是个高频考点,这里简单理一下。外部晶振25 MHz,经过PLL倍频到168 MHz作为系统主频SYSCLK,然后AHB预分频得到168 MHz,APB2预分频得到84 MHz。SPI1挂在APB2总线上,所以SPI1外设时钟是84 MHz,再经过SPI内部的分频器得到实际的SCLK频率。
我在这套项目里把SPI1配置成:
- SPI_Mode:Master
- Direction:2 Lines Full Duplex(写为主,全双工也没问题)
- Data Size:8 bit
- Clock Polarity:Low(CPOL=0)
- Clock Phase:1Edge(CPHA=0,SPI Mode 0)
- BaudRate Prescaler:SPI_BAUDRATEPRESCALER_2,得到42 MHz
- First Bit:MSB First
AD9910的SPI写时序要求数据在SCLK上升沿被采样,下降沿改变,和SPI Mode 0正好对应。实测下来用Mode 0很稳,但如果你发现数据写不进去,也有人在Mode 2(CPOL=1,CPHA=0)下调通的,这个跟模块上有没有做信号反相有关,到时候用逻辑分析仪看一眼就知道。
2.3 AD9910参考时钟与SYSCLK的关系
AD9910的SYSCLK不等于模块上的参考晶振频率。模块上通常有一个低频参考晶振(常见50 MHz),经过芯片内部的PLL倍频后得到真正的SYSCLK。假如参考时钟是50 MHz,PLL倍频20倍,SYSCLK就是1 GHz。
这里有个核心公式,建议记在笔记本上:
FTW = f_out × 2^32 / SYSCLKFTW就是32位频率控制字。反过来,知道了FTW,也能算出实际输出频率:
f_out = FTW × SYSCLK / 2^32所以,如果SYSCLK搞错了,所有频率输出都会按比例偏移。我的做法是先在AD9910_Init里配置好PLL倍频,然后在所有FTW计算函数里统一用同一个SYSCLK宏定义,避免不同地方写错。
3. 核心驱动实现:初始化、SPI时序与频率控制
3.1 驱动代码整体结构
驱动文件我拆成了AD9910.c和AD9910.h两个文件,对外主要暴露三个接口:
void AD9910_Init(void); void AD9910_SetFrequency(uint32_t freq_hz); void AD9910_IO_Update(void);底层用HAL库的SPI接口,但封装了一层寄存器读写函数。完整代码结构大概是这样的:
#define AD9910_SYSCLK_HZ 1000000000UL // 1 GHz,根据模块实际PLL配置修改 void AD9910_WriteReg(uint8_t reg_addr, uint8_t *data, uint8_t len) { uint8_t cmd = reg_addr & 0x3F; // bit7=0表示写,低6位是寄存器地址 HAL_GPIO_WritePin(AD9910_CS_GPIO_Port, AD9910_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); HAL_SPI_Transmit(&hspi1, data, len, 100); HAL_GPIO_WritePin(AD9910_CS_GPIO_Port, AD9910_CS_Pin, GPIO_PIN_SET); }片选CS必须用普通GPIO控制,不能用SPI硬件的NSS自动控制,因为AD9910要求每个命令帧单独拉低拉高CS,硬件NSS在某些情况下会出现时序上的麻烦。
3.2 初始化序列与寄存器配置
AD9910的寄存器很多,但不是每个都要碰。初始化时重点配置CFR1、CFR2、CFR3,然后设置一个默认频率。
我的初始化流程如下:
void AD9910_Init(void) { // 1. 硬件复位 HAL_GPIO_WritePin(AD9910_RESET_GPIO_Port, AD9910_RESET_Pin, GPIO_PIN_RESET); HAL_Delay(10); HAL_GPIO_WritePin(AD9910_RESET_GPIO_Port, AD9910_RESET_Pin, GPIO_PIN_SET); HAL_Delay(10); HAL_GPIO_WritePin(AD9910_RESET_GPIO_Port, AD9910_RESET_Pin, GPIO_PIN_RESET); HAL_Delay(10); // 2. 配置CFR1:选择串行编程模式、默认Profile0、使能内部PLL等 uint8_t cfr1[] = {0x00, 0x00, 0x00, 0x40}; AD9910_WriteReg(0x00, cfr1, 4); // 3. 配置CFR2:PLL倍频,50MHz参考时钟倍频到1GHz uint8_t cfr2[] = {0x00, 0x08, 0xA0, 0x00}; // 根据实际模块和PLL参数调整 AD9910_WriteReg(0x01, cfr2, 4); // 4. 触发IO_UPDATE,让配置生效 AD9910_IO_Update(); // 5. 设置默认频率10MHz AD9910_SetFrequency(10000000); }这里必须强调一点:CFR1和CFR2里面的具体数值不要照抄,一定要结合你自己模块上的参考晶振频率和AD9910数据手册里的寄存器位定义来算。不同卖家做的模块,参考晶振可能是25 MHz、50 MHz,甚至外接差分时钟,PLL倍频系数自然不一样。
3.3 频率控制字FTW的计算与写入
前面说了FTW公式,这里看具体代码实现:
void AD9910_SetFrequency(uint32_t freq_hz) { uint32_t ftw; uint8_t buf[4]; // 计算频率控制字,四舍五入处理 ftw = (uint32_t)(((uint64_t)freq_hz << 32) / AD9910_SYSCLK_HZ); buf[0] = (ftw >> 24) & 0xFF; buf[1] = (ftw >> 16) & 0xFF; buf[2] = (ftw >> 8) & 0xFF; buf[3] = ftw & 0xFF; AD9910_WriteReg(0x04, buf, 4); // FTW0寄存器地址0x04 AD9910_IO_Update(); }举例,SYSCLK=1 GHz,想输出10 MHz:
FTW = 10000000 × 2^32 / 1000000000 = 42949672.96 ≈ 42949673 换成十六进制就是 0x028F5C29你可以在代码里加一个printf把FTW打出来,和计算结果对上,那基本就成功一大半了。
计算时注意用64位变量做乘法,直接用32位会溢出。freq_hz << 32其实也一样,建议优先用(uint64_t)freq_hz * 4294967296ULL这种写法,编译器不会因为移位优化出问题。
3.4 IO_UPDATE机制:为什么写完寄存器没反应
AD9910的寄存器分为“缓冲区”和“有效区”。SPI写入的数据先进入缓冲区,只有IO_UPDATE引脚产生上升沿时,缓冲区内容才会一次性同步到有效区,芯片才开始按新配置工作。
新手最容易犯的错误就是每写一个寄存器就拉一次IO_UPDATE。这样反而可能把一组不完整的配置同步进去,导致输出混乱。正确做法是:一组相关寄存器全部写完后,再统一触发一次IO_UPDATE。如果写了好几个寄存器,最后只触发一次,芯片拿到的就是整组一致的配置。
void AD9910_IO_Update(void) { HAL_GPIO_WritePin(AD9910_IOUP_GPIO_Port, AD9910_IOUP_Pin, GPIO_PIN_SET); HAL_Delay(1); HAL_GPIO_WritePin(AD9910_IOUP_GPIO_Port, AD9910_IOUP_Pin, GPIO_PIN_RESET); }IO_UPDATE对脉宽要求并不苛刻,手册上给的参数很小,用HAL_Delay(1)已经非常保守了,稳得很。
4. 进阶功能:Profile切换与扫频输出
4.1 Profile寄存器与外部切换
AD9910支持8个Profile寄存器,每个Profile都包含自己的频率、相位、幅度参数。通过PROFILE0-PROFILE2三个引脚的电平组合,可以瞬间切换输出参数,而不需要重新经过SPI写寄存器。这在高频跳变场景下非常有用。
比如我想让F407在收到一个外部触发信号后,立刻从10 MHz跳到20 MHz,可以把10 MHz写进Profile0,20 MHz写进Profile1,然后PD12/PD13/PD14分别是PROFILE0-PROFILE2,用普通GPIO改变电平即可:
void AD9910_SelectProfile(uint8_t profile) { HAL_GPIO_WritePin(GPIOD, GPIO_PIN_12, (profile & 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOD, GPIO_PIN_13, (profile & 0x02) ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOD, GPIO_PIN_14, (profile & 0x04) ? GPIO_PIN_SET : GPIO_PIN_RESET); }4.2 扫频模式的实现思路
AD9910做扫频有两种常用方式:DRG(数字斜坡发生器)和RAM调制模式。
DRG模式适合做线性调频(chirp)信号,不需要外部干预,芯片自己按照设定的起始频率、终止频率和步进间隔向上或向下扫。关键配置在CFR1中使能DRG模式,然后设置DRG上下限寄存器和步进间隔寄存器。扫频速度取决于步进寄存器和SYSCLK的关系,公式在手册里写得很清楚,这里不展开。
RAM模式则更灵活,适合做自定义波形序列。可以把一段频率字、相位字或幅度字预先写入AD9910内置RAM,然后通过Profile寄存器设定起始地址、结束地址、步进方向和循环次数,芯片内部自动按地址递增输出。
我这次先做的是DRG扫频,用示波器看输出频率随时间线性变化,效果很直观。代码上有几个点容易踩坑:DRG的上限和下限寄存器都是64位的,分两次SPI写进去要特别小心字节顺序;另外扫频速率如果设置得太快,示波器刷新率跟不上,会看到一片模糊,最好把步进间隔调大一些再观察。
5. 调试实录:常见问题与排查技巧
5.1 输出无信号或直流电平的排查
我调这个模块时遇到的第一问题:寄存器写了,IO_UPDATE也触发了,但RF输出端一片安静,只有直流电平。
后来查了一圈,原因是FTW写进去是0。为什么?因为我用了一个全局变量存放FTW,但在初始化里先调用了AD9910_SetFrequency,这个函数里又依赖另一个还没初始化好的变量,导致传参变成0。改掉这个顺序问题后,信号就出来了。
另一个常见问题是PROFILE引脚悬空。AD9910的PROFILE0-2如果悬空,电平不确定,芯片可能选到其他Profile寄存器,而那个Profile里的FTW是默认值0,输出自然没有信号。解决办法是把PROFILE0-2全部接GND,或者用GPIO明确拉低。
5.2 频率偏移和杂散的常见原因
如果你发现输出频率是期望值的两倍或者一半,先别怀疑芯片坏了,检查SYSCLK配置。比如模块参考晶振是50 MHz,但你把PLL倍频设成了10倍,实际SYSCLK只有500 MHz,FTW按1 GHz算的话,输出频率就会变成实际目标的一半。
如果输出信号附近有大量杂散,先看供电。AD9910的模拟电源和数字电源建议分开走,模块上一般已经有LDO稳压,但如果你给模块供电的3.3V纹波很大,DAC输出质量会明显变差。另外SPI信号线如果太长,信号反射也会耦合到输出端,试试降低SPI时钟速度,连线尽量短。
5.3 使用逻辑分析仪抓SPI时序
调SPI驱动,强烈推荐用逻辑分析仪抓一次时序。你不需要抓所有的寄存器写入,只需要抓一条AD9910_WriteReg命令,确认CS低电平期间,SCLK上出现正确的8N+1个时钟周期(1个指令字节+N个数据字节),SDIO上的数据是MSB first,且SCLK空闲是低电平。
我第一次调的SPI时钟极性正好写反了,输出完全没反应。用逻辑分析仪一看,SCLK空闲电平是高的,和AD9910要求的Mode 0完全相反,改成CPOL=0后一切正常。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 无输出,只有直流 | FTW为0、Profile引脚悬空、IO_UPDATE未触发 | 检查FTW计算;PROFILE接地;统一触发IO_UPDATE |
| 输出频率偏差明显 | SYSCLK参数设置不对 | 核对参考晶振和PLL倍频,统一SYSCLK宏定义 |
| SPI写不进去 | 时钟极性不对、CS时序不对 | 逻辑分析仪抓时序,确认CPOL=0 CPHA=0 |
| 输出信号弱 | OSK幅度控制被启用 | 关闭OSK(CFR1中的相关位),或设置ASF为满幅 |
| 频率切换后旧配置残留 | 寄存器字节顺序错误 | 确认先写高字节还是低字节,AD9910默认MSB first |
以上是我在实际调AD9910+STM32F407时最关键的经验。最后再分享一个小技巧:调试阶段不要一上来就跑扫频模式,先把单频输出调准,用示波器确认FTW和输出频率精确对应,再做Profile切换和扫频扩展。这个顺序能帮你快速定位问题在硬件还是上层逻辑。
本文还有配套的精品资源,点击获取