基于 51 单片机、4×4 矩阵键盘和两位共阴极数码管的简易整数 / 小数计算器,核心功能覆盖 "输入 - 运算 - 显示 - 错误处理 - 重置"
本篇分为三个模块
模块一 矩阵按键
模块二 数码管
模块三 计算器
首先是电路图如上
模块一 :矩阵按键
如果想看视频的话
我在这里就简单把原理讲一下。


在单片机项目里,按键是最常用的输入设备。如果我们用最基础的独立按键,1 个按键就要占用 1 个单片机 IO 口;如果项目需要 16 个按键,就要占用 16 个 IO 口 ------ 这对 IO 资源本就紧张的单片机来说,是极大的浪费。
有没有办法用更少的 IO 口,实现更多按键的检测?这就是我们今天要讲的矩阵键盘,它的核心价值,就是用极少的 IO 口,实现大量按键的检测。
一、矩阵键盘的硬件结构
矩阵键盘的核心设计,是把按键排成N 行 M 列的矩阵结构,我们以最常用的 4×4 矩阵键盘(16 个按键)为例,结合原理图给大家讲清楚:
- 我们把横向的 4 根线叫做行线 (图中 P17、P16、P15、P14),纵向的 4 根线叫做列线(图中 P13、P12、P11、P10)。
- 每一个按键,都接在一根行线和一根列线的交叉点上:按键未按下时,行线和列线完全断开;按键按下时,对应的行线和列线就会直接导通。
这样设计的好处一目了然:4 行 4 列共 16 个按键,我们只需要4+4=8 个 IO 口就能完成全部检测,比独立按键节省了一半的 IO 口;如果是 8×8 的 64 键矩阵,仅需 16 个 IO 口,按键数量越多,IO 口的节省效果越夸张。
二、矩阵键盘核心工作原理:逐行 / 逐列扫描法
矩阵键盘的核心,就是逐行(逐列)扫描 ,我们先讲最通用、最好理解的逐行扫描法。
前置准备:IO 口模式配置
要实现扫描,我们先要给单片机 IO 口做固定配置,这是新手最容易忽略的关键点:
-
行线:配置为推挽输出模式,可以主动输出高 / 低电平;
-
列线:配置为上拉输入模式,默认状态下是高电平,只有被外部拉低时,才会变成低电平。
扫描的完整步骤(结合实例讲解)
我们以原理图中按键 5(第 2 行 P16、第 1 列 P13)按下为例,一步步拆解扫描的全过程,大家可以对着原理图同步理解。
第一步:预检测,快速判断有没有按键按下
先给所有行线,全部输出低电平,然后读取所有列线的电平:
-
如果没有任何按键按下:所有列线都是上拉的高电平,我们读到的列值全是 1,直接结束本轮扫描,不用做后续操作,提高程序效率;
-
如果有按键按下:按下的按键会把对应的行线(低电平)和列线导通,这根列线就会被拉成低电平,我们读到的列值就会出现 0,确认有按键按下,进入下一步定位。
第二步:逐行扫描,定位按键的精确位置
逐行扫描的核心逻辑:一次只拉低一根行线,其余所有行线全部输出高电平,然后依次读取每一列的电平。我们继续用按键 5 按下的场景演示:
-
先给第 1 行(P17)输出低电平,剩下的 P16、P15、P14 全部输出高电平,读取 4 根列线的电平:按键 5 在 P16 行,此时 P16 是高电平,就算按键按下,列线也不会被拉低,所以 4 根列线全是高电平,判定这一行没有按键按下。
-
接着给第 2 行(P16)输出低电平,剩下的 P17、P15、P14 全部输出高电平,再次读取列线电平:此时我们发现,第 1 列 P13 的电平变成了低电平,其他列还是高电平。这里我们就拿到了两个关键信息:输出低电平的行是 P16(第 2 行),读取到低电平的列是 P13(第 1 列)。
-
有了唯一的行号和列号,我们就能 100% 确定:按下的按键,就是第 2 行第 1 列的按键 5!
-
后续我们继续给第 3 行(P15)、第 4 行(P14)依次输出低电平,读取列线,完成一轮完整的扫描。
第三步:循环扫描,实现 "全按键实时检测"
我们只要让单片机快速循环执行上面的逐行扫描过程,就能实现所有按键的实时检测。这里有个很关键的点:人的按键动作是很慢的(按下到松开至少几十毫秒),只要单片机的扫描频率足够快,就能在你按下按键的瞬间,完成扫描定位,给人的体感就是 "所有按键都能同时被检测"。
补充:逐列扫描法
逐列扫描和逐行扫描是完全对称的逻辑:我们把列线配置为输出模式,行线配置为上拉输入模式,一次只拉低一根列线,其余列线输出高电平,然后读取每一行的电平,通过 "低电平的列号 + 低电平的行号",定位按下的按键,原理和逐行扫描完全一致。
我们讲代码写一下
三.矩阵按键代码
cpp
// ====================== 4x4矩阵键盘扫描函数 ======================
// 功能:扫描4x4矩阵键盘,返回按键值
// 返回值:按键编号(1-16),0表示无按键按下
// 键盘布局:
// P1_7-P1_4为行线(输出),P1_3-P1_0为列线(输入)
// 按键映射:1-9为数字,10=+, 11=-, 12==, 13=撤回, 14=×, 15=÷, 16=C(清除)
unsigned char Key()
{
unsigned char Number=0; // 按键编号,初始化为0(无按键)
// ====================== 扫描第一行(P1_7=0) ======================
P1=0XFF; // P1端口全部输出高电平
P1_7=0; // 第一行输出低电平
// 检测列线状态,判断是否有按键按下
if(P1_3==0){Delayms(20);if(P1_3==1){Delayms(20);Number=1;}} // 按键1:第一行第四列
if(P1_2==0){Delayms(20);if(P1_2==1){Delayms(20);Number=2;}} // 按键2:第一行第三列
if(P1_1==0){Delayms(20);if(P1_1==1){Delayms(20);Number=3;}} // 按键3:第一行第二列
if(P1_0==0){Delayms(20);if(P1_0==1){Delayms(20);Number=4;}} // 按键4:第一行第一列
// ====================== 扫描第二行(P1_6=0) ======================
P1=0XFF; // P1端口全部输出高电平
P1_6=0; // 第二行输出低电平
if(P1_3==0){Delayms(20);if(P1_3==1){Delayms(20);Number=5;}} // 按键5:第二行第四列
if(P1_2==0){Delayms(20);if(P1_2==1){Delayms(20);Number=6;}} // 按键6:第二行第三列
if(P1_1==0){Delayms(20);if(P1_1==1){Delayms(20);Number=7;}} // 按键7:第二行第二列
if(P1_0==0){Delayms(20);if(P1_0==1){Delayms(20);Number=8;}} // 按键8:第二行第一列
// ====================== 扫描第三行(P1_5=0) ======================
P1=0XFF; // P1端口全部输出高电平
P1_5=0; // 第三行输出低电平
if(P1_3==0){Delayms(20);if(P1_3==1){Delayms(20);Number=9;}} // 按键9:第三行第四列
if(P1_2==0){Delayms(20);if(P1_2==1){Delayms(20);Number=10;}} // 按键10(+):第三行第三列
if(P1_1==0){Delayms(20);if(P1_1==1){Delayms(20);Number=11;}} // 按键11(-):第三行第二列
if(P1_0==0){Delayms(20);if(P1_0==1){Delayms(20);Number=12;}} // 按键12(=):第三行第一列
// ====================== 扫描第四行(P1_4=0) ======================
P1=0XFF; // P1端口全部输出高电平
P1_4=0; // 第四行输出低电平
if(P1_3==0){Delayms(20);if(P1_3==1){Delayms(20);Number=13;}} // 按键13(撤回):第四行第四列
if(P1_2==0){Delayms(20);if(P1_2==1){Delayms(20);Number=14;}} // 按键14(×):第四行第三列
if(P1_1==0){Delayms(20);if(P1_1==1){Delayms(20);Number=15;}} // 按键15(÷):第四行第二列
if(P1_0==0){Delayms(20);if(P1_0==1){Delayms(20);Number=16;}} // 按键16(C):第四行第一列
return Number; // 返回按键编号
}
我们拿第一段进行讲解(注意 按键检测有更好的写法 这里是与江协保持一致)
- 准备工作:初始化端口状态
cppP1=0XFF; // P1端口全部输出高电平
作用:这是扫描前的 "清零 / 复位" 操作。
给 P1 口的所有 8 个引脚(P1.0 到 P1.7)全部写入高电平(1)。
目的是确保之前的扫描结果或残留电平不会干扰本次判断,让所有行线都回到 "未选中" 状态。
- 选中第一行:拉低行线
cppP1_7=0; // 第一行输出低电平
作用 :拉低第一行的行线(假设你的硬件连接是:行线接 P1.7-P1.4,列线接 P1.3-P1.0)。
这是 "逐行扫描" 的核心:一次只让一根行线为低电平(0),其他行线全为高电平(1)。
此时,如果第一行上有按键按下,那个按键就会把对应的列线 "短路" 到这个低电平上。
- 检测第一行的按键(以第一个
if为例,其余同理)我们以 "按键 1(第一行第四列)" 的检测代码为例进行拆解:
cpp// 检测列线 P1.3(第四列) if(P1_3==0) { Delayms(20); // 第1次延时 if(P1_3==1) { // 内部判断 Delayms(20); // 第2次延时 Number=1; // 赋值按键值 } }第一层:初步触发电平检测
cppif(P1_3==0) // 判断列线 P1.3 是否为低电平
作用:检查有没有按键把这根列线拉低。
因为我们刚才把第一行(P1.7)设为 0 了,如果 "第一行第四列" 的按键被按下,P1.3(列线)就会通过按键被强行拉到 GND,变成 0。
如果读到
P1_3 == 0,说明可能有按键按下了(但也可能是干扰或抖动)。第二层:软件消抖(关键步骤)
cppDelayms(20); // 延时 20 毫秒
作用 :机械按键消抖。
机械按键按下时,金属片会发生物理弹跳,导致电平在 0 和 1 之间快速跳变几次(约 5-20ms)。如果不延时直接读,单片机会误以为你按了很多次。
延时 20ms,就是为了等电平稳定下来。
第三层:逻辑陷阱(注意!这里有问题)
cppif(P1_3==1) // 再次判断列线 P1.3 是否为高电平
- 代码字面意思 :延时结束后,检查 P1.3 是否变回了 1。
cppDelayms(20); // 再延时 20 毫秒 Number=1; // 将变量 Number 赋值为 1
- 作用 :如果通过了上面的判断,就把变量
Number设为 1,代表 "按键 1 被按下了"。
我们将其封装成函数 按键读取的值返回。
模块二 : 数码管
视频([4-1] 静态数码管显示_哔哩哔哩_bilibili)

一、数码管的核心结构:8 个 LED 的组合
8 段数码管的本质,是8 个独立的发光二极管(LED)封装在一个器件里,通过控制不同 LED 的亮灭,组合出 0-9 的数字、小数点和少量简单字符。结合你图中的标注,8 个 LED 的定义如下:
- 7 个长条形 LED 组成数字 "8" 的笔画,分别命名为A、B、C、D、E、F、G 段,对应数字的 7 个笔画;
- 1 个圆形 LED 是DP 段,就是右下角的小数点;
- 加起来正好 8 个段,因此叫 "8 段数码管"。
类型 公共端定义 点亮逻辑 原理图对应 共阳极数码管 所有 LED 的正极(阳极)全部连在一起,作为公共端(COM 端) 公共端接高电平(VCC/5V),对应段的引脚给低电平(0),LED 点亮;给高电平(1),LED 熄灭 完全匹配,图中 3、8 脚是连在一起的公共阳极,A~DP 是各 LED 的阴极 共阴极数码管 所有 LED 的负极(阴极)全部连在一起,作为公共端(COM 端) 公共端接低电平(GND),对应段的引脚给高电平(1),LED 点亮;给低电平(0),LED 熄灭 与原理图逻辑完全相反
共阳极:低电平点亮,高电平熄灭;共阴极:高电平点亮,低电平熄灭,两者的显示段码完全相反。
二 显示数字
要显示一个数字,本质就是让对应笔画的 LED 点亮,不需要的笔画熄灭 。举个例子:要显示数字0,需要 A、B、C、D、E、F 段点亮,G 段和 DP 小数点熄灭。
显示数字 亮灯段 8 位二进制(DP G F E D C B A) 16 进制段码 0 A、B、C、D、E、F 1100 0000 0xC0 1 B、C 1111 1001 0xF9 2 A、B、G、E、D 1010 0100 0xA4 3 A、B、G、C、D 1011 0000 0xB0 4 F、G、B、C 1001 1001 0x99 5 A、F、G、C、D 1001 0010 0x92 6 A、F、G、C、D、E 1000 0010 0x82 7 A、B、C 1111 1000 0xF8 8 A、B、C、D、E、F、G 1000 0000 0x80 9 A、B、C、D、F、G 1001 0000 0x90 全灭 无 1111 1111 0xFF 仅小数点 DP 0111 1111 0x7F
补充:共阴极数码管的段码,就是上表的二进制数按位取反,比如数字 0 的段码是 0x3F,数字 1 是 0x06。
显示数字 亮灯段 8 位二进制(DP G F E D C B A) 16 进制段码 0 A、B、C、D、E、F 0011 1111 0x3F 1 B、C 0000 0110 0x06 2 A、B、G、E、D 0101 1011 0x5B 3 A、B、G、C、D 0100 1111 0x4F 4 F、G、B、C 0110 0110 0x66 5 A、F、G、C、D 0110 1101 0x6D 6 A、F、G、C、D、E 0111 1101 0x7D 7 A、B、C 0000 0111 0x07 8 A、B、C、D、E、F、G 0111 1111 0x7F 9 A、B、C、D、F、G 0110 1111 0x6F 全灭 无 0000 0000 0x00 仅小数点 DP 1000 0000 0x80
我们仿真里面用的数码管


7SEG-MPX2-CC-BLUE 器件
型号字段 含义说明 7SEG 7 段数码管(实际带小数点 DP,共 8 个发光段,行业通用简称 7 段) MPX2 Multiplex 2,即 **2 位一体、支持多路复用(动态扫描)** 的数码管 CC Common Cathode,共阴极类型 BLUE 数码管的发光颜色为蓝色
引脚分类 引脚名称 功能说明 位选脚(2 个) COM1(1 脚) 第 1 位(左位)数码管的公共阴极,拉低电平(0)时,该位被选通,允许点亮 COM2(2 脚) 第 2 位(右位)数码管的公共阴极,拉低电平(0)时,该位被选通,允许点亮 段选脚(8 个) a、b、c、d、e、f、g、dp 两个数码管共用的段选脚,高电平(1)时,对应段点亮(共阴极逻辑)
好了,那我们开始数码管的代码
三 代码片段
cpp
// ====================== 两位数码管显示函数 ======================
// 功能:在指定位置显示数字,支持小数点
// 参数:
// Location - 显示位置(1=十位,2=个位)
// Digital - 显示数字(0-9,10=E错误,11=熄灭)
// Dot - 小数点标志(1=点亮小数点,0=熄灭)
// 说明:使用共阴数码管,P0输出段码,P2控制位选
void Display(int Location, unsigned char Digital, bit Dot)
{
// 共阴数码管段码表:0-9、E(0x79)、熄灭(0x00)
// 每个段码对应数码管的a-g和dp段
unsigned char Num[]={0x3F,0x06,0x5B,0x4F,0x66,0x6D,0x7D,0x07,0x7F,0x6F,0x79,0x00};
unsigned char seg_code = Num[Digital]; // 获取对应数字的段码
// 点亮小数点(任意位有效,共阴:高电平点亮)
if(Dot)
{
seg_code |= 0x80; // 将最高位(小数点位)置1
}
// 位选控制(共阴:低电平点亮对应位)
switch(Location)
{
case 1: P2_0=0; P2_1=1; break; // 十位:P2_0=0选中
case 2: P2_0=1; P2_1=0; break; // 个位:P2_1=0选中
}
P0 = seg_code; // 输出段码到数码管
Delayms(1); // 动态扫描延时,保持显示
P0 = 0x00; // 消隐,防止鬼影
}
模块三 :计算器(10以内的计算)
1.输入第一位数值,在第一位上面显示,然后输入运算符号,数码管无变化,再输入第二位数字
第一位数值,移到第二位,第一位显示第二个数值
2.按下等号 保留一位小数。
3有撤销功能,比如 第一个输入3 再输入+号,此时按撤销键后,可以再输入符号如 --- 号。
代码如下
cpp
// ====================== 主函数:计算器逻辑 ======================
// 功能:实现两位数码管计算器,支持加减乘除运算
// 状态机设计:
// state=0:输入第一个操作数num1
// state=1:输入第二个操作数num2
// state=2:显示运算结果
void main()
{
unsigned char KeyNum; // 当前按键值
unsigned char num1 = 0; // 第一个操作数(0-9)
unsigned char num2 = 0; // 第二个操作数(0-9)
unsigned char op = 0; // 运算符:0=无,1=+,2=-,3=×,4=÷
float result = 0.0; // 运算结果(浮点数,支持小数)
unsigned char int_part = 0; // 结果的整数部分
unsigned char dec_part = 0; // 结果的小数部分(保留1位)
unsigned char state = 0; // 状态机:0=输入num1,1=输入num2,2=显示结果
unsigned char error_flag = 0;// 错误标志:1=负数/除零错误
while(1) // 主循环:持续扫描按键和更新显示
{
KeyNum = Key(); // 读取键盘按键值
// ====================== 按键处理逻辑 ======================
if(KeyNum != 0) // 有按键按下
{
// 清除键(C):重置所有状态
if(KeyNum == 16)
{
num1 = 0; // 清除第一个操作数
num2 = 0; // 清除第二个操作数
op = 0; // 清除运算符
result = 0.0; // 清除结果
int_part = 0; // 清除整数部分
dec_part = 0; // 清除小数部分
state = 0; // 返回初始状态
error_flag = 0; // 清除错误标志
}
// ====================== 状态0:输入第一个操作数 num1 ======================
else if(state == 0)
{
if(KeyNum >= 1 && KeyNum <= 9) num1 = KeyNum; // 输入数字1-9
else if(KeyNum == 13) num1 = 0; // 输入数字0
else if(KeyNum == 10) { op = 1; state = 1; } // 输入+号,进入状态1
else if(KeyNum == 11) { op = 2; state = 1; } // 输入-号,进入状态1
else if(KeyNum == 14) { op = 3; state = 1; } // 输入×号,进入状态1
else if(KeyNum == 15) { op = 4; state = 1; } // 输入÷号,进入状态1
}
// ====================== 状态1:输入第二个操作数 num2 ======================
else if(state == 1)
{
if(KeyNum==13)
{
state=0;//返回上一个状态 (输入运算符号)
}
else if(KeyNum >= 1 && KeyNum <= 9) num2 = KeyNum; // 输入数字1-9
else if(KeyNum == 12) // 等号=:执行运算
{
error_flag = 0; // 清除错误标志
// 根据运算符执行相应运算
switch(op)
{
case 1: result = num1 + num2; break; // 加法
case 2: if(num1 >= num2) result = num1 - num2; else error_flag = 1; break; // 减法(不允许负数)
case 3: result = num1 * num2; break; // 乘法
case 4: if(num2 == 0) error_flag = 1; else result = (float)num1 / num2; break; // 除法(不允许除零)
}
// 拆分整数和小数部分(四舍五入保留1位小数)
if(!error_flag)
{
int_part = (unsigned char)result; // 提取整数部分
dec_part = (unsigned char)((result - int_part) * 10 + 0.5); // 提取小数部分并四舍五入
if(dec_part >= 10) { dec_part = 0; int_part++; } // 处理进位(如9.95→10.0)
}
state = 2; // 进入结果显示状态
}
}
}
// ====================== 两位数码管显示逻辑 ======================
if(state == 0) // 状态0:显示第一个操作数
{
Display(1, 0, 0); // 十位:显示0
Display(2, num1, 0); // 个位:显示num1(无小数点)
}
else if(state == 1) // 状态1:显示第二个操作数
{
Display(1, num1, 0); // 十位:显示0
Display(2, num2, 0); // 个位:显示num2(无小数点)
}
else if(state == 2) // 状态2:显示运算结果
{
if(error_flag) // 有错误:显示错误提示
{
Display(1, 10, 0); // 十位:显示E(Error)
Display(2, 0, 0); // 个位:显示0
}
else // 无错误:显示运算结果
{
if(dec_part == 0) // 结果为整数
{
if(int_part < 10) // 整数部分小于10:只显示个位
{
Display(1, 11, 0); // 十位:熄灭
Display(2, int_part, 0); // 个位:显示整数部分(无小数点)
}
else // 整数部分大于等于10:显示两位
{
Display(1, int_part / 10, 0); // 十位:显示十位数
Display(2, int_part % 10, 0); // 个位:显示个位数
}
}
else // 结果有小数
{
if(int_part < 10) // 整数部分小于10:显示格式"X.X"
{
Display(1, int_part, 1); // 十位:显示整数部分(带小数点)
Display(2, dec_part, 0); // 个位:显示小数部分(不带小数点)
}
else // 整数部分大于等于10:显示格式"XX."(小数部分被截断)
{
Display(1, int_part / 10, 0); // 十位:显示十位数
Display(2, int_part % 10, 1); // 个位:显示个位数(带小数点)
}
}
}
}
}
}
好了
这就本次的内容
如果你喜欢,可以给我一个免费的赞和收藏,非常感谢。