1. 这段代码在做什么
这是江科大 51 单片机课程里最经典的入门实验:用 P2 口控制 8 个 LED,做出"流水灯"效果------灯从 P2.0 到 P2.7 依次点亮,再折返回来,循环往复。
整段代码其实包含三部分:
Delay():软件延时函数,用来控制灯亮灭的速度;- 被注释掉的
main():直接法,一个个写死 P2 的值; - 真正运行的
main():循环法,用移位运算自动生成 P2 的值。
小白看这段代码,最容易卡在"为什么 P2 赋值是 0xfe、0xfd 这些奇怪数字"以及"~(0x01<<i) 到底算出了什么"。下面逐个拆开讲。
2. 前置知识:LED 是低电平点亮
开发板上的 LED 一般是这样接的:
text
VCC ── 电阻 ── LED ── P2.x 引脚
也就是说,引脚输出低电平(0)时灯亮,输出高电平(1)时灯灭。
所以想让"第 0 个灯亮、其余灭",P2 应该是:
text
P2.7 P2.6 P2.5 P2.4 P2.3 P2.2 P2.1 P2.0
1 1 1 1 1 1 1 0
写成二进制就是 1111 1110,也就是十六进制的 0xFE。这就是代码里那些"奇怪数字"的来源。
3. Delay 函数:软件延时
c
void Delay(unsigned int xms)
{
unsigned char i,j;
while(xms--)
{
i=2;
j=199;
do
{
while(--j);
}
while(--i);
}
}
逐行理解:
while(xms--):外层循环,xms是"要延时多少毫秒"的近似值。注意xms--是先用后减,所以传入 500 就循环 500 次。i=2; j=199;:内层两层空循环的初值。while(--j);和while(--i);后面都是分号,表示循环体为空,纯粹靠 CPU 空转消耗时间。- 内层
do...while(--i)一共跑 2 轮,每轮while(--j)跑 199 次,所以一次外层循环大约消耗2 × 199次自减操作。
小提示:这种延时不精确,换晶振频率、换编译器优化等级,实际时间都会变。它只是"够用",课程里用它是因为简单直观。真正精确的延时要用定时器。
4. 直接法:把每个状态写死
被注释掉的那段 main() 就是直接法:
c
P2 = 0xfe; //1111 1110 P2.0 亮
Delay(500);
P2 = 0xfd; //1111 1101 P2.1 亮
Delay(500);
P2 = 0xfb; //1111 1011 P2.2 亮
Delay(500);
P2 = 0xf7; //1111 0111 P2.3 亮
Delay(500);
P2 = 0xef; //1110 1111 P2.4 亮
Delay(500);
P2 = 0xdf; //1101 1111 P2.5 亮
Delay(500);
P2 = 0xbf; //1011 1111 P2.6 亮
Delay(500);
P2 = 0x7f; //0111 1111 P2.7 亮
Delay(500);
把注释里的二进制竖着看,会发现那个 0 在从右往左移动:
text
1111 1110
1111 1101
1111 1011
1111 0111
1110 1111
1101 1111
1011 1111
0111 1111
这就是"流水"的本质:一个 0 在 8 位里逐位左移。
直接法的问题很明显:8 个灯要写 8 行,如果板子有 16 个灯就要写 16 行,而且一旦想改方向、改速度,全得手改。所以有了循环法。
5. 循环法:用移位运算自动生成
c
void main()
{
int i;
while(1)
{
for(i=0;i<8;i++)
{
P2 = ~(0x01<<i);
Delay(500);
}
for(i=0;i<6;i++) //去掉两次重复的头尾
{
P2 = ~(0x40>>i);
Delay(500);
}
}
}
5.1 第一段:正向流水
c
P2 = ~(0x01<<i);
拆成两步看:
0x01 << i:把0000 0001左移 i 位。i=0 时是0000 0001,i=3 时是0000 1000。~:按位取反。0000 0001取反变成1111 1110,正好就是0xFE。
所以 i 从 0 到 7,P2 依次得到:
| i | 0x01<<i |
~(0x01<<i) |
点亮的灯 |
|---|---|---|---|
| 0 | 0000 0001 | 1111 1110 | P2.0 |
| 1 | 0000 0010 | 1111 1101 | P2.1 |
| 2 | 0000 0100 | 1111 1011 | P2.2 |
| 3 | 0000 1000 | 1111 0111 | P2.3 |
| 4 | 0001 0000 | 1110 1111 | P2.4 |
| 5 | 0010 0000 | 1101 1111 | P2.5 |
| 6 | 0100 0000 | 1011 1111 | P2.6 |
| 7 | 1000 0000 | 0111 1111 | P2.7 |
和直接法那 8 行完全一致,但只用了 1 行代码。
5.2 第二段:反向流水
c
P2 = ~(0x40>>i);
0x40 是 0100 0000,右移 i 位再取反:
| i | 0x40>>i |
~(0x40>>i) |
点亮的灯 |
|---|---|---|---|
| 0 | 0100 0000 | 1011 1111 | P2.6 |
| 1 | 0010 0000 | 1101 1111 | P2.5 |
| 2 | 0001 0000 | 1110 1111 | P2.4 |
| 3 | 0000 1000 | 1111 0111 | P2.3 |
| 4 | 0000 0100 | 1111 1011 | P2.2 |
| 5 | 0000 0010 | 1111 1101 | P2.1 |
灯从 P2.6 一路回到 P2.1,形成"折返"。
5.3 为什么是 0x40 和 i<6?
这是小白最容易疑惑的地方,注释里写的"去掉两次重复的头尾"就是这个意思。
第一段结束时,灯停在 P2.7 (i=7)。如果第二段从 P2.7 开始,就会连续两次点亮 P2.7 ,看起来像卡了一下。所以第二段从 P2.6 开始,0x40 对应的正是 P2.6。
同理,第二段如果走到 P2.0,下一轮第一段又从 P2.0 开始,又会重复。所以第二段只走到 P2.1 就停,也就是 i 只取 0~5 共 6 次。
text
P2.0 → P2.1 → ... → P2.7 → P2.6 → ... → P2.1 → P2.0 → ...
↑ 正向 8 次 ↑ 反向 6 次
这样首尾衔接,视觉上就是一个平滑的来回流水。
6. 几个容易踩的坑
~和!别搞混 :~是按位取反(~0x01 = 0xFE),!是逻辑非(!0x01 = 0)。这里必须用~。<<和>>的位数 :0x01<<7是0x80,没问题;但如果继续<<8,在 8 位机上会溢出回 0,所以循环边界要卡准。int i与unsigned char i:这里用int是为了和for循环比较时不出意外,实际 51 上int是 16 位,够用。while(1)不能少:单片机程序没有"退出"的概念,主循环必须死循环,否则程序跑飞。- 延时时间 :
Delay(500)并不是精确 500ms,实际值取决于晶振(常见 11.0592MHz 或 12MHz)和编译器。想精确就用定时器中断。
7. 小结
| 写法 | 代码量 | 可读性 | 扩展性 |
|---|---|---|---|
| 直接法 | 8 行赋值 | 直观,适合理解原理 | 差,改灯数要重写 |
| 循环法 | 2 个 for | 需要理解移位和取反 | 好,改边界即可 |
学习路径建议:先看懂直接法里那个 0 是怎么移动的,再理解循环法如何用 <<、>>、~ 把同样的过程自动化。这两步走通,后面学数码管、矩阵键盘的动态扫描,思路是一模一样的------都是"用位运算生成端口值 + 延时切换"。