简介:基于STM32F103的无源蜂鸣器发声工程,主要面向嵌入式入门开发者与电子制作爱好者,解决如何利用STM32的定时器/PWM功能驱动无源蜂鸣器,使其按照不同音调与节拍播放旋律。工程内置“红海情歌”和“生日快乐”两首示例曲目,音符通过音调-时间参数表组织,想换成其他歌曲时只需修改对应数组数值即可,逻辑清晰且复用性高。压缩包内共157个文件、552KB,包含ST标准外设库头文件(.h)与源文件(.c),Keil工程配置文件(.uvprojx/.uvoptx),编译产生的目标文件(.d/.o/.crf),可直接烧录的.hex和.axf固件,以及keilkilll.bat清理脚本和说明文档,既可用于直接实验,也可作为工程模板参考。编译环境、库文件、输出文件齐全,目录结构有助于理解STM32标准库工程的组织方式。目前已有12573人学习下载,适合对照代码理解蜂鸣器发声原理并进行扩展改造。 前阵子收拾抽屉翻出一块stm32f103最小系统板,想着总不能一直吃灰,就琢磨着做点好玩的东西。正好手头有一颗无源蜂鸣器,干脆让它“唱”首歌。说实话,让蜂鸣器发声这件事本身不难,但要把一段旋律播得准、播得稳、播得不像闹钟,里面还是有不少门道。这篇文章就完整记录我从电路到代码、从无声到出声再到播放音乐的整个过程,包括那些让我卡了好几天的坑。
如果你手里也有一块F103的板子和一颗无源蜂鸣器,跟着这篇文章走一遍,不仅能学会播放音乐,还能把定时器PWM、中断优先级、状态机这些嵌入式基本功顺带练扎实。
1. 有源与无源蜂鸣器:一个“响不响”背后的本质区别
1.1 有源蜂鸣器为什么只能“嗡嗡”叫
很多人第一次接触蜂鸣器时都会困惑:既然是蜂鸣器,为什么还要区分“有源”和“无源”?这里的源不是指电源,而是指振荡源。
有源蜂鸣器内部集成了一个振荡电路,只要给它通上额定直流电压,内部就会自行产生一个固定频率的方波驱动压电陶瓷片或电磁线圈,于是直接“嗡嗡”响。这个频率通常在2.4kHz左右,由内部振荡器决定,用户改不了。所以有源蜂鸣器适合做报警提示,比如按键音、超温报警、倒计时结束提醒,它的核心特点是通电即响,频率固定。
无源蜂鸣器内部没有振荡电路,你给它通直流电,它只会保持在一个位置不动,不会发出声音。它必须依赖外部输入一定频率的方波或交流信号,通过频率的变化让内部振膜产生相应频率的机械振动,才能真正发声。
1.2 无源蜂鸣器驱动本质:频率控制音调
理解了上面这个区别,就明白为什么想播放音乐必须选无源蜂鸣器了。
声音的音高由振动频率决定,比如中央C(C4)频率是261.6Hz,A4是440Hz。无源蜂鸣器的驱动信号频率决定了它发出声音的音调,我们只要让STM32的某个定时器输出可调频率的PWM方波,就能控制蜂鸣器发出不同音高的音,再配合每个音的持续时间,就能“演奏”出旋律。
需要特别注意的是,STM32输出的PWM频率不能直接等于声音频率,虽然听起来有点绕,但实际匹配下来,PWM驱动信号的频率和蜂鸣器发声的基频是一致的。也就是说,我让定时器输出261.6Hz的方波,蜂鸣器就发出261.6Hz的声音。这一点和扬声器靠模拟音频信号驱动还不太一样,蜂鸣器本质上是一个带通特性的发声器件,对驱动频率特别敏感。
搞清楚这个底层逻辑,后面所有工作都围绕一件事:让定时器在正确的时间输出正确频率的方波。
2. 驱动电路设计:为什么不能直接把蜂鸣器接到GPIO
2.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。比如中央C(C4)的MIDI编号是60,代入公式:
f = 440 × 2^((60-69)/12) ≈ 440 × 0.5946 ≈ 261.6Hz实际编程时为了提高效率,我不会在每次播放时都做浮点运算,而是直接把常用音符的频率提前算好,存在一个常量数组里。从低音So(G3)196Hz到高音Do(C6)1047Hz,一张表就能覆盖大部分流行歌曲的主旋律。
3.2 定时器预分频与重装载值计算:以PA8输出为例
F103的定时器是16位的,意味着自动重装载寄存器(ARR)最大只能写入65535。如果直接用72MHz的主频去除以262Hz,得到约274809,这个值远超65535上限。所以必须先用预分频器(PSC)把定时器时钟降下来。
我的计算方法是:先用72MHz除以一个合适的预分频系数,让分频后的计数频率略高于目标音频频率,保证ARR值落在16位范围内,同时又能获得足够频率分辨率。
比如设置PSC = 71,分频后计数频率为72MHz / (71+1) = 1MHz。那么对于某个目标频率f,ARR的计算公式就是:
ARR = 1MHz / f - 1验证一下几个音符:
| 音符 | 频率(Hz) | ARR计算值 | 取整后实际频率 |
|---|---|---|---|
| C4 | 261.63 | 3822.1 | 261.65Hz |
| D4 | 293.66 | 3404.2 | 293.68Hz |
| E4 | 329.63 | 3032.7 | 329.63Hz |
| A4 | 440.00 | 2272.3 | 440.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);接下来是定时器初始化,设置PSC=71,ARR=0(后面播放每个音符时单独改),输出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%时反而没声音
有一次测试占空比的时候,我把比较寄存器的值设成了ARR+1,相当于占空比接近100%,结果蜂鸣器不响了。刚开始还以为是蜂鸣器坏了,后来一想,占空比到达100%时,PWM输出的方波变成了恒定的高电平,没有跳变沿,蜂鸣器线圈不产生交变磁场,自然不会有声音。
不只是100%,占空比太接近100%(比如95%以上),虽然还有微弱的跳变,但有效驱动时间极短,声音会急剧减小。同样道理,占空比太低(比如5%以下),驱动能量不足,声音也很小。
对于无源蜂鸣器,50%占空比方波理论上声音最响,因为这种波形在正负半周的能量最均衡。实际测试下来,40%到50%的占空比听觉上差别不大。我统一用50%,也就是设置CCR = ARR / 2。每次切换音符时同时更新ARR和CCR。
热词里提到“stm32f103芯片 定时器输出占空比到不了100”,正好就是这个现象。要说明的是,PWM输出是可以到100%的,ARR寄存器设定了这个上限,但到了100%它就不是方波了,而是一直恒定的电平状态,对蜂鸣器来说这个状态就是不工作。真正有意义的其实是把音量关掉这个场景,设置CCR=0即可,也就是输出恒定低电平。理解这一点后,就不会在“占空比上不去”这个方向上浪费时间了。
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这样的MCU,map文件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、中断、状态机这些核心知识点,而且做完之后能听到实实在在的成果,特别有成就感。我实测下来,整套流程走通大概需要大半天时间,其中大部分时间其实都花在调试和查手册上。所以把那些坑提前写出来,希望能帮你省下这些时间。
本文还有配套的精品资源,点击获取