写在前面
上一篇刚从点亮第一盏 LED 开始,完成了 LED 常亮、闪烁、流水灯和渐变加速几个基础实验。这次继续往前走了一步:把“输入”加进来。
前面都是单片机按照提前写好的程序自动控制 LED,而加入独立按键之后,程序开始能够根据人的操作做出反应:按下按键 → 单片机读取 IO 电平 → 执行对应逻辑 → LED 状态发生变化。
这次一共完成了四个练习:
-
3-1 独立按键控制 LED 亮灭
-
3-2 独立按键控制 LED 状态切换
-
3-3 独立按键控制 LED 显示二进制
-
3-4 两个独立按键控制 LED 左右移位
虽然表面上是在学单片机按键,但真正写下来会发现,代码里的大部分内容仍然是之前学过的 C 语言基础:if 条件判断、while 循环、变量自增自减、函数封装、位运算、移位运算。
只不过以前这些语法最终改变的是控制台里的数字,现在改变的是实际 LED 的亮灭位置。
而这次也踩到了一个非常典型的“代码复用 Bug”:复制上一段按键代码以后,只改了一部分端口号,忘记把 while(P3_1==0) 改成 while(P3_0==0),结果按一下 P3_0 后 LED 就开始不停变化。
这个问题也算给我提了个醒:复制代码很快,但复制之后逐行检查变量和端口更重要。
本篇源码仓库
嵌入式-单片机/C51 · Luminous/Code_2026 - 码云 - 开源中国
一、3-1 独立按键控制 LED 亮灭
第一个实验非常直接:按住按键,LED 点亮;松开按键,LED 熄灭。
代码:
#include <REGX52.H>
int main()
{
while(1)
{
if(P3_1==0)
{
P2_0=0;
}
else
{
P2_0=1;
}
}
}
这里程序不断读取 P3_1 的电平状态。当检测到:
P3_1 == 0
时,将:
P2_0 = 0;
对应 LED 点亮;否则:
P2_0 = 1;
LED 熄灭。整个判断一直放在 while(1) 中循环执行,因此 LED 状态会实时跟随按键状态变化。
这一版其实和最基础的 C 语言:
if(...)
{
}
else
{
}
没有本质区别。
真正不同的是:if 判断的不再是普通变量,而是单片机 IO 引脚上的实际电平。
所以第一个实验更像是把之前学过的条件判断第一次真正接到了硬件上。
二、3-2 从“按住才亮”到“按一下切换一次状态”
第一个实验有一个很明显的特点:手一直按着,灯才保持亮;手一松开,灯马上熄灭。
但实际很多按键功能更接近开关:
-
第一次按下 → LED 亮
-
第二次按下 → LED 灭
-
第三次按下 → LED 又亮
也就是每按一次,状态翻转一次。
完整代码:
#include <REGX52.H>
#include <INTRINS.H>
void Delay(unsigned int xms) //@11.0592MHz
{
unsigned char data i, j;
while(xms)
{
_nop_();
i = 2;
j = 199;
do
{
while (--j);
} while (--i);
xms--;
}
}
int main()
{
while(1)
{
if(P3_1==0)
{
Delay(20);
while(P3_1==0);
Delay(20);
P2_0=~P2_0;
}
}
}
这一版最大的变化,是加入了完整的一次“按下—松开”判断流程:
Delay(20);
while(P3_1==0);
Delay(20);
以及最后的:
P2_0=~P2_0;
代码会等待按键释放后再执行状态翻转,因此一次完整按键动作只切换一次 LED 状态。
2.1 为什么前后各有一个 Delay(20)?
机械按键并不是理想的数字开关,真实按键在刚按下或刚松开的一瞬间,触点可能会在很短时间内反复接触和断开,这种现象叫做:按键抖动。
所以代码中的第一次:
Delay(20);
主要用于过滤按下时的抖动。
然后:
while(P3_1==0);
表示:只要按键还没有松开,就一直在这里等待。
最后再:
Delay(20);
过滤松手时可能产生的抖动。
于是一次按键处理可以概括成:
检测按下
↓
延时消抖
↓
等待松手
↓
再次消抖
↓
执行一次功能
这也是后面几个按键实验一直重复使用的基本结构。
三、3-3 独立按键控制 LED 显示二进制
第三个实验开始不再只控制一个 LED,而是引入一个变量:
unsigned char LEDNum = 0;
每按一次按键:
LEDNum++;
然后直接把这个数字送到 P2:
P2=~LEDNum;
完整代码:
#include <REGX52.H>
#include <INTRINS.H>
void Delay(unsigned int xms) //@11.0592MHz
{
unsigned char data i, j;
while(xms)
{
_nop_();
i = 2;
j = 199;
do
{
while (--j);
} while (--i);
xms--;
}
}
int main()
{
unsigned char LEDNum = 0;
while(1)
{
if(P3_1==0)
{
Delay(20);
while(P3_1==0);
Delay(20);
LEDNum++;
P2=~LEDNum;
}
}
}
这里每完成一次按键动作,LEDNum 就增加 1,再通过 P2=~LEDNum 把它对应到 8 个 LED 上。
例如:
LEDNum = 1
二进制:0000 0001
LEDNum = 2
二进制:0000 0010
LEDNum = 3
二进制:0000 0011
由于当前开发板上的 LED 是低电平点亮,因此真正输出到 P2 时需要进行按位取反:
~LEDNum
这样 LED 的亮灭状态就能够直观表现变量的二进制形式。
3.1 这里又把 C 语言基础重新用了一遍
这一部分最有意思的是:
LEDNum++;
以前学自增运算时,它只是:一个变量增加 1。
到了单片机这里:一个变量增加 1 → 二进制发生变化 → IO 口电平发生变化 → 真实 LED 组合跟着变化。
所以很多以前感觉比较抽象的二进制和位运算,在单片机上反而更加直观。
特别是:
unsigned char
本身就是一个 8 位无符号数据类型,而 P2 同样对应 8 个 IO 位,两者天然就很适合放在一起观察。
四、3-4 两个独立按键控制 LED 左右移位
第四个实验是在前面流水灯和独立按键的基础上继续组合。
目标是:
-
P3_1:控制 LED 向一个方向移动 -
P3_0:控制 LED 向另一个方向移动
同时使用:
LEDNum
保存当前 LED 所在的位置。
最终代码:
#include <REGX52.H>
#include <INTRINS.H>
void Delay(unsigned int xms) //@11.0592MHz
{
unsigned char data i, j;
while(xms)
{
_nop_();
i = 2;
j = 199;
do
{
while (--j);
} while (--i);
xms--;
}
}
unsigned char LEDNum = 0;
int main()
{
P2=~0x01;
while(1)
{
if(P3_1==0)
{
Delay(20);
while(P3_1==0);
Delay(20);
LEDNum++;
if(LEDNum>=8)
{
LEDNum=0;
}
P2=~(0x01<<LEDNum);
}
if(P3_0==0)
{
Delay(20);
while(P3_0==0);
Delay(20);
if(LEDNum==0)
{
LEDNum=7;
}
else
{
LEDNum--;
}
P2=~(0x01<<LEDNum);
}
}
}
程序一开始先执行:
P2=~0x01;
让第一盏 LED 作为初始位置亮起。随后 P3_1 分支通过 LEDNum++ 向前移动,并在达到 8 时重新回到 0;P3_0 分支则使用 LEDNum-- 向反方向移动,并通过 LEDNum==0 的判断避免无符号变量直接向下越界。最终统一使用:
P2=~(0x01<<LEDNum);
控制对应位置的 LED。
独立按键控制 LED 显示二进制演示
五、这一句代码到底在干什么?
第四个实验中最核心的一行应该就是:
P2=~(0x01<<LEDNum);
先看:
0x01
对应:
0000 0001
如果:
LEDNum = 1
那么:
0000 0001 << 1
=
0000 0010
如果:
LEDNum = 2
则:
0000 0100
继续增加:
0000 1000
0001 0000
0010 0000
0100 0000
1000 0000
也就是说:
0x01 << LEDNum
本质上就是让唯一的那个 1 不断在 8 位数据中移动。
而由于当前 LED 为低电平点亮,所以最后再:
~
按位取反。
例如:
0000 0100
取反后:
1111 1011
于是对应位置变成唯一的 0,这颗 LED 就被点亮。
所以以前 C 语言里学过的:左移运算 <<;
到了这里直接变成:让真实的灯向旁边移动一格。
六、这次真正踩到的 Bug:复制代码忘记改 P3_1
第四个实验也是这次最值得记录的一个错误,因为前面的 P3_1 按键逻辑已经写好了,所以在添加 P3_0 时,我直接复制了一份原来的代码,再修改成反方向移动。
最开始写成了类似这样:
if(P3_0==0)
{
Delay(20);
while(P3_1==0); // 错误:这里忘记改成P3_0
Delay(20);
if(LEDNum==0)
{
LEDNum=7;
}
else
{
LEDNum--;
}
P2=~(0x01<<LEDNum);
}
表面上看,两块代码几乎完全一样,很容易一眼扫过去没发现问题。
但实际上:
if(P3_0==0)
检测的是 P3_0。
后面等待松手时却写成:
while(P3_1==0);
检测的变成了另外一个按键。
6.1 为什么按一下以后 LED 就不停变化?
假设我此时按住的是:
P3_0
于是:
if(P3_0==0)
成立,程序进入分支。
经过:
Delay(20);
之后,本来应该:
while(P3_0==0);
等待我真正松开这个按键。
但代码写成了:
while(P3_1==0);
而 P3_1 此时根本没有被按下,通常并不等于 0,所以这个 while 根本不会等待,程序直接继续执行下面的:
LEDNum--;
P2=~(0x01<<LEDNum);
执行完成以后重新回到最外层:
while(1)
但此时我的手还没有松开 P3_0。
于是:
P3_0==0
又成立。
程序再次进入。
整个过程就变成:
P3_0仍然按着
↓
进入if
↓
错误检查P3_1
↓
没有等待松手
↓
LED移动一次
↓
重新循环
↓
P3_0仍然为0
↓
再移动一次
↓
……
于是原本应该:按一下移动一格,
最终变成:按一下之后快速连续移动,看起来像一直闪。
6.2 修复其实只有一个字符
把:
while(P3_1==0);
改成:
while(P3_0==0);
问题就解决了。
最终正确版本也是如此:P3_1 分支等待 P3_1 松开,而 P3_0 分支等待 P3_0 松开。
这个 Bug 并不是什么复杂的指针错误,也没有涉及寄存器底层原理。纯粹就是:复制代码以后漏改了一个变量。但也正因为简单,反而很典型。
七、从这个 Bug 学到的:代码复用不是复制完就结束
之前写数据结构时,其实也遇到过不少类似问题:
-
函数复制后变量没有全部替换;
-
指针名字改了一半;
-
测试代码复用时传错参数。
这次到了单片机里,同一种问题又出现了。
所以以后再复制结构相似的代码时,我准备至少检查三个地方:
-
判断条件用的是不是当前对象
-
循环内部引用的变量是否全部替换
-
最后修改的目标是不是当前功能对应的变量或端口
尤其是这种:
P3_1
P3_0
只差一个数字的名字,肉眼确实很容易漏过去。
八、四个实验放在一起看
这次四个实验其实是一层一层加东西:
| 实验 | 功能 | 核心知识 |
|---|---|---|
| 3-1 按键控制亮灭 | 按住亮,松开灭 | if/else、IO 输入 |
| 3-2 按键切换状态 | 按一次切换一次 | 按键消抖、等待松手、取反 |
| 3-3 二进制显示 | 每按一次数字 +1 | 自增、8 位变量、二进制 |
| 3-4 LED 左右移位 | 两个按键控制方向 | ++/--、边界判断、<< 移位 |
从 C 语言角度来看,其实没有出现多少全新的语法。
但以前学过的:
if / else
while
++
--
~
<<
函数参数
unsigned char
现在全部开始和硬件产生联系,这也是我目前学 51 最大的感受:单片机入门阶段很多时候并不是在重新学习一门语言,而是在学习怎样把已经掌握的 C 语言映射到硬件行为上。
九、写在最后
上一篇还是让程序自己控制 LED:
亮
↓
灭
↓
流水
这一篇加入按键之后,终于开始有了一点真正的“交互”。
人按下按键,单片机读取电平,再根据程序决定:
-
LED 是否点亮;
-
是否切换状态;
-
当前数字是多少;
-
下一盏灯应该往哪个方向移动。
代码复杂度仍然很低,但它已经从:单片机自己运行,变成:单片机开始响应外部输入。
而且这一节学下来,我感觉很多内容其实还是 C 语言基础的继续应用。尤其是第四个实验:
P2=~(0x01<<LEDNum);
把位运算和真实 LED 的移动直接联系在一起之后,之前学过的移位运算一下就直观了很多。
当然,这次最深刻的可能还是那个:
while(P3_1==0);
复制代码忘记改端口号的小 Bug,改起来只有一行,但排查之后让我再次意识到:程序出问题不一定是不会,有时候只是眼睛把“看起来一样”的代码自动忽略了。
目前独立按键这一部分算是正式开始了。后面继续往下学时,应该还会接触数码管、定时器以及更多外设。
从点亮 LED,到按键真正控制 LED,硬件这条线也开始一点一点连起来了。
-独立按键控制LED:从亮灭、状态切换到二进制显示与左右移位&spm=1001.2101.3001.5002&articleId=163735769&d=1&t=3&u=2baf4a9f55f342698d1af759bf5dcf14)
989

被折叠的 条评论
为什么被折叠?



