1. 为什么我要用STM32加DDS方案做信号发生器
信号发生器这个东西,玩电子的朋友应该都不陌生。调试电路的时候需要正弦波,测试滤波器的时候需要扫频信号,给学生上课演示的时候需要方波和三角波,手头没有一台像样的信号源,很多事情就是卡在那里推不动。市面上的成品信号发生器,能输出到几兆赫兹、带调制功能的,价格基本都在大几百到几千块,对于个人爱好者和小型实验室来说,这个投入不算小。而低端的函数发生器芯片方案,比如经典的XR2206或者ICL8038,虽然便宜,但频率稳定度差、波形失真大、调节靠电位器,用起来精度完全没法看。
我这次做的这个项目,核心思路就是用STM32做控制大脑,搭配一颗DDS专用芯片来产生波形。DDS是Direct Digital Synthesis的缩写,中文叫直接数字频率合成。它的原理说起来也不复杂:把一个完整周期的正弦波采样成很多个点,存到芯片内部的查找表里,然后按照你设定的频率,以固定的时钟节拍去“读”这些点,读得快输出频率就高,读得慢输出频率就低。因为整个过程是数字控制的,所以频率精度可以做到非常高,而且切换频率的时候相位是连续的,不会像模拟方案那样出现跳变和毛刺。
STM32在这套方案里承担的角色是:接收用户输入、计算频率控制字、通过SPI或者并口把控制字写给DDS芯片、驱动显示屏显示当前参数、处理按键或者旋转编码器的输入。如果你用的是STM32F103这种经典型号,主频72MHz,跑这些任务绰绰有余。整个系统的成本可以控制在几十块钱以内,性能却能达到专业入门级信号发生器的水平。
这个项目适合谁呢?我觉得有三类人可以重点关注。第一类是电子相关专业的学生,尤其是正在做毕业设计或者课程设计的,信号发生器是一个非常好的综合练习项目,涉及单片机、SPI通信、人机交互、模拟电路等多个知识点。第二类是嵌入式开发的初学者,想找一个有一定复杂度但又不至于太难的实战项目来练手。第三类是实验室或者创客空间的技术人员,需要一台低成本、可定制、方便二次开发的信号源设备。源码我全部开源,你可以直接拿来用,也可以根据自己的需求修改波形表、增加调制功能、换用不同的显示屏。
2. 整体方案设计与核心器件选型
2.1 DDS芯片的选型对比与决策逻辑
做DDS信号发生器,绕不开的就是选哪颗DDS芯片。市面上常见的方案主要有ADI公司的AD9833、AD9834、AD9850、AD9851这几款,还有国产的一些替代型号。我当初选型的时候把这几款都仔细对比了一遍,最后选了AD9833,原因后面会详细说。
先看AD9833。这颗芯片是10引脚MSOP封装,非常小,供电范围2.3V到5.5V,最高时钟频率25MHz,输出频率最高可以到12.5MHz。它内部有一个28位的频率寄存器,什么意思呢?就是说频率控制字是28位的,配合25MHz的参考时钟,频率分辨率可以达到25MHz除以2的28次方,算下来大约是0.093Hz。这个分辨率对于绝大多数应用来说已经非常精细了。AD9833输出的是正弦波、方波和三角波三种波形,通过SPI接口控制,接线简单,编程也不复杂。
再看AD9850。这颗芯片性能更强,最高时钟频率125MHz,输出频率可以到40MHz以上,但它是28引脚SSOP封装,体积大不少,功耗也高,而且价格比AD9833贵了好几倍。AD9851跟AD9850类似,内置了6倍频器,可以用30MHz晶振产生180MHz的内部时钟,但价格更贵。
我最终选AD9833的理由很明确:第一,我的应用场景主要是音频到几百千赫兹的范围,偶尔需要到一两兆赫兹,AD9833完全覆盖;第二,SPI接口只需要三根线,跟STM32连接非常方便,不像AD9850的并口需要一堆IO;第三,价格便宜,淘宝上模块也就十几块钱,芯片本身几块钱;第四,封装小,做出来的板子可以很紧凑。如果你需要输出几十兆赫兹的高频信号,那AD9833确实不够用,得考虑AD9850或者AD9851,甚至上FPGA方案。但对于大部分调试和教学场景,AD9833的性能够了。
2.2 STM32主控的选型与资源分配
主控这边我用的是STM32F103C8T6,也就是大家常说的“蓝板”或者“最小系统板”上那颗芯片。72MHz的Cortex-M3内核,64KB Flash,20KB RAM,对于这个项目来说资源是够用的。选它的原因也很实际:资料多、价格便宜、开发工具链成熟、社区支持好。你要是用STM32F4系列当然性能更好,但价格也上去了,而且对于DDS控制这个任务来说,F1的性能已经过剩了。
资源分配上,我大致是这样规划的。SPI1用来跟AD9833通信,配置成主机模式,时钟极性跟AD9833的要求匹配。一个定时器用来做按键扫描和旋转编码器的解码,我用的TIM2,配置成1ms中断一次。另一个定时器TIM3用来做频率扫描时的步进定时。USART1留给串口调试和上位机通信,方便在电脑上直接设置频率。剩下的GPIO分配给OLED显示屏和几个功能按键。
这里有个细节值得说一下。STM32F103的SPI时钟最高可以到18MHz,但AD9833的SPI接口最高只支持40MHz,所以时钟分频设置要注意,别超了。我一般用9MHz左右,稳定可靠。另外AD9833的SPI模式是CPOL=0,CPHA=0,也就是空闲时时钟低电平,第一个边沿采样,这个在初始化的时候一定要配对,否则写进去的数据全是乱的。
2.3 模拟输出与滤波电路的设计考量
AD9833输出的信号是电流型的,需要外接一个运放做I/V转换,把电流信号变成电压信号。我用的是一颗低功耗的轨到轨运放,比如OPA2340或者TLV2372,单电源供电,电路简单。反馈电阻选10kΩ左右,输出幅度大概在0.6Vpp的样子,如果需要更大的幅度,后面再加一级同相放大器,增益设个2到3倍,就能到1Vpp以上。
输出端一定要加低通滤波器。为什么?因为DDS输出的是阶梯波,虽然经过内部DAC之后已经比较平滑了,但还是会有高频杂散。截止频率根据你需要的最高输出频率来定,一般取最高输出频率的1.5到2倍。比如你最高输出1MHz,那滤波器截止频率设在1.5MHz到2MHz之间。我用的是二阶巴特沃斯低通,用运放搭的,元件少,效果也不错。
还有一点,AD9833的参考时钟质量直接影响输出信号的相位噪声和杂散性能。如果你对信号纯度要求高,可以用一颗温补晶振或者恒温晶振给AD9833提供时钟,而不是用STM32的MCO输出。我一开始图省事用STM32的MCO输出25MHz给AD9833,实测下来杂散比较大,后来换了一颗独立的25MHz有源晶振,频谱干净了很多。这个经验后面还会细说。
3. 核心细节解析与实操要点
3.1 AD9833寄存器配置与频率控制字计算
AD9833内部有多个寄存器,控制起来其实不复杂,但有几个关键点必须搞清楚。它有两个频率寄存器FREQ0和FREQ1,两个相位寄存器PHASE0和PHASE1,还有一个控制寄存器。你可以选择用哪个频率寄存器,也可以让两个频率寄存器交替使用来实现FSK调制。
频率控制字的计算公式是这样的:FREQREG = (输出频率 × 2^28) / 参考时钟频率。举个例子,假设参考时钟是25MHz,你想输出1kHz的正弦波,那FREQREG = (1000 × 268435456) / 25000000 = 10737.41824,取整就是10737。实际输出频率就是10737 × 25MHz / 2^28 = 999.9997Hz,误差小到可以忽略。
写寄存器的时候要注意,28位的数据要分两次写,先写低14位,再写高14位。控制寄存器的配置也要小心,B28位要置1表示用28位频率寄存器,否则它只认14位。还有RESET位,上电后要先置1复位,配置完再清0。这些细节如果搞错了,芯片要么不输出,要么输出频率完全不对。
我踩过的一个坑是SPI的片选信号。AD9833的FSYNC引脚就是片选,低电平有效。每次写数据之前拉低,写完16位数据之后拉高。如果你用STM32的硬件SPI,片选可以用软件控制GPIO,也可以用硬件NSS。我建议用软件控制,因为AD9833的时序要求片选在最后一个时钟下降沿之后才能拉高,硬件NSS有时候时序对不上。这个坑我调了大半天才找到原因。
3.2 STM32的SPI通信配置与常见问题
STM32的SPI配置看起来简单,但实际用起来坑不少。我以STM32F103的标准库为例说一下关键配置。SPI1挂在APB2总线上,时钟72MHz,预分频系数设8,得到9MHz的SPI时钟。数据宽度设16位,因为AD9833一次接收16位数据。CPOL和CPHA都设0,跟AD9833的要求匹配。NSS设软件管理,也就是SPI_NSS_Soft。波特率预分频器设SPI_BaudRatePrescaler_8。传输方向设全双工,虽然我们只写不读,但全双工模式也没问题。
发送数据的时候,先拉低片选,然后调用SPI_I2S_SendData函数把16位数据写进去,接着等SPI_I2S_GetFlagStatus检查TXE标志,确认数据发送完成,最后拉高片选。这里有个细节,发送完成之后最好再等一小段时间,确保最后一个时钟沿已经结束,再拉高片选。我一般加一个几微秒的延时,用DWT或者简单的循环延时都行。
还有一个常见问题是SPI时钟太快导致数据出错。AD9833的数据手册上写最高支持40MHz的SCLK,但实际用的时候,如果你走线比较长或者没有做好阻抗匹配,9MHz以上就可能出现误码。我建议先用低速调试,确认功能正常之后再逐步提高时钟频率。另外,SPI的MOSI和SCLK走线尽量短,远离模拟输出部分,减少数字噪声耦合到模拟信号上。
3.3 人机交互:旋转编码器与OLED显示
人机交互这块我用了旋转编码器加OLED的方案。旋转编码器用来调节频率,顺时针增加,逆时针减少,按下可以切换步进值,比如1Hz、10Hz、100Hz、1kHz循环切换。OLED用的是0.96寸的SSD1306,I2C接口,显示当前频率、波形类型、幅度等信息。
旋转编码器的解码我用的是定时器中断加状态机的方式。TIM2配置成1ms中断,在中断里读取编码器的A相和B相电平,跟上一次的状态做比较,判断旋转方向和步数。这种方法比外部中断更稳定,不会因为抖动产生误判。状态机的状态转换表我放在代码里了,四个状态对应A、B两相的四种组合,每次变化根据转换方向判断是正转还是反转。
OLED显示这块,我用的是现成的SSD1306驱动库,移植过来改一下I2C的底层读写函数就能用。显示刷新率不用太高,10Hz就够了,太频繁反而影响主循环的响应速度。显示内容我做了个简单的界面布局,上面一行显示频率值,中间显示波形类型,下面显示幅度和步进值。字体用的是6x8和8x16两种,频率值用大字体,其他信息用小字体。
这里有个经验:旋转编码器的机械抖动比想象中严重,尤其是便宜的那种。我在中断里加了软件消抖,连续两次读到相同的状态才认为是有效变化。另外,编码器的步进值切换逻辑要设计好,不然用户转半天频率没变化,体验很差。我的做法是按下编码器切换步进值,同时在OLED上显示当前步进值,让用户心里有数。
4. 完整实操流程与核心环节实现
4.1 硬件连接与电路搭建
先把硬件连接理清楚。AD9833模块一般有五个引脚:VCC、GND、SCLK、SDATA、FSYNC。VCC接3.3V或者5V都可以,AD9833支持宽电压。SCLK接STM32的PA5,SDATA接PA7,FSYNC接PA4。这三个是SPI1的引脚,对应关系是SCK、MOSI和自定义的片选。AD9833的输出引脚OUT接运放的输入端,经过I/V转换和滤波之后输出到BNC或者排针。
OLED的I2C接口接STM32的PB6和PB7,分别是SCL和SDA。旋转编码器有三个引脚,A相接PB0,B相接PB1,公共端接GND,按下开关接PB10。按键这边我另外加了三个独立按键,分别接PB11、PB12、PB13,用来切换波形、调节幅度、进入菜单。电源部分,整个系统用USB 5V供电,STM32板子上有3.3V稳压器,AD9833和OLED都从3.3V取电。
如果你要自己做PCB,注意模拟部分和数字部分要分开布局,地平面要完整。AD9833的模拟输出走线尽量短,远离SPI时钟线。滤波器的元件精度用1%的电阻和5%的电容就够了,不用追求太高。晶振尽量靠近AD9833的时钟输入引脚,走线包地处理。
4.2 软件框架与主循环设计
软件这边我用的是前后台架构,没有上RTOS。主循环里轮询处理按键事件、更新显示、检查串口命令。定时器中断负责编码器解码和频率扫描的步进定时。SPI通信是阻塞式的,因为写AD9833的寄存器很快,几十个微秒就完成了,不会影响系统响应。
主循环的流程是这样的:先初始化系统时钟、GPIO、SPI、I2C、定时器、串口。然后初始化AD9833,写入默认频率和波形。接着进入while(1)循环,在循环里调用按键处理函数、编码器处理函数、显示刷新函数、串口命令解析函数。每个函数都设计成非阻塞的,执行时间很短,保证循环周期在10ms以内。
频率更新的逻辑是这样的:当编码器检测到旋转事件时,根据当前步进值调整目标频率,然后计算新的频率控制字,通过SPI写给AD9833。同时更新OLED显示。整个过程在几毫秒内完成,用户感觉是实时的。波形切换和幅度调节也是类似的流程,只是写的寄存器不同。
串口命令这块我定义了一套简单的协议,比如“FREQ 1000”设置频率为1000Hz,“WAVE SIN”设置正弦波,“WAVE SQU”设置方波。这样你可以用电脑的串口助手直接控制信号发生器,也方便后续做上位机软件。串口接收我用的是中断加环形缓冲区的方式,不会丢数据。
4.3 频率扫描与调制功能的实现
频率扫描是我后来加的一个功能,实用性很强。比如你要测试一个滤波器的幅频特性,就需要从低频到高频扫一遍,记录每个频点的输出幅度。手动一个一个频率去设置太麻烦了,用扫描功能就方便很多。
实现思路是这样的:设定起始频率、终止频率、扫描步进和每步的停留时间。用一个定时器中断来触发频率更新,每次中断把当前频率加上步进值,写到AD9833,直到达到终止频率。停留时间决定了扫描速度,一般设10ms到100ms。扫描过程中OLED实时显示当前频率,方便观察。
调制功能我实现了简单的FSK和ASK。FSK就是频移键控,用两个频率寄存器交替切换,切换速率由定时器控制。ASK是幅移键控,通过控制输出运放的增益或者用数字电位器调节幅度。这两个功能在通信实验里很有用,比如模拟串口通信或者红外遥控的信号。
代码里我把这些功能都封装成了独立的函数,通过菜单或者串口命令来调用。这样主循环不会因为某个功能阻塞,系统始终保持响应。如果你要加更多的调制方式,比如PSK或者QAM,可以在现有框架上扩展,原理都是类似的。
5. 常见问题与排查技巧实录
5.1 输出波形异常问题排查
调试过程中最常见的问题就是输出波形不对。我把遇到过的几种情况和排查方法整理了一下。
第一种情况:完全没有输出。先检查AD9833的供电是否正常,用万用表量VCC引脚,应该是3.3V或者5V。然后检查SPI通信是否正常,用示波器看SCLK、SDATA、FSYNC三个引脚,写数据的时候应该有波形。如果SPI没波形,检查STM32的SPI初始化代码,确认引脚配置和时钟使能都对了。如果SPI有波形但AD9833没输出,检查控制寄存器的RESET位是否清0了,B28位是否置1了。
第二种情况:输出频率不对。先确认参考时钟频率是不是你以为的那个值。如果你用STM32的MCO输出,实际频率可能跟设定值有偏差。用频率计或者示波器量一下AD9833的时钟输入引脚。然后检查频率控制字的计算是否正确,特别是2的28次方这个系数别搞错了。还有,写频率寄存器的时候高低14位的顺序别弄反了。
第三种情况:波形失真或者杂散大。先检查输出滤波器的截止频率是否合适,太低会把有用信号也滤掉,太高则滤不干净高频杂散。然后检查参考时钟的质量,用STM32的MCO输出通常杂散比较大,换独立晶振会好很多。还有,AD9833的电源引脚要加去耦电容,100nF和10uF并联,尽量靠近芯片放置。
第四种情况:方波输出不正常。AD9833的方波是从DAC的MSB直接输出的,幅度是固定的,不受频率控制字影响。如果你发现方波频率不对,检查是不是误用了正弦波的频率寄存器。方波的频率跟正弦波是同一个频率寄存器控制的,切换波形的时候不需要重新写频率。
5.2 SPI通信失败与数据错乱
SPI通信失败是另一个高频问题。我遇到过几次,每次原因都不太一样。
有一次是片选信号的问题。我用的是软件控制GPIO做片选,但初始化的时候忘了把GPIO配置成推挽输出,默认是浮空输入,导致片选信号一直是高阻态,AD9833根本收不到数据。后来把GPIO模式改成GPIO_Mode_Out_PP就好了。
还有一次是SPI时钟极性设错了。AD9833要求CPOL=0,CPHA=0,我一开始设成了CPOL=1,结果数据完全错乱。用逻辑分析仪抓了一下波形,发现时钟空闲时是高电平,跟AD9833的要求反了。改过来之后立马正常。
另外一次是SPI速度太快。我设了18MHz,结果数据偶尔出错,输出频率跳来跳去。降到9MHz之后就稳定了。所以如果你遇到偶发性的数据错误,先降速试试。
还有一个隐蔽的问题:STM32的SPI在发送16位数据的时候,如果用了DMA或者中断,要注意数据对齐。我一开始用8位数据模式发两次,结果高低字节顺序搞反了,AD9833收到的数据完全不对。后来改成16位数据模式,一次发完,问题解决。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 无输出 | 供电异常 | 万用表量VCC | 检查电源连接 |
| 无输出 | SPI无波形 | 示波器看SCLK | 检查SPI初始化 |
| 无输出 | RESET位未清 | 读控制寄存器 | 清RESET位 |
| 频率不对 | 参考时钟偏差 | 频率计量时钟脚 | 换晶振或校准 |
| 频率不对 | 控制字算错 | 重新计算 | 核对公式 |
| 波形失真 | 滤波器截止频率不当 | 扫频观察 | 调整滤波器 |
| 杂散大 | 参考时钟质量差 | 频谱分析 | 换独立晶振 |
| 方波异常 | 频率寄存器用错 | 检查代码 | 统一频率寄存器 |
| SPI数据错乱 | 时钟极性错误 | 逻辑分析仪 | 改CPOL/CPHA |
| SPI数据错乱 | 速度太快 | 降速测试 | 降低预分频 |
| 编码器不灵 | 抖动未消 | 示波器看波形 | 加软件消抖 |
| OLED不亮 | I2C地址错 | 扫描I2C地址 | 改地址配置 |
5.4 实操心得与避坑建议
第一个心得:AD9833的参考时钟非常关键。我一开始用STM32的MCO输出25MHz给AD9833,想着省一颗晶振。实测下来,输出信号的相位噪声明显偏大,频谱上能看到很多杂散。后来换了一颗独立的25MHz有源晶振,杂散改善了大概10dB以上。如果你对信号纯度有要求,这一步千万别省。
第二个心得:输出滤波器的设计要留余量。我第一版滤波器截止频率设在了最高输出频率的1.2倍,结果在最高频率处幅度衰减了将近3dB。后来改成2倍,幅度平坦度好多了。滤波器的阶数也不用太高,二阶巴特沃斯就够了,阶数太高会引入额外的相移和群延迟。
第三个心得:编码器的步进值切换逻辑要符合直觉。我一开始设计的步进值是1、10、100、1000循环,但用户从1000切回1的时候要按三次,体验不好。后来改成按下编码器弹出菜单,用旋转选择步进值,再按下确认,操作更直观。虽然代码复杂了一点,但用户体验提升明显。
第四个心得:串口命令的解析要健壮。我一开始用简单的字符串比较,结果用户输入“freq 1000”和“FREQ 1000”结果不一样。后来改成不区分大小写,并且支持前后空格,兼容性好多了。另外,命令的回复要清晰,比如设置成功返回“OK”,参数错误返回“ERROR”,方便上位机判断。
第五个心得:代码开源的时候要把注释写清楚。我见过很多开源项目,代码能跑但注释几乎没有,别人想改都不知道从哪下手。我在关键函数和寄存器配置的地方都加了详细注释,特别是频率控制字的计算、SPI时序的注意事项、滤波器的参数选择依据。这样别人拿到代码能快速理解,也方便二次开发。
6. 源码结构与二次开发建议
6.1 工程目录与关键文件说明
源码我按功能模块分了几个文件夹。Core文件夹里是STM32的主函数和中断处理,Drivers文件夹里是标准库和硬件驱动,Hardware文件夹里是AD9833、OLED、编码器的驱动,App文件夹里是应用层的逻辑,包括频率计算、菜单管理、串口协议。每个文件夹里都有对应的头文件和源文件,结构清晰,方便查找。
关键文件有几个。ad9833.c和ad9833.h是AD9833的驱动,里面封装了初始化、写频率、写相位、切换波形等函数。oled.c和oled.h是SSD1306的驱动,包含了I2C底层读写和显示刷新函数。encoder.c和encoder.h是旋转编码器的解码逻辑。main.c里是主循环和初始化代码。app_freq.c里是频率控制字的计算和更新逻辑。
编译环境我用的是Keil MDK 5,也提供了Makefile和PlatformIO的配置文件,方便用VSCode或者CLion开发。如果你用STM32CubeMX生成工程,把Hardware和App文件夹里的文件添加进去就行,注意SPI和I2C的初始化配置要跟我的代码匹配。
6.2 二次开发方向与扩展思路
这个项目的扩展空间很大,我列几个方向供参考。
第一个方向是增加调制功能。现在只有基础的FSK和ASK,你可以加PSK、QAM、扫频、猝发等模式。AD9833支持相位寄存器,做PSK很方便。扫频功能可以用定时器触发频率更新来实现。猝发模式需要控制输出的使能,可以用一个GPIO控制输出运放的使能引脚。
第二个方向是提高输出频率范围。AD9833最高到12.5MHz,如果你需要更高频率,可以换AD9850或者AD9851,软件框架基本不用大改,主要是SPI或者并口的驱动要重写。再往上就得考虑FPGA加高速DAC的方案了,那是另一个量级的项目。
第三个方向是增加幅度控制。现在幅度是固定的,你可以加一个数字电位器或者可编程增益放大器,用STM32的DAC或者PWM控制增益,实现幅度的数字调节。这样就能做ASK调制和幅度扫描了。
第四个方向是加上位机软件。串口协议已经定义好了,你可以用Python或者C#写一个上位机,通过串口设置频率、波形、扫描参数,还能画频谱图。Python的话用PyQt或者Tkinter做界面,pyserial做串口通信,matplotlib做绘图,很快就能搭起来。
第五个方向是换用FreeRTOS。现在的代码是前后台架构,功能多了之后主循环可能会有点吃力。你可以把按键处理、显示刷新、串口通信、频率更新分别放到不同的任务里,用信号量和队列做同步。STM32F103的RAM跑FreeRTOS是够的,注意任务栈大小别设太小。
6.3 开源协议与使用说明
源码我采用的是MIT协议,你可以自由使用、修改、分发,包括商业用途,只需要保留版权声明就行。我在代码头部加了版权信息和协议说明,你二次开发的时候可以改成自己的名字,但建议保留原作者的贡献说明,这是开源社区的基本礼仪。
使用的时候有几个注意事项。第一,AD9833的参考时钟频率在代码里是一个宏定义,你如果换了晶振,记得改这个值,否则频率全错。第二,SPI的引脚配置在spi.c里,如果你换了STM32型号或者引脚,要对应修改。第三,OLED的I2C地址默认是0x78,如果你的模块地址不一样,在oled.h里改一下。第四,串口的波特率默认是115200,你可以在usart.c里修改。
如果你在使用的过程中发现了bug或者有改进建议,欢迎提issue或者pull request。我平时也会关注这个项目,看到有价值的问题会及时回复。开源项目的生命力在于社区的参与,大家一起用、一起改,项目才能越来越好。
最后再分享一个小技巧:调试DDS输出的时候,如果没有频谱仪,可以用一个简单的LC谐振电路加示波器来粗略判断输出质量。把输出信号通过一个已知频率的LC谐振回路,观察幅度变化,就能大致判断信号的频率和纯度。这个方法虽然不精确,但在没有专业仪器的时候很实用。我自己在没有频谱仪的那段时间,就是靠这个方法调滤波器的。