简介:这份资源面向嵌入式开发初学者与STM32学习者,围绕STM32F103ZET6内置12位DAC实现音频输出这一实验场景,帮助读者理解数字信号转模拟信号、WAV转C数组、定时器与DMA驱动播放等关键环节,并涉及外接功放与扬声器的硬件连接思路。压缩包共181个文件,约5.54MB,以44个h头文件、39个c源文件为核心,配合25个o目标文件、25个d依赖文件及24个crf等编译中间产物,另含3个wav音频素材、3个txt说明、Keil工程文件(uvprojx、uvoptx、sct、axf、hex)与批处理脚本,构成可直接编译下载的完整工程。目前已有4862人学习下载,适合作为DAC音频实验的参考模板,读者可据此快速搭建工程、对照源码理解DAC配置与音频数据播放流程,并在此基础上扩展更复杂的嵌入式音频应用。
1. 从一块“哑巴”开发板到能唱歌的DAC:这个实验到底在做什么
手头有块 STM32F103 的最小系统板,跑过点灯、串口打印、PWM 调光,但总觉得少了点“能感知”的东西。直到把示波器探头夹在某个引脚上,看到正弦波从一堆方波里慢慢浮出来,才意识到 DAC 这个外设一直被忽略了。这个实验做的事情很纯粹:用 STM32F103 自带的 12 位 DAC 通道,把预先算好的数字采样值按固定时间间隔喂进去,让 PA4 或 PA5 引脚输出连续变化的模拟电压,接上耳机或者小功放就能听到声音。它适合已经会配置 GPIO 和定时器、想搞明白“数字音频到底怎么从单片机里出来”的嵌入式开发者。不需要外部音频解码芯片,也不需要文件系统,核心就是定时器触发 + DMA 搬运 + DAC 转换这条链路。搞通之后,做简易信号发生器、语音提示模块、甚至低采样率的 WAV 播放器,都是在这个骨架上加东西。
2. 硬件链路与工程结构:DAC 出声之前需要理清的几个连接
2.1 为什么选 PA4/PA5 而不是随便找个引脚
STM32F103 的 DAC 输出通道是固定映射到 PA4(通道 1)和 PA5(通道 2)的,这不是软件可以改的。很多新手翻原理图时看到 PA4 旁边标着 SPI1_NSS 或者 ADC12_IN4,就以为要避开,其实只要把 DAC 使能了,这个引脚的数字功能会自动让位给模拟输出。我一般会在 CubeMX 里先把 PA4 配成 DAC_OUT1,然后检查一下有没有其他外设占着这个脚。如果用的是那种带运放跟随的板子,PA4 后面可能已经接了电压跟随器,输出阻抗低,直接接耳机问题不大;如果是裸引脚,最好加一个隔直电容再接负载,否则直流分量会让耳机偏磁。这一点在实验里经常被忽略,现象就是声音出来了但很小,或者耳机里有“咔”的一声。
2.2 时钟树和定时器触发的关系
DAC 要输出音频,核心是“等间隔更新”。STM32F103 的 DAC 支持多种触发源,TIM2、TIM4、TIM5、TIM6、TIM7 的 TRGO 事件都能用。我一般选 TIM6,因为它基本定时器,没有输出引脚,不会跟其他外设抢资源。假设要输出 8kHz 采样率的音频,定时器就要每 125 微秒触发一次。系统时钟 72MHz,预分频器设成 72-1,计数器周期设成 125-1,这样更新事件频率正好是 72MHz / 72 / 125 = 8kHz。这个计算看着简单,但实际配置时经常有人把预分频和周期搞反,结果出来的声音要么快放要么慢放。配置完之后,在 DAC 的触发源里选 TIM6_TRGO,DMA 请求也一并使能,剩下的就是往缓冲区里填数据了。
2.3 工程目录里哪些文件真正在干活
一个典型的 Keil 或 STM32CubeIDE 工程,跟这个实验相关的文件其实就几个。main.c里放初始化和主循环,dac.c和tim.c是 CubeMX 生成的配置代码,真正需要自己写的是一个采样数据数组和 DMA 启动语句。如果用的是标准外设库,那就要手动写DAC_InitTypeDef和TIM_TimeBaseInitTypeDef,再开 DMA 通道。我习惯把采样数据单独放在一个audio_data.h里,用const uint16_t存,这样编译器会把它放到 Flash 里,不占 RAM。下面是一个最小工程的目录示意:
Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── dac.h │ │ ├── tim.h │ │ └── audio_data.h // 采样表 │ └── Src/ │ ├── main.c │ ├── dac.c │ ├── tim.c │ └── stm32f1xx_it.c ├── Drivers/ │ └── STM32F1xx_HAL_Driver/ └── MDK-ARM/ // 或 STM32CubeIDE 工程文件这个结构里,audio_data.h是唯一跟“音频内容”有关的东西,其他都是通道配置。改声音就是改这个数组,改采样率就是改定时器参数,分工很清晰。
3. 核心代码拆解:从采样表到模拟波形的完整链路
3.1 生成正弦采样表的两种做法
DAC 输出正弦波,第一步是有一张表。最简单的是在代码里用sin()算,但浮点运算在 F103 上跑起来慢,而且每次启动都算一遍没必要。我一般用 Python 或者 Octave 离线算好,存成 C 数组。比如要输出 1kHz 正弦波,采样率 8kHz,那一个周期就是 8 个点。但 8 个点太粗糙,听感上谐波很重,所以实际会取 32 或 64 个点,然后让定时器按 8kHz 触发,这样输出频率就是 8000 / 64 = 125Hz。想要 1kHz,要么提高采样率到 64kHz,要么减少点数。F103 的 DAC 建立时间大约 1 微秒,64kHz 完全没问题。下面是一个 32 点正弦表的生成代码:
# 生成 32 点正弦采样表,12 位 DAC,范围 0-4095 import math points = 32 for i in range(points): # 加 2048 偏置是因为 DAC 输出范围是 0-3.3V,正弦要居中 value = int(2048 + 2047 * math.sin(2 * math.pi * i / points)) print(f"0x{value:03X}, ", end="") if (i + 1) % 8 == 0: print()这段代码输出的是 12 位右对齐的数据,直接填进uint16_t数组就行。注意偏置 2048 和幅度 2047 的搭配,保证不会溢出到 0 以下或 4095 以上。如果想让声音大一点,可以把幅度调到 2000 左右,留一点余量,因为 DAC 在接近轨到轨的时候线性度会变差。
3.2 DAC + DMA + 定时器的初始化顺序
初始化顺序错了,DAC 要么不出波形,要么只输出一个直流电平。正确的顺序是:先开时钟,再配 GPIO 为模拟模式,然后配 DMA,再配 DAC,最后配定时器并启动。下面是一段基于 HAL 库的初始化代码:
// 1. 使能时钟 __HAL_RCC_DAC_CLK_ENABLE(); __HAL_RCC_DMA1_CLK_ENABLE(); __HAL_RCC_TIM6_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); // 2. PA4 配成模拟模式 GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_4; gpio.Mode = GPIO_MODE_ANALOG; HAL_GPIO_Init(GPIOA, &gpio); // 3. DMA 配置:从内存搬到 DAC 数据寄存器 hdma_dac.Instance = DMA1_Channel3; // DAC_CH1 对应 DMA1_Channel3 hdma_dac.Init.Direction = DMA_MEMORY_TO_PERIPH; hdma_dac.Init.PeriphInc = DMA_PINC_DISABLE; hdma_dac.Init.MemInc = DMA_MINC_ENABLE; hdma_dac.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_dac.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_dac.Init.Mode = DMA_CIRCULAR; // 循环模式,音频连续输出 hdma_dac.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_dac); __HAL_LINKDMA(&hdac, DMA_Handle1, hdma_dac); // 4. DAC 配置,触发源选 TIM6 TRGO hdac.Instance = DAC; HAL_DAC_Init(&hdac); DAC_ChannelConfTypeDef dac_ch = {0}; dac_ch.DAC_Trigger = DAC_TRIGGER_T6_TRGO; dac_ch.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; HAL_DAC_ConfigChannel(&hdac, &dac_ch, DAC_CHANNEL_1); // 5. 启动 DMA 和 DAC HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, (uint32_t *)sine_table, 32, DAC_ALIGN_12B_R); // 6. 定时器配置:72 分频,125 周期 -> 8kHz htim6.Instance = TIM6; htim6.Init.Prescaler = 72 - 1; htim6.Init.Period = 125 - 1; HAL_TIM_Base_Init(&htim6); HAL_TIM_Base_Start(&htim6);这段代码里几个关键点:DMA 必须配成循环模式,否则放完一遍就停了;HAL_DAC_Start_DMA的最后一个参数是数据对齐方式,12 位右对齐对应DAC_ALIGN_12B_R;定时器的Period决定采样间隔,改这个值就能改音调。如果发现没有波形,先查 DMA 通道有没有选对,F103 的 DAC 通道 1 固定用 DMA1_Channel3,通道 2 用 DMA1_Channel4,这个在参考手册里有表,选错了不会报错但就是不动。
3.3 主循环里到底还要不要做事
DMA 和定时器都跑起来之后,主循环其实是空的。CPU 只需要在初始化完成后进入while(1),剩下的搬运和转换全是硬件自动完成。这也是这个实验适合入门的原因:没有中断嵌套,没有实时任务调度,纯粹是外设之间的配合。如果想做更复杂的东西,比如按键切歌,那就在主循环里轮询按键,然后重新调用HAL_DAC_Start_DMA换一张表。注意换表之前要先HAL_DAC_Stop_DMA,否则新旧数据会混在一起,听起来像爆音。
4. 避坑与排查:DAC 不出声时先查这五个地方
4.1 现象:示波器上只有一条直线,没有波形
先看 DAC 的输出缓冲使能了没有。F103 的 DAC 输出缓冲默认是关闭的,关闭状态下输出阻抗很高,带不动负载,示波器探头接上去可能直接把它拉平。在DAC_ChannelConfTypeDef里把DAC_OutputBuffer设成DAC_OUTPUTBUFFER_ENABLE,输出能力会强很多。如果还是直线,检查 DMA 有没有真正启动,可以在HAL_DAC_Start_DMA后面加一句读 DMA 使能位的代码,确认EN位是 1。
4.2 现象:有波形但频率不对,要么快一倍要么慢一半
这是定时器参数算错了。常见错误是把预分频值直接写成 72 而不是 72-1,HAL 库的Prescaler是“分频系数减一”,写 72 实际是 73 分频。另一个坑是Period写成了 125 而不是 124,导致实际频率是 72MHz / 72 / 126 ≈ 7.94kHz,跟预期的 8kHz 差了一点。听感上可能不明显,但用示波器测周期就能看出来。我一般会在初始化之后用调试器看一眼 TIM6 的 ARR 和 PSC 寄存器,确认跟手算的一致。
4.3 现象:声音断断续续,或者放一小段就停了
DMA 模式没设成循环。DMA_CIRCULAR和DMA_NORMAL的区别就是前者搬完一圈自动回到开头继续搬,后者搬完就停。音频必须用循环模式。另外检查一下 DMA 的计数器有没有被意外清零,如果开了 DMA 传输完成中断但没处理,可能会进中断后卡住。最简单的做法是不开中断,纯轮询标志位也不开,就让 DMA 自己跑。
4.4 现象:接上耳机有很响的“嗡嗡”声
这是直流偏置导致的。DAC 输出的正弦波是叠加在 1.65V 直流上的,直接接耳机,直流分量会让音圈偏离中心位置,轻则声音小,重则发热。解决方法是加一个隔直电容,通常用 10uF 到 100uF 的电解电容,正极接 DAC 输出,负极接耳机。如果板子上已经有电容但还有嗡嗡声,可能是电源纹波耦合进来了,在 DAC 输出和地之间并一个 100nF 的瓷片电容能滤掉一部分高频噪声。
4.5 现象:换了一张采样表,声音变成刺耳的噪声
采样表的长度和 DMA 配置的缓冲区长度不一致。HAL_DAC_Start_DMA的第四个参数是“要搬运的数据个数”,如果表是 64 点但这里写 32,那只会搬前一半,波形就断了。另外,如果新表的点数跟定时器周期不匹配,比如定时器还是按 8kHz 触发但表变成了 16 点,输出频率就会翻倍。换表的时候要同时改 DMA 长度和定时器周期,这两个是绑定的。
5. 二次开发可以往哪走:从固定正弦到可交互音频
5.1 把采样表换成 WAV 文件片段
固定正弦波听久了会腻,下一步自然是放真实声音。F103 的 Flash 有 64KB 或 128KB,放一段 8kHz、8 位的单声道 WAV 可以存十几秒。做法是用工具把 WAV 转成 C 数组,每个采样点取 8 位无符号数,然后在 DMA 搬运之前左移 4 位变成 12 位(因为 DAC 是 12 位的)。注意 WAV 文件头要去掉,只取数据段。下面是一个转换脚本的片段:
import wave with wave.open('voice.wav', 'rb') as w: frames = w.readframes(w.getnframes()) # 假设是 8 位单声道,直接取字节 for i, b in enumerate(frames): dac_val = b << 4 # 8 位转 12 位 print(f"0x{dac_val:03X}, ", end="") if (i + 1) % 12 == 0: print()这段代码把 8 位采样值映射到 12 位 DAC 范围。如果原始 WAV 是 16 位的,就要取高 12 位,右移 4 位再送 DAC。采样率也要对应调整定时器,8kHz 的 WAV 就用 8kHz 的触发频率,否则声音会变调。
5.2 用按键或串口动态切换波形
在主循环里加一个按键扫描,按一下切换一张表,正弦、方波、三角波轮流来。方波表很简单,前一半填 4095,后一半填 0。三角波就是线性递增再递减。切换的时候先HAL_DAC_Stop_DMA,改HAL_DAC_Start_DMA的缓冲区指针和长度,再启动。如果通过串口接收命令,可以用一个简单的状态机解析,收到 's' 就切正弦,'q' 就切方波。注意串口中断的优先级不要设得比定时器高,否则可能会打断 DMA 请求,导致波形抖动。
5.3 提高采样率的边界在哪里
F103 的 DAC 最高转换率在数据手册里写的是 1MHz,但实际用 DMA 搬运时,受限于总线和 Flash 读取速度,稳定跑到 200kHz 左右问题不大。再高的话,DMA 搬运会跟 CPU 取指抢总线,波形开始出现台阶。如果想做 16kHz 采样率的音频,一个周期 64 点,输出频率就是 250Hz,这个范围做人声提示音足够了。想放音乐,要么降低采样率到 8kHz 并接受音质损失,要么换带 I2S 的型号。这个实验的价值在于把 DAC 的基本链路跑通,后面换芯片或者加外部 codec,思路是一样的。
5.4 一个容易忽略的细节:DAC 输出后的滤波
DAC 输出的是阶梯波,每个采样点保持一个定时器周期,频谱里除了基波还有大量高频谐波。如果直接接功放,这些高频会听起来像“沙沙”声。简单做法是在输出端加一个 RC 低通滤波器,截止频率设在采样率的一半左右。比如 8kHz 采样,截止频率设 4kHz,用 1kΩ 电阻和 100nF 电容组成的一阶滤波就够了。这个滤波器要放在隔直电容之前,否则电容的容抗会跟电阻分压,改变截止频率。我一般会在洞洞板上先搭一个试听,确认效果再画到 PCB 上。
从那以后我每次调 DAC 输出,都强制先用示波器看静态直流电平,确认在 1.65V 附近再启动 DMA,不然直接上耳机很容易被直流偏置吓一跳。希望帮到你。
本文还有配套的精品资源,点击获取