1. 项目概述:从信号到声音的实践之旅
在嵌入式开发领域,尤其是基于STM32这类主流MCU的项目中,PWM(脉冲宽度调制)和DAC(数模转换器)是两种极为核心的模拟信号生成技术。很多朋友在初学阶段,可能只是照着例程让一个LED灯呼吸闪烁,或者输出一个固定的电压,但往往知其然不知其所以然。这个练习的目的,就是要把这两项技术从“会用”提升到“懂用”的层次,通过一个连贯的、有实际意义的项目,深入理解它们的工作原理、应用场景以及如何协同工作。简单来说,我们将利用STM32的PWM来模拟一个数字音频信号,再通过DAC将其转换为真实的模拟音频波形,最终驱动一个扬声器或耳机,播放出我们“编程”出来的音乐或音效。这个过程,就像是在数字世界和模拟世界之间架起了一座桥梁,非常有趣且富有成就感。
无论你是刚接触STM32的新手,还是想巩固模拟外设知识的开发者,这个练习都能让你收获颇丰。它不仅涉及硬件定时器、GPIO、DMA等外设的配置,更考验你对信号处理、采样率、量化精度等概念的理解。我会以一个具体的工程为例,手把手带你走通整个流程,并分享我在调试过程中踩过的坑和总结出的技巧。我们使用的硬件平台是常见的STM32F103系列(比如“蓝桥杯”竞赛板或正点原子的Mini板),软件环境是Keil MDK或STM32CubeIDE,这些工具链大家应该都比较熟悉。
2. 核心硬件与原理深度解析
2.1 PWM:不仅仅是控制LED亮度
PWM的本质,是通过调节一个数字信号在一个周期内高电平所占的时间比例(即占空比),来等效地获得一个可变的平均电压。对于STM32来说,PWM功能通常由高级定时器(TIM1, TIM8)或通用定时器(TIM2-TIM5)的“输出比较”模式产生。
这里有几个关键参数需要彻底理解:
- 定时器时钟(TIMx_CLK):这是PWM频率的基石。它来源于系统时钟(SYSCLK)经过预分频器(PSC)后的结果。计算实际计数器时钟频率的公式是:
CK_CNT = TIMx_CLK / (PSC + 1)。 - 自动重装载值(ARR):这个值决定了计数器的周期。计数器从0计数到ARR,然后归零重新开始,这就定义了一个PWM周期。PWM的频率计算公式为:
PWM_Freq = CK_CNT / (ARR + 1)。 - 捕获/比较寄存器(CCRx):这是我们控制占空比的核心。当计数器的值小于CCRx时,输出一种电平(通常为高);大于等于CCRx时,输出另一种电平(通常为低)。占空比 =
CCRx / (ARR + 1)。
注意:在设置ARR和CCRx时,务必注意它们的数值范围(16位定时器是0-65535)。同时,STM32的PWM模式有“PWM模式1”和“PWM模式2”,主要区别在于输出极性,需要根据实际硬件电路(比如是共阳极还是共阴极LED)来正确选择。
2.2 DAC:将数字代码变为真实电压
STM32内部的DAC模块,其核心是一个电阻网络或电容阵列,它能够将我们写入DAC数据寄存器的数字值(比如12位精度下是0-4095),线性地转换为一个模拟电压。对于STM32F103,其DAC输出电压范围通常是0到Vref+(参考电压,一般接3.3V)。
DAC的工作模式值得我们关注:
- 软件触发:需要代码手动更改DAC数据寄存器来更新输出。响应慢,不适合连续信号。
- 定时器触发:可以通过定时器更新事件自动触发DAC转换,这是生成连续波形(如音频)的关键。
- DMA传输:这是本练习的精华所在。我们可以预先把一段代表音频波形的数字序列(一个数组)存放在内存中,然后配置DMA,让其在定时器的触发下,自动、不间断地将数组中的数据搬运到DAC数据寄存器中。这样CPU就被解放出来,可以处理其他任务,而音频输出则流畅无比。
PWM与DAC的关联与区别:PWM输出的仍然是数字方波,其“模拟”效果是靠外部滤波电路(电阻电容)将方波平滑成直流电压来实现的。而DAC是真正在芯片内部输出一个连续变化的模拟电压。用PWM来“模拟”DAC,实际上是通过生成一个其占空比随时间变化的PWM波,再经过低通滤波器滤除高频开关噪声,得到近似的模拟信号。这种方式成本低,但精度和动态响应通常不如真正的DAC。我们这个练习,就是要体验这两种方式,并理解它们的优劣。
3. 工程设计与软件架构思路
我们的目标是生成一段简单的音频,比如一个1kHz的正弦波。我们将设计两种实现方案进行对比。
3.1 方案一:PWM模拟音频输出
这个方案的核心思想是,用高频率的PWM载波,通过动态调整其占空比来“描绘”出我们想要的低频音频信号波形。
- 信号生成:首先,我们需要在代码中创建一个数组,比如
pwm_sine_table[100]。这个数组存放的不是电压值,而是对应正弦波每个采样点的PWM占空比值。计算公式为:value = (sin(2*π*i/100) + 1) / 2 * (ARR_MAX)。这里加1除以2是为了将正弦波的[-1,1]范围映射到[0, ARR_MAX]的占空比范围。 - 硬件定时器配置:
- 选择一个定时器(如TIM3)的某个通道(如CH2)输出PWM。
- 设置PWM频率(载波频率)。这里有个关键点:根据奈奎斯特采样定理,为了不失真地还原我们想要的正弦波(假设最高频率1kHz),PWM的载波频率至少需要是信号频率的2倍以上。但实际上,为了获得较好的滤波效果和更平滑的输出,载波频率通常需要是信号最高频率的10倍甚至100倍以上。我们这里可以设置为100kHz。假设系统时钟72MHz,经过预分频PSC=71,得到CK_CNT=1MHz。要得到100kHz的PWM,则ARR = (1MHz / 100kHz) - 1 = 9。这是一个非常小的ARR值,意味着PWM的分辨率很低(只有10个等级),这会导致输出波形阶梯感很强。这就是PWM模拟DAC的一个主要矛盾:载波频率(决定滤波难度)和分辨率(决定输出精度)相互制约。提高ARR可以提高分辨率,但会降低载波频率。为了解决这个问题,我们可以使用定时器的溢出中断,在中断里动态更新CCR寄存器来改变占空比,这样ARR可以设大以保证分辨率,而“波形更新频率”由中断频率控制。
- 滤波电路:PWM引脚输出后,必须连接一个低通滤波器(通常是一阶RC滤波),滤除100kHz的开关频率及其谐波,只留下我们想要的低频音频信号。滤波器的截止频率需要精心计算,要低于PWM载波频率,但高于音频信号频率。
3.2 方案二:DAC直接输出音频
这是更直接、性能通常更好的方案。
- 信号生成:同样创建一个数组
dac_sine_table[100]。这个数组存放的是直接写入DAC数据寄存器的12位数字值。计算公式为:value = (sin(2*π*i/100) + 1) / 2 * 4095。 - 外设协同配置(核心):
- DAC配置:使能DAC通道,设置为定时器触发、输出缓冲区使能。
- 定时器配置:配置一个定时器(如TIM6,基本定时器)作为DAC的触发源。定时器的更新频率就是我们的音频采样率。要生成1kHz的正弦波,并且一个周期用100个点来描绘,那么采样率就是 1kHz * 100 = 100kHz。根据这个频率去计算定时器的ARR和PSC。
- DMA配置:这是流畅播放的关键。配置DMA通道,将内存中的
dac_sine_table作为源地址,DAC的数据寄存器(如DAC_DHR12R1)作为目标地址。设置传输数据宽度为半字(16位),模式为循环模式。这样,每当定时器触发一次,DMA就自动搬运一个数据到DAC,周而复始,CPU零开销。
3.3 软件架构规划
我们将使用STM32CubeMX进行图形化初始化,然后在中途添加业务逻辑代码。软件层次可以这样划分:
- 硬件抽象层:由CubeMX生成的
tim.c,dac.c,dma.c以及对应的gpio.c组成,完成所有外设的引脚和基础参数配置。 - 应用层:我们创建
audio_app.c文件。在这里,我们将实现:- 正弦波数据表的生成函数。
- 两种方案(PWM中断更新、DAC+DMA)的启动控制函数。
- 可能的话,一个简单的函数用来动态改变频率(通过修改定时器ARR或数据表)。
4. 详细实现步骤与代码剖析
我们以方案二(DAC+DMA)为重点,因为这是更标准、更高效的音频输出方式。
4.1 使用STM32CubeMX进行初始化
- 时钟树配置:确保系统时钟(SYSCLK)设置为72MHz(对于F103)。
- DAC配置:
- 在
Analog->DAC中,使能OUT1 Configuration(对应PA4引脚)。 - 在
Parameter Settings中,选择Trigger为Timer 6 Trigger Out event。勾选Output Buffer使能(通常建议使能以获得更好的驱动能力)。
- 在
- 定时器配置:
- 配置
TIM6(基本定时器)。在Parameter Settings中,设置Prescaler (PSC)和Counter Period (ARR)以产生100kHz的更新事件。 - 计算示例:定时器时钟APB1为36MHz。若PSC=359,则计数器时钟CK_CNT = 36MHz / (359+1) = 100kHz。要产生100kHz的更新率,则ARR = 0即可(因为从0计数到0即溢出)。但这样控制不灵活。更常见的做法是,设置PSC=35,CK_CNT=1MHz,然后设置ARR=9,这样更新频率就是1MHz/(9+1)=100kHz。
- 关键一步:在
Trigger Output (TRGO) Parameters中,将TRGO Source选择为Update Event。这样定时器每次溢出更新时,就会产生一个触发信号给DAC。
- 配置
- DMA配置:
- 在
DAC的配置页,找到DMA Settings,点击Add。 - 选择通道(如
DAC1),方向为Memory To Peripheral。 - 模式选择
Circular(循环模式)。这样DMA在传输完整个数组后会自动回到开头重新开始,实现连续播放。 - 数据宽度都选择
Half Word(16位),因为DAC的12位数据寄存器是16位对齐的。
- 在
- 生成代码:生成基于MDK或IDE的工程代码。
4.2 编写应用层代码
在main.c或我们新建的audio_app.c中:
// 1. 定义音频数据表 #define SINE_TABLE_SIZE 100 uint16_t dac_sine_table[SINE_TABLE_SIZE]; // 2. 生成正弦波表函数 void Generate_SineWave_Table(void) { for (int i = 0; i < SINE_TABLE_SIZE; i++) { // 计算0到2π之间的角度 float angle = 2 * 3.1415926f * i / SINE_TABLE_SIZE; // 计算正弦值(范围-1到1) float sine_value = sinf(angle); // 将-1~1映射到0~4095(12位DAC) // 先映射到0~1: (sine_value + 1.0f) / 2.0f // 再映射到0~4095: * 4095.0f dac_sine_table[i] = (uint16_t)((sine_value + 1.0f) / 2.0f * 4095.0f); } } // 3. 音频播放启动函数 void Audio_Play_Start(void) { // 生成波形数据 Generate_SineWave_Table(); // 使能DAC的DMA请求(这一步很关键,CubeMX生成的代码可能不会自动开启) // 在stm32f1xx_hal_dac.h中,使用 HAL_DAC_Start_DMA 函数 // 参数:DAC句柄,通道,数据数组地址,数据长度,数据对齐方式 if (HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, (uint32_t*)dac_sine_table, SINE_TABLE_SIZE, DAC_ALIGN_12B_R) != HAL_OK) { Error_Handler(); } // 启动定时器,它将周期性地触发DAC转换 HAL_TIM_Base_Start(&htim6); } int main(void) { // HAL初始化,时钟配置等(由CubeMX生成) ... // 启动音频播放 Audio_Play_Start(); while (1) { // 主循环,此时CPU是空闲的,音频由DMA自动输出 // 可以在这里添加按键检测,用来改变频率或停止播放 ... } }4.3 方案一(PWM模拟)的关键代码补充
对于PWM方案,如果采用中断动态更新CCR,核心代码如下:
// 在定时器更新中断中 void TIM3_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim3, TIM_FLAG_UPDATE) != RESET) { __HAL_TIM_CLEAR_IT(&htim3, TIM_IT_UPDATE); static uint8_t index = 0; // 更新PWM占空比 __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, pwm_sine_table[index]); index++; if (index >= SINE_TABLE_SIZE) { index = 0; } } }同时,需要将PWM定时器(TIM3)的溢出中断频率设置为我们的音频采样率(如100kHz),并在初始化后开启更新中断。
5. 调试技巧、常见问题与实测心得
5.1 没有输出或输出不正确
- 检查时钟树:这是最常见的问题。确保定时器、DAC、DMA的时钟源都已使能,并且分频系数计算正确。使用STM32CubeMX的“Clock Configuration”标签页反复核对。
- 检查引脚复用:确认PWM或DAC输出引脚已正确配置为复用功能,并且没有和其他功能冲突。
- 验证触发链路:对于DAC方案,确保“定时器更新事件 -> 触发DAC -> DAC请求DMA”这条链路是通的。一个很好的调试方法是,先将DAC配置为软件触发,在main循环里手动写一个固定的值(如2047)到DAC寄存器,用万用表测量引脚电压是否约为1.65V(3.3V/2)。这能先排除DAC本身的问题。
- DMA传输完成中断:可以在DMA传输完成中断里翻转一个LED,看看中断是否正常进入,以判断DMA是否在工作。
5.2 输出波形失真或噪声大
- PWM方案:
- 载波频率过低:如果PWM载波频率太低,经过RC滤波后,残留的开关噪声会落在音频范围内,听到的就是刺耳的“吱吱”声。尝试提高PWM频率(减小ARR),但要注意这会降低分辨率。
- 滤波器设计不当:RC滤波器的截止频率
f_c = 1/(2πRC)。它应该远低于PWM频率,同时高于你要输出的最高音频频率。例如,PWM频率100kHz,音频最高1kHz,截止频率可以选在5kHz左右。用示波器观察滤波前后的波形至关重要。 - 中断抖动:如果靠定时器中断更新PWM占空比,中断响应时间的抖动会导致输出波形周期不稳定,听起来有杂音。确保中断优先级够高,且中断服务函数执行时间尽可能短。
- DAC方案:
- 采样率不足:如果采样率低于信号频率的两倍(奈奎斯特频率),会产生混叠失真。确保
定时器触发频率 >= 2 * 信号最高频率。对于正弦波,一个周期至少需要几十个点才能看起来光滑。 - 量化噪声:12位DAC本身就有量化误差,这是固有的。可以通过提高DAC位数(如果MCU支持)或使用过采样和噪声整形技术来改善,但这属于更高级的话题。
- 电源噪声:模拟部分的供电(Vref+, VDDA)不干净会直接反映在输出上。确保使用稳定的LDO为模拟部分供电,并在电源引脚附近放置足够的去耦电容(如10uF钽电容+0.1uF陶瓷电容)。
- 采样率不足:如果采样率低于信号频率的两倍(奈奎斯特频率),会产生混叠失真。确保
5.3 实测心得与进阶玩法
- 示波器是你的最佳伙伴:无论是看PWM方波、滤波后的波形,还是看DAC输出的阶梯波,一个示波器能直观地告诉你一切。用它的FFT功能还能分析输出信号的频谱纯度。
- 动态改变音调:我们可以通过改变定时器TIM6的ARR值来实时改变采样率,从而改变输出音频的音调。在主循环里检测按键,按下后调用
__HAL_TIM_SET_AUTORELOAD(&htim6, new_arr_value),就能实现简单的“弹钢琴”效果。注意,改变ARR后,可能需要重新启动DMA传输。 - 播放真实音频数据:这个框架可以直接用来播放WAV文件(需要是未压缩的PCM格式)。你需要将WAV文件的数据部分提取出来,转换成12位的数组,并设置正确的采样率(通过定时器ARR实现)。这就能让你的STM32开发板变成一个简单的MP3播放器核心。
- PWM与DAC混合使用:有些场景下,可能需要用PWM驱动功率部分(比如电机),同时用DAC提供精密的模拟参考电压。理解两者的特性和配置方法,就能灵活地在项目中运用。
通过这样一个从原理到实践,从配置到调试的完整练习,你对STM32的PWM和DAC的理解绝不会再停留在表面。更重要的是,你掌握了用DMA来高效管理数据流的方法,这在处理ADC采样、通信传输等任务时同样适用。