news 2026/9/2 16:05:39

STM32驱动AD9833实现DDS波形发生器:原理、代码与调试

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32驱动AD9833实现DDS波形发生器:原理、代码与调试

简介:这是一份基于STM32控制AD9833输出正弦波、方波和三角波的C语言工程,适合嵌入式初学者和需要快速实现信号发生功能的开发者。包内共有13个文件,由7个.h头文件和6个.c源文件组成,包含AD9833驱动、SPI通信、OLED显示、PWM控制与延时处理等模块,代码结构清晰,可直接阅读并移植到现有项目中。压缩包仅10KB,体量轻巧,便于下载后快速分析。已有3867人学习,反映了其在嵌入式波形生成方向上的实用性。通过该工程可掌握AD9833频率与相位寄存器的配置思路,理解STM32的GPIO复用、SPI时序以及驱动层的分层方法,并借鉴其中OLED和PWM模块的写法,为后续开发信号源、测试测量或物联网感知设备提供可复用的软件基础。 最近在折腾一个小型信号源,手头正好有STM32F103和一颗ADI的AD9833模块,就把“用STM32控制AD9833输出波形”这件事从硬件到代码完整走了一遍。如果你也想做函数信号发生器、扫频信号源,或者给LCR测量、音频设备提供一个稳定可控的激励信号,这个组合性价比很高:STM32负责控制和交互,AD9833负责把频率字直接合成正弦波、三角波和方波,频率最高能到参考时钟的一半,用25MHz晶振时输出12.5MHz以内的信号没什么压力。这篇文章我会把选型逻辑、寄存器计算、三线SPI时序和实际调试中的坑一次说完,照着做基本能点亮波形。

1. 备选方案那么多,为什么是STM32加AD9833

1.1 DDS芯片和直接DAC方案的差别

很多人看到“输出波形”第一反应是用STM32内部DAC加定时器,DMA往DAC寄存器里灌正弦表数据。这个方案不是不能用,但有两个绕不开的问题:一是频率上限低,DAC的更新速率受定时器触发频率和DMA带宽限制,STM32F103跑到几百kHz正弦波就很吃力;二是频率切换麻烦,改频率等于改正弦表的步进或重算表,代码开销大。AD9833这类DDS芯片把相位累加和查表逻辑做在硬件里,你只需要通过SPI写一个28位的频率控制字,输出频率就变了,整个过程只涉及几次寄存器写入,毫秒级响应,而且频率分辨率可以做到0.1Hz级别,这是普通DAC方案很难实现的。

1.2 我这次的需求拆解和选型取舍

做这个项目前我先列了一下需求:可输出1Hz到1MHz正弦波,频率步进尽量细,波形能切换,最好能预留扫频功能。基于这几点,AD9833的28位频率寄存器、三路波形输出和独立的相位寄存器都能满足。STM32在这里的角色是“大脑”,负责解析用户设定的频率值、换算成频率控制字、通过三线SPI喂给AD9833,再用按键或串口做交互。至于为什么不用AD9834或AD9850,主要考虑AD9833模块便宜、资料多、三线SPI省引脚,而且3.3V供电下功耗低,适合嵌入式场景。

2. 硬件连接和电路细节

2.1 STM32与AD9833三线SPI的接线

AD9833的通信接口是典型的SPI从机,但只有三根线:SDATA(数据)、SCLK(时钟)、FSYNC(帧同步,低有效)。控制寄存器、频率寄存器、相位寄存器都是通过16位一帧的数据写入。我的接线是这样:

AD9833引脚STM32引脚说明
FSYNCPA4帧同步信号,低电平有效
SCLKPA5SPI时钟
SDATAPA7SPI数据输入
VDD3.3V模块供电,注意稳压
GNDGND共地

这里我建议用GPIO模拟三线SPI,而不是直接复用STM32的硬件SPI外设。原因后面代码环节详细说,但先给结论:AD9833只写不读,时序又很简单,GPIO模拟比硬件SPI更容易控制帧边界,排错也直观。

2.2 电源、参考时钟和输出端的处理

AD9833的模拟和数字部分共用VDD,供电范围2.3V到5.5V。我用的模块板载了25MHz有源晶振,如果你手头是裸片或没带晶振的板子,需要自己给MCLK引脚接25MHz有源晶振,否则芯片不工作。电源去耦别省:VDD引脚旁边放0.1uF陶瓷电容和10uF钽电容,走线尽量短,晶振附近不要走数字信号线。

输出端是很多人忽略的重点。AD9833的IOUT引脚是DAC输出,它不是推挽电压输出,必须外接负载电阻到AGND才能形成电压信号。一般接200Ω电阻可以得到大约0.6Vpp的输出,如果你希望幅度更大或带载能力更强,后面要加一级运放做缓冲和放大。很多成品模块已经集成了运放电路,但如果你自己画板,这个负载电阻一定不能省,否则示波器探头上看不到波形。

3. AD9833寄存器体系和频率字计算

3.1 三组寄存器的读写规则

AD9833内部需要理解的是三组16位寄存器:控制寄存器、频率寄存器、相位寄存器。每一帧16位数据的高两位决定这帧数据写给谁:00是控制寄存器,01是频率寄存器,10和11是相位寄存器(对应PHASE0和PHASE1)。写入控制寄存器时,高两位是00;写入频率寄存器时,基准是0x4000,bit13选择FREQ0还是FREQ1;写入相位寄存器时,基准是0x8000,bit12选择PHASE0还是PHASE1。

控制寄存器里常用的几个位是:B28(bit15)决定频率字是否需要分两次写入,1表示分两次写完整的28位;RESET(bit11)是软件复位,置1后内部清零;OPBITEN(bit8)和MODE(bit5)配合决定输出正弦还是三角波。需要特别说明的是,B28置1的情况下,写频率字必须连续写两帧,第一帧是低14位,第二帧是高14位,中间不可以在寄存器之间切换,否则会出现频率字高低位错乱。

3.2 频率控制字计算公式和实例

AD9833的28位频率字和输出频率的关系是:

[ f_{out} = \frac{FreqReg \times MCLK}{2^{28}} ]

反过来,根据目标频率计算频率字就是:

[ FreqReg = \frac{f_{out} \times 2^{28}}{MCLK} ]

我用25MHz参考时钟举个例子。如果目标输出1kHz正弦波,那么FreqReg = 1000 * 268435456 / 25000000,结果是10737.418,取整10737。实际输出频率为10737 * 25000000 / 268435456,约999.8Hz,误差大约0.02%,对绝大多数应用都够了。如果目标输出1MHz,算出来是10737418.24取整10737418,实际频率基本是1.000000MHz。从这两个例子能看出来,频率越低,量化误差的相对影响越大,但AD9833的频率分辨率25MHz/2^28约0.093Hz,所以1Hz以上的信号精度还是可以接受。

计算频率字时要注意C语言的整型溢出问题。如果直接写freq * 268435456UL,当freq超过几十kHz时32位乘法的结果可能溢出。我在代码里用uint64_t做中间运算再截断,安全很多。

4. STM32代码实现,从初始化到波形切换

4.1 为什么我坚持用GPIO模拟三线SPI

先说结论:AD9833没有MISO回读功能,只有写操作,而且对帧边界要求严格。硬件SPI虽然也能驱动,但要注意两点:一是STM32的硬件SPI在发送16位数据时,NSS引脚的拉低拉高时机不一定能精确控制在“正好16个时钟周期”的边界;二是AD9833在SCLK下降沿采样数据,而STM32硬件SPI默认时序往往是主机在时钟边沿更新数据,上升沿/下降沿的采样关系容易搞混。实际测试中,我见过很多人用硬件SPI模式0或模式3驱动AD9833出现频率偶发跳变、波形时有时无的问题,本质就是时序沿不匹配。

GPIO模拟就没有这些烦恼。AD9833要求SCLK空闲为高,主机在FSYNC拉低后,先把数据放到SDATA,然后SCLK拉低产生下降沿,AD9833在下降沿锁存数据,再拉高SCLK准备下一位。整个过程完全由软件控制,只要循环里顺序对,帧边界一定干净。

4.2 完整驱动代码和初始化序列

我用的是标准库,HAL库的逻辑完全一样,只是GPIO读写函数换成HAL_GPIO_WritePin。先看模拟SPI写一个16位寄存器帧的函数:

#define AD9833_FSYNC_H() GPIO_SetBits(GPIOA, GPIO_Pin_4) #define AD9833_FSYNC_L() GPIO_ResetBits(GPIOA, GPIO_Pin_4) #define AD9833_SCLK_H() GPIO_SetBits(GPIOA, GPIO_Pin_5) #define AD9833_SCLK_L() GPIO_ResetBits(GPIOA, GPIO_Pin_5) #define AD9833_SDATA_H() GPIO_SetBits(GPIOA, GPIO_Pin_7) #define AD9833_SDATA_L() GPIO_ResetBits(GPIOA, GPIO_Pin_7) void AD9833_Write16(uint16_t data) { uint8_t i; AD9833_FSYNC_L(); for (i = 0; i < 16; i++) { if (data & 0x8000) AD9833_SDATA_H(); else AD9833_SDATA_L(); AD9833_SCLK_L(); AD9833_SCLK_H(); data <<= 1; } AD9833_FSYNC_H(); }

每个循环里先放数据、再拉低SCLK造下降沿,正符合上述时序分析。注意FSYNC在16位传输过程中始终保持低,必须等16个SCLK时钟全部跑完才拉高,这一点特别重要,很多人写成每发一位就拉一次FSYNC,芯片会识别成多帧,数据完全错乱。

初始化序列按位字段写,不搞网上流传的硬编码数字,可读性和可移植性都好很多:

#define AD9833_B28 (1U << 15) #define AD9833_RESET (1U << 11) #define AD9833_MODE_TRI (1U << 5) // 1为三角波,0为正弦波 void AD9833_Init(void) { AD9833_Write16(AD9833_B28 | AD9833_RESET); // 进入复位 AD9833_Write16(AD9833_B28); // 退出复位 AD9833_Write16(AD9833_B28); // 确认正弦波模式 }

这三帧的顺序不建议随意换。第一帧把复位位置1,芯片内部清零;第二帧把复位清掉,第三帧保持配置。初始化完成后,芯片默认输出FREQ0和PHASE0对应频率、相位的正弦波,但此时频率寄存器是空的,必须写频率字才有实际输出。

4.3 频率字写入和波形切换的完整函数

写频率字要按28位拆分两帧发送,基准地址0x4000对应FREQ0,0x6000对应FREQ1:

void AD9833_SetFreq(uint8_t freq_sel, uint32_t freq_hz, uint32_t mclk_hz) { uint32_t freq_reg; uint16_t base; freq_reg = (uint32_t)(((uint64_t)freq_hz * 268435456UL) / mclk_hz); freq_reg &= 0x0FFFFFFFUL; if (freq_sel == 0) base = 0x4000; // FREQ0 else base = 0x6000; // FREQ1 AD9833_Write16(base | (freq_reg & 0x3FFF)); // 低14位 AD9833_Write16(base | ((freq_reg >> 14) & 0x3FFF)); // 高14位 } void AD9833_SetWave(uint8_t wave) { uint16_t ctrl = AD9833_B28; if (wave == 1) ctrl |= AD9833_MODE_TRI; AD9833_Write16(ctrl); }

AD9833_SetFreq里先计算频率字,再拆成低14位和高14位两次写入。AD9833_SetWave切换正弦和三角波,本质就是改MODE位。方波输出我这次没有用,因为AD9833的方波其实是把内部比较器输出引出来,需要额外设置OPBITEN、SIGN/PIB、DIV2等多个位,不如直接用STM32的GPIO翻转或定时器输出PWM来得直观。

5. 实测结果、常见问题和调试经验

5.1 示波器实测波形观察

写好代码后,我用示波器分别抓了1kHz、100kHz和1MHz的正弦波输出。1kHz时波形很干净,几乎没有可见台阶;100kHz时能看到DAC固有的阶梯状毛刺,但幅度稳定;1MHz时波形开始有轻微失真,这是DAC重建后的高频镜像和寄生电容共同作用的结果,属正常现象。如果对波形纯净度有要求,AD9833输出端接一个简单的RC低通滤波器,截止频率取目标最高频率的3到5倍,毛刺能压掉不少。

用频率计实测1kHz设定值,实际输出999.8Hz,和前面算的误差吻合。如果你发现误差明显偏大,优先检查MCLK实际频率是不是25.000MHz,劣质模块上的晶振温漂或负载电容不对会导致频率偏差到几十上百ppm。

5.2 常见问题速查表

实际操作中遇到的问题,我整理成了一张表:

现象可能原因解决办法
完全没有输出复位位没释放检查初始化序列是否写入退出复位控制字
完全没有输出IOUT端没接负载电阻在IOUT与AGND间接200Ω电阻
完全没有输出MCLK没起振确认晶振供电和引脚连接
输出频率是设定值的一半DIV2位被误置1检查控制字bit6是否为0
波形是三角波而不是正弦MODE位错误确认控制字bit5为0
频率偶尔跳变或不稳定FSYNC在16位中间被拉高保证一帧16个SCLK持续期间FSYNC保持低
切换频率后输出不变FSELECT指向的频率寄存器没写入用示波器或逻辑分析仪确认写入基址是0x4000还是0x6000
波形毛刺大电源去耦不足VDD旁加0.1uF和10uF电容
输出幅度很小输出端负载电阻偏大换200Ω电阻或加运放缓冲

值得单独提醒的是“切换频率后输出不变”这个坑。AD9833默认输出由控制寄存器的FSELECT位决定是FREQ0还是FREQ1,如果你初始化时控制字不小心把FSELECT置成1,但频率字写进的是FREQ0,自然听不到变化。网上很多旧例程用0x2100和0x2000这套数字写控制寄存器,如果照抄又没注意FSELECT位,很容易踩这个雷。我在代码里直接用位字段宏,就是为了避免这种不易察觉的配置错误。

5.3 几个实际项目中的调试心得

最后补充几条常规文档里不会写的经验。

第一,调通之后,如果你要做扫频或者快速跳频,不要每次都重写28位频率字。AD9833硬件上给你准备了FREQ0和FREQ1两个频率寄存器,你可以预先把两个目标频率写进去,之后只需要写一帧控制字切换FSELECT位就能在两个频率之间跳转,时间开销从两帧缩短到一帧,而且没有频率字拆装过程的中间态。这个技巧在做扫频信号源时特别实用,频率切换速度可以做到微秒级。

第二,逻辑分析仪比示波器更适合排查SPI时序问题。示波器只能看到SCLK和SDATA的宏观电平,逻辑分析仪可以一帧一帧解码出实际的16位数据。我当时定位“频率跳变”问题,就是靠逻辑分析仪发现FSYNC在每传8位后意外拉高了一次,修正后才彻底解决。

第三,IOUT的输出幅度和参考电压有关,而AD9833内部参考电压是固定1.2V左右,所以输出幅度天然受限。如果你需要更大摆幅或可调幅度,后级运放几乎是必须的,设计时给运放留一个电位器调增益,工程上会灵活很多。

这个项目做完之后,我又顺手把扫频逻辑和串口交互加上了,现在可以按键设置起始频率、截止频率和扫频步进,配合ADC采集就能搭一个简易的频率响应测试工具。如果你也在做类似的东西,建议先照着上面的流程把固定频率输出点亮,再去折腾扫频和交互,一步步来,出问题也好定位。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/2 16:04:17

球墨管、铸铁球墨管和离心管到底怎么区分?

在给排水工程询价和材料清单中&#xff0c;“球墨管”“铸铁球墨管”“离心球墨铸铁管”等名称经常同时出现。有些名称是行业简称&#xff0c;有些强调材质或生产工艺&#xff0c;如果只凭叫法下单&#xff0c;容易出现口径理解不一致、配件遗漏或接口不匹配。采购前先把使用场…

作者头像 李华
网站建设 2026/9/2 16:01:34

胰腺癌研究常用株 AsPC-1,复苏慢这点要心里有数

AsPC-1 是胰腺癌研究里用得很多的一株&#xff0c;转移性胰腺腺癌来源&#xff0c;做 3D 培养、转染和药物评价都常见。它最需要心理准备的一点是——复苏恢复慢&#xff0c;别头几天看到一堆漂浮细胞就慌。AsPC-1 是人转移胰腺腺癌细胞&#xff0c;来源于一名 62 岁白人女性胰…

作者头像 李华
网站建设 2026/9/2 15:58:59

RK3588 -视觉算法渐进式集成

AI 视觉算法如何从 1 个扩展到 20 个&#xff1a;渐进式集成方法论与踩坑实录越微智能&#xff08;Yuewell&#xff09;工业边缘 AI 工程实践系列 第 3 篇 关键词&#xff1a;算法集成、串行封版、配置驱动路由、双管线 Pipeline、Fan-out、NPU 多实例一、为什么不能并行开发多…

作者头像 李华
网站建设 2026/9/2 15:57:24

OpenAI Astra连续运行启示:如何构建持续智能体系统

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/2 15:50:12

YOLO26深度估计实战:从单目深度预测到多任务部署

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华