ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32F103驱动无源蜂鸣器播放音乐:PWM配置与状态机实现

STM32F103驱动无源蜂鸣器播放音乐:PWM配置与状态机实现 简介基于STM32F103的无源蜂鸣器发声工程主要面向嵌入式入门开发者与电子制作爱好者解决如何利用STM32的定时器/PWM功能驱动无源蜂鸣器使其按照不同音调与节拍播放旋律。工程内置“红海情歌”和“生日快乐”两首示例曲目音符通过音调-时间参数表组织想换成其他歌曲时只需修改对应数组数值即可逻辑清晰且复用性高。压缩包内共157个文件、552KB包含ST标准外设库头文件.h与源文件.cKeil工程配置文件.uvprojx/.uvoptx编译产生的目标文件.d/.o/.crf可直接烧录的.hex和.axf固件以及keilkilll.bat清理脚本和说明文档既可用于直接实验也可作为工程模板参考。编译环境、库文件、输出文件齐全目录结构有助于理解STM32标准库工程的组织方式。目前已有12573人学习下载适合对照代码理解蜂鸣器发声原理并进行扩展改造。 前阵子收拾抽屉翻出一块stm32f103最小系统板想着总不能一直吃灰就琢磨着做点好玩的东西。正好手头有一颗无源蜂鸣器干脆让它“唱”首歌。说实话让蜂鸣器发声这件事本身不难但要把一段旋律播得准、播得稳、播得不像闹钟里面还是有不少门道。这篇文章就完整记录我从电路到代码、从无声到出声再到播放音乐的整个过程包括那些让我卡了好几天的坑。如果你手里也有一块F103的板子和一颗无源蜂鸣器跟着这篇文章走一遍不仅能学会播放音乐还能把定时器PWM、中断优先级、状态机这些嵌入式基本功顺带练扎实。1. 有源与无源蜂鸣器一个“响不响”背后的本质区别1.1 有源蜂鸣器为什么只能“嗡嗡”叫很多人第一次接触蜂鸣器时都会困惑既然是蜂鸣器为什么还要区分“有源”和“无源”这里的源不是指电源而是指振荡源。有源蜂鸣器内部集成了一个振荡电路只要给它通上额定直流电压内部就会自行产生一个固定频率的方波驱动压电陶瓷片或电磁线圈于是直接“嗡嗡”响。这个频率通常在2.4kHz左右由内部振荡器决定用户改不了。所以有源蜂鸣器适合做报警提示比如按键音、超温报警、倒计时结束提醒它的核心特点是通电即响频率固定。无源蜂鸣器内部没有振荡电路你给它通直流电它只会保持在一个位置不动不会发出声音。它必须依赖外部输入一定频率的方波或交流信号通过频率的变化让内部振膜产生相应频率的机械振动才能真正发声。1.2 无源蜂鸣器驱动本质频率控制音调理解了上面这个区别就明白为什么想播放音乐必须选无源蜂鸣器了。声音的音高由振动频率决定比如中央CC4频率是261.6HzA4是440Hz。无源蜂鸣器的驱动信号频率决定了它发出声音的音调我们只要让STM32的某个定时器输出可调频率的PWM方波就能控制蜂鸣器发出不同音高的音再配合每个音的持续时间就能“演奏”出旋律。需要特别注意的是STM32输出的PWM频率不能直接等于声音频率虽然听起来有点绕但实际匹配下来PWM驱动信号的频率和蜂鸣器发声的基频是一致的。也就是说我让定时器输出261.6Hz的方波蜂鸣器就发出261.6Hz的声音。这一点和扬声器靠模拟音频信号驱动还不太一样蜂鸣器本质上是一个带通特性的发声器件对驱动频率特别敏感。搞清楚这个底层逻辑后面所有工作都围绕一件事让定时器在正确的时间输出正确频率的方波。2. 驱动电路设计为什么不能直接把蜂鸣器接到GPIO2.1 一个三极管搞定电流放大确认用无源蜂鸣器之后下一步就是硬件电路。你可能会想STM32的GPIO不是可以直接输出高低电平吗直接把蜂鸣器一根脚接PA8另一根脚接地行不行不行这里面有两个问题。第一STM32F103的GPIO引脚最大输出电流大概在25mA左右但这只是极限参数实际稳定输出几毫安就很好了。而无源蜂鸣器正常工作电流通常在20mA到50mA之间直接用GPIO驱动电流不够声音会非常小甚至驱动不起来。第二蜂鸣器本质上是一个感性负载电磁式蜂鸣器内部有线圈在驱动信号跳变时会产生反向感应电动势。如果直接接GPIO反向电压可能会打坏引脚。所以最稳妥的方案是加一级三极管开关电路用GPIO的低电流去控制三极管导通和关断从而控制蜂鸣器的供电回路。我用的是最常见的S8050 NPN三极管电路结构如下3.3V或5V ---- 蜂鸣器正极 蜂鸣器负极 ---- 三极管集电极 三极管发射极 ---- GND GPIO ---- 基极电阻(1kΩ) ---- 三极管基极这个电路的本质是GPIO输出高电平三极管饱和导通蜂鸣器通电GPIO输出低电平三极管截止蜂鸣器断电。PWM信号通过GPIO输出时蜂鸣器就以PWM频率通断从而产生对应频率的声音。2.2 电路参数计算与元件选型基极电阻的取值是有讲究的不能随便拿一个就往上焊。还是上面那个电路GPIO高电平电压约3.3V三极管基极-发射极结电压VBE在导通时约0.7V基极电阻RB两端压降就是3.3 - 0.7 2.6V。设基极电流为IB根据欧姆定律RB 2.6V / IB。S8050的直流放大倍数hFE通常在100到300之间为了让三极管可靠饱和导通基极电流一般取集电极电流的1/10到1/20。蜂鸣器工作电流按30mA算基极电流取2mA到3mA就足够。这样计算下来RB 2.6V / 0.002A ≈ 1.3kΩ所以我选了1kΩ加上一个220Ω的限流电阻做保护实际用1kΩ也完全没问题。还有一个小细节蜂鸣器两端可以反向并联一个二极管1N4148或者SS14用来吸收关断瞬间的反向电动势。如果是用三极管驱动则二极管反向并联在蜂鸣器两端。这个二极管平时不导通但在蜂鸣器突然断电时能给感性电流一个泄放路径防止尖峰电压损坏三极管。有一点需要提醒蜂鸣器供电电压决定了它的响度等级。3.3V供电时声音偏小5V供电会响亮很多。我的最小系统板上有5V输出引脚但三极管的基极控制信号仍然是3.3V GPIO输出的所以驱动逻辑独立用5V给蜂鸣器供电完全没问题。如果你只有3.3V也能响只是声音小一些。3. 音符频率计算与定时器配置让PA8输出正确的方波3.1 十二平均律从一个标准音推算出所有音符驱动电路搭好了接下来是重头戏怎么让STM32输出我们想要的音符频率。这里要用到音乐领域的十二平均律。简单来说八度音程被平均分成12个半音相邻两个半音之间的频率比是固定的等于2的1/12次方约为1.059463。以A4标准音440Hz为基准任何一个音符的频率都可以用公式算出来f 440 × 2^((n-69)/12)其中n是MIDI音符编号A4对应编号69。比如中央CC4的MIDI编号是60代入公式f 440 × 2^((60-69)/12) ≈ 440 × 0.5946 ≈ 261.6Hz实际编程时为了提高效率我不会在每次播放时都做浮点运算而是直接把常用音符的频率提前算好存在一个常量数组里。从低音SoG3196Hz到高音DoC61047Hz一张表就能覆盖大部分流行歌曲的主旋律。3.2 定时器预分频与重装载值计算以PA8输出为例F103的定时器是16位的意味着自动重装载寄存器ARR最大只能写入65535。如果直接用72MHz的主频去除以262Hz得到约274809这个值远超65535上限。所以必须先用预分频器PSC把定时器时钟降下来。我的计算方法是先用72MHz除以一个合适的预分频系数让分频后的计数频率略高于目标音频频率保证ARR值落在16位范围内同时又能获得足够频率分辨率。比如设置PSC 71分频后计数频率为72MHz / (711) 1MHz。那么对于某个目标频率fARR的计算公式就是ARR 1MHz / f - 1验证一下几个音符音符频率(Hz)ARR计算值取整后实际频率C4261.633822.1261.65HzD4293.663404.2293.68HzE4329.633032.7329.63HzA4440.002272.3440.00Hz这个误差在0.02Hz以内人耳完全听不出来。所以PSC选71配合1MHz计数频率对音乐播放来说精度足够。3.3 TIM1_CH1的PWM输出配置标准库代码详解我选的是PA8作为PWM输出引脚对应的外设是TIM1的通道1。为什么不选TIM2或TIM3因为PA8就在板子边上方便接线而且TIM1作为高级定时器功能比通用定时器更强拿来练手正合适。配置PWM输出的第一步是GPIO初始化PA8必须配置为复用推挽输出GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_TIM1, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_8; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure);接下来是定时器初始化设置PSC71ARR0后面播放每个音符时单独改输出PWM模式1占空比由比较寄存器CCR决定TIM_TimeBaseInitTypeDef TIM_BaseInitStructure; TIM_OCInitTypeDef TIM_OCInitStructure; TIM_BaseInitStructure.TIM_Prescaler 71; TIM_BaseInitStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_BaseInitStructure.TIM_Period 0; TIM_BaseInitStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM1, TIM_BaseInitStructure); TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 0; TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM1, TIM_OCInitStructure);高级定时器的关键一步来了必须开启主输出也就是MOE位TIM_CtrlPWMOutputs(TIM1, ENABLE);这一步我当时漏掉了结果折腾了很久才发现问题。TIM1和TIM8这类高级定时器如果不开启主输出PWM波形根本不会出现在引脚上。而TIM2、TIM3、TIM4这类通用定时器则没有这个问题。用通用定时器的话可以跳过这句但用高级定时器一定不能省。最后启动定时器TIM_Cmd(TIM1, ENABLE);4. 音乐播放逻辑编码乐谱与播放状态机4.1 乐谱的简化编码音符加时长定时器能输出指定频率的PWM后剩下的事情就是如何把“音乐”这个信息表达为程序数据。人耳感知到的音乐由两个基本要素构成音的高低频率和音的长短时长。所以一首歌的旋律可以拆成一个一个音符事件每个事件包含频率和持续时间两个参数。我的做法是定义两个数组一个存音符频率一个存对应时长。时长用毫秒表示方便换算// 《小星星》主旋律 const u16 tone[] { 261, 261, 392, 392, 440, 440, 392, // C C G G A A G 349, 349, 330, 330, 294, 294, 261 // F F E E D D C }; const u16 duration[] { 500, 500, 500, 500, 500, 500, 1000, 500, 500, 500, 500, 500, 500, 1000 };播放逻辑就是一个简单的循环遍历数组设置定时器ARR为目标频率对应的值设置CCR为占空比对应值延时duration[i]毫秒然后关闭PWM或设置占空比为0一小段时间防止粘连。这里有一个时间精度的知识点要提醒delay_ms的精度直接决定了节奏准不准。我用的是SysTick中断计时这比简单的循环延时可靠得多。F103的SysTick是24位向下计数器配置好重装载值后可以精确产生1ms中断在中断里对一个全局变量加1延时函数只要轮询这个变量即可。4.2 阻塞式播放与优化方向最简单的播放方式是在主循环里同步调用也就是阻塞型播放void play_music(u16 *tone_arr, u16 *dur_arr, u8 len) { for (u8 i 0; i len; i) { set_tone(tone_arr[i]); // 设置PWM频率和占空比 delay_ms(dur_arr[i]); // 保持该音符时长 set_tone(0); // 关音 delay_ms(20); // 音符间短暂间隔 } }这个函数逻辑简单跑起来也稳定对于纯播放场景完全够用。它的问题在于播放期间CPU被完全占用没办法同时响应按键、刷新屏幕或者处理串口数据。比如你想做一个电子琴一边播旋律一边读按键这个结构就行不通了。解决办法是把播放逻辑改成非阻塞状态机。用一个结构体保存播放状态包括当前音符索引、音符开始时间、是否正在播放等然后在主循环或定时器中断里不断刷新typedef struct { u8 playing; u8 index; u32 note_start_time; u32 note_duration; u32 current_frequency; } MusicPlayer; void music_player_update(MusicPlayer *player) { if (!player-playing) return; u32 now get_tick_ms(); if (now - player-note_start_time player-note_duration) { // 当前音符结束切换到下一个 player-index; if (tone[player-index] 0) { player-playing 0; // 播放结束 } else { set_tone(tone[player-index]); player-note_duration duration[player-index]; player-note_start_time now; } } }这样主循环只管调用music_player_update既不阻塞按键检测也不需要CPU空等。我实际测试下来非阻塞播放配合按键控制播放/暂停效果很好。4.3 音符间为什么要加“静音间隔”很多新手做蜂鸣器播放音乐时会忽略一个听感细节两个同音高的连续音如果中间没有间隙听起来就像同一个音被拉长了。尤其是在节奏较快的段落音符之间完全没有间隔整首歌会糊成一团。解决办法是每个音符结束时把PWM占空比设为0或者关闭通道输出一小段时间我一般取20ms到30ms。这个间隔不能太长太长会有明显的“断气感”太短又区分不开相邻音符。经过多次实验20ms是最适合人耳自然断句的间隔既能让音头音尾听得清晰又不会破坏旋律连贯性。另外如果切换的两个音符频率跨度很大比如从低音196Hz直接跳到中音880Hz蜂鸣器振膜需要时间响应这个短暂的“拖尾”在听感上像是有一个过度音。加了静音间隔之后这个过渡被切掉每个音的起止就干净利落。5. 实测中踩过的坑从无声到难听再到正常5.1 高级定时器TIM1没有输出忘了开主输出这是我这块板子第一次上电测试时遇到的最大问题。波形没有任何输出但代码逻辑怎么看都没问题。GPIO配置了定时器启动了PWM模式也设了引脚量出来就是没有波形。排查方向一度跑偏去检查是不是板子坏了甚至把PA8当普通GPIO用高低电平翻转测试发现引脚能正常输出高低电平。这就排除了硬件问题把范围缩小到定时器配置。翻了stm32f103中文参考手册在高级定时器章节里看到一句话TIM1和TIM8的PWM输出受刹车寄存器和MOE位控制。MOE位在主输出使能寄存器里默认是关闭状态。也就是说即使定时器在跑通道输出也会被禁止引脚上什么都测不到。解决办法就是上面提到的那行代码TIM_CtrlPWMOutputs(TIM1, ENABLE);加在TIM_Cmd(TIM1, ENABLE)前面就可以。我是因为踩了这个坑才对高级定时器和通用定时器的区别印象特别深。以后用TIM8或者其他高级定时器都会先检查这一项。5.2 占空比100%时反而没声音有一次测试占空比的时候我把比较寄存器的值设成了ARR1相当于占空比接近100%结果蜂鸣器不响了。刚开始还以为是蜂鸣器坏了后来一想占空比到达100%时PWM输出的方波变成了恒定的高电平没有跳变沿蜂鸣器线圈不产生交变磁场自然不会有声音。不只是100%占空比太接近100%比如95%以上虽然还有微弱的跳变但有效驱动时间极短声音会急剧减小。同样道理占空比太低比如5%以下驱动能量不足声音也很小。对于无源蜂鸣器50%占空比方波理论上声音最响因为这种波形在正负半周的能量最均衡。实际测试下来40%到50%的占空比听觉上差别不大。我统一用50%也就是设置CCR ARR / 2。每次切换音符时同时更新ARR和CCR。热词里提到“stm32f103芯片 定时器输出占空比到不了100”正好就是这个现象。要说明的是PWM输出是可以到100%的ARR寄存器设定了这个上限但到了100%它就不是方波了而是一直恒定的电平状态对蜂鸣器来说这个状态就是不工作。真正有意义的其实是把音量关掉这个场景设置CCR0即可也就是输出恒定低电平。理解这一点后就不会在“占空比上不去”这个方向上浪费时间了。5.3 播放时F103掉电保存数据的问题这里多说一个我在调试过程中遇到的关联问题F103掉电保存数据。原本的测试程序里有一次我需要保存播放状态和当前音符索引方便断电后恢复上次播放位置。F103内部没有EEPROM掉电后RAM数据全部丢失需要外接或者利用内部Flash。最简单的办法是利用F103内部Flash的末页来存储。F103有512KB Flash大容量型号每页1KB我取最后一页做数据存储区。写入时需要先擦除整页这个过程耗时大约20ms到40ms所以不能频繁调用一般在状态切换时保存一次就够了。播放音乐的场景其实用不上掉电保存但如果你做的是带有播放配置的电子设备比如记住音量档位、记住上次播放的歌这个思路就很有用。配合前面提到的非阻塞播放状态机把player结构体里的关键字段序列化掉电前写入Flash上电后读出来恢复体验会完整很多。5.4 听感发闷检查占空比与蜂鸣器谐振频率还有一次代码逻辑全部正常声音也能播出来但整体听感发闷发闷音调明显偏低且含混不清。排查了一圈最后用示波器看不波形的频率发现实际输出的频率比目标值低了大概15%。问题出在定时器的时钟源配置上。F103的TIM1挂载在APB2总线上APB2时钟默认是72MHz但如果工程里别人把时钟树改成了36MHz而且没有正确配置PLL定时器计数频率就会减半PWM频率自然全错。这种情况下检查SystemInit()和RCC配置函数是否正常设置系统时钟到72MHz。你可以用RCC_GetClocksFreq()打印实际时钟确认SYSCLK和PCLK2的值。如果IDE是直接从其他工程拷贝的启动文件也很容易带着错误的时钟配置过来。还有一个容易忽略的坑是蜂鸣器本身的谐振频率。每颗无源蜂鸣器的谐振频率不同一般在2kHz到4kHz之间。在这个谐振频率附近驱动声音最响最亮偏离太多可能声音变小或发闷。如果发现频率计算完全正确但某个音域明显过响或过弱很可能就是碰到了蜂鸣器谐振频点。这种情况下没有很好的软件补偿办法要么换蜂鸣器要么调整播放歌曲的音域。5.5 从map文件定位问题在排查定时器配置过程中我还发现一个实用的小技巧查看编译输出的map文件。当怀疑某个变量或函数被优化器吃了或者想确认中断向量表是否正确直接打开工程编译后生成的.map文件搜索对应的符号名就能看到它的地址、大小、所在区域和引用来源。比如我调试SysTick延时不准时Pycheck了map文件发现SysTick_Handler的地址在0x08000000到0x08000004这个向量区说明中断函数已经被正确链接进启动文件指定的向量表。如果发现SysTick_Handler符号后面挂着undefined或者被放在不正常的段里多半是启动文件里的中断向量名和实际函数名不一致导致中断永远进不去。对于F103这样的MCUmap文件debug效率很高特别是多人协作工程、频繁增删源文件的时候它能帮你快速确认哪些代码真正被编进了固件哪些已经被编译器丢弃。6. 进阶玩法让蜂鸣器播放变得更好用6.1 音量控制与节拍处理前面提到过蜂鸣器的音量可以通过改变PWM占空比来近似控制。50%占空比声音最大降低占空比可以减小声音。实测下来占空比在10%到50%之间声音大小有明显的梯度变化。利用这个特性可以在乐谱里加入力度标记。比如每个音符的编码除了频率和时长再加一个力度字段0到100播放时按力度映射到对应占空比这样就能做出简单的“轻重音”效果。虽然距离真实乐器还有距离但比一刀切的50%占空比好听了不少。节拍方面可以做一个更通用的解析器。先定义一种简单协议每条音符编码包含音高索引、默认时长和点音符标记。用状态机解析后存入播放队列。这个思路适合“歌谱比较长”的场景比如存几百个音符的乐谱程序运行时逐条解析播放。6.2 用非阻塞播放配合其他外设如果你不想让蜂鸣器播放占死整个CPU非阻塞状态机是必然选择。配合按键中断、OLED显示或者串口指令可以实现很多有趣的交互。比如我后来把播放状态机和三个按键接在一起一个按键播放/暂停一个切换歌曲一个调节音量全部通过主循环轮询状态实现运行非常稳定。再进一步乐谱数据可以存到外部Flash或者SD卡需要播放时读出一段解码一段这样就能支持很长的曲目而不用占用太多RAM。F103的FSMC接口接外部NOR Flash或者直接用SPI接口接SD卡都是成熟的方案。播放状态机核心逻辑不变只是音符数据的来源从const数组变成了外设读取。这几个方向本质上都在围绕“如何让一个简单的发声元件变得可以控制、可以交互、可以承载更多信息”展开。作为嵌入式入门项目它的性价比非常高代码量适中硬件简单原理覆盖了GPIO、Timer、PWM、中断、状态机这些核心知识点而且做完之后能听到实实在在的成果特别有成就感。我实测下来整套流程走通大概需要大半天时间其中大部分时间其实都花在调试和查手册上。所以把那些坑提前写出来希望能帮你省下这些时间。本文还有配套的精品资源点击获取
返回列表