简介基于STM32的函数信号发生器完整工程包面向嵌入式开发学习者演示如何借助STM32内置DAC生成方波、三角波与正弦波并利用DMA把波形数据快速搬运到DAC寄存器实现无需CPU全程参与的高效连续输出。项目通过按键中断动态调节频率与幅值频率覆盖100Hz至10kHz适合作为单片机原理、DAC/DMA应用及中断系统的实战参考。资源包共188个文件压缩后约2.96MB主要包含H/C源码、Keil工程配置文件uvproj/uvopt、编译产生的o/crf中间文件等其中H/C文件涉及系统初始化、GPIO、EXTI、DAC、DMA及定时器等外设驱动逻辑文件结构清晰便于按模块查阅与二次开发。目前已有3840人学习/下载。通过学习本工程读者可掌握STM32标准外设库的工程搭建方式理解DAC与DMA配合生成连续波形的配置流程并学会使用外部中断实时响应用户按键来调整信号参数为后续信号源类项目打下良好基础。1. 用 STM32 自制函数信号发生器一百块以内的实用波形源手头有块吃灰的STM32开发板正好最近要给音频电路做频响测试需要一路正弦波、方波和三角波信号源。现成的函数信号发生器动辄几百块于是动了用 STM32 的 DAC 加 DMA 自己搓一个的念头。这份「STM32-函数信号发生器」源码包核心思路就一句话DMA 按照定时器触发节奏把预先生成的波形表循环搬运到 DACCPU 全程不参与输出环节。它既能解决测试信号从哪来的实际问题又能把 DAC、DMA、定时器三个外设的联动机制彻底跑通。适合手里有 STM32 最小系统板、想直接上手复现的人也适合课程设计需要“信号发生器 示波器验证”完整闭环的场景。2. 信号生成原理与硬件选型查表法为什么比逐点计算更实际2.1 查表法核心原理波形表与定时器触发的关系让 STM32 输出任意波形最容易想到的思路是在定时器中断里逐点计算正弦值送给 DAC。我在 F103 上实测过72MHz 主频下每点做一次浮点 sin 计算输出 5kHz 以上波形时 CPU 占用率就超过 70%按键扫描和显示刷新马上变得卡顿。频率再往上拉上一次中断的计算还没结束下一轮触发已经进来波形直接糊掉。这个方案在低频教学演示里还能看工程上完全行不通。改用查表法之后事情变得简单很多。预先在内存放一张 N 点的波形表运行时用定时器触发 DMA让 DMA 把表数据从头到尾循环搬运到 DAC 的数据寄存器。CPU 不参与数据搬运采样率完全由定时器的触发节奏决定。输出频率和采样率之间的关系是固定的输出频率 采样率 / 波形表长度举个例子表长 1024 点采样率 1.024MHz输出频率刚好是 1kHz。想输出 2kHz要么把采样率抬到 2.048MHz要么把表长换成 512 点。在这个简化方案里改采样率更常见因为表长决定每个周期的波形精度一般固定不动。这里有一个容易被忽视的限制DAC 的建立时间。F103 的数据手册给出 DAC 满幅阶跃的建立时间在数微秒量级这决定了单通道实际稳定更新率大概在两百 k 到三百 k 点每秒。配 1024 点表对应的输出频率上限就是两三百赫兹做音频测试完全够用但如果要输出几十 kHz就必须把表长压到 128 点甚至 64 点波形阶梯也会随之变明显。这个取舍在选型和定表长的时候就要想清楚不然做到高频再回头改表很多代码都要跟着动。2.2 硬件外设选型DAC、运放与滤波电路怎么配STM32 内置的 DAC 是 12 位电压输出型输出范围一般是 0 到 Vref芯片自带输出缓冲。但缓冲放大器驱动能力有限带 10k 以下负载会明显掉电压波形塌陷。所以正经设计里会在 DAC 输出后加一级运放做电压跟随或者同相放大。选运放时要注意压摆率DAC 输出是阶梯状的如果运放压摆率只有 0.3V/us输出方波沿会变得很斜。我一般选压摆率至少 2V/us、增益带宽积 1MHz 以上的通用运放10kHz 以内方波沿都能保持陡峭。滤波是另一个关键点。DAC 输出的阶梯波在频域上存在以采样率为中心的镜像频谱不加滤波直接看示波器没问题一旦接频谱仪全是镜像分量。够用的做法是一阶 RC 低通截止频率取输出主频的 5~10 倍。比如输出 1kHz 正弦波RC 截止频率设到 5k~10kHz镜像能压到 -40dB 以下。二阶有源低通更干净但相位延迟大方波会变圆除非做高保真音频否则没必要上。这里给一组我实际调通过的基础参数参数取值说明DAC 输出引脚PA4 (DAC_OUT1)12 位模式数据统一按 16 位半字搬运波形表长度1024 点频率分辨率 采样率 / 1024触发定时器TIM6基础定时器专门伺候 DAC 触发运放压摆率≥ 2V/us避免方波沿变钝一阶 RCR1kΩ, C10nF截止约 16kHz压镜像频谱2.3 最小硬件电路从 PA4 引脚到输出端子最小可用的硬件链路是STM32 的 PA4DAC_OUT1→ 运放电压跟随 → RC 低通 → 输出端子。运放用单电源 3.3V 供电。如果要输出正负对称的波形需要给运放做虚拟地拿两只 10k 电阻把 3.3V 分压成 1.65V再接一个运放电压跟随器把 1.65V 作为信号放大级的地参考。这样 DAC 输出的 0~3.3V 波形经过运放后负载两端看到的信号是 -1.65V~1.65V 的对称输出。这一步对后面接音频功放或者测量电路非常关键。我实际跑通的接法分三步第一步PA4 直接连运放同相输入端反相输入端接反馈电阻网络用 10kΩ 和 3k9Ω 做同相放大增益约 3.5 倍。第二步运放输出端串 1kΩ 电阻对地接 10nF 电容构成一阶 RC 低通截止频率约 16kHz。如果主要输出 100Hz~1kHz把 C 换成 100nF截止降到 1.6kHz波形更干净但高频段会明显衰减。第三步RC 输出接到示波器探头或后级负载。如果要拉长线缆输出端再加一级缓冲器防止线缆电容影响 RC 截止频率。走线方面PA4 到运放输入这段尽量短运放电源脚附近放一只 10uF 电解电容并联一只 100nF 陶瓷电容。我见过有人把去耦电容省掉结果波形带高频自激纹波这是典型该省的没省后面排查浪费一下午。3. 核心代码实现波形表、DMA 搬运与频率控制实战3.1 波形表生成正弦、方波、三角波三张表波形表是整套方案的基础资源包里三张表都按 1024 点、12 位 DAC 的 0~4095 范围生成。正弦表用浮点计算一次成型方波和三角波用纯整数生成改参数时重建速度快得多。#include math.h #define TABLE_SIZE 1024 uint16_t sin_table[TABLE_SIZE]; uint16_t square_table[TABLE_SIZE]; uint16_t tri_table[TABLE_SIZE]; // 生成正弦表2048 为基准幅度 2047 void generate_sine_table(void) { for (int i 0; i TABLE_SIZE; i) { double v 2048.0 2047.0 * sin(2.0 * M_PI * i / TABLE_SIZE); sin_table[i] (uint16_t)v; } } // 生成方波表前 512 点为高电平 4095后 512 点为低电平 0 void generate_square_table(void) { for (int i 0; i TABLE_SIZE; i) { square_table[i] (i TABLE_SIZE / 2) ? 4095 : 0; } } // 生成三角波前半段从 0 线性升到 4095后半段对称降回 0 void generate_triangle_table(void) { for (int i 0; i TABLE_SIZE; i) { if (i TABLE_SIZE / 2) { tri_table[i] (uint16_t)((uint32_t)i * 4095u / (TABLE_SIZE / 2 - 1)); } else { tri_table[i] (uint16_t)((uint32_t)(TABLE_SIZE - 1 - i) * 4095u / (TABLE_SIZE / 2 - 1)); } } }逻辑说明正弦表以中位 2048 为基准sin 范围是 -1 到 1乘 2047 之后落在 1~4095 之间避开了 0 和 4095 两个端点给后级运放留一点裕量。方波表直接映射高低电平没有任何中间值。三角波用整数线性插值前半段从 0 升到 4095后半段从 4095 归零完全不涉及浮点运算重建速度在微秒级。参数说明TABLE_SIZE 1024 是最关键的常量同时影响频率分辨率、内存占用和重建时间。1024 点每点占 2 字节一张表 2KB三张表 6KBF103 的 20KB RAM 完全放得下。做低频精细测试可以扩到 2048 点频率步进精度翻倍但内存和重建时间也翻倍做高频输出就压到 128 或 64 点波形阶梯变明显但频率上限能拉上去。3.2 DMA DAC 定时器三个外设怎么联动这部分是整个工程的核心。数据流是这样的定时器按采样率节拍产生触发事件 → DAC 收到触发后发起 DMA 请求 → DMA 从波形表搬一个 16 位数据到 DAC 数据寄存器 → DAC 输出对应电压。整个链路没有 CPU 参与采样率完全由定时器决定。以下是关键初始化代码用 STM32F103 和 Cube HAL 库写DAC_HandleTypeDef hdac; DMA_HandleTypeDef hdma_dac; TIM_HandleTypeDef htim; void waveform_engine_init(uint32_t sample_rate) { // 1. DAC 通道 1 初始化触发源选择 TIM6 TRGO hdac.Instance DAC; HAL_DAC_Init(hdac); DAC_ChannelConfTypeDef sConfig {0}; sConfig.DAC_SampleAndHold DAC_SAMPLEANDHOLD_DISABLE; sConfig.DAC_Trigger DAC_TRIGGER_T6_TRGO; sConfig.DAC_OutputBuffer DAC_OUTPUTBUFFER_ENABLE; HAL_DAC_ConfigChannel(hdac, sConfig, DAC_CHANNEL_1); // 2. DMA1 通道 2内存到外设半字对齐循环模式 hdma_dac.Instance DMA1_Channel2; hdma_dac.Init.Direction DMA_MEMORY_TO_PERIPH; hdma_dac.Init.PeriphInc DMA_PINC_DISABLE; hdma_dac.Init.MemInc DMA_MINC_ENABLE; hdma_dac.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; hdma_dac.Init.MemDataAlignment DMA_MDATAALIGN_HALFWORD; hdma_dac.Init.Mode DMA_CIRCULAR; hdma_dac.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_dac); __HAL_LINKDMA(hdac, DMA_Handle1, hdma_dac); // 3. 定时器 6按采样率触发 DAC htim.Instance TIM6; htim.Init.Prescaler 0; htim.Init.Period (SystemCoreClock / sample_rate) - 1; HAL_TIM_Base_Init(htim); // 4. 启动 DMA 搬运DMA 循环模式持续输出 HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t *)sin_table, TABLE_SIZE); HAL_TIM_Base_Start(htim); }逻辑说明__HAL_LINKDMA这条语句非常关键它把 DMA 句柄挂到 DAC 句柄上DAC 触发时才能找到 DMA漏掉这一步DAC 触发信号发了但 DMA 不会动。TIM6 在这里只产生触发事件不开启中断所以不占用 CPU。DMA_CIRCULAR循环模式让 DMA 搬完表尾立刻回表头波形无缝循环。如果这里配成普通模式DMA 搬完一轮就停波形只输出一个周期就没了这是最常见的翻车点。参数说明sample_rate是核心输入。以输出 1kHz、表长 1024 点为例采样率取 1.024MHzPeriod算出来是 70实际触发频率是 72MHz / 71 ≈ 1.014MHz对应输出 990Hz 左右偏差来自整数除法截断。要减小偏差可以把浮点四舍五入加回来但完全消除要换方案后面避坑章节会细说。3.3 频率设定与幅值调节实际项目的改法改频率的完整流程是先停止 DMA改定时器周期重建波形表再重新启动 DMA。以串口下发f2000为例void set_output_frequency(uint32_t freq_hz, uint16_t *table) { uint32_t sample_rate freq_hz * TABLE_SIZE; // 重算波形表这里用正弦表做示例 for (int i 0; i TABLE_SIZE; i) { double v 2048.0 2047.0 * sin(2.0 * M_PI * i / TABLE_SIZE); table[i] (uint16_t)v; } // 停 DMA改定时器周期再启动 HAL_DAC_DMAStop(hdac, DAC_CHANNEL_1); htim.Init.Period (uint32_t)round((double)SystemCoreClock / sample_rate - 1.0); HAL_TIM_Base_Init(htim); HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t *)table, TABLE_SIZE); }逻辑说明HAL_DAC_DMAStop不是可省的。如果旧 DMA 传输还没结束时直接改定时器周期波形会偶发断层甚至出现一个周期长一个周期短的鬼样子。先停 DMA、改 TIM6 重载值、再重新启动 DMA三个外设状态才能对齐。正弦表 1024 点浮点重建耗时几百微秒人机交互完全无感。参数说明sample_rate freq_hz * TABLE_SIZE的意思是每周波固定 1024 个采样点任何频率下示波器看波形平滑度一致。这个做法的缺点是采样率随频率变化RC 滤波器的截止频率不会跟着变高频时镜像抑制会变差但 20kHz 以内没有可感知的区别。如果频率跨度要求 10Hz~100kHz更好的是固定采样率加相位累加器这份资源在有限频段内够用跨度大再考虑扩展。幅值调节方面DAC 的 12 位输出每 LSB 约 0.8mV以 3.3V Vref 计算。调幅值有两种实际做法一是电位器手动调运放反馈电阻简单线性二是用 I2C 数字电位器做自动增益。资源里用的是电位器方案稳定、无噪声适合教学和通用测试。如果你要程控幅值把反馈电阻换成 MCP41xx 系列数字电位器即可代码改动不大。4. 常见问题与避坑波形失真、频率偏差与运放自激诊断4.1 DAC 输出一直是 0V查了半天发现 DMA 根本没跑现象PA4 引脚量不到任何电压程序不报错DAC 寄存器值也写进去了输出就是一口闷井。原因常见的有三个。一是只调了HAL_DAC_Start没有调HAL_DAC_Start_DMADMA 压根没启动二是 CubeMX 里 DAC 的 DMA 请求勾选没开DMA 通道分配不到外设三是__HAL_LINKDMA(hdac, DMA_Handle1, hdma_dac)这条关联语句漏了。解决先看 DMA1 的 CCR2 寄存器 EN 位有没有置 1EN 位是 0 就直接定位到 DMA 没启动。其次确认HAL_DAC_Start_DMA第三参数传的是波形表首地址。最后检查一遍 LINK DMA 那行代码写没写和写没写对是两回事地址写错编译期根本看不出来。4.2 波形输出几个周期后突然变平DMA 搬完就停现象示波器上看到波形只有头一个周期之后变成一条直线。原因DMA 的 Mode 被配成DMA_NORMAL而不是DMA_CIRCULAR。普通模式搬完整张表就停止DMA 不再产生新请求DAC 保持最后一个电压值不动。解决把hdma_dac.Init.Mode改成DMA_CIRCULAR。另外别怀疑是内存对齐问题Cortex-M3 的 DMA 搬运 16 位数据不要求 2 字节对齐总线会自动处理踩过这个坑的人不少其实和它无关。4.3 实测频率和设定值偏差明显低频段尤其扎眼现象设定输出 1kHz示波器频率计读出 982Hz 左右偏差接近 2%。原因定时器周期公式用了整型除法。(SystemCoreClock / sample_rate) - 1这一步SystemCoreClock 72MHzsample_rate 1.024MHz数学上除出来是 70.3125C 语言整数除法直接截断成 70实际触发频率变成 72MHz / 71 ≈ 1.014MHz对应输出 990Hz再叠加晶振本身的误差就在 1.5%~2% 之间晃。解决不要直接写除法先用浮点算出理论重载值再四舍五入uint32_t period (uint32_t)round((double)SystemCoreClock / sample_rate - 1.0);这样能压到 1% 以内。要求更高精度要么换温补晶振要么用带小数分频的定时器对系统时钟做精细分频但对一般信号发生器的应用场景1% 以内的误差完全够用。4.4 波形毛刺和高频纹波去耦电容和滤波布局没做好现象波形主干正常但叠了一层高频纹波正弦波峰谷位置特别明显运放输出端量到自激。原因运放电源去耦不足。DAC 的阶梯跳变会在电源线上产生尖峰毛刺被运放不理想地放大。还有一种情况是 RC 滤波电容放得太远走线长导致寄生电感反而变成 LC 振荡。解决运放电源引脚处加 10uF 电解电容并联 100nF 陶瓷电容RC 滤波整体放运放输出端电阻电容之间短线直连地线加宽。我遇到过一次把电容放在 PA4 旁边而不是运放输出端纹波更严重后来把 RC 整体移到运放输出端就干净了。这个真的属于布局玄学排线绕一圈效果都不一样。4.5 输出幅值随负载变化明显波形直接塌陷现象接上后级电路之后波形峰峰值从 3.3V 掉到 2.4V形状没变但幅度差一大截。原因这是 STM32 内置 DAC 缓冲器的驱动能力极限。片内缓冲为低负载设计输出阻抗偏高驱动 10k 以下负载时电压全摔在缓冲器内阻上。解决DAC 输出后加一级运放电压跟随器让运放去驱动外部负载。如果后级还是大电容负载把跟随器换成隔离放大器组态。这条是硬件设计的血泪经验软件配置再正确也救不了一定要在画板之前就把运放一级留出来。5. 从能响到好用示波器校准波形质量的三个验证技巧5.1 用示波器验证波形频率、峰峰值、谐波三项检查把示波器探头接到 RC 滤波输出端先做频率校准。设定 1kHz看示波器自带频率计读数在设定值 1% 以内算合格超过 1% 就按上一章的方法重算定时器重载值。然后是峰峰值看正弦波正负半周是否对称有没有削顶溢出三角波两端是否到同样幅度。最后用示波器 FFT 档看主频之外的谐波分量要求主峰与最大谐波之差在 -30dB 以上。这三项过关波形基本能放心用。如果看到波形边缘有锯齿感说明采样点不够优先把采样率提上去而不是改表长。比如输出 1kHz 时采样率从 1.024MHz 加到 2.048MHz同时把表长扩到 2048 点波形明显更光滑。频率不变、每周期点数翻倍示波器上能看到肉眼可辨的改善。5.2 进阶技巧双缓冲 DMA 做波形无缝切换单个波形表能跑通后可以玩一点进阶的东西用 DMA 的半传输中断做双缓冲切换差一个变量就能把正弦波改成 AM 调制波或扫频波。做法是准备两张波形表DMA 配置成循环模式在半传输中断里把内存地址改到另一张表extern uint16_t next_table[TABLE_SIZE]; void DMA1_Channel2_IRQHandler(void) { if (__HAL_DMA_GET_FLAG(hdma_dac, DMA_FLAG_HT2)) { // 半传输完成直接改 DMA 下一次读表的源地址 WRITE_REG(hdma_dac.Instance-CMAR, (uint32_t)next_table); __HAL_DMA_CLEAR_FLAG(hdma_dac, DMA_FLAG_HT2); } }逻辑说明HT2 是 DMA 通道 2 的半传输标志在搬运完数据量一半时置位。利用这个时机改写 CMAR内存地址寄存器DMA 下一轮循环就会从新表开始读数据实现两段波形的无缝衔接。参数说明这个技巧的关键是确保下一张表的数据在中断触发时已经准备完毕否则会读到旧数据。从那以后我每次调这类波形输出都会先看一眼 DMA 状态寄存器确认循环模式确实跑起来再谈其他。这套资源最值得收藏的一点是它把 DAC、DMA、定时器三者的配合方式完整演示了一遍你改改表长和定时器参数还能继续往外扩展任意波形输出。希望帮到你。本文还有配套的精品资源点击获取