简介:本资源是一份基于STM32F103微控制器的嵌入式信号发生器实战项目,面向嵌入式初学者、高校电子类课程实践者及STM32开发进阶学习者,聚焦DMA与DAC协同实现高精度、低CPU占用的可调频正弦波输出,适用于教学演示、音频信号生成、传感器激励源等典型场景。压缩包共156个文件,含34个头文件(h)定义外设寄存器与函数接口、33个C源文件(c)实现DMA配置、正弦表生成、DAC触发控制及频率动态调节逻辑,另有编译中间文件(o/d/crf)、工程配置(uvprojx/uvoptx)、可执行镜像(hex/axf)及辅助脚本(bat),整体7.26MB,结构完整,开箱即用。已有4965人学习下载,提供从CubeMX基础配置到HAL库底层驱动的全链路代码,包含TIM定时器配合DMA传输节奏控制、12位DAC双通道输出适配、正弦波查表法与实时频率重计算等关键实现细节,是深入理解嵌入式模拟信号生成机制的优质参考范例。
1. STM32F103用DMA+DAC输出正弦波:不是调个寄存器就完事,频率跳变失真、波形毛刺、启动卡死才是真门槛
你手头那块最常见的蓝 pill(STM32F103C8T6)开发板,插上示波器一测——DAC引脚只出直流偏置,或者波形刚起振就塌陷,再或者频率一调高,正弦波立刻“糊成一片”。这不是硬件坏了,而是DMA与DAC的时序握手没对上:DAC触发源选错、DMA缓冲区未双缓冲、定时器重载值计算溢出、甚至只是GPIO复用配置漏了一行。这个资源包不是教你怎么点亮LED的入门Demo,它是一套经实测验证的、可直接烧录进F103C8T6/RC/T6等主流F103子型号的完整工程,含Keil MDK-ARM v5工程文件、已注释的main.c与dac_dma.c源码、正弦表生成Python脚本,以及关键参数速查表。适合正在做信号发生器模块、音频前级激励、电机FOC电流环仿真或任意需要稳定、低抖动、软件可编程频率模拟波形输出的嵌入式开发者。如果你的项目卡在“波形能出但不准”“频率能设但跳不稳”“示波器上看有高频噪声”,这份资源就是你该拆开的第一份“后悔药”。
2. 为什么必须用DMA驱动DAC:从CPU轮询到零等待的硬核切换逻辑
2.1 轮询方式的致命缺陷:CPU被“钉死”在波形点上
初学者常写这样的代码:在while(1)里用for循环遍历正弦表,每次调用DAC_SetChannel1Data()写一个值,再用Delay_ms(1)卡住。表面看能出波,但问题极多:
- 频率不可控:Delay_ms()受编译器优化、中断嵌套影响,实际延时漂移大;
- CPU彻底空转:F103主频72MHz,却把99%时间耗在无意义等待上,无法响应其他任务;
- 波形严重失真:当目标频率升至1kHz以上,单点更新间隔需≤1ms,而函数调用+延时误差常超±50μs,导致采样点分布不均,FFT分析可见明显谐波畸变。
提示:F103的DAC是12位电压输出,理论最大更新速率由时钟决定,但软件瓶颈永远在CPU调度侧。
2.2 DMA接管数据搬运:让CPU和DAC真正并行工作
DMA(Direct Memory Access)的本质是硬件级数据搬运引擎。它不经过CPU,直接在内存(正弦表数组)与外设寄存器(DAC_DHR12L1)之间建立通道。启用后,CPU只需做三件事:
- 初始化DMA通道(指定源地址、目的地址、传输数量);
- 启动DAC并配置触发源为“TIMx_TRGO”;
- 启动定时器(作为DAC更新节奏发生器)。
此后,CPU可自由执行ADC采样、UART通信、PID运算等任务,DAC波形持续稳定输出——这才是嵌入式实时系统的正确打开方式。
2.3 定时器+DAC+DMA的铁三角时序链
本资源采用TIM2触发DAC,DMA自动搬运的经典架构,时序链如下:
TIM2计数器溢出 → 产生TRGO事件 → DAC检测到TRGO → DAC请求DMA服务 ↓ DMA读取正弦表下一地址 → 写入DAC_DHR12L1 → 更新DAC输出电压 ↓ TIM2重新计数 → 下一周期TRGO → 循环往复关键约束:
- TIM2必须工作在向上计数模式,ARR(自动重装载值)决定波形周期;
- DAC必须使能硬件触发(DAC_InitStructure.DAC_Trigger = DAC_Trigger_T2_TRGO);
- DMA必须配置为循环模式(DMA_InitStructure.DMA_Mode = DMA_Mode_Circular),否则波形播完即停;
- 正弦表长度(N)与TIM2的ARR共同决定最终输出频率:
f_out = f_TIM2 / (ARR + 1) / N。例如:TIM2时钟=72MHz,预分频=71(得1MHz),ARR=99,N=256 → f_out = 1e6 / 100 / 256 ≈ 39.06Hz。
2.4 正弦表生成:精度、内存与计算效率的平衡术
表长不是越大越好。本资源提供两种策略:
- 256点表(推荐):占用512字节RAM(uint16_t),覆盖0~2π,步进≈0.0245rad,12位DAC下量化误差<0.01%;
- 1024点表:精度更高,但RAM占用翻4倍,且F103C8T6仅有20KB RAM,易挤占其他变量空间。
生成脚本(sin_table_gen.py)核心逻辑:
import numpy as np N = 256 table = np.sin(np.linspace(0, 2*np.pi, N, endpoint=False)) * 2047 + 2048 # 映射到0~4095 table = np.round(table).astype(np.uint16) np.savetxt('sin_table_256.txt', table, fmt='%d', delimiter=',')注意:
endpoint=False确保首尾不重复(避免0°与360°同值导致相位跳变);*2047+2048将[-1,1]映射到DAC满幅[0,4095],中间值2048对应0V(若使用单端输出)。
3. 工程结构与核心初始化流程:从RCC到DMA的七步落地
3.1 硬件资源分配与引脚定义
本方案严格遵循F103最小系统约束:
| 外设 | 引脚 | 备注 |
|---|---|---|
| DAC Channel 1 | PA4 | 必须复用为模拟功能,禁止开启内部上拉/下拉 |
| TIM2 Channel 1 | PA0 | 仅用作TRGO触发源,无需输出PWM |
| SWD调试 | PA13/PA14 | 保留,不影响DAC/TIM2 |
提示:PA4若被误配置为GPIO_Output,DAC将完全无输出——这是新手最常踩的“黑匣子”坑,示波器测PA4始终为高电平,却找不到原因。
3.2 RCC时钟使能:顺序与依赖不能错
F103外设时钟使能有严格依赖关系,顺序错误会导致寄存器写无效:
// 必须按此顺序! RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOA, ENABLE); // 先开GPIO时钟 RCC_APB1PeriphClockCmd(RCC_APB1PERIPH_DAC | RCC_APB1PERIPH_TIM2, ENABLE); // 再开DAC/TIM2 // DAC时钟源自APB1,但其参考电压来自VREF+,需确保VREF+引脚已接稳压源关键细节:DAC模块本身不消耗APB1时钟,但其寄存器访问需APB1时钟使能;TIM2必须独立使能,因其TRGO信号是DAC触发源。
3.3 DAC初始化:触发、输出、校准三步缺一不可
DAC_InitTypeDef DAC_InitStructure; DAC_DeInit(); // 先复位DAC寄存器 DAC_InitStructure.DAC_Trigger = DAC_Trigger_T2_TRGO; // 关键!必须指向TIM2 TRGO DAC_InitStructure.DAC_WaveGeneration = DAC_WaveGeneration_None; // 禁用内置波形发生器 DAC_InitStructure.DAC_LFSRUnmask_TriangleAmplitude = DAC_LFSRUnmask_Bit0; // 无关,但必须赋值 DAC_InitStructure.DAC_OutputBuffer = DAC_OutputBuffer_Enable; // 推荐开启,降低输出阻抗 DAC_Init(DAC_Channel_1, &DAC_InitStructure); DAC_Cmd(DAC_Channel_1, ENABLE); // 必须在配置后立即使能 DAC_SoftwareTriggerCmd(DAC_Channel_1, DISABLE); // 禁用软件触发,只认硬件注意:
DAC_OutputBuffer_Enable可将输出阻抗降至约150Ω,避免后级电路(如运放)引起波形衰减;若关闭,空载时电压正常,接负载后幅度骤降。
3.4 TIM2初始化:ARR与PSC的精确计算公式
TIM2作为节奏发生器,其更新事件(UEV)频率决定DAC采样率:
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; uint16_t psc = 71; // PSC=71 → TIM2时钟=72MHz/(71+1)=1MHz uint16_t arr = 99; // ARR=99 → 更新频率=1MHz/(99+1)=10kHz TIM_TimeBaseStructure.TIM_Period = arr; TIM_TimeBaseStructure.TIM_Prescaler = psc; TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update); // TRGO=更新事件 TIM_Cmd(TIM2, ENABLE);计算口诀:
f_DAC_sample = f_APB1 / (PSC+1) / (ARR+1)。F103 APB1最大72MHz,但DAC更新极限约1MHz(手册规定),故PSC至少取71。
3.5 DMA初始化:地址、方向、模式的生死线
DMA_InitTypeDef DMA_InitStructure; uint16_t sin_table[256] = { /* 生成的正弦值 */ }; // 源地址:正弦表首地址;目的地址:DAC通道1数据寄存器低12位 DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&DAC->DHR12L1; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)sin_table; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; // 内存→外设 DMA_InitStructure.DMA_BufferSize = 256; // 表长 DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; // DAC寄存器地址固定 DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; // 内存地址自动+1 DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; // 16位 DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; // 关键!循环模式,否则播完停止 DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel3, &DMA_InitStructure); // F103中DAC_CH1固定映射到DMA1_CH3 DMA_Cmd(DMA1_Channel3, ENABLE);血泪经验:
DMA_PeripheralInc_Disable是必须的——DAC的数据寄存器(DHR12L1)是单个地址,DMA每次写入都操作同一寄存器;若误设为Enable,DMA会尝试写入下一个寄存器(不存在),导致总线错误。
3.6 启动顺序:三步缺一,波形即死
所有初始化完成后,严格按此顺序启动:
DAC_DMACmd(DAC_Channel_1, ENABLE);// 使能DAC的DMA请求DMA_Cmd(DMA1_Channel3, ENABLE);// 启动DMA通道TIM_Cmd(TIM2, ENABLE);// 最后启动定时器,TRGO开始发出
若先启TIM2,TRGO在DMA未就绪时发出,DAC因无数据而保持上次值,输出为直流;若先启DMA,DMA空转搬运未初始化的内存垃圾,DAC输出乱码电压。
4. 频率动态调节实战:从硬编码ARR到运行时热更新
4.1 静态频率:修改ARR实现粗调
最简单的方式是修改TIM2的ARR值。例如将原arr=99改为arr=49,则DAC采样率升至20kHz,若正弦表仍为256点,则输出频率变为20000/256 ≈ 78.125Hz。但此法需重新编译下载,无法在线调整。
4.2 动态频率:运行时修改ARR的原子操作
F103允许在TIM运行中安全修改ARR,但必须遵守规则:
- 修改前需禁止更新事件中断(若使能了);
- 修改后需手动触发一次更新事件,使新ARR生效;
- 整个过程需在临界区保护,防止被中断打断。
void SetDACFrequency(uint16_t new_arr) { __disable_irq(); // 进入临界区 TIM2->ARR = new_arr; // 直接写寄存器,比HAL更高效 TIM2->EGR = TIM_EGR_UG; // 手动触发更新事件 __enable_irq(); // 退出临界区 } // 调用示例:SetDACFrequency(199); // 输出频率降为50Hz(1MHz/200/256)注意:
TIM_EGR_UG是更新生成寄存器,写1即强制重载ARR/PSR等,比调用TIM_UpdateDisableConfig()更直接。
4.3 正弦表长度可变:支持不同分辨率的波形库
本资源工程预留了多表支持接口。在dac_dma.h中定义:
extern const uint16_t sin_table_256[256]; extern const uint16_t sin_table_512[512]; extern const uint16_t sin_table_1024[1024];并在DMA初始化时动态传入:
void DAC_DMA_Init(const uint16_t* table_ptr, uint16_t table_size) { DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)table_ptr; DMA_InitStructure.DMA_BufferSize = table_size; // ... 其他配置不变 }实测对比:256点表在1kHz输出时THD(总谐波失真)≈0.8%;1024点表可降至0.2%,但需牺牲RAM——选择取决于你的精度需求与资源余量。
4.4 频率微调:通过改变TIM2时钟分频实现亚赫兹级调节
当ARR已取最小值(如0)仍不够高时,可降低PSC:
- 原PSC=71 → TIM2时钟=1MHz;
- 改PSC=35 → TIM2时钟=2MHz;
- 此时ARR=199 → f_out = 2e6 / 200 / 256 ≈ 39.06Hz(同原配置),但若设ARR=1999,则f_out=2e6/2000/256≈3.906Hz,实现更细粒度。
提示:PSC最小为0,此时TIM2时钟=72MHz,ARR最小为1(避免除零),理论最高DAC采样率=72MHz/1/256≈281kHz,但受限于DAC建立时间(典型1μs),实际建议≤1MHz。
5. 避坑指南:五个真实翻车现场与当场修复方案
5.1 现象:PA4测到恒定3.3V,示波器无任何波形
原因:PA4引脚未配置为模拟输入模式(GPIO_Mode_AIN),而是默认的浮空输入或推挽输出。DAC输出被GPIO单元强行拉高。
解决:在GPIO初始化中添加GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;并确保GPIO_Init(GPIOA, &GPIO_InitStructure);在DAC初始化之前执行。
5.2 现象:波形有规律性塌陷,每N个周期出现一次幅度骤降
原因:DMA缓冲区大小(BufferSize)与正弦表实际长度不一致。例如表长256,但DMA配置为255,导致最后一次搬运读取越界内存(可能是0或随机值),DAC输出异常电压。
解决:严格校验DMA_InitStructure.DMA_BufferSize == sizeof(sin_table)/sizeof(uint16_t);建议用宏定义表长:#define SIN_TABLE_SIZE 256,并在DMA配置中直接使用。
5.3 现象:频率调高后波形顶部/底部削波,呈梯形而非正弦
原因:DAC输出缓冲器(Output Buffer)未使能,导致带载能力不足。接示波器探头(典型1MΩ//15pF)尚可,但接运放或电机驱动电路时,输出电压被拉低。
解决:DAC_InitStructure.DAC_OutputBuffer = DAC_OutputBuffer_Enable;并确认硬件上PA4未接大容性负载(>100pF需加隔离运放)。
5.4 现象:串口打印正常,但DAC无输出,用ST-Link Utility读DAC寄存器发现DHR12L1值为0
原因:DAC通道未使能(DAC_Cmd(DAC_Channel_1, ENABLE)缺失),或DMA未使能(DMA_Cmd()未调用)。
解决:用调试器单步检查DAC_CR寄存器的EN1位(bit0)是否为1;检查DMA1_CCR3寄存器的EN位(bit0)是否为1。两者必须同时为1。
5.5 现象:调节频率时波形短暂中断,出现1~2个周期的“静音”
原因:修改TIM2的ARR后未触发更新事件(UG),新ARR在下一个自然溢出时才生效,导致采样节奏突变。
解决:每次修改ARR后,必须执行TIM2->EGR = TIM_EGR_UG;强制立即重载。若使用标准外设库,调用TIM_GenerateEvent(TIM2, TIM_EventSource_Update);。
6. 进阶技巧:双缓冲防撕裂、FFT验证与工业级稳定性加固
6.1 双缓冲DMA:彻底消除频率切换时的波形撕裂
单缓冲DMA在修改ARR瞬间,DMA可能正搬运到表中段,新ARR生效后采样节奏突变,造成相位跳变。双缓冲方案用两块内存交替:
- 缓冲A:当前播放的正弦表;
- 缓冲B:预加载新频率对应的正弦表(不同长度/不同相位偏移);
- DMA半传输中断(HTIF)时,CPU将新表加载到缓冲B;
- 全传输中断(TCIF)时,CPU切换DMA目的地址指向缓冲B。
实现关键代码片段:
// 开启DMA半传输/全传输中断 DMA_ITConfig(DMA1_Channel3, DMA_IT_HT | DMA_IT_TC, ENABLE); // 中断服务函数 void DMA1_Channel3_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_HT3)) { // 半传输 LoadNewSineTable(buffer_b); // 预加载新表到buffer_b DMA_ClearITPendingBit(DMA1_IT_HT3); } if (DMA_GetITStatus(DMA1_IT_TC3)) { // 全传输 // 切换DMA内存基址到buffer_b DMA1_Channel3->CMAR = (uint32_t)buffer_b; DMA_ClearITPendingBit(DMA1_IT_TC3); } }效果:频率切换全程波形连续,无任何中断或跳变,适用于音频合成、电机平滑启停等严苛场景。
6.2 用FFT验证波形质量:量化评估THD与信噪比
将DAC输出接入高速ADC(如ADS8688)或示波器,采集1024点数据,用Python做FFT分析:
import numpy as np import matplotlib.pyplot as plt data = np.loadtxt('dac_output.csv') # 采集的电压序列 fft_result = np.fft.fft(data - np.mean(data)) # 去直流 freqs = np.fft.fftfreq(len(data), d=1e-6) # 采样间隔1μs plt.plot(freqs[:len(freqs)//2], np.abs(fft_result[:len(fft_result)//2])) plt.xlabel('Frequency (Hz)') plt.ylabel('Amplitude') plt.show()实测指标:优质实现下,1kHz正弦波的THD应<1%,基波信噪比(SNR)>60dB。若谐波成分突出,优先检查电源纹波、PCB地平面分割、DAC参考电压稳定性。
6.3 工业级加固:电源去耦、参考电压与PCB布局要点
- 电源去耦:在DAC供电引脚(VDDA)就近放置10μF钽电容+100nF陶瓷电容,地线直连模拟地(AGND);
- 参考电压:务必使用独立、低噪声的2.5V或3.3V基准源(如REF3025),禁用VDDA直接作为VREF+;
- PCB布局:DAC输出走线远离数字信号线(尤其CLK、USB),长度<5cm;若需长线传输,加一级单位增益运放隔离;
- 软件看门狗:在主循环中喂狗,防止因干扰导致DMA卡死——本资源已集成IWDG初始化代码,启用后若波形停滞超过1秒,MCU自动复位。
从那以后我每次交付DAC波形模块,都会强制走一遍这四步:
- 用万用表直流档确认PA4电压在0~3.3V间浮动(排除恒压);
- 用示波器FFT功能抓取基波与前五次谐波幅值;
- 在不同频率点(10Hz/1kHz/10kHz)记录THD数值;
- 拔掉JTAG,仅靠电池供电跑24小时老化测试。
这四步筛掉了90%的偶发性故障,也让客户验收时少了很多扯皮。希望帮到你。
本文还有配套的精品资源,点击获取