51单片机录音电子琴制作:定时器中断与矩阵键盘实战

51单片机录音电子琴制作:定时器中断与矩阵键盘实战 简介这份压缩包是一套基于51单片机的电子琴完整工程源码适合嵌入式入门学习者、课程设计学生及单片机爱好者研究和复用。项目实现按键演奏、内置音乐播放与录制回放功能涉及IO端口读取、音符解析、定时器中断、波形生成及音频输出等关键环节。压缩包共17个文件大小约53KB包含C语言源码、Keil工程配置uvproj/uvopt、编译生成的hex烧录文件、列表与映射文件lst/m51以及若干备用备份文件bak可据此快速查看程序逻辑并直接烧录验证。目前已有1980人学习下载。通过阅读源码可以直观理解51单片机如何将按键输入转换为对应音调、如何通过延时或定时器控制发声频率以及录音数据的缓冲与管理思路对掌握单片机外设编程和音频应用开发具有实际参考价值。1. 用 51 单片机攒一台能录音的电子琴核心不是按键而是定时器把电子琴做到 51 单片机里很多人第一反应是「用 PWM 生成不同频率的方波」。方向没错但 89C52 这类经典 51 芯片本身没有硬件 PWM 模块至少传统 51 没有真正干活的是定时器加中断设定时器按音符频率溢出在中断服务函数里翻转一次 IO 口电平输出端就得到对应的方波再驱动无源蜂鸣器发声。至于「录制」和「回放」本质也不是存音频而是把按键事件和持续时长按时间序列存进缓冲区回放时再交给同一个发声逻辑。这套方案不需要外挂 DAC 或音频编解码芯片一块 51、一个无源蜂鸣器、一组按键就能跑起来非常适合做课程设计或嵌入式入门练手。压缩包里的main.c、Keil 工程文件和已编译好的电子琴.hex就是一个完整可烧录的参考实现。下面从音符频率的生成原理开始逐个拆解这个项目里最值钱的部分。2. 定时器分频与音符频率映射从查表到中断翻转2.1 为什么选择定时器中断而不是软件延时翻转 IO按键按下后要输出某个音符最粗糙的做法是写一个while循环里面延时半个周期再翻转引脚。这种软件延时方案有两个硬伤一是 CPU 被占满按键扫描和录音逻辑完全没有机会执行二是延时受编译器优化级别影响换个优化等级音准就飘了。正确的做法是让定时器工作在方式 116 位计数把初值按目标频率算好使定时器溢出间隔恰好等于方波半周期。溢出时触发中断在中断服务函数里执行一次引脚翻转。这样主循环可以专心扫描按键、记录演奏事件发声由中断后台完成互不干扰。2.2 初值计算公式与误差来源51 单片机的定时器是加法计数从初值计数到 65536 溢出。所以初值的计算公式是初值 65536 - 晶振频率 / (12 * 2 * 目标频率)其中12是 51 单片机的固定 12 分频一个机器周期占 12 个时钟周期2代表方波半周期翻转一次、完整周期翻转两次。以 11.0592MHz 晶振、C4261.63Hz为例初值 65536 - 11059200 / (12 * 2 * 261.63) 65536 - 1761.0 63775取整后实际频率会有极小的偏差人耳基本听不出来但设计时应该知道误差从哪来。下面是一段常用的音符初值表直接用unsigned int数组存放查表比运行时计算快得多// C4 ~ B5共 16 个音符晶振 11.0592MHz定时器方式 1 unsigned int code note_freq[] { 63775, // C4 261.63Hz 63603, // D4 293.66Hz 63437, // E4 329.63Hz 63277, // F4 349.23Hz 63076, // G4 392.00Hz 62881, // A4 440.00Hz 62691, // B4 493.88Hz 62499, // C5 523.25Hz 62358, // D5 587.33Hz 62171, // E5 659.26Hz 62036, // F5 698.46Hz 61855, // G5 783.99Hz 61678, // A5 880.00Hz 61505, // B5 987.77Hz 61437, // C6 1046.50Hz 61317 // D6 1174.66Hz };code关键字把表格固定到程序存储器ROM不占用宝贵的片内 RAM。14 个 16 位数据占 28 字节 ROM对 8KB 程序空间的 89C52 来说可以忽略不计。2.3 定时器初始化和中断服务函数的写法// 定时器 T0 初始化方式 116 位定时 void timer0_init(void) { TMOD 0xF0; // 只修改低 4 位保留 T1 的配置 TMOD | 0x01; // T0 工作方式 116 位定时器 TH0 0x00; TL0 0x00; ET0 1; // 开启 T0 中断 EA 1; // 开启总中断 } // 按键按下后把当前音符的初值装载到定时器 void play_note(unsigned char index) { TR0 0; // 先停止定时器避免装载瞬间产生毛刺 TH0 note_freq[index] 8; // 取初值高 8 位 TL0 note_freq[index] 0xFF; // 取初值低 8 位 TR0 1; // 重新启动定时器 } // 定时器 T0 溢出中断服务函数翻转蜂鸣器引脚产生方波 void timer0_isr(void) interrupt 1 { TH0 note_freq[current_note] 8; // 重新装载初值 TL0 note_freq[current_note] 0xFF; buzzer_pin !buzzer_pin; // P3.7 或其他 IO 口翻转 }这里有两个容易踩的坑。第一play_note里先TR0 0再装载初值而不是直接写TH0/TL0是为了防止正在计数时装载导致半个周期异常第二中断服务函数里重新装载初值的顺序必须先高 8 位后低 8 位。51 的定时器是 16 位但分两个 8 位寄存器访问如果先写TL0再写TH0中间会出现一个极短的错误计数值听感上偶尔会有一声爆音。current_note是全局变量保存当前正在播放的音符索引。音符结束时执行TR0 0并置一个无效索引比如0xFF这样定时器不再中断引脚电平停留在最后状态——这部分逻辑要配合按键扫描一起设计。3. 矩阵键盘扫描与键值到音符的映射策略3.1 矩阵键盘比独立按键省多少 IO电子琴至少需要 16 个琴键两个八度如果用独立按键方案一个按键占用一个 IO 口16 个键就要 16 个引脚89C52 总共才 32 个 IO 口剩下的还要接蜂鸣器、LED 指示灯、可能的外挂存储明显不够用。矩阵键盘把 16 个按键排成 4 行乘 4 列行线接 4 个 IO列线接 4 个 IO一共 8 个引脚就能扫描 16 个按键。代价是扫描变复杂了不能再像独立按键那样直接读引脚电平必须主动控制行线电平再读取列线状态每一次完整扫描要分 4 轮完成。3.2 逐行拉低法一个完整扫描周期#define KEY_ROW P1 // P1.0 ~ P1.3 接矩阵键盘行线 #define KEY_COL P1 // P1.4 ~ P1.7 接矩阵键盘列线 unsigned char key_scan(void) { unsigned char row, col, key 0xFF; // 0xFF 表示无按键 // 每轮拉低一行检查四根列线 for (row 0; row 4; row) { P1 ~(0x01 row); // 拉低当前行其余行为高电平 P1 0x0F; // 确认低 4 位只有当前行是 0 // 注意直接写 P1 会覆盖高 4 位这里用 | 保留列线输入状态 P1 (P1 0x0F) | 0xF0; // 低 4 位输出扫描电平高 4 位输入 // 延时几个微秒等待电平稳定 _nop_(); _nop_(); // 读取高 4 位判断哪一列被按下 col (P1 4) 0x0F; if (col ! 0x0F) { // 根据 row 和 col 计算键值 key row * 4 col; // 简化写法col 需要解析出具体位 break; } } return key; }这段代码里有个很容易出错的地方就是P1既做输出低 4 位行线又做输入高 4 位列线端口的读写方向必须管理好。第 5 行用P1 ~(0x01 row)把当前行拉低、其他行拉高但这条语句同时会把高 4 位也写成0xF0的取反值即0x0F导致列线方向错误。正确做法是先给P1写入扫描电平再把高 4 位置 1 切成输入模式。我用注释标注了一个常用写法实际工程中更稳健的方式是用一个变量保存整个端口值分两次赋值unsigned char scan_val 0xFF; // 默认所有行输出高电平 scan_val ~(0x01 row); // 当前行拉低 P1 scan_val; // 一次写端口行线列线状态都确定 _nop_(); col (P1 4) 0x0F; // 读列线这个写法避免了对端口寄存器的反复修改和方向切换逻辑也清晰得多。3.3 消抖与音符触发时机按下触发还是电平触发矩阵键盘扫描拿到的还只是「某行某列被按下」的原始键值要变成音符事件中间还差两步消抖和边沿检测。软件消抖最常见的方案是「检测到电平变化后延时 10~20ms 再确认一次」。电子琴场景下延时消抖有个副作用如果消抖期间没有继续扫描快速连击会丢音。比较好的做法是把消抖放到状态机里——每个按键维护一个 2~3 位的计数连续 N 次扫描读到相同电平才确认状态翻转这样消抖期间按键事件不会丢失。51 的 RAM 只有 256 字节52 是 512 字节16 个按键每个分配 1~2 字节是完全可以接受的。音符触发要用边沿而不是电平。按下时触发play_note松开时触发stop_note这样才能支持后面的录音功能——录音存的不是「某个时刻某个键被按住」的瞬时状态而是「按下事件」和「松开事件」的序列。如果在主循环里检测到按键电平就直接播放按住不放会反复触发播放录音数据里会产生大量冗余事件。// 简化的按键扫描状态机只处理按下边沿 #define KEY_RELEASED 0 #define KEY_PRESSED 1 unsigned char key_state[16]; // 每个按键的当前状态 unsigned char key_count[16]; // 消抖计数器 void key_process(void) { unsigned char i; unsigned char key_val key_scan(); // 通过 key_val 判断每个按键当前的电平状态这里做了简化处理 for (i 0; i 16; i) { if (key_level(i)) { if (key_state[i] KEY_RELEASED) { key_count[i]; if (key_count[i] 3) { // 连续 3 次扫描都是按下 key_state[i] KEY_PRESSED; key_count[i] 0; play_note(i); // 触发音符事件 record_note_event(i, 1); // 写入录音缓冲区按下事件 } } } else { if (key_state[i] KEY_PRESSED) { key_count[i]; if (key_count[i] 3) { // 连续 3 次扫描都是松开 key_state[i] KEY_RELEASED; key_count[i] 0; stop_note(); // 停止发声 record_note_event(i, 0); // 写入录音缓冲区松开事件 } } } } }这段代码里key_level(i)是一个辅助函数内部根据key_scan的结果解析第i个按键当前的电平状态。消抖计数器在状态未确认前持续累加一旦确认翻转就清零重新计数。3 次连续确认一般对应 15ms 左右假设扫描周期 5ms比固定延时更平滑也方便统一调节——想加快响应就把阈值从 3 改成 2。4. 录音与回放用事件序列和时间戳对抗 RAM 限制4.1 为什么不能直接存音频波形51 单片机演奏时输出的是一路方波信号理论上把这个方波的采样点全部存下来就能完整回放。但计算一下就知道不现实8kHz 采样率勉强覆盖到 C6 的 1046Hz、每个采样点 1 字节、89C52 的 RAM 只有 256 字节。即使外挂 24C02256 字节 EEPROM也只能存 32ms 的音频一个音都录不完。所以工程上绝对不可能存采样点。可行的路是存事件序列记录每个音符开始时间、音符编号、持续时长。一个音符事件只需要 3 个字节2 字节时间戳 1 字节音符编号256 字节 RAM 能存 85 个音符事件在课程设计的演奏速度下大约能录 30~60 秒完全够用。4.2 循环缓冲区加系统节拍的设计录音需要一个公共的时间基准最常见的是让定时器 T1 工作在方式 28 位自动重装溢出周期设成 2ms中断里维护一个全局tick计数。音符开始和结束时记录当前的tick值作为时间戳。// 录音缓冲区每个事件占用 3 字节 // [0] 时间戳低字节 [1] 时间戳高字节 [2] 音符编号0x80 表示按下0x00 表示松开 #define MAX_EVENTS 80 unsigned char code rec_buffer[MAX_EVENTS * 3]; // 实际应放在 XRAM 或 EEPROM unsigned char rec_head 0; // 写入位置生产索引 unsigned char rec_count 0; // 事件总数 unsigned int last_tick 0; // 记录相对时间 // 由按键扫描调用按下时 event 0x80 | 音符号松开时 event 音符号 void record_note_event(unsigned char note, bit is_press) { if (rec_count MAX_EVENTS) return; // 缓冲区已满丢弃新事件 unsigned int relative_time tick - last_tick; // 相对上一事件的时间差 rec_buffer[rec_head] relative_time 0xFF; rec_buffer[rec_head] (relative_time 8) 0xFF; rec_buffer[rec_head] is_press ? (0x80 | note) : note; rec_count; }用相对时间戳而不存绝对时间有一个好处回放时不需要知道起始tick是多少也天然规避了tick溢出循环的问题。假设tick是 16 位无符号数速度很快的情况下两个事件之间间隔 65535 个单位约 131 秒以内都可以正确编码超过这个间隔录音缓冲区会出现一个很大的空隙实际演奏中不会出现这种情况因为人不松开上一个键就无法按下一个。4.3 回放状态机的实现回放逻辑不能阻塞在主循环里等延时结束否则回放期间按键扫描和 LED 刷新全部卡死。正确做法是一个状态机维护当前回放位置和剩余等待时间在系统节拍中断里减计数减到 0 就执行下一个事件。// 回放状态控制变量 bit playing 0; // 1 正在回放 unsigned char play_pos 0; // 当前事件索引 unsigned int wait_tick 0; // 距离下一事件还需等待的 tick 数 // 在 2ms 系统节拍中断服务函数中调用 void playback_poll(void) { if (!playing) return; if (wait_tick 0) { wait_tick--; // 每个 tick 减一 return; } // 等待时间到取出下一个事件 unsigned char t_low rec_buffer[play_pos]; unsigned char t_high rec_buffer[play_pos]; unsigned char note rec_buffer[play_pos]; wait_tick (unsigned int)(t_high 8) | t_low; if (note 0x80) { play_note(note 0x7F); // 按下事件播放对应音符 } else { stop_note(); // 松开事件停止发声 } // 所有事件处理完毕停止回放 if (play_pos rec_count * 3) { playing 0; stop_note(); } }回放期间蜂鸣器的发声还是走 T0 中断和演奏模式完全相同所以听起来和录制时几乎一致。唯一可能出现的差异是演奏时按键扫描间隔约 5ms录音时间戳精度就是 5ms回放时事件调度精度是 2ms 的 tick反而更高。这个精度差异人耳无法分辨不需要额外补偿。4.4 单缓冲区还是双缓冲区上面的实现使用的是单缓冲区录音和回放共享同一个rec_buffer。这意味着录音过程中不能同时回放回放开始后新录音会覆盖旧数据。对课程设计来说这个限制可以接受代码也简单。如果要做成「录完 A 段再录 B 段再叠加播放」这种多轨功能就要设计双缓冲区RAM 占用翻倍51 的资源会非常紧张。一般的做法是需要双轨的项目直接换成 STC15 或 STM3251 的定位就是把这个单轨流程跑通、跑稳。5. 基于工程文件还原 Keil 项目结构与 main.c 的代码走读5.1 从文件列表反推工程配置压缩包里有一组典型的 Keil C51 工程文件文件名能透露不少信息文件作用电子琴.uvproj/电子琴.uvoptKeil uVision4/5 的项目文件uvproj存编译配置uvopt存界面和调试配置电子琴.Uv2.bak旧版 uVision2 格式的工程备份main.c唯一的源文件全部程序逻辑都在这里main.OBJmain.LST编译生成的二进制目标文件和汇编对照清单LST文件可以查看 C 语句对应的汇编指令电子琴.hexIntel HEX 格式的烧录文件最终烧进单片机电子琴.M51内存分配映射文件显示变量、函数在内存中的具体地址电子琴.plg编译日志编译出错的输出信息在这里看用 Keil 打开电子琴.uvproj就能直接编译但如果要用 Proteus 做仿真建议单独建工程再添加main.c避免备份文件干扰。打开电子琴.M51可以看到CODE MEMORY段的具体分配确认音符查表有没有超出程序空间正常情况下这个项目编译后应该在 4KB 以内。5.2 main.c 的函数组织方式一个典型的 51 电子琴main.c按这个顺序组织头文件包含、引脚宏定义、全局变量、工具箱函数延时、初始化、T0/T1 中断服务函数、按键扫描与处理、录音回放逻辑、main主函数。下面给出一个可以直接用的main函数框架void main(void) { // 第一步初始化 timer0_init(); // 配置 T0 为 16 位定时器用于发声 timer1_init(); // 配置 T1 为 8 位自动重装定时器2ms 系统节拍 buzzer_pin 0; // 蜂鸣器引脚初始化为低电平 rec_count 0; // 清空录音缓冲区 EA 1; // 开启总中断必须在所有外设初始化完成后 // 第二步主循环 while (1) { key_process(); // 扫描按键处理音符事件 // 功能切换逻辑 if (play_back_requested()) { // 按了「回放」功能键 play_pos 0; wait_tick rec_buffer[1] 8 | rec_buffer[0]; // 加载第一个事件的时间戳 playing 1; } } }注意上电后要先完成时钟和外设配置再开总中断如果提前EA 1T0 中断可能在初始化到一半时触发此时current_note还是随机值装载初值会算出一个错误频率蜂鸣器发出一个异常的爆音。5.3 从 LST 文件学习中断服务函数的隐式 RETI生成的main.LST文件对理解 51 的底层执行非常有用特别是中断部分。打开LST文件搜timer0_isr或ISR能看到 Keil 生成的中断向量跳转; 中断向量区 0003H 02 00 30 LJMP timer0_isr以及中断服务函数末尾自动生成的RETI指令。C 语言写的interrupt 1关键字被编译器解析成三个动作把地址挂到固定中断向量位置、函数开头保护寄存器现场、函数结尾用RETI返回。这解释了为什么中断服务函数里不要做太多耗时操作——RETI不会自动清除中断标记如果函数执行时间超过中断间隔下一次溢出会立即再次触发造成中断嵌套风暴。到这一步演奏和录音回放的主流程已经全部打通。但代码在 Proteus 里跑起来有声音不代表硬件上一定能响最后这部分把仿真和实物的坑一次性说完。6. Proteus 仿真联调示波器验证频率和时长6.1 电路连接要点Proteus 仿真 51 电子琴的电路按下面的方式搭单片机选 AT89C51 或 AT89C52晶振填11.0592MHz两个 30pF 负载电容接地复位电路用 10uF 电解电容串 10k 电阻到 VCC经典的 51 上电复位结构矩阵键盘的行线接 P1.0~P1.3列线接 P1.4~P1.7注意在仿真里加RESPACK上拉电阻到 VCC不然列线浮空会随机读到低电平蜂鸣器接 P3.7蜂鸣器另一端接地。P3.7 推挽能力有限实物驱动建议加三极管放大仿真里可以直接接6.2 用虚拟示波器验证方波频率Proteus 里放一个OSCILLOSCOPE探针接在 P3.7 上。按下 C4 琴键应该看到约 261Hz 的方波周期约 3.83ms。如果测出来频率偏差超过 5%先检查晶振频率是否填成了 12MHz——很多教程默认用 12MHz但 12MHz 配合 51 的 12 分频会产生 0.5us 的定时步长计算音符初值时误差会比 11.0592MHz 稍大。11.0592MHz 的优势在于它本身就是为串行波特率设计的频率同时也能被音符频率整除得比较干净。下面给出实际测量时的判断方法C4 期望频率 261.63Hz - 周期 3822us - 定时器中断间隔 1911us半个周期 A4 期望频率 440.00Hz - 周期 2273us - 定时器中断间隔 1136us C6 期望频率 1046.50Hz - 周期 956us - 定时器中断间隔 478us用示波器的光标功能量一个完整周期的宽度换算成频率和表格值对比。误差在 ±1% 以内即可接受人耳对这个级别的音高偏差不敏感。6.3 无源蜂鸣器与有源蜂鸣器一个让初学者集体翻车的坑仿真里随便选哪个蜂鸣器都能响但实物硬件上必须用无源蜂鸣器不能有源蜂鸣器。有源蜂鸣器通常型号带 H比如 HNB12A内部有振荡电路通电就响一个固定频率给高低电平只会接通或断开发不出不同音高的声音。无源蜂鸣器型号不带 H需要外部给方波才响频率由方波决定才是电子琴需要的。另一个实物问题51 的 P3.7 灌电流和拉电流能力都很有限约 20mA直接驱动无源蜂鸣器声音很轻电平也可能被拉跨导致频率失真。常见做法是加一个 S8550 三极管放大P3.7 -- 1k 电阻 -- 三极管基极 VCC -- 蜂鸣器正极 蜂鸣器负极 -- 三极管集电极 三极管发射极 -- GND这个接法是发射极接地、集电极驱动蜂鸣器P3.7 输出高电平时三极管导通蜂鸣器通电。对比直接接 P3.7 的方式声音明显更响亮波形也更接近标准方波。6.4 Keil 与 Proteus 联调的三个常见问题编译main.c时如果用了多个文件比如把按键扫描拆成单独模块Proteus 加载 HEX 后点运行单片机没有反应先检查 Keil 的 Target Options 里有没有勾选Create HEX File。这个选项默认不勾选编译只生成OBJ文件Proteus 加载的是一个空 HEX自然什么都不跑。如果按下按键声音正常但录音回放没声音优先检查rec_count有没有成功递增。在 Keil 的 Debug 模式下全速运行断点打在record_note_event里按下琴键后看rec_count是否变化。如果断点根本没触发说明按键事件没有进入录音逻辑问题出在key_process的边沿检测上。还有一种情况是回放速度明显偏慢或偏快通常是 T1 定时器的重装初值和系统节拍不一致。比如timer1_init里配置的是 2ms 中断但playback_poll在别的地方也被调用相当于等待时间被稀释或加速。这类时序问题排查方法很直接在回放模式下用示波器同时看 P3.7 的方波和录音时的方波对比同一音符的持续时间长短差多少就说明节拍偏了多少。完成这三项验证——频率对得上、录音回放事件数正确、回放时长和演奏时长一致这个 51 电子琴项目就可以从仿真环境烧录到实物上收工了。本文还有配套的精品资源点击获取