基于STM32的AD9851+DAC双通道信号发生器设计与实现
发布时间:2026/9/16 7:58:10来源:尧图网络
简介基于STM32的信号发生器工程源码包面向嵌入式学习者、电子竞赛备赛者和入门开发者适合正在学习STM32库函数开发、准备电子设计竞赛或需要快速搭建信号发生器原型的读者。资源共47个文件整包仅78KB以22个C源文件与22个头文件为主体代码覆盖LCD显示、DAC波形输出、FSMC并口通信、ADC采样、FFT频谱分析、AD9851控制等功能模块同时包含1个ioc格式的STM32CubeMX工程配置、1个md说明文档和1个txt说明文件便于直接打开工程、查看引脚配置、阅读编译环境与使用要点。工程采用分层目录结构将菜单界面、参数管理、红外遥控、底层驱动等模块分开编写逻辑清晰适合在开发板上边编译边调试也可作为任意波形发生器或嵌入式信号处理项目的改造基础。已有1827人学习下载实践参考价值较高。1. 基于STM32的信号发生器为什么最终选了AD9851DAC双通道输出把一个没有按键、没有屏幕的 DDS 模块调出 1kHz 正弦波通常不是算法难而是“这些功能代码该放在哪”让人卡住。这套基于 STM32 的信号发生器工程用的是 STM32F103 AD9851 的高频 DDS 通道同时保留一路 DAC 输出做低频任意波。代码里已经铺好了 FSMC 驱动的 LCD、GUI 菜单、NEC 红外遥控、FFT 频谱显示和参数存取拿来做多波形信号发生器设计或者 STM32 毕业设计比从裸机重新起一个工程要省力得多。适合想研究信号发生器仿真、又需要一套真实源码的人也适合已经会点 STM32 标准库但没完整看过“界面 输入 信号输出 频谱显示”怎么在一个工程里配合的开发者。2. 源码结构拆解System/User/Src/Inc 四层工程怎么对应到外设2.1 拿到压缩包先画一张目录地图这个工程解压后是SignalGenerator-master第一眼很像 GitHub 仓库结构根目录有SignalGenerator_v0.1.ioc和说明文件System放基础驱动User放业务代码Src/Inc是 STM32CubeMX 生成的 HAL 底层。先看懂这三层关系才不会在User/gui.c里到处找延迟函数。SignalGenerator-master/ ├── SignalGenerator_v0.1.ioc # STM32CubeMX 配置 ├── System/ │ ├── lcd.c / lcd.h # LCD 驱动FSMC 接口 │ ├── delay.c / delay.h # 延时 │ ├── sys.c / sys.h # 系统时钟/复位 │ └── font.h # 点阵字库 ├── User/ │ ├── ad9851.c / ad9851.h # AD9851 DDS 控制 │ ├── gui.c / gui.h # 界面绘制 │ ├── menu.c / menu.h # 菜单状态机 │ ├── handle.c / handle.h # 按键/红外事件处理 │ ├── func.c / func.h # 波形功能计算 │ ├── irremote.c/irremote.h # NEC 红外解码 │ ├── paramanager.c/h # 参数保存/恢复 │ ├── fft.c / fft.h # FFT 频谱 │ └── c.c / c.h # 工程公共代码 ├── Src/ # HAL 底层 │ ├── main.c / dac.c / dma.c / fsmc.c / tim.c │ ├── usart.c / gpio.c │ └── stm32f1xx_it.c ├── Inc/ # HAL 头文件 └── README.md / readme.txtc.c/c.h这个名字很不友好我迁移时把它当“check”模块搜了一遍实际上就是工程里公用的类型定义和少量工具函数不是业务核心。Src里的fsmc.c和dma.c来自 CubeMX说明底层已经帮你把 FSMC 控制器的初始化、DMA 通道的时钟使能都做好了移植时不用重写只需要确认硬件引脚编号和你手里的板子一致。2.2 v0.1.ioc 里已经标好外设分工用 STM32CubeMX 打开SignalGenerator_v0.1.ioc可以看到几个关键外设配置FSMC 用于并行 LCDDAC 输出低频波形TIM 提供红外输入捕获和内部定时DMA 做数据搬运USART 用于调试打印。下面这个表格对应到仓库源码里的模块外设源码位置在这个工程里的用途RCCsys.c外部晶振倍频到 72MHz 系统主频FSMCSrc/fsmc.c、System/lcd.c并行 LCD 总线控制DACSrc/dac.c低频任意波形/辅助输出TIMSrc/tim.c、User/irremote.c定时、红外输入捕获USARTSrc/usart.c、User/func.c调试输出/参数回显GPIOSrc/gpio.cAD9851 数据/控制脚如果你打开 Keil 后发现fsmc.c里没有 AD9851 相关代码不要意外。这个工程更典型的接法是 FSMC 只服务 LCDAD9851 用普通 GPIO 模拟并行时序我代码移植时也优先按 GPIO 模拟处理这样 PCB 布线压力小。若你的硬件把 AD9851 也挂到了 FSMC 地址区那把写数据改成指针赋值即可时序原理一样。2.3 移植到 Keil芯片包、Define 和 Include Path先明确手里的芯片型号。v0.1.ioc默认是 STM32F1 系列但 F103C8 和 F103ZE 的 Flash/RAM 容量不同#define 也不同。打开.uvprojx后如果报错满屏第一件事不是改代码而是把 Keil 的 Device 选对然后安装对应的 STM32F1 芯片包。# Keil5 工程里的 Define 和 Include Path以下写法等价于魔术棒里手工配置 Define: STM32F103xE, USE_HAL_DRIVER Include: System, User, Inc, Src这里两个宏是绑定关系USE_HAL_DRIVER告诉编译器包含 HAL 库的 stm32f1xx_hal_conf.hSTM32F103xE决定芯片内部外设寄存器地址和中断向量表。如果漏了其中一个最常见的是stm32f1xx.h里报 undefined或者HAL_RCC_GetHCLKFreq找不到实现。另外注意 Keil 的 C/C 选项里每个 Include Path 都要单独添加不能用分号一次性粘贴工程里System和User都加了才算完整。2.4 main.c 的初始化顺序也是学习模板这个工程的main.c由 CubeMX 生成但作者把业务初始化放在外设之后顺序非常直接int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_FSMC_Init(); MX_DAC_Init(); MX_TIM_Init(); MX_USART_Init(); LCD_Init(); AD9851_Init(); GUI_Init(); Menu_Init(); Params_Load(); while (1) { HandleKeyEvent(); Menu_Update(); GUI_Refresh(); } }这里的顺序不是随便写的。HAL 库必须先HAL_Init()SystemClock_Config()要给 FSMC、DAC、TIM 提供时钟源DMA 要在外设使能前初始化否则 AD9851 或 DAC 使用 DMA 时找不到通道。业务层里LCD_Init要在GUI_Init前因为 GUI 要往 LCD 上画Params_Load放在Menu_Init后是为了把上次保存的频率和波形直接灌进菜单当前状态。如果改成先Params_Load再Menu_Init菜单初始化很可能会把保存值覆盖掉。3. AD9851 并行时序和频率字计算DDS 波形准不准全在这里3.1 为什么要额外挂 AD9851而不是 STM32 DAC 直接合成STM32F103 的 DAC 是 12 位、最高输出频率受限于更新速率用定时器触发也只能产生几十 kHz 级别的平滑波形。要做 1MHz 以上正弦波靠 72MHz 主频查表输出不太现实一个周期如果只采 32 个点正弦波就会变成阶梯波滤波后谐波也压不干净。而 AD9851 是完整的 DDS 芯片内部有相位累加器、正弦查找表和高速 DACSTM32 只需要写出频率字剩下的波形整形全在芯片内部完成。AD9851 的核心参数是参考时钟。它支持外部晶振倍频典型配置是 30MHz 晶振配合内部 6 倍频器得到 180MHz REFCLK。输出频率上限受奈奎斯特限制工程上不会真跑到 90MHz通常控制在 30MHz 以内给输出滤波留出足够过渡带。3.2 频率字的 32 位计算不能直接用 intDDS 输出频率由 32 位频率字控制公式是FTW (uint32_t)(((uint64_t)freq_hz 32) / REFCLK);在工程代码里最终实现是这样// ad9851.c 频率设置核心 void AD9851_SetFrequency(uint32_t freq_hz) { const uint32_t REFCLK 180000000UL; // 30MHz 晶振 * 6实际 REFCLK uint32_t ftw; uint8_t ctrl_word 0x01; // W0使能 6 倍频器 ftw (uint32_t)(((uint64_t)freq_hz 32) / REFCLK); AD9851_WriteByte(ctrl_word); // 先写控制字 W0 AD9851_WriteByte((ftw 24) 0xFF); // 频率字最高字节 AD9851_WriteByte((ftw 16) 0xFF); AD9851_WriteByte((ftw 8) 0xFF); AD9851_WriteByte(ftw 0xFF); // 频率字最低字节 AD9851_Pulse(FQ_UD); // 更新输出 }代码里最容易被忽略的是(uint64_t)freq_hz 32。如果直接写成freq_hz * 4294967296或者把2^32当常量乘32 位整型会溢出算出来的频率字完全是错的。REFCLK也要用无符号长整型避免编译器按 16 位整数处理导致除法异常。ctrl_word的位定义在不同模式下略有差异作者工程里默认写0x01也就是打开 6 倍频如果你的板子直接给了 180MHz 有源时钟这里要改成 0x00否则实际输出频率会是设置值的 6 倍。3.3 用 GPIO 模拟 AD9851 四位时序的写法不依赖具体引脚编号AD9851 并行写数据最少需要 8 根数据线、W_CLK、FQ_UD 和 RESET。我一般把每个引脚封装成宏这样换板子时只改一个头文件#define AD9851_DATA(x) do { \ HAL_GPIO_WritePin(AD9851_DATA_PORT, AD9851_DATA_PIN, (x) ? GPIO_PIN_SET : GPIO_PIN_RESET); \ } while (0) #define AD9851_WCLK_H() HAL_GPIO_WritePin(AD9851_WCLK_PORT, AD9851_WCLK_PIN, GPIO_PIN_SET) #define AD9851_WCLK_L() HAL_GPIO_WritePin(AD9851_WCLK_PORT, AD9851_WCLK_PIN, GPIO_PIN_RESET) #define AD9851_FQUD_H() HAL_GPIO_WritePin(AD9851_FQUD_PORT, AD9851_FQUD_PIN, GPIO_PIN_SET) #define AD9851_FQUD_L() HAL_GPIO_WritePin(AD9851_FQUD_PORT, AD9851_FQUD_PIN, GPIO_PIN_RESET) void AD9851_WriteByte(uint8_t dat) { for (int i 7; i 0; i--) { AD9851_DATA((dat i) 0x01); // 高位先出 AD9851_WCLK_H(); AD9851_WCLK_L(); } }这个函数的关键是 W_CLK 的上升沿锁存数据所以每次写完一位都要有高、低电平的完整脉冲。FQ_UD是所有数据写完后才拉高的不能在写字节过程中拉否则 AD9851 会把不完整的数据当频率字锁存。AD9851_Init里还必须先做一次 RESET 脉冲让内部状态机回到并行加载模式否则后续写入可能错位。3.4 频率输出不正常的排查顺序先量 REFCLK。用示波器看 AD9851 的 REFCLK 引脚如果外部是 30MHz 晶振这里应在 30MHz 附近。如果完全停振检查晶振两个脚的负载电容是否焊反或者匹配电容是否过大导致起振时间太长。再看 FQ_UD 波形每次设频率时应有一路稳定的上升沿平时为低。如果 FQ_UD 被外部上拉一直拉高AD9851 会把每一次 W_CLK 都当成更新输出频率完全乱掉。下面是并行加载时序里的几个关键参数实际调试用逻辑分析仪抓更直观参数含义调试建议W_CLK 高电平时序决定数据建立时间W_CLK 脉冲间隔不要太短软件延时加 1us 以上FQ_UD 滞后时间必须在频率字写完后拉高高电平维持至少一个系统周期RESET 脉冲宽度复位内部并行接口上电后先拉高 10us 再拉低如果这几个信号都对IOUT 引脚仍然没有波形大概率不是 AD9851 问题而是输出端变压器或运放电路没工作。我最后一次调试时查了一下午代码最后发现是运放供电脚的退耦电容掉了输出被拉到地。4. GUI 状态机、NEC 红外解码和 FFT 频谱显示是怎么串起来的4.1 菜单树用结构体组织不接受 if-else 套 if-else裸机 GUI 最容易写成一层层if (key KEY_UP) ...。这个工程把菜单模型抽出来了menu.c里每个页面是一个MenuPage选中项和页面切换全部交给状态机处理typedef struct { const char *title; // 页面标题 uint8_t item_count; // 菜单项数量 const char *const *items; // 菜单项名指针数组 void (*draw)(uint8_t active);// 绘制回调 } MenuPage; static const char *main_items[] {Freq, Wave, Step, Save}; static const MenuPage main_page { Main, 4, main_items, Draw_Main_Page };这里的draw回调只负责把当前页面画到 LCD 上具体菜单逻辑由Menu_Update根据当前 active 下标决定。新增一个页面只需要在数组里注册不用改主循环。我实际改这个工程时把Step项展开成1Hz/10Hz/1kHz三档步进只加了两个结构体变量主循环一行没动。这个模式在 5 年以上经验的人手里也值得保留因为后续要加 USB、蓝牙按键都只需要转换成统一的MenuEvent喂给状态机。4.2 NEC 红外遥控的输入捕获解码红外接收头出来的信号是 38kHz 载波的负逻辑。NEC 协议里逻辑 0 是一个 1.125ms 周期逻辑 1 是 2.25ms 周期。用定时器输入捕获测两次下降沿之间的时间差就能解出数据位。void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { static uint32_t last 0, bit[32]; static uint8_t idx 0; uint32_t now, diff; if (htim-Instance ! IR_TIM_INSTANCE) return; now HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); diff now - last; last now; if (diff 12000) // 13.5ms 引导码定时器按 1MHz 计数 { idx 0; return; } if (idx 32) { bit[idx] (diff 1800) ? 1 : 0; // 2.25ms1, 1.125ms0 idx; } if (idx 32) { HandleIRCode(bit); idx 0; } }这个解法的前提是定时器预分频后计数频率 1MHz也就是每个 tick 1us。diff 12000对应 12ms 以上引导码 13.5ms 才不会被误判成逻辑 1。注意diff是计数器差值如果定时器 16 位溢出会出错STM32F103 的定时器计数到 65535 才溢出NEC 最长的一帧不过 100ms建议把定时器设成 1MHz 并允许溢出中断或者在捕获回调里先处理计数器回绕。原工程irremote.c里肯定还有重复码处理长按按键时发送的是 9ms 2.25ms 的 repeat 码要单独识别为“保持”否则菜单翻页只能一格一格点。4.3 FFT 频谱显示只画前一半频点别全搬fft.c在工程里承担两件事一是把 ADC 采到的波形做频谱二是给 GUI 提供频点幅值。常见实现是自写基 2 FFT采样点数 64 到 1024 可配。显示端不要直接画 N 个 FFT 频点屏幕宽度不够刷新也慢。// 以 64 点 FFT 为例只用前 32 个频点 float mag_sq fft_out_real[i] * fft_out_real[i] fft_out_imag[i] * fft_out_imag[i]; uint16_t bar_h (uint16_t)(sqrtf(mag_sq) / 16.0f); if (bar_h LCD_HEIGHT) bar_h LCD_HEIGHT; LCD_Fill(10 i * 3, LCD_HEIGHT - bar_h, 10 i * 3, LCD_HEIGHT - 1, BAR_COLOR);sqrtf只算幅值不需要转换为 dB否则屏幕显示范围很难调。i * 3是在水平方向把每根柱子隔开 3 像素64 点缩成 32 根柱正好占满一个窄频段显示区。FFT 计算要和 ADC 采集放在同一时基里采样率必须是信号频率的整数倍以上否则频谱会出现栅栏效应峰值不落在一个频点上。4.4 paramanager.c 把参数写进 Flash频率下次开机还在信号发生器最痛的点是每次上电都要重新设频率。这个工程用paramanager.c把频率、波形、步进打包后写入 STM32 内部 Flash 最后一页。常见结构体如下typedef struct { uint32_t freq; // 输出频率 uint8_t wave; // 波形类型 uint8_t step; // 步进档 uint16_t crc; // 简单校验 } SignalParams; SignalParams g_params; void Params_Save(void) { uint32_t *p (uint32_t *)PARAM_FLASH_ADDR; FLASH_Unlock(); FLASH_ErasePage(PARAM_FLASH_PAGE); p[0] g_params.freq; p[1] g_params.wave; p[2] g_params.step; FLASH_Lock(); }Flash 必须先擦除再写而且不能按字节改只能整页擦。所以Params_Save不要放在每次旋钮调频率的循环里否则几天就能把 Flash 寿命写没。工程里的习惯是只在菜单选 “Save” 或红外遥控按下保存键时才调用。Params_Load要在Menu_Init之后读读出来的值直接覆盖菜单结构体里的当前项。5. 调校技巧波形不准先查晶振电容杂散先查电源和地5.1 晶振负载电容的计算决定频率初始误差很多 AD9851 模块用 30MHz 无源晶振两个引脚各接一个到地的负载电容。电容选错了输出频率不是低几十 Hz而可能偏几百 Hz。常见计算方法是让晶振看到的有效负载电容等于数据手册标称值CL (C1 * C2) / (C1 C2) CstrayCstray 是 PCB 引脚寄生电容一般估 3~5pF。如果手册要求 CL18pF取 C1C2则C 2 * (18 - 4) 28pF实际标注容值没有 28pF取 27pF 或 30pF 都行。这个误差会在 DDS 输出端直接表现为频率比例偏差因为 AD9851 的 REFCLK 就是晶振频率乘 6。我在调这块板子时先用频率计测 AD9851 的 REFCLK 引脚看到 29.98MHz再倒推电容值换完 27pF 后频率误差明显小了一个量级。5.2 2Vpp 输出幅度校准别靠软件削波热词里“信号发生器2vpp电压多少”问的是测试标准DDS 输出默认幅度往往不是整数值。AD9851 的 IOUT 引脚输出电流可调经过片内电阻和外部运放转成电压后幅度受电源电压和负载影响很大。校准步骤先用示波器测量空载输出再在输出端接一个 50Ω 或 300Ω 负载看带载后的幅度衰减。如果软件里直接调 DAC 的满幅值来凑 2Vpp非线性会很差尤其高频段。正确做法是保留硬件电位器或可调增益电阻软件只负责频率幅度由模拟链路标定。最后还差一个边界检查AD9851 的 IOUT 输出是单端电流别把它直接接示波器中间应串一个电阻并联一个小电容到地否则输出端反射会增大波形抖动。我最后的调试顺序是这样的先量 REFCLK再量 W_CLK/FQ_UD然后量 IOUT 前端的滤波网络最后量运放输出如果看到杂散很高第一件事不是改 FFT而是检查模拟地有没有和数字地在 AD9851 下方单点连接。本文还有配套的精品资源点击获取
网站建设高端定制企业官网