ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

STM32F103 DAC实战:从寄存器配置到DMA波形生成

STM32F103 DAC实战:从寄存器配置到DMA波形生成 简介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 的数据写入有几种方式对应不同的寄存器寄存器名称适用场景DHR12R112位右对齐最常见的直接写入方式和 DMA 配合时推荐DHR12L112位左对齐数据源是 16 位且高位有效时用DHR8R18位右对齐想省带宽或数据本来就是 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 到 409512 位右对齐模式下正好填满。如果编译器没有开启 FPUF103 没有硬件 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.3VVSSA 和 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_zero2写入 DAC 值 4095测量输出电压V_full3计算理论斜率(V_full - V_zero) / 40954写入目标电压对应的理论值测量实际值V_actual5计算修正量并存入 Flashoffset校准的本质是测出零点和满量程两点之间的实际传输特性然后用目标电压反推应该写入的 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 回读值交叉验证也能尽早发现电源电压跌落这类间接问题。本文还有配套的精品资源点击获取
返回列表