51单片机计算器Keil工程拆解:矩阵键盘、LCD1602与状态机设计 📅 发布时间:2026/9/16 2:01:05 👁 浏览次数: 简介基于51单片机的计算器项目完整Keil工程面向单片机初学者和嵌入式系统设计者演示如何通过矩阵键盘输入、LCD液晶显示实现加减乘除等基础运算。压缩包共19个文件大小仅69KB包含成品.c主程序源码、STARTUP.A51启动文件、成品.hex可烧录文件以及uvproj、uvopt、lnp等Keil工程配置文件分别保存工程选项、链接参数等配置lst与m51文件便于查错分析bak为工程备份。项目源码按键盘扫描、显示驱动、计算引擎、内存管理等模块组织覆盖I/O口操作、矩阵键盘扫描去抖、LCD接口时序与字符编码等关键知识点整体代码量适中结构清晰对照hex烧录文件可完成从源码到硬件运行的全流程验证。已有1406人学习下载对课程设计、入门自学或毕业设计均有直接参考价值。1. 从零拆解一份51计算器Keil工程而不是只烧hex拿到这份51计算器的Keil工程压缩包第一眼看到的是一批同名不同后缀的文件成品.c、成品.hex、成品.M51、成品.uvproj。很多人只关心.hex能不能烧进去却忽略了这一堆文件正好串起了一条完整的嵌入式开发链路。这个项目用4x4矩阵键盘接收输入用LCD1602显示结果在STC89C52这类51单片机上是课程设计里出现频率最高的组合之一。但比起“做个计算器”这个结果更值得拆的是背后的四块内容STARTUP.A51启动代码如何接管硬件、矩阵键盘扫描怎么做行列反转与消抖、LCD1602的4位驱动时序怎么稳定、计算状态机怎么处理连续运算和除零。把这四块过一遍比单纯跑通一个demo有用得多这也是这份keil程序真正的学习价值所在。2. Keil工程文件构成与STARTUP.A51启动流程解析刚接触51单片机的人打开Keil工程往往只盯住主程序.c文件。实际上一个完整的C51工程从双击.uvproj到芯片跑起来中间隔着好几层东西。这个计算器项目的文件列表非常典型几乎每个后缀都有它存在的理由。2.1 工程文件、备份文件与中间文件各管什么Keil μVision的核心工程文件是.uvproj它保存了源文件列表、编译选项、芯片型号和链接脚本。.uvopt是选项文件记录窗口布局、断点、仿真器配置这些开发环境状态不影响最终生成的代码。这两种文件在工程里是必需的对而.uvgui.xxx、.opt.bak、.Uv2.bak这类带设备和时间戳的属于历史备份删掉也不影响编译烧录。需要重点区分的是编译生成的中间文件它们虽然不是最终产物但调试时价值很大。文件后缀全称/类型作用什么时候看它.LST列表文件C源码与汇编指令的逐行对照检查某段C代码到底被编译成了几条指令、变量被分配到哪里.M51内存映射文件所有段(CODE/DATA/XDATA)的起始地址、长度、占用情况变量溢出、堆栈冲突时先看它.OBJ目标文件编译器输出的中间机器码一般不用管.lnp链接参数文件记录被链接的OBJ和库文件出现链接错误时查看.plg编译日志保存最近一次编译的输出信息编译告警丢失时回看这里最容易忽略的是.M51。当程序出现莫名其妙的变量串位、堆栈区被数据覆盖时打开这个文件能看到类似下面这样的段布局直接定位问题。LINK MAP OF MODULE: 成品 TYPE BASE LENGTH NAME CODE 0000H 0080H STARTUP DATA 0000H 0010H ?STACK DATA 0010H 0020H ?C_LIB_DATA BIT 0030H 0000H ?C_LIB_BITBASE是段的起始地址LENGTH是长度。如果变量地址把?STACK段挤到了内部RAM顶部附近就意味着加大深度、调整变量类型而不是盲目优化代码逻辑。2.2 STARTUP.A51C程序的真正起点这个工程里的STARTUP.A51是C51编译器自带的启动代码模板Keil在创建工程时会自动把它加进去。传统观念认为main()是程序入口但在8051上main()执行之前硬件状态是这样的堆栈指针SP不确定、内部RAM里是随机值、全局变量未赋值。STARTUP.A51干的就是这些脏活。以下是Keil C51标准启动代码的核心片段和这个工程里的STARTUP.A51逻辑一致。?STACK SEGMENT IDATA /* 申请一个IDATA段 */ RSEG ?STACK DS 10H /* 堆栈深度16字节可修改 */ CSEG AT 0 /* 复位向量地址0x0000 */ LJMP STARTUP1 /* 上电后第一条指令跳到这里 */ STARTUP1: CLR A /* 累加器清零 */ MOV R0, #IDATALEN /* 指向IDATA末地址 */ IDATALOOP: MOV R0, A /* 逐个字节清零 */ DJNZ R0, IDATALOOP ... LJMP ?C_START /* 跳转到C语言运行时入口 */?STACK段定义在IDATA空间DS 10H表示给堆栈预留了16字节。IDATALOOP这段循环把内部RAM全部清零确保全局变量初始值为0。最后一条LJMP ?C_START跳进C51运行库由库里完成全局变量赋初值、调用main()。调试时一个非常实用的原则如果程序跑飞后单步执行到main()发现变量值不对先看STARTUP.A51里的IDATALEN是不是和芯片型号一致。STC89C52内部有256字节RAMIDATA最多256如果是STC89C51只有128字节而IDATALEN还配成80H启动代码会把堆栈区之外的高128字节RAM也清零导致特殊功能寄存器区数据被破坏整机行为立刻诡异起来。2.3 启动代码与硬件电路的关系STARTUP.A51里真正需要手动改的通常只有两处。一是DS 10H这个堆栈深度值。这个计算器程序如果加了中断每个中断入口会占用至少4个字节保存现场嵌套一层再加8字节。16字节堆栈对于纯查询式键盘扫描够用一旦启用串口或定时器中断建议改成20H起步。另一个是看门狗。部分51新品上电默认开看门狗C51的启动代码不会帮你关它必须在main()开头尽早喂狗或关闭。常见做法是在main()第一行写特殊功能寄存器操作如果STARTUP.A51执行时间太长——比如IDATA清零循环在24MHz下要跑几百微秒——看门狗在上电瞬间就可能复位。碰到“烧录后一直重启、仿真却正常”的情况排查方向就在这条时间线上而不是怀疑逻辑代码。3. 矩阵键盘扫描与消抖从I/O读到可靠按键输入侧是这个计算器项目里最容易出问题的部分。16个按键如果全部用独立按键接法需要16个I/O而4x4矩阵只需要8个I/O。对于这个计算器项目P1口刚好8位整组口线拿来做键盘扫描LCD占用P2口分工清楚。3.1 为什么矩阵键盘能省I/O矩阵键盘把16个按键排成4行4列的网格。行线接P1.0-P1.3列线接P1.4-P1.7按键放在行列交叉点上。平时所有行线被上拉电阻拉高当某个按键被按下它所在的行线和列线就被接通。扫描时只需要让其中一组线输出固定电平另一组线读电平变化就能判断出具体按键位置。独立按键要占用16个引脚矩阵方式只要8个。代价是扫描逻辑复杂一点而且在多键同时按下时可能出现“鬼影”——电流从某一行漏到另一行导致误判。课程设计阶段一般默认单键操作不做多键判定但扫描顺序最好是逐行或逐列进行减少漏电流通路出现的概率。3.2 行列反转法两次读I/O定位按键行列反转法是最常用的矩阵扫描方式。原理很直白第一次把列线全部输出低电平读行线没有键按下时行线全为高。有键按下时被接通的那根行线被拉低。第二次把方向反过来行线输出低电平列线输入再读回列线电。两次读到的低电平位置拼在一起就是按键的唯一编码。实际代码实现如下端口定义用P1。#define KEY_PORT P1 /* 4x4键盘布局与编码行线和列线的低电平位组合而成 */ code unsigned char key_code[16] { 0xEE, 0xDE, 0xBE, 0x7E, // 第0行: 1 2 3 0xED, 0xDD, 0xBD, 0x7D, // 第1行: 4 5 6 - 0xEB, 0xDB, 0xBB, 0x7B, // 第2行: 7 8 9 * 0xE7, 0xD7, 0xB7, 0x77 // 第3行: C 0 / }; unsigned char key_scan(void) { unsigned char col, row; unsigned char i; KEY_PORT 0x0F; /* 列线输出低行线上拉输入 */ col KEY_PORT 0x0F; /* 读回哪些行线被拉低 */ if (col 0x0F) /* 全是高说明没有按键 */ return 0xFF; delay_ms(10); /* 第一次检测到按键延时消抖 */ col KEY_PORT 0x0F; /* 消抖后再读一次确认 */ if (col 0x0F) return 0xFF; KEY_PORT 0xF0; /* 方向反转: 行线输出低列线输入 */ row KEY_PORT 0xF0; /* 读回哪些列线被拉低 */ for (i 0; i 16; i) { if ((row | col) key_code[i]) return i; /* 返回按键索引 */ } return 0xFF; /* 编码不匹配认为是无效按键 */ }第一次KEY_PORT 0x0F把P1口高四位写0、低四位写1让列线输出低电平、行线处于输入状态。KEY_PORT 0x0F读的是P1.0-P1.3的电平有键按下时对应行线被拉低读回的值就不是0x0F。第二次方向反转后读高四位两块信息拼起来得到唯一编码。这里要提示一个细节51单片机P1口是准双向IO读引脚之前必须先在对应位写1否则读到的是端口锁存器的值而不是引脚上的真实电平。第一次扫描之前如果别的代码在这之前对P1低四位写过0这里直接读就会出现“永远读不到按键”的假象。所以在函数开头一次性把低四位置1是很多人漏掉的一步。3.3 消抖延时与按键响应边界机械按键按下和释放时触点会来回弹跳多次波形持续时间一般5到20ms。如果不处理一次按键可能会被识别成两次甚至更多次计算器就会输错数字。上面代码用了10ms延时这个值落在抖动典型区间中间是最常见的消抖参数。消抖的另一种方案是检测到第一次低电平后不阻塞等待而是启动定时器10ms后再采样一次。这种做法不占用CPU键盘扫描期间LCD还能正常刷新但在简单课程设计中直接delay更直观也更容易验证。要注意的是10ms延时在12MHz晶振的51上大约消耗一万多个指令周期如果把它放在一个高频调用的循环里LCD刷新会看到明显卡顿。所以我一般把键盘扫描频率控制在每几十毫秒才调用一次而不是在while循环里连续扫描。下面这个调用方式在这个场景下更常见。while (1) { key key_scan(); /* 每次循环最多扫描一次 */ if (key ! 0xFF) { calc_on_key(key); /* 交给计算状态机处理 */ while (key_scan() ! 0xFF); /* 等待按键释放防止连加 */ } // 其他定时任务可以放在这里 }等待按键释放的这行while(key_scan() ! 0xFF)很关键。如果去掉一次按下的抖动被消抖拦截后按下期间第二次调用key_scan会再次读到同一个键结果就是按一下“1”会输入“11”。这是初版计算器程序最典型的“手速bug”。4. LCD1602四线驱动从初始化时序到字符显示LCD1602在51单片机项目里几乎是标配输出设备但这个项目的驱动方式值得单独展开因为Keil工程文件里出现的LCD驱动代码通常面向4位数据总线而不是8位。选4位不是省事而是省I/O。4.1 4位模式还是8位模式先算I/O账LCD1602的数据接口是8根数据线DB0-DB7加RS寄存器选择、RW读写、EN使能三根控制线。8位模式下一条指令或一个字符分一次8位并行传输。4位模式下8位数据被拆成高4位和低4位两次发送数据线只用DB4-DB7四根。这个项目的LCD如果接在P2口8位模式需要P2整口8根线加P3口两到三根控制线4位模式只需要P2.4-P2.7四根线加控制线省出的口线可以留给其他功能。对于计算器这种字符刷新频率低的应用4位模式一次传输多花两个时序周期几乎没有性能损失省I/O的价值远大于那点时间开销。4.2 初始化序列每条指令为什么是这个值LCD1602内部主控是HD44780上电后默认工作在8位模式。如果硬件直接接成4位必须先通过一个特定的握手序列把它切换到4位模式否则后续指令全部错位。这个序列在各类课程设计里都长得一样但很少有人讲清每步的含义。#define LCD_DATA P2 /* DB4-DB7接P2.4-P2.7 */ #define LCD_EN P3_4 /* 使能信号 */ #define LCD_RS P3_5 /* 寄存器选择: 0命令/1数据 */ #define LCD_RW P3_6 /* 只写模式固定接地更省I/O */ void lcd_write_nibble(unsigned char nibble) { LCD_DATA (LCD_DATA 0x0F) | (nibble 4); /* 写到高四位 */ LCD_EN 1; delay_us(1); /* EN高电平保持至少450ns */ LCD_EN 0; delay_us(1); } void lcd_write_cmd(unsigned char cmd) { LCD_RS 0; /* 命令模式 */ lcd_write_nibble(cmd 4); /* 先发高4位 */ lcd_write_nibble(cmd 0x0F); /* 再发低4位 */ delay_us(40); /* 普通指令执行时间约40us */ } void lcd_init(void) { delay_ms(15); /* 上电等待手册要求15ms */ lcd_write_nibble(0x03); /* 8位模式握手第一步 */ delay_ms(5); lcd_write_nibble(0x03); /* 第二次发送0x03 */ delay_ms(5); lcd_write_nibble(0x03); /* 第三次发送0x03 */ delay_ms(1); lcd_write_nibble(0x02); /* 切换为4位总线模式 */ lcd_write_cmd(0x28); /* 4位、双行、5x7点阵字库 */ lcd_write_cmd(0x0C); /* 显示开、光标关、不闪烁 */ lcd_write_cmd(0x01); /* 清屏 */ delay_ms(2); /* 清屏执行时间1.64ms */ }lcd_write_nibble里的(LCD_DATA 0x0F)不是可有可无。如果直接LCD_DATA nibble 4低四位会被清成0可能把P2.0-P2.3上连接的其他器件误触发。工程里如果接线时把未用的低四位空着这个问题不爆发一旦复用调试起来非常隐蔽。初始化序列的三次0x03是数据手册里规定的“唤醒”流程。每次发送时DB4-DB7上是0011LCD在上电后处于8位模式只认前四位所以这三次是在确认总线时序。第三次之后发0x02高位0010告诉LCD“我接下来要用4位模式了”。之后的0x28、0x0C、0x01才是真正的功能配置。执行时间方面不是所有指令都等40us就够。指令典型执行时间0x01 清屏1.64ms0x02 光标归位1.64ms0x04-0x07 输入模式设置40us0x08-0x0F 显示开关控制40us写显示数据40us最容易踩的坑就是在0x01清屏后只等了40us就立刻写数据结果屏幕上出现乱码。原因就是清屏指令内部要对整个DDRAM逐字符写空格1.64ms的执行时间没等够。4.3 写字符与数字显示数据显示函数和写命令结构相同只是RS置1、数据指向显示缓冲区的当前地址。void lcd_show_char(unsigned char ch) { LCD_RS 1; /* 数据模式 */ lcd_write_nibble(ch 4); lcd_write_nibble(ch 0x0F); delay_us(40); } void lcd_show_num(int num) { char buf[7]; unsigned char i, len; if (num 0) { /* 负数处理: 先显示负号 */ lcd_show_char(-); num -num; } /* 整数转字符串逐位取出 */ len 0; if (num 0) { lcd_show_char(0); return; } while (num 0) { buf[len] 0 (num % 10); num / 10; } for (i len; i 0; i--) /* 倒序输出 */ lcd_show_char(buf[i-1]); }lcd_show_num先把整数拆成单个字符放进临时缓冲再倒序输出避免正序输出时数字反着显示。这个函数在这个计算器项目里被反复调用每次按键输入、每次运算结束都要刷新整个数字区。提示LCD1602的DDRAM地址每写一个字符自动加1所以连续调用lcd_show_char就行不需要每写一个字符都重新设置地址。只有在换行或清屏后才需要发0x80列地址这类指令这也是很多实现里频繁刷屏却花屏的原因之一。5. 计算引擎状态机设计与边界保护这个项目名称叫计算器但程序里真正需要动脑的部分是“如何组织输入、运算、显示三者之间的状态关系”。如果不用状态机很自然的写法是在主循环里对每个按键做if判断于是按下“1”后能不能再按“”、连按“”会发生什么这些问题越写越乱。状态机的好处是让键盘的每个输入都有一张明确的转移表可比对。5.1 五态模型从空闲到连续运算计算器按键行为的核心是“当前正在编辑哪个操作数”。我把程序划分成五个状态空闲等待、输入第一个操作数、等待第二个操作数、输入第二个操作数、显示结果。难点在“等待第二个操作数”这个状态上它意味着20 30这种输入结束后50是否已经被计算出来。typedef enum { ST_IDLE, /* 上电或按C后初始态 */ ST_IN_OP1, /* 正在输入第一个操作数 */ ST_WAIT_OP2, /* 已按运算符等待第二操作数 */ ST_IN_OP2, /* 正在输入第二个操作数 */ ST_RESULT /* 结果已显示 */ } calc_state_t; static int op1, op2; static unsigned char op; static calc_state_t state;状态之间的主要转移关系如下。当前状态输入数字输入运算符输入等号ST_IDLE / ST_RESULT清空op1进入ST_IN_OP1忽略或复用上一次结果无动作ST_IN_OP1累加进op1保存op进入ST_WAIT_OP2无动作ST_WAIT_OP2清空op2进入ST_IN_OP2更换运算符直接显示op1ST_IN_OP2累加进op2先结算当前表达式再保存新运算符执行calc_run并显示结果核心处理函数calc_on_key接收键盘扫描返回的键值按数字、四则运算符、等号、清零四种类别分别处理。void calc_on_key(unsigned char key) { if (key C) { op1 op2 0; state ST_IDLE; lcd_show_num(0); /* 显示0表示复位 */ return; } if (key 0 key 9) { if (state ST_IDLE || state ST_RESULT) { op1 0; /* 新表达式op1重新开始 */ state ST_IN_OP1; } if (state ST_WAIT_OP2) { op2 0; /* 首次输入第二个数时先清零 */ state ST_IN_OP2; } if (state ST_IN_OP1) { op1 op1 * 10 (key - 0); } else if (state ST_IN_OP2) { op2 op2 * 10 (key - 0); } return; } if (key || key - || key * || key /) { if (state ST_IN_OP2) { /* 连续运算: 先完成前一步再存新运算符 */ op1 calc_run(op1, op2, op); } op key; state ST_WAIT_OP2; return; } if (key ) { if (state ST_IN_OP2) { op1 calc_run(op1, op2, op); state ST_RESULT; } lcd_show_num(op1); } }运算符分支里的“先结算”是连续运算的关键。输入1234后再按程序会先把46算出来存入op1再把新的运算符保存这样下一次输入操作数时基于46继续运算。如果这里不结算等号触发时只能算最后一段前一段的数据就丢了。等号分支里即使没有输入第二个操作数比如直接按12也会把op1显示出来这是普通手持计算器的常见行为。5.2 整数溢出与除零用long做中间量而不是逐分支判断8051的int是16位范围-32768到32767。乘法如300*200结果60000直接溢出如果写成int r a * b;得到的可能是负值而不自知。常见做法是把计算提升为long类型结果算完再检查上下界。static int calc_run(int a, int b, unsigned char op) { long r 0; switch (op) { case : r (long)a b; break; case -: r (long)a - b; break; case *: r (long)a * b; /* long乘long避免16位截断 */ break; case /: if (b 0) { /* 除零保护必须在除法前 */ lcd_show_char(E); return 0; } r (long)a / b; /* 整数除法结果截断 */ break; default: r a; break; } if (r 32767L || r -32768L) { lcd_show_char(E); /* 溢出标志 */ return 0; } return (int)r; }这里没有用复杂的符号判断检测溢出而是统一先转long算再比较范围。8051上long是4字节多出的几条指令对按键输入的频率完全可以忽略换来的是逻辑极简。这个取舍在Keil C51里比一堆if (a INT_MAX / b)的预判实际得多。除零在C语言里是未定义行为在8051上结果不可控。所以除零判断放在case分支内必须在执行除法之前返回错误值。返回0后主状态机继续运行用户按C即可复位。需要提醒的是整数除法无法表达小数。7/2在这个51计算器上得到3这是预期行为而不是bug。如果要显示小数需要用定点数算法把结果拆成整数部分和小数部分分别显示这属于功能扩展不是本项目的缺陷。工程复用场景里如果要改这一层注意LCD显示逻辑也要跟着改成“整数小数点小数”三段式不能直接复用lcd_show_num。5.3 显示刷新策略什么时候清屏什么时候只更新数字很多初学者在每次按键后都调用一次全清屏lcd_write_cmd(0x01)结果LCD频繁闪烁。实际上计算器界面只有两个区域需要维护第二行输入的数字以及偶尔出现的错误提示。清屏指令执行时间1.64ms在高速循环里频繁发送会让按键响应变慢。更好的做法是只在按C和发生错误时清屏平时用“覆盖写”的方式更新数字。这个工程里lcd_show_num每次都从当前光标位置开始写如果上一次显示“123”这次要显示“8”上一次的“23”会残留在屏幕上。所以在显示新数字前先发送“空格填充”指令把旧显示内容抹掉再写新值。void lcd_refresh_num(int num) { unsigned char i; lcd_write_cmd(0x80); /* 光标回到第1行第1列 */ for (i 0; i 16; i) lcd_show_char( ); /* 整行清空为空格 */ lcd_write_cmd(0x80); /* 光标再回起点 */ lcd_show_num(num); /* 重新写入数字 */ }对比直接清屏这种“行清除”的方式省去了1.64ms的等待显示连续性也更好。代价是多写了16个空格但每个空格只要40us总耗时不到1ms明显快于清屏指令。6. 用Keil调试器与边界用例验证计算器逻辑代码写完后验证的重点不是“能不能算出358”而是边界情况下程序是否仍然受控。用Keil uVision5的硬件仿真模式先把下面这张表逐条执行一遍每一条都对应一个容易翻车的实现细节。操作序列期望结果考察点5 3 8基本加法12 34 56 102连续运算中间结算7 / 2 3整数除法截断规则9 / 0 错误显示程序不跑飞除零保护9999 * 9 错误显示溢出检测1 / 3 * 3 0整数除法精度边界按C后按显示0状态复位状态机复位完整性在Keil中执行到calc_run函数时把鼠标悬停在r变量上观察其值。如果某一步的long中间量高出32767而最终结果没有报错说明程序里漏掉了范围检查。最常见的错误是只检查了乘法、没检查累加调试时要把和-的结果也纳入同样的范围判断。没有硬件时可以用Proteus仿真51单片机加载这个工程的.hex把LCD和4x4键盘接好直接跑上面这张表。但仿真器和真实硬件最大的差异在时序上——Proteus的LCD模型对时序要求宽真实LCD在快速连续写指令时更容易出现漏字符所以仿真通过后还是要回到真板子验证一遍。最后分享一个键盘扫描定位技巧当怀疑某个按键不灵时临时把key_scan()返回的原始组合码直接显示到LCD上比如显示成0xEE或0xDE这种十六进制值。按下某个键如果显示出来的编码和key_code表完全一致说明IO读到的信号正确如果显示0xFE或0xE6这类“半相似”的编码大概率是相邻行线或列线之间短路。这个方法能快速区分问题在硬件接线还是在扫描逻辑比用万用表逐根量线快得多也顺带验证了整条IO链路从按键到扫描再到LCD显示的通路完整性。本文还有配套的精品资源点击获取