简介:基于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 按照定时器触发节奏,把预先生成的波形表循环搬运到 DAC,CPU 全程不参与输出环节。它既能解决测试信号从哪来的实际问题,又能把 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 | 避免方波沿变钝 |
| 一阶 RC | R=1kΩ, C=10nF | 截止约 16kHz,压镜像频谱 |
2.3 最小硬件电路:从 PA4 引脚到输出端子
最小可用的硬件链路是:STM32 的 PA4(DAC_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,三张表 6KB,F103 的 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.024MHz,Period算出来是 70,实际触发频率是 72MHz / 71 ≈ 1.014MHz,对应输出 990Hz 左右,偏差来自整数除法截断。要减小偏差,可以把浮点四舍五入加回来,但完全消除要换方案,后面避坑章节会细说。
3.3 频率设定与幅值调节:实际项目的改法
改频率的完整流程是:先停止 DMA,改定时器周期,重建波形表,再重新启动 DMA。以串口下发f=2000为例:
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_DMA,DMA 压根没启动;二是 CubeMX 里 DAC 的 DMA 请求勾选没开,DMA 通道分配不到外设;三是__HAL_LINKDMA(&hdac, DMA_Handle1, hdma_dac)这条关联语句漏了。
解决:先看 DMA1 的 CCR2 寄存器 EN 位有没有置 1,EN 位是 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 = 72MHz,sample_rate = 1.024MHz,数学上除出来是 70.3125,C 语言整数除法直接截断成 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、定时器三者的配合方式完整演示了一遍,你改改表长和定时器参数,还能继续往外扩展任意波形输出。希望帮到你。
本文还有配套的精品资源,点击获取