☰
51单片机实战:从矩阵键盘到状态机,做一台简化版计算器
2026/10/5 8:14:22 网站建设 项目流程

把"no.4 简化版计算器"当项目做的时候,我在心里给它排了个位置:这是我嵌入式学习系列里的第4个动手任务。前面三个分别是跑马灯、花样点阵、电子秒表,说白了都是"往IO口写数据、看现象",到了计算器这一步,才真正开始碰"逻辑"——矩阵键盘怎么扫描才不会串键,按键怎么消抖才不误触,运算过程怎么用状态机管起来,除零和溢出怎么处理。这些才是做工程真正要面对的问题。如果你是刚学单片机、想找一个能把"输入→处理→输出"完整串起来的练习项目,这个简化版计算器非常合适,它不炫技,但五脏俱全。

1. 项目整体设计与思路拆解

1.1 这个"No.4"到底练的是什么

先说清楚一件事:这个"简化版",简在哪里?我的定位是——支持0~9数字输入、加减乘除四则运算、等号计算、C清零,运算按"输入顺序从左到右"执行。什么意思?就是1+2×3,它给你算成9,而不是7。很多廉价计算器和入门开发板例程都是这个逻辑,因为它把复杂度控制在一个很舒服的范围:不需要处理运算符优先级,不需要括号,不需要负数输入。项目编号"No.4",前面三个是基础外设练习,这个项目则是把GPIO、定时器、中断、字符设备显示这些零散知识点,第一次整合成一个有完整交互流程的小系统。

这个定位非常有意识。很多新手一上来就想做科学计算器,要处理括号、优先级、浮点数、三角函数,结果卡在表达式解析上,连按键和显示都还没调通,自信心直接崩了。简化版的价值在于:先把"输入链路"和"输出链路"跑通,把"状态管理"这种软件工程思维练熟,后面再往高级版加特性时,你会发现只是在这个骨架上添砖瓦。

1.2 硬件方案选型:不是越高级越好

主控我选了STC89C52,51内核,8位单片机,Flash 8KB,RAM 512字节。你别嫌它老,这个项目的计算量它完全够用,而且资料多、编译烧录简单、坏了不心疼。如果手上正好有STM32F103这类Cortex-M3芯片,也完全可以移植,核心逻辑不用改,只是GPIO初始化和库函数调用差异。

按键这里有个关键选择:你是用独立按键还是矩阵键盘?功能上,简化版计算器至少要16个键(10个数字、4个运算符、等号、清零),如果用独立按键,一片单片机GPIO都不够用,一般开发板也就20来个IO口。所以必须上4×4矩阵键盘——8个IO口搞定16个键,这是经典的"用逻辑换引脚"思路。

显示方案我也纠结过一会儿:数码管还是LCD1602?数码管显示数字很直观、驱动也简单,但你要显示完整的运算表达式就很吃力,四位数码管最多放4位数字,超过就溢出符号乱跳。LCD1602虽然驱动时序比数码管繁琐,但它能显示两行16个字符,第一行放当前输入的数字,第二行放结果,甚至能把1+2×3这样的表达式完整打出来,排查问题的时候体验好太多。我的结论是:既然做的是"交互产品",显示的信息量比显示器件本身复杂更重要。

1.3 显示与键盘:简化但不将就

键盘布局我直接采用最常见的一排排布:第一行1、2、3、+,第二行4、5、6、-,第三行7、8、9、×,第四行C、0、=、÷。这个布局跟手机计算器基本一致,手指记忆成本最低。键值映射用一张4×4的表存起来,扫描到行列号就直接查出字符,简洁高效。

LCD1602的接线也简单:8根数据线并到P0口,RS接P2.0,RW接P2.1,EN接P2.2。为什么数据线用P0?因为P0是真正的双向口,驱动能力弱但接LCD这种逻辑器件没问题,而且这样P1、P2剩下的引脚可以全部留给矩阵键盘。如果你手头LCD模块是I2C转接板的,那更轻松,写个I2C驱动就行,不过我建议第一次做还是用并口版本的LCD1602,把时序弄明白,后面换I2C是降维打击。

2. 核心细节解析与实操要点

2.1 矩阵键盘扫描:一次只认准一个键

矩阵键盘的原理一句话就讲完:把16个按键排成4行4列,行线和列线交叉处放按键。扫描时,单片机逐行拉低,然后读取列线电平;如果某列被拉低,说明这一行和这一列交叉的那个按键被按下了。反过来逐列扫描也行,本质都一样。

但真写代码的时候,有个非常容易翻车的点:IO口的模式。STC89C52的P1口内部有上拉电阻,平时读回来是高电平,按下按键把行和列短接,对应引脚被拉低,这样判断逻辑才是"读到0表示按下"。如果你把某根线配置成推挽输出又去读它,读数永远是固定电平,键就永远扫不出来。我调试时吃过这个亏:矩阵键盘第一行怎么按都没反应,最后发现是行线那个IO没设成准双向口,电平被锁死了。

扫描代码的骨架长这样:

// keypad.h #define KEY_NONE 0xFF uint8_t keymap[4][4] = { {'1','2','3','+'}, {'4','5','6','-'}, {'7','8','9','*'}, {'C','0','=','/'} }; uint8_t keypad_scan(void) { uint8_t row, col; for (row = 0; row < 4; row++) { ROWS = ~(1 << row); // 当前行拉低,其它行保持高 delay_ms(1); // 等电平稳定 col = (COLS & 0x0F) ^ 0x0F; // 取反,得到按下的位 if (col != 0x00) { // col是0001/0010/0100/1000中某一个 while (((COLS & 0x0F) ^ 0x0F) != 0x00); // 等待松开 return keymap[row][col]; } } return KEY_NONE; }

这个扫描有个细节:ROWS = ~(1 << row)每次只拉低一行,其它行为高。如果你把所有行同时拉低,按下任何一个键时会有多列同时变低,你就分不清到底是哪一个键了。另外,按下检测后那个while等待松开,就是为了防止一次按下被主循环当成多次按下处理。

不过这个写法有个小瑕疵:如果用户按键时间过长,主程序会卡在等待松开的循环里。对计算器这种交互频率不高的设备来说问题不大,但如果你想把代码质量提上去,可以改成"检测到按下→做一次运算→标记释放标志→等下次按下时判断",这就是用状态机做按键消抖的思路了。

2.2 按键消抖:别让抖动坑了你的输入

机械按键按下去的瞬间,簧片会来回弹几下,每次接触都会产生高电平到低电平的跳变。如果不处理,按一下"1",单片机可能读到的是一串"111111",显示出来就是"111111"。解决这个问题的标准操作就是"消抖"。

消抖有两类做法,一种是延时法,检测到电平变化后等10~20ms再确认一次电平;另一种是扫描法,每隔几毫秒采样一次,连续若干次采样结果一致才认为按键状态稳定。延时法简单但会阻塞CPU,扫描法复杂但更符合实时系统的思维。我项目里用的是延时法变种:扫描到某列变低后,先delay_ms(15),再读一次,如果还是低才确认按下。

为什么是15ms而不是5ms或者50ms?机械按键的抖动时间一般不超过10ms,取15ms是个安全的中间值,既能躲过抖动,又不会让人觉得按键卡顿。如果驱动能力充裕,也可以实测一下你手头按键的抖动波形,用示波器看最准,没有示波器就按经验值15ms来,问题不大。

一个容易被忽略的点:消抖不仅仅是为了防止"一次按下变多次",也是为了可靠识别"一次按下什么都不触发"。如果你的消抖时间太短,比如只有1ms,抖动期间的某个瞬间读到一个"低电平"就当成一次有效按下,程序可能跳到错误的逻辑分支,表现出来就是按"+"结果却输入了"5"。这类问题最难排查,因为现象不是稳定复现的。

2.3 运算状态机:计算器的"大脑"

这是我整个项目觉得最值得写的一部分。计算器表面上是个硬件设备,核心其实是个"有限状态机"。我给它定义了三个状态:INPUT_OP1(输入第一个操作数)、INPUT_OP2(输入第二个操作数)、SHOW_RESULT(显示结果)。状态之间靠按键事件来迁移。

举个具体例子:开机后进入INPUT_OP1,你按"1"、"2",op1就从0变成12;按"+",状态切成INPUT_OP2,op2清零;再按"3"、"4",op2变成34;按"=",计算12+34,结果46显示在LCD上,状态切到SHOW_RESULT;这时再按"+",计算机会把"46"当作下一次运算的op1,状态重新回到INPUT_OP2,这就是连续运算的衔接逻辑。

状态机的核心代码长这样(我故意把细节压缩得明亮一点):

uint8_t state = INPUT_OP1; int32_t op1 = 0, op2 = 0; uint8_t cur_op = 0; void calc_event(uint8_t key) { if (key == 'C') { op1 = op2 = 0; cur_op = 0; state = INPUT_OP1; refresh_display("0"); return; } if (key >= '0' && key <= '9') { if (state == SHOW_RESULT) { op1 = 0; state = INPUT_OP1; } if (state == INPUT_OP1) { op1 = op1 * 10 + (key - '0'); refresh_num(op1); } else if (state == INPUT_OP2) { op2 = op2 * 10 + (key - '0'); refresh_num(op2); } return; } if (key == '+' || key == '-' || key == '*' || key == '/') { if (state == INPUT_OP2) { // 第一个数已经输入过,连续运算时先算一次 op1 = calc(op1, op2, cur_op); op2 = 0; } else { state = INPUT_OP2; } cur_op = key; return; } if (key == '=') { if (state == INPUT_OP2 && cur_op) { op1 = calc(op1, op2, cur_op); op2 = 0; state = SHOW_RESULT; refresh_num(op1); } } }

这个版本是按"输入顺序运算"的简化逻辑,cur_op记录上一次按下的运算符,calc()做四则运算。你仔细看+/-/*//那个分支:如果在INPUT_OP2状态下又按了运算符,说明用户想连续运算,比如输入了"12+34"还没按等号,直接按"×",那就先算12+34=46,再把运算符切为"×",等待下一个操作数。这个行为跟很多计算器是一致的。

状态机的好处是,你不需要在按键中断里处理复杂逻辑,只需要把按键事件丢给calc_event(),所有状态都由它统一管理,出bug时也好定位——打印当前状态和两个操作数就知道问题出在哪一环。

2.4 显示缓冲:让LCD刷新不乱码

LCD1602的写操作是并口的,每次写数据前要拉低RS、置低RW、把数据放到P0、给一个高脉冲到EN、再恢复低。这个时序网上教程一抓一大把,我在这里想重点说的是"缓冲"的思路。

我的做法是:在内存里维护一个字符数组disp_buf[16],每当op1、op2或结果发生变化时,先用snprintf把这个数字格式化成字符串放到disp_buf里,再调用统一的lcd_write_string(0, 0, disp_buf)刷到屏幕。这样做有两个好处:一是LCD的刷新频率完全由逻辑控制,不会在运算过程中频繁闪烁;二是当你需要显示"E"(错误)、"0"、负号时,只需要改缓冲区内容,不用改外设驱动。

还有个细节:数字类型我用的是int32_t。为什么不用整型int?因为C标准里int的范围在不同平台不一样,51单片机上int默认是16位,范围-32768到32767,计算器做个稍微大点的乘法就溢出了。用int32_t至少能扛到正负21亿,对简化版计算器来说绰绰有余。如果你想要更大的范围,就得换成long long或者自己写大数处理,那是另一个话题了。

3. 实操过程与核心环节实现

3.1 硬件连接要点与原理图思路

我手里的板子是STC89C52开发板,带LCD1602接口和4×4矩阵键盘的排针,接线基本是现成的,但如果你要自己搭,连接关系建议这样定:

  • 矩阵键盘:P1.0~P1.3接3行或4行,P1.4~P1.7接4列(这4根要有内部上拉)
  • LCD1602:数据口D0~D7接P0.0~P0.7,RS接P2.0,RW接P2.1,EN接P2.2
  • 电源:统一5V,注意LCD背光串一个限流电阻,不然背光电流可能偏高

实际连接时,矩阵键盘的公共端如果接到有外部上拉电阻的端口,扫描时会更稳定。有些开发板矩阵键盘的列端子上已经焊了上拉电阻,那就直接用;如果没有,记得在代码里把对应端口配置为准双向口(内部上拉),否则高电平状态不确定。

一个我从项目里得到的教训:接线前先把键盘每个按键用万用表测量一下通断,确认行列关系。我因为图省事,直接按原理图假设行列,结果扫描代码写出来之后怎么都不对,一量才发现板子上的矩阵键盘排线顺序和原理图不一致。硬件上的一个想当然,能让软件排查一整天。

3.2 核心代码框架:从按键到运算到显示

整个工程主循环非常短,在我看来这也是嵌入式程序应有的样子:初始化,然后不停扫键盘、处理事件、刷新显示。代码骨架如下:

void main(void) { uint8_t key; lcd_init(); lcd_write_string(0, 0, "Calc No.4"); lcd_write_string(1, 0, "Ready"); while (1) { key = keypad_scan(); if (key != KEY_NONE) { calc_event(key); } } }

就这么简单。真正的工作都被拆到了keypad_scan和calc_event里。这也是我一直强调的:把功能拆成独立模块,主循环可以保持简洁,方便后续维护。你要是把这个计算器升级成支持优先级,要改的也只是calc_event内部,外面主循环和驱动都不用动。

calc_event里用到的calc()函数,因为要处理除零,我会先判断:

int32_t calc(int32_t a, int32_t b, uint8_t op) { if (op == '+') return a + b; if (op == '-') return a - b; if (op == '*') return a * b; if (op == '/') { if (b == 0) { return INT32_MAX; // 用特殊值表示错误 } return a / b; } return 0; }

除法返回INT32_MAX这个做法有点粗糙,但够用。调用处可以检查返回值是不是INT32_MAX来触发错误显示。更规范的做法是让calc返回一个结构体,里面同时放结果和错误标志。不过对简化版项目来说,一个特殊返回值配合注释已经说得过去了。如果你写代码的时候有强迫症,换成结构体版本,改起来也不难。

refresh_display()函数我做得更直白:把操作数和当前运算符拼成一行表达式,显示在第一行,把结果或当前输入显示在第二行。因为LCD1602只有两行,表达式太长就需要截断,我一般只保留最后16个字符,确保"+=数字"这些最近操作可见,这样联调时用户能立刻看到自己的操作被正确接收了。

3.3 编译、烧录与真机验证

写代码之前先确认工具链:STC89C52用Keil C51或者SDCC都行。我是用Keil C51建的工程,选择AT89C52/STC89C52 device,然后编译生成HEX文件。烧录用的是STC-ISP工具,通过串口下载。如果是新板子,第一次烧录需要冷启动(断电再上电),老手都知道这个操作,新手可能卡在"连接超时"上,记住先点下载按钮再给板子通电就能解决。

真机验证我的建议是分步骤做,别一口气把整套逻辑写完再测。第一步,不写运算逻辑,只写键盘扫描,按一下键就LCD上显示对应字符,先确认16个键每一个都能正确识别。第二步,只加两个操作数和一个运算符,测"数字+数字=结果"。第三步,再加连续运算和C清零。每步验证完,发现问题时范围都很小。

我第一次直接把完整代码烧进去,结果按"7+8=",屏幕显示"15",看起来正常;再按"9×3=",却显示"18"——明显是状态残留问题:上一次的op2或者cur_op没有正确清零。这个bug如果一开始就分步验证,在第二步就会暴露,不用等全套写完了再痛苦地断点排查。所以说,分步验证看着慢,其实是最快的路径。

4. 常见问题与排查技巧实录

4.1 按键失控:总是重复触发或完全无响应

这是我在做这个项目的过程中被问到最多的两个问题。先说重复触发:最常见的原因是消抖不彻底,或者检测到按下后没有等待释放。我见过有人写扫描函数,主循环每秒扫几十次,按下一次键,主循环连续扫到好几次"低电平",每个循环都当作一次新按键发给逻辑层,屏幕瞬间跳好几个字符。解决办法就是我在2.2里说的:检测到低电平后先等15ms再确认,并且按下的整个期间只上报一次事件。

再说完全无响应:第一怀疑对象是IO口方向配置,第二是接线。矩阵键盘的行列扫描本质上是"推挽输出行、输入读取列",如果行线设成了输入模式,行电平根本拉不低,按下也不会有任何列变化。我建议在扫描代码前加一个很短的LED翻转调试语句,确认主循环活着。如果扫描函数没进,就先检查IO配置;如果进了但COLS读回来一直是0x0F,那就是接线或者上拉的问题。

4.2 结果不对:优先级、负号、连续运算的坑

"1+2×3"算出来9,这是顺序运算的预期,不算bug,但如果你想让结果变成7,就得实现优先级。方法不复杂:把表达式拆成操作数和运算符两个栈,遇到高优先级运算符(×、÷)时先弹出栈顶运算,遇到低优先级运算符(+、-)则先压栈,等表达式处理完再把栈里的运算符挨个弹出计算。这个思路我在扩展部分再细讲。

负号是另一个坑。简化版计算器不支持输入负数,但结果完全可能是负数,比如"5-8=-3"。我的做法是在显示结果时判断符号位,如果op1 < 0,就在disp_buf[0]放'-',后面放数字的绝对值。这里有个坑:LCD1602第一列如果没清干净,上次显示"123"这次显示"-8",右边会残留一个"3"变成"-83"。解决的办法是每次refresh_display前先lcd_clear(),或者写入前先把整行填充为空格。清屏太频繁会有闪烁,填充空格更稳。

连续运算的坑也是状态残留。比如输入"10+20",你按了"×"想继续乘,如果op2没有先清零,它会带着上次的值20参与乘法,结果就成了600而不是300。我在2.3的calc_event里有个关键分支:按下运算符时,如果当前是INPUT_OP2,先op1 = calc(op1, op2, cur_op),然后op2 = 0;如果当前是INPUT_OP1,只把cur_op更新,不计算。这个"清零时机"是连续运算不出错的关键。

4.3 除零与溢出:简化版也不能装看不见

除零是所有计算器项目的送命题。我见过有人写op1 / op2完全没判断,一旦op2为0,程序直接跑飞或者显示一个莫名其妙的大数。正确的处理是在calc()分流器里判断,除零时返回错误标志,calc_event里检测到这个标志就清屏显示"E: Div0"。还有一件事:整数除法里,1/3会得0,很多用户会觉得不对。这个问题简化版可以不处理,但你要在文档或注释里说明白,免得别人拿到代码后当成bug提出来。

溢出也是要管的。int32_t虽然能存21亿,但两个大数相乘很容易越界,这时你会看到负数或者诡异乱码。我做了个简单的检查:如果op1 > op2且op1 > INT32_MAX / op2这种条件成立,就判定溢出,显示"E: OVF"。这属于"简化但不放任"的做法,至少让错误可见,而不是让屏幕出现不可控结果。

把这些失败场景都处理掉之后,你会明显感觉到项目质量上了一个档次。因为写一个"快乐路径"的计算器谁都会,难的是把用户的不规范操作也稳稳接住。

4.4 移植与扩展:一个速查表收尾

我把调试过程中的典型问题整理成一个速查表,后续你换成STM32、换成Raspberry Pi Pico,甚至改成纯C语言控制台版本,这张表都能帮你快速定位问题。

现象可能原因排查思路
按一下键出现多个字符硬件抖动没有消干净把消抖延时加到15~20ms,并等待按键释放
某个键或某一行完全无响应IO口方向配置错误/接线不对/行列序号与原理图不一致单独写测试程序,逐个引脚置低读回验证
LCD显示乱码或花屏初始化时序不对或数据线接触不良检查EN使能脉冲宽度,重新初始化并逐字节测试
1+2×3=9顺序运算逻辑符合"简化版"定位想升级就实现双栈优先级算法
除0时屏幕狂闪没做除零错误分支除零时显示错误码,并清空状态机
结果溢出显示负数int32_t范围被超过计算前做溢出判断,显示OVF
上一次的显示内容残留LCD没有按行清屏刷新前给显示缓冲区填满空格或整行清空

5. 从一个简化版看到的一大片"计算器生态"

5.1 嵌入式里的三级递进:从点灯到完整系统

现在回看整个项目,你会明白为什么"计算器"这个概念在嵌入式学习链路上那么经典。在培训体系里,它往往被设计成三级递进:第一级用数码管显示一个递增计数,练的是IO输出和定时器;第二级加矩阵键盘,练的是输入扫描和状态识别;第三级加LCD显示和四则运算,练的是状态机和模块化设计。做完这三步,一个"能用的计算器"就诞生了。而我这个"No.4"更像是在三级基础上加了点"工程化"的要求:处理连续运算、除零、溢出、显示缓冲。这四件事单拆出来都不难,但组合在一起,就是一个完整的嵌入式小系统。

你现在再去看网上各种"STM32计算器"、"51计算器"的demo,思路应该能一眼看穿:无非是按键扫描、状态机、显示驱动、运算算法四块拼起来的。硬件平台可以换,驱动写法可以换,但这四块的逻辑骨架不会变。这就是做这个项目练出来的"迁移能力"。

5.2 软件计算器的多样形态与共性逻辑

计算器当然不只是单片机的专利。你用Java写一个带Swing界面的高级计算器,核心还是表达式解析;你用Rust写一个命令行计算器,只是把LCD显示换成了println!,把按键事件换成了标准输入读取。核心逻辑永远逃不开那几件事:数字怎么累积、运算符怎么处理优先级、结果怎么格式化、非法输入怎么报错。

我之前在Linux环境测试过命令行计算器,bc是最基础的,qalc之类的现代工具能直接处理"1+2*3-4/5"这种完整表达式。装一个也很简单,包管理器一行命令就行。用它们来验证你的算法逻辑是最省事的办法:你写个处理优先级的表达式解析,丢进qalc对比结果,就知道自己有没有写对。这类工具对嵌入式开发来说不叫抢饭碗,叫辅助校验。

5.3 领域专用计算器:换个场景,算法为王

当"计算器"这个词脱离通用四则运算,你会发现它在各个领域都有专门变体:GIS字段计算器可以用表达式给地理属性表做"文字+数字编号"的批量赋值,核心是字符串拼接与序号迭代;校验码计算器8位是通信里算CRC、校验和的工具,核心是位运算与查表;AQI计算器2.0.0是把污染物浓度按国标折算成空气质量指数,核心是分段线性插值;LLC在线计算器帮电源工程师算谐振腔参数,核心是几个公式的反复迭代;瑞士轮计算器则是赛事编排系统的一个模块,核心是选手积分排序与配对规则。这些不同形态的"计算器",底层都在做同一件事:把领域规则封装成可重复执行的计算流程。

做"No.4 简化版计算器"的真正价值就在这里。你练的不是某个具体的计算公式,而是"如何把一个领域的输入规则、处理规则、输出规则结构化"的能力。有了这套能力,你以后做一个AQI计算器、做一个校验码计算器,或者给某个业务写一个字段计算器,都只是换一套领域规则、换一个输入输出通道的事。

我做完这个项目最深的体会是:真正难的不是四则运算,而是把用户无序的按键行为约束到一套可控的状态迁移里。矩阵键盘的抖动、连续运算的状态残留、LCD的整行清屏、除零溢出错误码,每一个问题单独看都是小事,但它们共同决定了这个计算器是"能跑的demo"还是"能用的小产品"。最后分享一个实操小技巧:调试状态机的时候,在calc_event入口临时加一个串口打印,把每次按键的字符和当前状态打出来,你能非常直观地看到状态迁移有没有按预期走。我当时就靠这个,十分钟定位了一个连续运算的bug——比断点好使,比猜快得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询