☰
STM32F103驱动AD9850 DDS模块:频率字、串行加载与扫频调试
2026/11/5 2:28:07 网站建设 项目流程

简介:面向嵌入式初学者与电子竞赛选手的STM32波形发生器实践工程,基于正点原子STM32精英板搭配AD9850 DDS芯片,通过编程写入频率控制字,输出正弦、方波、三角波等常见信号,可用于信号处理实验、通信原理教学演示与自制信号源等场景。压缩包内共200个文件,约5.34MB,以34个c源文件、37个头文件及配套工程文件为主,另含Keil工程的uvprojx与uvoptx配置、hex烧录文件、map与lst编译清单、sct分散加载脚本,以及LCD、定时器、ADC、I2C等标准外设库驱动,完整呈现了从外设初始化到DDS时序驱动的构建脉络。已有3403人学习下载,适合对照源码理解GPIO模拟串行时序、定时节拍与波形参数下发流程,也可直接编译烧录验证,作为课程设计与二次开发的起点。

1. STM32F103 外挂 AD9850:一块 DDS 模块能补上什么

STM32F103 开发板接 AD9850 做信号源,最容易翻车的地方不是接线,而是把 AD9850 当成“频率可调的正弦波发生器”直接上电。它输出的是差分电流,模块板上用运放转成电压,幅度固定、带直流偏置,高频段还要靠低通滤波器压谐波。这套组合适合做 1Hz~10MHz 的扫频、时钟源、滤波器测试激励,也适合拿 STM32 练 GPIO 时序和串口命令解析。板子上有 5V 和 3.3V,AD9850 模块常见 5V 供电、3.3V 逻辑,电平匹配和地去耦没做好,频率字写得再对也出不来干净波形。

2. 硬件接口与供电:五根控制线背后的电平边界

先把“能出波”和“出干净波”分开看。AD9850 模块看起来只有 VCC、GND、W_CLK、FQ_UD、DATA、RESET 几根线,真正决定底噪和杂散的是供电回路、地线长度和数字线上的串扰。STM32F103 开发板的 GPIO 是 3.3V 推挽,AD9850 模块有的是 5V 直供,有的板上带了 3.3V 降压,接线前先用万用表量一遍模块 VCC 对 GND 的电阻和上电电压,比看丝印可靠。

2.1 AD9850 模块的引脚定义与电平匹配

常见 AD9850 模块把 D0~D7 引成两排排针,但串行加载只用 DATA、W_CLK、FQ_UD、RESET 四根控制线。模块丝印上标了 DATA 的引脚就是串行数据输入,W_CLK 是移位时钟,FQ_UD 是频率更新脉冲,RESET 是复位。OUT1、OUT2 是正弦差分输出,有的模块还引出一路比较器整形的方波输出,方波用来做频率计自检很方便,但不要拿它当低抖动时钟源。

电平边界要单独说。AD9850 数字输入的高电平阈值跟模块 VCC 有关,5V 直供时 3.3V 推挽输出可能刚好卡在门槛附近,短杜邦线还能凑合,线一长、温度一变就容易丢位。我的做法是先看模块上有没有 3.3V 降压芯片或电平转换芯片;没有的话,3.3V 到 5V 方向加一片 74HCT245,或者至少串 100Ω 电阻并把线压到 10cm 以内。电阻只能限流,不能抬电平,别把“能跑”当成“长期可靠”。

RESET 时序也常被忽略。上电后先给 RESET 一个高脉冲,再拉低,等几个微秒再开始写 40 位数据。有的模块内部有上电复位,不手动复位也能出波,但扫频到一半复位、热插拔之后,内部移位寄存器状态不确定,输出频率会飘到奇怪的值。> 提示:第一次上电先用示波器看 RESET、W_CLK、FQ_UD 三根线,确认没有毛刺再插 AD9850 模块。

2.2 接线表与去耦:把 5V 和 3.3V 分开走

STM32F103 开发板上引出的 GPIO 很多,选脚时避开 JTAG 的 PA13、PA14、PA15 和 PB3、PB4,免得调试器一接就影响输出。我一般把五根控制线集中放在 GPIOB 的 12~15,接线短,查线也方便。下面这张表是串行加载的最小接线,模块的 D0~D7 不接。

STM32F103 引脚AD9850 模块引脚说明
PB12W_CLK移位时钟,GPIO 推挽输出
PB13DATA串行数据,LSB 先行
PB14FQ_UD上升沿更新频率字
PB15RESET高脉冲复位,平时拉低
5VVCC先确认模块是否 5V 直供
GNDGND地线尽量短,和开发板共地

去耦不要只靠开发板上的大电容。AD9850 模块 VCC 与 GND 之间并 100nF 和 10uF,100nF 尽量靠近模块引脚,10uF 放在排针入口。输出用 SMA 座转 BNC,不要用长杜邦线把正弦波引到示波器,杜邦线的电感和邻近数字线的串扰会把高频段弄得很脏。电源方面,开发板 USB 供电带 AD9850 模块通常够,但扫频时串口一打印、继电器一动作,电流突变会让输出频率抖,独立 5V 供电更省心。

注意:AD9850 模块的模拟输出带直流偏置,直接接 ADC 或后级运放前先隔直,别把偏置带进下一级。

2.3 GPIO 模拟串行加载的选型理由

AD9850 不是标准 SPI 器件,40 位加载、FQ_UD 更新、RESET 时序都要自己控。STM32F103 的硬件 SPI 可以拼 8 位帧发 5 次,但中间要手动插 FQ_UD,还要处理片选和时钟相位,调试时反而不如 GPIO 直观。72MHz 主频下 GPIO 翻转足够快,W_CLK 半周期加几个 __NOP 就能满足模块时序,代码也好移植到其他 STM32 型号。

下面这段初始化代码把五根线一次配好,注意初始电平:W_CLK、DATA、FQ_UD、RESET 都先拉低,上电后单独发复位脉冲。GPIO 速度选 50MHz 是为了减少边沿拖尾,实际用 2MHz 也行,但高频扫频时边沿太慢会增加误码。

/* 五根控制线集中放 GPIOB 12~15,避开 JTAG 的 PA13/14/15 */ #define AD9850_WCLK_PORT GPIOB #define AD9850_WCLK_PIN GPIO_Pin_12 #define AD9850_DATA_PORT GPIOB #define AD9850_DATA_PIN GPIO_Pin_13 #define AD9850_FQUD_PORT GPIOB #define AD9850_FQUD_PIN GPIO_Pin_14 #define AD9850_RESET_PORT GPIOB #define AD9850_RESET_PIN GPIO_Pin_15 static void ad9850_gpio_init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); gpio.GPIO_Pin = AD9850_WCLK_PIN | AD9850_DATA_PIN | AD9850_FQUD_PIN | AD9850_RESET_PIN; gpio.GPIO_Mode = GPIO_Mode_Out_PP; /* 推挽输出,驱动模块数字输入 */ gpio.GPIO_Speed = GPIO_Speed_50MHz; /* 边沿快一点,短线下误码少 */ GPIO_Init(GPIOB, &gpio); GPIO_ResetBits(AD9850_WCLK_PORT, AD9850_WCLK_PIN); GPIO_ResetBits(AD9850_DATA_PORT, AD9850_DATA_PIN); GPIO_ResetBits(AD9850_FQUD_PORT, AD9850_FQUD_PIN); GPIO_ResetBits(AD9850_RESET_PORT, AD9850_RESET_PIN); }

这段代码只做引脚初始化,不包含复位和写频率字。参数上唯一要改的是端口和引脚号,换到别的开发板时把 GPIOB 和 Pin_12~15 换成实际引出脚,同时确认这些脚没有被板载外设占用。GPIO_Speed 不建议设成 2MHz 以下,W_CLK 上升沿太缓时,AD9850 内部移位寄存器在长线下容易多移一位,现象是频率字整体左移,输出频率变成设定值的两倍。

3. 频率字与串行加载:40 位时序里最容易写错的三个位置

AD9850 的核心是 32 位频率调谐字,输出频率由外部时钟和频率字共同决定。很多人把公式抄对了,代码里却用 32 位乘法,频率一过 1MHz 就溢出;也有人把 40 位数据顺序发反,控制字跑到频率字后面,模块照样收,但输出频率完全对不上。这一章把公式、时序和延时拆开写,照着改就能复现。

3.1 32 位频率字:公式、晶振与溢出边界

公式本身不复杂:f_out = f_MCLK × FTW / 2^32,反推 FTW = round(f_out × 2^32 / f_MCLK)。f_MCLK 是模块上的晶振频率,常见 125MHz,也有 100MHz、50MHz 的板子,必须看模块晶振丝印或用频率计量,不能默认。FTW 是 32 位无符号数,计算时先转成 64 位,乘完再除,最后判断是否超过 0xFFFFFFFF。

设定频率MCLK=125MHz 时 FTW说明
1kHz34360低频段分辨率足够
1MHz34359738常用检查点
10MHz343597384接近模块低通边界
40MHz1374389535理论可写,实际看模拟滤波
#define AD9850_MCLK_HZ 125000000ULL /* 按模块晶振改,100M 就写 100000000 */ uint32_t ad9850_ftw(uint32_t f_out_hz) { uint64_t ftw; /* 先左移 32 位再除,加半个 MCLK 做四舍五入,避免低频段累计误差 */ ftw = ((uint64_t)f_out_hz << 32) + (AD9850_MCLK_HZ / 2); ftw /= AD9850_MCLK_HZ; if (ftw > 0xFFFFFFFFULL) { ftw = 0xFFFFFFFFULL; /* 超过 32 位就钳位,别让高位被截断 */ } return (uint32_t)ftw; }

参数说明:f_out_hz 单位是 Hz,传 1000000 就是 1MHz;AD9850_MCLK_HZ 一定要按实际晶振改,写成 125000000ULL 时后面的 ULL 不能省,否则编译器的 32 位乘法会在中途溢出。低频段 1kHz 时 FTW 只有 34360,四舍五入的半个 MCLK 能减少一点截断误差,但真正要准还是得靠后文的两点校准。

提示:f_out_hz 不要超过 f_MCLK/2,超过之后输出混叠,示波器上看到的是折叠频率,不是设定频率。

3.2 40 位串行加载:先控制字后频率字,LSB 先行

AD9850 串行模式一次收 40 位:前 8 位是控制字,后 32 位是频率字,低位先发。W_CLK 每个上升沿移入一位,40 位发完后给 FQ_UD 一个上升沿,模块才把新频率锁进内部寄存器。控制字平时写 0x00,需要掉电或相位控制时再改;第一次写之前先发 RESET 脉冲,把移位寄存器清干净。

下面这段写 40 位的函数把控制字和频率字分开循环,循环里先摆 DATA,再给 W_CLK 一个高脉冲。FQ_UD 放在最后,且只给一个上升沿,不要在每个字节后都触发。

static void ad9850_write40(uint8_t ctrl, uint32_t ftw) { uint8_t i; /* 前 8 位控制字,LSB 先行 */ for (i = 0; i < 8; i++) { if (ctrl & (1U << i)) { GPIO_SetBits(AD9850_DATA_PORT, AD9850_DATA_PIN); } else { GPIO_ResetBits(AD9850_DATA_PORT, AD9850_DATA_PIN); } GPIO_SetBits(AD9850_WCLK_PORT, AD9850_WCLK_PIN); __NOP(); __NOP(); GPIO_ResetBits(AD9850_WCLK_PORT, AD9850_WCLK_PIN); } /* 后 32 位频率字,同样 LSB 先行 */ for (i = 0; i < 32; i++) { if (ftw & (1UL << i)) { GPIO_SetBits(AD9850_DATA_PORT, AD9850_DATA_PIN); } else { GPIO_ResetBits(AD9850_DATA_PORT, AD9850_DATA_PIN); } GPIO_SetBits(AD9850_WCLK_PORT, AD9850_WCLK_PIN); __NOP(); __NOP(); GPIO_ResetBits(AD9850_WCLK_PORT, AD9850_WCLK_PIN); } /* 40 位发完再给 FQ_UD 上升沿,模块此时更新输出 */ GPIO_SetBits(AD9850_FQUD_PORT, AD9850_FQUD_PIN); __NOP(); __NOP(); GPIO_ResetBits(AD9850_FQUD_PORT, AD9850_FQUD_PIN); }

逻辑说明:控制字循环 8 次,频率字循环 32 次,顺序不能反;每次 W_CLK 高电平至少保持两个 __NOP,72MHz 下约几十纳秒,短线下够用,线长超过 20cm 时把 __NOP 加到 10 个更保险。参数说明:ctrl 常用 0x00,ftw 由 ad9850_ftw 算出来;FQ_UD 脉冲之后建议延时 100us 再读输出,模块内部运放需要建立时间。

3.3 微秒延时与 FQ_UD 脉冲宽度

AD9850 对 W_CLK 和 FQ_UD 的脉宽要求不苛刻,但复位后到第一次写、FQ_UD 后到输出建立,都需要微秒级等待。用 SysTick 做 delay_us 最省事,但不要在中断里调用,扫频中断里写 40 位再加延时,串口很容易丢包。我一般给一个粗延时函数,按调试器实测校准,不追求绝对精度,够用就行。

static void ad9850_delay_us(uint32_t us) { /* 72MHz 下的粗延时,循环次数按实际编译优化等级微调 */ while (us--) { for (volatile uint32_t i = 0; i < 72; i++) { __NOP(); } } } void ad9850_reset(void) { GPIO_SetBits(AD9850_RESET_PORT, AD9850_RESET_PIN); ad9850_delay_us(10); GPIO_ResetBits(AD9850_RESET_PORT, AD9850_RESET_PIN); ad9850_delay_us(10); ad9850_write40(0x00, 0); /* 复位后先写 0,确认移位寄存器干净 */ }

参数说明:ad9850_delay_us(10) 是 10 微秒量级,不是精确 10us;如果编译器开了 -O2,volatile 循环可能被优化得偏快,用示波器打一个 GPIO 翻转对照一下。ad9850_reset 里的 write40(0x00, 0) 不是必须,但能避免上电后移位寄存器里残留旧数据,尤其热插拔模块之后。

4. 串口命令到扫频输出:一套能复现的波形控制流程

硬件和底层写完后,下一步是把频率设定、扫频、停止做成串口命令。调试时用 ASCII 命令最省事,串口助手直接敲;产品化再换二进制帧。命令解析不要放在中断里做,中断只收字节、置标志,主循环解析并调用 ad9850_write40,这样扫频和串口打印不会互相抢时间。

4.1 命令帧格式:ASCII 还是二进制

ASCII 帧适合手工调试,格式用“字母 + 参数 + 回车换行”。比如 F1000000 表示设 1MHz,S1000,100000,100 表示从 1kHz 扫到 100kHz、步进 100Hz,T 表示停止。解析时用 sscanf 或自己写状态机,注意串口缓冲区要留足,别让一行命令跨两次中断。

typedef enum { CMD_NONE = 0, CMD_FREQ, CMD_SWEEP, CMD_STOP } cmd_type_t; typedef struct { cmd_type_t type; uint32_t f_start; uint32_t f_stop; uint32_t f_step; } cmd_t; /* 在主循环里解析一行,line 以 '\0' 结尾,不含回车换行 */ cmd_t parse_cmd(char *line) { cmd_t cmd = { CMD_NONE, 0, 0, 0 }; if (line[0] == 'F' || line[0] == 'f') { cmd.type = CMD_FREQ; cmd.f_start = (uint32_t)strtoul(&line[1], 0, 10); } else if (line[0] == 'S' || line[0] == 's') { cmd.type = CMD_SWEEP; sscanf(&line[1], "%lu,%lu,%lu", &cmd.f_start, &cmd.f_stop, &cmd.f_step); } else if (line[0] == 'T' || line[0] == 't') { cmd.type = CMD_STOP; } return cmd; }

逻辑说明:parse_cmd 只做字符串到结构体的转换,不直接操作 AD9850;返回 CMD_NONE 表示命令不认识,主循环可以回一行错误提示。参数说明:f_start、f_stop、f_step 单位都是 Hz,步进不要小于 1Hz,否则扫频循环次数太多,串口看起来像卡死。strtoul 和 sscanf 会占一点栈,栈小的工程把 line 改成全局缓冲。

4.2 扫频状态机:定时器里只做标记,主循环算频率

扫频最怕在定时器中断里写 40 位 GPIO,40 次循环加延时,中断时间一长,串口接收就丢字节。正确做法是定时器 1ms 中断只置 sweep_tick 标志,主循环看到标志后判断驻留时间到没到,到了就写下一个频点。这样扫频速度受主循环影响,但不会破坏串口和别的任务。

volatile uint8_t sweep_tick = 0; volatile uint8_t sweep_on = 0; static uint32_t sweep_cur, sweep_stop, sweep_step; static uint16_t sweep_dwell_ms, sweep_cnt; void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); sweep_tick = 1; /* 中断里只置标志,不写 AD9850 */ } } void sweep_task(void) { if (!sweep_tick) return; sweep_tick = 0; if (!sweep_on) return; if (++sweep_cnt >= sweep_dwell_ms) { sweep_cnt = 0; ad9850_write40(0x00, ad9850_ftw(sweep_cur)); if (sweep_cur >= sweep_stop) { sweep_cur = sweep_stop; /* 到顶停止,避免无符号回绕 */ sweep_on = 0; } else { sweep_cur += sweep_step; } } }

逻辑说明:sweep_task 放在主循环里轮询,1ms 进一次,sweep_dwell_ms 是每个频点驻留的毫秒数;sweep_cur 到 sweep_stop 后停住,不要让它继续加,无符号数回绕会从 4GHz 重新开始。参数说明:sweep_step 太大时高频段跳变明显,太小则扫完整个频段要很久;做滤波器测试一般 100Hz~1kHz 步进,驻留 10ms~50ms 就能在示波器上看到包络。

4.3 与示波器对齐的验证:从 1kHz 到 1MHz 的四个检查点

写完命令和扫频,不要直接上频谱仪,先用示波器和频率计做四个检查点。第一步设 1kHz,看正弦有没有削顶、直流偏置多少;第二步设 10kHz,看幅度和波形有没有明显失真;第三步设 100kHz,看频率计读数和设定值差多少;第四步设 1MHz,看幅度掉没掉、有没有高频杂散。四个点都过,再跑扫频。

检查时把示波器时基打到能看清 3~5 个周期,触发源用输出本身,不要用 STM32 的 GPIO 触发,否则看到的是写时序不是波形。频率计接方波输出或正弦输出都可以,但方波输出经过比较器,低频段可能有抖动,读数以频率计的平均值为准。> 注意:AD9850 模块输出幅度随频率升高会下降,这是模块低通和运放带宽决定的,不是代码写错,别急着改频率字。

5. 联调排查:AD9850 不出波与频率偏差的五类现场

调试 AD9850 最花时间的不是写代码,而是判断问题出在电源、时序还是模块本身。下面五类现场是我在 STM32F103 开发板上重复遇到过的,按“现象 → 原因 → 解决”记下来,下次不用从零猜。

5.1 上电无输出,电流正常但示波器只有直流

现象:模块电源电流在几十毫安,OUT1 对地只有 2V 左右的直流,示波器看不到正弦。原因通常是 RESET 时序没做,或者 FQ_UD 没有上升沿,模块内部频率字一直是 0;也有模块并行/串行跳线焊错,DATA 根本没进移位寄存器。解决:先给 RESET 高脉冲,再写 40 位,最后 FQ_UD 拉高再拉低;用逻辑分析仪抓 W_CLK,确认正好 40 个脉冲,多一个少一个都不行。如果 W_CLK 有 40 个脉冲但 OUT 还是直流,换一个已知能出波的频率字,比如 1MHz 对应的 FTW,排除模块损坏。

5.2 输出频率是设定值的一半或两倍

现象:设 1MHz,频率计读 500kHz 或 2MHz,波形看起来正常。原因最常见的是 AD9850_MCLK_HZ 写错,把 125MHz 写成 250MHz 或 62.5MHz;其次是频率字循环里位移方向写反,LSB 和 MSB 颠倒;还有示波器时基没校准,读出来差一倍。解决:先用频率计量模块晶振,确认 MCLK 实际值;再把 ad9850_ftw 的结果用串口打印出来,和表格里的理论值对照;最后检查 for 循环里是 1UL << i 还是 1UL << (31 - i)。AD9850 串行模式是低位先发,写成高位先发会得到完全不同的频率。

5.3 低频段正常,高频段幅度断崖

现象:1MHz 时幅度有 1Vpp,10MHz 只剩 200mVpp,20MHz 几乎看不见。原因不是 MCU 写错,而是模块上的低通滤波器截止频率、输出运放压摆率和杜邦线电容共同造成的。AD9850 芯片本身能到更高频率,但模块模拟前端有带宽限制,长杜邦线又把高频衰减得更厉害。解决:看模块低通滤波器的电感和电容值,估算截止频率;把输出线换成短线或 SMA 转 BNC;测试时把设定频率限制在模块标称范围内,不要拿 40MHz 的设定值去要求 1Vpp 输出。

5.4 串口一发达就死机或复位

现象:串口助手发一条 F 命令,MCU 直接复位,或者程序跑飞。原因可能是串口中断里直接调 ad9850_write40,40 次 GPIO 循环加延时把中断拖得太长,看门狗复位;也可能是命令缓冲区越界,sscanf 写穿了栈;还有可能是 AD9850 模块和开发板共用 USB 供电,写频率瞬间电流突变导致电压跌落。解决:中断只收字节,主循环解析和执行;命令缓冲区留足 64 字节并做长度检查;电源入口加 100uF 电解电容,AD9850 单独供电时把地共好。复位问题先关看门狗试,能复现再查栈。

5.5 频率计读数跳动大,谐波丰富

现象:设定 1MHz,频率计在 999.95kHz 到 1.00005MHz 之间跳,频谱上 3 次谐波很明显。原因是地线环路、W_CLK 串扰、电源纹波,或者 FQ_UD 触发太频繁导致频率字更新和测量不同步。解决:W_CLK 线上串 33Ω 电阻,靠近 STM32 端;地线用短而粗的线,模块和开发板单点共地;FQ_UD 后加 100us 延时再测;频谱仪看谐波时先把输出幅度调低,避免后级饱和产生假谐波。频率计读数跳动在几十 Hz 以内属于模块和测量误差,做校准前先排除地线问题。

6. 校准与交付:用频率计把输出误差压到 100Hz 以内

AD9850 的频率字是离散的,晶振本身也有误差,想要输出和设定值对得上,得做两点校准。我一般选 1MHz 和 10MHz 两个点,用频率计读实际值,算出修正系数,扫频时把设定频率乘系数再算 FTW。校准表不用很复杂,两个点足够把常见模块的误差压到 100Hz 以内。

设定频率实测频率修正系数
1.000000MHz999.982kHz1.000018
10.000000MHz9.999870MHz1.000013
/* 校准系数用定点数存,避免浮点运算占空间;1.000018 放大 1e6 存 1000018 */ static uint32_t cal_num = 1000018; static uint32_t cal_den = 1000000; uint32_t ad9850_ftw_cal(uint32_t f_out_hz) { uint64_t f_cal = (uint64_t)f_out_hz * cal_num / cal_den; return ad9850_ftw((uint32_t)f_cal); }

参数说明:cal_num 和 cal_den 来自实测,两个点算出来的系数如果差太多,说明模块晶振温漂大或者频率计不准,先换频率计复核。校准系数写 Flash 时先擦后写,写完读回校验,掉电重启后先读系数再初始化 AD9850。血泪经验是校准完顺手把系数和日期打印到串口,下次换模块不用翻记录。这套 STM32F103 加 AD9850 的工程、接线表和命令说明我整理在文末下载包里,拿过去改晶振宏就能跑。从那以后我每次焊完 AD9850 模块都先量晶振、再抓 40 个 W_CLK 脉冲、最后才写频率字,少走很多弯路。希望帮到你。

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

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

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

立即咨询