1. 从数字到模拟的桥梁:为什么需要DAC?
在嵌入式开发里,我们经常和数字信号打交道,比如用STM32的GPIO输出高低电平控制LED,或者用PWM模拟一个电压去驱动电机。但有时候,我们需要的是一个实实在在的、连续变化的电压,比如你想让一个喇叭发出特定频率和音量的声音,或者想生成一个标准的正弦波信号给其他电路用。这时候,GPIO的0和1、PWM的占空比就显得不够“细腻”了。
这就是DAC(数模转换器)出场的时候。简单来说,DAC干的活儿就是把单片机里那一串串冰冷的数字代码,变成电路板上真实、平滑的电压。你写个数字4095给DAC,它可能就在输出引脚上给你一个3.3V;你写个0,它就输出0V;你写个2048,它可能就输出一个1.65V。这个过程是连续的,理论上,在它的精度范围内,你可以输出任意电压值。
我最早接触DAC是为了做一个简单的信号发生器。当时想测试一个模拟电路的频率响应,手头没有专业的信号源,就琢磨着用STM32来产生。一开始用PWM加RC滤波,效果很差,波形毛刺多,频率和幅度调整也不灵活。直到用上了片内的DAC,问题才迎刃而解。STM32F103C8T6这款经典的“蓝桥杯”神器,就内置了2个12位的DAC通道,虽然算不上高精度,但对于很多音频生成、波形输出、电压基准的应用来说,已经足够好用且成本极低。
2. STM32F103的DAC模块架构与核心配置
STM32F103的DAC模块相对简洁,理解它的工作模式是灵活运用的前提。它不是独立运行的,需要配合定时器、DMA等外设才能发挥最大效能。
2.1 DAC的核心功能框图与数据通路
STM32F103的DAC是12位分辨率,电压输出范围一般是0到Vref+(通常接VDDA,即模拟电源,典型值3.3V)。它有两条输出通道:DAC_OUT1 (PA4) 和 DAC_OUT2 (PA5)。这里有个关键点:一旦使能了DAC通道,对应的GPIO引脚(PA4/PA5)会自动被配置为模拟模式,你不需要也不应该再去手动配置这些引脚的模式寄存器。
数据怎么送到DAC呢?有三种方式:
- 软件触发:你直接往
DAC_DHRx(数据保持寄存器)写一个12位的数字,然后手动设置一个软件触发位,DAC就会开始转换。这种方式最简单,但速度慢,全靠CPU。 - 定时器触发:这是最常用的方式。你可以配置一个定时器(如TIM2, TIM4, TIM6, TIM7等)产生更新事件,这个事件会自动触发DAC进行转换。这样就能以固定的、精确的频率更新DAC输出值,从而生成稳定的波形。
- 外部引脚触发:用外部引脚(EXTI线9)的上升沿来触发,用得比较少。
对于生成连续波形,定时器触发+DMA是黄金组合。定时器负责定节奏,DMA负责自动从内存(比如一个存储了正弦波数据的数组)搬运数据到DAC的数据寄存器。整个过程无需CPU干预,可以输出非常纯净、稳定的模拟信号。
2.2 关键配置寄存器详解与代码实现
我们以使用DAC通道1(PA4),由TIM6触发,并使用DMA传输数据来生成一个1kHz正弦波为例,拆解配置过程。假设系统主频72MHz。
第一步:开启外设时钟这是最容易忘记的一步。DAC、对应的GPIO、触发用的定时器、DMA,它们的时钟都必须开启。
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_DAC, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM6, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA2, ENABLE); // DAC1的DMA请求在DMA2通道3第二步:配置GPIO虽然DAC会使能模拟模式,但好的习惯是先初始化引脚为模拟输入,这可以降低功耗。
GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; // 模拟输入模式 GPIO_Init(GPIOA, &GPIO_InitStructure);第三步:配置定时器TIM6作为触发源我们的目标是1kHz正弦波。假设一个正弦波周期采样100个点,那么DAC的更新频率就需要是100kHz。TIM6的时钟来自APB1,频率是36MHz(72MHz/2)。
- 定时器频率:
TIM6_CLK = 36MHz - 需要产生100kHz的更新事件,那么定时器的计数频率就应该是100kHz。
- 因此,预分频器(PSC)设置为:
36MHz / 100kHz - 1 = 359 - 自动重装载值(ARR)设置为0即可,因为我们需要的是每次溢出都产生更新事件来触发DAC。更常见的做法是设置ARR=0,然后通过调整PSC来改变频率。但更精确的控制是设置ARR为一个固定值(比如1),然后计算PSC。
更通用的计算:Update_Freq = TIM_CLK / ((PSC+1)*(ARR+1))。如果我们设ARR=0,那么公式简化为Update_Freq = TIM_CLK / (PSC+1)。所以PSC = TIM_CLK / Update_Freq - 1 = 36000000 / 100000 - 1 = 359。
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Period = 0; // 自动重装载值 TIM_TimeBaseStructure.TIM_Prescaler = 359; // 预分频值 TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM6, &TIM_TimeBaseStructure); TIM_SelectOutputTrigger(TIM6, TIM_TRGOSource_Update); // 设置更新事件为触发输出 TIM_Cmd(TIM6, ENABLE);第四步:配置DMADMA负责把内存里的波形数据数组,自动搬运到DAC的DHR12R1寄存器(12位右对齐数据保持寄存器)。
- 外设地址:
DAC_DHR12R1的地址。 - 内存地址:我们定义好的正弦波数组
Sine12bit的首地址。 - 数据传输方向:内存到外设。
- 数据宽度:半字(16位),因为DAC数据寄存器是12位,按16位访问。
- 循环模式:必须使能,这样波形才能循环播放。
- 外设地址不增量,内存地址增量。
uint16_t Sine12bit[100]; // 稍后填充这个数组 DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA2_Channel3); // DAC1对应DMA2通道3 DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&DAC->DHR12R1; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)Sine12bit; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; // 内存到外设 DMA_InitStructure.DMA_BufferSize = 100; // 数组长度 DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; 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(DMA2_Channel3, &DMA_InitStructure); DMA_Cmd(DMA2_Channel3, ENABLE);第五步:配置并启动DAC这是最后一步,也是最核心的一步。需要配置DAC通道,选择触发源,并使能DMA请求。
DAC_InitTypeDef DAC_InitStructure; DAC_InitStructure.DAC_Trigger = DAC_Trigger_T6_TRGO; // 使用TIM6作为触发源 DAC_InitStructure.DAC_WaveGeneration = DAC_WaveGeneration_None; // 不使用内置波形生成 DAC_InitStructure.DAC_OutputBuffer = DAC_OutputBuffer_Enable; // 使能输出缓冲,驱动能力更强 DAC_Init(DAC_Channel_1, &DAC_InitStructure); // 使能DAC通道1的DMA请求 DAC_DMACmd(DAC_Channel_1, ENABLE); // 使能DAC通道1 DAC_Cmd(DAC_Channel_1, ENABLE);注意:
DAC_OutputBuffer(输出缓冲)是否使能需要根据负载决定。使能后,DAC能输出更大电流(典型值几个mA),直接驱动轻负载(如运放同相端)。如果负载很重或对精度要求极高(缓冲器有微小偏移),可以考虑禁用缓冲,但必须外接高输入阻抗的运放做缓冲。
第六步:生成正弦波数据并启动在程序初始化部分,我们需要计算出一个周期的正弦波数据。DAC是12位,输出范围0-3.3V。如果我们想得到幅值为1.65V(即0V到3.3V的中点)的正弦波,那么数据应该在2048±2047之间变化(因为4095/2≈2048)。
for(int i=0; i<100; i++) { // 计算0-2π之间的正弦值(-1 到 1) float sinVal = sin(2 * 3.1415926 * i / 100); // 将正弦值缩放到0-4095范围,并偏移到以2048为中心 Sine12bit[i] = (uint16_t)(2048 + 2047 * sinVal); }最后,一旦TIM6启动,DAC使能,DMA就会开始工作,PA4引脚上就会出现一个漂亮的1kHz正弦波。
3. 精度、噪声与性能提升实战技巧
STM32F103的DAC标称12位,但实际有效位数(ENOB)往往达不到。输出精度和噪声水平受电源、PCB布局、软件配置影响很大。下面分享几个实测中提升性能的关键点。
3.1 电源与参考电压的净化
这是影响DAC精度的头号因素。STM32F103的DAC参考电压Vref+内部连接到VDDA(模拟电源)。VDDA和VSSA(模拟地)必须与数字电源VDD和VSS分开处理。
- 独立供电与滤波:理想情况下,
VDDA应由一个独立的LDO(低压差线性稳压器)供电。如果与数字电源共用,必须在靠近芯片的VDDA引脚处放置一个磁珠或0欧电阻进行隔离,并配合一个10uF的钽电容和一个100nF的陶瓷电容进行去耦。VSSA必须通过最短的路径连接到模拟地平面。 - 参考电压的稳定性:即使
VDDA很干净,如果数字部分负载剧烈变化(比如GPIO频繁翻转,无线模块发射),也会通过芯片衬底耦合噪声到DAC。一个有效的办法是使用外部参考电压芯片(如REF1933,输出3.3V),将其输出连接到Vref+引脚(如果芯片有独立引出的话)。对于STM32F103,多数型号Vref+内部连VDDA,所以提升VDDA质量是唯一途径。
我在一个音频项目里就吃过亏。最初VDDA和VDD直接连在一起,只用了一个共用的稳压芯片。当CPU全速运行并频繁操作FSMC总线时,用示波器能看到DAC输出的直流电平上有几十毫伏的毛刺。后来用了一个简单的LC(磁珠+电容)隔离了模拟电源,毛刺幅度立刻降低了70%以上。
3.2 输出缓冲与负载匹配
DAC内置的输出缓冲放大器不是万能的。它的主要参数:
- 输出阻抗:使能缓冲后很低,通常在几十欧姆级别。
- 驱动能力:典型值±5mA。
- 压摆率:有限,意味着输出快速变化的电压时,波形边缘会变圆滑,高频分量会衰减。
如果你的负载是:
- 高阻抗负载(>10kΩ):比如运放的输入端。直接连接即可,缓冲使能或禁用影响不大,但使能缓冲可以提供短路保护。
- 低阻抗负载:比如直接驱动一个耳机(32Ω)。绝对不要这么做!这会严重过载DAC,导致输出电压不准、芯片发热甚至损坏。必须外接一个运算放大器作为电压跟随器或功率放大器。
- 容性负载:比如长导线带来的寄生电容。这可能导致缓冲器振荡。可以在DAC输出端串联一个小的电阻(如10-100Ω),再连接到负载,这能有效抑制振荡。
3.3 软件配置中的“坑”与优化
- 数据对齐问题:DAC的数据寄存器有
DHR12R1(12位右对齐)、DHR12L1(12位左对齐)、DHR8R1(8位右对齐)。如果你用12位数据,务必写入正确的寄存器。使用DMA时,外设地址一定要对应你选择的对齐方式。右对齐是最常用的。 - 触发与DMA的启动顺序:一个常见的错误是波形输出不连续或卡顿。正确的启动顺序是:
- 填充好波形数据数组。
- 配置并使能DMA。
- 配置并使能DAC(此时DAC等待触发)。
- 最后启动定时器(开始产生触发信号)。 如果顺序乱了,可能导致DMA在DAC准备好之前就开始传输,或者DAC触发了几次后DMA才就绪,造成波形开头缺失。
- 双DAC同步输出:如果你需要两个通道输出同步的波形(比如立体声音频),要使用同一个定时器作为两个DAC的触发源,并且确保两个DAC的配置(触发方式、缓冲设置)完全一致。然后使用
DAC_DualSoftwareTriggerCmd或让定时器同时触发两个通道。
4. 超越基础:DAC的高级应用场景与方案选型
掌握了基础输出后,我们可以用DAC做更多有趣的事情。但也要清楚STM32F103片内DAC的局限,知道什么时候该寻求外部方案。
4.1 波形合成与任意信号发生
通过改变内存中的数据数组,我们可以生成任意波形,而不仅仅是正弦波。比如方波、三角波、锯齿波,甚至是复杂的调制波形。关键在于波形表的设计。
- 方波:最简单,数组里就两个值,比如0和4095,各占一半长度。
- 三角波:数据从0线性增加到4095,再线性减少到0。
- 任意波形:你可以用数学公式计算,也可以用软件(如MATLAB、Python)生成一个数据表,然后导入到工程中作为常量数组。
这里有个技巧:为了减少CPU计算量,尤其是动态改变波形时,可以预先计算好多种波形表存放在Flash或RAM中。切换波形时,只需改变DMA传输的内存源地址即可。但要注意,如果新波形表长度和旧的不同,别忘了同时修改DMA的CNDTR(数据传输数量)寄存器。
4.2 音频播放实践
用DAC播放WAV或MP3解码后的音频数据是一个经典应用。对于8kHz或16kHz采样率的单声道语音,STM32F103的DAC+DMA完全能胜任。流程如下:
- 音频数据准备:将音频文件(如WAV)转换为原始的PCM数据(通常是16位有符号整数)。
- 数据格式转换:STM32F103的DAC是12位无符号。需要将16位有符号的PCM数据缩放到0-4095范围。公式:
dac_data = ((int16_pcm_data + 32768) >> 4)。>>4相当于除以16,将16位映射到12位。 - 双缓冲机制:这是流畅播放的关键。准备两个缓冲区(BufferA和BufferB)。DMA当前正在从BufferA读取数据播放时,CPU赶紧把下一段音频数据填充到BufferB。当DMA播完BufferA,会自动触发一个DMA半传输/传输完成中断,在中断里,CPU切换DMA的目标内存地址到BufferB,并开始填充BufferA。如此循环,实现无缝播放。
我做过一个简单的MP3播放器,使用VS1053解码芯片输出I2S数据流,STM32用I2S接口接收后,通过计算(或查找表)转换成DAC数据,再用DMA输出。虽然音质比不上专业音频芯片,但作为系统提示音或语音播报,效果非常不错。
4.3 何时需要外部DAC?方案对比
STM32F103的片内DAC在简单场合很好用,但它有几个硬伤:
- 精度有限:12位,且实际ENOB可能只有10位左右。
- 通道数少:最多2路。
- 无音频专用接口:不支持I2S直接对接,播放音频需要CPU介入格式转换。
- 动态性能一般:信噪比(SNR)、总谐波失真(THD)指标不高。
当你的项目遇到以下需求时,就该考虑外部DAC了:
| 需求场景 | 推荐方案 | 关键考量 |
|---|---|---|
| 高保真音频播放 | 专用音频DAC(如VS1053、PCM5102A) | 支持24位/192kHz,集成耳机放大,信噪比>100dB,直接处理I2S流。 |
| 多通道同步输出 | 多通道DAC芯片(如AD5668, 8通道16位) | 需要精确同步控制多个模拟输出,如工业控制。SPI或并行接口。 |
| 超高精度与稳定性 | 精密DAC(如AD5689, 16/18位) | 用于仪器仪表、电压基准。关注INL(积分非线性)、DNL(微分非线性)、温漂。 |
| 高速信号生成 | 高速DAC(并行接口或专用DDS芯片) | 输出频率超过几百kHz的复杂波形。STM32的DAC更新率受触发定时器和DMA速度限制。 |
关于“R-2R DAC”:这是一种用电阻网络搭建的分立元件DAC方案,在热词中被提及。它利用GPIO并行输出数字码,通过精密电阻网络直接合成电压。它的优点是理论上可以达到很高的速度(取决于GPIO翻转速度),且成本低。但缺点极其明显:精度严重依赖电阻的匹配度(需要0.1%甚至更高精度的电阻),PCB布局要求高,通道数多时占用大量IO。对于大多数应用,我不推荐自己搭建R-2R,除非是用于教学原理或对成本极度敏感且精度要求不高的特定场景。
5. 调试与问题排查:从无声到完美波形
在实际操作中,DAC不出声、波形不对是常事。下面是一个系统的排查链路,你可以像查案一样一步步来。
现象一:DAC引脚完全没有输出(一直为0V或一个固定电压)
- 检查时钟:用调试器或代码检查
RCC->APB1ENR寄存器,确认DAC、GPIO、触发定时器、DMA的时钟使能位是否真的被置位。这是最最常见的疏忽。 - 检查引脚配置:确认PA4/PA5是否被配置成了其他功能(比如不小心初始化成了USART或SPI)。即使你后面初始化了DAC,之前错误的GPIO配置也可能锁死引脚状态。一个保险的做法是在系统初始化最开始,就把所有要用到的模拟引脚设为模拟输入。
- 检查DAC使能:确认
DAC_CR寄存器中对应通道的ENx位是否为1。单步调试,跟踪DAC_Cmd()函数是否被正确执行。 - 检查触发源:如果你用的是定时器触发,用示波器看看定时器的触发输出(TRGO)有没有信号?或者软件仿真查看定时器是否在正常计数和溢出。可以临时改成软件触发,在代码里手动调用
DAC_SoftwareTriggerCmd(),看看DAC是否有输出变化。如果有,问题就出在触发定时器上。
现象二:有输出,但电压值不对
- 数据对齐错误:如果你写入了
2048(0x800),期望得到1.65V,但实际输出是0.4V。检查你写入的寄存器地址。你是不是写到了DHR12L1(左对齐)?左对齐时,12位数据放在高12位,你写入的0x800会被当作0x8000,远超出范围,DAC可能只取了低12位或产生未定义行为。 - 参考电压问题:测量
VDDA引脚的实际电压。如果VDDA只有3.0V,那么你写入4095,输出也就是3.0V,而不是3.3V。 - 输出缓冲过载:接上负载后再测电压。如果空载时电压正确,接上一个1kΩ电阻后电压被拉低,说明负载太重,需要外接运放缓冲。
现象三:输出波形有毛刺或台阶
- 电源噪声:用示波器探头(打到AC耦合)直接测量
VDDA引脚和VSSA引脚之间的电压,看看在DAC输出变化时,电源上是否有同步的毛刺。如果有,加强电源滤波。 - 数据更新不同步:在定时器触发+DMA模式下,波形应该是平滑的。如果出现明显的台阶,可能是DMA在两次触发之间未能及时将新数据送达DAC。检查DMA的传输速度是否跟得上触发频率。触发频率(比如100kHz)不能超过DMA的最大传输速率(取决于系统时钟和DMA优先级)。
- 数据数组问题:检查你生成波形数据的代码。计算正弦波时,浮点运算是否引入了精度问题?数组索引是否越界?可以尝试输出一个简单的锯齿波(数据从0递增到4095再回0)来测试,如果锯齿波是平滑的,那问题就出在波形数据生成算法上。
现象四:输出波形频率不对
- 定时器计算错误:反复核对定时器预分频(PSC)和重装载值(ARR)的计算公式。记住
Update_Freq = TIM_CLK / ((PSC+1)*(ARR+1))。如果使用高级定时器,还要注意重复计数器的设置。 - DMA传输长度影响:波形频率 = 定时器触发频率 / 波形数组长度。如果你的数组长度是100,定时器触发频率是100kHz,那么输出波形频率就是1kHz。如果你数组长度误写成了50,波形频率就会变成2kHz。
调试DAC,一台示波器是必不可少的。它不仅能看电压对不对,还能通过FFT功能分析输出波形的谐波成分,直观地评估DAC的性能。从一个点不亮,到输出一条直线,再到出现稳定的正弦波,最后优化到波形干净平滑,这个过程本身就是对单片机外设理解的一次深化。