简介:一套基于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.3 | LCD1602 DB4 - DB7 | 四线数据总线,P0是开漏输出,必须外接上拉电阻 |
| P2.5 | LCD1602 RW | 读写选择,0写1读,这里固定写方向 |
| P2.6 | LCD1602 RS | 寄存器选择,0指令1数据 |
| P2.7 | LCD1602 EN | 使能信号,下降沿锁存数据 |
| P1.0 - P1.3 | 矩阵键盘第0 - 3行 | 输入引脚,读回按键状态 |
| P1.4 - P1.7 | 矩阵键盘第0 - 3列 | 输出引脚,逐列拉低扫描 |
| EA / VCC / GND | 31脚接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时结果是0xEF,P1.4拉低;col为1时是0xDF,P1.5拉低;col为3时是0x7F,P1.7拉低。读回的临时值tmp是P1口的低4位,常态全是1,某个按键按下后对应的行位变0,再通过GetRow的四个case换算出具体行号。
返回值的计算用col * 4 + row,把二维坐标压成一维编号,方便后面映射成数字、运算符和小数点。映射表要跟物理键位对应好,我常用下面这种布局,注意小数点占了第13号键位,这是4×4键盘只有16键时必须做的取舍。
| 键值 | 按键 | 键值 | 按键 |
|---|---|---|---|
| 0 | 1 | 8 | 7 |
| 1 | 2 | 9 | 8 |
| 2 | 3 | 10 | 9 |
| 3 | + | 11 | × |
| 4 | 4 | 12 | 0 |
| 5 | 5 | 13 | . (小数点) |
| 6 | 6 | 14 | = |
| 7 | - | 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_RESULT |
| S_RESULT | 清空缓存,重新开始输入 | 把result当作num1继续连算,跳S_INPUT2 | 忽略 |
S_INPUT2里再按运算符只替换而不计算,这是一个容易踩的坑。比如输入12+3,再按×,如果按「先算加法」的思路,会先得到15再进乘法,结果变成15×4,但用户的预期是12+3×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或4800,STC89C52在12MHz晶振下用定时器2产生,具体初值可以用STC-ISP软件生成,或者直接用软件延时模拟发送,调试够了。这个技巧的好处是把键盘从显示链路里剥离出来,如果串口收到的键值正确,问题就锁定在LCD侧;如果键值错乱,再回头查矩阵键盘的行列接线。串口助手比对完键值,再回头去看LCD1602的初始化时序,基本就能一次跑通。
本文还有配套的精品资源,点击获取