简介:这套基于51单片机的计算器资源,集源码、仿真与原理图于一体,实现了加减乘除、平方和开二次根运算,支持5位数以内输入、结果保留4位有效数字,并允许输入过程中退格修正。液晶屏第一行显示输入值,第二行显示计算结果,界面简洁直观。压缩包共37个文件,大小仅625KB,包含C语言程序源码、Proteus仿真工程、AD原理图、PDF预览、元件清单、流程图及Hex烧写文件等,可满足从程序编译到仿真验证、再到底板PCB设计的学习链条。仿真工程基于Proteus7.8制作,不低于该版本均可打开,实测Proteus8也能完美运行,环境适配成本低。这套资料适合51单片机初学者进阶、电子类课程设计以及毕业设计参考,有助于掌握LCD1602液晶显示、按键输入处理和数值计算程序设计。目前已有938人浏览学习,配套资料齐全,可作为单片机项目实战与常见问题排查的有用参考。
1. 基于51单片机的计算器,先把加减乘除和开方的设计边界想清楚
一块 STC89C52、一块 LCD1602、一个 4×4 矩阵键盘,是许多 51 单片机课程设计的标准开头。真正动手时你会发现,计算器最难的不是"会算",而是按键按下去总是连跳、多位数一输就溢出、除法和开方结果带小数时显示逻辑一团糟。本文围绕"基于51单片机的计算器(加减乘除、平方、开方)"这条主线,先把矩阵键盘扫描和输入逻辑做扎实,再给出可复现的定点运算方案,最后讲怎么用 Proteus 仿真把状态机调稳。读者不管是做课程设计还是自己玩硬件,都能按这套思路把代码从"能显示"推进到"能连续算、结果正确"。
2. 矩阵键盘扫描与按键消抖:把按下去的动作变成计算器能用的键值
2.1 4×4 矩阵键盘的行列扫描原理
计算器至少需要 16 个键:0~9、加、减、乘、除、等号、清除。用独立按键会占掉 16 个 IO,51 单片机资源吃紧。4×4 矩阵键盘用 4 根行线加 4 根列线就管理 16 个按键,是这里最常用的硬件方案。
接线方式可以固定为:P1.0~P1.3 接行线,P1.4~P1.7 接列线,列线外接 10kΩ 上拉电阻。扫描时把某一行拉低,然后读列线;如果某列也读到低电平,说明交叉点有按键按下。例如第 2 行、第 1 列被按下,交叉点行列同时为低,就能确定唯一键位。行线不需要上拉,准双向口拉低后能稳定保持低电平。
标题里的平方键和开方键放在 4×4 矩阵里会多出两个键位,常见做法是两个独立按键直接接 P3.2 和 P3.3,按钮另一端接地,按下时 IO 读到低电平。这样矩阵键盘保持标准 16 键布局,功能键也不必扩展成 4×5 矩阵去挤 IO。硬件上只多两根线,软件逻辑也更清楚。
2.2 可直接使用的矩阵扫描函数
下面是一个标准的逐行拉低扫描函数,返回的是按键码。注意 P1 是准双向口,做矩阵扫描时不需要额外配置输入输出模式。
#define KEY_PORT P1 const unsigned char code keymap[16] = { 7, 8, 9, 13, // 第一行: 7 8 9 ÷ 4, 5, 6, 12, // 第二行: 4 5 6 × 1, 2, 3, 11, // 第三行: 1 2 3 - 0, 14, 15, 10 // 第四行: 0 C = + }; unsigned char keyscan(void) { unsigned char row, col, keycode; for (row = 0; row < 4; row++) { KEY_PORT = ~(0x01 << row); // 当前行拉低,其他行保持高 for (col = 0; col < 4; col++) { if (!(KEY_PORT & (0x10 << col))) { keycode = row * 4 + col; return keymap[keycode]; } } } return 0xFF; // 无按键 }这段代码的核心在KEY_PORT = ~(0x01 << row)。外层循环让第 0 到第 3 行依次输出低电平,其他行输出高电平。内层循环再读 P1.4~P1.7,判断哪一列被拉低。0x10 << col对应列线的位定义,例如 col=0 检测 P1.4,col=3 检测 P1.7。
keymap表用于把行列号换算成真正的按键语义。比如第四行第三列keymap[15]是加号,第四行第一列keymap[13]是等号。硬件的行列顺序和按键丝印不一样时,只需要改这张表,不用改扫描算法。
2.3 消抖、一次触发与长按区分
机械按键按下和松开时都有 5~15ms 的抖动,扫描函数在抖动期间可能连续读到多个不同键值。常见做法是检测到按键后延迟 20ms 再读一次,两次一致才确认按下。还要用一个标志位保证一次按下只触发一次,避免手指按住不放导致数字连续往屏幕里塞。
unsigned char get_key(void) { static unsigned char key_down = 0; unsigned char k; k = keyscan(); if (k == 0xFF) { key_down = 0; return 0xFF; } if (!key_down) { delay_ms(20); // 再扫描一次,排除抖动 if (keyscan() == k) { key_down = 1; // 标记按键已按下,防止连按 return k; } } return 0xFF; }key_down是静态变量,保存上一次是否有按键处于按下状态。返回键值后先把key_down置 1,之后keyscan()即使仍然读到同一个键,也因为key_down非 0 而不返回。直到扫描函数返回 0xFF 时,key_down清零,按键才算释放完毕。这个逻辑可以把"按下一次"和"长按"区分开,后续状态机里不用再担心按键重复触发。
2.4 独立开方键和平方键的读取
P3.2 和 P3.3 两个按键按下为低电平,消抖逻辑和矩阵键盘一致,可以简化成一次状态判断:
sbit KEY_SQRT = P3^2; sbit KEY_SQUARE = P3^3; unsigned char read_function_key(void) { if (KEY_SQRT == 0) { delay_ms(20); if (KEY_SQRT == 0) return 16; } if (KEY_SQUARE == 0) { delay_ms(20); if (KEY_SQUARE == 0) return 17; } return 0xFF; }返回 16 和 17 是为了和 4×4 矩阵的键码区隔开。主循环里先调用get_key(),如果返回值是 0xFF 再调用read_function_key(),就能统一处理两个独立键。这样设计键码不会冲突,后面状态机分发按键时也只需要一个 switch。
3. 数值输入与 LCD1602 显示:多位数怎么拼、结果怎么带两位小数
3.1 用 long 存值,结果统一用定点两位小数
51 单片机上的 float 运算要靠 Keil 的浮点库,占 ROM 多、执行慢,而且不同建项目配置下行为还不一样。常见做法是全部用long整数表示数字,显示时再插入小数点。输入阶段用户按的是整数,运算结果统一按"定点两位小数"输出,例如3 + 4显示7.00,5 / 2显示2.50,√2显示1.41。
内部用一个结构体管理当前计算状态:
typedef struct { long current; // 当前输入/显示的数字 long op1; // 第一操作数 unsigned char op; // 运算符 unsigned char state; // 状态机当前状态 unsigned char input_cnt; // 当前操作数已输入位数 unsigned char err; // 溢出/除零错误标志 } Calculator; Calculator calc;current在输入状态下保存用户刚按的那串数字,例如连按 1、2、3,它累积成 123。按运算符后current会被转成定点值存到op1。err标志一旦置位,LCD 显示Err,只有按清除键才能恢复。这样比在运算函数里到处返回错误码更直观,也方便在 Proteus 仿真时观察。
3.2 数字键录入与输入长度限制
用户按数字键时,实质是把现有值左移一位再加新数字:
void input_digit(unsigned char d) { long next; if (calc.err) return; if (calc.input_cnt >= 6) return; // 最多输入6位整数 next = calc.current * 10 + d; if (next > 20000000L) return; // 防止后续定点放大时溢出 calc.current = next; calc.input_cnt++; show_raw(calc.current); }限制 6 位整数是为了内部定点表示不超出long的范围。所有参与运算的数字都要放大 100 倍存成定点值,current如果到 999999,放大后是 99999900,还在signed long的范围内。再往上加一位就可能溢出,所以在录入阶段就拦截,比运算时才发现问题要好处理得多。
清除键和退格键也要在这里一起处理。清除键把calc.current、op1、op和input_cnt全部清零,退格键则是calc.current /= 10,同时input_cnt--,这两个操作不影响内部定点标志。
3.3 LCD1602 最小驱动与两行显示
LCD1602 用 8 位并口模式,P0 接数据线 D0~D7,P2.0 接 RS,P2.1 接 RW,P2.2 接 E。最小驱动只需要四个函数:
sbit LCD_RS = P2^0; sbit LCD_RW = P2^1; sbit LCD_E = P2^2; #define LCD_DAT P0 void lcd_cmd(unsigned char cmd) { LCD_DAT = cmd; LCD_RS = 0; LCD_RW = 0; LCD_E = 1; delay_us(5); LCD_E = 0; } void lcd_dat(unsigned char dat) { LCD_DAT = dat; LCD_RS = 1; LCD_RW = 0; LCD_E = 1; delay_us(5); LCD_E = 0; } void lcd_init(void) { delay_ms(15); lcd_cmd(0x38); // 8位数据、2行、5×7点阵 lcd_cmd(0x0C); // 显示开,光标关 lcd_cmd(0x01); // 清屏 lcd_cmd(0x06); // 写入后地址自动加一 }初始化时序里,0x38是 51 驱动 1602 最常见的命令,必须等 15ms 以上再发后续命令。0x0C表示显示打开但不显示光标,避免计算器界面上出现一条横线干扰读数。
显示字符串前先定位坐标,一个可复用的写法如下:
void lcd_show_str(unsigned char line, unsigned char *str) { lcd_cmd(line == 1 ? 0x80 : 0xC0); while (*str) { lcd_dat(*str++); } }第一行地址从0x80开始,第二行从0xC0开始。字符逐个写入后地址自动增加,所以不需要手动管理光标位置,计算器界面也就稳定在第二行显示结果。
3.4 结果显示的定点拆分
运算结果在内部是放大了 100 倍的long,显示时必须拆成整数部分和小数部分,并且处理负数、小数补零:
void show_fixed(long v) { unsigned char neg = 0; unsigned long uv; char buf[12]; if (calc.err) { lcd_show_str(2, "Err"); return; } if (v < 0) { neg = 1; uv = (unsigned long)(-v); } else { uv = (unsigned long)v; } if (neg) sprintf(buf, "-%ld.%02ld", uv / 100, uv % 100); else sprintf(buf, "%ld.%02ld", uv / 100, uv % 100); lcd_show_str(2, buf); }%02ld的作用是小数部分不足两位时左边补零,这样250会显示成2.50而不是2.5。neg单独处理是因为对 32 位long取反时,负数最小值-2147483648无法安全地直接转成无符号数,先判断符号再取绝对值更稳。输入阶段的show_raw调用同一个函数时,把calc.current * 100传进去即可,显示逻辑可以完全复用。
4. 加减乘除、平方、开方的计算核心:统一定点运算与溢出控制
4.1 运算入口:先把原始输入转成定点值
运算时统一把calc.current放大 100 倍再参与计算。所有二元运算函数的参数和返回值都是"定点值",即真实数字乘以 100。分开写的好处是加减乘除混用时不需要反复猜当前值有没有放大过。
#define TO_FIX(x) ((long)(x) * 100L) #define FROM_FIX(x) ((x) / 100L) long add_f(long a, long b) { long long r = (long long)a + b; if (r > 2147483647LL || r < -2147483648LL) { calc.err = 1; return 0; } return (long)r; }TO_FIX(5)得到 500,表示 5.00。add_f(300L, 400L)得到 700,显示时见7.00。计算过程中用long long做中间变量,是为了在结果超出 32 位long范围时不产生不可预知的溢出,而是主动置calc.err,让显示层给出提示。
4.2 乘法和平方:用 64 位暂存结果
两个定点数相乘时,真实数学结果是(a/100)*(b/100),但内部定点结果应该是真实值再乘 100,所以表达式为(a * b) / 100。这里的中间乘积可能远远超过 32 位,必须用long long暂存:
long mul_f(long a, long b) { long long r = (long long)a * b / 100; if (r > 2147483647LL || r < -2147483648LL) { calc.err = 1; return 0; } return (long)r; }平方运算可以直接复用乘法:mul_f(value, value)。输入值是未放大的原始整数时,应该先TO_FIX再乘。例如用户输入 12 后按平方键,流程是mul_f(TO_FIX(12), TO_FIX(12)),得到 14400,显示144.00。如果用户是在结果状态继续做一元运算,则直接拿当前定点值调用mul_f,不能再次放大,否则结果会多乘 100 倍。
Keil C51 的long long会引入额外的 64 位乘除法库,编译后 ROM 占用会增加 1~2KB,这对 51 来说可以接受。如果目标单片机的 Flash 只有 4KB,需要把乘法拆成 16 位分段乘法,但课程设计用的 STC89C52 一般有 8KB Flash,不需要为此牺牲代码可读性。
4.3 定点除法:先缩放再除,做好除零保护
两个原始整数直接相除会丢掉小数。除法的定点式是(a * 100) / b,结果直接就是真实商乘以 100。例如 5 ÷ 2 表示为(500 * 100) / 200 = 250,显示 2.50。同样用long long避免a * 100在 32 位下溢出:
long div_f(long a, long b) { long long r; if (b == 0) { calc.err = 1; return 0; } r = (long long)a * 100 / b; if (r > 2147483647LL || r < -2147483648LL) { calc.err = 1; return 0; } return (long)r; }除零没有数学意义,所以要在函数最开始判断b。注意这里的a和b已经是定点值,不能用原始输入直接调用。比如用户先输 5,按 ÷,再输 2,按等号,实际调用的是div_f(TO_FIX(5), TO_FIX(2)),即div_f(500L, 200L)。主循环里调用前统一转换,可以避免各运算函数内部重复做猜测。
4.4 平方根算法:整数牛顿迭代与两位小数的余数修正
开方在 51 上不能用浮点库,常见做法是先算整数平方根,再手动补小数。整数平方根用牛顿迭代效率很高,代码短且不依赖硬件除法器:
unsigned long isqrt(unsigned long n) { unsigned long x, y; if (n == 0) return 0; x = n; y = (x + n / x) / 2; while (y < x) { x = y; y = (x + n / x) / 2; } return x; }迭代收敛后x就是平方根的整数部分。但要显示两位小数,只算整数部分不够。设n = s * s + r,其中s是整数根,r是余数。真实根在s和s+1之间,可以用余数修正算出两位小数:迭代公式f = (10000 * r) / (200 * s + f),收敛后f就是需要追加的百分位数字。
long sqrt_fixed(long raw) { unsigned long n, s, r, f; if (raw < 0) { calc.err = 1; return 0; } n = (unsigned long)raw; s = isqrt(n); if (s == 0) return 0; r = n - s * s; f = (10000UL * r) / (200UL * s + 1); f = (10000UL * r) / (200UL * s + f); f = (10000UL * r) / (200UL * s + f); if (f >= 100) { s++; f -= 100; } return (long)(s * 100 + f); }输入sqrt_fixed(2)时,s=1,r=1,迭代后f=41,返回 141,显示 1.41。这个算法里10000UL * r和200UL * s + f都在 32 位范围内,不需要 64 位乘法,比直接算sqrt(n*10000)更稳,而且保留了牛顿迭代的收敛性。对 255 开方,s=15,r=30,迭代出f=97,返回 1597,显示 15.97,与真实值 15.968 只差 0.01,显示精度足够。
4.5 优先级问题:顺序计算还是标准优先级
标题没提括号,51 计算器一般也不做表达式解析。整套程序采用"操作数 1 + 运算符 + 操作数 2"的顺序计算模型,也就是1 + 2 × 3会先算1+2=3,再算3×3=9。
如果坚持标准优先级,需要在状态机外维护两个操作数栈和运算符栈,51 的片内 RAM 只有 128 字节,用结构体数组做栈会很紧张。顺序计算的好处是状态机简单、容易在 Proteus 里仿真预期行为,绝大多数课程设计的验收要求也只到"能连续按运算符并得到结果"。如果项目需求确实要求优先级,建议把显示和运算逻辑移植到 STM32 或 16 位 MCU,51 做这件事性价比很低。
5. 把模块拼成计算器:状态机、Proteus 仿真排错与三个容易翻车的地方
5.1 状态机和主循环:四态切换
计算器主循环围绕四个状态切换:等待第一操作数、等待运算符、等待第二操作数、显示结果。用一个枚举管理,按键处理后只改状态和操作数,不直接操作屏幕:
typedef enum { S_FIRST, S_WAIT_OP, S_SECOND, S_RESULT } CalcState; void handle_key(unsigned char key) { if (key >= 0 && key <= 9) { if (calc.state == S_RESULT) { calc.current = 0; calc.input_cnt = 0; calc.state = S_FIRST; } input_digit(key); } else if (key == 10 || key == 11 || key == 12 || key == 13) { calc.op1 = TO_FIX(calc.current); calc.op = key; calc.current = 0; calc.input_cnt = 0; calc.state = S_WAIT_OP; } else if (key == 15) { calc_equal(); } }等号处理是所有二元运算的入口。按下等号时,把当前输入值和op1都转成定点值,再按calc.op分发到add_f、sub_f、mul_f、div_f。计算完成后把结果保存回calc.current,状态切到S_RESULT。之后如果用户按数字,就从S_RESULT清空旧结果重新输入;如果用户按运算符,则把结果当作第一操作数继续运算。
开方键和平方键在一元运算分支里处理,不需要改calc.op。按开方时,如果状态是S_FIRST或S_SECOND,直接对calc.current调sqrt_fixed,随后把状态切到S_RESULT;如果状态是S_RESULT,则直接对当前结果调sqrt_fixed,这样能支持对计算结果连续开两次方。
5.2 在 Proteus 里用虚拟串口观察中间值
LCD 只能看到最终数字,运算出错时很难判断是按键扫描丢了键,还是状态机提前切换。最常见做法是把 Keil 的 printf 映射到串口,在 Proteus 里接一个 Virtual Terminal 观察内部变量:
char putchar(char c) { SBUF = c; while (!TI); TI = 0; return c; } void uart_init(void) { SCON = 0x50; TMOD = 0x20; TH1 = 0xFD; TR1 = 1; TI = 0; }TH1 = 0xFD对应 11.0592MHz 晶振下 9600 波特率。在主循环的关键位置加printf("state=%d op1=%ld cur=%ld\r\n", ...),就能在 Proteus 的串口终端里看到按键后状态机的迁移过程。这个方法对排查"按一次等于号却算了两遍"特别有效,因为只要等号连续执行,串口日志里会出现两次相同的运算输出。
5.3 三个容易翻车的地方
第一个是矩阵按键的"松开再触发"。许多初版代码没有key_down标志,导致手指还没松开,主循环已经执行了好几轮按键处理,输入 2 会变成 2222。解决方法是把get_key()作为主循环唯一按键入口,所有逻辑只认它返回的一次键值,独立键也要加上同样的按下标志。
第二个是除法和开方里出现负数时没统一符号。div_f和sqrt_fixed都假设输入已经提前取绝对值,如果直接在代码里对负数求绝对值再相除,符号会错。常见做法是调用方在转定点值之前就把两个操作数的符号分别记录,或者在div_f里用neg变量保存符号,先算绝对值,最后再符号拼回去。
第三个是等号连按的状态污染。用户按完等号后再次按等号,很多计算器会自动用上一次结果和 op2 再算一次。如果状态机不处理,第二次等号可能会把当前结果当成新的第一操作数,结果完全错误。一个稳妥的写法是增加last_op2变量,等号后保留上次的第二操作数;再次按等号时,用calc.current和last_op2重复执行上次运算。这样既符合普通计算器的使用习惯,又不会把状态机打乱。
void calc_equal(void) { static long last_op2 = 0; long result; if (calc.state == S_RESULT) { result = calc_with_op(calc.current, last_op2, calc.op); } else { last_op2 = TO_FIX(calc.current); result = calc_with_op(calc.op1, TO_FIX(calc.current), calc.op); } calc.current = result; calc.state = S_RESULT; show_fixed(result); }这个static long last_op2是整套状态机里最容易遗漏的一笔。连续按等号时,它让计算器以当前显示值继续和上次的第二个操作数做运算,比如先算 10 ÷ 4 得到 2.50,再按等号会得到 0.62,符合标准计算器"重复上一次运算"的行为。到此,整个 51 计算器的按键、显示、算术运算和状态机已经串成一条完整的链路,直接在 Proteus 里修改按钮触发条件,就能验证每一步的状态迁移是否符合预期。
本文还有配套的精品资源,点击获取