ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32 DAC音频生成实战:从正弦波到WAV音乐播放

STM32 DAC音频生成实战:从正弦波到WAV音乐播放 1. 项目缘起从蜂鸣器“滴滴”到DAC“歌唱”玩过STM32的朋友对GPIO点灯、PWM驱动蜂鸣器播个《生日快乐》应该都不陌生。我之前用有源蜂鸣器做过类似的项目通过调整PWM频率来模拟音符虽然能听出个调调但那声音尖锐、单薄毫无美感可言纯粹是“电子噪音”。这让我一直有个念头能不能让这块小小的单片机发出更接近真实世界、更悦耳的声音比如一段旋律清晰的歌曲片段或者一个干净的正弦波。这个想法直接指向了STM32内部一个常被初学者忽略的“高级”外设DAC数模转换器。与PWM那种通过数字脉冲占空比来“模拟”电压幅值不同DAC是真正意义上将数字代码转换成连续模拟电压的硬件。用DAC来生成音频波形理论上能得到平滑、保真度更高的信号。STM32F103C8T6这类常见的“蓝桥杯”核心板就内置了2个12位的DAC通道这为我们提供了硬件基础。网络上关于STM32 DAC输出固定电压的教程很多但系统地讲如何用它产生特定频率波形、尤其是组织数据播放音频的却比较零散。很多人卡在数据怎么来、定时器怎么配、输出怎么处理这几个环节。这次我就以最经典的STM32F103为例手把手带你实现两个目标一是输出一个精准的2kHz正弦波二是播放一段你喜欢的歌曲音频片段。我们会从原理开始一直讲到代码实现和硬件连接过程中遇到的坑和优化技巧也会一并分享。2. 核心原理DAC如何“画”出波形在动手写代码之前我们必须先搞清楚DAC是怎么工作的以及我们要让它做什么。这决定了后续所有环节的设计思路。2.1 STM32的DAC外设简析STM32F103的DAC模块相对简单直接。它支持两个独立的通道DAC通道1和2每个通道的核心是一个12位的数字输入寄存器DORx和一个12位的数模转换器。我们编程时通常操作的是数据保持寄存器DHRx写入的数据会在特定事件如定时器触发或软件触发下自动转移到DORx并开始转换。关键点在于触发源。DAC的转换可以由软件触发写SWTRIG位也可以由多个内部定时器TIM2, TIM4, TIM5, TIM6, TIM7, TIM8的TRGO事件触发。对于生成连续波形这种需要精确时序的任务我们必须使用定时器触发。这样DAC就能以固定且精准的时间间隔由定时器频率决定自动更新其输出值从而在引脚上“连续”地画出我们想要的电压曲线。2.2 波形数据的本质一个预先计算好的“值表”无论是2kHz正弦波还是一段歌曲在DAC看来都是一系列按时间顺序排列的数字值。DAC不关心这个值代表什么它只负责把接收到的数字量比如0-4095对应0-3.3V转换成相应的模拟电压输出。因此我们的核心任务就是为DAC准备一个数据数组Buffer并让定时器以恒定的速度一个接一个地将数组中的值“喂”给DAC。对于2kHz正弦波这是一个周期性的、可以用数学公式y A * sin(2πft φ) Offset描述的波形。我们只需要计算其一个周期内的若干个采样点存入数组即可。定时器会循环地从数组中读取这些值从而输出一个连续的正弦波。这里采样点的数量和定时器的触发频率共同决定了输出正弦波的频率。对于歌曲音频片段这本质上是已经数字化采样了的波形。我们可以从WAV等音频文件中提取出原始的PCM脉冲编码调制采样数据。这些数据本身就是一系列按采样率排列的幅度值。我们的任务就是将这些数据转换成DAC能接受的格式如12位右对齐存入数组然后同样由定时器触发DAC按原采样率播放出来。理解了这一点整个项目的流程就清晰了准备波形数据 - 配置DAC和定时器触发 - 启动DMA自动搬运数据 - 聆听输出。下面我们就分步拆解。3. 硬件与软件环境准备工欲善其事必先利其器。在开始编码前需要把环境和硬件链路理清楚。3.1 硬件连接与核心器件选型我使用的是STM32F103C8T6最小系统板也就是常说的“蓝色药丸”Blue Pill。它的DAC通道1对应引脚是PA4通道2是PA5。我们以通道1PA4为例。输出引脚将PA4引脚引出。DAC输出的是电压信号驱动能力很弱绝对不能直接驱动扬声器或耳机否则可能损坏芯片或声音极小。信号调理与放大为了让声音能被听到我们需要一个简单的音频放大电路。这里有个低成本且效果不错的方案方案一LM386功放模块。这是最常用的迷你音频功放芯片几毛钱一个。将PA4的输出通过一个10uF的耦合电容隔直通交连接到LM386模块的输入端模块输出接一个小喇叭8Ω 0.5W即可。模块需要5V供电。方案二直接驱动耳机需谨慎。对于高阻抗如32Ω以上的耳机可以尝试在PA4和耳机之间串联一个100-220Ω的电阻进行限流同时并联一个10uF的耦合电容隔直。务必先测量DAC输出直流电压确保在安全范围内且音量要从小调起。这种方法有风险不推荐长期使用。我的选择为了更好的效果和安全性我使用了LM386模块。同时在PA4和LM386输入之间我额外加了一个RC低通滤波器如1kΩ电阻和0.1uF电容到地用于滤除DAC输出阶梯波中的高频谐波即“毛刺”让声音更纯净。注意STM32的DAC输出范围是0~Vref通常Vref接3.3V。为了输出正负交替的音频信号交流我们需要让DAC工作在“偏移”模式即让静态输出在1.65VVref/2附近波形围绕这个中心上下摆动。这在数据准备环节处理。3.2 软件工程与库函数选择我使用STM32CubeMX进行初始化配置生成Keil MDK-ARM工程。这能极大简化外设配置过程。系统时钟将系统时钟SYSCLK配置为最高72MHz为定时器提供高精度时钟源。DAC配置使能DAC通道1。触发源选择“Timer 6 Trigger Out Event”。为什么是TIM6因为它是一个基本定时器专为驱动DAC/DMA设计没有PWM等复杂功能简单可靠。输出缓冲使能Output Buffer。这能提高DAC的驱动能力但会引入一点非线性。对于音频通常建议禁用Disable输出缓冲以获得更好的线性度但你需要确保后级电路输入阻抗足够高LM386输入阻抗很高所以可以禁用。定时器6TIM6配置时钟源为内部时钟APB1 Timer Clocks 72MHz。预分频器PSC设置为0即不分频。自动重载值ARR这是关键。它决定了DAC的更新频率即采样率。计算公式为Update Event Frequency TIM6_CLK / ((PSC1)*(ARR1))。例如要得到44.1kHz的音频采样率ARR 72000000 / 44100 - 1 ≈ 1631。触发输出Trigger Output选择“Update Event”。DMA配置为DAC通道1添加DMA流。方向为“Memory To Peripheral”。模式循环模式Circular。这样当数组发送完后DMA会自动从头开始实现循环播放。数据宽度外设和内存都选择“Half Word”半字16位因为DAC的12位数据寄存器是16位对齐的。生成代码生成Keil工程后我们主要需要编写的代码就是波形数据数组和启动逻辑。4. 实战一生成2kHz正弦波我们先从标准的正弦波开始它能验证整个DAC-DMA-Timer链路是否工作正常。4.1 计算并生成正弦波数据表正弦波是周期函数我们只需计算一个周期的数据并存入数组。数组长度即一个周期的采样点数会影响波形的“光滑度”和最终生成的频率精度。步骤与代码实现确定参数目标频率f_signal 2000 Hz(2kHz)DAC更新频率即采样率f_update。这个由TIM6的ARR决定。为了方便我们通常先定一个标准的采样率比如f_update 32000 Hz32kHz。这个频率远高于2kHz的2倍满足奈奎斯特采样定理且容易计算。一个周期采样点数N f_update / f_signal 32000 / 2000 16。这意味着我们用16个点来“描绘”一个完整的正弦波。生成数据数组 我们需要计算这16个点对应的DAC值。DAC是12位范围0-4095。为了得到以1.65V2048为中心的正弦波我们使用公式DAC_Value 2048 2047 * sin(2 * π * i / N)其中i从0到15。2047是幅度保证输出在0-4095范围内实际是1到4095因为sin最小值-1时结果为1。// 在文件开头定义 #define SINE_WAVE_LENGTH 16 uint16_t sineWave[SINE_WAVE_LENGTH]; // 在初始化函数中生成数据 void Generate_SineWave(void) { for (int i 0; i SINE_WAVE_LENGTH; i) { // 计算正弦值幅度为2047中心为2048 sineWave[i] (uint16_t)(2048.0f 2047.0f * sinf(2 * M_PI * i / SINE_WAVE_LENGTH)); } }注意M_PI需要定义或者直接使用数值3.1415926f。使用sinf函数进行浮点计算初始化时执行一次即可。如果你担心浮点运算在启动时的耗时可以预先在电脑上计算好数组直接以常量形式写入代码。4.2 配置DMA并启动传输在CubeMX生成的代码中DMA和DAC的初始化已经完成。我们只需要在main函数的初始化部分之后启动DMA传输。// 在main.c的user code 2区域初始化之后循环之前 Generate_SineWave(); // 生成正弦波数据 // 启动DAC通道1的DMA传输 // 参数DAC通道句柄 内存地址 数据对齐方式 数据长度 if (HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t*)sineWave, SINE_WAVE_LENGTH, DAC_ALIGN_12B_R) ! HAL_OK) { Error_Handler(); } // 注意此时DAC已经使能但转换由定时器触发。需要启动定时器。 HAL_TIM_Base_Start(htim6);关键点解析DAC_ALIGN_12B_R指定数据为12位右对齐。我们的sineWave数组是uint16_t高4位无效低12位是有效数据。这是最常用的格式。调用HAL_DAC_Start_DMA后DMA就会等待触发信号。一旦HAL_TIM_Base_Start启动了TIM6TIM6就会按照设定的ARR周期产生更新事件这个事件触发DACDAC再向DMA请求数据DMA便将下一个sineWave中的值搬运到DAC的数据寄存器中。如此循环往复。4.3 实测与调试技巧将PA4连接到示波器你应该能看到一个频率为2kHz的正弦波。如果没有检查时钟树确保系统时钟、APB1总线时钟TIM6的时钟源配置正确确实是72MHz。检查定时器配置确认TIM6的PSC和ARR值计算正确。例如PSC0 ARR2249时更新频率为 72MHz / (01)*(22491) 32000Hz。检查DAC输出缓冲如果后级电路输入阻抗低输出缓冲必须开启否则电压可能拉不上去。用万用表量一下PA4的直流电压静态时应该在1.65V左右波动。检查DMA配置在stm32f1xx_hal_msp.c中确认DMA通道是否已正确链接到DAC。使用调试器在DMA启动后观察hdac.DMA_Handle-Instance-CNDTR寄存器这个值应该递减表示DMA正在传输。滤波很重要在示波器上你可能会看到阶梯状的波形这是正常的因为DAC是离散采样。接入RC低通滤波器截止频率设为略高于2kHz如3kHz后波形会变得光滑很多。用耳朵听LM386输出的声音应该是一个纯净的单一音调。5. 实战二播放歌曲音频片段生成固定波形只是开胃菜播放真实的音频才是DAC的“高光时刻”。这里我们以播放一段WAV格式的音频为例。5.1 音频数据的获取与预处理WAV文件是存储PCM数据最常见的容器格式。我们需要从WAV文件中提取出原始的音频采样数据。操作流程选择音频片段用Audacity、GoldWave等音频编辑软件截取一段你喜欢的歌曲建议5-10秒太长发数据太大并做如下处理转换为单声道DAC只有一个通道立体声需要混音或只取一个声道。降低采样率STM32F103内存有限处理高采样率如44.1kHz的长音频数据困难。建议将采样率降至8kHz或16kHz。8kHz对于语音和简单旋律足够16kHz音质更好。这里我们以16kHz为例。降低位深度将32位或16位采样转换为8位或12位。为了匹配DAC我们选择12位。但WAV文件没有标准的12位格式所以我们需要将16位的PCM数据缩放到12位范围。导出为WAV导出时选择“PCM 有符号 16位”格式。提取PCM数据并转换使用一个小工具如Python脚本读取WAV文件进行缩放和格式转换。import wave import numpy as np import struct # 打开WAV文件 with wave.open(input.wav, rb) as wav: params wav.getparams() n_channels, sampwidth, framerate, n_frames params[:4] print(fChannels: {n_channels}, SampleWidth: {sampwidth}, FrameRate: {framerate}, Frames: {n_frames}) # 读取所有帧数据 frames wav.readframes(n_frames) # 根据采样宽度解析数据 if sampwidth 2: # 16位PCM格式为有符号短整型 fmt f{n_frames * n_channels}h data np.array(struct.unpack(fmt, frames), dtypenp.int16) else: # 处理其他位深度... pass # 转换为单声道取平均值 if n_channels 2: data data.reshape(-1, 2).mean(axis1).astype(np.int16) # 此时data是一维数组包含有符号16位PCM数据 # 缩放将16位有符号数据(-32768~32767)映射到12位无符号DAC值(0~4095) # 公式DAC_val ((pcm_sample / 32768.0) 1.0) / 2.0 * 4095 dac_data ((data / 32768.0) 1.0) / 2.0 * 4095 dac_data np.clip(dac_data, 0, 4095).astype(np.uint16) # 确保在范围内 # 将数据保存为C语言数组格式 with open(audio_data.h, w) as f: f.write(f#define AUDIO_DATA_LEN {len(dac_data)}\n) f.write(const uint16_t audioData[AUDIO_DATA_LEN] {\n) for i, val in enumerate(dac_data): f.write(f {val}) if i ! len(dac_data) - 1: f.write(,) if (i 1) % 16 0: # 每行16个数据 f.write(\n) f.write(\n};\n) print(fGenerated {len(dac_data)} samples.)运行这个脚本你会得到一个audio_data.h头文件里面包含了转换好的12位DAC数据数组。5.2 工程集成与内存管理将audio_data.h文件添加到你的Keil工程中。这里会遇到一个关键问题数组太大可能超出RAM或Flash。Flash存储const数组通常存储在Flash中。STM32F103C8T6有64KB Flash。一段16kHz采样率、16位的单声道音频每秒数据量是16000 * 2 32KB。10秒就是320KB远超容量。因此必须严格控制音频片段的时长和采样率。对于8kHz、8位的音频数据量会小很多8KB/秒。RAM播放我们的DMA是从内存RAM中读取数据。STM32F103C8T6只有20KB RAM。如果音频数据放在Flash中DMA无法直接读取。我们需要将数据从Flash拷贝到RAM或者直接用DMA从Flash读取需要配置DMA的源地址为Flash地址且内存位宽匹配。更常见的做法是如果数据量小于RAM就全部加载到RAM如果数据量大则需要用到DMA的双缓冲Double Buffer或循环缓冲Circular Buffer结合定时器中断从Flash分段加载这比较复杂。简化方案我们选择一段很短的音频比如2-3秒采样率8kHz位深8位在脚本中缩放至12位。这样数据量在几KB以内可以轻松放入RAM。在main.c中#include audio_data.h // 包含生成的数据 uint16_t audioBuffer[AUDIO_DATA_LEN]; // 在初始化时可以将const数组拷贝到RAM缓冲区如果DMA源地址直接指向Flash常量则不需要拷贝 memcpy(audioBuffer, audioData, AUDIO_DATA_LEN * sizeof(uint16_t));5.3 配置定时器与启动播放播放音频的关键是让DAC的更新频率采样率与音频数据的原始采样率一致。重新配置TIM6在CubeMX中修改TIM6的ARR值使其产生与音频采样率一致的触发频率。例如对于8kHz音频ARR 72000000 / 8000 - 1 8999。 对于16kHz音频ARR 72000000 / 16000 - 1 4499。 生成代码。启动播放和播放正弦波类似但数据源换成了音频数组。// 启动DAC DMA传输音频数据 if (HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t*)audioBuffer, AUDIO_DATA_LEN, DAC_ALIGN_12B_R) ! HAL_OK) { Error_Handler(); } HAL_TIM_Base_Start(htim6);处理播放完成DMA在循环模式下会一直重复播放。如果你只想播放一次需要将DMA模式设置为“Normal”单次并在DMA传输完成中断回调函数HAL_DAC_ConvCpltCallbackCh1中停止定时器和DAC。void HAL_DAC_ConvCpltCallbackCh1(DAC_HandleTypeDef* hdac) { // 传输完成回调 HAL_TIM_Base_Stop(htim6); HAL_DAC_Stop_DMA(hdac, DAC_CHANNEL_1); // 可以在这里设置一个标志位通知主循环播放结束 }5.4 音质优化与常见问题声音卡顿或杂音中断干扰确保音频播放期间没有其他高优先级中断如SysTick中断处理任务过长打断DMA传输。可以尝试提高DMA和TIM6的中断优先级。数据对齐确认DMA和DAC的数据对齐方式匹配。我们用了12位右对齐内存中的数据高4位必须为0。电源噪声模拟电路部分供电不稳会引入噪声。给模拟部分DAC输出到功放使用独立的LDO供电并增加滤波电容。声音失真削顶失真原始音频数据幅度过大缩放后超出了0-4095范围。在Python预处理阶段使用np.clip函数限幅。采样率不匹配TIM6的ARR计算错误导致播放速度变快或变慢音调不对。仔细核对时钟和ARR计算公式。内存不足这是最可能的问题。务必估算音频数据大小。一个实用的技巧是使用SD卡或SPI Flash存储长音频然后使用DMA双缓冲模式一边播放当前缓冲区的数据一边从存储设备读取下一段数据填充另一个缓冲区。这需要用到FATFS文件系统和更复杂的中断管理是进阶玩法。6. 进阶思考与优化方向当你成功播放出第一段音乐后可以尝试以下优化让项目更完善动态音量控制可以在DMA传输完成中断中动态缩放音频缓冲区中的数据值实现简单的音量调节。注意缩放后要重新限制范围。多声道混合伪立体声虽然只有一个DAC但你可以通过时分复用的方式快速切换输出两路不同的信号需要额外的模拟开关电路或者简单地用两个GPIO配合RC电路模拟但这效果有限。使用定时器触发DMA的双缓冲这是实现流畅播放长音频的关键。配置DMA为双缓冲模式当一半数据传输完成时产生中断在中断中填充下一半数据。这样可以实现音频数据的“流式”播放无需将整个文件加载进内存。加入按键控制结合外部中断实现播放/暂停、切歌等功能。输出波形可视化可以将DAC的输出同时接到单片机的ADC输入进行采样并发送到串口在电脑上用工具如Python的Matplotlib绘制出实际输出的波形与理论波形对比分析失真。通过这个项目你不仅掌握了STM32 DAC的基本应用更深入理解了数字信号如何通过数模转换变为模拟信号以及实时音频播放的基本流程。从固定的正弦波到动态的音频片段这一步跨越让你真正触摸到了嵌入式系统中信号处理的门槛。
返回列表