☰
STM32F103无源蜂鸣器PWM音乐播放:从定时器配置到乐谱驱动全解析
2026/10/20 3:36:10 网站建设 项目流程

简介:使用STM32F103控制无源蜂鸣器发声播放音乐的完整工程,面向嵌入式入门开发者与STM32爱好者,解决通过定时器精确延时或PWM输出驱动蜂鸣器按音调播放乐曲的需求。工程内置《红海情歌》与《生日快乐》两首曲目,音符与节拍以时间参数形式存放,替换曲目仅需修改音调表与延时数据,便于二次改编。资源共157个文件,压缩包仅552KB,以h头文件与c源代码为核心逻辑,另有uvprojx/uvoptx工程配置、d与o编译中间产物、hex与axf可执行文件,覆盖从源码到烧录验证的完整链路,结构清晰可直接参考。已有12581人学习下载,适合用来理解蜂鸣器发声原理、音乐编码方式及STM32定时器与GPIO外设的实际配合。

1. 用STM32F103让无源蜂鸣器"唱"出完整旋律:一个能跑通、能改谱、能拆解的最小音乐播放系统

很多第一次接触蜂鸣器的人都有过这种经历:上电后蜂鸣器只会"滴——"地长叫,想让它按乐谱唱出《小星星》却毫无头绪。这篇笔记要拆的项目,就是基于STM32F103标准外设库实现的无源蜂鸣器音乐播放工程,核心思路是用定时器PWM输出不同频率的方波驱动蜂鸣器,再按乐谱的时值切换频率与延时,最终还原出一段完整的旋律。它不是一个烧录完就结束的玩具Demo,而是一个把"频率控制、定时器配置、时值调度"串起来的完整小系统。适合刚学会GPIO点灯、想更进一步理解定时器PWM与中断协作的开发者,也适合做课程设计或桌面小玩具时直接抄作业。

2. 硬件与发声原理:为什么无源蜂鸣器必须靠频率"喂"才肯唱

2.1 有源和无源蜂鸣器的本质区别

蜂鸣器分有源和无源两类。所谓"有源"是指内部自带振荡电路,只要通电就会以固定频率发声,你没法改变它的音调,只能控制响或不响;而无源蜂鸣器内部没有振荡源,必须由外部提供方波信号才能推动振膜发声,方波的频率决定了音高。这个项目用的是无源蜂鸣器,正因为"无源",它才能被STM32的PWM信号驱动成各种音调——从低音Do到高音Do,本质上就是切换方波频率的过程。

常见的驱动方式有两种:一种是直接用GPIO翻转电平产生方波,适合频率要求不高、CPU闲置的场景;另一种是用定时器PWM输出,由硬件自动翻转引脚电平,CPU只在切换频率时介入一次。这个项目选的是后者,因为播放音乐时每秒钟要切几十次频率,如果用GPIO翻转配合延时,主循环会被占死,后续想加按键、加LED节奏灯就很吃力。PWM方式下,定时器负责"干活",CPU负责"安排",分工清楚。

2.2 音符频率表:C调音符与频率的映射关系

音乐播放的核心是音符频率表。C调常用音符的频率值如下:

音符简谱频率(Hz)
Do1261.6
Re2293.7
Mi3329.6
Fa4349.2
Sol5392.0
La6440.0
Si7493.9
高音Do1.523.3

这段频率表是整个播放引擎的"音色字典"。代码里会用一个const数组把它存下来,索引0对应休止符(频率0,表示静音),索引1~7对应中音Do到Si,索引8开始放高音区。之所以把休止符放进索引0,是因为乐谱里需要表示"停顿"——某个音符弹完不马上接下一个,中间留一拍空白,这在数组里就映射成PWM占空比输出0或者直接关闭定时器输出。

2.3 定时器PWM输出:把72MHz主频换算成音符频率

STM32F103的主频是72MHz,定时器TIM2挂载在APB1总线上,时钟经过预分频后到达计数器。PWM输出频率的计算公式是:

PWM频率 = TIM_CLK / ((PSC + 1) * (ARR + 1))

要让PWM频率等于音符频率,做法是先把PSC设为71,让计数时钟变成 72MHz / 72 = 1MHz,然后根据音符频率反推ARR值:ARR = 1000000 / 目标频率 - 1。比如要输出440Hz的La音,ARR就是 1000000 / 440 - 1 ≈ 2272。

实际工程里我一般把这段换算写成一个函数,输入音符频率返回ARR值。这样主播放引擎只需要在切歌时调用"设置频率",不用每次自己算分频系数。另外还要注意PWM模式选TIM_OCMode_PWM1,占空比设成50%——蜂鸣器的响度在50%占空比时最合适,太低声音发虚,太高电流偏大还可能把蜂鸣器烧出"嘶哑感"。

3. 工程结构拆解:标准外设库下的代码组织与初始化链路

3.1 目录结构与文件职责:一个最小但五脏俱全的Keil工程

整个工程基于STM32标准外设库(StdPeriph),不是HAL库。选标准库的原因很直接:项目小、依赖少、代码直白,适合把"定时器PWM"这条线从头看到尾。工程目录如下:

BEEP_MUSIC/ ├── User/ │ ├── main.c // 主循环:初始化 + 调用播放引擎 │ ├── beep.c // 蜂鸣器驱动:PWM初始化、频率设置 │ ├── beep.h // 驱动头文件,对上层暴露Beep_Init / Beep_SetFreq │ ├── music.c // 乐谱数据 + 播放调度逻辑 │ └── music.h // 音符频率表、乐谱结构体定义 ├── STM32F10x_FWLib/ // 标准外设库源码 ├── CMSIS/ // 内核头文件与启动文件 └── Project/ // Keil工程文件与烧录配置

文件划分的思路是"驱动层与应用层分离"。beep.c只负责"让蜂鸣器以某个频率响起来",它不知道什么是乐谱;music.c只负责"下一拍该响什么、响多久",它不关心引脚配置。这样做的好处是二次开发时改乐谱根本不用碰驱动,换一个蜂鸣器型号也只需要改beep.c里的定时器配置。

3.2 定时器PWM初始化:TIM2_CH1输出方波的关键寄存器配置

初始化代码集中在beep.c里,核心是TIM2的PWM配置。我用PA0作为PWM输出引脚,它复用为TIM2_CH1,代码按"开时钟-配GPIO-配定时器-配输出通道"四步走:

void Beep_Init(void) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tim; TIM_OCInitTypeDef oc; // 1. 打开GPIOA和TIM2的时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // 2. 配置PA0为复用推挽输出,带2MHz翻转速度 GPIO_StructInit(&gpio); gpio.GPIO_Pin = GPIO_Pin_0; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_2MHz; GPIO_Init(GPIOA, &gpio); // 3. 定时器时基:72MHz分频72次,计数时钟1MHz TIM_StructInit(&tim); tim.TIM_Prescaler = 71; tim.TIM_Period = 1000; // 初始值随意,后面切换频率时改 tim.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &tim); // 4. PWM输出通道:模式1,50%占空比 TIM_StructInit(&oc); oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_OutputState = TIM_OutputState_Enable; oc.TIM_Pulse = 500; // CCR = 500,即50%占空比 TIM_OC1Init(TIM2, &oc); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_Cmd(TIM2, ENABLE); }

这段代码里有两个容易被忽略的点。第一是gpio.GPIO_Speed设成2MHz而不是默认的50MHz——PWM方波的翻转频率最高也就几KHz,没必要用50MHz的高速翻转,还能减少电磁干扰;第二是TIM_Pulse = 500配合TIM_Period = 1000,刚好50%占空比,当你后续用函数修改TIM_Period时,CCR值也要跟着改,否则占空比会漂移。

3.3 频率设置函数:让播放引擎只关心"目标频率"

播放引擎不直接操作寄存器,而是调用Beep_SetFreq。这个函数接收一个音符频率值,计算出ARR和CCR后写回定时器:

void Beep_SetFreq(uint16_t freq) { uint16_t arr; uint16_t ccr; if (freq == 0) { // 休止符:关闭PWM输出,让蜂鸣器彻底静音 TIM_CCxCmd(TIM2, TIM_Channel_1, DISABLE); return; } // 计数时钟是1MHz,ARR = 1000000 / freq - 1 arr = 1000000 / freq - 1; // 占空比保持50%:CCR取ARR的一半 ccr = arr / 2; // 先禁止定时器再改寄存器,防止在计数过程中写入产生毛刺 TIM_Cmd(TIM2, DISABLE); TIM_SetAutoreload(TIM2, arr); TIM_SetCompare1(TIM2, ccr); TIM_Cmd(TIM2, ENABLE); TIM_CCxCmd(TIM2, TIM_Channel_1, ENABLE); }

注意里面有个细节:修改频率前先把定时器关闭,改完再打开。如果不这么做,当ARR从2272突然跳到1827时,计数器可能已经超过了新ARR值,导致输出异常。虽然这个场景下偶尔一次毛刺不影响听感,但音乐播放过程中每个音符都要切一次频率,积累起来杂音会很明显,所以这里值得认真处理。

4. 核心模块实现:从音符表到音乐播放的完整链路

4.1 乐谱数据结构:用"音符编号 + 节拍数"表示一首歌

乐谱本质上是一串"什么时候响、响多久"的指令。工程里用结构体数组来存乐谱,每个元素包含音符编号和节拍数:

typedef struct { uint8_t note; // 音符编号,0=休止,1~7=中音Do~Si,8~14=高音 uint8_t beat; // 节拍数,1=八分音符,2=四分音符,4=全音符 } MusicScore;

以《小星星》开头为例,前四小节是"1 1 5 5 6 6 5 -",每个音符是四分音符,最后的"-"是休止一拍。对应的乐谱数组是这样写的:

const MusicScore score_star[] = { {1, 1}, {1, 1}, {5, 1}, {5, 1}, {6, 1}, {6, 1}, {5, 2}, // 这里的5持续两拍 {4, 1}, {4, 1}, {3, 1}, {3, 1}, {2, 1}, {2, 1}, {1, 2}, // 第一段结束 {0, 1}, // 休止一拍 // ... 后续小节省略 };

这种设计最直观的好处是:改歌不用动任何逻辑代码,只需要重新声明一个const数组,或者把数组指针切换成另一首歌。节拍数也不是绝对时间,而是相对时长——播放引擎有一个"每拍延时"的基准值,改这个值就能整体加快或放慢速度,乐谱本身的结构完全不用动。

4.2 播放引擎:遍历乐谱 + 切换频率 + 时值延时的三拍协作

播放的核心逻辑在music.c里,主循环调用一次Music_Play就会把整首歌从头到尾播放一遍。它的工作方式是"读一条乐谱-设置频率-延时-读下一条"的循环:

void Music_Play(const MusicScore *score, uint16_t length, uint16_t beat_delay_ms) { uint16_t i; for (i = 0; i < length; i++) { uint8_t note = score[i].note; uint16_t freq = 0; // 从音符编号映射到频率表 if (note > 0 && note <= 14) { freq = note_freq_table[note - 1]; } // 切换到目标频率(note=0时Beep_SetFreq内部会静音) Beep_SetFreq(freq); // 延时:每个节拍乘以该音符的节拍数 Delay_Ms(beat_delay_ms * score[i].beat); } // 播完最后一首歌把蜂鸣器关掉,防止持续响 Beep_SetFreq(0); }

这段代码看着简单,但有一个隐藏的"时值精度"问题:如果每个音符都调用Delay_Ms,那么音符之间的切换延迟会累积,整首歌会越播越慢。因为每次调用Beep_SetFreq和循环判断本身也要花几微秒到几十微秒,节拍越多累积误差越明显。实际工程里更稳的做法是用SysTick做基准计时,而不是裸延时。

4.3 时值调度的坑:裸延时的累积误差与SysTick方案

裸延时方案在我第一次跑的时候翻过车:一首三十几个音符的歌,从头播到尾,肉眼可见后半段比前半段拖沓。原因是Delay_Ms本身基于SysTick或循环计数的近似值,加上Beep_SetFreq里关闭定时器、重新配置寄存器的开销,每个音符多出几十微秒,几十个音符叠起来就是几毫秒,人耳对节奏偏差非常敏感。

后来我改成SysTick节拍器方案。核心是用SysTick作为节拍基准,在一个1ms中断里递减一个全局计数值,延时函数改成"等待计数值归零":

volatile uint32_t tick_count; void SysTick_Handler(void) { if (tick_count > 0) { tick_count--; } } void Delay_Ms(uint32_t ms) { tick_count = ms; while (tick_count > 0); // 等待中断递减到0 }

这个方案把"延时多久"从CPU忙等的死循环变成了"由中断计时,主循环查询",虽然本质还是阻塞,但计时彻底交给了硬件,误差从几十微秒降到了微秒级。如果你后续要给播放器加按键切换歌曲功能,这个tick_count还能顺带充当去抖计时器,一举两得。

5. 编译烧录与避坑排查:GPIO驱动能力与定时器配置的实践记录

5.1 从Keil到实物的完整跑通流程

拿到工程后的标准操作是:用Keil打开Project目录下的uvproj文件,在Options for Target里确认芯片型号是STM32F103C8T6,然后在C/C++选项卡的Define里加上USE_STDPERIPH_DEVICE和STM32F10X_MD。编译无误后用ST-Link烧录,接线就三根线:PA0接蜂鸣器正极,蜂鸣器负极接一个NPN三极管的集电极,三极管发射极接GND,基极通过1K电阻接3.3V——不对,蜂鸣器驱动的标准接法是PA0通过三极管放大后驱动蜂鸣器,因为PA0的推挽输出最大也就几毫安,直接推蜂鸣器声音会非常小。

正确接法是这样的:PA0输出PWM到NPN三极管基极,基极串一个1K电阻限流,集电极接蜂鸣器负极(有些蜂鸣器模块是接正极,具体看模块丝印),蜂鸣器另一头接3.3V,发射极接GND。三极管在这里起开关作用,PWM高电平让蜂鸣器导通发声,低电平关断。

5.2 蜂鸣器不响或声音很小:现象、原因与解决路径

现象一:烧录后蜂鸣器完全没动静。先别怀疑代码,用万用表测PA0对地电压,如果一直是0V,说明PWM根本没输出——这时候检查TIM_Cmd有没有调用,以及GPIO是否配置成了复用推挽模式。我遇到过最荒诞的一次是把GPIO_Mode配成了GPIO_Mode_Out_PP(普通推挽),普通推挽模式下复用功能不生效,引脚死活不出波形。

现象二:蜂鸣器能响但声音像蚊子叫,又细又弱。这基本是驱动电流不够,PA0直接接蜂鸣器的结果。STM32F103单个GPIO最大输出电流约25mA,蜂鸣器正常工作需要30~50mA,必须加三极管放大。用S8050三极管,基极串1K电阻,集电极接蜂鸣器,实测音量能提高一倍以上。

现象三:音乐播着播着突然变调或者"吞音符"。这是ARR和CCR写入不同步导致的,当定时器正在计数时直接改ARR,输出波形会短暂失真。解决方式就是前面Beep_SetFreq里的做法——先关定时器、改完寄存器再打开,给计数器一个干净的重新开始。

现象四:音高整体偏高或偏低。检查PSC是不是真的配成了71。很多板子的系统时钟不是72MHz而是内部HSI的8MHz,这种情况下定时器计数时钟不是1MHz,ARR计算全错。遇到音高不对,先打印或者调试器看一眼RCC_GetClocksFreq返回的系统时钟频率。

现象五:播放过程中有"咔咔"的爆音。这通常是休止符处理不当——频率为0的时候没有关闭PWM输出,而是把ARR设成0,导致定时器工作异常产生随机频率。休止符一定要走TIM_CCxCmd关闭输出通道那个分支,不要试图输出"0Hz方波"。

6. 二次开发:改乐谱、加按键、换音色的具体改造路径

6.1 换一首歌只需要改数据,不碰驱动

这个项目最让我满意的地方是"改歌成本几乎为零"。想换《欢乐颂》,只需要在music.c里新增一个score数组,然后把main函数里传给Music_Play的指针从score_star改成score_ode_to_joy就行。注意长度值length要跟数组元素个数一致,可以直接用sizeof(score_ode_to_joy) / sizeof(MusicScore)自动计算,省得数错了数组长度导致播放越界。

改谱的时候遵循一个原则:先标音符再标节拍。我一般把简谱抄在纸上,每个音符上方标一小节序号,然后按小节录入数组,录完数一遍整首歌有多少个音符,确认跟数组长度对得上。这一步看着笨,但能省掉大量调式时"少了一个休止符"的排查时间。

6.2 功能扩展:按键切歌与LED节奏灯

接下来可以做的扩展是加一个按键切换歌曲、加一颗LED跟着节奏闪。按键接PB1,用外部中断或轮询加消抖的方式检测;LED接PB0,在Beep_SetFreq里加一句GPIO_WriteBit翻转。播放引擎需要从"整个循环播完才返回"改成"每个音符播完检查一次按键状态",这时前面预留的SysTick计时就派上用场了——把播放循环拆成"播放当前音符-检查按键-播放下个音符"的状态机,避免长时间阻塞在播放里。

从那以后我每次改这类定时器应用,都会强制走一遍"先确认时钟频率、再测PWM波形、最后挂负载"的顺序,省掉了不少拿耳朵猜问题的冤枉时间。希望这篇拆解能帮你把这个蜂鸣器项目跑起来,也让你少踩几个我当时踩过的坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询