简介:这份资源面向STM32初学者与嵌入式爱好者,提供用STM32F103驱动无源蜂鸣器播放音乐的完整工程,解决定时器PWM输出与音调、节拍控制的实际问题。压缩包共157个文件,约552KB,包含36个c与37个h源码文件、23个d与23个o编译中间文件、22个crf交叉引用文件,以及uvprojx工程、hex可执行文件、map映射、lst列表和bat批处理脚本等,覆盖从源码到烧录的完整链路。工程默认内置《红海情歌》与《生日快乐》两首曲目,通过修改定时器周期与延时即可替换为其他歌曲,便于理解频率与节拍映射关系。目前已有12580人学习下载,适合作为STM32定时器、PWM与GPIO综合练习的参考案例,帮助读者掌握无源蜂鸣器发声原理并快速移植到自己的项目中。
1. 用 STM32F103 驱动无源蜂鸣器播放音乐:从方波到旋律的完整落地
很多人第一次拿到无源蜂鸣器,下意识把它当有源蜂鸣器用——给个高电平就想让它响,结果一声不吭,还以为是模块坏了。实际上无源蜂鸣器内部没有振荡电路,必须由 MCU 输出特定频率的方波才能发声,频率决定音高,占空比和驱动电流决定响度。这个项目就是围绕 STM32F103 的定时器 PWM 输出,把一首曲子拆成「音符频率 + 节拍时长」的序列,逐个喂给蜂鸣器,最终播放出完整旋律。它适合刚接触 STM32 定时器、想找一个能听得到反馈的练手项目的开发者,也适合需要给设备加简单提示音、开机音的嵌入式场景。整份资源的核心不在硬件接线,而在定时器频率计算和音符表的组织方式,这两块吃透了,换任何一首歌都只是改数组的事。
2. 硬件与定时器选型:为什么无源蜂鸣器必须走 PWM
2.1 有源与无源的本质区别
有源蜂鸣器内部集成了振荡电路,通电即响,频率固定,MCU 只需要给一个 GPIO 高电平就能驱动。无源蜂鸣器本质是一个电磁或压电发声元件,没有内置振荡源,必须外部输入交变信号。STM32F103 要驱动它,最稳妥的方式是用定时器产生 PWM 方波,方波频率对应音高,然后通过三极管或直接 GPIO 推挽输出到蜂鸣器。
这里有个容易翻车的点:STM32F103 的 GPIO 单脚驱动能力有限,一般 20mA 左右,而无源蜂鸣器瞬时电流可能超过这个值。常见做法是加一个 NPN 三极管(如 S8050)做开关,GPIO 接基极,蜂鸣器接集电极和电源之间,再并一个续流二极管保护。如果只是小功率压电蜂鸣器,直接 GPIO 推挽也能响,但声音偏小。
2.2 定时器通道分配与频率计算
STM32F103 有多个通用定时器,TIM2、TIM3、TIM4 都是 16 位,适合做 PWM 输出。假设系统时钟 72MHz,选用 TIM3 的通道 1(PA6)输出 PWM。定时器频率计算公式:
PWM频率 = 72MHz / ((ARR + 1) * (PSC + 1))要发出不同音高,就改变 ARR 的值,PSC 固定。比如中央 C(261.63Hz),取 PSC=71,则计数时钟为 1MHz,ARR = 1000000 / 261.63 - 1 ≈ 3821。这样每个音符只需要算出一个 ARR 值,写进定时器的自动重装载寄存器即可。
下面是一段初始化 TIM3 通道 1 PWM 的代码,注释里标了关键参数:
// TIM3 PWM 初始化,用于驱动无源蜂鸣器 // 系统时钟 72MHz,PSC=71 得到 1MHz 计数频率 void BEEP_PWM_Init(u16 arr, u16 psc) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); // TIM3 挂载在 APB1 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // PA6 需要 GPIOA 时钟 // PA6 配置为复用推挽输出,交给定时器控制 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 定时器基础配置:ARR 决定频率,PSC 决定计数时钟 TIM_TimeBaseStructure.TIM_Period = arr; TIM_TimeBaseStructure.TIM_Prescaler = psc; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); // PWM 模式 1:CNT < CCR 时输出高电平 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = arr / 2; // 占空比 50%,方波听感最干净 TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM3, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM3, ENABLE); TIM_Cmd(TIM3, ENABLE); // 使能定时器,但先不发声 }这段代码里arr和psc是入口参数,调用时先传一个默认值让定时器跑起来,后续播放音符时只改 ARR。TIM_Pulse设为arr/2是为了得到 50% 占空比,方波对称,谐波成分简单,听感最接近纯音。如果占空比偏离 50%,声音会变尖或变闷,这是很多人忽略的细节。
2.3 音符频率表与节拍控制
要让蜂鸣器唱歌,得先把简谱翻译成频率和时长。常见做法是定义一个结构体数组,每个元素包含音符频率和节拍时长:
// 音符频率表,单位 Hz,对应中音区 #define L1 262 // Do #define L2 294 // Re #define L3 330 // Mi #define L4 349 // Fa #define L5 392 // Sol #define L6 440 // La #define L7 494 // Si #define M1 523 // 高音 Do #define M2 587 #define M3 659 #define M4 698 #define M5 784 #define M6 880 #define M7 988 // 一个音符 = 频率 + 节拍(单位:毫秒) typedef struct { u16 freq; // 0 表示休止符 u16 beat; // 持续时间 } Note; // 《小星星》片段 const Note music[] = { {L1, 400}, {L1, 400}, {L5, 400}, {L5, 400}, {L6, 400}, {L6, 400}, {L5, 800}, {L4, 400}, {L4, 400}, {L3, 400}, {L3, 400}, {L2, 400}, {L2, 400}, {L1, 800}, {0, 400} // 休止符,收尾 };播放逻辑就是遍历数组,对每个音符:如果频率为 0,关闭 PWM 输出并延时;否则根据频率算出 ARR,写入 TIM3->ARR,同时更新 CCR 保持 50% 占空比,然后延时对应节拍。延时可以用 SysTick 或简单的循环延时,但更稳妥的是用定时器中断做节拍计时,避免阻塞主循环。
3. 工程结构拆解:从 main 到中断的调用链
3.1 目录组织与模块划分
一份可维护的 STM32 工程不会把所有代码堆在 main.c 里。这个项目的常见结构如下:
Project/ ├── USER/ │ ├── main.c // 主流程:初始化 + 播放控制 │ ├── stm32f10x_it.c // 中断服务函数 │ └── system_stm32f10x.c ├── HARDWARE/ │ ├── beep.c // 蜂鸣器 PWM 初始化与发声函数 │ └── beep.h ├── MUSIC/ │ ├── music.c // 音符表与播放逻辑 │ └── music.h └── SYSTEM/ ├── delay.c // 延时函数 └── sys.cbeep.c只负责硬件层:初始化 GPIO 和定时器、设置频率、开关输出。music.c负责应用层:定义曲子、解析音符、调用 beep 接口。这样分层的好处是换一首歌只改 music.c,换一个引脚只改 beep.c,互不影响。
3.2 核心播放函数逐段讲解
播放函数是整个项目的中枢,它把音符表翻译成定时器寄存器的操作:
// 播放整首曲子 // music: 音符数组,len: 音符个数 void Music_Play(const Note *music, u16 len) { u16 i; for (i = 0; i < len; i++) { if (music[i].freq == 0) { // 休止符:关闭 PWM 输出 TIM_SetCompare1(TIM3, 0); } else { // 根据频率计算 ARR:72MHz / (PSC+1) / freq - 1 // PSC 固定为 71,计数时钟 1MHz u16 arr = 1000000 / music[i].freq - 1; TIM3->ARR = arr; TIM_SetCompare1(TIM3, arr / 2); // 保持 50% 占空比 } // 延时对应节拍,控制音符持续时间 Delay_ms(music[i].beat); } // 播放结束,关闭输出 TIM_SetCompare1(TIM3, 0); }逐段看:TIM_SetCompare1(TIM3, 0)把比较值设为 0,输出恒低,蜂鸣器不发声,用来实现休止符。频率非零时,先算 ARR,再设 CCR 为 ARR 的一半。这里有个顺序问题:改 ARR 之后如果 CCR 不跟着改,占空比会突变,产生咔哒声。所以每次改频率都要同步改 CCR。最后的Delay_ms是阻塞式延时,简单但会占住 CPU,如果主循环还有其他任务,建议改成定时器中断驱动状态机。
3.3 中断方式播放与主循环解耦
阻塞延时在播放长曲子时会让 MCU 干不了别的事。更工程化的做法是用一个定时器(比如 TIM2)产生 1ms 中断,在中断里递减节拍计数器,减到 0 就切换到下一个音符。主循环只需要启动播放,然后该干嘛干嘛。
// TIM2 中断服务函数,1ms 一次 volatile u16 note_index = 0; volatile u16 note_timer = 0; volatile u8 is_playing = 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); if (is_playing && note_timer > 0) { note_timer--; if (note_timer == 0) { note_index++; if (note_index >= music_len) { is_playing = 0; TIM_SetCompare1(TIM3, 0); // 播完关闭 } else { Music_LoadNote(note_index); // 加载下一个音符 } } } } }Music_LoadNote负责把第note_index个音符的频率写入 TIM3,并把节拍数写入note_timer。这样主循环里只需要is_playing = 1; note_index = 0; Music_LoadNote(0);就能启动,完全不阻塞。参数note_timer的单位是毫秒,因为 TIM2 配置成 1ms 中断,节拍表里的 400 就对应 400 次中断。
4. 避坑与排查:蜂鸣器不响、音不准、有杂音的常见原因
4.1 上电后完全没声音
现象:代码烧进去,蜂鸣器一点动静没有,示波器测 PA6 也没有波形。
原因:最常见的是 GPIO 模式配错。有人把 PA6 配成普通推挽输出,然后手动翻转电平,但 TIM3 的 PWM 根本没映射到引脚上。STM32F103 的定时器通道和 GPIO 是复用关系,必须把 GPIO 设成GPIO_Mode_AF_PP,并且确认该引脚对应的定时器通道正确。PA6 对应 TIM3_CH1,PA7 对应 TIM3_CH2,PB0 对应 TIM3_CH3,不能随便换。
解决:查数据手册的复用功能表,确认引脚和通道匹配;用示波器或 LED 接在引脚上看有没有方波;检查RCC_APB1PeriphClockCmd是否使能了 TIM3 时钟。
4.2 声音频率明显不对,偏高或偏低
现象:想发 262Hz,实际测出来是 500Hz 或者 130Hz。
原因:ARR 计算时忘了减 1,或者 PSC 设错。STM32 的定时器实际分频是PSC + 1,自动重装载是ARR + 1,公式里少减一个 1 就会差出接近一倍。另外系统时钟不是 72MHz 时(比如用 HSI 8MHz 没倍频),整个计算基准就错了。
解决:用示波器测实际频率,反推72MHz / ((ARR+1)*(PSC+1))是否等于目标值;确认SystemInit里时钟配置正确,SystemCoreClock变量是 72000000。
4.3 播放时有咔哒声或破音
现象:音符切换瞬间有杂音,或者声音发闷。
原因:改 ARR 时 CCR 没有同步更新,占空比瞬间偏离 50%;或者蜂鸣器驱动电流不足,方波边沿变缓。另外,如果用的是电磁式蜂鸣器,没有续流二极管,关断瞬间的反向电动势会干扰电源。
解决:每次改 ARR 后立即TIM_SetCompare1设成新 ARR 的一半;在蜂鸣器两端并一个 1N4148 或 1N4007;三极管基极电阻取 1kΩ 左右,太大导致驱动不足,太小可能烧 GPIO。
4.4 播放速度忽快忽慢
现象:曲子节奏不稳定,有时快有时慢。
原因:用Delay_ms做阻塞延时,如果主循环里有其他中断打断,延时就不准。或者节拍表里的数值和实际中断周期不匹配。
解决:改用定时器中断驱动状态机,节拍计时放在中断里,不受主循环影响;确认中断优先级没有被打断;节拍单位统一成毫秒,和中断周期对齐。
注意:无源蜂鸣器不要直接接 5V 和 GPIO 之间,GPIO 耐压一般 3.3V,反向电流可能损坏引脚。用三极管隔离是成本最低的保护方式。
5. 二次开发:换歌、加按键、做多任务播放的改造思路
5.1 换一首歌只需要改一个数组
音符表是数据和逻辑分离的,换歌就是重新填Note数组。比如要换成《欢乐颂》,只需要把简谱翻译成频率和节拍:
const Note ode_to_joy[] = { {L3, 400}, {L3, 400}, {L4, 400}, {L5, 400}, {L5, 400}, {L4, 400}, {L3, 400}, {L2, 400}, {L1, 400}, {L1, 400}, {L2, 400}, {L3, 400}, {L3, 600}, {L2, 200}, {L2, 800}, {0, 400} };改完数组后,把Music_Play的入口参数指向新数组即可。如果曲子很长,可以把数组放到外部 Flash 或通过串口动态下发,但那是另一个话题了。
5.2 加按键切换曲目
在主循环里扫描按键,按一下切换一首歌。常见做法是定义一个函数指针数组,每个元素指向一首曲子的播放函数,按键按下就切换索引:
typedef void (*SongFunc)(void); void Play_Song1(void) { Music_Play(music, sizeof(music)/sizeof(Note)); } void Play_Song2(void) { Music_Play(ode_to_joy, sizeof(ode_to_joy)/sizeof(Note)); } const SongFunc songs[] = { Play_Song1, Play_Song2 }; u8 song_index = 0; // 主循环中 if (KEY_Scan(0) == KEY0_PRES) { song_index = (song_index + 1) % 2; songs[song_index](); }这里sizeof(music)/sizeof(Note)是计算数组长度的惯用写法,比手写数字可靠。按键扫描要加消抖,否则一次按下会触发多次切换。
5.3 用状态机支持播放、暂停、停止
如果要做成带控制的产品,阻塞式播放就不够了。我一般会定义一个播放状态机:
| 状态 | 含义 | 行为 |
|---|---|---|
| IDLE | 空闲 | 关闭 PWM,等待启动命令 |
| PLAYING | 播放中 | 中断递减节拍,到点切换音符 |
| PAUSED | 暂停 | 保存当前索引和剩余节拍,关闭 PWM |
| STOPPED | 停止 | 索引归零,关闭 PWM |
暂停时把note_index和note_timer存到全局变量,恢复时重新加载。停止则直接清零。这样按键可以映射到启动、暂停、停止三个动作,互不干扰。
5.4 验证方法:用示波器和手机调音器交叉确认
改完代码后,别只靠耳朵判断。我习惯先用示波器测 PA6 的频率,确认和音符表一致;再用手机上的调音器 App 对着蜂鸣器,看显示的音名是否匹配。两个手段交叉验证,基本不会出错。如果示波器频率对但调音器显示偏差,那多半是蜂鸣器本身的谐振频率不在理想区间,换一个型号就行。
从那以后我每次改完音符表,都会先让蜂鸣器单曲循环一个标准音(比如 A4=440Hz),用调音器确认基准没错,再播放整首曲子。这个习惯帮我省掉了很多「明明代码没问题但听起来就是怪」的排查时间。希望帮到你。
本文还有配套的精品资源,点击获取