51单片机矩阵键盘计算器:LCD1602驱动与状态机设计 📅 发布时间:2026/9/17 1:27:20 👁 浏览次数: 简介一套基于STC89C52单片机、LCD1602液晶屏与4×4矩阵键盘的简易计算器设计资料适合单片机初学者、课程设计及电子竞赛备赛人群。设计围绕加、减、乘、除及小数点输入等常用功能展开涵盖硬件电路组成、矩阵键盘行列扫描、按键消抖、数值显示刷新等核心环节能够帮助读者完整理解单片机的输入输出控制与液晶驱动方法。资源包以7z格式压缩整体大小约37.51MB便于直接下载使用。目前已有2207人学习或下载内容贴近实际项目需求既可作为课程设计参考也可用于快速搭建一台带小数点的多功能计算器原型。1. 8个IO口接16个按键矩阵键盘计算器的第一课一块4×4矩阵键盘只有8个引脚却要顶16个独立按键的活一个看似不起眼的小数点在只有16键的键盘上还要挤掉一个功能键的位置。这套以STC89C52为核心的简易计算器硬件就三块——主控芯片、LCD1602液晶屏和矩阵键盘却把GPIO输入、时序驱动、状态机换算这三类51单片机最常见的实战点全包含了。下面从矩阵键盘的列扫描原理讲起一路拆到带小数点的四则运算状态机最后给出Proteus仿真和实物调试时真正值得注意的坑。适合正在做单片机课程设计、准备做Proteus仿真51单片机项目或者想把51按键和显示功底打扎实的开发者。2. 硬件引脚分配与矩阵键盘列扫描时序2.1 三块硬件的引脚连接与选型理由STC89C52是51内核里很经典的型号内部8KB Flash、512字节RAM兼容AT89S52的引脚定义Keil C51直接选AT89C52或STC89C52设备即可。选它做计算器原因是LCD1602和4×4键盘都是标准外设51的IO口足够覆盖不需要上STM32那种外设资源。硬件连接见下表。STC89C52引脚连接对象说明P0.0 - P0.3LCD1602 DB4 - DB7四线数据总线P0是开漏输出必须外接上拉电阻P2.5LCD1602 RW读写选择0写1读这里固定写方向P2.6LCD1602 RS寄存器选择0指令1数据P2.7LCD1602 EN使能信号下降沿锁存数据P1.0 - P1.3矩阵键盘第0 - 3行输入引脚读回按键状态P1.4 - P1.7矩阵键盘第0 - 3列输出引脚逐列拉低扫描EA / VCC / GND31脚接VCC配12MHz晶振和复位电路EA接高电平执行内部程序存储器这里有一个容易忽略的点P1口内部有上拉复位后默认输出高电平所以拿P1.0 - P1.3做输入时不需要额外配置方向寄存器直接读就行。P0口就不行它是开漏结构内部没有上拉接LCD的数据线时如果不上拉到VCC高电平输出就是高阻态LCD收到的电平不确定显示就会乱码。这个在实物调试里是第一个怀疑对象后面再展开。2.2 列扫描为什么逐列拉低而不是并行读矩阵键盘省IO的原理是行列交叉8个IO分成4行4列任何一个按键按下等效于把某一行和某一列短接。读按键的思路是先把某一列输出低电平其余三列输出高电平然后读回四行的电平状态。如果哪一行读到低电平就说明「这一列的这一行」交叉点上的按键被按下。逐列轮流来一遍16个按键全部可识别。常见的错误做法是四个列引脚同时拉低然后一次读行。这样如果两个不同列的按键同时按下行引脚的低电平无法区分来自哪一列会产生串键。计算器场景里用户大概率是一个指头点一个键串键概率不大但严谨起见还是逐列扫描4次扫描加起来也就几十微秒对按键操作完全无感。扫描流程还有一个细节行列交叉点按下时引脚电平不是瞬间跳变的机械触点有抖动所以要先读一次确认有键按下延时10ms左右再读一次两次一致才判定有效这叫软件消抖。消抖之后等按键释放才返回键值否则一次按下会被误判成多次。2.3 消抖与键值编码的实现下面是完整的矩阵键盘扫描函数封装成独立模块主循环里反复调用。#include reg52.h #define KEY_PORT P1 #define uchar unsigned char uchar GetRow(uchar v) { switch (v) { case 0x0E: return 0; // P1.0 被拉低说明第0行有键 case 0x0D: return 1; // P1.1 被拉低第1行 case 0x0B: return 2; // P1.2 被拉低第2行 case 0x07: return 3; // P1.3 被拉低第3行 default: return 0xFF; } } uchar Key_Scan(void) { uchar col, tmp, row; for (col 0; col 4; col) { // 逐列拉低0xEF, 0xDF, 0xBF, 0x7F KEY_PORT ~(0x10 col); tmp KEY_PORT 0x0F; // 读回低4位行状态 if (tmp ! 0x0F) // 有行变低说明该列有键按下 { delay_ms(10); // 10ms消抖 tmp KEY_PORT 0x0F; // 二次确认 if (tmp ! 0x0F) { while ((KEY_PORT 0x0F) ! 0x0F); // 等待释放 row GetRow(tmp); return col * 4 row; // 返回 0 ~ 15 的键值编码 } } } return 0xFF; // 无按键 }这段代码的核心在循环里那条等式KEY_PORT ~(0x10 col)col为0时结果是0xEFP1.4拉低col为1时是0xDFP1.5拉低col为3时是0x7FP1.7拉低。读回的临时值tmp是P1口的低4位常态全是1某个按键按下后对应的行位变0再通过GetRow的四个case换算出具体行号。返回值的计算用col * 4 row把二维坐标压成一维编号方便后面映射成数字、运算符和小数点。映射表要跟物理键位对应好我常用下面这种布局注意小数点占了第13号键位这是4×4键盘只有16键时必须做的取舍。键值按键键值按键0187129823109311×441205513. (小数点)66147-15÷2.4 轮询与中断的取舍有同学会问键盘扫描放进定时器中断里做是不是更专业计算器这个场景我建议用主循环轮询。理由是按键事件本质是人的动作按下、释放都发生在几十毫秒量级主循环里几微秒调用一次Key_Scan完全不会漏键而中断里做键盘扫描要处理跟主循环的共享变量保护、消抖计时稍不注意还会和LCD写时序产生嵌套反而引入难查的bug。真到了要做LED动态扫描、数码管刷新这类实时性要求高的场景再上定时器中断也不迟。3. LCD1602四线驱动初始化时序与显示布局3.1 四线模式省四个IO代价是时序翻倍LCD1602本身是8位数据总线的HD44780兼容屏但数据引脚可以只接DB4 - DB7通过两次写入拼成一个字节这就是四线模式。51的IO是稀缺资源接了LCD要占8个引脚再算上RS、RW、EN就11个一块52根本剩不下几个引脚给键盘。四线模式把数据线压到4根加上控制线3根共7根才给矩阵键盘留出了P1整个端口。代价是每次写指令或写数据都要操作两次E脉冲时序比八线模式更敏感。八线模式一次发完一个字节错了能马上定位四线模式下先发高4位再发低4位如果两次中间插入了别的事情LCD收到的就是一个错位的残缺字节而且症状表现为「个别功能不对」很难一眼看出是接线还是时序问题。3.2 四线初始化序列与写命令实现四线模式的初始化有固定的握手过程不能上来就直接发0x28因为上电后LCD默认还在8位模式你发4位数据它根本收不对。标准的做法是先发三个0x03对齐8位模式再发0x02切换到4位模式之后才能用四线时序发正常的指令。sbit RS P2^6; // 0 指令1 数据 sbit RW P2^5; // 只写固定为 0 sbit EN P2^7; // 使能下降沿锁存 #define LCD_DATA P0 void LCD_WriteNibble(uchar nib) { LCD_DATA nib; // nib 是高4位对齐的数据 EN 1; delay_us(5); EN 0; // 下降沿LCD锁存数据 delay_us(5); } void LCD_WriteCmd(uchar cmd) { RS 0; RW 0; LCD_WriteNibble(cmd 0xF0); // 先发高4位 LCD_WriteNibble((cmd 4) 0xF0); // 低4位移到高4位位置再发 delay_ms(2); // 指令需要稍长等待 } void LCD_WriteData(uchar dat) { RS 1; RW 0; LCD_WriteNibble(dat 0xF0); LCD_WriteNibble((dat 4) 0xF0); delay_ms(1); } void LCD_Init(void) { delay_ms(15); // 上电等待HD44780要求15ms以上 LCD_WriteNibble(0x30); delay_ms(5); // 8位模式下发 0x30 LCD_WriteNibble(0x30); delay_us(100); LCD_WriteNibble(0x30); delay_ms(2); // 第二次重复 LCD_WriteNibble(0x20); delay_ms(2); // 切到4位模式 LCD_WriteCmd(0x28); // 4位总线2行5x7点阵 LCD_WriteCmd(0x0C); // 显示开光标关不闪烁 LCD_WriteCmd(0x01); // 清屏 LCD_WriteCmd(0x06); // 地址指针自增显示不移位 }这个初始化的顺序不能乱尤其是那组0x30序列。为什么发0x30而不是0x03因为0x30是8位模式下完整的一条指令高4位是3低4位是0而四线模式下一次只能发半个字节所以借助LCD_WriteNibble直接发0x30这样的高4位数据让LCD在8位模式下先完成对齐。0x02的作用是让LCD切到4位模式之后LCD_WriteCmd里的0x28才会被正确解析为「4位总线、2行、5×7点阵」。RW引脚在整个设计里固定为0因为我们只写不读。很多教程会写一个读忙标志位的函数用BF位判断LCD是否空闲但读操作需要把数据总线切换成输入方向时序更复杂。在计算器这种低速场景用固定延时完全够写指令等2ms写数据等1ms比读忙标志更省事。3.3 显示布局与局部刷新LCD1602一行16个字符计算器的显示布局我习惯这样分配第一行显示当前输入的操作数和运算符第二行显示等号结果。这样眼睛不用在一行里找断点。输入区的刷新不要整个清屏重写容易闪。正确做法是维护一个输入缓冲数组每次按键只更新光标位置处的字符。比如输入光标停在第一行某个位置先把光标定位过去再写一个字符写完把光标列号加1。定位光标用下面这段。void LCD_SetPos(uchar row, uchar col) { uchar addr; if (row 0) addr 0x80 col; // 第一行DDRAM地址从0x80开始 else addr 0xC0 col; // 第二行DDRAM地址从0xC0开始 LCD_WriteCmd(addr); }参数里的0x80和0xC0是HD44780的DDRAM首地址偏移写入的其实是地址加读/写标志位的组合指令。第二行地址不是0xA0而是0xC0很多人会记错。局部刷新还有个额外好处清屏指令0x01耗时较长频繁调用会让数字输入有明显停顿改成按位写入后按键的跟手度好很多。4. 带小数点的四则运算状态机与浮点取舍4.1 为什么不上完整表达式解析看到计算器项目容易想到把整个输入存成字符串再做中缀表达式求值、运算符优先级、括号匹配。在PC上这是常规操作但在STC89C52上要慎重。52的片内RAM只有512字节一个完整的表达式解析要分配操作数栈、运算符栈、临时缓冲区栈深一点就撑爆。Keil C51的指针模型和标准C有差异在51上写递归解析器代码体积大、调试困难对只有一个加、减、乘、除需求的课程设计来说属于过度设计。更关键的是4×4键盘只有16个键根本输不了括号和表达式。所以这个项目的正解是输入序列天然简单操作数1、运算符、操作数2、等号。用一个只有四个状态的有限状态机就能覆盖所有合法操作路径。4.2 四状态状态机拆解状态机定义成四个状态S_INPUT1等待输入第一个操作数S_INPUT2等待输入第二个操作数S_RESULT显示结果加上一个隐含的错误态。状态转移不用if套if直接用表格驱动逻辑清楚也好维护。当前状态收到数字/小数点收到运算符收到 S_INPUT1追加到输入缓存保存num1和运算符跳S_INPUT2忽略S_INPUT2追加到输入缓存替换当前运算符不触发计算锁定num2计算跳S_RESULTS_RESULT清空缓存重新开始输入把result当作num1继续连算跳S_INPUT2忽略S_INPUT2里再按运算符只替换而不计算这是一个容易踩的坑。比如输入123再按×如果按「先算加法」的思路会先得到15再进乘法结果变成15×4但用户的预期是123×4也就是还没输入完第二个操作数。所以正确行为是只把运算符从换成×等最后按时统一处理。这个细节区分了会写状态机和只会拼代码的两种实现。4.3 按键事件驱动代码下面把状态机落到C代码。输入缓冲用字符串数组数字键追加字符运算符键保存状态等号键触发计算。enum { S_INPUT1, S_INPUT2, S_RESULT } state; uchar input_buf[9]; // 8位有效数字 结束符 float num1, num2, result; uchar cur_op; // 0: 1:- 2:* 3:/ bit has_point; // 小数点防重复标志 void Feed_Key(uchar key) { if (key 10) // 键值 0-9 为数字10 为小数点 { if (key 10) { if (!has_point) // 一个数里小数点只能出现一次 { Append_Char(.); has_point 1; } } else { Append_Char(0 key); } } else if (key 11 key 14) // - * / { if (state S_INPUT1) { num1 atof(input_buf); // 锁定第一个操作数 Clear_Buf(); state S_INPUT2; } else if (state S_INPUT2) { // 替换运算符不计算 } else if (state S_RESULT) { num1 result; // 结果作为操作数继续连算 Clear_Buf(); state S_INPUT2; } cur_op key - 11; } else if (key 14) // { if (state S_INPUT2) { num2 atof(input_buf); Calc_Result(); // 执行四则运算 state S_RESULT; } else if (state S_RESULT) { // 连续按忽略避免重复计算 } } }Append_Char和Clear_Buf是对input_buf的简单维护注意input_buf只能存8个字符超长时要拦截否则字符串溢出会把相邻变量冲掉这是C51调试里最隐蔽的内存问题。atof是标准库函数展开比较占空间可以用手动解析的方式替代原理就是循环读每一位累乘10再累加遇到小数点记录位数后除以对应的10的幂。手动解析还能顺便做溢出判断代码可控性更高。运算符键在S_RESULT状态下的处理是连续运算的关键按完等号出结果再按一个运算符结果直接变成新的操作数1接着输第二个数状态切到S_INPUT2这就是计算器上「连算」功能的实现。4.4 float与定点的取舍运算部分用float还是定点是51项目里绕不开的取舍。float在C51里是32位加减乘除都有对应的浮点库函数代码直观sprintf格式化也方便缺点是库函数占Flash偏多运行速度比整型慢一个量级。但对计算器这个场景一次运算在12MHz晶振下也就几十到几百微秒远小于人的反应时间速度完全不是瓶颈。52的8KB Flash放完键盘扫描、LCD驱动后留给浮点库大约还剩1KB多空间紧张但没有溢出风险。真正要注意的是浮点比较和显示格式化。判断除零不能用num2 0这种精确等值浮点运算里0.000001和0的差距用等值判断会漏掉要写成绝对值小于某个阈值的区间判断。显示用sprintf的%g格式会自动去掉多余的0但C51的sprintf实现相对笨重如果Flash吃紧就换成定点方案把输入值乘100存成整数显示时手动在倒数第二位前面插入小数点缺点是小数的第三位以后会被丢弃且除法的商要自己处理舍入。4.5 除零、溢出与错误恢复除零必须在计算前拦截直接清屏显示Error然后让状态机回到S_INPUT1等待重新输入否则LCD上会留下一个非法浮点值。void Calc_Result(void) { if (cur_op 3 num2 -0.000001 num2 0.000001) { LCD_WriteCmd(0x01); // 清屏 LCD_WriteString(Error: /0); state S_INPUT1; // 状态复位 Clear_Buf(); return; } switch (cur_op) { case 0: result num1 num2; break; case 1: result num1 - num2; break; case 2: result num1 * num2; break; case 3: result num1 / num2; break; } if (result 99999999.0f || result -99999999.0f) { LCD_WriteCmd(0x01); LCD_WriteString(Overflow!); state S_INPUT1; return; } Clear_Buf(); sprintf(display_buf, %g, result); // %g自动去末尾0 LCD_WriteString(display_buf); }溢出判断的上限99999999不是随便拍的LCD一行16个字符显示一个8位整数加符号位加小数点就差不多占满了。超过这个范围后继续计算没有意义直接提示Overflow比输出一个科学计数法字符串更符合计算器的使用直觉。错误恢复的关键是两条赋值语句没漏状态切回S_INPUT1输入缓存清空缺一条都会导致按数字键时残留上一次的错误状态。5. Proteus与实物调试五个高频坑和一个串口验证技巧5.1 P0口上拉是第一个怀疑对象实物调试时LCD显示乱码或者全不亮先把P0口的上拉电阻补上。STC89C52的P0口内部没有上拉四个数据线接LCD后输出高电平时引脚处于高阻态LCD读到的电压受自身内部下拉影响逻辑不稳定。常规做法是用一个10kΩ排阻把P0.0 - P0.3统一接到VCC或者四个独立10kΩ电阻。Proteus仿真里不加上拉经常也能跑因为仿真器对高阻态的处理偏理想化但实物一定不行这也是「仿真能跑、实物拉胯」的高频原因。5.2 黑块先调对比度花屏查初始化时序LCD1602上电后屏幕出现一排黑方块第一时间不是查代码而是调对比度。Vo脚需要一个约1kΩ到10kΩ的电位器分压到地电压偏高偏低都会让显示变成黑块。Proteus里这个电位器可以省略实物缺了就看不到任何字符。花屏或错位字符则是另一回事优先检查初始化时序。常见错误是跳过了0x30对齐序列直接发0x28或者两个nibble之间插入了其他写操作导致LCD总线错位。用逻辑分析仪抓EN脚的电平变化对比正常波形能看到每个字节是否完整发了两次。5.3 优化等级会吃掉空循环延时Keil C51默认优化等级较高空循环延时函数容易被优化掉症状是按键消抖失效、LCD初始化时有时无。调试阶段把优化等级降到0确认功能正常后再逐级提升。另外延时函数里的循环变量要声明成volatile否则在优化等级8下会被整个裁剪。这个坑在仿真里不常见因为仿真的指令执行是定时的实物会非常明显。5.4 EA引脚和复位电路在实物上不能省Proteus仿真里晶振、复位电路、EA引脚经常不画也能跑但实物缺任何一个都不行。EA是31脚必须接VCC否则单片机从外部程序存储器取指令而外部总线没有接任何设备程序直接跑飞。12MHz晶振配两个30pF电容是常规做法复位电路用10μF电容加10kΩ电阻Proteus里这些都可以偷懒实物焊接时一个都不能少。5.5 用串口把键值打出来验证调试矩阵键盘接线对不对比看LCD更高效的方法是走串口。在Key_Scan返回有效键值时把键值通过UART发到电脑串口助手跟2.3的键值编码表比对一下就能定位是接线错误还是扫描逻辑错误。void Uart_SendByte(uchar dat) { SBUF dat; while (!TI); // 等待发送完成 TI 0; // 清除发送中断标志 } // 主循环里这样调用 uchar val Key_Scan(); if (val ! 0xFF) { Uart_SendByte(val); // 发到串口助手比对键值表 }串口波特率用2400或4800STC89C52在12MHz晶振下用定时器2产生具体初值可以用STC-ISP软件生成或者直接用软件延时模拟发送调试够了。这个技巧的好处是把键盘从显示链路里剥离出来如果串口收到的键值正确问题就锁定在LCD侧如果键值错乱再回头查矩阵键盘的行列接线。串口助手比对完键值再回头去看LCD1602的初始化时序基本就能一次跑通。本文还有配套的精品资源点击获取