设计原理
声音是如何产生的?
声音的本质是振动,不同频率的振动产生不同音高(Do, Re, Mi...)的声音,在单片机中,我们无法直接产生复杂的模拟音频信号,但我们可以通过数字信号来模拟。

(图片来源网络,侵删)
最简单的数字音频信号就是方波,一个方波在高电平和低电平之间快速切换,其切换的频率就是声音的频率,要演奏中央 Do (C4),频率约为262Hz,意味着我们每秒钟要让I/O口在高低电平之间切换262次,也就是每个高电平或低电平持续的时间约为 1 / (2 * 262) 秒。
如何用单片机产生方波?
单片机产生精确时序的最佳工具就是定时器。
- 基本思路:
- 设置一个定时器,让它每隔一段时间就产生一次中断。
- 在定时器中断服务程序中,我们翻转一个I/O口的状态(如果原来是高电平,就变为低电平;反之亦然)。
- 这个“每隔一段时间”的长度,决定了输出方波的频率,要产生262Hz的方波,我们需要每
1 / (262 * 2)秒(约1.91毫秒)翻转一次I/O口。
如何演奏一首曲子?
一首曲子由不同音高的音符(对应不同频率)和不同时长的节拍(对应延时)组成。
- 基本思路:
- 定义音符表:我们需要一个数据结构来存储每个音符的信息,最简单的方法是使用一个数组,每个元素代表一个音符,包含其频率和节拍时长。
- 播放循环:在主程序中,我们循环遍历这个音符数组,对于每个音符:
- 根据音符的频率,计算出定时器需要设置的初值。
- 启动定时器,让蜂鸣器发出这个音符的声音。
- 根据音符的节拍时长,延时一段时间。
- 关闭定时器,停止发声,形成音符间的间隙。
- 休止符:用一个特殊的频率(比如0)来表示休止符,当遇到休止符时,我们只需要关闭定时器并延时即可。
硬件准备
- 单片机开发板:任何一款51系列、STM32或AVR的开发板都可以,这里我们以经典的STC89C52(51内核)为例。
- 蜂鸣器:
- 有源蜂鸣器:内部有振荡源,只要给高电平就会响,频率固定。不适合本项目,因为我们无法改变其音高。
- 无源蜂鸣器:内部没有振荡源,需要单片机提供特定频率的方波才能发声。必须使用无源蜂鸣器。
- 杜邦线、下载器等。
硬件连接
将无源蜂鸣器的正极通过一个限流电阻(如1kΩ)连接到单片机的任意一个I/O口(P2.7),蜂鸣器的负极接地。

(图片来源网络,侵删)
软件设计 (以STC89C52为例)
我们将分步实现代码:
步骤 1:建立工程和配置头文件
#include <reg52.h> // 包含STC89C52的头文件 // sbit 是C51扩展的关键字,用于定义位寻址的I/O口 sbit BEEP = P2^7; // 定义蜂鸣器连接的I/O口
步骤 2:定义音符频率表
我们需要一个将音符映射到频率的查找表,这里我们使用一个简单的数组。
// 音符频率表 (单位: Hz)
// C调, 4MHz晶振 (频率可能需要微调)
// 音符: Do, Re, Mi, Fa, Sol, La, Si, Do(H), Re(H), Mi(H), Fa(H), Sol(H), La(H), Si(H)
// 索引: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14
// 索引0用于休止符
unsigned int freq[] = {
0, // 0: 休止符
262, // 1: Do (C4)
294, // 2: Re (D4)
330, // 3: Mi (E4)
349, // 4: Fa (F4)
392, // 5: Sol (G4)
440, // 6: La (A4)
494, // 7: Si (B4)
523, // 8: Do (C5)
587, // 9: Re (D5)
659, // 10: Mi (E5)
698, // 11: Fa (F5)
784, // 12: Sol (G5)
880 // 13: La (A5)
};
步骤 3:编写定时器中断服务程序
这是项目的核心,我们使用定时器0工作在模式1(16位定时器)。
- 计算定时器初值:
- 假设晶振频率为
Fosc = 12MHz,则机器周期为T = 12 / Fosc = 1μs。 - 定时器需要每
T_period = 1 / (2 * freq)秒触发一次中断。 - 定时器需要计数的个数
N = T_period / T = (1 / (2 * freq)) / (1 / 12,000,000) = 6,000,000 / freq。 - 定时器初值
TH0 = TL0 = 65536 - N。
- 假设晶振频率为
// 定时器0中断服务程序
void Timer0_ISR() interrupt 1
{
static bit toggle_bit = 0; // 静态局部变量,用于翻转I/O口
// 重装初值 (这里以262Hz为例,实际应在播放时动态计算)
// TH0 = (65536 - 6000000 / 262) / 256;
// TL0 = (65536 - 6000000 / 262) % 256;
// 翻转蜂鸣器I/O口状态
toggle_bit = ~toggle_bit;
BEEP = toggle_bit;
// 注意:在真正的代码中,初值会根据当前播放的音符频率动态计算。
// 为了简化,这里先不动态计算,后面会整合。
}
步骤 4:编写主程序
主程序负责初始化、定义曲谱、并控制播放。

(图片来源网络,侵删)
// 定义曲谱 (使用上面音符表的索引)
// 节拍: 4代表1/4音符, 8代表1/8音符, 0代表休止符
// 这里以《小星星》为例
unsigned char song[] = {
1, 1, 5, 5, 6, 6, 5, 0, // 一闪一闪亮晶晶
4, 4, 3, 3, 2, 2, 1, 0, // 满天都是小星星
1, 1, 5, 5, 6, 6, 5, 0, // 一闪一闪亮晶晶
4, 4, 3, 3, 2, 2, 1, 0 // 满天都是小星星
};
// 播放单个音符
void PlayNote(unsigned char note_index, unsigned int beat)
{
unsigned int frequency;
unsigned int timer_reload_value;
if (note_index == 0) {
// 休止符
BEEP = 0; // 确保蜂鸣器不响
TR0 = 0; // 关闭定时器
} else {
// 计算当前音符的频率
frequency = freq[note_index];
// 计算定时器重载值
timer_reload_value = 65536 - 6000000 / frequency;
// 设置定时器初值
TH0 = timer_reload_value / 256;
TL0 = timer_reload_value % 256;
// 启动定时器0
TR0 = 1;
}
// 根据节拍延时 (这里用简单的软件延时,不精确)
// 假设一个4分音符的延时为250ms
// 8分音符就是125ms
Delay_ms(250 / beat); // 注意:这是一个简化的延时函数
// 停止发声,形成音符间的间隙
TR0 = 0;
BEEP = 0;
Delay_ms(50); // 音符间隙
}
// 软件延时函数 (粗略延时)
void Delay_ms(unsigned int ms)
{
unsigned int i, j;
for (i = ms; i > 0; i--)
for (j = 114; j > 0; j--);
}
// 主函数
void main()
{
unsigned char i = 0;
// 初始化定时器0
TMOD = 0x01; // 设置定时器0为模式1 (16位定时器)
TH0 = 0x00; // 设置初值 (初始值不重要,因为PlayNote会重置)
TL0 = 0x00;
ET0 = 1; // 使能定时器0中断
EA = 1; // 开启总中断
// 初始化蜂鸣器
BEEP = 0;
while(1)
{
// 循环播放曲谱
for (i = 0; i < sizeof(song) / sizeof(song[0]); i++)
{
// 假设所有音符都是4分音符 (beat=4)
// 如果需要不同节拍,可以再建一个节拍数组
PlayNote(song[i], 4);
}
// 曲子播放完后,暂停一会儿
Delay_ms(1000);
}
}
代码整合与优化
上面的代码结构清晰,但有一个问题:PlayNote函数中的延时会阻塞CPU,导致无法同时处理其他任务(如果有的话),一个更好的方法是使用定时器来驱动节拍,而不是软件延时。
优化版思路(使用定时器驱动节拍)
- 使用两个定时器:
- 定时器0:负责产生特定频率的方波(音高)。
- 定时器1:负责产生节拍延时(时长)。
- 状态机:在主循环中,我们不再阻塞延时,而是检查节拍定时器是否到了,如果到了就播放下一个音符。
优化版代码框架
// ... (头文件和频率表同上) ...
// 定义曲谱
unsigned char song[] = { ... };
unsigned char beat[] = { 4, 4, 4, 4, 4, 4, 4, 8, ... }; // 对应每个音符的节拍
unsigned char current_note = 0; // 当前播放到第几个音符
bit is_playing = 0; // 是否正在播放一个音符
// 定时器0中断 (产生音高)
void Timer0_ISR() interrupt 1
{
// 翻转I/O口
BEEP = ~BEEP;
// 重装初值 (这个初值在开始播放音符时就已经设置好了)
// TH0 = ...;
// TL0 = ...;
}
// 定时器1中断 (产生节拍)
void Timer1_ISR() interrupt 3
{
static unsigned int beat_count = 0;
// 重装初值 (每5ms触发一次)
// TH1 = ...;
// TL1 = ...;
beat_count++;
if (beat_count >= 100) { // 假设100 * 5ms = 500ms为一个基准单位
beat_count = 0;
is_playing = 0; // 标记当前音符播放结束
TR0 = 0; // 停止发声
BEEP = 0;
}
}
void PlayNextNote()
{
if (current_note >= sizeof(song) / sizeof(song[0])) {
current_note = 0; // 重新开始
return;
}
if (song[current_note] == 0) {
// 休止符
TR0 = 0;
BEEP = 0;
} else {
// 根据音符频率设置定时器0
unsigned int timer_reload_value = 65536 - 6000000 / freq[song[current_note]];
TH0 = timer_reload_value / 256;
TL0 = timer_reload_value % 256;
TR0 = 1; // 启动定时器0开始发声
}
// 根据节拍设置定时器1的延时
// 假设4分音符 = 500ms, 8分音符 = 250ms
unsigned int beat_duration = 500 / beat[current_note];
// 需要根据beat_duration调整定时器1的溢出次数或重载值
// ... (这里省略具体计算) ...
is_playing = 1;
current_note++;
}
void main()
{
// 初始化定时器0 (模式1)
TMOD = 0x11; // 定时器0和1都为模式1
// 初始化定时器1 (节拍)
// TH1 = ...;
// TL1 = ...;
ET1 = 1;
// 开启中断
ET0 = 1;
EA = 1;
TR1 = 1; // 启动节拍定时器
while(1)
{
if (!is_playing) {
PlayNextNote();
}
// 在这里可以添加其他非阻塞的任务
}
}
总结与扩展
- 设计一个C语言单片机音乐盒,核心是利用定时器中断产生特定频率的方波来模拟音符,并通过遍历音符数组来控制曲子的播放。
- 扩展:
- 更精确的节拍:使用定时器驱动节拍,而不是软件延时,使系统更高效。
- 多任务处理:在主循环中,除了播放音乐,还可以同时点亮LED、读取按键等。
- 按键控制:添加按键,实现“播放/暂停”、“上一曲/下一曲”等功能。
- LCD显示:结合LCD屏幕,显示正在播放的歌曲名或歌词。
- 更复杂的曲谱:可以设计一个更灵活的曲谱数据结构,支持升降调、附点音符等。
- PWM输出:对于一些高级单片机(如STM32),可以使用PWM(脉冲宽度调制)输出,音质会比简单的方波更好。
这个项目从简单到复杂,有很多可以深入探索的地方,是学习单片机外设和中断编程的绝佳实践,祝你成功!
