
把no.4 简化版计算器当项目做的时候我在心里给它排了个位置这是我嵌入式学习系列里的第4个动手任务。前面三个分别是跑马灯、花样点阵、电子秒表说白了都是往IO口写数据、看现象到了计算器这一步才真正开始碰逻辑——矩阵键盘怎么扫描才不会串键按键怎么消抖才不误触运算过程怎么用状态机管起来除零和溢出怎么处理。这些才是做工程真正要面对的问题。如果你是刚学单片机、想找一个能把输入→处理→输出完整串起来的练习项目这个简化版计算器非常合适它不炫技但五脏俱全。1. 项目整体设计与思路拆解1.1 这个No.4到底练的是什么先说清楚一件事这个简化版简在哪里我的定位是——支持0~9数字输入、加减乘除四则运算、等号计算、C清零运算按输入顺序从左到右执行。什么意思就是12×3它给你算成9而不是7。很多廉价计算器和入门开发板例程都是这个逻辑因为它把复杂度控制在一个很舒服的范围不需要处理运算符优先级不需要括号不需要负数输入。项目编号No.4前面三个是基础外设练习这个项目则是把GPIO、定时器、中断、字符设备显示这些零散知识点第一次整合成一个有完整交互流程的小系统。这个定位非常有意识。很多新手一上来就想做科学计算器要处理括号、优先级、浮点数、三角函数结果卡在表达式解析上连按键和显示都还没调通自信心直接崩了。简化版的价值在于先把输入链路和输出链路跑通把状态管理这种软件工程思维练熟后面再往高级版加特性时你会发现只是在这个骨架上添砖瓦。1.2 硬件方案选型不是越高级越好主控我选了STC89C5251内核8位单片机Flash 8KBRAM 512字节。你别嫌它老这个项目的计算量它完全够用而且资料多、编译烧录简单、坏了不心疼。如果手上正好有STM32F103这类Cortex-M3芯片也完全可以移植核心逻辑不用改只是GPIO初始化和库函数调用差异。按键这里有个关键选择你是用独立按键还是矩阵键盘功能上简化版计算器至少要16个键10个数字、4个运算符、等号、清零如果用独立按键一片单片机GPIO都不够用一般开发板也就20来个IO口。所以必须上4×4矩阵键盘——8个IO口搞定16个键这是经典的用逻辑换引脚思路。显示方案我也纠结过一会儿数码管还是LCD1602数码管显示数字很直观、驱动也简单但你要显示完整的运算表达式就很吃力四位数码管最多放4位数字超过就溢出符号乱跳。LCD1602虽然驱动时序比数码管繁琐但它能显示两行16个字符第一行放当前输入的数字第二行放结果甚至能把12×3这样的表达式完整打出来排查问题的时候体验好太多。我的结论是既然做的是交互产品显示的信息量比显示器件本身复杂更重要。1.3 显示与键盘简化但不将就键盘布局我直接采用最常见的一排排布第一行1、2、3、第二行4、5、6、-第三行7、8、9、×第四行C、0、、÷。这个布局跟手机计算器基本一致手指记忆成本最低。键值映射用一张4×4的表存起来扫描到行列号就直接查出字符简洁高效。LCD1602的接线也简单8根数据线并到P0口RS接P2.0RW接P2.1EN接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、2op1就从0变成12按状态切成INPUT_OP2op2清零再按3、4op2变成34按计算1234结果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状态下又按了运算符说明用户想连续运算比如输入了1234还没按等号直接按×那就先算123446再把运算符切为×等待下一个操作数。这个行为跟很多计算器是一致的。状态机的好处是你不需要在按键中断里处理复杂逻辑只需要把按键事件丢给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.7RS接P2.0RW接P2.1EN接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清零。每步验证完发现问题时范围都很小。我第一次直接把完整代码烧进去结果按78屏幕显示15看起来正常再按9×3却显示18——明显是状态残留问题上一次的op2或者cur_op没有正确清零。这个bug如果一开始就分步验证在第二步就会暴露不用等全套写完了再痛苦地断点排查。所以说分步验证看着慢其实是最快的路径。4. 常见问题与排查技巧实录4.1 按键失控总是重复触发或完全无响应这是我在做这个项目的过程中被问到最多的两个问题。先说重复触发最常见的原因是消抖不彻底或者检测到按下后没有等待释放。我见过有人写扫描函数主循环每秒扫几十次按下一次键主循环连续扫到好几次低电平每个循环都当作一次新按键发给逻辑层屏幕瞬间跳好几个字符。解决办法就是我在2.2里说的检测到低电平后先等15ms再确认并且按下的整个期间只上报一次事件。再说完全无响应第一怀疑对象是IO口方向配置第二是接线。矩阵键盘的行列扫描本质上是推挽输出行、输入读取列如果行线设成了输入模式行电平根本拉不低按下也不会有任何列变化。我建议在扫描代码前加一个很短的LED翻转调试语句确认主循环活着。如果扫描函数没进就先检查IO配置如果进了但COLS读回来一直是0x0F那就是接线或者上拉的问题。4.2 结果不对优先级、负号、连续运算的坑12×3算出来9这是顺序运算的预期不算bug但如果你想让结果变成7就得实现优先级。方法不复杂把表达式拆成操作数和运算符两个栈遇到高优先级运算符×、÷时先弹出栈顶运算遇到低优先级运算符、-则先压栈等表达式处理完再把栈里的运算符挨个弹出计算。这个思路我在扩展部分再细讲。负号是另一个坑。简化版计算器不支持输入负数但结果完全可能是负数比如5-8-3。我的做法是在显示结果时判断符号位如果op1 0就在disp_buf[0]放-后面放数字的绝对值。这里有个坑LCD1602第一列如果没清干净上次显示123这次显示-8右边会残留一个3变成-83。解决的办法是每次refresh_display前先lcd_clear()或者写入前先把整行填充为空格。清屏太频繁会有闪烁填充空格更稳。连续运算的坑也是状态残留。比如输入1020你按了×想继续乘如果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使能脉冲宽度重新初始化并逐字节测试12×39顺序运算逻辑符合简化版定位想升级就实现双栈优先级算法除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之类的现代工具能直接处理12*3-4/5这种完整表达式。装一个也很简单包管理器一行命令就行。用它们来验证你的算法逻辑是最省事的办法你写个处理优先级的表达式解析丢进qalc对比结果就知道自己有没有写对。这类工具对嵌入式开发来说不叫抢饭碗叫辅助校验。5.3 领域专用计算器换个场景算法为王当计算器这个词脱离通用四则运算你会发现它在各个领域都有专门变体GIS字段计算器可以用表达式给地理属性表做文字数字编号的批量赋值核心是字符串拼接与序号迭代校验码计算器8位是通信里算CRC、校验和的工具核心是位运算与查表AQI计算器2.0.0是把污染物浓度按国标折算成空气质量指数核心是分段线性插值LLC在线计算器帮电源工程师算谐振腔参数核心是几个公式的反复迭代瑞士轮计算器则是赛事编排系统的一个模块核心是选手积分排序与配对规则。这些不同形态的计算器底层都在做同一件事把领域规则封装成可重复执行的计算流程。做No.4 简化版计算器的真正价值就在这里。你练的不是某个具体的计算公式而是如何把一个领域的输入规则、处理规则、输出规则结构化的能力。有了这套能力你以后做一个AQI计算器、做一个校验码计算器或者给某个业务写一个字段计算器都只是换一套领域规则、换一个输入输出通道的事。我做完这个项目最深的体会是真正难的不是四则运算而是把用户无序的按键行为约束到一套可控的状态迁移里。矩阵键盘的抖动、连续运算的状态残留、LCD的整行清屏、除零溢出错误码每一个问题单独看都是小事但它们共同决定了这个计算器是能跑的demo还是能用的小产品。最后分享一个实操小技巧调试状态机的时候在calc_event入口临时加一个串口打印把每次按键的字符和当前状态打出来你能非常直观地看到状态迁移有没有按预期走。我当时就靠这个十分钟定位了一个连续运算的bug——比断点好使比猜快得多。