简介:这份资源面向使用STM32F103系列单片机的嵌入式初学者与进阶开发者,解决无源蜂鸣器驱动与音乐播放的实践问题。包内提供完整的Keil工程源码,通过定时器PWM控制蜂鸣器频率与节拍,实现《红海情歌》和《生日快乐》两首曲目的播放,修改音调与时间参数即可替换为其他歌曲。资源共157个文件,以36个c源文件、37个h头文件为核心,涵盖STM32标准外设库的定时器、RCC、GPIO等驱动模块,另有23个d依赖文件、23个o编译目标文件、22个crf交叉引用文件及uvprojx工程文件、hex可执行文件、map映射文件等,压缩包约552KB,结构完整可直接编译下载。目前已有12580人学习下载,适合作为STM32定时器与PWM输出的练手项目,帮助读者掌握无源蜂鸣器发声原理、频率与音调的对应关系,并快速移植到自己的开发板上验证效果。
1. 从“滴一声”到《青花瓷》:STM32F103 驱动无源蜂鸣器的完整落地思路
很多人第一次在 STM32F103 上点响蜂鸣器,都是拿有源蜂鸣器直接接 GPIO,给个高电平就“滴”一声完事。但一旦需求变成“播放一段旋律”,有源蜂鸣器就彻底歇菜了——它内部自带振荡源,频率固定,你只能控制它响或不响,控制不了音高。而无源蜂鸣器本质上就是一个电磁线圈加振膜,没有内置振荡电路,必须由 MCU 输出特定频率的方波才能发声,频率决定音高,占空比影响响度和音色。这意味着只要你能精确控制方波频率和持续时间,就能让 STM32F103 演奏出完整的乐曲。这套方案适合已经掌握 GPIO 和定时器基础、想从“点灯”进阶到“发声”的嵌入式开发者,也适合做课程设计、毕业设计或小玩具需要加音乐提示音的从业者。核心就三件事:用定时器产生方波、用延时或系统滴答控制节拍、把音符频率和时值编码成数组。听起来简单,但实际调起来,坑比想象中多。
2. 无源蜂鸣器的驱动原理与 STM32F103 定时器选型
2.1 为什么必须用 PWM 而不是 GPIO 翻转
无源蜂鸣器需要外部提供交变信号才能持续振动发声。最朴素的做法是在 GPIO 上写一个循环,用delay翻转电平,但这种方式有两个致命问题:一是 CPU 被完全占用,无法同时处理其他任务;二是delay的精度受中断和编译器优化影响,频率容易漂移,听感上就是“跑调”。用定时器输出 PWM 则完全不同——定时器硬件自动翻转引脚,CPU 只需在需要换音符时更新寄存器,主循环可以空出来做按键扫描、显示刷新等事情。
STM32F103 有多个通用定时器(TIM2、TIM3、TIM4、TIM5)和高级定时器(TIM1、TIM8),它们都能输出 PWM。对于蜂鸣器这种单通道应用,选哪个都行,但要注意引脚分布。比如 TIM3_CH2 可以映射到 PB5,TIM4_CH1 映射到 PB6,TIM2_CH1 映射到 PA0。我一般优先选 TIM3 或 TIM4,因为 TIM1 和 TIM8 是高级定时器,配置互补输出和死区等寄存器更复杂,杀鸡不用牛刀。
2.2 频率计算:从音符到 ARR 和 PSC
STM32F103 的定时器时钟来自 APB1(TIM2~TIM7)或 APB2(TIM1、TIM8)。以常见的 72MHz 系统时钟为例,APB1 预分频系数为 2 时,定时器时钟为 72MHz。PWM 频率公式为:
[ f_{PWM} = \frac{f_{TIM}}{(PSC+1) \times (ARR+1)} ]
其中 ( f_{TIM} ) 是定时器输入时钟,PSC 是预分频器,ARR 是自动重装载值。要产生某个音符频率 ( f ),可以先固定 PSC,再反算 ARR:
[ ARR = \frac{f_{TIM}}{(PSC+1) \times f} - 1 ]
比如中音 A(440Hz),取 PSC=71,则定时器计数频率为 72MHz/72=1MHz,ARR = 1000000/440 - 1 ≈ 2272。这样占空比通过 CCR 设置,一般取 ARR 的一半即可得到 50% 方波,蜂鸣器响度最大。
2.3 硬件连接与驱动电路
无源蜂鸣器不能直接接 GPIO 和 GND,因为蜂鸣器线圈在断电瞬间会产生反向电动势,可能击穿 IO 口。常见做法是用一个 NPN 三极管(如 S8050)做开关:GPIO 接基极串联 1kΩ 电阻,集电极接蜂鸣器负极,蜂鸣器正极接 3.3V 或 5V,发射极接地。同时蜂鸣器两端反向并联一个续流二极管(如 1N4148),用来泄放反向电动势。如果蜂鸣器工作电压高于 3.3V,必须用三极管隔离,不能直接接 GPIO。
注意:有些无源蜂鸣器标称“3V~5V”,但实际在 3.3V 下响度偏低,如果对音量有要求,建议用 5V 供电并加三极管驱动。
3. 从零搭建:CubeMX 配置与代码实现
3.1 用 CubeMX 生成定时器 PWM 初始化代码
打开 CubeMX,选择 STM32F103C8T6,在 Pinout 视图里找到 TIM3,将 Channel2 设为 PWM Generation CH2,对应引脚 PB5 会自动高亮。在 Clock Configuration 里确认系统时钟为 72MHz,APB1 Prescaler 为 2,这样 TIM3 时钟为 72MHz。在 TIM3 参数设置中:
- Prescaler: 71
- Counter Period (ARR): 先填 2272(对应 440Hz)
- PWM Generation CH2: Pulse (CCR): 1136(50% 占空比)
- CH Polarity: High
生成代码后,HAL 库会自动初始化 TIM3 并启动 PWM。但要注意,CubeMX 默认生成的MX_TIM3_Init()里会调用HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2)吗?不会,启动 PWM 需要手动在main()里调用。很多人生成代码后直接烧录发现没声音,就是因为忘了这一步。
/* main.c 中手动启动 PWM */ HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);3.2 动态改频率:一个函数搞定音符切换
要播放音乐,核心就是不断改变 PWM 频率。HAL 库提供了__HAL_TIM_SET_PRESCALER和__HAL_TIM_SET_AUTORELOAD宏,可以在运行时修改 PSC 和 ARR。但更高效的做法是固定 PSC,只改 ARR,这样计算量小,切换速度快。
/** * @brief 设置蜂鸣器发声频率 * @param freq: 目标频率,单位 Hz,0 表示停止发声 * @note 定时器时钟 72MHz,PSC 固定为 71,计数频率 1MHz */ void Buzzer_SetFreq(uint32_t freq) { if (freq == 0) { /* 停止 PWM 输出,蜂鸣器不发声 */ HAL_TIM_PWM_Stop(&htim3, TIM_CHANNEL_2); return; } /* 确保 PWM 已启动 */ HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2); /* 计算 ARR:1MHz / freq - 1 */ uint32_t arr = 1000000 / freq - 1; __HAL_TIM_SET_AUTORELOAD(&htim3, arr); /* 占空比设为 50%,响度最大 */ __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, arr / 2); /* 产生更新事件,让新 ARR 立即生效 */ HAL_TIM_GenerateEvent(&htim3, TIM_EVENTSOURCE_UPDATE); }这段代码的逻辑说明:freq为 0 时直接停止 PWM,避免蜂鸣器一直响。非零时先确保 PWM 处于启动状态,然后根据公式计算 ARR。__HAL_TIM_SET_AUTORELOAD修改的是影子寄存器,需要产生一个更新事件才能把新值加载到实际寄存器。HAL_TIM_GenerateEvent就是干这个的。占空比设为arr/2即 50%,这是无源蜂鸣器响度最大的点。参数freq的有效范围建议在 200Hz~5000Hz 之间,太低或太高蜂鸣器可能不响应或声音极小。
3.3 节拍控制:用 SysTick 做非阻塞延时
播放音乐时,每个音符需要持续一定时间,比如四分音符 500ms,八分音符 250ms。如果用HAL_Delay(),整个程序会卡住,按键和显示都没法响应。常见做法是用HAL_GetTick()做非阻塞延时:
/** * @brief 播放一个音符 * @param freq: 频率 Hz * @param duration_ms: 持续毫秒数 */ void Buzzer_PlayNote(uint32_t freq, uint32_t duration_ms) { uint32_t start = HAL_GetTick(); Buzzer_SetFreq(freq); while ((HAL_GetTick() - start) < duration_ms) { /* 这里可以插入按键扫描、显示刷新等任务 */ } Buzzer_SetFreq(0); /* 音符间短暂停顿,避免连音 */ HAL_Delay(10); /* 10ms 间隔让音符更清晰 */ }HAL_GetTick()返回的是自系统启动以来的毫秒数,利用无符号整数减法可以安全处理溢出。duration_ms根据曲谱的 BPM 计算,比如 BPM=120 时,四分音符为 500ms,八分音符为 250ms。音符之间加 10ms 停顿是为了区分相邻同频率音符,否则听起来会连成一个长音。
3.4 把简谱翻译成频率数组
中音 C 到高音 C 的频率表如下,实际编码时用宏定义或常量数组:
| 音符 | 频率(Hz) | 音符 | 频率(Hz) |
|---|---|---|---|
| 低音5 | 392 | 中音1 | 523 |
| 低音6 | 440 | 中音2 | 587 |
| 低音7 | 494 | 中音3 | 659 |
| 中音4 | 698 | 中音5 | 784 |
| 中音6 | 880 | 中音7 | 988 |
| 高音1 | 1047 | 高音2 | 1175 |
以《小星星》为例,简谱为1 1 5 5 6 6 5 - 4 4 3 3 2 2 1 -,编码成数组:
/* 音符频率数组,0 表示休止符 */ const uint16_t star_freq[] = { 523, 523, 784, 784, 880, 880, 784, 698, 698, 659, 659, 587, 587, 523 }; /* 对应节拍,单位 ms */ const uint16_t star_dur[] = { 400, 400, 400, 400, 400, 400, 800, 400, 400, 400, 400, 400, 400, 800 }; void Buzzer_PlaySong(void) { for (uint32_t i = 0; i < sizeof(star_freq)/sizeof(star_freq[0]); i++) { Buzzer_PlayNote(star_freq[i], star_dur[i]); } }这段代码把频率和时长分开成两个数组,遍历时依次播放。star_dur里 400ms 对应四分音符,800ms 对应二分音符。实际演奏时可以根据听感微调,比如把 400 改成 350 会让节奏更轻快。
4. 避坑指南:无源蜂鸣器调试中的五个血泪教训
4.1 蜂鸣器不响,但示波器有波形
现象:用示波器测 GPIO 有方波输出,频率也对,但蜂鸣器一点声音没有。原因:驱动电路电流不足。STM32 的 GPIO 最大输出电流约 20mA,而无源蜂鸣器工作电流通常在 30mA 以上,直接驱动带不动。解决:加三极管驱动,或者用达林顿管 ULN2003。检查三极管引脚是否接错,基极电阻是否过大(建议 1kΩ)。
4.2 声音断断续续或频率漂移
现象:播放时声音发抖,或者用手机调音器测频率发现偏差很大。原因:在Buzzer_PlayNote的 while 循环里调用了HAL_Delay或其他阻塞函数,导致 PWM 更新事件被延迟。另外,如果中断优先级配置不当,SysTick 中断被高优先级中断打断,HAL_GetTick()会丢 tick。解决:确保播放循环里只做非阻塞操作。检查 NVIC 优先级,SysTick 优先级不要设得太低。如果用了 FreeRTOS,注意HAL_GetTick的基准可能被任务调度影响。
4.3 音符切换时有“咔哒”声
现象:每个音符开始或结束时听到明显的爆音。原因:频率突变时,PWM 占空比没有平滑过渡,或者停止 PWM 时引脚状态不确定。解决:在切换频率前先把 CCR 设为 0,再改 ARR,最后恢复 CCR。或者在音符之间插入 5~10ms 的静音期。更彻底的做法是用定时器中断做包络控制,但代码复杂度会上升。
4.4 用 HAL_Delay 导致按键无响应
现象:播放音乐时按键完全没反应,必须等整首曲子播完。原因:HAL_Delay是忙等待,期间不执行主循环。解决:改用状态机。把每个音符的播放拆成“设置频率 → 记录起始 tick → 主循环里检查是否到时 → 到时后切下一个音符”。这样主循环每轮都能扫描按键。
4.5 不同蜂鸣器响度差异大
现象:同一块板子换一个蜂鸣器,声音大小天差地别。原因:无源蜂鸣器的谐振频率不同,有的在 2kHz 附近最响,有的在 4kHz。另外,占空比不是 50% 时响度也会下降。解决:查阅蜂鸣器规格书,找到谐振频率,播放时尽量让音符靠近这个频率。如果曲子里低频较多,可以考虑加一级放大或换用谐振频率更低的蜂鸣器。
5. 进阶技巧:用定时器中断实现真正的多任务播放
前面用HAL_GetTick()做非阻塞延时已经能应付大多数场景,但如果你想让蜂鸣器在后台播放,同时主循环跑 OLED 刷新、串口通信、传感器采集,更优雅的方案是用定时器中断驱动音符切换。思路是:用一个基本定时器(如 TIM6)产生 1ms 中断,在中断里递减当前音符的剩余时长,减到 0 就切换到下一个音符并更新 PWM 频率。这样主循环完全不用管音乐播放,只需要在需要时启动或停止即可。
/* 全局变量 */ volatile uint32_t note_index = 0; volatile uint32_t note_remain_ms = 0; volatile uint8_t is_playing = 0; /* TIM6 中断服务函数,1ms 周期 */ void TIM6_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim6, TIM_FLAG_UPDATE) != RESET) { __HAL_TIM_CLEAR_FLAG(&htim6, TIM_FLAG_UPDATE); if (!is_playing) return; if (note_remain_ms > 0) { note_remain_ms--; } else { /* 切换到下一个音符 */ if (note_index >= sizeof(star_freq)/sizeof(star_freq[0])) { is_playing = 0; Buzzer_SetFreq(0); return; } Buzzer_SetFreq(star_freq[note_index]); note_remain_ms = star_dur[note_index]; note_index++; } } } /* 启动播放 */ void Buzzer_StartSong(void) { note_index = 0; note_remain_ms = 0; is_playing = 1; HAL_TIM_Base_Start_IT(&htim6); }这段代码的关键点:note_remain_ms和note_index用volatile修饰,因为它们在中断和主循环之间共享。Buzzer_SetFreq在中断里调用,要确保它执行时间足够短——实际上它只操作几个寄存器,完全没问题。TIM6 的配置很简单,PSC=71,ARR=999,就是 1ms 中断。启动播放时把is_playing置 1,中断里就会自动推进。停止播放时把is_playing清 0 并调用Buzzer_SetFreq(0)。
验证方法:播放《小星星》的同时,在主循环里翻转一个 LED,用示波器看 LED 引脚波形是否均匀,如果 LED 闪烁节奏稳定,说明中断没有阻塞主循环。另外可以用逻辑分析仪抓 PWM 引脚,看频率切换是否干净,有没有多余的毛刺。
从那以后我每次做蜂鸣器驱动,都会先把Buzzer_SetFreq单独测一遍,用示波器确认频率误差在 1% 以内,再往上叠音乐播放逻辑。这个习惯帮我省下了大量“明明代码没错但就是难听”的排查时间。希望帮到你。
本文还有配套的精品资源,点击获取