☰
51单片机无源蜂鸣器播放《小星星》:定时器初值与方波频率详解
2026/10/15 15:57:25 网站建设 项目流程

简介:面向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.6637780xF90x22
2(D4)293.7639690xF90xE1
3(E4)329.6641400xFA0x8C
4(F4)349.2642160xFA0xD8
5(G4)392.0643610xFB0x69
6(A4)440.0644890xFB0xE9
7(B4)493.9646040xFC0x5C

如果你观察表格会发现相邻两个音之间的 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 工程怎么建、代码怎么烧:手动操作一次全流程

拿到解压后的工程,第一步不是急着改代码,而是先把它在编译环境里过一遍,生成烧写文件。

一个典型流程是这样:

  1. 打开编译环境,新建工程,选择芯片型号。STC89C516 和标准 8051 内核兼容,寄存器定义可以直接用reg52.h。
  2. 把下载包里的.c源文件和.h头文件添加到工程。
  3. 打开编译选项,勾选生成 HEX 文件。没生成 HEX 文件,后面烧录软件根本没东西可写。
  4. 编译,观察输出窗口有没有零错误。如果有错误,先看是不是头文件路径没加对。
  5. 连接 USB 转串口下载器,把下载器的 TX 接板子的 RXD,RX 接板子的 TXD,GND 共地。
  6. 打开 ISP 烧写软件,选择芯片型号 STC89C516,选择刚才生成的 HEX 文件。
  7. 点“下载”按钮后,再给板子上电。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 晶振算了,而不是去怀疑代码结构。

从那以后,我每次拿到蜂鸣器工程都会强制走一遍流程:先确认蜂鸣器无源,再确认晶振频率和初值表一致,最后才听旋律。这三步看起来基础,却是避坑效率最高的三件事。希望帮到你。

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

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

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

立即咨询