
1. 从“数字”到“模拟”为什么我们需要DAC在嵌入式开发的世界里我们最常打交道的是数字信号。GPIO引脚输出高电平3.3V或低电平0VPWM输出一串占空比可调的方波ADC读取外部世界的电压值并将其转换为一个数字。STM32F103作为一款经典的Cortex-M3内核微控制器其强大的数字处理能力早已被大家熟知。但你是否想过如何让这颗数字芯片“开口说话”或者“唱出”一段优美的正弦波这就需要我们今天的主角——数模转换器DAC登场了。简单来说DAC就是一座桥梁它负责将我们程序里计算好的数字代码转换成连续、平滑的模拟电压输出。这个功能看似简单但在实际项目中应用极广。比如你想做一个可编程的直流电压源用来校准传感器或者驱动一个老式的VGA显示器需要生成精确的模拟RGB信号再或者你想让蜂鸣器播放一段音乐而不是单调的“滴滴”声。在这些场景下GPIO和PWM都显得力不从心而DAC则能完美胜任。STM32F103C8T6即常见的“蓝莓派”最小系统板核心芯片内部集成了两个12位精度的DAC通道。12位意味着它的输出可以有 2^12 4096 个不同的电压等级分辨率达到 3.3V / 4096 ≈ 0.8mV这对于大多数音频和中等精度的控制应用来说已经足够了。接下来的内容我将带你从零开始打通STM32F103的DAC功能并分享几个从基础到进阶的实战案例以及我踩过的一些坑。2. 硬件连接与CubeMX基础配置在写代码之前正确的硬件连接和软件环境配置是成功的一半。STM32F103的DAC输出引脚是固定的DAC通道1对应PA4DAC通道2对应PA5这两个引脚默认功能就是DAC_OUT你不需要像某些串口引脚那样进行重映射。硬件上你只需要将PA4或PA5引脚用杜邦线连接到你的负载如示波器、运放电路、扬声器驱动电路等即可。这里有一个非常重要的细节DAC输出是电压型的驱动能力很弱通常只有几十毫安。如果你要驱动低阻抗负载比如直接驱动一个8Ω的喇叭必须在DAC输出后接一级电压跟随器或功率放大电路否则不仅输出波形会失真还可能损坏单片机的DAC模块。接下来是软件配置我们以STM32CubeMX为例。创建一个新的F103C8Tx项目。2.1 时钟树配置速度的基石DAC模块的时钟来源于APB1总线。首先确保系统时钟SYSCLK设置正确通常我们外部接8MHz晶振通过PLL倍频到72MHz这是F103的极限速度也能让DAC工作在最佳状态。在Clock Configuration标签页确认APB1的时钟PCLK1是36MHz系统时钟72MHz的一半。DAC模块的时钟就是PCLK1这个速度决定了DAC数据寄存器更新和触发转换的速率上限。2.2 DAC模块参数化配置在Pinout Configuration标签页找到Analog-DAC。启用通道在OUT1 Configuration或OUT2 Configuration下将Mode设置为Enabled。触发源选择这是DAC工作的核心控制方式之一。Trigger选项决定了DAC何时将数据寄存器DHRx的值搬移到输出寄存器DORx从而更新实际输出电压。常见选项有None软件触发。你需要在代码中手动调用HAL_DAC_Start()和HAL_DAC_SetValue()来更新输出。适合输出静态或低速变化的电压。Timer 2/4/6/8 TRGO定时器触发。这是生成波形如正弦波、三角波的关键你可以配置一个定时器如TIM6以固定的频率产生更新事件TRGO然后自动触发DAC转换。这样就能用DMA自动搬运波形数据表实现“零CPU占用”的连续波形输出。这里我们先选None从最简单的开始。输出缓冲Output Buffer建议保持Enabled。片上集成的输出缓冲器可以降低输出阻抗提高驱动容性负载的能力使输出更稳定。除非你有特殊需求比如需要满幅0-3.3V输出启用缓冲时最高输出电压会比Vref低一点否则不要关闭它。其他设置Wave generation波形生成和Noise generation噪声生成是DAC的高级功能可以直接由硬件生成特定波形我们暂时不启用。配置完成后生成代码即可。CubeMX会自动帮你初始化GPIO和DAC外设。3. 软件触发模式输出一个固定电压这是最基础的用法目的是验证DAC硬件和软件配置是否正确。我们让DAC输出一个1.65V的电压即满量程3.3V的一半。打开生成好的工程找到main.c文件在/* USER CODE BEGIN 2 */和/* USER CODE END 2 */之间添加我们的代码。/* USER CODE BEGIN 2 */ // 启动DAC通道1 if (HAL_DAC_Start(hdac, DAC_CHANNEL_1) ! HAL_OK) { Error_Handler(); } // 设置DAC输出值并立即更新输出 // 公式输出值 (期望电压 / 参考电压) * (2^分辨率 - 1) // 期望电压 1.65V 参考电压 3.3V 分辨率12位 4096 // 输出值 (1.65 / 3.3) * 4095 0.5 * 4095 2047.5 ≈ 2048 uint32_t dac_value 2048; // 12位对齐直接写入0-4095之间的值 if (HAL_DAC_SetValue(hdac, DAC_CHANNEL_1, DAC_ALIGN_12B_R, dac_value) ! HAL_OK) { Error_Handler(); } /* USER CODE END 2 */代码解析与避坑点HAL_DAC_Start()这个函数不仅“启动”DAC更重要的是它建立了DAC数据保持寄存器DHRx到数据输出寄存器DORx的通道。在软件触发模式下调用SetValue后输出会立即更新。HAL_DAC_SetValue()第三个参数DAC_ALIGN_12B_R是关键。它指定了输入数据的对齐方式。DAC_ALIGN_12B_R12位右对齐。这是我们最常用的方式直接传入一个0-4095之间的uint32_t值即可。HAL库会帮你处理。DAC_ALIGN_12B_L12位左对齐。此时你需要传入左移4位后的值即数值 * 16不直观容易出错除非特殊需求否则不用。DAC_ALIGN_8B_R8位右对齐。用于8位分辨率模式此时有效数据范围是0-255。计算技巧在实际项目中我习惯用一个宏或者函数来封装电压到数值的转换避免每次心算。例如#define VREF 3.3f #define DAC_RESOLUTION 4096.0f uint32_t VoltageToDacValue(float voltage) { if (voltage 0) voltage 0; if (voltage VREF) voltage VREF; return (uint32_t)((voltage / VREF) * (DAC_RESOLUTION - 1)); }注意这里用的是40952^12 - 1因为0代表0V4095代表满量程电压。编译下载程序后用万用表测量PA4引脚对地的电压应该非常接近1.65V。如果电压是0V或者3.3V请检查引脚是否连接正确PA4。CubeMX中DAC通道是否确实启用。代码中DAC通道号是否正确DAC_CHANNEL_1。是否有其他程序如之前的实验将PA4配置为了其他功能如GPIO输出冲突了。4. 定时器触发与DMA生成连续波形输出固定电压只是小试牛刀DAC真正强大的地方在于能连续输出变化的电压从而生成任意波形。要实现这一点必须结合定时器触发和DMA。原理是这样的我们预先在内存中定义一个数组里面存放了一个完整周期波形比如正弦波的所有采样点数据。然后配置一个定时器让它以我们想要的波形频率 * 每个周期的采样点数 的频率产生触发信号。DAC被设置为由该定时器触发。最后启用DMA让DMA在每次定时器触发时自动从内存数组中搬运一个数据到DAC的数据寄存器。这样CPU就可以完全解放出来去做其他事情波形输出由硬件自动完成非常稳定和精确。4.1 CubeMX进阶配置我们以使用TIM6触发DAC通道1输出1kHz正弦波为例每个周期采样100个点。配置定时器TIM6在Timers-TIM6中将Clock Source设为Internal Clock。Prescaler预分频器设为71。因为APB1时钟是36MHzTIM6挂在APB1上经过倍频后实际时钟是72MHz。72MHz / (711) 1MHz即定时器计数频率为1MHz。Counter Period自动重装载值ARR设为9。我们想要DAC的更新率即采样率为 1kHz * 100点 100kHz。定时器触发频率 1MHz / (ARR 1) 1MHz / 10 100kHz符合要求。在Trigger Output (TRGO) Parameters中将Master/Slave Mode下的Trigger Event Selection设置为Update Event。这样每次定时器溢出更新时就会产生一个TRGO信号去触发DAC。重新配置DAC回到DAC配置将通道1的Trigger改为Timer 6 TRGO event。勾选DMA Settings选项卡下的Add按钮为DAC_CH1添加一个DMA流。通常使用DMA1 Channel3对于DAC1。模式设为Circular循环模式这样DMA在搬运完整个数组后会自动回到开头循环搬运实现连续输出。数据宽度都设为Word因为DAC数据寄存器是32位的但实际用低12位。生成代码。4.2 编写波形数据与启动代码在main.c的顶部用户代码区定义正弦波数组。为了提高精度我们通常预先计算好。/* USER CODE BEGIN PV */ #define SINE_WAVE_POINTS 100 uint32_t sineWave[SINE_WAVE_POINTS]; /* USER CODE END PV */在main函数的初始化部分/* USER CODE BEGIN 2 */之后计算正弦波数据并启动DAC。/* USER CODE BEGIN 2 */ // 生成正弦波查找表幅度为满量程的90%偏置为50%即中心电压1.65V for (int i 0; i SINE_WAVE_POINTS; i) { float radian 2 * 3.1415926f * i / SINE_WAVE_POINTS; float voltage 1.65f 1.485f * sin(radian); // 1.65V ± 1.485V (90% of 1.65V) if (voltage 3.3f) voltage 3.3f; if (voltage 0) voltage 0; sineWave[i] VoltageToDacValue(voltage); // 使用前面定义的转换函数 } // 启动DAC使用定时器触发和DMA if (HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t*)sineWave, SINE_WAVE_POINTS, DAC_ALIGN_12B_R) ! HAL_OK) { Error_Handler(); } /* USER CODE END 2 */关键点与经验HAL_DAC_Start_DMA这个函数一次性完成了所有工作启动DAC、启动DMA、并将DAC与DMA关联起来。之后的一切都是硬件自动完成。幅度与偏置DAC输出不能为负电压也不能超过Vdda通常为3.3V。所以我们的正弦波需要叠加一个直流偏置1.65V并控制交流幅度在1.65V以内防止削顶失真。1.65f 1.485f * sin(...)就实现了一个从0.165V到3.135V变化的正弦波。DMA循环模式与缓冲区因为设置了循环模式DMA会永无止境地搬运sineWave数组中的数据。这意味着你不能在程序运行中随意修改这个数组的内容否则会导致输出波形错乱。如果需要在运行时改变波形更安全的做法是使用双缓冲区一个缓冲区用于DMA当前搬运sineWaveA另一个用于CPU准备新数据sineWaveB。当新数据准备好后通过HAL_DAC_Stop_DMA停止修改DMA目标地址再重新HAL_DAC_Start_DMA。但这会带来波形输出的短暂中断。采样率与波形频率最终输出的正弦波频率 定时器触发频率 / 一个周期的采样点数 100kHz / 100 1kHz。如果你想改变输出频率要么改变定时器的触发频率调整ARR或PSC要么改变采样点数。增加采样点数会使波形更平滑但会占用更多内存并且对定时器频率要求更高。现在用示波器连接PA4你应该能看到一个漂亮的1kHz正弦波。如果没有检查定时器和DAC的触发配置是否正确。DMA是否成功添加并配置为循环模式。正弦波数组数据计算是否正确可以用调试器查看数组前几个值是否在0-4095合理范围内。5. 实战进阶用DAC播放WAV音频有了生成任意波形的能力我们就可以尝试更酷的应用——播放音频。这里以播放一段低采样率8kHz、8位精度的WAV文件为例。虽然F103的资源和性能有限无法播放高质量MP3但实现一个简单的提示音或语音播放器是完全可行的。5.1 WAV文件处理WAV文件是一种常见的无损音频格式头部包含采样率、位深度、声道数等信息后面是连续的音频数据PCM样本。对于8位单声道WAV每个样本就是一个0-255的uint8_t值对应着电压值。你需要一个工具如Audacity将你的音频文件转换为单声道8kHz或更低采样率根据CPU处理能力8位PCM格式 然后你需要将音频数据部分提取出来转换成C语言数组。有很多在线工具或脚本如xxd -i命令可以做到这一点。最终你会在代码中得到一个像这样的数组const uint8_t audioData[] {128, 135, 142, ..., 120, 115};注意8位音频数据通常是“偏置二进制”格式128代表零点1.65V0代表负向最大255代表正向最大。5.2 系统设计思路直接像正弦波那样把整个音频数组用DMA循环播放是不行的因为音频数据很长会耗尽内存而且只播一遍。我们需要实现一个“流式”播放将大的音频数据放在外部存储器如SPI Flash或作为常量数组存储在Flash中。开辟一个较小的RAM缓冲区如512字节作为DMA的搬运目标。使用DMA的半传输完成HT和传输完成TC中断。在HT中断中用后半段音频数据填充缓冲区的前半段在TC中断中用新的音频数据填充缓冲区的后半段。这就是所谓的“双缓冲”或“乒乓缓冲”机制。DAC的DMA设置为循环模式但只循环搬运这个固定大小的双缓冲区。而CPU则在中断服务程序中源源不断地将Flash中的音频数据填入这个“环形”的播放缓冲区。5.3 关键代码片段这是一个高度简化的框架展示了核心逻辑#define AUDIO_BUFFER_SIZE 512 uint32_t dacAudioBuffer[AUDIO_BUFFER_SIZE]; // DAC需要32位数据但我们会填充低8位 uint32_t audioReadIndex 0; const uint8_t* fullAudioData; // 指向完整音频数据的指针 uint32_t fullAudioSize; // 在DMA HT中断回调函数中需在CubeMX中开启DMA中断 void HAL_DAC_ConvHalfCpltCallbackCh1(DAC_HandleTypeDef *hdac) { // 填充缓冲区前半部分 (0 到 AUDIO_BUFFER_SIZE/2 - 1) for(int i0; iAUDIO_BUFFER_SIZE/2; i) { if(audioReadIndex fullAudioSize) { // 音频播放完毕可以填充静音数据(128)或停止DMA dacAudioBuffer[i] 2048; // 对应1.65V静音点 } else { // 将8位音频数据转换为12位DAC值 // 假设8位数据0对应0V255对应3.3V则转换公式 // dacValue audioData[audioReadIndex] * 4095 / 255; uint32_t dacVal fullAudioData[audioReadIndex] * 16; // 近似转换快速 dacAudioBuffer[i] dacVal; audioReadIndex; } } } // 在DMA TC中断回调函数中 void HAL_DAC_ConvCpltCallbackCh1(DAC_HandleTypeDef *hdac) { // 填充缓冲区后半部分 (AUDIO_BUFFER_SIZE/2 到 AUDIO_BUFFER_SIZE - 1) for(int iAUDIO_BUFFER_SIZE/2; iAUDIO_BUFFER_SIZE; i) { // ... 逻辑同上 ... } } // 开始播放 void PlayAudio(const uint8_t* data, uint32_t size) { fullAudioData data; fullAudioSize size; audioReadIndex 0; // 首先手动填充整个缓冲区 // ... // 然后启动DAC DMA目标指向这个双缓冲区 HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t*)dacAudioBuffer, AUDIO_BUFFER_SIZE, DAC_ALIGN_12B_R); }这个方案的挑战与优化中断频率如果DAC采样率是8kHz缓冲区大小是512那么HT和TC中断的频率是 8000 / 512 * 2 ≈ 31Hz。这个频率对CPU压力不大。但如果采样率提高或缓冲区减小中断频率会变高。数据转换中断服务函数中的循环转换会消耗CPU周期。如果发现CPU占用率高可以预先将整个音频文件转换成12位的DAC值数组存储在Flash中中断里直接进行内存拷贝memcpy这会快得多。时序精度整个音频播放的时序依赖于DAC的定时器触发精度非常高。但填充缓冲区的操作必须在下一个中断到来前完成否则会出现“爆音”。因此缓冲区不能太小中断服务函数必须高效。驱动能力同样DAC输出必须连接到一个音频功率放大器如LM386、PAM8403等才能驱动扬声器。6. 精度校准与噪声抑制实战技巧即使代码正确你可能发现DAC的输出并不是那么理想可能有固定的偏移误差、增益误差或者输出上有毛刺噪声。下面分享几个提升DAC输出质量的实战技巧。6.1 内部参考电压与校准STM32F103的DAC使用VDDA模拟电源作为参考电压。VDDA的稳定性直接决定了DAC的输出精度。如果你的系统对精度要求较高比如用于测量仪表建议为VDDA提供干净的电源使用LDO单独为VDDA和VSSA供电并与数字电源VDD通过磁珠或0Ω电阻隔离。在VDDA和VSSA引脚附近放置10uF和100nF的退耦电容。启用内部参考电压STM32F103没有专用的内部参考电压源Vrefint给ADC和DAC用。但你可以测量ADC通道17内部温度传感器通道它也连接到Vrefint来反推实际的VDDA电压从而在软件中进行动态校准。公式是Vdda_measured 1.2V * 4095 / ADC_Value。知道了实际的Vdda你就可以修正DAC的输出计算值。6.2 输出噪声抑制DAC输出上的噪声可能来自电源、数字电路耦合或PCB布局。解决方法硬件滤波在DAC输出引脚PA4/PA5串联一个100Ω的电阻然后对地接一个100pF到1nF的电容形成一个简单的RC低通滤波器。这可以滤除高频噪声。如果需要更陡的滤波可以使用运放搭建有源低通滤波器。软件过采样如果你的输出信号变化很慢比如用于控制一个慢速的阀门你可以通过软件过采样来提高有效分辨率和平滑度。原理是以远高于信号频率的速率更新DAC并对多个输出值求平均。例如你需要输出一个100Hz的信号但你可以用10kHz的频率去更新DAC每次更新前计算最近100个“目标值”的平均值作为实际输出值。这可以在一定程度上抑制量化噪声和随机噪声。PCB布局确保模拟部分VDDA, VSSA, DAC_OUT的走线远离高速数字信号线如时钟、PWM、数据总线。使用地平面将模拟和数字区域隔离。6.3 动态范围扩展STM32F103的DAC输出范围是0到VDDA通常3.3V。有时我们需要更大的电压范围比如0-5V或±5V。这时就需要一个后级的运放电路。同相放大电路可以放大DAC的输出电压。例如用一颗轨到轨运放如LMV358搭建一个增益为2的同相放大器可以将0-1.65V的DAC输出放大到0-3.3V。注意你需要确保DAC输出的信号在放大前不超过运放的输入共模范围。加法器电路可以将DAC的输出电压平移。例如你需要一个±2.5V的输出。可以先用DAC输出一个0-3.3V的信号然后通过一个加法器电路减去一个1.65V的偏置电压可以用电阻分压从Vref得到再通过放大调整到±2.5V。这些模拟电路的设计需要一定的模电知识在面包板上搭建时务必注意运放的供电、虚短虚断条件并用示波器观察实际波形。