AD9833与STM32的DDS信号发生器设计:SPI时序与频率控制详解
2026/9/16 16:12:01 网站建设 项目流程

简介:这是一套面向STM32嵌入式开发者的AD9833波形发生器驱动资料包,解决STM32通过SPI接口配置AD9833、生成正弦波/方波/三角波并完成固件烧录的实际问题,适用于电子测量、教学实验和信号测试等场景。压缩包共222个文件、6.15MB,以C/H源码为核心,另含Keil MDK工程配置、编译中间文件、AXF/HEX可烧录文件及少量说明文档,可在Keil中直接打开、重新编译并下载到开发板验证。已有1332人浏览学习,工程结构完整、驱动经实测可用,适合正在使用STM32但需要快速集成DDS波形发生功能的开发者。基于STM32F103系列标准外设库的工程,覆盖GPIO控制、SPI初始化、频率与相位寄存器写入、波形切换以及TIM/RCC/ADC模块调用,能够帮助理解AD9833的上电时序、寄存器配置与通信报文格式;同时保留调试阶段的映射、列表、备份文件,便于排查编译下载错误,缩短软硬件联调时间。

1. 先看这个工程解决的是哪一类问题

做信号发生器时,多数人的第一反应是 DAC 加查表,到了 1MHz 以上就开始吃 CPU,波形还不干净。AD9833 这类 DDS 芯片的思路完全不同:STM32 只负责通过 SPI 把频率字、波形模式写进寄存器,之后芯片内部相位累加器自己跑,输出正弦、三角、方波都不再占用 MCU 时间。这个AD9833 STM32.zip解压后是一套基于 STM32F103RBT6 的标准外设库工程,启动文件 hd/ld 都带,例程本身亲测出波。值得注意的反直觉点是:SPI 时钟再快也决定不了输出频率,AD9833 的输出频率只由外部主时钟 MCLK 和 28 位频率寄存器共同决定,SPI 只是配置通道。适合要往设备里加可调信号源、又不想上 FPGA 的嵌入式工程师,也适合准备做扫频仪、锁相环参考源的人研究时序设计。

2. STM32 SPI 引脚分配与 AD9833 时序匹配

2.1 为什么工程里选 SPI 而不是 I2C

AD9833 只支持三线 SPI 写操作,数据线 SDATA、时钟 SCLK、帧同步 FSYNC,不需要读回。工程里虽然出现了stm32f10x_i2c.c,但那是作者其它外设的代码,AD9833 这条链路用不到 I2C。选 SPI 的另一个原因是 AD9833 写入端接受最高 40MHz 的 SCLK,而 STM32F103 的 SPI1 挂在 APB2 上,72MHz 主频下分频到 9MHz 完全够用,不会成为配置瓶颈。

有人会用 GPIO 模拟 SPI 去驱动这块芯片,一样能工作,但 F103 硬件 SPI 配合 16 位数据帧更省事。工程里没有启用stm32f10x_spi.c,说明作者当时用的是软件模拟或者后续自己补了库文件,我这里给出的方案采用硬件 SPI1,代码风格依然是标准外设库,跟工程现有文件一致。如果坚持软件模拟,注意 FSYNC 必须在 SCK 开始前至少拉低一个建立时间,这个时序用 GPIO 模拟反而容易控制,代价是每 16 位要手动翻转多次引脚。

2.2 F103 引脚分配与 SPI 模式配置

STM32F103RBT6 的 SPI1 默认引脚是 PA5(SCK)、PA6(MISO)、PA7(MOSI)。AD9833 不向主机回数据,MISO 可以空着不接。FSYNC 用任意 GPIO 控制,工程里选 PA4 比较常见,因为 PA4 默认就是 SPI1_NSS,软件管理片选时把它当普通推挽输出用,不开启硬件 NSS。下面是初始化代码。

void AD9833_SPI_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; SPI_InitTypeDef SPI_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); /* FSYNC: PA4 推挽输出, 空闲拉高 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_SetBits(GPIOA, GPIO_Pin_4); /* SCK: PA5, MOSI: PA7 复用推挽 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex; SPI_InitStructure.SPI_Mode = SPI_Mode_Master; SPI_InitStructure.SPI_DataSize = SPI_DataSize_16b; SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; SPI_InitStructure.SPI_CPHA = SPI_CPHA_2Edge; SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_8; SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); }

这段配置里最关键的是SPI_CPOL_LowSPI_CPHA_2Edge。AD9833 手册要求数据在 SCLK 下降沿被锁存,主机这边选择 CPOL=0、CPHA=1 后,SCK 空闲为低,数据在上升沿移出、下降沿被从机采样,正好对上。数据帧必须设成 16 位,AD9833 的寄存器字就是 16 位,拆成两个 8 位发反而会多出片选翻转。分频选 8,SPI1 时钟为 72/8=9MHz,留有余量。如果板子走线长或者干扰大,可以换SPI_BaudRatePrescaler_16,4.5MHz 也能正常工作,速率瓶颈不在 SPI 而在 AD9833 的 MCLK。

2.3 FSYNC 低电平期间必须完整发完 16 个 SCK

AD9833 的 FSYNC 相当于片选,但比普通 SPI 片选更敏感。正确的帧格式是:先拉低 FSYNC,再启动一次 16 位 SPI 传输,等移位寄存器完全发送完毕,再拉高 FSYNC。问题出在很多新手用SPI_I2S_SendData之后立刻拉高片选,实际上发送缓冲空和移位完成是两件事,BSY 标志才是真正的传输结束标志。看下面代码。

void AD9833_Write(uint16_t dat) { /* FSYNC 拉低, 开始一帧 */ GPIO_ResetBits(GPIOA, GPIO_Pin_4); /* 等待发送缓冲可写 */ while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, dat); /* 等待缓冲再次为空, 并且移位寄存器不忙 */ while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) == SET); /* 整帧发完, FSYNC 拉高 */ GPIO_SetBits(GPIOA, GPIO_Pin_4); /* 两次写之间给 FSYNC 高电平恢复时间 */ for (volatile int i = 0; i < 8; i++); }

SPI_I2S_FLAG_BSY这个标志很容易被忽略。SPI 发送寄存器把数据移交给移位寄存器后 TXE 就置位,但此时数据还在 SCK 上逐位移出,如果马上拉高 FSYNC,AD9833 收到的帧会缺尾巴,表现就是写频率没反应、输出还是默认波形。循环里的空转是给 FSYNC 高电平恢复时间,AD9833 要求两次写之间 FSYNC 高电平至少保持几个纳秒,F103 跑 72MHz,空转 8 次足够。如果工程里启用了中断或者 DMA,记得把 FSYNC 拉高动作放到传输完成回调里,不要在中断里提前做。

3. AD9833 控制寄存器布局与标准库驱动实现

3.1 控制寄存器位定义与上电初始化序列

AD9833 的控制寄存器是 16 位,写入时最高两位 DB15、DB14 为 00,剩下 14 位是控制位。这部分每个位都对应一个具体功能,做驱动之前先把位定义表放在手边。下面是常用位映射,多位资料版本标注略有差异,移植时以你手上数据手册 Table 5 为准。

掩码名称功能说明
DB130x2000B281 时 28 位频率分两次 14 位写入
DB120x1000HLBB28=0 时选择写高 14 位还是低 14 位
DB110x0800FSEL选择 FREQ0 或 FREQ1 作为输出频率源
DB100x0400PSEL选择 PHASE0 或 PHASE1 作为输出相位偏移
DB90x0200RESET1 时复位内部逻辑,不输出
DB60x0040OPBITEN1 时输出方波替代正弦
DB50x0020MODEOPBITEN=0 时,1 选三角波,0 选正弦
DB30x0008FSYNC控制寄存器内的同步位,常规写 0

上电后不要急着写频率。AD9833 上电默认状态不可控,例程的做法是先发一个控制字进入确定状态,然后写频率,最后清掉状态位开始输出。初始化序列如下。

void AD9833_Init(void) { /* 开启 B28 并进入编程/复位状态, 0x2100 是例程原值 */ AD9833_Write(0x2100); /* 等待内部逻辑稳定 */ for (volatile int i = 0; i < 1000; i++); /* 默认输出 FREQ0, 正弦波, 清掉状态位开始工作 */ AD9833_Write(0x2000); }

这里有个容易踩的细节:0x2100 这个数值在网络上流传很广,但不同批次的芯片对 RESET 位的标注位置有差异,有的数据手册写 DB9,有的写 DB8。我的习惯是初始化时先查数据手册确认 RESET 位,然后把控制字换成宏定义,而不是直接抄裸数。B28 位本身是 0x2000 这一点没有争议,0x2100 多出来的低位就是状态位,写入频率寄存器后一定要用 0x2000 把它清掉,否则芯片一直处于复位态,片选之后 VOUT 上什么都没有。

3.2 频率寄存器写入与频率更新函数

AD9833 的每个频率寄存器是 28 位,而 SPI 帧只有 16 位,所以必须拆分。B28=1 模式下,先写低 14 位再写高 14 位,两次写都发送 0x4000 开头的数据字。0x4000 对应的二进制是 DB15=0、DB14=1,这是 AD9833 规定好的 FREQ0 寄存器帧头,第二次写入时芯片内部自动把数据放到高 14 位,不需要额外地址。频率更新函数如下。

#define AD9833_MCLK_HZ 25000000UL void AD9833_SetFreqReg(uint32_t freq_reg) { uint16_t low = (uint16_t)(freq_reg & 0x3FFF); uint16_t high = (uint16_t)((freq_reg >> 14) & 0x3FFF); /* 进入编程状态, 避免写入过程中输出乱跳 */ AD9833_Write(0x2100); /* FREQ0 低 14 位, 再写高 14 位 */ AD9833_Write(0x4000 | low); AD9833_Write(0x4000 | high); /* 退出编程状态, 应用新频率 */ AD9833_Write(0x2000); }

低 14 位和高 14 位分别用freq_reg & 0x3FFF(freq_reg >> 14) & 0x3FFF取出,0x3FFF 就是 14 位全 1。如果用户传入的 freq_reg 超过 28 位范围,高位会被& 0x3FFF截掉,所以调用前要保证频率寄存器值不超过 0x0FFFFFFF。每次更新频率都先写 0x2100 是稳妥做法,代价是输出会有短暂的复位间隙。对于连续扫频应用,这个间隙会导致输出毛刺,真正需要平滑跳频的场景要用第 6 章的方式处理。

这个函数想要输出 FREQ1,把两处 0x4000 换成 0xC000 即可。0xC000 是 DB15=1、DB14=1 的 FREQ1 帧头,同样分两次写。切换 FREQ0 和 FREQ1 作为输出源则通过控制寄存器 FSEL 位实现,后面跳频部分会专门讲。

3.3 正弦、三角、方波切换完整实现

波形选择不靠改频率寄存器,而是改控制寄存器的 OPBITEN 和 MODE 两位。OPBITEN=0、MODE=0 输出正弦,OPBITEN=0、MODE=1 输出三角波,OPBITEN=1 输出方波。注意方波模式下 MODE 位无效,但最好还是置 0 避免混淆。实现代码如下。

#define AD9833_WAVE_SINE 0 #define AD9833_WAVE_TRI 1 #define AD9833_WAVE_SQUARE 2 void AD9833_SetWave(uint8_t wave) { uint16_t ctrl = 0x2000; /* 保持 B28=1, 其它控制位默认 0 */ switch (wave) { case AD9833_WAVE_TRI: ctrl |= 0x0020; /* MODE=1, 三角波 */ break; case AD9833_WAVE_SQUARE: ctrl |= 0x0040; /* OPBITEN=1, 方波 */ break; case AD9833_WAVE_SINE: default: break; /* OPBITEN=0, MODE=0, 正弦 */ } AD9833_Write(ctrl); }

0x0020 对应 MODE 位,0x0040 对应 OPBITEN 位,这两个掩码在大多数资料里是固定的。这里有一个新手容易犯的错误:切换波形时直接用AD9833_Write(0x0020),把 B28 位丢了。控制寄存器是整体覆盖写入,不是置位写入,所以每次写控制字都必须带上当前需要的所有位状态。工程里如果后续要同时保持 FSEL、PSEL 等位的状态,建议维护一个全局 shadow 控制字,修改位后再整体写出去,而不是在函数里拼一个只有单个位的临时值。

主流程就很简单了:初始化 SPI、初始化 AD9833、设置一次频率、选择波形,剩下的输出由芯片自己完成。实测时用示波器探头接 VOUT 引脚,正弦波满幅输出约 0.6Vpp,不是 3.3V 满摆幅,这是 AD9833 内部 DAC 的固有特性,后面章节会展开讲。

4. AD9833 频率寄存器计算与精度边界

4.1 28 位频率字换算公式

AD9833 内部是 28 位相位累加器,输出频率由 MCLK 和频率寄存器值共同决定,公式是:

fout = (MCLK × FREQREG) / 2^28

反过来由目标频率求寄存器值就是FREQREG = (fout × 2^28) / MCLK。工程里 MCLK 常见是 25MHz,F103 端的 SPI 速率跟这个没关系,因为 MCLK 是 AD9833 自己的模拟主时钟,通常由外部有源晶振提供。代码实现时要小心乘法溢出,fout 最大 25MHz 时,fout × 2^28 已经超过 32 位无符号整数上限,必须用 64 位中间变量。

uint32_t AD9833_CalcFreqReg(uint32_t mclk_hz, uint32_t freq_hz) { uint64_t acc = (uint64_t)freq_hz << 28; return (uint32_t)(acc / mclk_hz); }

(uint64_t)freq_hz << 28等于乘以 268435456,先把被除数扩大再除,避免浮点误差。如果 mclk_hz 传 25000000、freq_hz 传 10000,得到 107374,写入芯片后实际输出约 10000.0Hz。计算结果的误差来源只有一个:寄存器值必须是整数,小数部分被截断。频率分辨率是 MCLK / 2^28,25MHz 对应约 0.093Hz,也就是频率字每增加 1,输出频率增加约 0.093Hz。想提高分辨率就降 MCLK,但 MCLK 太低会限制最高输出频率,25MHz 下理论上限是奈奎斯特边界 12.5MHz,实际建议不超过 10MHz。

4.2 典型频点寄存器值与误差对照

用上面公式可以算出一组常用频点的寄存器值。下面的表格按 MCLK=25MHz、截断取整计算,实际频率是写入寄存器后芯片的真实输出。

目标频率FREQREG实际输出绝对误差
1kHz10737999.96Hz0.04Hz
10kHz1073749999.79Hz0.21Hz
100kHz107374199999.66Hz0.34Hz
1MHz10737418999999.30Hz0.70Hz
5MHz536870914999996.51Hz3.49Hz

可以看到频率越高绝对误差越大,这是取整误差被放大导致的,相对误差始终在万分之几量级。如果某个频点对精度要求苛刻,两个做法:一是改用更接近实际值的 MCLK 频率做除法,二是在公式里加舍入而不是截断,(acc + mclk_hz/2) / mclk_hz可以把最大误差从 0.093Hz 降到 0.047Hz。但对大多数信号源应用,这点误差远小于晶振本身的频率偏差,实际瓶颈是有源晶振的精度,而不是 DDS 的量化误差。

4.3 波形输出幅度与负载边界

AD9833 的 VOUT 引脚输出的是内部 DAC 电流经过负载电阻后的电压,满幅约 0.6Vpp,不是 TTL 电平。方波例外,OPBITEN=1 时输出的是数字方波,幅度接近 VDD。很多人第一次上电看到正弦波只有 0.3V 就以为芯片坏了,其实这是正常的,需要外部运放放大到需要的幅度。

这带来一个使用时必须注意的边界:如果后级直接接 50Ω 负载,VOUT 电压会被拉到很低,波形变形。我一般会在 VOUT 后加一级高输入阻抗的运放跟随器,或者用同相放大把 0.6Vpp 放大到 3Vpp 左右再接负载。还要注意 AD9833 的输出默认带 DC 偏置,VOUT 内部参考约 VREF/2,AC 耦合时串一个隔直电容。如果目标是扫频应用,放大器的带宽要按最高输出频率算,至少留 3 到 5 倍裕量,否则高频段幅度会先塌下去。

5. 上电无波形的排查路径

5.1 第一件事是量 MCLK 而不是量 SPI

AD9833 输出没波形时,我见过最多的原因是 MCLK 根本没起振。SPI 配置得再对,写入寄存器再多,没有主时钟芯片内部完全不动。先拿示波器或频率计量 AD9833 的 MCLK 引脚,有源晶振输出应该在 25MHz 附近,幅度一般几百毫伏到几伏。如果 MCLK 正常,再去量 SPI 三根线。另外也出现过晶振虚焊、晶振电源脚没退耦这类低级问题,MCLK 引脚旁边加 0.1μF 去耦电容是标准做法。

如果手头没有 25MHz 晶振,可以让 STM32F103 的 MCO 引脚输出 8MHz HSE 给 AD9833 当 MCLK,用RCC_MCOConfig(RCC_MCO_HSE)配置。此时输出频率上限降低,分辨率变成 8M/2^28 约 0.0298Hz,对低频信号源反而更精细。要注意 MCO 输出的是数字方波,幅度是 0 到 VDD,直接接 AD9833 的 MCLK 没问题。

5.2 SPI 帧该长什么样

用逻辑分析仪抓 FSYNC、SCLK、SDATA 三根线,重点看几个点。FSYNC 拉低期间必须恰好 16 个 SCLK 下降沿,多一个少一个都不行。SDATA 的数据要在每个 SCLK 下降沿稳定,所以抓波形时把采样率调到 SCLK 的 8 倍以上,否则看不出建立时间。最后一根 SCLK 下降沿之后,FSYNC 要等 SCK 回到高电平才能拉高,这个间隔太小的话,高位数据会被下一帧干扰。

我的经验是 FSYNC 拉高后加个 1μs 的延迟再发下一帧,虽然 AD9833 手册只要求几十纳秒,但留余量对长走线的板子很重要。如果抓到的波形显示 SCK 在 FSYNC 拉低前就有边沿,说明 SPI 外设在片选之前就启动了时钟,要查是不是软件 NSS 和硬件 NSS 配置打架,SPI_NSS_Soft模式下SPI_NSSInternalSoftwareConfig也要关掉。

5.3 启动文件、芯片包与常见误区

这个工程里同时存在startup_stm32f10x_hdstartup_stm32f10x_ld,对应高密度和低密度启动文件。STM32F103RBT6 是 128KB Flash,属于高密度,Keil 工程里应该选 hd 版本。启动文件选错不会直接编译报错,但中断向量表偏移不对,SPI 中断、定时器中断都可能不正常,表现非常诡异。检查方法是在 Keil 的 Options for Target 里看 Device 是否选了 STM32F103RB,再让编译器自动匹配启动文件。

烧录时如果 Keil 报error: no stm32 target found!,先确认 ST-Link 在设备管理器里正常枚举,再看 Keil 的 Debug 设置里选的是不是 ST-Link,最后检查芯片包版本。很多情况下是 Keil 装好了但 STM32 芯片包没装,或者装了旧版包不识别 F103RB,打开 Pack Installer 更新到新版即可。这类问题跟 AD9833 本身无关,但卡住之后最容易让人误判成驱动写错,先排除环境再调时序,效率更高。

提示:AD9833 的寄存器写入没有读回功能,写对了没有任何即时反馈。所以第一次调通后建议用示波器把正弦波波形存下来,后续改代码如果波形消失,先对比 SPI 时序而不是怀疑寄存器数值。

6. AD9833 跳频技巧:半字更新与 FREQ0/FREQ1 无缝切换

6.1 B28=0 模式下只更新半个寄存器

默认 B28=1 模式每次写频率都要传两个 16 位半字,更新一次 28 位频率需要 32 个 SCK。对于实时扫频应用,比如 PID 调频或者频率步进扫描,这个延迟可以被优化。AD9833 支持 B28=0 模式,配合 HLB 位可以单独更新频率寄存器的高 14 位或低 14 位,不用每次都把所有位重新写一遍。

/* 只更新 FREQ0 低 14 位, 高频段扫频时用 */ void AD9833_UpdateFreqLow(uint16_t low14) { /* 控制字: B28=0, HLB=0, 保持其他位 */ AD9833_Write(0x0000); AD9833_Write(0x4000 | (low14 & 0x3FFF)); } /* 只更新 FREQ0 高 14 位 */ void AD9833_UpdateFreqHigh(uint16_t high14) { /* 控制字: B28=0, HLB=1 */ AD9833_Write(0x1000); AD9833_Write(0x4000 | (high14 & 0x3FFF)); }

这两个函数里控制字都写成固定值,实际工程中如果已经设置了 OPBITEN 或 MODE,一定要把对应位加上,否则切波形后频率更新会互相覆盖。B28=0 模式真正的价值不是省那几个微秒,而是让程序可以只变更需要的半字,低 14 位常用于精细调频,高 14 位用于粗调。F103 在 9MHz SPI 下传 16 位大约 1.8μs,整字写也就 3.6μs,性能提升有限,但代码逻辑上更贴合扫频控制的思路。

6.2 用 FREQ0 和 FREQ1 实现相位连续切换

AD9833 内部有两个完整的 28 位频率寄存器 FREQ0 和 FREQ1,切换输出源只需要改控制寄存器 FSEL 一位。由于两个频率寄存器同时存在于芯片内部,切换瞬间相位累加器不中断,输出频率改变但相位连续,这对锁相环参考源和 FSK 调制非常关键。先分别给两个寄存器写不同的频率,再用一条写控制字的指令切换。

void AD9833_SetupFskFreqs(void) { /* FREQ0 写 1kHz */ AD9833_SetFreqReg(AD9833_CalcFreqReg(25000000UL, 1000UL)); /* 写 FREQ1, 帧头换成 0xC000 */ uint16_t reg = AD9833_CalcFreqReg(25000000UL, 1250UL); AD9833_Write(0x2100); AD9833_Write(0xC000 | (reg & 0x3FFF)); AD9833_Write(0xC000 | ((reg >> 14) & 0x3FFF)); AD9833_Write(0x2000); } /* 切换输出到 FREQ1 */ void AD9833_SwitchFreq1(void) { /* FSEL=1, B28 保持 1 */ AD9833_Write(0x2000 | 0x0800); } /* 切回 FREQ0 */ void AD9833_SwitchFreq0(void) { /* FSEL=0 */ AD9833_Write(0x2000); }

FSEL 位是 0x0800,切换操作本身只写一个 16 位控制字,硬件上就是一次 SPI 事务,延迟在微秒级。这个特性用来做二进制 FSK 很合适,两个频点提前配置好,数据 0 和 1 分别对应 FREQ0 和 FREQ1,主循环里只翻转 FSEL,不碰频率寄存器,输出保持相位连续。测试方法是用信号发生器模式输出 1kHz 和 1.25kHz 交替,示波器上看波形没有毛刺和相位跳变,说明切换生效。如果看到切换瞬间波形异常,回查 FSYNC 是否在每次控制字写入后正确拉高,以及控制字是否覆盖了 B28 位导致寄存器写入模式变化。

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

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

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

立即咨询