ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

51单片机计算器实战:矩阵键盘、LCD1602与定点运算实现

51单片机计算器实战:矩阵键盘、LCD1602与定点运算实现 简介这套基于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 个键09、加、减、乘、除、等号、清除。用独立按键会占掉 16 个 IO51 单片机资源吃紧。4×4 矩阵键盘用 4 根行线加 4 根列线就管理 16 个按键是这里最常用的硬件方案。接线方式可以固定为P1.0P1.3 接行线P1.4P1.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.4P1.7判断哪一列被拉低。0x10 col对应列线的位定义例如 col0 检测 P1.4col3 检测 P1.7。keymap表用于把行列号换算成真正的按键语义。比如第四行第三列keymap[15]是加号第四行第一列keymap[13]是等号。硬件的行列顺序和按键丝印不一样时只需要改这张表不用改扫描算法。2.3 消抖、一次触发与长按区分机械按键按下和松开时都有 515ms 的抖动扫描函数在抖动期间可能连续读到多个不同键值。常见做法是检测到按键后延迟 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.005 / 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 接数据线 D0D7P2.0 接 RSP2.1 接 RWP2.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 占用会增加 12KB这对 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和s1之间可以用余数修正算出两位小数迭代公式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)时s1r1迭代后f41返回 141显示 1.41。这个算法里10000UL * r和200UL * s f都在 32 位范围内不需要 64 位乘法比直接算sqrt(n*10000)更稳而且保留了牛顿迭代的收敛性。对 255 开方s15r30迭代出f97返回 1597显示 15.97与真实值 15.968 只差 0.01显示精度足够。4.5 优先级问题顺序计算还是标准优先级标题没提括号51 计算器一般也不做表达式解析。整套程序采用操作数 1 运算符 操作数 2的顺序计算模型也就是1 2 × 3会先算123再算3×39。如果坚持标准优先级需要在状态机外维护两个操作数栈和运算符栈51 的片内 RAM 只有 128 字节用结构体数组做栈会很紧张。顺序计算的好处是状态机简单、容易在 Proteus 里仿真预期行为绝大多数课程设计的验收要求也只到能连续按运算符并得到结果。如果项目需求确实要求优先级建议把显示和运算逻辑移植到 STM32 或 16 位 MCU51 做这件事性价比很低。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 里修改按钮触发条件就能验证每一步的状态迁移是否符合预期。本文还有配套的精品资源点击获取
返回列表