用STM32和DMA打造“零CPU干预”的波形发生器:从原理到实战
你有没有遇到过这样的问题?想在STM32上生成一个干净的正弦波,结果一测输出,波形像锯齿、频率不准、抖动严重——更糟的是,系统其他任务全卡住了。原因往往出在方法上:别再用中断或延时函数刷DAC了!
真正高效的波形发生器,不是靠CPU“拼命搬砖”,而是让硬件自己动起来。本文将带你深入剖析如何利用STM32的定时器 + DMA + DAC 三件套,构建一个几乎不占用CPU资源、输出稳定精准的连续波形系统。
这不是理论推演,而是一套经过验证、可直接落地的工程方案。我们将一步步拆解每个模块的核心机制,并告诉你哪些参数最关键、哪些坑必须避开。
为什么传统方式行不通?
先说清楚痛点。
很多初学者写波形发生器,习惯这么干:
while (1) { for (int i = 0; i < 1024; i++) { HAL_DAC_SetValue(&hdac, DAC_CHANNEL_1, SIN_TABLE[i]); HAL_Delay(1); // 想实现1kHz采样? } }这代码看着简单,实则问题重重:
HAL_Delay()精度差,受系统调度影响;- 中断来了会打断循环,导致采样间隔不均(时序抖动);
- CPU全程被锁死,无法处理通信、UI或其他任务;
- 最高采样率受限于函数调用开销,很难突破几kHz。
要生成高质量模拟信号,关键在于时间的一致性。哪怕每次只差几个微秒,累积起来也会让频谱变脏、谐波增多。
真正的解决之道是:把数据传输这件事交给DMA,把时间控制交给定时器,CPU只负责启动和配置。
定时器:给波形输出装上“节拍器”
STM32里的定时器不只是用来做延时的。它更像是一个精密的“脉冲发生器”,能以极高的稳定性周期性地触发事件。
我们选TIM6 或 TIM7这类基本定时器,专门用于驱动DAC。它们虽功能简单,但胜在可靠、独立、低功耗。
它是怎么工作的?
想象你在打节拍:每拍一下手,乐手就弹一个音符。在这个系统中:
- 你打拍子的手→ TIM6 的更新事件(Update Event)
- 乐手弹音符的动作→ DAC 启动一次转换
- 节拍的快慢→ 由 PSC 和 ARR 寄存器决定
配置流程如下:
htim6.Instance = TIM6; htim6.Init.Prescaler = 72 - 1; // 72MHz → 1MHz htim6.Init.Period = 1000 - 1; // 1MHz / 1000 = 1kHz HAL_TIM_Base_Start(&htim6);此时,TIM6 每毫秒产生一次更新事件。这个事件可以自动连接到 DAC 的外部触发输入脚,无需任何软件参与。
⚠️ 关键点:一定要启用主模式(Master Mode),设置为
UPDATE触发,这样才能对外输出触发信号。
// 让TIM6的更新事件作为TRGO信号输出 __HAL_TIM_ENABLE_IT(&htim6, TIM_IT_UPDATE);一旦连通,后续所有动作都将由硬件链式触发完成——这才是“硬实时”的意义所在。
DMA:沉默的数据搬运工
如果说定时器是指挥官,那DMA就是执行兵。它的任务只有一个:当DAC说“我准备好下一个点了”,立刻从内存里取一个数据送过去。
为什么非要用DMA?
因为只有DMA能做到:
- 每次传输延迟固定(仅几个总线周期);
- 不受中断优先级干扰;
- 支持循环模式,自动重复;
- 传输过程中CPU完全自由。
换句话说,DMA + 定时器 = 硬件级流水线。
核心配置要点
我们以 STM32F4 系列为例,使用 DMA2_Stream5 配合 DAC1:
| 参数 | 设置说明 |
|---|---|
| 方向 | 内存 → 外设(Memory to Peripheral) |
| 源地址 | 波形查找表首地址(如&sine_table[0]) |
| 目标地址 | &DAC->DHR12R1(DAC通道1数据寄存器) |
| 内存增量 | 启用(MINC_ENABLE),每次读下一个点 |
| 外设增量 | 禁用(PINCE_DISABLE),始终写同一个寄存器 |
| 数据宽度 | 半字(16位),匹配DAC寄存器 |
| 模式 | 循环模式(Circular Mode)✅ 必须开启! |
下面是初始化代码(基于HAL库):
static void MX_DMA_Init(void) { __HAL_RCC_DMA2_CLK_ENABLE(); hdma_dac1.Instance = DMA2_Stream5; hdma_dac1.Init.Channel = DMA_CHANNEL_7; hdma_dac1.Init.Direction = DMA_MEMORY_TO_PERIPH; hdma_dac1.Init.PeriphInc = DMA_PINC_DISABLE; hdma_dac1.Init.MemInc = DMA_MINC_ENABLE; hdma_dac1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_dac1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_dac1.Init.Mode = DMA_CIRCULAR; // 关键!无限循环 hdma_dac1.Init.Priority = DMA_PRIORITY_HIGH; hdma_dac1.Init.FIFOMode = DMA_FIFOMODE_DISABLE; if (HAL_DMA_Init(&hdma_dac1) != HAL_OK) { Error_Handler(); } __HAL_LINKDMA(&hdac1, DMA_Handle, hdma_dac1); }最后一句__HAL_LINKDMA是关键,它把DMA句柄和DAC外设绑定在一起,这样调用HAL_DAC_Start_DMA()时才会真正激活DMA流。
DAC:最后一步的模拟艺术
片内DAC虽然不如专用芯片快,但对于中低频应用(<100kHz)已经足够优秀。STM32F4/F7/H7 等系列集成的 12位 DAC 具备不错的线性度与噪声性能。
工作模式选择
DAC有三种主要触发方式:
| 模式 | 是否推荐 | 原因 |
|---|---|---|
| 软件触发 | ❌ | 需CPU干预,不适合连续输出 |
| 自由运行 | ❌ | 更新速率不可控,易失步 |
| 外部触发(TIM6/TIM7) | ✅✅✅ | 精确同步,适合波形输出 |
务必设置为外部触发 + 缓冲输出模式:
hdac1.Instance = DAC; HAL_DAC_Init(&hdac1); // 配置通道1 DAC_ChannelConfTypeDef sConfig = {0}; sConfig.DAC_Trigger = DAC_TRIGGER_T6_TRGO; // 使用TIM6触发 sConfig.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; HAL_DAC_ConfigChannel(&hdac1, &sConfig, DAC_CHANNEL_1);然后启动DMA传输:
HAL_DAC_Start(&hdac1, DAC_CHANNEL_1); HAL_DAC_Start_DMA(&hdac1, DAC_CHANNEL_1, (uint32_t*)&sine_table[0], TABLE_SIZE, DAC_ALIGN_12B_R);至此,整个系统进入自主运行状态:
定时器发脉冲 → DAC准备转换 → 发起DMA请求 → 数据自动写入 → 输出电压变化 → 下一拍继续……
CPU?早就去处理串口命令、LCD刷新或者休眠省电了。
实战技巧与避坑指南
纸上谈兵不够,来看看实际开发中的“秘籍”。
🎯 技巧一:合理设计波形查找表
假设你要生成 1kHz 正弦波,采样率为 10ksps,则每周期需 10个点。但太少会导致阶梯明显。
建议:
- 使用256~4096点/周期提升平滑度;
- 数据预计算并存储为const uint16_t数组,节省RAM;
- 幅度归一化到 0~4095(12位DAC满量程);
- 可加入直流偏移(如+2048)生成双极性信号。
示例生成代码(Python预处理):
import numpy as np N = 1024 sine_table = [(int(2047 * (1 + np.sin(2*np.pi*i/N))) ) for i in range(N)] print("{ " + ", ".join(map(str, sine_table)) + " }")🔇 技巧二:加一级抗混叠滤波器(Anti-Aliasing Filter)
DAC输出的是“阶梯波”,包含大量高频成分。即使你只想输出1kHz正弦波,频谱上也可能看到10kHz以上的毛刺。
解决方案:在DAC输出端加一个RC低通滤波器或二阶Sallen-Key滤波器。
例如:
- 截止频率设为 1.5 × 最大信号频率;
- 若最高输出10kHz信号,则滤波器截止设为15kHz;
- 推荐使用 1kΩ + 10nF 组合(fc ≈ 15.9kHz);
小贴士:用电压跟随器隔离滤波器与负载,避免阻抗影响截止频率。
🔄 技巧三:动态切换波形?试试双缓冲DMA!
如果想实时切换波形类型(比如按键切方波/三角波),不能直接改正在使用的数组——会导致中途错乱。
更好的做法是使用DMA双缓冲模式(Double Buffer Mode):
- 分配两块内存区域,分别存正弦波和三角波;
- 当前用A区输出,后台悄悄更新B区内容;
- 切换时通知DMA交换缓冲区;
- 实现无缝过渡,无输出中断。
HAL库支持该特性,通过回调函数HAL_DAC_ConvCpltCallbackCh1()捕获半传输或全传输事件。
💡 技巧四:提高频率分辨率的小窍门
输出频率公式为:
$$
f_{out} = \frac{f_{sample}}{N}
$$
其中:
- $ f_{sample} $:定时器触发频率(由PSC/ARR决定)
- $ N $:查找表长度
若想精细调节频率(比如从 999Hz 到 1000Hz),可通过以下方式:
- 固定采样率(如100kHz),改变表长 N;
- 或固定表长,微调ARR值实现亚赫兹步进;
- 更高级玩法:使用相位累加器(DDS思想),实现纳赫兹级分辨率。
性能表现实测参考(STM32F407VG)
| 项目 | 表现 |
|---|---|
| 最高采样率 | ~1 MSPS(受限于DAC建立时间) |
| 实际可用率 | 100–500 kSPS(配合外部滤波) |
| CPU占用率 | < 1%(仅初始化和交互) |
| 输出失真度(THD) | < 1% @ 1kHz(加滤波后) |
| 频率调节精度 | 达0.1Hz级别 |
| 支持波形类型 | 正弦、方波、三角、锯齿、任意自定义 |
注:若追求更高性能,可外接高速SPI DAC(如 AD5662、LTC2668),配合DMA+SPI实现 10MSPS 级别输出。
扩展思路:不止是信号源
这套架构潜力远不止做个函数发生器。你可以进一步扩展:
- 结合ADC:构成闭环系统,实现扫频分析仪;
- 多通道同步:用TIM8同时触发多个DAC,生成I/Q信号;
- 音频合成:加载WAV样本,实现简易音乐播放器;
- 电机控制激励:生成特定轨迹电压驱动步进电机;
- 配合FreeRTOS:在空闲任务中动态生成新波形,实现智能信号源。
写在最后
当你第一次看到DAC引脚输出一条光滑的正弦曲线,而CPU负载几乎为零时,你会明白什么叫“软硬件协同”的力量。
这个方案的价值不仅在于实现了高效波形输出,更在于它传递了一种嵌入式设计哲学:
不要让人去做机器的事,也不要让CPU去做硬件能做的事。
STM32本身就具备强大的外设联动能力,关键是懂得如何“编排”它们。定时器、DMA、DAC 的组合,正是这种自动化思维的经典体现。
如果你正在开发测试设备、工业控制器或音频模块,不妨尝试这套架构。它足够成熟、足够高效,也足够值得写进你的技术笔记里。
如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。