简介:这份资源面向参加电子设计竞赛或学习信号源开发的学生与工程师,提供基于STM32F407与HAL库驱动AD9854 DDS模块输出正弦波并实现扫频的完整工程。相比单片机DAC或FPGA ROM方案,AD9854可输出频率更高、幅度更平坦的信号,适用于扫频仪、阻抗分析仪等场景。压缩包共1123个文件,约43.66MB,以592个C源文件、283个头文件、78个汇编文件及46个ICF链接配置为主,另含工程文件、数学库与说明文档,构成可直接编译的嵌入式工程。目前已有2638人学习下载。读者可从中获取AD9854寄存器配置、扫频逻辑、HAL库外设初始化与DMA传输等关键实现,并借助完整目录结构快速定位驱动与算法模块,适合作为电赛练习与DDS开发的参考模板。
1. 从一块吃灰的 AD9854 模块说起:为什么我拿它练手 STM32F407
手头有一块 AD9854 DDS 模块,买回来在抽屉里躺了大半年,一直没正经驱动过。这次借着电赛小练习的机会,用 STM32F407 加 HAL 库把它跑起来,顺带把扫频功能也做了。AD9854 是一颗双通道正交 DDS 芯片,最高系统时钟 300MHz,能输出两路正弦波,频率、相位、幅度都可编程,做信号源、扫频仪、正交调制前端都合适。它和 STM32 之间走并行或串行总线,我手上这块是并行接口版本,数据线加地址线一共十几根,接线不算少,但时序清晰,适合拿来练 GPIO 和总线读写。
这篇笔记面向的是手里有 AD9854 模块、想用 F407 把它驱动起来的人。新手可以照着步骤一步步接线和写代码,熟手可以直接看寄存器配置和扫频实现那几章。我会把并行总线时序、寄存器写入、频率字计算、扫频参数设置这些容易翻车的地方都拆开讲,代码基于 HAL 库,用 CubeMX 生成工程骨架,尽量做到能直接抄。
2. 硬件连线与 CubeMX 配置:把 20 根线接明白
2.1 AD9854 并行接口的引脚定义
AD9854 的并行接口分数据口和地址口。数据口 D0~D7 是双向的,地址口 A0~A5 用来选内部寄存器。控制线有 WR、RD、RESET、CS、IO_UD_CLK 等。我手上这块模块已经把电平转换和必要的外围电路做好了,对外引出的是 3.3V 兼容的排针,所以可以直接和 F407 的 GPIO 对接,不需要额外加电平转换芯片。
先把引脚分配列清楚,后面写代码才不会乱:
| AD9854 引脚 | 方向 | F407 引脚 | 功能说明 |
|---|---|---|---|
| D0~D7 | 双向 | PD0~PD7 | 8 位数据总线 |
| A0~A5 | 输入 | PE0~PE5 | 6 位地址总线 |
| WR | 输入 | PE6 | 写选通,低有效 |
| RD | 输入 | PE7 | 读选通,低有效 |
| CS | 输入 | PE8 | 片选,低有效 |
| RESET | 输入 | PE9 | 复位,高有效 |
| IO_UD_CLK | 输入 | PE10 | 更新时钟,上升沿把缓冲寄存器内容送到 DDS 核 |
| FSK/BPSK/HOLD | 输入 | 暂不接 | 本练习不用调制功能 |
数据总线我放在 PD 口,因为 PD0~PD7 正好连续,用GPIO_PIN_0到GPIO_PIN_7一次配置完,写数据的时候直接操作 ODR 低八位就行,比零散引脚快得多。地址和控制线放 PE 口,也是连续分配,方便用位带或者直接寄存器操作。
2.2 CubeMX 里的 GPIO 配置要点
在 CubeMX 里把 PD0~PD7 配成GPIO_Output,输出模式选Push Pull,速度选Very High,初始电平低。PE0~PE10 同样配成推挽输出。这里有个细节:AD9854 的 WR 和 RD 是低有效,空闲时必须拉高,所以初始电平要设成High。CS 也是低有效,空闲拉高。RESET 空闲拉低。
时钟配置按 F407 的常规来,HSE 8MHz 晶振,PLL 倍频到 168MHz,APB2 给 84MHz。GPIO 挂在 AHB1 上,时钟频率 168MHz,所以一个 GPIO 翻转大概 6ns 左右,写一个字节的建立保持时间完全够。
// gpio.c 中关键初始化片段 static void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOD_CLK_ENABLE(); __HAL_RCC_GPIOE_CLK_ENABLE(); // PD0~PD7 数据总线,初始低电平 HAL_GPIO_WritePin(GPIOD, GPIO_PIN_0|GPIO_PIN_1|GPIO_PIN_2|GPIO_PIN_3| GPIO_PIN_4|GPIO_PIN_5|GPIO_PIN_6|GPIO_PIN_7, GPIO_PIN_RESET); GPIO_InitStruct.Pin = GPIO_PIN_0|GPIO_PIN_1|GPIO_PIN_2|GPIO_PIN_3| GPIO_PIN_4|GPIO_PIN_5|GPIO_PIN_6|GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH; HAL_GPIO_Init(GPIOD, &GPIO_InitStruct); // PE0~PE5 地址线,PE6 WR,PE7 RD,PE8 CS,PE9 RESET,PE10 IO_UD_CLK // WR/RD/CS 空闲高,RESET 空闲低 HAL_GPIO_WritePin(GPIOE, GPIO_PIN_6|GPIO_PIN_7|GPIO_PIN_8, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOE, GPIO_PIN_9, GPIO_PIN_RESET); GPIO_InitStruct.Pin = GPIO_PIN_0|GPIO_PIN_1|GPIO_PIN_2|GPIO_PIN_3| GPIO_PIN_4|GPIO_PIN_5|GPIO_PIN_6|GPIO_PIN_7| GPIO_PIN_8|GPIO_PIN_9|GPIO_PIN_10; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH; HAL_GPIO_Init(GPIOE, &GPIO_InitStruct); }这段代码把数据口和地址控制口都配好了。注意HAL_GPIO_WritePin在初始化之前先把 WR、RD、CS 拉高,避免上电瞬间误触发写操作。RESET 保持低,等后面软件里再发复位脉冲。
2.3 写时序的底层封装
AD9854 并行写时序不复杂:先把地址放到 A0~A5,再把数据放到 D0~D7,然后拉低 CS,拉低 WR,保持至少 10ns,再拉高 WR,拉高 CS。连续写同一个地址时 CS 可以一直保持低,只翻转 WR。我一般封装两个函数,一个写地址,一个写数据,再组合成AD9854_WriteByte。
// 把 6 位地址送到 PE0~PE5 static void AD9854_SetAddr(uint8_t addr) { GPIOE->ODR = (GPIOE->ODR & 0xFFC0) | (addr & 0x3F); } // 把 8 位数据送到 PD0~PD7 static void AD9854_SetData(uint8_t data) { GPIOD->ODR = (GPIOD->ODR & 0xFF00) | data; } // 写一个字节到指定寄存器地址 void AD9854_WriteByte(uint8_t addr, uint8_t data) { AD9854_SetAddr(addr); AD9854_SetData(data); HAL_GPIO_WritePin(GPIOE, GPIO_PIN_8, GPIO_PIN_RESET); // CS 低 HAL_GPIO_WritePin(GPIOE, GPIO_PIN_6, GPIO_PIN_RESET); // WR 低 __NOP(); __NOP(); __NOP(); __NOP(); // 保持 >10ns HAL_GPIO_WritePin(GPIOE, GPIO_PIN_6, GPIO_PIN_SET); // WR 高 HAL_GPIO_WritePin(GPIOE, GPIO_PIN_8, GPIO_PIN_SET); // CS 高 }这里直接操作 ODR 而不是用HAL_GPIO_WritePin逐位写,是因为数据总线要 8 位同时变化,逐位写会产生中间态,AD9854 可能采到错误数据。__NOP()四个大概 24ns,加上函数调用开销,WR 低电平时间足够满足手册要求。CS 每次写完拉高,虽然连续写时可以不拉高,但拉高更保险,避免地址切换时数据总线上的毛刺被误写入。
3. AD9854 寄存器配置:频率字、相位字和更新时钟
3.1 寄存器地址映射与初始化流程
AD9854 内部有 40 多个寄存器,常用的就那么几个。并行接口下地址是 6 位,我列一下本练习用到的:
| 地址 | 寄存器名 | 位宽 | 作用 |
|---|---|---|---|
| 0x00 | CFR1 | 32 位 | 控制寄存器 1,设置 PLL、旁路、输出模式 |
| 0x01 | CFR2 | 16 位 | 控制寄存器 2,设置更新时钟和调制模式 |
| 0x04 | FREQ1 | 48 位 | 通道 1 频率调谐字 |
| 0x05 | FREQ2 | 48 位 | 通道 2 频率调谐字 |
| 0x06 | PHASE1 | 14 位 | 通道 1 相位调谐字 |
| 0x07 | PHASE2 | 14 位 | 通道 2 相位调谐字 |
| 0x08 | OSC_DAC | 8 位 | 输出幅度控制 |
初始化顺序很重要:先复位,再写 CFR1 配置 PLL 和参考时钟,等 PLL 锁定,然后写 CFR2 设置更新时钟模式,最后写频率和相位。顺序错了会出现输出频率不对或者根本没波形的情况。
void AD9854_Init(void) { // 复位脉冲,至少 10 个系统时钟周期 HAL_GPIO_WritePin(GPIOE, GPIO_PIN_9, GPIO_PIN_SET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOE, GPIO_PIN_9, GPIO_PIN_RESET); HAL_Delay(1); // CFR1: 设置 PLL 倍频 4x,参考时钟 25MHz -> 系统时钟 100MHz // 旁路 PLL 的话系统时钟就是参考时钟,这里用 4x AD9854_WriteByte(0x00, 0x00); // 低字节 AD9854_WriteByte(0x00, 0x00); AD9854_WriteByte(0x00, 0x00); AD9854_WriteByte(0x00, 0x10); // 高字节,PLL 4x // 等待 PLL 锁定,手册建议至少 1ms HAL_Delay(5); // CFR2: 更新时钟由 IO_UD_CLK 引脚控制,不用内部自动更新 AD9854_WriteByte(0x01, 0x00); AD9854_WriteByte(0x01, 0x00); // 输出幅度拉满 AD9854_WriteByte(0x08, 0xFF); // 发一次更新脉冲,把缓冲寄存器内容送到 DDS 核 AD9854_Update(); }CFR1 的高字节写0x10表示 PLL 倍频系数 4,参考时钟 25MHz 的话系统时钟就是 100MHz。如果模块上晶振是别的频率,这个值要跟着改。PLL 锁定时间手册写的是典型 1ms,我给了 5ms 余量。CFR2 写 0 表示更新时钟由外部引脚控制,这样扫频的时候可以精确控制每次频率切换的时机。
3.2 频率调谐字计算与写入
AD9854 的频率调谐字是 48 位,计算公式是:
FTW = (f_out * 2^48) / f_sysclkf_sysclk 是系统时钟,上面配成 100MHz。f_out 是想输出的频率。比如要输出 10MHz:
FTW = (10e6 * 2^48) / 100e6 = 0.1 * 2^48 = 28147497671这个数超过 32 位,要用 64 位变量算,然后取低 48 位分 6 个字节写入 FREQ1 寄存器。注意 AD9854 的寄存器是低字节先写,所以顺序是 FTW[7:0]、FTW[15:8]……一直到 FTW[47:40]。
void AD9854_SetFrequency(uint8_t channel, double freq) { uint64_t ftw; uint8_t addr = (channel == 1) ? 0x04 : 0x05; // 系统时钟 100MHz,2^48 = 281474976710656 ftw = (uint64_t)((freq * 281474976710656.0) / 100000000.0); // 低字节先写,共 6 个字节 for (int i = 0; i < 6; i++) { AD9854_WriteByte(addr, (uint8_t)(ftw & 0xFF)); ftw >>= 8; } }这里用double算是因为 48 位整数乘法在 STM32 上虽然能做,但浮点更直观,F407 有 FPU,算一次也就几百个周期,扫频时每点算一次完全来得及。如果对速度要求高,可以预先把频率表算好存数组里。
3.3 更新时钟与输出使能
写完频率字之后,数据还在缓冲寄存器里,必须给 IO_UD_CLK 一个上升沿,内容才会送到 DDS 核。这个设计是为了多通道同步更新,扫频时尤其有用——可以先写好频率,等定时器到点再发更新脉冲,保证频率切换时刻精确。
void AD9854_Update(void) { HAL_GPIO_WritePin(GPIOE, GPIO_PIN_10, GPIO_PIN_RESET); __NOP(); __NOP(); HAL_GPIO_WritePin(GPIOE, GPIO_PIN_10, GPIO_PIN_SET); __NOP(); __NOP(); HAL_GPIO_WritePin(GPIOE, GPIO_PIN_10, GPIO_PIN_RESET); }更新脉冲宽度要求至少 5ns,两个__NOP()加上函数开销够了。发完脉冲后 DDS 核会在几个系统时钟内切换到新频率,输出波形会有短暂的相位不连续,这是 DDS 的固有特性,做扫频时如果在意相位连续性,要用相位调谐字补偿,本练习不展开。
4. 扫频实现:定时器触发与频率步进
4.1 扫频参数设计
扫频就是让输出频率按一定步进从 f_start 走到 f_stop,每个频点停留固定时间。参数有四个:起始频率、终止频率、步进、每点停留时间。比如做音频扫频,20Hz 到 20kHz,步进 10Hz,每点 1ms,一共 2000 个点,2 秒扫完。做射频扫频,1MHz 到 10MHz,步进 10kHz,每点 100us,900 个点,90ms 扫完。
停留时间用定时器中断控制最准。我一般用 TIM6 或 TIM7 这种基本定时器,不占用高级定时器资源。定时器溢出频率就是扫频速率,中断里更新频率并发送更新脉冲。
4.2 定时器配置与中断服务
CubeMX 里开 TIM6,预分频和自动重载值按需要的扫频速率算。比如要 10kHz 中断频率,APB1 定时器时钟 84MHz,预分频 84-1,自动重载 100-1,得到 84MHz/84/100 = 10kHz。
// tim.c 中 TIM6 初始化 void MX_TIM6_Init(void) { TIM_MasterConfigTypeDef sMasterConfig = {0}; htim6.Instance = TIM6; htim6.Init.Prescaler = 84 - 1; htim6.Init.CounterMode = TIM_COUNTERMODE_UP; htim6.Init.Period = 100 - 1; htim6.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(&htim6); sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim6, &sMasterConfig); }中断服务函数里做频率步进。用一个全局变量记录当前频点索引,每次中断加一,算出频率,写寄存器,发更新脉冲。扫到终点后可以选择停住、回到起点重新扫、或者反向扫。
volatile uint32_t sweep_index = 0; volatile uint8_t sweep_running = 0; double sweep_start = 1000000.0; // 1MHz double sweep_stop = 10000000.0; // 10MHz double sweep_step = 10000.0; // 10kHz uint32_t sweep_points; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6 && sweep_running) { double freq = sweep_start + sweep_index * sweep_step; if (freq > sweep_stop) { sweep_index = 0; freq = sweep_start; } AD9854_SetFrequency(1, freq); AD9854_Update(); sweep_index++; } }sweep_points在启动扫频前算好:(sweep_stop - sweep_start) / sweep_step + 1。中断里判断freq > sweep_stop就回绕,实现连续扫频。注意AD9854_SetFrequency里有浮点运算和 6 次字节写,在 10kHz 中断频率下大概占用几微秒,F407 跑 168MHz 完全扛得住。如果扫频速率更高,比如 100kHz,就要把频率表预计算好,中断里只查表写寄存器。
4.3 扫频启动与停止的控制逻辑
扫频不能一上电就开,得有个启动条件。我一般用串口命令或者按键控制。串口收到 's' 启动,'p' 停止。启动时先复位索引,配置好起始频率,再开定时器中断。停止时关中断,把输出频率设回一个默认值。
void Sweep_Start(void) { sweep_index = 0; sweep_points = (uint32_t)((sweep_stop - sweep_start) / sweep_step) + 1; AD9854_SetFrequency(1, sweep_start); AD9854_Update(); sweep_running = 1; HAL_TIM_Base_Start_IT(&htim6); } void Sweep_Stop(void) { sweep_running = 0; HAL_TIM_Base_Stop_IT(&htim6); AD9854_SetFrequency(1, 1000000.0); // 回到 1MHz AD9854_Update(); }启动前先把频率设到起始点,再开中断,这样第一个中断来的时候频率从起始点开始步进,不会跳。停止后把频率设回一个安全值,避免输出停在某个高频点。如果做的是射频功放测试,停在高频点可能让功放一直满功率输出,发热厉害。
5. 避坑与排查:那些让我调了半天的坑
5.1 上电没波形,示波器一片平
现象:接好线、烧完程序,示波器接输出端,什么都没有。
原因:最常见的是 RESET 引脚时序不对。AD9854 要求复位高电平至少 10 个系统时钟周期,我一开始只给了 1us,理论上够,但模块上复位电路有 RC 延迟,实际到达芯片引脚的脉冲被拉平了。另一个可能是 PLL 没锁定,CFR1 里 PLL 倍频设了但参考时钟没起振。
解决:把复位脉冲拉长到 10ms,用HAL_Delay(10)。PLL 锁定等待也给足 10ms。然后用示波器量 REFCLK 引脚,看有没有 25MHz 正弦。如果没有,检查模块上的晶振和负载电容。我那块模块晶振虚焊过一次,补焊后正常。
5.2 输出频率是设定值的两倍或一半
现象:设 5MHz,示波器量出来 10MHz 或者 2.5MHz。
原因:系统时钟算错了。CFR1 里 PLL 倍频系数和参考时钟频率没对上。模块上丝印写 25MHz,但实际焊的是 50MHz 晶振的情况也有。另外 CFR1 的高字节写入顺序搞反了,PLL 配置没生效,系统时钟等于参考时钟。
解决:先确认模块晶振频率,用频率计量 REFCLK 引脚。然后核对 CFR1 的 PLL 配置位。AD9854 的 CFR1 是 32 位,PLL 倍频在最高字节的低 5 位。写的时候低字节先写,最后写高字节。我习惯把四个字节的值打印出来核对。
5.3 扫频时频率跳变不连续,有咔哒声
现象:扫频输出接喇叭,能听到规律的咔哒声,示波器看频率切换点有相位跳变。
原因:DDS 换频率时相位累加器不清零,导致相位不连续。这是 DDS 原理决定的,不是 bug。如果对相位连续性要求高,需要在换频率的同时写相位调谐字做补偿。
解决:做一般扫频测试,相位跳变不影响幅度测量,可以忽略。如果做正交调制或者需要连续相位,要么用线性调频模式(AD9854 支持),要么在软件里算相位补偿值。本练习没做补偿,因为电赛小练习对相位连续性没要求。
5.4 写寄存器偶尔失败,输出频率乱跳
现象:大部分时候正常,偶尔频率跳到奇怪的值,复位后又好。
原因:写时序的建立保持时间不够,或者数据总线在地址切换时产生毛刺被误写入。F407 的 GPIO 翻转太快,如果 WR 拉低之前数据还没稳定,AD9854 会采到旧数据。
解决:在AD9854_SetData之后加几个__NOP()再拉低 WR。另外 CS 每次写完拉高,不要图省事一直拉低。我还在数据总线上加了 33 欧姆串联电阻,减缓边沿,毛刺明显减少。如果还不行,把 GPIO 速度从 Very High 降到 High,边沿慢一点反而稳。
5.5 定时器中断里浮点运算导致扫频卡顿
现象:扫频速率设 10kHz,实际只有 5kHz 左右,示波器看频率步进不均匀。
原因:中断里做了double乘法和除法,F407 虽然有 FPU,但AD9854_SetFrequency里每次都要算 48 位频率字,加上 6 次字节写和更新脉冲,总时间超过了中断周期。
解决:预计算频率表。启动扫频前把每个频点的 6 个字节算好存到二维数组里,中断里只查表写寄存器。内存够的话用uint8_t freq_table[POINTS][6],POINTS 是频点数。这样中断里只有 GPIO 操作,时间缩短到几百纳秒。
6. 进阶技巧:用 DMA 和查表把扫频速率推到极限
上面预计算频率表已经能解决大部分卡顿,但如果想把扫频速率推到 100kHz 以上,中断里查表加写 GPIO 还是不够快。这时候可以用 DMA 直接刷 GPIO 的 BSRR 寄存器。思路是把每个频点对应的 GPIO 操作序列编成一个数组,DMA 循环发送到 GPIOD 和 GPIOE 的 BSRR,硬件自动完成引脚翻转,CPU 完全不参与。
具体做法:先算好每个频点的 6 个数据字节和地址字节,再展开成 BSRR 操作。GPIOD 的 BSRR 低 16 位是置位,高 16 位是复位。写数据的时候,先把 PD0~PD7 全部复位,再置位需要为 1 的位。每个字节对应两次 BSRR 写。地址线同理。WR 和 CS 的翻转也编进去。整个序列大概每个频点 30~40 次 BSRR 写,DMA 以 AHB 时钟跑,100kHz 扫频速率下每秒 400 万次写,DMA 完全扛得住。
// 伪代码示意:把频点展开成 BSRR 序列 // 每个频点生成一个 uint32_t 数组,DMA 循环发送 // 这里只展示思路,实际数组很大,放外部 SRAM 或 Flash uint32_t dma_buf[POINTS][40]; void Build_DMA_Buffer(void) { for (int p = 0; p < POINTS; p++) { double freq = sweep_start + p * sweep_step; uint64_t ftw = (uint64_t)((freq * 281474976710656.0) / 100000000.0); int idx = 0; // 写地址 0x04 dma_buf[p][idx++] = (0x3F << 16) | 0x04; // PE0~PE5 置位 // 写 6 个数据字节 for (int i = 0; i < 6; i++) { uint8_t data = (uint8_t)(ftw & 0xFF); ftw >>= 8; dma_buf[p][idx++] = (0xFF << 16); // PD0~PD7 全复位 dma_buf[p][idx++] = data; // 置位数据位 dma_buf[p][idx++] = (1 << 22); // PE6 WR 复位 dma_buf[p][idx++] = (1 << 6); // PE6 WR 置位 } // 更新脉冲 dma_buf[p][idx++] = (1 << 26); // PE10 复位 dma_buf[p][idx++] = (1 << 10); // PE10 置位 dma_buf[p][idx++] = (1 << 26); // PE10 复位 } }这个方案的好处是 CPU 完全解放,扫频速率只受 DMA 带宽和 AD9854 更新时钟最小周期限制。AD9854 的更新时钟最小周期手册写的是 10ns,理论上 100MHz 更新率,实际受限于内部逻辑,跑 10MHz 以上就可能有丢点。我实测 1MHz 更新率很稳,再高没试过,因为手头示波器带宽不够看了。
验证方法:把扫频输出接频谱仪,看频率步进是否均匀,有没有丢点。没有频谱仪的话,用示波器看更新脉冲和输出波形的对应关系,每个更新脉冲后频率应该变一次。如果发现频率没变,检查 DMA 传输完成中断有没有正确重装缓冲区。
从那以后我每次做 DDS 扫频,都先把频率表算好、DMA 缓冲区建好,再开定时器。直接中断里算浮点的做法,只适合低速调试,真跑起来迟早翻车。希望帮到你。
本文还有配套的精品资源,点击获取