news 2026/10/19 17:09:22

STM32F103 DAC音频输出实验:从波形可控到参数可算的完整方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103 DAC音频输出实验:从波形可控到参数可算的完整方案

简介:这份资源面向嵌入式开发初学者与STM32学习者,围绕STM32F103ZET6内置12位DAC实现音频输出这一实验展开,帮助读者理解数字信号到模拟电压的转换过程,并完成从WAV音频数据到扬声器发声的完整链路。压缩包共181个文件,约5.54MB,以c与h源码、o与d编译中间文件、crf与lst等工程记录为主,另含wav音频素材、txt说明、Keil工程文件及hex、axf等烧录产物,结构完整,可直接用于复现实验。目前已有4862人学习下载。资源涵盖DAC初始化、时钟与GPIO配置、定时器触发采样、DMA搬运数据以及外接功放驱动扬声器等关键环节,读者可据此掌握音频采样率匹配、缓冲输出与噪声排查思路,为后续开发更复杂的嵌入式音频系统打下基础。

1. 从“能响就行”到“波形可控”:STM32F103 DAC音频输出实验到底要解决什么

很多人第一次碰 STM32F103 的 DAC,目标都很朴素——让蜂鸣器或者耳机里出点声音,证明这块芯片“能响”。但真到要交一份完整的课程设计或者实验报告时,问题就来了:声音是出来了,可波形长什么样?频率准不准?换首歌还能不能放?采样率一改为什么全是噪声?这套 DAC 音频输出实验,要解决的恰恰不是“能不能响”,而是“波形可控、参数可算、结果可复现”。它适合正在做嵌入式课设的学生、需要一份完整音频输出链路的从业者,以及想把 DAC 从“玄学调参”变成“按公式算参数”的人。下面这套方案,从需求、选型、设计到验收,按能直接抄作业的粒度展开。

2. 需求拆解与技术选型:为什么是 STM32F103 的 DAC 而不是 PWM 凑合

2.1 先明确音频输出的三个硬指标

做音频输出实验,最怕一上来就写代码。我一般会先把指标定死,否则后面调参全靠猜。这套实验的核心指标有三个:第一是输出波形类型,至少要覆盖正弦波、方波、三角波,最好能扩展到任意波形表;第二是频率范围,人耳可听范围是 20Hz 到 20kHz,但受限于 STM32F103 的 DAC 建立时间和定时器触发频率,实际能稳定输出的正弦波上限通常在 10kHz 左右,方波可以更高;第三是采样率与更新率,DAC 每输出一个点都需要一次触发,触发频率决定了波形的“细腻程度”。

把这三个指标写进需求文档,后面选型才有依据。很多同学翻车就翻在没定指标,直接抄一段 DMA 代码,结果频率对不上,改一个参数全乱套。

2.2 为什么不用 PWM 加滤波,而用片内 DAC

STM32F103 的 PWM 加 RC 滤波确实能出模拟波形,成本也低,但它的致命伤是:波形质量高度依赖滤波器的截止频率和负载阻抗,换个耳机、改个音量,波形就变形。而 STM32F103 自带的 12 位 DAC 是真正的数模转换,输出阻抗稳定,配合 DMA 可以做到“算好一张表,硬件自动播”。

选型结论很明确:需要波形保真度和可重复性,就用片内 DAC;只是做个报警提示音,PWM 足够。这套实验既然叫“DAC 音频输出”,就老老实实走 DAC 通道。

2.3 关键外设组合:DAC + DMA + 定时器

STM32F103 的 DAC 有两个通道,通道 1 对应 PA4,通道 2 对应 PA5。要让 DAC 连续输出不同电压,必须解决“谁来喂数据”的问题。CPU 轮询写 DAC 寄存器,频率一高就顾不上别的;用中断写,中断太频繁会拖垮系统。标准做法是 DAC + DMA + 定时器触发:

  • 定时器(常用 TIM2 或 TIM6)产生固定频率的触发信号;
  • 每次触发,DAC 从 DMA 搬运一个数据到数据保持寄存器;
  • DMA 循环模式,数据搬完自动从头开始。

这样 CPU 只在初始化时配置一次,之后完全不参与波形输出。下面这张表把三个外设的职责和关键参数列清楚:

外设职责关键参数典型配置
DAC数模转换分辨率、触发源、输出缓冲12 位,TIM6 触发,开启输出缓冲
DMA数据搬运传输方向、循环模式、数据宽度外设到 DAC,循环,半字
定时器触发节拍预分频、自动重装值72MHz 下分频到目标采样率

提示:DAC 的输出缓冲建议开启,否则带负载能力很弱,接耳机几乎听不到声音。

3. 总体设计:波形表生成、DMA 搬运与定时器触发的三段式结构

3.1 波形数据表的生成方式

DAC 输出正弦波的本质,是把一个周期的正弦函数离散成 N 个点,每个点算出一个 0 到 4095 之间的整数(12 位 DAC),然后让 DMA 按顺序把这些整数喂给 DAC。N 越大,波形越细腻,但占用内存也越多。我一般取 128 或 256 个点,兼顾精度和内存。

生成正弦表有两种方式:一是在代码里用sin()函数实时算,二是提前算好存成常量数组。实时算会引入浮点运算,STM32F103 没有硬件 FPU,跑起来很慢,所以推荐提前算好。下面是一段生成正弦表的 Python 脚本,用来生成 C 语言数组:

import math POINTS = 256 # 一个周期的采样点数 AMPLITUDE = 2047 # 12位DAC满量程4095,取一半偏置 OFFSET = 2048 # 直流偏置,保证波形在0~4095之间 print("const uint16_t sine_table[%d] = {" % POINTS) for i in range(POINTS): # 正弦值映射到0~4095,并做四舍五入 value = int(round(OFFSET + AMPLITUDE * math.sin(2 * math.pi * i / POINTS))) print(" %d," % value, end="") if (i + 1) % 8 == 0: print() print("};")

这段脚本的关键参数是POINTS和AMPLITUDE。POINTS决定频率分辨率,AMPLITUDE决定输出幅度。注意OFFSET取 2048 是为了让正弦波有正有负,因为 DAC 只能输出 0 到 3.3V,没有负电压。如果直接输出负值,DAC 会截断成 0,波形就废了。

3.2 定时器触发频率与输出频率的换算

这是整个实验里最容易算错的地方。DAC 每收到一次触发,就输出一个点。如果波形表有 256 个点,定时器触发频率是 f_trigger,那么输出波形的频率就是:

f_out = f_trigger / POINTS

反过来,想要输出 1kHz 的正弦波,波形表 256 个点,定时器触发频率就得是 256kHz。STM32F103 的 TIM6 挂在 APB1 总线上,时钟通常是 72MHz(如果系统时钟配置为 72MHz)。要得到 256kHz 的触发频率,预分频器和自动重装值这样算:

  • 定时器计数频率 = 72MHz / (PSC + 1)
  • 触发频率 = 计数频率 / (ARR + 1)

取 PSC = 0,则计数频率 72MHz,ARR = 72MHz / 256kHz - 1 = 280。所以 PSC=0,ARR=280,就能得到约 256kHz 的触发频率,对应 1kHz 正弦波输出。

下面是一段初始化 TIM6 的代码示例:

// 假设系统时钟72MHz,APB1定时器时钟72MHz void TIM6_Init(uint16_t arr, uint16_t psc) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM6, ENABLE); TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Period = arr; // 自动重装值 TIM_TimeBaseStructure.TIM_Prescaler = psc; // 预分频 TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM6, &TIM_TimeBaseStructure); // 使能TIM6的触发输出,用于触发DAC TIM_SelectOutputTrigger(TIM6, TIM_TRGOSource_Update); TIM_Cmd(TIM6, ENABLE); }

arr和psc就是上面算出来的值。改频率只需要改这两个参数,不用动波形表。这里有个血泪经验:改完arr和psc后,一定要重新使能 DAC 的触发,否则波形会停在最后一个值不动。

3.3 DMA 配置与数据搬运的细节

DMA 的配置决定了数据能不能顺畅地从内存搬到 DAC。STM32F103 的 DAC 通道 1 对应 DMA1 通道 3,通道 2 对应 DMA1 通道 4。配置时要注意几个点:

  • 外设地址是&DAC->DHR12R1,这是 DAC 通道 1 的 12 位右对齐数据寄存器;
  • 内存地址是波形表数组的首地址;
  • 传输方向是内存到外设;
  • 数据宽度是半字(16 位),因为 DAC 数据寄存器是 16 位;
  • 循环模式必须开启,否则播完一遍就停了。
void DAC_DMA_Init(uint16_t *wave_table, uint16_t table_size) { // 使能DMA1时钟和DAC时钟 RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_DAC, ENABLE); DMA_InitTypeDef DMA_InitStructure; DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&(DAC->DHR12R1); DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)wave_table; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; // 内存到外设 DMA_InitStructure.DMA_BufferSize = table_size; 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(DMA1_Channel3, &DMA_InitStructure); DMA_Cmd(DMA1_Channel3, ENABLE); // DAC配置:使能通道1,使用TIM6触发,开启输出缓冲 DAC_InitTypeDef DAC_InitStructure; 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); DAC_Cmd(DAC_Channel_1, ENABLE); DAC_DMACmd(DAC_Channel_1, ENABLE); // 关键:使能DAC的DMA请求 }

这段代码里最容易漏的是最后一行DAC_DMACmd。没有它,DMA 搬数据,DAC 不认,波形就是一条直线。另外DMA_BufferSize必须和波形表长度一致,写错了要么只播一部分,要么读越界。

4. 模块实现:从正弦波到任意波形的代码落地

4.1 正弦波输出的完整调用链

把前面的模块串起来,主函数里只需要做三件事:初始化波形表、初始化 DMA 和 DAC、启动定时器。下面是一个最小可运行的主函数框架:

extern const uint16_t sine_table[256]; // 由Python脚本生成 int main(void) { // 系统时钟初始化,配置为72MHz SystemInit(); // 初始化DMA和DAC,传入正弦表 DAC_DMA_Init((uint16_t *)sine_table, 256); // 初始化TIM6,触发频率256kHz,对应1kHz正弦波 // PSC=0, ARR=280 TIM6_Init(280, 0); while (1) { // 主循环空转,波形由DAC+DMA+定时器自动输出 } }

这段代码跑起来后,用示波器接 PA4,应该能看到一个 1kHz 的正弦波,峰峰值约 3.3V(实际会略低,因为输出缓冲有压降)。如果波形频率不对,先查 TIM6 的arr和psc;如果波形幅度不对,查正弦表的AMPLITUDE和OFFSET;如果波形是一条直线,查DAC_DMACmd有没有调用。

4.2 方波和三角波的实现差异

方波和三角波不需要单独写驱动,只需要换一张波形表。方波表很简单:前一半是 4095,后一半是 0。三角波表是线性递增再线性递减。用同样的 Python 脚本生成即可:

POINTS = 256 # 方波表 print("const uint16_t square_table[%d] = {" % POINTS) for i in range(POINTS): value = 4095 if i < POINTS // 2 else 0 print(" %d," % value, end="") if (i + 1) % 8 == 0: print() print("};") # 三角波表 print("const uint16_t triangle_table[%d] = {" % POINTS) for i in range(POINTS): if i < POINTS // 2: value = int(4095 * i / (POINTS // 2)) else: value = int(4095 * (POINTS - i) / (POINTS // 2)) print(" %d," % value, end="") if (i + 1) % 8 == 0: print() print("};")

换波形时,只需要把DAC_DMA_Init的第二个参数换成对应的表,并重新初始化 DMA 的DMA_BufferSize。注意:DMA 传输过程中不能直接改内存地址,必须先DMA_Cmd(DISABLE),改完再使能。

4.3 频率动态调整的两种做法

实验做到后面,通常要求“按键调频率”或者“串口发指令改频率”。有两种做法:

第一种是改定时器的ARR和PSC。这种方法响应快,但频率分辨率受限于定时器位数。比如要输出 1kHz 到 10kHz,ARR从 280 变到 28,步进比较大,低频段调不准。

第二种是改波形表的点数。比如固定定时器触发频率 256kHz,波形表 256 点对应 1kHz,128 点对应 2kHz,64 点对应 4kHz。这种方法频率是成倍变化的,适合做倍频切换,不适合连续调。

我一般会结合两种:粗调改点数,细调改ARR。下面是一个改ARR的函数示例:

// 根据目标频率计算ARR值,假设PSC=0,系统时钟72MHz,波形表256点 void Set_Frequency(uint32_t target_freq) { // 触发频率 = 目标频率 * 波形表点数 uint32_t trigger_freq = target_freq * 256; // ARR = 72MHz / 触发频率 - 1 uint32_t arr = 72000000 / trigger_freq - 1; TIM6->ARR = arr; // 直接写寄存器,立即生效 TIM6->EGR = TIM_EGR_UG; // 产生更新事件,让新值生效 }

这里TIM6->EGR = TIM_EGR_UG是必须的,否则新写入的ARR要等到下一次溢出才生效,调频率时会有延迟感。

5. 避坑与常见问题排查:DAC 音频输出最容易翻车的五个点

5.1 波形是一条直线,示波器什么都不显示

现象:代码编译下载后,PA4 引脚电压恒定,没有波形。

原因:最常见的是DAC_DMACmd没有调用,DAC 没有发出 DMA 请求,DMA 搬了数据但 DAC 不接收。其次是 DMA 通道选错,DAC 通道 1 必须用 DMA1 通道 3,通道 2 必须用 DMA1 通道 4。

解决:检查DAC_DMACmd(DAC_Channel_1, ENABLE)是否存在;检查 DMA 初始化里用的是不是DMA1_Channel3;用调试器看 DMA 的CNDTR寄存器是否在递减,如果不递减说明 DMA 没启动。

5.2 波形频率对不上,差一倍或者差几倍

现象:想要 1kHz,实际出来 2kHz 或者 500Hz。

原因:定时器时钟算错了。STM32F103 的 APB1 总线时钟在系统时钟 72MHz 时是 36MHz,但定时器时钟会倍频到 72MHz。如果误以为 APB1 是 72MHz 再分频,就会差一倍。另外,波形表点数写错也会导致频率偏差。

解决:确认RCC_ClocksTypeDef里PCLK1_Frequency的值,定时器时钟是它的两倍(当 APB1 分频系数不为 1 时)。用示波器测实际频率,反推ARR和PSC是否正确。

5.3 输出声音有“滋滋”噪声,波形上有毛刺

现象:正弦波看起来还行,但接耳机能听到明显的高频噪声。

原因:DAC 输出没有加低通滤波。DAC 输出的是阶梯波,含有大量高频谐波,人耳听到的就是“滋滋”声。另外,如果 DMA 传输和定时器触发不同步,也会产生周期性毛刺。

解决:在 PA4 和地之间加一个简单的 RC 低通滤波器,截止频率取 20kHz 左右。电阻取 1kΩ,电容取 10nF,截止频率约 15.9kHz。如果还不行,检查定时器触发源是否配置为TIM_TRGOSource_Update。

5.4 改频率后波形卡死不动

现象:调用Set_Frequency后,波形停在某个电压值不变。

原因:直接写TIM6->ARR后没有产生更新事件,定时器还在用旧的ARR值计数,但 DAC 的触发可能已经乱了。另外,如果ARR设得太小,触发频率超过 DAC 的建立时间,DAC 输出会跟不上。

解决:写完ARR后立即写TIM6->EGR = TIM_EGR_UG。同时确认ARR不小于 10,否则触发频率超过 7MHz,DAC 根本反应不过来。

5.5 接耳机声音极小或者完全没声

现象:示波器看波形正常,但接耳机几乎听不到。

原因:DAC 输出缓冲的驱动能力有限,直接驱动 32Ω 耳机,电压会被拉低。另外,如果耳机是立体声插头,只接了单声道,可能只听到一边。

解决:加一个射极跟随器或者运放缓冲级,提高驱动能力。实验阶段可以用一个 100Ω 电阻串联耳机,虽然声音小但能听清。如果要驱动扬声器,必须加功放模块。

6. 验收与答辩:怎么证明这套 DAC 音频输出实验真的做完了

验收不是把代码跑起来就完事。我一般会按下面这张表逐项过,每一项都有明确的通过标准:

验收项通过标准检查方法
正弦波输出1kHz 正弦波,峰峰值≥3V,无明显失真示波器接 PA4,测频率和幅度
方波输出1kHz 方波,上升沿<10μs示波器测上升时间
三角波输出1kHz 三角波,线性度良好示波器观察斜率是否均匀
频率可调通过按键或串口,频率在 100Hz~5kHz 可调改频率后示波器读数跟随变化
波形切换三种波形可切换,切换后无卡死切换后示波器波形正确
音频输出接耳机能听到对应频率的声音人耳听,无明显噪声

答辩时,导师最常问的三个问题是:定时器触发频率怎么算的?DMA 为什么用循环模式?波形表点数改了频率怎么变?这三个问题分别对应第 3.2 节、第 3.3 节和第 4.3 节,提前把公式和代码准备好,现场直接画图解释。

还有一个容易被忽略的点:答辩演示时,最好把示波器画面和代码同屏展示。一边改ARR,一边看波形频率变化,比单纯讲代码有说服力得多。我习惯在演示前把几个关键频率对应的ARR值写在纸上,现场直接改,不翻代码。

从那以后我每次做 DAC 实验,都强制先算一遍触发频率和ARR,再写代码。这个习惯帮我省掉了至少三次“波形不对但不知道哪错了”的返工。希望这套方案能帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 17:07:28

Open Code Review:CLI+Agent+Git Diff 的自动化评审工作流

1. 项目概述&#xff1a;这不是一个工具&#xff0c;而是一套可落地的开源代码评审工作流“open-code-review”这个词乍一听像某个新发布的开源项目名&#xff0c;但其实它代表的是一种正在快速演进的工程实践范式——把代码评审&#xff08;Code Review&#xff09;这件事&…

作者头像 李华
网站建设 2026/10/19 17:06:21

基于YOLOv11的无人机检测识别系统实战解析

无人机反制是这两年非常热的方向&#xff0c;无论是机场净空保护、大型活动安保&#xff0c;还是军事和公共安全领域&#xff0c;对低空慢速小目标的探测识别需求都非常旺盛。做毕设选了这个题目&#xff0c;等于踩中了一个既有学术价值、又有实际落地场景的赛道。我当初带学生…

作者头像 李华
网站建设 2026/10/19 17:06:18

open-code-review实践:让代码评审从走形式变为聚焦逻辑与架构

1. 代码评审这件事&#xff0c;为什么总在"走形式"和"挑刺"之间反复横跳我是从2022年下半年开始认真对待code review的。在此之前&#xff0c;我和大多数研发团队一样&#xff0c;把评审当成走流程&#xff1a;开了PR&#xff0c;挂两天没人看&#xff0c;…

作者头像 李华
网站建设 2026/10/19 17:06:04

工业缺陷检测数据集实战:AeBAD叶片异常检测与YOLO训练全解析

做工业视觉检测的同行应该都有体会&#xff0c;缺陷检测领域最缺的不是模型&#xff0c;而是干净能用的数据集。常规公开数据集要么目标大而清晰&#xff0c;要么缺陷类别就一两个&#xff0c;拿到真实的产线、孔探场景根本不够用。航空发动机叶片的异常检测更是如此&#xff0…

作者头像 李华
网站建设 2026/10/19 17:04:17

C# 十字路口红绿灯模拟:有限状态机与多线程并发实战

简介&#xff1a;这份C#十字路口红绿灯模拟资源面向具备C#基础、希望深入理解多线程与事件驱动编程的开发者&#xff0c;通过一个完整的交通信号灯项目&#xff0c;帮助解决状态管理、线程同步与UI联动等典型问题。压缩包共29个文件&#xff0c;约1.52MB&#xff0c;以cs源码、…

作者头像 李华
网站建设 2026/10/19 17:00:59

Claude Code CLI 安装配置与实战指南:Node.js 环境、BASE_URL 与 AUTH_TOKEN 详解

1. 环境准备与基础依赖梳理1.1 为什么 Node.js 是绕不开的第一道坎Claude Code 这个 CLI 工具本质上是跑在 Node.js 运行时上的一个命令行程序&#xff0c;所以不管你用 Windows、macOS 还是 Linux&#xff0c;第一步都得先把 Node.js 装好。很多人卡在第一步不是因为不会装&am…

作者头像 李华