资讯详情

资讯详情

51单片机智能计算器实战:矩阵键盘与LCD1602时序整合

简介面向51单片机学习者与电子类课程设计学生的一套智能计算器软件源码基于C51编写利用矩阵按键完成数字与运算符输入配合LCD1602液晶显示与蜂鸣器提示实现四则运算、清零、等于功能。压缩包内有17个文件核心为C源程序、LCD驱动头文件、Keil工程文件、HEX烧录文件及M51、LST编译辅助信息另含接线说明txt与接线图jpg体积约4.09MB便于查阅。设计可通过宏定义切换LCD1602的4位/8位模式矩阵按键引脚映射清晰蜂鸣器预留P20扩展位适合初学者对照原理图验证与二次开发。目前已有2859人学习代码结构简洁可直接烧录运行也可扩展连续运算和括号功能作为智能计算器课程设计模板很有参考价值。1. 51单片机按键键盘LCD1602蜂鸣器的智能计算器源码难点在时序整合拿到基于51单片机按键键盘LCD1602显示蜂鸣器设计的智能计算器软件源码这类压缩包多数人的第一反应是把网上现成的 LCD1602 例程和矩阵键盘教程拼在一起。拼完编译零错误烧进开发板却发现按键一次按下被识别成好几次、输入数字时屏幕刷新跟不上、按完等于号蜂鸣器响个没完。这个项目的含金量不在计算本身而在把 4×4 矩阵键盘扫描、LCD1602 显示刷新、蜂鸣器短促反馈三套外设拧进同一条主循环时间轴中间还要夹一个自己定义状态机的运算逻辑。适合正在做课设、或者想弄明白为什么外设例程凑不成产品的嵌入式初学者。文中的驱动代码和状态机可以直接抄进 Keil C51 工程也标出了参数边界和常见翻车点。2. 51单片机智能计算器的硬件层矩阵键盘、LCD1602 与蜂鸣器驱动电路2.1 矩阵键盘而不是独立按键IO 占用减半扫描方式选逐行标题里的按键键盘在 16 键计算器场景下标准做法是 4×4 矩阵。原因很直接独立按键一个键占用一个 IO16 个键要 16 根线51 单片机总共 32 个 IO还要留给 LCD1602、蜂鸣器、晶振和复位电路根本排不开。矩阵键盘用 4 根行线加 4 根列线共 8 个引脚IO 占用直接减半代价是把读引脚变成扫描。矩阵的原理是每个按键放在某一行与某一列的交点上。扫描时对行线依次输出低电平再读回列线电平哪一列被拉低就说明交叉点上的键被按下。网上矩阵键盘教程里常见两种扫描行列反转法和逐行扫描法。我一般用逐行扫描因为逻辑直观、定位准确而且可以在同一段代码里顺带完成消抖和释放判断。两种方案的对比如下方案IO 占用扫描方式适用场景独立按键16 根直接读电平键数不超过 8 的简单控制4×4 矩阵8 根逐行扫描 / 行列反转计算器、拨号键盘Proteus 仿真 51 单片机时虚拟按键是理想开关按键抖动看不出来真实按键按下瞬间有 5~20ms 的机械抖动不处理的话一次按下会被当成多次输入。所以消抖不是可选项是矩阵键盘扫描代码的必备部分。2.2 LCD1602 用 4 位模式接线省下的 IO 留给蜂鸣器HD44780 兼容的 LCD1602 有 RS、RW、E 和 D0~D7 共 11 个控制与数据脚。8 位模式下数据线直接占 8 个 IO对计算器这种 IO 紧张的局面不划算。常见做法是用 4 位模式数据只接 D4~D7加上 RS 和 E共 6 根线RW 直接接地因为计算器只写不读。代价是每写一个字节要分高低两次发送速度慢一半但对显示刷新来说完全够用。一套很常见的接线表如下下文所有代码也按这张表来写外设引脚接 51 的 IOLCD1602RSP2.4LCD1602RWGNDLCD1602EP2.6LCD1602D4~D7P2.0~P2.3矩阵键盘行 R1~R4P1.0~P1.3矩阵键盘列 C1~C4P1.4~P1.7蜂鸣器信号输入P3.5这张表里刻意没有出现 P0。P0 是开漏结构内部没有上拉电阻接 LCD 数据线时必须在外部加 10k 排阻才能可靠输出高电平这是 51 单片机硬件设计里最容易翻车的一处。把 LCD 数据放 P2、矩阵键盘放 P1 之后整板不需要任何上拉电阻原理图干净仿真和实物行为也一致。引脚映射直接在代码里写成宏和位定义保证原理图与程序一一对应#define KEY_IO P1 // 矩阵键盘行接 P1.0~P1.3列接 P1.4~P1.7 #define LCD_NIB P2 // D4~D7 接 P2.0~P2.3走低 4 位 sbit LCD_RS P2^4; // 寄存器选择0 命令1 数据 sbit LCD_E P2^6; // 使能信号下降沿锁存 sbit BEEPER P3^5; // 蜂鸣器控制脚这样写的好处是换板子时只改这几行驱动函数不用动。很多初学者从某个开发板例程复制代码板子的接线和源码对不上第一反应是改程序其实先核对这张引脚映射表往往更快。2.3 蜂鸣器选型与驱动电路有源无源决定代码写法蜂鸣器分两大家族有源蜂鸣器内部自带振荡电路通直流电就响无源蜂鸣器没有振荡电路需要外部方波驱动。计算器的按键反馈两个都能用但控制代码差异很大。两者的对比如下类型内部振荡驱动方式频率控制典型成本有源蜂鸣器有高电平或低电平持续不能变调略高无源蜂鸣器无方波翻转改变翻转频率即可变调略低常见的蜂鸣器驱动电路图有两种NPN 三极管高电平触发以及 PNP 三极管低电平触发。以 NPN比如 S8050 或 2N2222为例P3.5 经 1kΩ 电阻接基极发射极接地蜂鸣器正极接 VCC、负极接集电极。P3.5 输出高电平时三极管饱和导通蜂鸣器响。基极电阻不要用到 10kΩ实测会推迟三极管进入饱和、声音发虚1kΩ 左右更保险。记住一条原则51 单片机 IO 的驱动能力只有十几毫安蜂鸣器不能直接怼在引脚上必须走三极管。51 单片机开发板上常见的有源蜂鸣器接法多数是 PNP 低电平触发拿到实物先看丝印原理图再决定代码里的电平极性。3. 软件源码三块地基51单片机键盘扫描、LCD1602驱动代码、蜂鸣器非阻塞响键很多人的 51 单片机是照江科大那套笔记入门的单外设例程都能跑合到同一个工程就乱。原因不是外设驱动写错而是没有一个统一的时间基准。键盘扫描要消抖LCD1602 要等执行时间蜂鸣器要控制响多久三件事挤在同一个 while(1) 里互相抢占 CPU。这一章把三块驱动分开讲清楚最后一节给出非阻塞的整合方式。3.1 矩阵键盘扫描源码先粗测再精测配合 10ms 消抖#include reg51.h #define KEY_IO P1 void DelayMs(unsigned int ms); // 12MHz 晶振下约 1ms unsigned char Key_Scan(void) { unsigned char row, col, key 0xFF; unsigned char val; KEY_IO 0x0F; // 行线输出低列线作为输入 val KEY_IO 0xF0; if (val ! 0xF0) // 有任意列被拉低说明有键按下 { DelayMs(10); // 跳过机械抖动窗口 val KEY_IO 0xF0; if (val 0xF0) { return 0xFF; // 刚才只是抖动未形成稳定按下 } for (row 0; row 4; row) { KEY_IO ~(0x01 row); // 只把第 row 行拉低 val KEY_IO 0xF0; // 重新读回列状态 for (col 0; col 4; col) { if ((val (0x10 col)) 0) { key row * 4 col; // 行优先编码为 0~15 while ((KEY_IO 0xF0) ! 0xF0); // 等待按键释放 } } } } return key; // 无键按下返回 0xFF }这段扫描分成明显的两段先用全部行线拉低做一次粗测确认有没有键按下再逐行拉低做精测定位具体是哪一个键。粗测的意义是省时间——绝大多数时间用户没有按键一次读回就能判断不必每次跑四轮循环。10ms 消抖放在粗测之后只有前后两次读回都有键按下才认定有效。最后的while等待释放是为了防连发按住不松只触发一次。对计算器这种输入场景是合理的但代价是按住期间主循环被阻塞如果想做长按退格需要把这段改成记录按下时刻而不是原地等待。参数上给一个参考表参数取值说明消抖延时10~20ms机械抖动窗口一般为 5~20ms行切换间隔1µs 左右行线拉低后电平很快建立无需长等待释放等待直到列全为高防止一次按下被识别成多次无键返回值0xFF与有效键值 0~15 区分开3.2 LCD1602 驱动代码4 位模式下初始化时序是重点void DelayUs(unsigned int us) // 粗略微秒延时 { while (us--) { _nop_(); } } void LCD_WriteNibble(unsigned char nib) { P2 0xF0; // 清掉 D4~D7 对应的 P2.0~P2.3 P2 | (nib 0x0F); // 低 4 位直接对应 D4~D7 LCD_E 1; DelayUs(5); LCD_E 0; // E 下降沿液晶锁存数据 } void LCD_WriteCmd(unsigned char cmd) { LCD_RS 0; // 命令模式 LCD_WriteNibble(cmd 4); // 先发高 4 位 LCD_WriteNibble(cmd 0x0F); // 再发低 4 位 DelayMs(2); // 覆盖最长指令 1.64ms 执行时间 } void LCD_WriteData(unsigned char dat) { LCD_RS 1; // 数据模式 LCD_WriteNibble(dat 4); LCD_WriteNibble(dat 0x0F); DelayUs(50); } void LCD_Init(void) { DelayMs(15); // 等待液晶内部上电复位完成 LCD_RS 0; LCD_WriteNibble(0x03); // 连续三次发 0x30 的高 4 位 DelayMs(5); LCD_WriteNibble(0x03); DelayUs(120); LCD_WriteNibble(0x03); LCD_WriteNibble(0x02); // 发 0x20正式切换到 4 位模式 LCD_WriteCmd(0x28); // 4 位接口、2 行、5x7 点阵 LCD_WriteCmd(0x0C); // 开显示、关光标、不闪烁 LCD_WriteCmd(0x06); // 写完一个字符后地址自动加 1 LCD_WriteCmd(0x01); // 清屏 DelayMs(2); }初始化里那三次 0x30 是整段代码的灵魂。HD44780 控制器上电时默认工作在 8 位模式即使你的电路只接了 D4~D7它也在等 8 位数据。连续送三次 0x30 的高半字节是为了让控制器和发送方的位序对齐第四次送 0x02 高半字节才真正切换进 4 位模式。此后所有命令和数据的低 4 位必须分两次发送顺序是高 4 位在前、低 4 位在后颠倒就会显示乱码。RW 接地省掉了读忙标志代价是所有命令都靠固定延时兜底LCD_WriteCmd里的 2ms 延时要大于清屏指令的 1.64ms 执行时间这个值不要随手改成 1ms。3.3 蜂鸣器按键反馈用定时器 tick 代替 Delay 阻塞新手最容易写成BEEPER 1; DelayMs(80); BEEPER 0;。这段代码本身没错但 80ms 里主循环被冻住键盘扫描和 LCD 刷新全部暂停。Proteus 仿真里因为虚拟外设响应快看不出问题真机上表现为按键偶尔失灵、显示卡顿。常见做法是让定时器提供一个 1ms 节拍蜂鸣器只维护一个倒计数sbit BEEPER P3^5; unsigned int beep_count 0; void Buzzer_Start(unsigned int ms) { beep_count ms; BEEPER 1; // NPN 驱动高电平触发 } void Buzzer_Tick(void) // 每 1ms 调用一次 { if (beep_count 0) { beep_count--; if (beep_count 0) { BEEPER 0; // 时间到关闭蜂鸣器 } } } void Timer0_Init(void) { TMOD 0xF0; TMOD | 0x01; // 定时器 0模式 116 位 TH0 0xFC; // 12MHz 下重装值约 1ms 中断一次 TL0 0x18; ET0 1; EA 1; TR0 1; } void Timer0_ISR() interrupt 1 { TH0 0xFC; TL0 0x18; Buzzer_Tick(); }TH0 0xFC、TL0 0x18 对应 65536 - 1000在 12MHz 晶振下每个机器周期 1µs正好 1ms 中断一次。Buzzer_Start(80)表示响 80ms主循环照常扫键盘、刷显示。注意这段代码对应 NPN 高电平触发如果开发板上是无源蜂鸣器不能只给高电平要在Buzzer_Tick里改为每次翻转电平输出方波有源蜂鸣器用高电平、无源蜂鸣器用方波这一点最容易搞混。这个定时器节拍 倒计数的非阻塞模式同样可以用在按键长按判断和无源蜂鸣器非阻塞播放音乐上。4. 计算器运算核心代码float 显示转换、输入状态机与 LCD 刷新4.1 数据用 float 还是定点先定精度再写转换函数Keil C51 的浮点运算是软件模拟的速度不快但计算器这种低频输入场景完全够用。真正的坑在显示C51 的 printf 默认不编译 %f项目选项里没开放浮点库的话printf(%f, x)输出是空的。所以源码包里常见做法是手写一个浮点转字符串函数保留两位小数void Float_ToStr(float val, char *buf) { long tmp, int_part, frac_part; char t[12]; int i, n 0; if (val 0) // 负号单独处理 { buf[n] -; val -val; } tmp (long)(val * 100 0.5); // 放大 100 倍再四舍五入 int_part tmp / 100; frac_part tmp % 100; i 0; if (int_part 0) { t[i] 0; // 整数部分至少保留一位 } while (int_part 0) { t[i] 0 int_part % 10; int_part / 10; } while (i 0) { buf[n] t[--i]; // 反转输出 } buf[n] .; buf[n] 0 frac_part / 10; buf[n] 0 frac_part % 10; buf[n] \0; }乘以 100 加 0.5 再取整是把浮点数的两位小数并入整数部分并做四舍五入最后用整数除法拆出整数部分和小数部分。0.5 显示为 0.50123.456 显示为 123.46。这个方法有个边界要记住long在 C51 里是 32 位有符号val * 100超过约 21474836 就会溢出也就是输入或中间结果超过 21 万时显示会错乱这点要在运算前做范围检查或者把结果直接标成溢出。如果项目要求精确十进制而不是 float 的近似值可以改成 long 定点数把每一笔输入都按数值 × 100存储加减乘都好做除法再单独处理余数。4.2 运算状态机支持连算与结果复用等于号一个状态搞定计算器的输入不是单纯的两个数加一个运算符用户会连续按12 5 × 8 还会在出结果后接着按 2 继续算。用状态机描述输入过程最清晰状态含义数字键运算符键等于号S_FIRST输入第一个操作数追加到 v1记录 opch转 S_OP忽略S_OP已输入运算符开始输入 v2替换 opch按 v20 计算S_SECOND输入第二个操作数追加到 v2先算中间结果输出最终结果S_RESULT已显示结果开始新算式结果作为 v1 继续忽略对应代码typedef enum { S_FIRST, S_OP, S_SECOND, S_RESULT } CalcState; CalcState st S_FIRST; float v1 0, v2 0, result 0; char opch 0; bit has_dot 0; float dot_scale 1.0; bit err_flag 0; void Calc_OnDigit(unsigned char d) { if (st S_RESULT) // 出结果后再按数字开新算式 { v1 0; st S_FIRST; has_dot 0; dot_scale 1.0; } if (st S_FIRST) { if (has_dot) { dot_scale * 0.1; v1 d * dot_scale; } else v1 v1 * 10 d; } else { if (has_dot) { dot_scale * 0.1; v2 d * dot_scale; } else v2 v2 * 10 d; } } float Calc_Do(float a, float b, char op) { if (op / b 0) // 除零单独标记 { err_flag 1; return 0; } switch (op) { case : return a b; case -: return a - b; case *: return a * b; case /: return a / b; } return a; } void Calc_OnOp(char op) { if (st S_RESULT) // 结果继续参与运算 { v1 result; } else if (st S_SECOND) // 遇到第二个运算符先结算中间结果 { v1 Calc_Do(v1, v2, opch); } opch op; v2 0; has_dot 0; dot_scale 1.0; st S_OP; } void Calc_OnEqual(void) { if (st S_SECOND || st S_OP) { result Calc_Do(v1, v2, opch); if (err_flag) return; v1 result; v2 0; has_dot 0; st S_RESULT; } } void Calc_Clear(void) { st S_FIRST; v1 0; v2 0; result 0; opch 0; has_dot 0; dot_scale 1.0; err_flag 0; }Calc_OnOp里对 S_SECOND 状态的处理是智能的关键用户输入12 5 ×时先算出 12517再把 17 作为第一个操作数继续等待×后面的数。这是大多数廉价计算器的行为即按键顺序就是运算顺序不区分乘除优先级。要支持先乘除后加减得把表达式解析成逆波兰式或者建表达式树超出这个项目的标题范围。除零时置err_flag主循环里检测到该标志就显示错误提示。4.3 LCD 显示刷新行缓冲加差异刷新避免整体清屏闪烁最常见的错误写法是每按一个键就LCD_WriteCmd(0x01)清一次屏再重新写全部内容。清屏指令本身要 1.64ms加上重写 32 个字符按键稍快屏幕就闪。常见做法是在内存里维护两行缓冲区内容变了才整体重写一次char line1[17], line2[17]; char last_line1[17], last_line2[17]; void Screen_Refresh(void) { char tmp[16]; unsigned char len; memset(line1, , 16); line1[16] 0; memset(line2, , 16); line2[16] 0; if (err_flag) { strcpy(line1, Error!); err_flag 0; } else if (st S_OP || st S_SECOND) { Float_ToStr(v1, line1); len strlen(line1); if (len 15) return; // 防溢出普通输入不会到这 line1[len] opch; Float_ToStr(v2, line1 len); line2[0] ; Float_ToStr(v2, line2 1); } else { Float_ToStr(v1, line1); line2[0] ; Float_ToStr(v1, line2 1); } if (memcmp(line1, last_line1, 16) ! 0) { LCD_WriteCmd(0x80); // 光标定位到第一行首位 LCD_WriteStringRaw(line1); memcpy(last_line1, line1, 16); } if (memcmp(line2, last_line2, 16) ! 0) { LCD_WriteCmd(0xC0); // 第二行首位 LCD_WriteStringRaw(line2); memcpy(last_line2, line2, 16); } }LCD_WriteStringRaw按顺序写完 16 个字符末尾的空格会把上一帧的残留字符盖掉所以缓冲区初始化为空格是有意义的。对比last_line1只刷新变化的那一行是消除闪烁的关键第一行显示输入表达式第二行显示等号和当前值用户在按键过程中视觉焦点集中在第二行。这套双缓冲 差异刷新的思路同样适用于 51 单片机电子时钟这类需要每秒重绘的界面。5. Proteus 仿真验证、三处翻车点与 LCD1602 自定义 ÷ 字符5.1 最小复现步骤Keil 生成 HEXProteus 里接线跑通拿到源码后先在 Proteus 仿真 51 单片机跑通再上实物顺序不能反。最小步骤是这样Keil 里新建工程器件选 AT89C51 或 STC89C52把上一章的键盘、LCD、蜂鸣器、主循环代码合到一个 main.cOptions for Target 的 Output 页勾选 Create HEX File晶振频率按 12MHz 设置编译到 0 错误 0 警告。Proteus 里放置 AT89C51、LM016LLCD1602 仿真模型、KEYPAD-SMALLCALC 或 KEYPAD-CALC矩阵键盘组件、BUZZER有源蜂鸣器以及一只 2N2222 三极管和 1kΩ 电阻。按第 2 章接线表连好其中 LM016L 的 D0~D3 悬空即可4 位模式下用不到。双击单片机载入 HEX 文件点运行按任意键应当听到短促响声LCD 第一行出现输入的数字。Proteus 里有一个仿真特有的坑虚拟键盘没有机械抖动10ms 消抖代码在仿真里显示不出作用但释放等待的while循环仍然有效。所以仿真通过不代表实物一定没问题反过来实物按键失灵时先怀疑消抖参数而不是怀疑扫描逻辑。5.2 最容易翻车的三处P0 上拉、清屏延时、蜂鸣器有源无源第一处是 P0 上拉。网上下载的原理图很多把 LCD 数据线画在 P0 上P0 是开漏结构没有外部 10k 排阻时输出高电平很弱现象是背光亮、字符乱码或者干脆不显示。如果电路用了 P0必须加排阻到 VCC如果代码里用的是 P2就不要照抄 P0 的图纸。第二处是清屏延时。LCD1602 的清屏指令执行时间最长约 1.64ms很多人抄的例程里写DelayMs(1)Proteus 里看不出问题真实液晶上偶发花屏。把命令后的延时放到 2ms 以上或者干脆把 RW 接回单片机做读忙标志检查读忙是最可靠的做法但代码量会略增。第三处是蜂鸣器类型搞混。有源蜂鸣器给持续高电平就响无源蜂鸣器给持续高电平只会咔一声。拿到板子先看原理图确认是哪种再决定Buzzer_Tick里是维持电平还是翻转方波。实物上快速判断方法通电后用万用表或者直接用手摸有源的自带振荡持续发声无源的只有触点咔哒声。5.3 用 CGRAM 给 LCD1602 加一个 ÷ 号HD44780 的字库里有数字、字母和常见的 - * /但偏偏没有 ÷ 号。LCD1602 内部有一段 CGRAM 用户自定义字符区可以造 8 个 5×8 的自定义字符。把 ÷ 写进 CGRAM显示时就能直接输出真正的除号code unsigned char font_div[8] { 0x00, 0x00, 0x11, 0x1F, 0x00, 0x00, 0x11, 0x00 }; void LCD_LoadCG(unsigned char index) { unsigned char i; LCD_WriteCmd(0x40 | (index * 8)); // 定位到 CGRAM 第 index 个字模 for (i 0; i 8; i) { LCD_WriteData(font_div[i]); } LCD_WriteCmd(0x80); // 回到 DDRAM恢复显示 }0x11是上下两个点0x1F是中间横线合起来就是 ÷。烧录时用LCD_LoadCG(0)之后在要显示除号的位置调用LCD_WriteData(0)表达式就能显示成8÷24而不是8/24。CGRAM 一共 64 字节一个字符占 8 字节还可以继续造出分数横线、根号、度符号把计算器界面做得更像真机。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →