
简介面向单片机初学者与嵌入式爱好者这份压缩包提供了用C语言驱动蜂鸣器播放音乐的完整仿真实验结合Protues还原了从原理到代码再到仿真验证的全流程。包内共22个文件约20.41MB包含buzzer.c源码、可烧录的hex文件、pdsprj仿真工程、uvproj工程配置以及mp4演示录像和png截图便于对照学习。实验重点讲解无源蜂鸣器通过I/O口输出不同频率脉冲来改变音高的机制示例代码涵盖端口寄存器操作、定时周期计算与音符切换逻辑可帮助读者理解音乐合成的底层实现。通过Protues虚拟环境无需实物硬件即可调整频率参数、观察蜂鸣器响应适合反复调试与排错。目前已有253人学习下载适合想结合仿真快速上手单片机蜂鸣器编程的读者。1. 单片机蜂鸣器播放音乐到底在仿什么如果你的课设或实训任务里有“单片机蜂鸣器播放音乐”这一条别把它当成普通的IO口翻转实验。真正要练的是两件事一是用定时器产生不同音调对应的频率二是把简谱翻译成C语言能查表播放的数据。这个实验用 Proteus标题里常被拼成 Protues做仿真芯片、蜂鸣器、连线全在软件里完成不用担心接错线烧硬件非常适合把定时器中断、查表、延时这些基本功串起来。这篇笔记会从无源蜂鸣器选型讲起给出C语言代码骨架、Proteus联调顺序以及最容易让蜂鸣器“哑火”的仿真细节。适合刚学完51单片机、正在为课程设计找方案的同学也适合想快速验证一段旋律能不能播的熟手。2. 先把蜂鸣器选对为什么有源蜂鸣器唱不了歌Proteus元件库里搜 Buzzer通常会出现两个容易混淆的模型名字带 Active 的内部自带振荡电路通电就“呜——”地响音调固定名字带 Passive 的内部没有振荡源你给它多少频率的方波它就出多高的音。很多人第一次做音乐实验随手拖了一个默认 Buzzer 进来结果程序写得再完整声音永远是同一声这就是选错型号了。音乐播放必须用无源蜂鸣器因为每个音符对应的频率要由你的C代码实时生成。2.1 有无源蜂鸣器对比一个自带音源一个需要外部驱动有源蜂鸣器和无源蜂鸣器的差别可以简单地记成“有没有心脏”。有源蜂鸣器内置了一个固定频率的振荡器通电就会自己震动外部IO给高电平还是低电平、给多少Hz的方波对它来说都只是开和关而无源蜂鸣器只是一个电磁发声单元线圈必须通过交变电流才能推动振膜所以外部IO翻转多快它就震动多快。这个特性决定了音乐播放只能选无源。下面这张表是两者在Proteus仿真里最常用的区分方式对比项有源蜂鸣器 Active无源蜂鸣器 Passive内部振荡源有通电即响无需要外部方波通直流电表现持续发声无声音调可否改变不可变固定单一频率可改变频率由方波决定适合场景报警、提示音音乐旋律播放Proteus元件特征名称通常不带Passive名称带Passive、Sounder等字样在Proteus里放置元件时搜索“BUZZER”通常会先看到有源模型需要再往下翻或改关键词“BUZZER-PASSIVE”。认准元件属性里带 Passive 字样再放。仿真里选错不会烧东西但会浪费大量排查时间这一步值得先花一分钟确认。2.2 驱动电路与接线仿真里直连可以真机建议加三极管无源蜂鸣器在Proteus里可以直接接单片机IO口仿真模型不会模拟IO口驱动电流不足的问题但如果你后面要转到实物直接用IO驱动大概率声音很轻甚至可能拉低电平导致芯片工作异常。我一般的习惯是仿真时就按真实电路画用一颗NPN三极管来驱动蜂鸣器这样仿真通过后移植到开发板硬件不用改。常见接法是这样P1.0串一颗1k电阻接到三极管2N2222的基极蜂鸣器一端接电源VCC另一端接三极管集电极三极管发射极接地。单片机IO输出高电平时三极管导通蜂鸣器有电流通过IO输出低电平时三极管关断蜂鸣器停止。IO翻转的方波频率就是蜂鸣器发声的频率。Proteus连线步骤很简单从元件库放AT89C51、无源蜂鸣器、2N2222、RES1k电阻到原理图上按上面的顺序连线。注意无源蜂鸣器模型有正负极标记接反了可能不发声。双击单片机模型在属性里确认Crystal Frequency和代码里的晶振宏一致这一步我建议先设成12MHz后面代码按12MHz算初值。3. 用定时器0产生音符频率C语言骨架与初值计算蜂鸣器播放音乐的核心是把“音高”这个听觉概念转换成“频率”再把“频率”转换成“定时器初值”。一个音符在物理上就是一段固定频率的方波。51单片机没有硬件DAC也没有音频模块最常见也最可靠的做法是用定时器中断翻转IO口让无源蜂鸣器的振膜按指定频率震动。这一章给出可以直接跑通的代码骨架以及三个必须调对的参数。3.1 一个音符的本质频率与定时器初值怎么换算以中音1简谱里的1对应C5为例标准频率是523.25Hz意思是每秒要产生523.25个完整的方波周期。IO口翻转一次只能产生半个周期所以一个完整周期需要翻转两次。若定时器每溢出一次就翻转IO那么方波周期是两次溢出时间的和频率f与溢出周期T_ov的关系就是 f 1 / (2 × T_ov)。定时器工作在方式1时是16位计数器从初值n计数到65536溢出。一个计数脉冲是机器周期51单片机12MHz晶振下采用12分频机器周期是 1μs。因此溢出时间 T_ov (65536 - n) × 1μs。把关系代入并整理得到初值 n 65536 - 500000 / f。这里500000来自 12000000 ÷ 12 ÷ 2前提是晶振12MHz、12T模式。带入f523.25Hzn约等于64580十六进制就是0xFC44。频率越低需要的计数溢出的时间越长初值越小频率越高初值越接近65536。这个反向关系很容易搞混写代码时建议统一用宏或脚本算不要口算。3.2 中断翻转IO的代码骨架中断里只做三件事有了初值计算公式接下来就是中断服务函数。51单片机定时器0的中断号是1代码里写 interrupt 1。中断里只做三件事重装TH0、重装TL0、翻转蜂鸣器IO。除法运算和查表都不要放进来。C51的除法要几十条指令中断频繁进入时会拖慢翻转节奏导致实际频率和理论值对不上声音发飘。/* 12MHz晶振、12T模式根据目标频率算重载初值 */ #define FOSC 12000000UL #define RELOAD(freq) (65536UL - FOSC / 12UL / 2UL / (freq)) sbit BEEP P1^0; unsigned int g_t0Reload; /* 当前音符对应的定时器重载值 */ void Timer0_Init(void) { TMOD 0xF0; /* 只改低4位不影响定时器1 */ TMOD | 0x01; /* 定时器0方式116位定时器 */ TH0 0xFF; TL0 0xFF; ET0 1; /* 使能定时器0中断 */ EA 1; /* 开总中断 */ } void Timer0_ISR(void) interrupt 1 { TH0 (unsigned char)(g_t0Reload 8); /* 先装高字节 */ TL0 (unsigned char)(g_t0Reload 0xFF); /* 再装低字节 */ BEEP ~BEEP; /* 翻转IO输出半个周期 */ }RELOAD宏里三个参数要注意FOSC是晶振频率12是12T分频2代表一个方波需要两次翻转。如果换用11.0592MHz晶振FOSC改成11059200UL全工程只用改这一处。我习惯把FOSC定义在头文件顶部方便移植到不同板子。装初值时的顺序也有讲究先写TH0再写TL0。原因是TL0写入后定时器立即可能继续计数如果先写低字节高字节还是旧值计数器可能瞬间从一个错误的组合值继续跑产生一个误差节拍。先写高字节再写低字节误差只出现在低字节写入前的几个计数脉冲内听感上可以忽略。3.3 播放一个音符定时器、延时和停声的配合频率对上了还不够一个音符还得有时长。时长由乐谱的节拍决定比如四分音符500ms。播放单个音符的流程是把频率换算成重载值启动定时器保持对应时长然后停止定时器。这样下一个音符从零开始不会拖尾。void DelayMs(unsigned int ms) { unsigned int i, j; for (i 0; i ms; i) for (j 0; j 123; j); /* 12MHz下大约1ms具体以实际为准 */ } void Buzzer_PlayTone(unsigned int freq, unsigned int durationMs) { g_t0Reload (unsigned int)RELOAD(freq); /* 先算好重载值 */ TR0 1; /* 启动定时器0 */ DelayMs(durationMs); /* 保持当前音调 */ TR0 0; /* 停止音符断开 */ }这里有一个容易被忽略的点停止定时器后IO口电平保持在上次翻转后的状态蜂鸣器不响但下一个音符启动时是从这个残余电平开始的。实际听感上就是一个极短的电平跳变几乎察觉不到所以不需要额外处理。如果你的旋律里连续两个音符之间有明显杂音可以在TR00后面加一句 BEEP 0让振膜先回到静止位。DelayMs里的循环次数123是经验值受到Keil优化等级影响很大。仿真里听旋律够用但如果发现整首歌速度偏快或偏慢先调这个值不要改乐谱数据。更精确的做法是用定时器1做节拍计时第6章会给出改造方案。4. 把简谱翻译成数据音符频率表与节拍控制有了播放函数下一步就是把一首曲子的简谱转换成C语言能读的数据。最简单的方式是建两张数组一张存音符1到70表示休止一张存每个音符的时长。播放时逐个取出来。这里用《小星星》第一段做例子完整演示从频率表生成到播放循环的整个链条。4.1 先建一张音高-重载值表用脚本离线生成不靠手算前面说过初值 n 65536 - 500000 / f。每个音算一遍虽然不难但手算容易错而且七个音有中音、高音、低音算完还要反复核对。我一般会用一个小脚本离线生成再把结果复制进C数组。频率数据是固定的脚本一次写好以后换歌只需要填频率。以下脚本用Python按照中音C大调常见音高生成重载值freq_map { C5: 523.25, D5: 587.33, E5: 659.25, F5: 698.46, G5: 783.99, A5: 880.00, B5: 987.77, C6: 1046.50, } for name, f in freq_map.items(): n 65536 - int(500000 / f 0.5) print(%s: 0x%04X % (name, n))脚本输出与计算逻辑一一对应四舍五入到整数。得到的C数组如下/* 索引0留给休止符1~7对应中音8对应高音1 */ code unsigned int g_toneReload[9] { 0, /* 0: 休止 */ 0xFC44, /* 1: 523.25Hz */ 0xFCAD, /* 2: 587.33Hz */ 0xFD0A, /* 3: 659.25Hz */ 0xFD34, /* 4: 698.46Hz */ 0xFD82, /* 5: 783.99Hz */ 0xFDC8, /* 6: 880.00Hz */ 0xFE06, /* 7: 987.77Hz */ 0xFE22, /* 8: 1046.50Hz */ };赛前说明一下为什么用查表而不是运行期调用RELOAD宏音乐播放时中断频率很高比如523Hz每秒要进523次中断每次中断如果都做一次除法耗时会占掉很大比例波形周期会被拉长。查表只读一次数组延迟极小。这是音乐播放和普通定时器实验最大的区别牺牲一点存储空间换实时性。4.2 乐谱编码两张数组存一首歌以《小星星》第一段为例简谱是1 1 5 5 6 6 5 - | 4 4 3 3 2 2 1 -。这里的“-”表示前一拍延长也就是二分音符时长是四分音符的两倍。我们可以直接建两个数组一个存音符一个存毫秒时长一一对应。code unsigned char g_musicTone[] { 1, 1, 5, 5, 6, 6, 5, 0, 4, 4, 3, 3, 2, 2, 1, 0 }; code unsigned int g_musicBeatMs[] { 500, 500, 500, 500, 500, 500, 1000, 200, 500, 500, 500, 500, 500, 500, 1000, 200 };这里的8个音符是每小节4拍一小节8个位置前6个是四分音符500ms第7个是二分音符1000ms第8个是休止符200ms。休止符我随手给了200ms作为乐句之间的气口听起来不会太“赶”。第二小节同理。数组长度用两边的0占位播放循环用 sizeof 自动计算不用手动数。4.3 主循环播放逻辑休止符一定要关定时器播放循环的任务取音符判断是休止还是发声装载重载值启动或停止定时器然后延时对应时长。void Music_Play(void) { unsigned char i; for (i 0; i sizeof(g_musicTone); i) { if (g_musicTone[i] 0) { /* 休止符停定时器并将IO拉低 */ TR0 0; BEEP 0; } else { g_t0Reload g_toneReload[g_musicTone[i]]; TR0 1; } DelayMs(g_musicBeatMs[i]); } TR0 0; /* 整曲结束关闭定时器 */ }休止符的处理是这段代码里最容易省略的部分。如果不关TR0IQ会继续翻转蜂鸣器会一直响听起来就是一段连续的“嘶——”根本不像音乐。强制 BEEP0 是为了让振膜彻底停下避免在上一个音符结束时留下电平残余。这个细节建议在实际播放前自己验证一遍把 BEEP0 那行注释掉听感差异非常明显。节拍时长如果想整体调速不需要改数组只需要把所有 g_musicBeatMs 的值乘以一个系数。比如想快一点就整体乘0.8想慢一点就乘1.2。更标准的方式是按BPM算四分音符时长毫秒 60000 / BPM。把数组改成按四分音符倍数写逻辑会更清晰但为了直接能跑我这里直接用毫秒值。5. Proteus蜂鸣器仿真的避坑清单5个让“音乐”变成“噪音”的原因仿真音乐播放大多数问题不是C语言语法错而是“声音不对”和“没声音”这两类。下面5条是我在实际使用中遇到最多的情况每一条都按现象、原因、解决三个步骤拆开方便你按图索骥。5.1 选了有源蜂鸣器只会“嘀嘀嘀”永远唱不了歌现象播放程序运行后蜂鸣器发出持续单调的“呜——”声或者随着IO高低电平断续响但音调一直不变。原因Proteus默认搜索到的Buzzer模型可能是有源蜂鸣器内部振荡器固定了频率外部IO的方波频率对它没有意义。它只认“有电”和“没电”不认“多快的电”。解决删掉当前蜂鸣器重新搜索“BUZZER-PASSIVE”或名称中带Passive的元件。拿不准时双击元件看属性说明书性的描述里如果出现“oscillator built-in”就是有源不能用于音乐播放。放好后IO输出波形应该是方波但只有无源蜂鸣器会跟随方波频率发声。5.2 晶振值对不上音调整体偏高或偏低现象代码编译下载后每个音都能发出来但整体音调明显偏高歌曲听起来像“加速变调”不是某个音错而是所有音都错。原因Proteus单片机模型的Crystal Frequency和代码里的FOSC不一致。最常见的情况是代码按12MHz计算初值但仿真芯片属性里是默认的12MHz刚好没问题如果改成11.0592MHz做串口实验忘了改回初值全按错误晶振算出来频率偏差达到约8%人耳很敏感。解决双击单片机模型打开属性对话框把Crystal Frequency改成和代码FOSC一致。代码里改宏仿真里改属性两边必须同步。我在拿到一个现成工程时第一件事永远是先看这两处。5.3 中断服务函数里做了除法波形频率飘忽不定现象示波器看P1.0输出波形不是稳定方波周期忽长忽短蜂鸣器声音发颤像接触不良。原因中断函数里直接调用了RELOAD(freq)宏C51在运行期算除法进去一次要几十条指令。高音时中断间隔短除法还没算完下一次中断又到了导致翻转时刻乱掉。解决把除法移到主循环或初始化阶段完成中断里只查全局变量g_t0Reload。如果歌的音符较多就用第4章的查表数组运行期零计算。5.4 软件延时做节拍仿真和真机速度对不上现象同一份代码在Proteus里听是正常速度下载到开发板上明显变快或变慢或者Keil优化等级改一下整曲速度就变了。原因for循环的延时函数基于指令周期估算不同IAR、Keil优化等级会影响循环体内代码的指令条数Proteus对指令时序的模拟也和真实芯片有差异。所以DelayMs适合做短延时不适合做精确音乐节拍。解决如果只是课设演示先把DelayMs的循环次数调到一个仿真里听着舒服的值如果还要接数码管、按键等其它任务不要用DelayMs占住CPU改用定时器1产生1ms基准在第6章的播放器结构里推进节拍。仿真主要用于验证音符对不对最终节拍以真机为准。5.5 IO有方波但没声音查模型电压和电脑音频输出现象示波器显示P1.0有漂亮方波蜂鸣器模型也选对了但电脑扬声器就是没有声音。原因Proteus仿真声音输出依赖电脑音频设备另外无源蜂鸣器模型有工作电压设置如果Operating Voltage设置成12V而电路电源只有5V模型可能判定驱动不足不发声。解决先确认电脑音量未被静音Proteus右下角的声音开关是否打开再双击无源蜂鸣器把Operating Voltage设为5V或和电路电源一致。最后用示波器看蜂鸣器两个端子之间的电压确认方波确实加到了蜂鸣器上而不是只在IO口。6. 把播放器改造成状态机让蜂鸣器“边唱边干活”前面几章的播放方式是“延时卡住”DelayMs期间CPU一直在空转蜂鸣器是响了数码管、按键什么都做不了。真实课设里蜂鸣器往往是某个综合系统的一部分比如播放音乐时还要扫数码管显示当前温度、接受按键切歌。这时最好把播放器改成非阻塞的状态机定时器1产生1ms节拍主循环按节拍推进音符CPU在音符与音符之间仍然可以执行其它任务。核心结构是用一个结构体描述一首歌再加一个持有当前播放状态的小模块typedef struct { unsigned char code *tone; /* 音符数组指针 */ unsigned int code *beatMs; /* 时长数组指针 */ unsigned char len; /* 音符个数 */ } Music; Music g_nowMusic; unsigned char g_toneIndex; unsigned int g_noteMs; bit g_noteFinished; void LoadCurrentNote(void) { unsigned char tone g_nowMusic.tone[g_toneIndex]; if (tone 0) { TR0 0; /* 休止符关定时器 */ BEEP 0; } else { g_t0Reload g_toneReload[tone]; TR0 1; /* 发声启动定时器 */ } g_noteMs 0; } void Music_Start(const Music *m) { g_nowMusic *m; g_toneIndex 0; g_noteFinished 0; LoadCurrentNote(); }节拍推进放在定时器1中断里每1ms累加一次超过当前音符的时长就置完成标志void Timer1_ISR(void) interrupt 3 { TH1 0xFC; /* 1ms初值请按自己的晶振实际算 */ TL1 0x18; g_noteMs; if (g_noteMs g_nowMusic.beatMs[g_toneIndex]) { g_noteFinished 1; } }主循环里查标志决定是否推下一个音符void main_loop(void) { if (g_noteFinished) { g_noteFinished 0; g_toneIndex; if (g_toneIndex g_nowMusic.len) { TR0 0; /* 整曲播完 */ return; } LoadCurrentNote(); } /* 空出来的时间可以扫数码管、读按键、刷新LCD */ }这种状态的播放器有一个很大的好处切换成多首歌时只需要准备多个Music结构体按键事件里调用Music_Start换指针就行。比如把《小星星》《两只老虎》《欢乐颂》分别定义成三个数组和三个Music按键1切换到第一首按键2切换到第二首。蜂鸣器播放本身不再占用主循环整个系统的扩展性就打开了。我自己做这类工程时保留一个习惯改完晶振第一件事先用示波器确认单颗音符的波形频率和理论值一致再整曲试听这个步骤能省掉后面90%的“玄学”排查。这套状态机结构你也可以直接套到其它需要定时触发非阻塞更新的任务上不只是放音乐。希望帮到你。本文还有配套的精品资源点击获取