简介:STM32(F103)DAC模拟电压输出压缩包面向嵌入式初学者与STM32开发者,聚焦利用F103内置12位DAC实现模拟电压输出,解决音频、波形生成、电机控制等场景的模拟信号产生问题。包内共253个文件,压缩后约4.97MB,以C/H源码、Keil工程文件(uvproj/uvopt)、编译中间产物(o/d/crf)、烧录文件(hex/axf)为主,并附带htm/doc说明文档,方便直接打开工程对照学习。目前已有1492人学习下载,适合结合HAL库逐步理解DAC配置流程。资源详细展示了DAC时钟使能、GPIO模拟模式设置、DAC初始化、数据写入与触发转换等关键步骤,还包含红外解码、RTC、ADC等模块代码,可作为STM32多外设开发的参考模板,有助于快速上手模拟输出类项目。
1. 为什么 F103 的 DAC 总被当成“会响的 PWM”用
很多工程师拿到 STM32F103 的第一反应是“没有 DAC”,因为它的数据手册里确实没把 DAC 列进主要特性,大家习惯用定时器加 PWM 加 RC 滤波去凑一个“模拟量”。但实际上,F103 的大容量型号(比如 STM32F103RC、RD、RE、RF、RG)内部是带 12 位 DAC 的,只是中小容量型号没集成。DAC 和 PWM 滤波的本质区别在于:DAC 输出的是真正的连续电压,而 PWM 滤波出来的是带有纹波的平均电压,负载一变电压就跟着漂。做传感器激励、音频波形、模拟仪表驱动这类场景,DAC 的精度和响应速度更可靠。
这篇文章就围绕 F103 的 DAC 模块展开,从内部结构和寄存器行为讲起,给出基于标准外设库和寄存器两种做法的完整代码,讨论 DMA、定时器触发和多通道同步这些实战参数,最后落在校准和 PCB 布局上。你手上如果是带 DAC 的 F103 型号,读完可以直接照着写出一路稳定的模拟电压输出。
2. F103 DAC 的硬件行为:12位分辨率、对齐方式和输出缓冲
2.1 引脚、触发源和转换关系
F103 的 DAC 模块集成在 APB1 总线上,包含两个独立的转换通道,分别映射到 PA4(通道1)和 PA5(通道2)。这两个通道可以独立工作,也可以同时更新,但要注意:即使你只用一路输出,另一个通道对应的引脚也不能随意复用,否则会使参考电压或输出缓冲产生意外耦合。DAC 的参考电压来自 VREF+,在 LQFP 封装上通常和 VDDA 短接,所以输出范围是 0V 到 VDDA,一般就是 3.3V。
数据寄存器是 12 位的,写入的数值和输出电压的关系是:Vout = VREF × (DAC_DOR / 4096)。这里不需要取整再移位,DAC 硬件会自动把右对齐(12 位右对齐 DHR12R1)的数据处理成对应的模拟电平。有一点容易被忽略:DAC 的建立时间在有输出缓冲时是 4 微秒,关闭缓冲时是 1.5 微秒,但关闭缓冲后输出阻抗会明显上升,带上普通负载电压会掉,所以绝大多数应用要保留缓冲。这个建立时间决定了你用 DAC 做波形输出时的最高刷新率,后面讲 DMA 时会再算。
2.2 数据对齐的三个寄存器组
DAC 的数据写入有几种方式,对应不同的寄存器:
| 寄存器 | 名称 | 适用场景 |
|---|---|---|
| DHR12R1 | 12位右对齐 | 最常见的直接写入方式,和 DMA 配合时推荐 |
| DHR12L1 | 12位左对齐 | 数据源是 16 位且高位有效时用 |
| DHR8R1 | 8位右对齐 | 想省带宽或数据本来就是 8 位时用,分辨率降为 8 位 |
标准外设库里的 DAC_SetChannel1Data 就是对这些寄存器做写入。选择 12 位右对齐的理由很简单:DMA 搬运数据时一次安放 16 位(半字)传输,源数组里存的就是 0–4095 的数值,不需要额外处理。如果用 8 位右对齐,你每两个 DAC 数值就得自己拼装一次,增加的代码开销不大,但很容易出错。
提示:BOOT1 引脚在部分 F103 型号上如果被外部电路拉高,会影响内部参考电压的复用方式,间接导致 DAC 输出满量程偏移。排查输出不准时先确认 BOOT0 和 BOOT1 的状态,再怀疑校准问题。
2.3 启动顺序:时钟、引脚复用和使能
F103 的 DAC 没有独立时钟源,挂载在 APB1 上。使能 DAC 时钟只是第一步,真正让引脚变成模拟输出的是 GPIOA 的配置。很多人直接调用 DAC_Init 却发现 PA4 没有输出,原因就是 GPIO 的模式没配成 Analog 输入。这个“输入”很反直觉——你在做输出,但引脚必须配成模拟输入模式,因为模拟引脚没有数字输入缓冲,这样外部噪声才不会被引入内部逻辑。
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_DAC, ENABLE); GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); 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_Trigger 参数。DAC_Trigger_None 表示软件触发,配置完调用 DAC_SetChannel1Data 后要手动调用 DAC_SoftwareTriggerCmd(DAC_Channel_1, ENABLE) 才会真正更新输出。有些例程能看到 DAC_Cmd 之后输出就已经有电压了,那是因为不仅使能了 DAC,还隐式触发了软件更新。保险起见,初始化完成后写一次数据,再触发一次,然后读 DAC_GetDataOutputValue 确认寄存器值已经变化。PA4 和 PA5 的模拟输入配置在标准库 v3.50 和 HAL 库里的写法略有差异,但寄存器层面做的事是一样的:清除 MODE 位、清除 CNF 位,再把引脚设为输入模式。
3. 用标准外设库和寄存器分别实现 0-3.3V 可调电压输出
3.1 标准外设库的最小可运行代码
很多下载到的 F103 工程模板里带的还是标准外设库 3.5 版本,这套库对 DAC 的封装比较简单,直接调用即可,不需要操心寄存器位的偏移计算。下面这段代码覆盖了从初始化到输出指定电压的完整过程,编译后直接能跑:
#include "stm32f10x.h" #include "stm32f10x_dac.h" void DAC_Init_GPIO(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); } void DAC_Init_Module(void) { DAC_InitTypeDef DAC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_DAC, ENABLE); 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); } void DAC_Set_Voltage(float voltage) { uint16_t dac_value = (uint16_t)(voltage / 3.3f * 4095.0f); if (dac_value > 4095) dac_value = 4095; DAC_SetChannel1Data(DAC_Align_12b_R, dac_value); DAC_SoftwareTriggerCmd(DAC_Channel_1, ENABLE); } int main(void) { DAC_Init_GPIO(); DAC_Init_Module(); DAC_Set_Voltage(1.65f); while (1); }DAC_Set_Voltage 函数里先做浮点转整数,再做上限钳位,防止 3.3V 以上的输入导致寄存器溢出回绕。DAC_Align_12b_R 参数对应右对齐模式,它告诉库函数把 16 位容器里的低 12 位写入 DHR12R1。DAC_SoftwareTriggerCmd 调用一次即可,后续重复写数据不需要每次都触发软件更新,因为使能触发后硬件会自动锁存新数据——这里的细节是:DAC 的数据寄存器写入和输出更新之间不是同步的,触发信号负责把 DHR 的内容搬移到 DOR。
3.2 寄存器版:绕过库函数,看真正的硬件时序
直接操作寄存器能让你更清楚 DAC 更新输出过程中到底发生了什么,也能在调试时更快定位是库的问题还是硬件的问题。寄存器版的初始化步骤和库函数完全一致,但代码量少很多,核心只有三条语句:
#define DAC_BASE_ADDR 0x40007400 #define DAC_CR_OFFSET 0x00 #define DAC_DHR12R1_OFFSET 0x08 void DAC_Reg_Init(void) { RCC->APB1ENR |= RCC_APB1ENR_DACEN; RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; GPIOA->CRL &= ~(GPIO_CRL_MODE4 | GPIO_CRL_CNF4); GPIOA->CRL |= GPIO_CRL_CNF4_0; // 模拟输入模式 DAC->CR &= ~DAC_CR_EN1; DAC->CR |= DAC_CR_EN1 | DAC_CR_BOFF1; DAC->CR &= ~DAC_CR_TEN1; } void DAC_Reg_Set(uint16_t value) { DAC->DHR12R1 = value; DAC->CR |= DAC_CR_SWTRIG1; DAC->CR &= ~DAC_CR_SWTRIG1; }代码里的 SWTRIG1 是一个有意思的位:写入 1 触发更新,但必须再清 0,否则后续设置 TEN1 位启用定时器触发时会产生一次意外的额外转换。寄存器版的触发方式倾向于“清除即完成”的流程,这也是和库函数版最明显的差异。GPIOA->CRL 的模拟输入配置,本质是把 MODE4 置 00、CNF4 置 01,也就是数字输入输出全部断开,只保留模拟通路。
3.3 验证输出是否准确的三个方法
写完代码接上万用表测电压是最直接的验证方式。分别写入 0、2048、4095 三个值,对应理论电压应该是 0V、1.65V、3.3V,误差在 20mV 以内算正常。如果用手头没有精密万用表,可以用 ADC 的通道 1 去读 PA4 的输出,让系统自己形成闭环,但这要求 PA4 和 ADC 的输入引脚之间有物理连接,且 ADC 参考电压和 DAC 参考电压是同一个源。第三种方法是把 DAC 输出接进示波器,看波形是否稳定、有无周期性毛刺,这个方法尤其适合后面做波形输出时用。
提示:F103 的 DAC 没有输出失调校准寄存器,线性度虽然不至于离谱,但零点和满量程都存在几毫伏到十几毫伏的偏移。对精度要求高的场景,需要做两点校准,具体做法放在最后一章。
4. DMA 搬运波形数据和定时器触发:从直流电压到正弦波
4.1 为什么直流输出也需要 DMA
直流电压输出完全不需要 DMA,一次软件写入就结束了。但当你需要连续输出正弦波、三角波或者任意波形时,CPU 如果每个采样点都去执行一次 DAC_SetChannel1Data,波形频率稍微高一点,主循环就被占满了。F103 的 DAC 带有 DMA 请求能力:数据寄存器 DHR12R1 在每次触发信号到来时自动从内存地址加载新值,整个过程不经过 CPU。
配合 DMA 的标准做法是:用定时器的更新事件作为 DAC 触发源,定时器每个周期产生一次更新,DMA 把内存里的波形表逐点搬运到 DAC 的数据寄存器。F103 的 DAC 通道 1 可以映射到 TIM2、TIM4、TIM6 三个定时器的更新事件。TIM6 和 TIM7 是基本定时器,没有输出比较通道,看起来功能少,但做 DAC 触发反而更干净——不会因为引脚复用冲突产生干扰。
4.2 TIM6 触发 DAC 通道 1 的完整配置
初始化顺序是:先配 DAC 的触发源为 TIM6,再配置 DMA 请求的方向和缓冲区,最后启动定时器。顺序反了会在启动瞬间出现一次不确定的输出跳变。
#define WAVE_SAMPLES 128 uint16_t sine_wave[WAVE_SAMPLES]; void DAC_DMA_Setup(void) { DAC_InitTypeDef DAC_InitStructure; TIM_TimeBaseInitTypeDef TIM_InitStructure; DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA2, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM6, ENABLE); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&(DAC->DHR12R1); DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)sine_wave; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize = WAVE_SAMPLES; 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_InitStructure.DAC_Trigger = DAC_Trigger_T6_TRGO; DAC_InitStructure.DAC_WaveGeneration = DAC_WaveGeneration_None; DAC_InitStructure.DAC_OutputBuffer = DAC_OutputBuffer_Enable; DAC_Init(DAC_Channel_1, &DAC_InitStructure); TIM_TimeBaseStructInit(&TIM_InitStructure); TIM_InitStructure.TIM_Period = 72 - 1; TIM_InitStructure.TIM_Prescaler = 1 - 1; TIM_TimeBaseInit(TIM6, &TIM_InitStructure); TIM_SelectOutputTrigger(TIM6, TIM_TRGOSource_Update); DAC_Cmd(DAC_Channel_1, ENABLE); TIM_Cmd(TIM6, ENABLE); }这段配置里,DAC 的外设地址必须是 &(DAC->DHR12R1),不能写 DAC 的基地址 0x40007400。因为 DMA 需要访问的是 12 位右对齐模式的数据寄存器,基地址对应的寄存器是 CR,写进去没有任何效果。DMA2_Channel3 是 DAC 通道 1 的专用通道,通道 2 对应 DMA2_Channel4,这个映射关系在 F103 上不可更改。DMA_Mode_Circular 使能循环模式,波形表播完会自动从头开始,不需要中断介入。
TIM_Prescaler 设为 0、TIM_Period 设为 71 的话,TIM6 的更新频率就是 72MHz / 72 = 1MHz,也就是说 DAC 每秒刷新一百万次,每个采样点之间的间隔是 1 微秒。128 个采样点构成的波形表,输出频率约为 1MHz / 128 = 7.8125kHz。要输出音频频率,有两种改法:加大 TIM_Period 降低触发频率,或者加大波形表长度。
for (int i = 0; i < WAVE_SAMPLES; i++) { sine_wave[i] = (uint16_t)((sinf(2 * M_PI * i / WAVE_SAMPLES) + 1.0f) * 2047.5f); }波形表生成用到了 sinf 浮点函数,生成的数值范围是 0 到 4095,12 位右对齐模式下正好填满。如果编译器没有开启 FPU(F103 没有硬件 FPU),浮点运算会有几十微秒的开销——但这是在生成表的时候,不在运行时的中断路径里,所以不影响 DAC 的输出稳定性。
4.3 决定波形最高频率的三个参数
输出波形的极限频率由三个因素共同决定:DAC 建立时间、DMA 搬运速度、波形表长度。F103 的 DAC 建立时间在使能输出缓冲后大约是 4 微秒,意味着每个采样点最短时间间隔不能低于 4 微秒,否则前一拍的电压还没稳定就被新值覆盖了,波形会明显失真。按 4 微秒算,单点频率上限是 250kHz。128 点的正弦波最高输出频率是 250k / 128 ≈ 1.95kHz。
如果确实需要更高频率的波形,可以关闭输出缓冲把建立时间缩到 1.5 微秒,换来约 5.2kHz 的 128 点正弦波。但代价是输出阻抗从几欧姆升到几十千欧,后级必须接运放缓冲,否则负载稍重电压就塌陷。另一个思路是减少波形表长度到 64 点甚至 32 点,波形谐波分量会增多,但基频能做得更高。
4.4 双通道同步输出的处理方式
双 DAC 通道同时输出不同电压或相位差波形时,会遇到同步性问题。如果两个通道分别由软件触发,执行两条写指令之间存在不确定的延迟,波形相位差会抖动。F103 的 DAC 提供一个同时更新位 DAC_CR 里的 DUAL 位,配合 DMA 的突发传输可以把两个通道的数据打包在一次触发里同时更新。
具体做法是把两个波形表交叉存储在内存里:偶数地址存通道 1 的样本,奇数地址存通道 2 的样本。DMA 的每次传输搬运一个 32 位字,硬件自动把低半字给通道 1、高半字给通道 2,触发信号到来时两个 DAC 同时锁存。这种方式下,两个通道之间的相位差只取决于波形表的设计,不再受软件执行顺序影响。
5. F103 最小系统上的电源噪声、PCB 布局和参考电压处理
5.1 最小系统的电源设计直接影响 DAC 精度
F103 最小系统的经典做法是把 VDDA 和 VREF+ 直接接到 3.3V,VSSA 和 VREF- 接地。这种做法能用,但你如果仔细看输出波形,会发现上面叠加了几毫伏的工频纹波和高频毛刺。这不是 DAC 本身的问题,而是最小系统板上的 LDO 输出噪声直接耦合进了参考电压路径。DAC 的输出精度不会高于它的参考电压纯度,VREF+ 上 10mV 的纹波会 1:1 出现在输出端。
改善做法是在 VREF+ 引脚上增加一个 1 到 10 微法的低 ESR 陶瓷电容,并串联一个几欧姆的磁珠把它和 VDDA 隔离。对于有独立 VREF+ 引脚的 LQFP100 封装型号,这是标准做法。LQFP48 和 LQFP64 封装没有独立的 VREF+,只能靠 VDDA 滤波。另一个容易被忽略的点是 PA4 和 PA5 的相邻引脚如果走数字信号,给 DAC 输出带来的串扰很难靠软件消除,在 PCB 布局时要留出隔离地带。
5.2 ADC/DAC 电路设计里三个必须遵守的 PCB 布局要点
ADC 和 DAC 在同一个最小系统上使用时,布局问题会被放大。数字信号的跳变沿通过地平面回流到模拟区域,产生的噪声会被 DAC 输出再采进 ADC,形成一条自我干扰的环路。规避时钟抖动与电源噪声的 PCB 布局通常围绕三个要点:
第一,模拟地和数字地单点连接。在 MCU 下方或电源入口处用一个 0 欧电阻或磁珠把 AGND 和 DGND 连起来,不要让模拟电路和数字电路各自形成回路后又在多个点交汇。第二,DAC 输出引脚到负载的走线要短而直,不要经过过孔换层,因为过孔的寄生电感会和高频信号相互作用产生振铃。第三,DAC 的电源去耦电容要放在 MCU 的 VDDA 引脚附近,而不是放在电源输入端——去耦电容的有效性取决于它离负载引脚的物理距离,越近越好。
5.3 参考电压的硬件校准和软件校准
F103 的 DAC 官方手册保证的线性度在正负 1.5 LSB 以内,这已经覆盖了大部分工业控制场景。但如果你的项目需要万用表级精度,或者电压值要用于严苛的标定,就必须做校准。下面是一份可复用的校准方案:
| 步骤 | 操作 | 记录值 |
|---|---|---|
| 1 | 写入 DAC 值 0,用 6 位半万用表测量输出电压 | V_zero |
| 2 | 写入 DAC 值 4095,测量输出电压 | V_full |
| 3 | 计算理论斜率 | (V_full - V_zero) / 4095 |
| 4 | 写入目标电压对应的理论值,测量实际值 | V_actual |
| 5 | 计算修正量并存入 Flash | offset |
校准的本质是测出零点和满量程两点之间的实际传输特性,然后用目标电压反推应该写入的 DAC 值。F103 没有内部校准寄存器,所以修正值要存到 Flash 或者外部 EEPROM 里,每次上电初始化时读出来应用。
提示:不要在代码里把校准值和运行时计算的修正逻辑混在一起。建议单独建一个 calib.c 文件,里面只做读取校准参数、计算修正量、输出最终 DAC 值三件事,便于以后换料重新校准时只改标定数据不动逻辑。
5.4 输出级加运放缓冲还是直接用无源滤波
DAC 使能输出缓冲后,可以直接驱动几十千欧以上的负载,比如单片机的 ADC 输入、电压比较器、运放的同相输入端。但要驱动电流型负载或者走较长线缆,输出缓冲的驱动力不够,常见做法是外接一个轨到轨运放做电压跟随。运放选型时注意输入偏置电流和失调电压两个参数,前者影响带载误差,后者直接叠加在输出上。关于地线布局,如果运放和 MCU 离得比较远,在运放电源脚附近也要加 100nF 去耦电容,否则高频噪声会从运放的电源端直接串到输出。
6. 用 LPTIM 做低频慢爬坡输出,避免定时器资源被占用
很多 F103 项目在跑 modbus 协议或者多路传感器采集时,定时器资源已经所剩无几,但 DAC 还得出一个缓慢变化的电压,比如温控器的设定值斜坡、老化测试的电压渐变。这时如果用 TIM6 配合 DMA 做波形表,等于为一个几秒钟才变化一次的信号浪费了一个定时器和一路 DMA 通道。低速场景有更省资源的做法:直接用 DAC 的软件触发,在空闲时修改 DHR12R1 的值。
void DAC_SlowRamp(uint16_t target, uint16_t step, uint32_t interval_ms) { uint16_t current = DAC_GetDataOutputValue(DAC_Channel_1); while (current != target) { if (current < target) { current += step; if (current > target) current = target; } else { current -= step; if (current < target) current = target; } DAC_SetChannel1Data(DAC_Align_12b_R, current); DAC_SoftwareTriggerCmd(DAC_Channel_1, ENABLE); delay_ms(interval_ms); } }这个函数的步进逻辑单独拿出来看并不复杂,关键在调用它的位置。如果放在主循环里用 delay_ms 等待,整个循环会被堵住,modbus 报文处理就会超时;如果放在定时器中断里,interval_ms 只是中断间隔,current 会成为中断服务的局部状态,函数返回后再次进入时要用静态变量保存。我一般会在一个 10ms 的时基中断里检查“当前值是否等于目标值,不等则以固定步长逼近”,这样斜坡的速率是确定的,也不影响其他任务。
DAC 输出的零点和满量程偏移不会因为这个“省资源”的方案而消失。即使不需要高精度,也要在系统初始化时花 50 毫秒做一次简单的自检:输出中间值并回读 ADC,确认 DAC 通路是通的,这个习惯能帮你在现场排查时省下大量时间。斜坡输出完成后,建议记录一下最终写入的 DAC 值,方便和 ADC 回读值交叉验证,也能尽早发现电源电压跌落这类间接问题。
本文还有配套的精品资源,点击获取