本篇实现效果, 预览:

目录
[三、CubeMX 配置](#三、CubeMX 配置)
[1、DAC 基础 配置](#1、DAC 基础 配置)
[2、DMA 传输 配置](#2、DMA 传输 配置)
[3、TIM 定时器 配置](#3、TIM 定时器 配置)
[四、代码实现 --- 正弦表数组法输出信号](#四、代码实现 --- 正弦表数组法输出信号)
[1、 编写可调式波形函数](#1、 编写可调式波形函数)
一、前言
使用 数模转换器 (DAC) 生成高精度模拟波形 是一项常见需求。
本文将示范一种高效、可靠的正弦波生成方案:
利用定时器 (TIM) 自动触发 DAC 转换,并由 DMA 直接搬运波形数据。
该方案能最大限度地解放CPU资源,确保输出连续、精准的正弦波信号。
为帮助读者循序渐进地掌握,教程将分为两个核心部分:
- 基础实践:通过STM32CubeMX的图形化配置,快速实现一个预设频率(10kHz)正弦波的输出,了解完整操作流程。
- 进阶应用:深入讲解波形数据表的动态生成方法与频率计算公式,实现目标频率的正弦波输出。
本文所有示例及方法均提供开源程序以供验证与参考,如有疑问或建议,欢迎交流指正。
开源程序: 【STM32 CubeMX】 DAC 输出正弦波 (已设置0积分。如发现下载需要积分,请通知修改)
实验器材:
| 实验器材 | 备注说明 | 链接 |
|---|---|---|
| STM32F407VE 开发板 | F4系列DAC共两个通道(PA4, PA5) | 【链接】 |
| 示波器 | 带宽50MHz、采样率1Gsa/s | 【链接】 |
| 探头 | 使用1X档位 | 机配 |

二、正弦波频率公式
在开始操作之前,先看看正弦波频率的计算公式:
- 正弦波频率 = TIM时钟频率 ÷ PSC ÷ ARR ÷ 波点数
|-------------|--------------------------|
| 正弦波频率 | 希望输出的正弦波频率(单位:Hz) |
| TIM时钟频率 | 触发DAC的定时器的时钟频率(单位:Hz) |
| PSC | 定时器时钟的预分频器值 |
| ARR | 定时器的自动重载值。多少个定时器脉冲触发一次事件 |
| 波点数 | 一个正弦波周期的采样点数(波形数据表的长度) |
实例计算:输出10kHz正弦波
为了让大家更直观地理解,以STM32F407为例,规划一组参数来实现 10kHz 的正弦波。
-
正弦波频率: 10,000 Hz
-
**TIM 时钟频率:**本篇使用 TIM6 。通常,F103的TIM6是72MHz, F407的TIM6是84MHz。
-
**PSC 预分频器:**通常设置为1,即不分频,以方便计算,并获得最高的触发频率。
-
**ARR 周期值:**分频后的TIM时钟,产生N个脉冲数后,触发一次DAC转换。
-
波点数:这里用128,即一个正弦波由128个样本点组成 。样本点数越多,波形越细腻。
现在,公式中的5个成员,仅剩 ARR 为未知数。
我们将数值代入公式进行计算 (正弦波频率 = TIM时钟频率 ÷ PSC ÷ ARR ÷ 波点数):
10000 = 84000000 ÷ 1 ÷ ARR ÷ 128
计算过程:ARR = 84000000 ÷ 1 ÷ 128 ÷ 10000 = 65.625
寄存器值必须是整数 ,取整 ARR = 66。
因此,实际的波形频率将是:正弦波频率 = 84000000 ÷ 1 ÷ 66 ÷ 128 = 9943Hz
这与我们的目标10kHz存在微小误差,这是由于ARR必须为整数所致。
解释、提示
设计目标是输出 10,000 Hz 的正弦波,但根据最终参数计算出的实际频率约为 9,943 Hz。
这不是错误,而是一个刻意展示的关键现象。
寄存器值必须是整数。当ARR的计算不能整除,取整后,将无法实现精准波形频率的输出!
不必担心,这种微小的误差(约0.57%)在绝大多数应用中是完全可以接受的。
下文也将详细示范如何通过联动调整样本点数(波点数)与 ARR,来寻找最优解,从而实现对特定目标频率的精准输出。
三、CubeMX 配置
本节基于一个已建立的CubeMX工程进行讲解。若需了解新建工程的基础操作,可参考:
以下将在现有工程基础上,添加正弦波生成所需的DAC、DMA 和 TIM 配置。
1、DAC 基础 配置
实现步骤:
- 使能:打勾通道
- Output Buffer:Disable 禁用
- Trigger:Timmer 6 Trigger Out event

解释、 提示
1、输出通道 OUT1、OUT2
STM32系列的大部分型号,DAC通常包含两个独立通道:
- OUT1 对应引脚 PA4
- OUT2 对应引脚 PA5
勾选需要的通道后,CubeMX 会自动配置对应引脚的工作模式,无需手动设置。
为演示方便,本示例同时启用了两个通道。
2、Output Buffer (输出缓冲器):Disable。
这个功能很有意思 。好些网上教程对此项的描述过于偏理论了。稍作解释。
把这个"输出缓冲器"理解为:在DAC核心与输出引脚之间,有一个片内集成运算放大器。
输出的信号是否启用 (经过) 这个 输出缓冲放大器,是影响输出性能的关键选择。
使能--输出缓冲 Enabled
- 优势:提供更大的驱动电流,可直接驱动如耳机、小功率扬声器等负载;
- 缺点:略有噪声、输出电压范围受限(约0.2V~3.1V)
- 适用场景:长电缆驱动、耳机、功率器件等对驱动能力要求高而对精度要求不严的场合
禁用--输出缓冲 Disabled
- 优势:噪声更低、线性度更好、输出电压范围更宽(接近0V~VDDA)
- 缺点:驱动负载时需外接运放电路
- 适用场景:高精度测量、传感器校准、示波器等需要满幅输出的应用
本篇是把信号直接输出至示波器探头,因此禁用输出缓冲器: Disabled。
3、Trigger (触发源): Timer 6 Trigger Out event。
为使DAC能自动、周期性地进行转换,需要为其配置一个硬件触发源。
此处选择 Timer 6 Trigger Out event。
触发源在项目中用得比较少,这里也稍作解释。
每当Timer的计数器达到设定值,产生"更新事件"(Update Event)时,通过内部的TRGO线路向外发送一个脉冲信号。DAC被连接至此脉冲信号后,每个脉冲都会自动启动一次DAC转换。
结合下面的DMA功能,CPU只需启动传输,即可由定时器"指挥"DMA将波形数据数组自动、同步地搬运给DAC,从而高效、精准地输出连续波形,无需CPU持续干预。
2、DMA 传输 配置
实现步骤:
- 点击Add: 添加DAC1、DAC2
- Mode: Circular
- Memory: 打勾
- 数据宽度: HalfWord
注意!别漏了添加DAC2,不然PA5会没信号输出。

解释、 提示
1、Mode 传输模式
- Circular 循环传输 --- 输出连续波形必需
- 每当 DMA 传输完指定数量的数据后,会重新、从头开始传输。这是输出连续、周期性波形 (如正弦波)的必需设置。
2、Increment Address 地址增量控制
- Peripheral 外设地址:不勾选。DAC数据寄存器是硬件,地址是固定的。
- Memory 内存地址:勾选 。波形数组地址自动递增。
3、Data Width 数据宽度
- Half Word :**半字, 16位。**DAC 的数据寄存器是 12 位,用16 位即可对齐访问。
- 程序中的波形样本数组,应定义为
uint16_t类型,与此处设置保持一致
3、TIM 定时器 配置
实现步骤:
- 使能 TIM6:勾选 Activated
- PSC (预分频器值): 1-1 (TIM的分频从0起,0即1分频,即不分频)
- ARR (周期脉冲数):66-1 (TIM计数从0起; 即66个脉冲为一周期)
- Trigger Event Selection (触发事件):Update Event (更新事件)

解释、 提示
TIM的配置,目的是将定时器配置为一个精准的"节拍器",每个"节拍"(更新事件), 触发DAC进行一次转换。
1、为什么上图里用 n-1 的写法? 而不直接写 0、65?
因为定时器硬件的计数机制是从0开始的。
寄存器值 = 期望值 - 1 。
权威解释请参阅《STM32参考手册》中关于TIM寄存器
TIMx_PSC和TIMx_ARR的详细描述。2、TIM 的使能方式
在CubeMX中,使能定时器的方式因TIM而异,常见有三种:
- Activated 复选框:勾选即可使能(TIM6、TIM7等基础定时器)
- Internal Clock 复选框:勾选即可使能内部时钟(部分通用定时器)
- Clock Source 多选框:选择"Internal Clock"选项(多数高级定时器)
对于本篇使用的TIM6 ,只需勾选Activated复选框即可完成使能。
3、预分频器 ( Prescaler, PSC )
作用:对输入时钟进行分频,得到供给计数器CNT的计数时钟。
每接收 PSC
(Prescaler+1)个时钟脉冲,才产生1个计数脉冲。在使用 TIM 触发 DAC 转换的应用中,通常将
Prescaler设置为 0(PSC=0+1,1分频即不分频)不分频的好处是简化计算,并允许定时器以最高频率运行。
4、自动重载值 (Period, ARR )
设定定时器的计数周期,是决定触发频率的关键参数。
工作原理:
- 计数器CNT从0开始,每个脉冲加1
- 当CNT值达到ARR设定值时,定时器发生溢出
- CNT自动清零,并产生一个更新事件 (Update Event)
- 此过程周而复始,形成稳定周期。
数值关系:
- CubeMX界面或代码里的Period项填写值:
66-1- 实际寄存器值:
65(第66个脉冲溢出触发更新事件)5、 触发事件选择 ( Trigger Event Selection )
选择:Update Event 更新事件
解释:此配置将定时器内部的更新事件映射到其TRGO(Trigger Output) 引脚上。每次定时器溢出产生更新事件时,都会通过TRGO线路发送一个精准的脉冲信号,用于触发DAC进行数据转换。这些操作将由硬件自动完成。
信号路径:
定时器计数溢出 → 产生更新事件 → TRGO输出脉冲 → 触发DAC转换
这种硬件级触发方式确保了转换时序的精确性和稳定性,无需CPU干预。
配置阶段已完成。
点击 "**GENERATE CODE"**按钮,令CubeMX 把 DAC、DMA 、TIM6 的初始化代码生成到工程中。
接下来,我们将进入 代码实现 部分。
四、代码实现 --- 正弦表数组法输出信号
在使用CubeMX完成DAC、DMA和TIM的配置后,用户需要编写的代码量极少。主要步骤为:
- 准备正弦波数据表(数组)
- 启动定时器(TIM)
- 启动DAC的DMA传输
1、波形样本数组
实现步骤:
- 在
/* USER CODE BEGIN PV */和/* USER CODE END PV */之间,添加正弦波数据数组。
cpp
#define WAVE_POINTS 128 // 正弦波一个周期的采样点数
/* 1、正弦波数据表 (12位DAC,数值范围0~4095)
* 使用数组法的优点:减少每次启动时的计算时间,保证波形精度
* 注意事项:若将数组定义在函数内部,建议使用static修饰,避免栈溢出
*/
static const uint16_t sineData[WAVE_POINTS] =
{
/* 0°~90° 上升段 (n=0~31) */
2048, 2148, 2248, 2348, 2447, 2545, 2642, 2737,
2831, 2923, 3013, 3100, 3185, 3267, 3346, 3423,
3495, 3565, 3630, 3692, 3750, 3804, 3853, 3898,
3939, 3975, 4007, 4034, 4056, 4073, 4085, 4093,
/* 90°~180° 下降段 (n=32~63) */
4095, 4093, 4085, 4073, 4056, 4034, 4007, 3975,
3939, 3898, 3853, 3804, 3750, 3692, 3630, 3565,
3495, 3423, 3346, 3267, 3185, 3100, 3013, 2923,
2831, 2737, 2642, 2545, 2447, 2348, 2248, 2148,
/* 180°~270° 下降段 (n=64~95) */
2048, 1947, 1847, 1747, 1648, 1550, 1453, 1358,
1264, 1172, 1082, 995, 910, 828, 749, 672,
600, 530, 465, 403, 345, 291, 242, 197,
156, 120, 88, 61, 39, 22, 10, 2,
/* 270°~360° 上升段 (n=96~127) */
0, 2, 10, 22, 39, 61, 88, 120,
156, 197, 242, 291, 345, 403, 465, 530,
600, 672, 749, 828, 910, 995, 1082, 1172,
1264, 1358, 1453, 1550, 1648, 1747, 1847, 1947
};
复制添加后,位置如下图:

解释、提示
- 数组
sineData存储了一个完整正弦周期的128个采样点,对应12位DAC的数值范围(0~4095)。- 若将数组定义在函数内部,尽量使用
static,否则数组会在栈上分配,当样本数组过大时可能导致栈溢出,并且每次函数调用都会重新初始化。- 如果想修改波点数,不能直接修改这里的宏定义,还需要波点数重新计算每一个样本值。下文中提供了计算函数。
2、启动定时器、DAC的DMA传输
实现步骤:
- 在
main函数的/* USER CODE BEGIN 2 */和/* USER CODE END 2 */之间(即在while(1)循环之前),添加以下代码:
cpp
/* 2、启动定时器 TIM6 */
HAL_TIM_Base_Start(&htim6); // 启动TIM。每个更新事件(计数达到一周期),由硬件发出脉冲信号触发DAC转换一次
/* 3、启动DAC的DMA传输 */
HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, (uint32_t *)sine12bit, WAVE_POINTS, DAC_ALIGN_12B_R); // 启动DAC通道1(对应PA4)的DMA传输
HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_2, (uint32_t *)sine12bit, WAVE_POINTS, DAC_ALIGN_12B_R); // 启动DAC通道2(对应PA5)的DMA传输
编写后,位置如下图:

解释、提示
1、HAL_TIM_Base_Start(&htim6):启动定时器TIM6
定时器开始从0计数,当计数值达到ARR(即66)时产生更新事件 。
2、HAL_DAC_Start_DMA ( ):启动DAC的DMA传输。
参数依次为:
- &hdac:DAC句柄
- DAC_CHANNNEL_1:DAC通道1(对应PA4)
- (uint32_t*)sineData:波形数据数组的首地址(需转换为uint32_t指针)
- WAVE_POINTS:传输数据个数(即数组长度)
- DAC_ALIGN_12B_R:数据右对齐,12位有效位(因为DAC数据寄存器是12位的)
注意:如果只使用一个通道(例如只使用PA4),则只需调用一次HAL_DAC_Start_DMA,并选择对应的通道。
3、运行效果
编译、烧录到STM32F407开发板,
使用示波器采集 PA5 引脚 (或PA4),即可观察到输出的10kHz正弦波。
效果如下:

解释、提示
真实频率应该是9.943KHz,不是10KHz。
上面截图显示10KHz,是因为示波器 Freq 值在小幅跳动,拍照时刚好卡点10K而已。
为什么真实频率是9.943,在上文中已有说明:
因为寄存器必须用整数,而计算结果 ARR=65.6,ARR取值66,因此无法达成10KHz。
可参考下文,通过联动调整样本点数(波点数)与 ARR,寻找最优解,实现精准输出。
五、进阶---频率调整实战
想输出其他频率,只需在核心公式中调整参数即可:
回顾公式:
- 正弦波频率 = TIM时钟频率 ÷ PSC ÷ ARR ÷ 波点数
当使用相同TIM时钟且PSC=1时,调整原则如下:
- 提高频率:减小ARR值,或 减小波点数
- 降低频率:增大ARR值,或 增加波点数
下面通过 100kHz 和 500Hz 两个实例,演示具体操作及常见误区。
1、 编写可调式波形函数
为便于调试,我们将波形生成过程封装为函数,
并把启动TIM 、启动DAC的DMA传输这两条执行语句,也封装进去,以简化调试过程。
实现步骤:
- 在 main.c 的 /* USER CODE BEGIN 0 */ 与 /* USER CODE END 0 */ 之间添加函数
cpp
/******************************************************************************
* 函数名 : DAC_SineStart
* 功 能 : 启动DAC正弦波输出 (动态计算波形表)
* 备 注 : 1. 波点数可通过宏定义WAVE_POINTS灵活调整;
* 2. 波点数的取值,建议根据 TIM时钟频率即值,如84MHz时钟下,取值21、42、84、168等,能令公式整除,以达到精准的波形频率
* 2. 输出幅值:[MIN_VALUE, MAX_VALUE](默认 0-4095)
* 3. 直流偏置:OFFSET(默认 0)
* 4. TIM6 更新事件触发,DMA 循环模式
* 5. 上电重新计算表,调试方便;若需提速,可在调试成型后改用 const 静态表
* 参 数 : 无 (由函数内宏定义控制)
* 返回值 : 无
******************************************************************************/
#include "math.h"
void DAC_SineStart(void)
{
/* 1、定义参数 */
#define WAVE_POINTS 128 // 正弦数据点数; 取值建议根据 TIM时钟频率即值,如84MHz时钟下,取值21、42、84、168等,能令公式整除,以达到精准的波形频率
#define MIN_VALUE 0 // 正弦数据最小值; 即,波底的DAC值; 取值范围:0~4095 (对应0V~3.3V); 用于适配不同设备的工作安全范围,减少硬件电路的设计修改;
#define MAX_VALUE 4095 // 正弦数据最大值; 即,波顶的DAC值; 取值范围:0~4095 (对应0V~3.3V); 用于适配不同设备的工作安全范围,减少硬件电路的设计修改;
#define OFFSET 0 // 偏移量; 控制波形的直流偏置 (垂直位置)
#define SCALE ((MAX_VALUE - MIN_VALUE) / 2.0) // 缩放系数; 控制波形的幅度 (振幅大小)
/* 2、开辟缓存 */
static uint16_t sineData[WAVE_POINTS] = { 0 }; // 存储正弦波点值
/* 3、生成无缝正弦表
计算法:上电实时算表
优点:调试阶段可随时改 WAVE_POINTS / MIN / MAX,方便灵活调试出需要的波形、振幅
缺点:每次上电运行都得跑一遍 sin(),启动时间随点数线性增加; 调试定型后建议改用 const 静态表,上电即用,零计算延时
*/
for (uint16_t i = 0; i < WAVE_POINTS; i++)
{
double x = (double)i * (2.0 * 3.14159265 / WAVE_POINTS); // 周期内均匀采样; 将索引转换为0到2π之间的值
double sinValue = sin(x); // 计算正弦值
sineData[i] = (uint16_t)(lround((sinValue + 1.0) * SCALE + OFFSET)); // 缩放和平移正弦值
}
/* 4、启动 TIM */
HAL_TIM_Base_Start(&htim6); // 启动TIM。每个更新事件(计数达到一周期),由硬件发出脉冲信号触发DAC转换一次
/* 5、启动DAC的DMA传输 */
HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, (uint32_t *)sineData, WAVE_POINTS, DAC_ALIGN_12B_R); // 启动DAC通道1的DMA传输,数据输出至PA4引脚
HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_2, (uint32_t *)sineData, WAVE_POINTS, DAC_ALIGN_12B_R); // 启动DAC通道2的DMA传输,数据输出至PA5引脚
}
解释、提示
- sin() 和 lround() 需要添加文件引用: #include "math.h"
- 波点样本数,为了示范,也用 128 个波点数。
- 其余几个参数用于限制振幅,以适用于不同的方案场景,本篇不述。
- 使用这种实时计算波点样本,可便于调试。定型后改为静态const数组能提升性能。
编写后,位置如下图:

实现步骤:
- 在main函数的初始化后、while之前, 调用刚才编写的函数
cpp
DAC_SineStart(); // 启动波形输出
编写完成后,位置如下:

2、实例一:输出100kHz正弦波(高频挑战)
初次尝试。
使用100KHz、128个样本点。
参数计算: (TIM6时钟=84MHz、PSC=1、波点数=128、波形频率=100000)
得到 ARR = 84000000 ÷ 1 ÷ 128 ÷ 100000 = 6.56
寄存器必须是整数,取整后 ARR = 7。
如何修改ARR的值呢?两种操作方法:
- CubeMX修改:打开工程文件夹的ioc文件进入CubeMX,修改TIM的period值为 7-1,生成。
- 代码修改:在keil里双击打开 tim.c 文件,找到MX_TIM6_Init()函数,修改 Period = 7-1
建议使用修改代码的方法,更省工夫。
操作步骤:
- 打开tim.c文件,找到TIM的初始化函数:MX_TIM6_Init( )
- 修改 TIM 初始化中的 htim6.Init.Period = 7-1

修改完成 。
编译、烧录,运行效果如下图:
示波器实测,只有46.7KHz, 远远达不到100Khz!

问题根源 :DAC转换时间不足!
在调试摸索中总结:F407 的 DAC 的转换时间需90ns。
而当前的触发周期:7 ÷ 84MHz ≈ 83ns < 90ns
触发过快 导致了转换错误!
疑惑
这个转换时间的测试数据,与芯片手册中的理论转换时间,差距甚大。
若有朋友知道其中原因,望分享。
正确方案:减少采样点数、加大ARR值
将采样点由128减至42。
参数计算: (TIM6时钟=84MHz、PSC=1、波点数=42、波形频率=100000)
ARR = 84000000 ÷ 1 ÷ 42 ÷ 100000 = 20
操作步骤:
- 修改 WAVE_POINTS 宏值 为 42
- 修改 TIM 初始化中的 htim6.Init.Period = 20-1
编写完成后,如下图:

编译、烧录,实现效果如下图:

解释、提示
1、关于波点数、ARR的取值技巧
为了能精准地实现波形频率,关键在于公式中的 ARR 能被整除。
举例,如F4的TIM6,时钟频率是84MHz,波点数取 21、42、84、168等, ARR容易被整除。
2、关于波形频率的上限
细心的朋友应该发现,上图中,波顶和波底,没有达到满幅的3.3V、0V。
这是因为波点数太少了。增大波点数,如168,可令信号无限接近满幅。
但是,这又回到头痛的问题,增大波点数,需要减小ARR值。而当F407的168MHz时钟下,务必注意 ARR >= 8,即 触发时间 > 90ns,否则转换出错。
补充:有些朋友咨询,如何实现1M、4M的波形。不能!!上面100K已经不能满幅了!!
3、实例二:输出500Hz正弦波(低频实现)
高频应用关注转换速度极限,而低频实现则更侧重于波形精度与平滑度。
下面以输出500Hz正弦波为例,展示不同采样点数对波形质量的影响及参数调整方法。
参数计算 :(TIM6时钟=84MHz、PSC=1、波点数=42个、波形频率=500Hz)
ARR = 84000000 ÷ 1 ÷ 42 ÷ 500 = 4000
实现步骤:
- 修改 WAVE_POINTS 宏值为 42
- 修改 TIM 初始化中的 htim6.Init.Period = 4000-1

编译、烧录运行后,波形效果如下图:
可生成500Hz正弦波,但波形呈明显阶梯状 ,平滑度差。
这是因为每个周期仅用42个点来描绘,样本点数过低,导致波形失真严重,无法满足大多数对质量有要求的应用。

改进:增加点数至168点的平滑实现
为提高波形质量,将采样点数增加至168点。
参数计算(TIM6时钟84MHz、PSC=1、波点数168个、波形频率500Hz):
得到 ARR = 84000000 ÷ 168 ÷ 500 = 1000
实现步骤:
- 修改 WAVE_POINTS 宏值为 168
- 修改 TIM 初始化中的 htim6.Init.Period = 1000-1

输出效果,如下图:
增加点数后重新运行,波形平滑度得到显著改善。虽然会略微增加内存占用,但对于低频应用,这是提升输出信号质量的必要代价。

至此,我们已完整演示了从固定数组 到动态计算 、从10kHz 到100kHz 高频挑战及500Hz 低频实现的全过程。通过调整采样点数(N) 与定时器周期(ARR) 的配比,即可灵活生成不同频率与精度的正弦波。
教程中的方法、公式及代码均通过实际硬件验证。如有任何疑问或发现错漏,欢迎指正与交流。