简介:面向STM32F103的DAC模拟电压输出例程包,适合刚接触数字模拟转换器,或在嵌入式项目中需要快速产生模拟电压的开发者。工程完整演示了从时钟开启、引脚模拟模式配置、DAC通道初始化,到写入数字量、触发转换输出的全过程,代码结构清晰,包含可用的初始化与输出函数,便于学习与复用。压缩包共253个文件,以C语言源文件与头文件为主,配有Keil工程文件,同时提供编译生成的可执行映像、中间文件及少量说明文档,整体约4.97MB,打开即可直接浏览源码与调试。除DAC外,包内还包含ADC、RTC、红外解码等多个外设参考例程,可对照理解不同外设的初始化逻辑,也可作为后续项目的基础工程模板。目前已有1492人学习下载,对想通过完整工程案例掌握STM32F103外设配置的开发者,是一份能快速落地的参考资料。
1. 一块被低估的12位DAC:F103模拟电压从PA4、PA5走出来
做基于STM32的毕业设计或者小整机时,很多人默认模拟输出就用PWM加RC滤波,直到发现纹波、负载调整率和响应速度都不够才回头翻手册。STM32F103内置的12位DAC其实是一块被低估的外设:两个独立通道挂在PA4和PA5上,配合内置运放缓冲、定时器TRGO触发和DMA,能直接输出平滑的直流电压、三角波和正弦波,不用外挂DAC芯片。这个压缩包里除了DAC工程文件,还带红外解码、RTC、ADC等备份工程,说明它原本是一整套外设模板,DAC部分只是其中一块。适合正在做信号发生、鱼缸灯光调光、电机给定电压这类需要真正模拟量输出的项目,也适合想搞清楚标准库里DAC到底怎么配置的人。
2. DAC通道架构与时钟、GPIO配置:把隐蔽的模拟外设点起来
2.1 两个DAC通道的引脚与标准库工程文件对应关系
STM32F103的DAC单元挂在APB1总线上,包含两个相互独立的转换通道。DAC_OUT1固定在PA4,DAC_OUT2固定在PA5,这两个引脚在复用功能上同时属于SPI3的SCK和MISO,这也是很多人用SPI时莫名其妙发现引脚电平不对的原因。通道数据寄存器是12位的,支持左对齐、右对齐和12位右对齐带符号三种写入方式,标准库中用DAC_Align_12b_R这类枚举区分。
这份模板里同时出现了stm32f10x_tim.c、stm32f10x_flash.c、stm32f10x_rcc.c、stm32f10x_adc.c,说明工程并不是只点一个DAC就结束,而是把定时器、ADC、Flash都编译进来了。实际阅读时建议先看工程模板主文件,确认DAC初始化被调用在哪个外设之后。常见顺序是先配置RCC时钟,再配置GPIO复用,最后初始化DAC本身,顺序反了会出现寄存器写入成功但引脚无输出的情况。
2.2 时钟使能与GPIO模拟输入模式
DAC的时钟开关位于RCC_APB1ENR寄存器的DACEN位,标准库封装为RCC_APB1PeriphClockCmd(RCC_APB1Periph_DAC, ENABLE)。同时PA4和PA5所在GPIOA挂在APB2总线上,也需要单独使能时钟。这两个时钟一个是外设时钟,一个是端口时钟,缺一不可。
GPIO配置是DAC最容易出错的地方。DAC引脚必须配置为模拟输入模式GPIO_Mode_AIN,而不是复用推挽输出。原因在于模拟输入模式会关闭施密特触发器,避免数字输入电路在模拟电压摆到中间电平时产生附加电流和闩锁效应,同时把引脚内部的上拉下拉电阻全部断开,保证DAC输出阻抗和电压精度不受额外负载影响。配置GPIO_Speed字段对模拟模式没有实际意义,但结构体字段必须写全,否则GPIO_Init内部会读取未初始化数据。
void DAC_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_DAC, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); }这段代码把PA4和PA5一起处理,即使这次只打算用通道1也建议把两个引脚都配成模拟输入,防止PA5残留的复用功能配置干扰后续扩展。GPIO_Speed_50MHz在这里不参与电气参数,只是结构体赋值的完整性要求。如果只配置PA4而忘记配置PA5,当DAC通道2被意外使能时,PA5会保持上一次的配置状态,可能直接短路到电源或者变成push-pull输出,表现很难排查。
2.3 触发源:软件触发与硬件触发的选择
DAC的转换由DAC_CR寄存器的TEN1、TEN2位控制使能,触发方式由EXTSEL字段决定。F103的DAC支持软件触发、定时器2/4/6/7的TRGO事件、外部中断线9,以及片上定时器触发联动。软件开发中最常用的触发方式是软件触发和TIM6_TRGO。软件触发适合调整直流电平,只要调用DAC_SoftwareTriggerCmd就能立即刷新输出;需要周期性波形时必须用定时器触发,否则CPU要一直干预。
| EXTSEL[2:0] | 触发源 | 适用场景 |
|---|---|---|
| 000 | 软件触发 | 直流电压输出、手动步进调整 |
| 001 | TIM2_TRGO | 双通道同时输出,与ADC同步采样 |
| 010 | TIM4_TRGO | 电机控制中与PWM同步刷新 |
| 011 | TIM6_TRGO | 周期波形生成,最常用 |
| 100 | TIM7_TRGO | 第二路独立频率波形 |
| 101 | EXTI9外部中断线 | 外部边沿触发转换 |
选择硬件触发时有一个细节:定时器必须先使能并跑起来,再使能DAC通道。如果反过来,DAC使能瞬间会丢失第一个触发边沿,导致输出停留在一个旧值直到下一个周期到来。用示波器看就是波形头尾不一致,偶尔还出现起始电平跳变。还有一点,DAC触发源是三位的,如果把DAC_InitTypeDef里的DAC_Trigger误填成类似DAC_Trigger_None之外的枚举,却没有同步配置对应定时器,DAC会一直等待触发,软件触发也不再生效,现象是SetValue之后输出纹丝不动。
3. 标准库三步走:DAC初始化、数据写入与单位电压换算
3.1 DAC_InitTypeDef结构体里三个字段怎么填
标准外设库对DAC的初始化封装在DAC_InitTypeDef里,只有三个成员:DAC_Trigger、DAC_WaveGeneration、DAC_OutputBuffer。这三个字段恰好对应DAC_CR寄存器里最核心的控制位。DAC_Trigger选择上面说的触发源,如果填DAC_Trigger_None,DAC不会自动开始工作,必须显式调用软件触发命令;DAC_WaveGeneration用DAC_WaveGeneration_None关闭内部波形发生器,开启后外部写入的数据会被噪声或三角波发生器覆盖,初学者经常在这里踩坑,以为写入了设置电压,实际输出的是内部生成的波形。
DAC_OutputBuffer是内部运放缓冲的开关,直接影响带负载能力。开启缓冲后DAC输出可以直接驱动数十千欧级别的负载,输出阻抗明显降低,但满量程电压会比VREF低一点;关闭缓冲后输出阻抗高,一般需要外接运放跟随器才能带负载。大多数板载DAC输出都建议开启缓冲,除非后续电路本身就接了高阻抗输入。
void DAC_Channel1_Init(void) { DAC_InitTypeDef DAC_InitStructure; DAC_InitStructure.DAC_Trigger = DAC_Trigger_None; DAC_InitStructure.DAC_WaveGeneration = DAC_WaveGeneration_None; DAC_InitStructure.DAC_OutputBuffer = DAC_OutputBuffer_Enable; DAC_Init(DAC_Channel_1, &DAC_InitStructure); DAC_Cmd(DAC_Channel_1, ENABLE); DAC_SetChannel1Data(DAC_Align_12b_R, 2048); DAC_SoftwareTriggerCmd(DAC_Channel_1, ENABLE); }DAC_Cmd使能通道后,DOR寄存器还是零,必须先写入数据再触发,输出才会跳到目标电压。DAC_SetChannel1Data只把数据写到DHR寄存器,真正送到DOR需要等触发信号。软件触发模式下,DAC_SoftwareTriggerCmd一次调用就会把DHR内容锁存到DOR,不需要每次写入数据后都触发;连续修改两次数据只触发一次时,输出直接跳变到最终值,不会停在中间值,这在电压渐变场景里要注意。
3.2 位码写入:对齐方式影响数据含义
DAC数据寄存器支持12位右对齐、12位左对齐和8位右对齐三种格式。DAC_Align_12b_R是最直观的写法,写入范围0到4095,直接对应输出电压从0到接近VREF;DAC_Align_12b_L适合从16位数据的高12位提取,数据要左移四位;DAC_Align_8b_R适合把DAC当8位用,但精度浪费了四分之一。位码和电压的换算关系是Vout约等于DOR除以4096乘以VREF。
| 数字量(12位右对齐) | VREF=3.3V时输出 | VREF=2.5V时输出 |
|---|---|---|
| 0 | 0V | 0V |
| 1024 | 0.825V | 0.625V |
| 2048 | 1.65V | 1.25V |
| 3072 | 2.475V | 1.875V |
| 4095 | 约为3.29V | 约为2.49V |
不少板子的VREF并不是严格的3.3V,而是通过REF引脚单独供电。Core407这类板子把VREF+直接接VDDA,所以满载输出会跟着电源波动;如果项目对绝对电压精度有要求,应该用万用表实测VREF,再反推位码,而不是直接套3.3V计算。缓冲模式下满量程离VREF会有几十毫伏压降,以MCU数据手册里的DAC输出特性曲线为准。
3.3 从直流渐变到多通道同步
在现有初始化基础上做电压渐变,只需循环改写DHR寄存器并软件触发,但要注意每次循环加一个延时,否则连续写入太快,输出端看起来还是突变。常见做法是每次递增或者递减1个LSB,延时1毫秒,这样从0爬到1.65V需要约2秒,肉眼能看到平滑过渡,也方便用示波器观察台阶。
uint16_t value = 0; while (value < 2048) { DAC_SetChannel1Data(DAC_Align_12b_R, value); DAC_SoftwareTriggerCmd(DAC_Channel_1, ENABLE); value++; delay_ms(1); }上述代码示意了渐变核心逻辑,实际工程里delay_ms需要替换成自己的延时函数。如果两个通道都需要输出,DAC_SetChannel2Data和DAC_SoftwareTriggerCmd(DAC_Channel_2, ENABLE)是同样的调用方式,唯一区别是DAC1和DAC2的数据寄存器和触发位各自独立,使能通道2前别忘了把PA5初始化成AIN。
4. 定时器触发DAC与ADC回采:让输出自己跑起来
4.1 用TIM6_TRGO替代软件触发,得到稳定刷新率
软件触发模式下,DAC输出什么时候更新完全取决于CPU执行到哪一行代码,中断一多相邻两次更新的间隔会抖动。生成周期波形时,应该用定时器做触发源,让DAC在固定时间点从DHR锁存数据到DOR,配合DMA还能做到CPU完全不参与数据传输。F103里最常用于触发DAC的定时器是TIM6和TIM7,它们是基本定时器,没有外部引脚,专门用来生成内部触发事件。
TIM6的更新事件通过TIM_SelectOutputTrigger配置成TRGO输出,DAC_InitTypeDef里把DAC_Trigger改成DAC_Trigger_T6_TRGO,这样DAC就会在每次TIM6更新时自动转换。触发频率由72MHz主频经过PSC分频再经过ARR重装载得到,计算公式为f = 72000000 / (PSC + 1) / (ARR + 1)。要注意这个频率是DAC的数据更新率,不是输出波形频率。输出正弦波时,波形频率等于更新率除以单周期点数,很多人以为定时器配了1kHz就能看到1kHz正弦波,实际看到的是39Hz,就是因为256点、1kHz更新率。
void TIM6_DAC_Trigger_Init(uint16_t psc, uint16_t arr) { TIM_TimeBaseInitTypeDef TIM_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM6, ENABLE); TIM_InitStructure.TIM_Period = arr; TIM_InitStructure.TIM_Prescaler = psc; TIM_InitStructure.TIM_ClockDivision = 0; TIM_InitStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM6, &TIM_InitStructure); TIM_SelectOutputTrigger(TIM6, TIM_TRGOSource_Update); TIM_Cmd(TIM6, ENABLE); }TIM_Period填ARR值,TIM_Prescaler填PSC值。TIM_SelectOutputTrigger把更新事件作为TRGO送到DAC,配置成DAC_Trigger_T6_TRGO后,DAC硬件会自动响应。这里没有开TIM6中断,因为DAC触发只需要事件信号,不需要CPU进中断,开了反而增加额外开销。
| 目标更新率 | PSC | ARR | 实际更新率 |
|---|---|---|---|
| 1kHz | 71 | 999 | 1000Hz |
| 10kHz | 7 | 899 | 约9.99kHz |
| 20kHz | 1 | 1799 | 约19.99kHz |
选参数时先定PSC再定ARR,PSC尽量取小,因为PSC分频会损失时间分辨率,ARR再精细也无法补偿。比如目标1kHz时,PSC取71得到1MHz计数频率,ARR取999刚好1ms;如果PSC取719,计数频率变成100kHz,ARR只能取9,实际触发频率变成10kHz而不是1kHz,这就说明PSC和ARR是配合关系,不是独立选择。
4.2 与ADC回采配合验证输出电压
DAC输出是否正确,不能只靠万用表看一个平均值,最好用ADC把一个通道的输出采集回来做闭环校验。F103的ADC1可以配置成规则通道单次转换,把DAC_OUT1连接到一个空闲ADC引脚,比如PA1,分压后采样。注意不要直接把DAC输出接到ADC引脚然后又同时驱动负载,ADC采样电容在采样瞬间会抽取电荷,导致DAC输出电压跌落,特别是DAC缓冲关闭时跌落更明显。中间串一个100欧电阻或者用运放缓冲后再进ADC引脚都能缓解。
void ADC1_CH1_Single_Init(void) { ADC_InitTypeDef ADC_InitStructure; GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); RCC_ADCCLKConfig(RCC_PCLK2_Div6); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_239Cycles5); ADC_Cmd(ADC1, ENABLE); } uint16_t ADC1_Read(void) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET); return ADC_GetConversionValue(ADC1); }ADC_ExternalTrigConv_None表示软件启动转换,ADC_RegularChannelConfig里的ADC_SampleTime_239Cycles5是采样时间,这里取最大采样周期是为了降低DAC输出阻抗带来的采样误差。ADC_Read返回12位原始值,和DAC写入的位码对比,理论上应该接近。如果发现ADC读到的值和DAC设定值差异超过几十个LSB,先检查DAC负载有没有接入,再检查VREF共用路径上的走线压降,最后考虑是不是PA1被其他外设复用了。
4.3 波形抖动和频率不准的排错方向
定时器触发DAC后看到波形抖动,第一步不是改代码,而是检查触发源是不是真的来自硬件。用示波器看DAC输出,如果边沿位置忽前忽后,常见原因是DAC触发源里混入了软件触发,比如初始化流程里又调用了DAC_SoftwareTriggerCmd,两个触发源竞争导致DOR更新时刻不稳定。另外电源噪声和地线噪声也会叠加到输出上,尤其是示波器探头地线夹太长时,波形上会看到明显的高频毛刺,这属于测量方式问题而非DAC问题。正确做法是用探头最短的地弹簧接地,或者把探头打到10x档,降低探头电容对PA4输出节点的影响。
5. 正弦波查表与一阶RC整形:让DAC信号更靠近理想值
5.1 用查表代替实时计算
生成正弦波最直接的想法是每次触发算一次sin,但在MCU上sin运算耗时很长,F103做浮点运算又慢,波形频率根本提不上去。常见做法是在电脑上把一张正弦波表算好,做成常量数组烧进Flash。这个工程里恰好有stm32f10x_flash.c,说明原本就可能涉及查表或者参数存储。查表点数取128或256比较平衡,单周期点数越多波形越细,但内存占用和DMA搬运量也上去了。用Python生成C数组的写法很顺手:
import math N = 256 table = [int((math.sin(2 * math.pi * i / N) + 1) / 2 * 4095) for i in range(N)] for i in range(0, N, 16): print(", ".join(f"0x{v:03X}" for v in table[i:i+16]) + ",")这段脚本生成256个12位右对齐的正弦波采样点,范围0到4095,直流偏置在2048附近,正负幅值各约2048。生成后粘贴成const uint16_t sine_table[256]数组。注意波形幅值受限于DAC满量程,最大输出是VREF减去缓冲压降,最小接近0V,不会出现负半周,需要交流信号时要在外部加隔直电容。
5.2 用DMA让DAC自己搬数据
有了正弦表,还需要在每次TIM6触发时把下一个表项写入DWR。如果放在中断里做,每个周期都会进一次中断,波形频率高时CPU占用率直线上升。F103的DAC1请求映射在DMA1_Channel3,配置成内存到外设、循环模式,DMA会自动把正弦表逐个搬到DAC的DHR12R1寄存器,搬完一圈又从表头开始,CPU完全不用管波形刷新。
void DAC_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&DAC->DHR12R1; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)sine_table; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize = 256; 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(DMA1_Channel3, &DMA_InitStructure); DAC_DMACmd(DAC_Channel_1, ENABLE); DMA_Cmd(DMA1_Channel3, ENABLE); }DMA_PeripheralBaseAddr填DAC的DHR12R1地址,标准库宏DAC_Channel_1在这里无效,必须直接取寄存器地址。DMA_BufferSize等于正弦表点数,循环模式让DMA自动回绕。DMA使能和DAC使能的顺序反过来可能导致第一次搬运落空,建议先DAC_DMACmd再DMA_Cmd。正弦波输出频率等于定时器更新率除以256,点亮示波器前先按这个公式估算,避免把频率设错。
5.3 输出级加RC滤波器,抑制量化台阶
DAC输出直接接示波器会看到阶梯状波形,尤其正弦波每个周期只有128点时,台阶特别明显。加一级一阶RC低通滤波器能把台阶抹平,截止频率按fc = 1 / (2 * π * R * C)估算。R取1千欧、C取100纳法时fc约为1.6kHz,适合几百赫兹到一点几kHz的波形;想保留更高频率分量,C换小到10纳法,fc抬到约16kHz。注意RC滤波器同时会把负载调整率变差,后级输入阻抗至少要大于10倍R,否则分压误差直接落在输出电压上。
滤波后波形能不能看,用示波器FFT看谐波最直接。把探头切到10x,地线用短弹簧,测量1kHz正弦波输出,基频周围不该看到明显的高次毛刺;如果在几十kHz处发现能量堆积,多半是定时器触发抖动带来的杂散分量。此时回到触发配置检查PSC和ARR取值,原理上分频数越小,触发时间均匀性越好,波形越干净。
本文还有配套的精品资源,点击获取