简介:面向51单片机初学者的蜂鸣器音乐实验例程,以STC89C516为主控芯片,利用定时器产生PWM信号驱动蜂鸣器,还原《小星星》旋律。资源完整包含C语言源码、可直接烧录的hex文件、Keil工程配置(uv2/opt/plg),以及编译过程生成的m51、lst、obj、bak等辅助文件,共11个文件,压缩包仅11KB,结构精简,适合对照学习。目前已吸引19102人次浏览学习。通过该例程可直观理解PWM占空比与频率变化对蜂鸣器音调的影响,掌握51单片机定时器配置、IO口输出及乐谱与音符频率的转换思路。代码注释清晰,工程可直接编译下载到STC89C516开发板验证,便于学习者在此基础上改编更多曲目,是入门嵌入式音频控制与PWM应用的实用素材。
1. 蜂鸣器播放《小星星》听着简单,装着却是入门到课设的分水岭
很多初学51单片机的开发者,第一次真正理解“方波、频率、定时器初值、中断重载”这几件事,不是从流水灯,而是从无源蜂鸣器的 DO RE MI 里。这份资源解压后,就是这样一个典型的《小星星》蜂鸣器工程:用定时器翻转 IO 口产生不同频率的方波,STC89C516 在循环里把音符表一段段喂给 BUZZ,最终让无源蜂鸣器按简谱响出旋律。它适合刚把定时器搞明白、想动手做课程设计又不想额外加音频模块的人。打开源码前先搞清楚一个前提:蜂鸣器不是“播放文件”,而是被方波频率驱动,音准全靠初值表。
2. 从音高表到定时器初值:先算对频率,再谈播放
2.1 无源蜂鸣器和有源蜂鸣器的本质差别:为什么必须用“方波”去推
蜂鸣器分成两类,工程里最常见的翻车就是没区分这两类就直接接线。
有源蜂鸣器内部自带振荡电路,只要给直流电就会发出固定频率的声音,通常 2.5kHz 左右,像闹钟那种刺耳单音。用有源蜂鸣器做报警可以,但放旋律完全不行,因为它的频率被内部硬件锁死,IO 口只能控制“响”或“不响”,控制不了“响成哪个音”。
无源蜂鸣器内部没有振荡源,直流电通上去,膜片只在接通瞬间动一下,发出“咔”一声,然后就没了。要让无源蜂鸣器发出持续声音,必须由外部给它一个方波信号:方波的频率决定音高,方波的峰值电压驱动膜片往复振动。想让它发出 262Hz 的 C4,就是给 262Hz 的方波;想让它发 440Hz 的 A4,方波就得是 440Hz。
这也是“PWM”这个词会出现在蜂鸣器播放工程里的原因。本质上我们要产生的就是一连串占空比 50% 左右的方波,通过改变方波周期来改变音调。STC89C516 这类增强型 51 芯片,即使没有硬件 PWM 模块,也可以用定时器中断+IO翻转模拟出一个“软件方波发生器”,周期由定时器初值决定。严格讲这不叫调制,更多是占空比固定的翻转输出,但思路是一致的。
选型时的建议:如果手头板子上只写着“BUZZ”看不出类型,用万用表量一下两个引脚间的直流电阻,阻值在 16Ω 到 50Ω 之间的多半是无源蜂鸣器,阻值在几百欧以上且表笔一搭就有“吱吱”声的是有源蜂鸣器。这个判断要放在所有接线步骤之前,等焊完再发现类型不对已经晚了。
2.2 音符频率与定时器初值怎么换算:一张表和一个公式
先确认频率基准。C 大调简谱里的“1”对应 C4,频率约 261.6Hz;“2”对应 D4,约 293.7Hz;依次到“7”对应 B4,约 493.9Hz。小星星用到的最高音是 A4 的 6,实际到 B4 也绰绰有余,这些音都在蜂鸣器正常频响范围内。
接下来是核心换算。STC89C516 默认工作在 12T 模式,也就是 12 个晶振周期等于 1 个机器周期。当晶振是 11.0592MHz 时:
- 机器周期 = 12 / 11.0592MHz,约 1.085 微秒
- 方波的半周期 = 1 / (2 × 音符频率)
- 半周期内需要计数的机器周期数 = 半周期时间 / 机器周期
- 定时器初值 = 65536 - 计数个数
写成 C 语言,就是一行宏:
#define FOSC 11059200UL #define RELOAD(f) (65536UL - FOSC / (24UL * (unsigned long)(f)))这里的 24 来自两部分的乘积:前半部分“12”是 12T 模式下 1 个机器周期的倍数,后半部分“2”是因为我们只翻转半个周期。宏里把参数转成 unsigned long,是为了防止频率 1000 以上时乘法溢出。
用这个宏直接算,就得到一张 11.0592MHz 晶振下《小星星》需要的音阶表:
| 唱名 | 标准频率(Hz) | 整数初值 | TH0 高字节 | TL0 低字节 |
|---|---|---|---|---|
| 1(C4) | 261.6 | 63778 | 0xF9 | 0x22 |
| 2(D4) | 293.7 | 63969 | 0xF9 | 0xE1 |
| 3(E4) | 329.6 | 64140 | 0xFA | 0x8C |
| 4(F4) | 349.2 | 64216 | 0xFA | 0xD8 |
| 5(G4) | 392.0 | 64361 | 0xFB | 0x69 |
| 6(A4) | 440.0 | 64489 | 0xFB | 0xE9 |
| 7(B4) | 493.9 | 64604 | 0xFC | 0x5C |
如果你观察表格会发现相邻两个音之间的 TH0 差值很小,这也是手动改初值时最容易出错的地方。所以工程里通常不是手写TH0 = 0xF9这种魔法数,而是先把音阶索引存成数组,播放时再查表。
需要注意,这里用的是整数除法,计算结果和浮点算出来的理想值会差两三个计数,听感差异在几个音分以内,人耳几乎分辨不出来。但晶振如果换成了 12MHz,这张表就完全失效了,整体音调会明显偏高。换晶振就重算,这是后面避坑章会反复强调的一条。
2.3 节拍从哪里来:把简谱拆成“音高+时值”数组
频率表解决的是“音准”,节拍解决的是“节奏”。标准做法:以四分音符作为基准,定义一拍等于一个基础延时值。为了不在程序里出现小数点,我习惯把最小音符定为八分音符,时值用整数表示。
按这种方式,《小星星》第一行简谱:
1 1 5 5 6 6 5- | 4 4 3 3 2 2 1-
其中每个四分音符占 2 个单位,最后的二分音符“5-”和“1-”占 4 个单位,写成数组:
code unsigned char tune_pitch[] = { 1, 1, 5, 5, 6, 6, 5, 0x80, 4, 4, 3, 3, 2, 2, 1, 0x80 }; code unsigned char tune_beat[] = { 2, 2, 2, 2, 2, 2, 4, 0x80, 2, 2, 2, 2, 2, 2, 4, 0x80 };这里0x80是我自己约定的“分隔符”,表示一行结束,也可以理解成休止符。tune_pitch存唱名,tune_beat存对应时值,两个数组下标一一对应。为什么不用浮点存 0.5 拍?因为 51 单片机做浮点运算要调用软件库,代码体积变大,广播出来还有可能让主循环卡顿,整数时值是最稳妥的选择。
真正播放时,主循环取出tune_pitch[i],查频率表得到初值,开定时器;再取出tune_beat[i],延时对应时长后关定时器。那句话是:音高由定时器中断内部处理,音符时长由主循环负责,两者一分工,代码就清爽了。
3. 接线、编译到烧录:让蜂鸣器第一次发出正确的声音
3.1 最小驱动电路:三极管、限流电阻和引脚选择
直接拿 IO 口去推无源蜂鸣器,是小项目里最常见的第一板翻车。51 单片机的 IO 口在准双向模式下高电平输出电流很弱,而蜂鸣器是感性负载,启动瞬间电流需求不小,直接接轻则声音发闷,重则芯片复位。
我一般会加一颗 NPN 三极管做驱动,接法如下:
VCC(5V)→ 无源蜂鸣器正极 无源蜂鸣器负极 → 三极管集电极 三极管发射极 → GND 三极管基极 → 1kΩ 电阻 → 单片机 P1.0这个电路里,P1.0 输出高电平时三极管导通,蜂鸣器负极被拉到 GND,电流从 VCC 穿过蜂鸣器形成回路;P1.0 输出低电平时三极管截止,蜂鸣器不响。整个驱动电流由三极管承担,单片机引脚只提供基极电流。
参数上几个关键点:基极电阻取 1kΩ 到 4.7kΩ 都可以,阻值大一点,IO 口负担小,但三极管开关速度会略慢,对 2kHz 以下的音符没影响;蜂鸣器供电和单片机供电必须是同一个稳压源,否则烧录握手时电平不稳,会看到下载一半失败。如果系统里还有其他电机、继电器这类大负载,建议在蜂鸣器电源两端并一个 100μF 电解电容和 0.1μF 陶瓷电容,防止蜂鸣器启动瞬间把 VCC 拉塌。
引脚选择上,代码里用sbit BEEP = P1^0。不要用 P3.0 和 P3.1,这两个引脚同时是串口下载引脚,接三极管基极还好,接电容负载会干扰下载时序,烧录时可能反复失败。
3.2 工程怎么建、代码怎么烧:手动操作一次全流程
拿到解压后的工程,第一步不是急着改代码,而是先把它在编译环境里过一遍,生成烧写文件。
一个典型流程是这样:
- 打开编译环境,新建工程,选择芯片型号。STC89C516 和标准 8051 内核兼容,寄存器定义可以直接用
reg52.h。 - 把下载包里的
.c源文件和.h头文件添加到工程。 - 打开编译选项,勾选生成 HEX 文件。没生成 HEX 文件,后面烧录软件根本没东西可写。
- 编译,观察输出窗口有没有零错误。如果有错误,先看是不是头文件路径没加对。
- 连接 USB 转串口下载器,把下载器的 TX 接板子的 RXD,RX 接板子的 TXD,GND 共地。
- 打开 ISP 烧写软件,选择芯片型号 STC89C516,选择刚才生成的 HEX 文件。
- 点“下载”按钮后,再给板子上电。STC 系列的下载习惯是“冷启动”,先点软件端,后给板子上电,上电瞬间芯片才会进入 ISP 引导区。
这里有个容易被忽略的步骤:如果波特率设得太高,比如 115200,而你的 USB 转串口质量一般,下载会在擦除阶段就报错。我一般先从默认波特率开始,烧录失败再降一档,稳定后再追求速度。
主函数骨架长这样:
void main(void) { EA = 0; // 先把总中断关掉,防止初始化进行到一半被中断打断 Timer0_Init(); // 配置定时器0,设置好中断重载初值 EA = 1; // 初始化完成后再开总中断 while (1) { Play_Twinkle(); // 播放《小星星》,播放完循环继续 } }先关中断再初始化,这个顺序很重要。如果开着中断就去设 TH0/TL0,定时器可能已经在跑,而 reload 初值里的全局变量还没被赋值,中断一进来就用旧数据重载,蜂鸣器会发出一声不受控的尖锐爆音。
3.3 先做一次“长响测试”:三条语句验证电路是好的
烧录成功后先别急着听《小星星》,用一个最朴素的测试确认硬件链路:让蜂鸣器持续发一个固定频率的方波。
while (1) { BEEP = 0; DelayUs(250); // 半周期 250 微秒,整体 500 微秒,对应约 2kHz BEEP = 1; DelayUs(250); }如果这段程序能让蜂鸣器发出连续、稳定的“滴”声,说明无源蜂鸣器、三极管驱动、引脚定义都是通的。如果听到的是“哒哒哒”的断续声而不是连续音,说明方波频率过低或者占空比不正常,多半是延时函数实际延时和标称值差太多。实测中,DelayUs在优化等级不同的编译环境里差异很大,所以它只适合做 3 秒验证,不能用来做长期音符延时。
这一步做完再切回正常播放工程,万一旋律不对,你至少能确定问题在“音高数据”而不是“硬件电路”。
4. 中断是怎么“干活”的:读代码、改参数、换效果
4.1 定时器0中断的配置与重载逻辑
播放蜂鸣器,核心就是定时器 0。初始化代码一般长这样:
void Timer0_Init(void) { TMOD &= 0xF0; // 低四位清零,保留定时器1的配置不动 TMOD |= 0x01; // 定时器0工作方式1:16位不自动重装载 TH0 = 0xF9; // 预置 C4 的初值高字节 TL0 = 0x22; // 预置 C4 的初值低字节 ET0 = 1; // 使能定时器0中断 TR0 = 1; // 启动定时器0 }注意这里用方式 1,也就是 16 位定时器。方式 0 是 13 位定时器,初值上限小;方式 2 是 8 位自动重装载,省掉了在中断里重新写 TH/TL 的麻烦,但初值最多只能表达 256 个计数周期,算低频音符时误差太大。方式 1 在大多数工程里是平衡选择。
懒虫式写法是把初值写死,但可读性差。我习惯用一个全局变量统一管理重载值:
volatile unsigned int reload_value = 0xF922; void Timer0_ISR(void) interrupt 1 { TH0 = (unsigned char)(reload_value >> 8); TL0 = (unsigned char)(reload_value & 0xFF); BEEP = !BEEP; }中断服务程序只做了三件事:取全局变量的高字节给 TH0,取低字节给 TL0,翻转 BEEP。整个函数没有调用任何延时函数,也没有做音乐索引累加,这是中断代码的基本素养。中断里做的事情越少,越不容易出现重入和堆栈问题。
参数说明:reload_value是 16 位变量,在 51 内核下赋值操作不是原子的。如果主循环在改reload_value的低字节,中断刚好被触发,它可能读到“高字节是新值、低字节是旧值”的混合数据,表现出来就是换音瞬间破音。解决方法是给reload_value加volatile修饰,或者在主循环改值时临时EA = 0、改完再EA = 1。后者更彻底。
4.2 主循环的调度:音符时值由“人”决定,拍子交给延时
定时器中断管音高,主循环管时值。一个典型的播放函数是这样:
void Play_Twinkle(void) { unsigned char i = 0; while (tune_pitch[i] != 0x80) { unsigned char pitch = tune_pitch[i]; unsigned char beat = tune_beat[i]; Reload_Tone(pitch); // 查频率表,更新 reload_value TR0 = 1; // 启动定时器,蜂鸣器开始发声 Delay_1ms(200UL * beat); // 一拍基础长度 200ms,乘以时值 TR0 = 0; // 停止定时器,进入音符间隙 BEEP = 0; // 引脚复位,避免持续通电路 i++; } }Reload_Tone内部是一个查表过程:把pitch作为下标,从第 2.2 节的初值表里取出对应的 16 位数,赋给reload_value。Delay_1ms(200UL * beat)里的 200 是“一拍持续 200ms”,也就是每分钟 300 拍的夸张速度?不对,一拍 200ms 是每分钟 120 拍的常规速度,正好适合《小星星》。
这段代码的问题和取舍并存:主循环被延时卡住,播放过程中没法同时做按键扫描。如果只是做个课设演示,没有问题;如果想要边播放边控制 LED,就要把延时改成事件计数,比如每次进中断对节拍计数减一,主循环只判断计数是否到零。这个重构放到第 6 章里展开。
还有一个细节:音符结束我习惯先TR0 = 0再BEEP = 0。原因是如果只停定时器而不主动把引脚拉低,蜂鸣器可能停在最后翻转的电平上,虽然声音停了,但驱动管一直处于导通状态,白白耗电。
4.3 没有硬件 PWM 的时候,软件“PWM”怎么理解
蜂鸣器播放工程的 PWM 本质,你可以简单理解为:以极快的频率交替翻转引脚,让蜂鸣器膜片在方波驱动下振动发声。硬件 PWM 模块的优势是占空比和频率可以由寄存器独立控制,不占 CPU;而软件模拟方案的优点是灵活,换频率只要改一个reload_value,缺点则是占空比固定在 50% 左右,无法通过改变占空比来调节音量。
在某些工程里,会看到用软件延时直接翻转方波的实现,连定时器都不用。那种写法在改变频率时同时改变了发声时间,音高和音长耦合在一起,几乎没法精确还原简谱。定时器中断方案则把“频率”和“时长”完全解耦:频率由中断初值决定,时长由主循环延时决定,这才是往正经播放框架上靠的做法。
5. 避坑指南:蜂鸣器不响、音不准、节奏乱的五个现场问题
5.1 一上电就长鸣,但旋律出不来
现象:烧录后蜂鸣器持续发出尖锐单音,不管程序怎么改都只有一种音调。
原因:板上接的是有源蜂鸣器。有源蜂鸣器上电即响,频率由内部振荡器决定,外部方波对它无效,所以永远只会长鸣。
解决:换成无源蜂鸣器。如果手边没有替换件,可以用万用表电阻挡确认:有源蜂鸣器通常是几百欧以上,且表笔接触时内部电路会激发“吱吱”声;无源蜂鸣器阻抗在几十欧上下,表笔接触只有很轻微的咔哒声。
5.2 旋律能走,但音调整体偏高或偏低
现象:能听出《小星星》的轮廓,但“5 5 6 6 5”整段都比印象中的音高高出一截,或整体偏低发闷。
原因:初值表里的晶振频率和实际板子不匹配。下载包里初值如果是按 11.0592MHz 算的,而你的板子用的是 12MHz 晶振,定时器计数快了,同样的初值下面产生的方波频率偏高,整体音调就上去了。
解决:把第 2.2 节的宏FOSC改成12000000UL,重新计算整张初值表。换完晶振后一定要回来改这行,我见过为图省事直接不改的,结果整段旋律听起来像快进版。
5.3 节奏忽快忽慢,前几句正常后面乱拍
现象:前几个音符还正常,后半段音符时长开始长短不一,甚至出现连续两个音粘在一起。
原因:常见原因是延时函数里套了其他任务,或者中断服务程序里做了太多运算导致定时器溢出次数不稳定。另一种可能是主循环里Delay_1ms在不同优化等级下实际延时不一致,编译时优化开高后循环被改写,延时值变了。
解决:把音符延时的基准改成定时器节拍变量。也就是用一个全局变量beat_counter,在主循环里累减,播放函数判断它归零再切下一个音,彻底摆脱对Delay_1ms精确性的依赖。同时保证中断服务程序里只做初值重载和引脚翻转,不查表、不运算、不调函数。
5.4 声音沙哑或者音量太小
现象:音符能分辨,但声音像被闷住,堵在蜂鸣器里出不来,或者音量对比同板子上的其他蜂鸣器工程明显小。
原因:两个常见来源。一是驱动方式没有加三极管,直接用 IO 口驱动蜂鸣器,电流不够,膜片振幅不够,声音自然沙哑;二是蜂鸣器供电和信号地之间有电位差,或者蜂鸣器正负极接反。
解决:回到 3.1 的三极管驱动电路。基极电阻可以适当减小到 1kΩ,让三极管更快进入饱和区。电源两端并 100μF 电解电容,尤其注意蜂鸣器电源和单片机要是同一路稳压输出。若还是小,可以考虑把蜂鸣器接到 P2 端口,P2 口在准双向模式下的驱动能力和 P1 差别不大,但有个别板子在 P1 上接了其他负载会分走电流。
5.5 换音瞬间有“咔哒”爆音
现象:音符和音符衔接的边界,总能听到一声类似触点开关的“咔哒”,低音部分尤其明显。
原因:切换音符初值时,方波正处于半周期中间,中断进来后用新初值重载,波形的相位被生生切断。扬声器和蜂鸣器对这种阶跃信号很敏感,瞬间的直流跳变就变成了爆音。
解决:换音前先关闭定时器并复位引脚,再更新初值。顺序是TR0 = 0; BEEP = 0; reload_value = 新值; TR0 = 1。这样每次音符都在同一个相位起点开始,爆音会消失,代价是音符之间有几十微秒的静音间隔。如果间隔听感太明显,可以在换音后加一个 5ms 到 10ms 的短休止符,把“咔哒”掩盖成交替呼吸的顿感。
6. 把《小星星》换成任意旋律:一种只要填数组的播放框架
6.1 用结构体数组代替两个平行数组
前面第 2.3 节的数组虽然能跑,但每次要加新歌就得同时维护两个数组,下标一旦错位,音高和时值就串了。更省事的做法是定义结构体,把“音高”和“时值”放在同一个节点里:
typedef struct { unsigned char pitch; // 唱名索引,0 表示休止,0xFF 表示结束 unsigned char beat; // 时值,单位:半拍 } note_t; code note_t song[] = { {1, 2}, {1, 2}, {5, 2}, {5, 2}, {6, 2}, {6, 2}, {5, 4}, {4, 2}, {4, 2}, {3, 2}, {3, 2}, {2, 2}, {2, 2}, {1, 4}, {5, 2}, {5, 2}, {4, 2}, {4, 2}, {3, 2}, {3, 2}, {2, 4}, {5, 2}, {5, 2}, {4, 2}, {4, 2}, {3, 2}, {3, 2}, {2, 4}, {1, 2}, {1, 2}, {5, 2}, {5, 2}, {6, 2}, {6, 2}, {5, 4}, {4, 2}, {4, 2}, {3, 2}, {3, 2}, {2, 2}, {2, 2}, {1, 4}, {0xFF, 0xFF} };播放函数就变成了纯粹的数据消费端:
void Song_Play(code note_t *s) { unsigned char i = 0; while (s[i].pitch != 0xFF) { if (s[i].pitch == 0) { TR0 = 0; BEEP = 0; } else { Reload_Tone(s[i].pitch); TR0 = 1; } Delay_1ms(200UL * s[i].beat); i++; } }code关键字表示把结构体数组放在程序存储区而不是内部 RAM,51 单片机的内部 RAM 只有一百多字节,一首歌几十个音符放 RAM 里会有压力,放到code区后这部分数据不占运行内存。pitch == 0表示休止,不启动定时器,但保留时值,这样休止符在节奏上不会错位。
6.2 换歌时我的验证习惯
换歌最容易犯的错是“看着简谱自以为已经编对了”。我现在每换一次歌,都会先拿一张纸把简谱写成两列,左边音高、右边半拍数,数完一整张再抄进数组。抄完先编译一次,下载上电,如果音准没问题,再用节拍器对一次速度。那一次当场听,如果连续两小节听不出“1 1 5 5”的轮廓,我一定先回头检查初值表是不是给 12MHz 晶振算了,而不是去怀疑代码结构。
从那以后,我每次拿到蜂鸣器工程都会强制走一遍流程:先确认蜂鸣器无源,再确认晶振频率和初值表一致,最后才听旋律。这三步看起来基础,却是避坑效率最高的三件事。希望帮到你。
本文还有配套的精品资源,点击获取