简介:面向STM32初学者的无源蜂鸣器音乐播放完整工程方案,基于STM32F103微控制器控制蜂鸣器发声,内置《红海情歌》《生日快乐》两首歌曲,只需改动代码中的音调与时长参数,即可换成任意曲目,适合理解定时器PWM驱动蜂鸣器发声、音乐节拍与音高映射基本原理的学习者。包内共157个文件,压缩后约552KB,含37个.h头文件、36个.c源文件、Keil工程文件、hex/axf烧录调试文件及编译过程文件,另附清理编译中间文件的批处理脚本与工程配置文件,整体目录结构清晰,便于快速定位蜂鸣器控制代码。当前已有12581人浏览学习,工程压缩包小巧完善,能帮助刚入门STM32标准库的读者省去搭工程与排错时间,直接获得从代码编写、编译到烧录验证的完整闭环,是一份边学边做的实用参考。
1. 用 STM32F103 控制无源蜂鸣器播放音乐:先让 PWM 响起来,再谈乐谱还原
先说结论:STM32F103 控制无源蜂鸣器播放音乐,本质不是“驱动喇叭”,而是按乐谱输出一串指定频率与时长的 PWM 方波。很多人第一次把蜂鸣器接上开发板,直接写 GPIO 拉高,结果它要么不响,要么只发出微弱“哒”声,原因就是无源蜂鸣器需要交变信号激励,而不是直流电平。这个项目适合正在学定时器 PWM 和中断的人拿来练手,也适合做小家电、桌面摆件、玩具提示音时直接抄走这段逻辑。下面代码基于标准外设库,凡是换到 HAL 工程,核心只改外设句柄,整套流程不变。
2. 无源蜂鸣器的发声原理:为什么音高由频率决定,而不是供电电压
先看硬件再谈代码。搞清楚蜂鸣器内部结构,才能解释为什么同一个引脚,有人能放出《小星星》,有人只能听到“哒哒哒”。
2.1 有源蜂鸣器与无源蜂鸣器:为什么这个项目必须用无源
有源蜂鸣器内部自带振荡电路,给它一个直流电压,它自己就会发出固定频率的声音,频率通常在 2.5kHz 左右,没法改。无源蜂鸣器内部没有振荡源,只有压电陶瓷片或电磁线圈,需要外部给它一个方波信号,膜片才会随着电平跳变产生机械振动。这个项目要播放不同音高的音乐,必须用无源蜂鸣器,因为只有通过改变方波频率才能改变音高。
判断手里那颗蜂鸣器是无源还是有源,有个土办法:直接给两个引脚接 3.3V 直流电,会响的是有源,只会“啪”一声或者完全没反应的是无源。更靠谱的是看丝印,标了正负极且没有内部驱动电路的型号基本都是无源。我常用的是电磁式无源蜂鸣器,直流阻抗 16 欧姆左右,工作频段大约在 100Hz 到 4kHz,人耳可听的旋律基本落在这个范围。如果用的是压电式无源蜂鸣器,发声效率在 2kHz 以上更高,低频会偏弱,这个差异在第四章会专门展开。
很多人踩的第一个坑是把无源蜂鸣器正负极接反。虽然它不像电解电容那样会炸,但接反后驱动电流小,声音明显偏小偏闷。我习惯在打样时统一规定:长引脚接驱动管集电极,短引脚接 GND,这样从原理图到 PCB 都少一层判断。
2.2 定时器 PWM 输出配置:从 72MHz 主频到 440Hz 方波的参数换算
STM32F103 主频 72MHz,定时器 TIM2 挂在 APB1 总线上。用定时器输出 PWM 时,实际输出频率由预分频器 PSC 和自动重装载值 ARR 共同决定:
输出频率 = 定时器时钟 / ((PSC + 1) * (ARR + 1))
为了生成 A4 = 440Hz,我通常把 PSC 固定为 71,把 72MHz 先分频成 1MHz,这样 ARR = 1000000 / 440 - 1 = 2271。ARR 是 16 位寄存器,最大值 65535,覆盖从低频到高频的音乐频段绰绰有余。占空比由 CCRx 决定,CCR = (ARR + 1) / 2 时输出 50% 占空比,方波最“正”。下面是基于标准外设库的初始化代码,把 TIM2_CH1 配置到 PA0 输出 PWM:
void Beep_Init(void) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tim; TIM_OCInitTypeDef oc; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); gpio.GPIO_Pin = GPIO_Pin_0; gpio.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽输出,交给TIM2控制 gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); tim.TIM_Prescaler = 72 - 1; // PSC=71,72MHz -> 1MHz tim.TIM_CounterMode = TIM_CounterMode_Up; tim.TIM_Period = 1000 - 1; // 初始1kHz,播放时再动态修改 tim.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, &tim); oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_OutputState = TIM_OutputState_Enable; oc.TIM_Pulse = 500 - 1; // 50%占空比 oc.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM2, &oc); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); // 使能预装载 TIM_Cmd(TIM2, ENABLE); }参数关系如下表,调试时对着寄存器看不容易乱:
| 参数 | 对应寄存器 | 我的取值 | 作用 |
|---|---|---|---|
| 预分频 | TIMx_PSC | 71 | 把 72MHz 分频成 1MHz |
| 周期 | TIMx_ARR | 1MHz / freq - 1 | 决定方波频率,也就是音高 |
| 比较值 | TIMx_CCR1 | (ARR + 1) / 2 | 决定占空比,影响音量和音色 |
代码里最容易敲错的是 PSC 写 72 而不是 71,因为硬件分频是 PSC + 1。另一个隐蔽问题是 APB1 时钟。如果工程里 APB1 预分频器不是 2 分频,TIM2 的时钟可能是 36MHz 而不是 72MHz,那所有音符频率都会整体低一半。这个问题在第四章会给出排查方法。
2.3 第一个可运行版本:封装发声函数,先用 1kHz 方波验证通路
初始化完成后,先不要让蜂鸣器去唱乐谱,而是封装两个底层函数,让它稳定输出一个固定频率。调试时听到持续 1kHz 的声音,说明 GPIO 复用、定时器、PWM 输出这条路是通的,再去调乐谱逻辑才有意义。
void Beep_SetFreq(uint32_t freq) { uint32_t arr; if (freq == 0) { TIM_Cmd(TIM2, DISABLE); // 休止符:关闭定时器输出 return; } TIM_Cmd(TIM2, ENABLE); arr = 1000000 / freq - 1; // 定时器时钟1MHz TIM_SetAutoreload(TIM2, arr); TIM_SetCompare1(TIM2, (arr + 1) / 2); // 同步更新比较值,保持50% } void Beep_DelayMs(uint32_t ms) { uint32_t i; for (i = 0; i < ms; i++) { delay_1ms(); } } void Beep_PlayNote(uint32_t freq, uint16_t duration) { Beep_SetFreq(freq); Beep_DelayMs(duration); Beep_SetFreq(0); }Beep_SetFreq 里做了两层保护:freq 为 0 时直接关闭定时器,对应休止符;正常音符则动态修改 ARR 和 CCR1,不需要重新跑一遍初始化。我见过不少工程每次播放前都重新初始化定时器,结果音符一多,初始化耗时让节奏越来越慢,这是完全不必要的。
Beep_PlayNote 里每个音符结束后强制调用 Beep_SetFreq(0),好处是音符之间有极短静音,不会出现“滑音”。麻烦是如果用延时方式播放,主循环会被阻塞。对于开机提示音、按键确认音这种几秒长度的片段,阻塞完全够用;但要播一整首歌且同时处理显示、按键扫描,就必须改造成非阻塞播放,这个放到第五章。
到这一步,硬件和底层发声都已经就绪,接下来要解决的是“怎么把一首曲子的音高和节拍变成数组”。这是整个项目最需要耐心、也最容易出乱子的部分。
3. 乐谱数据化:把简谱转成频率与时值两个数组,主循环踩着节拍走
播放音乐的程序不复杂,复杂的是如何用数据结构描述一首歌。我见过有人把每个音符写成一条case语句,几十个音符下来代码又臭又长,改一个节奏要翻半天。正确思路是:把乐谱看成一张表,每一行是一个音符,横坐标是音高,纵坐标是时值。
3.1 十二平均律音阶频率表:用查表代替现场计算
一个八度内 12 个半音的频率,从 C4 = 261.626Hz 开始,每高一个半音乘 2 的 1/12 次方,约等于 1.059463。在 MCU 上用浮点现场计算又慢又占 Flash,更重要的是浮点转整数时四舍五入会造成音准偏移。常见做法是直接把用到的音阶做成常量表,播放时按下标取值。
下面这张表覆盖 C 调从低音 1 到高音 7 的常用音阶,频率单位 Hz,全部取整:
// 0~6:低音1~7,7~13:中音1~7,14~20:高音1~7 const uint16_t beep_freq_table[21] = { // 低音 131, 147, 165, 175, 196, 220, 247, // 中音 262, 294, 330, 349, 392, 440, 494, // 高音 523, 587, 659, 698, 784, 880, 988 };这张表的边界值要记清楚:中音 1 的索引是 7,不是 0。我在早期版本里就把中音 1 放在了 0 号位置,结果所有曲子都低了整整一个八度,听着像大提琴版《小星星》。如果曲子里出现升降号,比如 #4、b7,就按同样的规律在对应位置插入频率,比如 #4 是 380Hz 左右,需要自己补成整数。
3.2 节拍数据编码:用数组下标对应音符,用数组值对应时值
简谱里一个音符除了音高还有时值。为了在一个数组里同时表达,我拆成两张表:一张存音符索引,一张存时值。时值以 10ms 为单位,四分音符如果定成 400ms,就存 40;二分音符存 80;八分音符存 20。
以《小星星》前两句为例,简谱是1 1 5 5 6 6 5 - | 4 4 3 3 2 2 1 -:
// 7~13为中音1~7,0表示休止 const uint8_t melody[] = { 7, 7, 11, 11, 12, 12, 11, 0, 10, 10, 9, 9, 8, 8, 7, 0 }; // 时值:单位10ms,40表示400ms,80表示800ms const uint16_t rhythm[] = { 40, 40, 40, 40, 40, 40, 80, 10, 40, 40, 40, 40, 40, 40, 80, 10 };对照关系是:melody 里的 11 对应 beep_freq_table[11],查表得到 392Hz,也就是中音 5;rhythm 里的 40 表示这个音持续 400ms。最后补一个 10ms 静音作为分句,这样一段放完,下一段接着放时不会粘在一起。
这种“两张平行数组”的结构,优点是写新曲子只需要照着简谱填数字,不需要改代码;缺点是数组长度要人工保持一致。我习惯在数组末尾加一个0xFF结束符,并在播放函数里显式判断,后面在主循环里会看到。
3.3 播放主循环:查表、保护、踩节拍
有了 melody 和 rhythm 两张表,播放循环可以写得很短:
void Beep_PlayMelody(const uint8_t *notes, const uint16_t *durs, uint16_t len) { uint16_t i; for (i = 0; i < len; i++) { uint8_t note = notes[i]; uint16_t freq = 0; if (note != 0) { // 索引越界保护,避免数组访问越界 if (note <= 20) { freq = beep_freq_table[note]; } } Beep_PlayNote(freq, durs[i]); Beep_PlayNote(0, 2); // 音符之间插入2ms静音 } }这里有个细节:原始数组用const修饰,不能在循环里直接notes[i] = 0做保护,必须先拷到局部变量note再判断。我把越界保护放在这里,因为后面扩展曲谱时,手滑把索引写成 21 以上是常态,保护生效时最多这个音不响,而不是直接 HardFault。
音符间插入 2ms 静音是刻意为之。不加这 2ms,相邻两个同频率的音会连成一条长音,听起来像手风琴;加了之后,每个音有清晰的“起音”和“收尾”,接近真实演奏的颗粒感。如果你希望曲子连贯平滑,把Beep_PlayNote(0, 2);注释掉即可。
播放速度完全由 rhythm 数组控制,想加快一倍就全部除以 2,想改调就把 beep_freq_table 整体换成更高一组频率,代码里其他部分都不用动。到这一步,一个能用蜂鸣器播放完整乐谱的最小工程就成立了,接下来进入排错环节。音符对不上、节奏跑偏、声音刺耳,这些问题在调试阶段几乎一定会遇到。
4. 避坑与常见问题:五个影响发声、音准和节奏的典型故障
这部分是实际调音时最容易翻车的地方。每一条我都用“现象 → 原因 → 解决”的顺序说清楚,照着排查能省下大半天。
4.1 现象:蜂鸣器只有细微“哒哒”声,完全没有音调感
原因:最常见的是代码里没有用 PWM,而是在主循环里用GPIO_WriteBit手动翻转 PA0。手翻频率通常只有几十到几百赫兹,人耳听到的是一个个脉冲声,不是音高;另一个可能是频率对,但占空比接近 0,膜片没被推起来。
解决:回到定时器 PWM 方案,确认 GPIO 初始化为复用推挽GPIO_Mode_AF_PP,并且已经调用TIM_Cmd(TIM2, ENABLE)。如果只是临时验证硬件,可以直接把频率提升到 1000Hz 以上,并保证高低电平时间接近 1:1,听感立刻从“哒哒”变成“嘀——”持续音。
4.2 现象:整个旋律像放慢了半拍,或者所有音都比预期低一个八度
原因:定时器时钟实际只有 36MHz,我却按 72MHz 计算 PSC。STM32F103 的 TIM2 挂在 APB1 上,只有当 APB1 预分频器配置为 2 分频时,定时器时钟才会倍频到 72MHz;如果预分频是 1 分频,定时器时钟就是 36MHz。此时 ARR 还是按 1MHz 计数频率算的,但实际计数频率只有一半,输出频率整体减半,听感就是每个音低八度、节奏变慢。
解决:在调试器里读一下RCC->CFGR的 PPRE 位,确认 APB1 是 2 分频。如果确实配成 1 分频,修正时钟树;如果时钟树不能改,就把PSC从 71 改成 35,让 36MHz 分频成 1MHz。更好的办法是用示波器直接测量 PA0 输出频率,以实测为准。这个坑很隐蔽,因为代码逻辑完全正确,只是时钟配置假设错了。
4.3 现象:播放速度突然变快,尤其是开启编译优化之后
原因:延时函数用 for 循环空转实现,而不是基于 SysTick。没有volatile修饰的循环计数器在 O2 优化下可能被编译器部分或完全消除,导致实际延时缩短;即便计数器没被优化掉,不同优化等级下空转周期数也不同,速度自然会变。
解决:改用 SysTick 实时时钟计数。在 SysTick 中断里维护一个全局毫秒计数,延时函数只做“等待计数增加”的操作,和 CPU 主频、编译器优化完全解耦。从那之后我要求自己,所有时间相关逻辑一律走硬件时基,空转延时只允许出现在纯算法验证里。
4.4 现象:音量忽大忽小,低音发闷高音刺耳
原因:蜂鸣器在不同频率下的阻抗和辐射效率差异很大,同一个 50% 占空比方波,可能低频推不动、高频过载。50% 占空比的最大基波分量并不等于听感最舒服,这在压电式蜂鸣器上尤其明显。
解决:把固定 50% 占空比改成可调,并用软件做简单补偿。例如低音段把 Pulse 提高到 ARR 的 50%,高音段降到 30%,听感更均衡。代码里比较值改成(uint32_t)(arr * 3 / 10)即可得到 30% 占空比。需要精确控制音量的场景,可以在音符表里增加第三个“音量”字段,每个音符单独指定,灵活性最高,代价是数组从两张变三张,写乐谱时工作量增加。
4.5 现象:播放结束蜂鸣器不停,或者第二次播放时第一个音符不响
原因:播放循环结束后没有执行休止逻辑,PWM 持续输出最后一个音符的频率,所以一直响。第二次播放时第一个音符不响,多半是上一次结束时虽然调用了TIM_Cmd(TIM2, DISABLE),但比较值没清零,重新使能定时器后输出状态不确定。
解决:封装一个Beep_Stop()函数,播放前后统一调用:
void Beep_Stop(void) { TIM_SetCompare1(TIM2, 0); // 占空比归零 TIM_Cmd(TIM2, DISABLE); // 关闭定时器 GPIO_ResetBits(GPIOA, GPIO_Pin_0); // 引脚拉低,彻底关断 }我见过一个工程,播放函数自己调了TIM_Cmd(DISABLE),但没把 CCR1 清零,导致蜂鸣器在引脚高电平时“咔”了一小下。这个声音很短暂,不注意听根本发现不了,但每播完一次就响一下,显得很不专业。把这四步收口成同一个函数,能在所有退出路径里统一保证静音状态。
5. 进阶验证技巧:示波器校准音准,三行代码把阻塞播放改造成非阻塞
到这里,蜂鸣器已经能完整播放一首歌了,但离“能用的工程”还有两步:一是验证频率准不准,二是把播放从延时阻塞改成非阻塞。这两件事做完,这个模块才算真正能嵌进上位机程序里。
先说校准。耳朵在判断音准上很不靠谱,尤其是连续听几个八度的旋律,误差五分之一个半音根本听不出来。我习惯把示波器探头直接夹在 PA0 上,测量方波频率和占空比。440Hz 音对应周期约 2.27ms,实测 440.1Hz 左右属于正常,因为 ARR 整数取整导致几十毫 Hz 的偏差。如果实测差了几赫兹甚至几十赫兹,优先检查 APB1 时钟树,而不是怀疑代码。占空比也顺手看一眼,50% 时高低电平宽度应当一致。没有示波器时,可以用逻辑分析仪,甚至用另一块板子读频率计功能,总之要有一个客观上衡量频率的手段。
再说非阻塞播放。阻塞播放的问题在于Beep_DelayMs会让 CPU 停在原地,播放期间扫描按键、刷新 OLED 全部暂停。把延时改成基于 SysTick 的状态机,就可以让蜂鸣器“后台”播放。核心思路是:不再用延时函数傻等,而是每隔 10ms 查询一次播放状态,时间到了就切到下一个音符。
volatile uint16_t g_tick10ms; uint8_t Beep_Update(void) { static uint16_t idx = 0; static uint16_t remain = 0; uint16_t note, freq = 0; if (idx >= 16) { // 播放完整个乐谱 Beep_Stop(); return 1; } if (remain == 0) { note = melody[idx]; if (note > 0 && note <= 20) { freq = beep_freq_table[note]; } Beep_SetFreq(freq); remain = rhythm[idx] / 10; // 10ms一个tick idx++; } if (g_tick10ms) { g_tick10ms = 0; if (remain > 0) remain--; } return 0; }主循环里每 10ms 调用一次Beep_Update(),其他任务照常运行。remain以 10ms 为单位倒计时,rhythm[idx] / 10把原本 10ms 为单位的时值换算成 tick 数。这个版本牺牲了一点节拍精度,因为所有时值都被强制对齐到 10ms 粒度,但换来的是主循环不再被阻塞。如果要求更精准,可以把 SysTick 改成 1ms 中断,代价是Beep_Update()被调用得更频繁。
有一次我为赶项目,把播放循环里一个delay_1ms()顺手改成了空转 for 循环,当时听起来觉得差不多就发版了。后来同事用示波器一测,整段旋律比预期快了将近一倍,完全不像原曲。从那以后我只要碰蜂鸣器音频代码,必定先接示波器确认基准频率,再谈乐谱和节拍,最后才过耳朵这一关。这个习惯帮我挡住了很多次“听感玄学”造成的返工,希望你也能少踩这几个坑。
本文还有配套的精品资源,点击获取