点亮流水灯

Tutorial: 电子电路 Category: 嵌入式 Published: 2026-08-29 08:09:36 Views: 1 Likes: 0 Comments: 0

032.2.2 点亮流水灯

#include "reg52.h"
#include <intrins.h>

sbit LED1 = P1^0;
sbit LED2 = P1^1;
sbit LED3 = P1^2;
sbit LED4 = P1^3;

int i;

void Delay1000ms(void) //@11.0592MHz
{
    unsigned char data i, j, k;

    _nop_();
    i = 8;
    j = 1;
    k = 243;
    do
    {
        do
        {
            while (--k);
        } while (--j);
    } while (--i);
}

void main()
{
    while(1)
    {
//      P1 = 0xfe; //11111110
//      Delay1000ms();
//      P1 = 0xfd; //11111101
//      Delay1000ms();
//      P1 = 0xfb; //11111011
//      Delay1000ms();
//      P1 = 0xf7; //11110111
//      Delay1000ms();

//      P1 = 0xff;
//      Delay1000ms();
//      for(i=0; i<4; i++)
//      {
//          P1 = P1 << 1;
//          Delay1000ms();
//      }

//      LED1 = 0;
//      LED3 = 0;
//      Delay1000ms();
//      LED1 = 1;
//      LED3 = 1;
//      Delay1000ms();

        LED2 = ~LED2;
        LED4 = ~LED4;
        Delay1000ms();
    }
}

一、搜索C206问题

  • 上一节视频结尾的 Keil Build Output 实际包含两条相关诊断:

    warning C206: '_nop_': missing function-prototype
    error C264: intrinsic '_nop_': declaration/activation error
    
  • C206 是“缺少函数原型”的警告,真正使构建失败的是随后出现的 C264 错误。_nop_() 已出现在延时函数中,但编译器在处理调用之前没有看到它的声明,因此不知道应如何正确生成这条内建指令。

  • 搜索编译错误时,应复制 Build Output 中最能描述问题的部分,例如:Keil C51 C206 _nop_ missing function-prototype。文件名 main.c 和具体行号会随工程变化,可以删除;但编译器名称、错误编号和函数名通常很有价值,不必一律删掉。

  • 搜索结果只能作为线索。较可靠的判断顺序是:先读完整错误上下文,再看代码所在行和已包含的头文件,然后查编译器帮助、官方头文件或器件手册,最后再参考论坛答案。不要只复制第一段搜索结果就直接修改工程。

  • 英文报错可借助翻译工具,但要结合 C 语言语境理解:这里的 prototype 是“函数原型/声明”,不是普通意义上的产品原型。

二、添加头文件解决编译错误

  • _nop_() 是 Keil C51 提供的 intrinsic(编译器内建函数),用于生成一条 NOP(No Operation,空操作)机器指令。它不是一个什么都不做却需要普通函数调用开销的 C 函数,而是由编译器直接转换为目标指令。
  • Keil 的 intrins.h 中声明了:extern void _nop_(void);。在源文件顶部加入 #include <intrins.h> 后,编译器便能识别 _nop_(),C206/C264 随之消失。
  • NOP 不改变普通运算结果,但会占用指令执行时间,可用于微小延时、对齐或满足硬件时序。它到底对应多长时间取决于晶振频率和 MCU 的机器周期设置,不能简单理解为固定 1µs。
  • reg52.hintrins.h 作用不同:前者声明 8051/8052 的特殊功能寄存器和可位寻址名称,后者声明 _nop_、循环移位等编译器内建函数。本节源码两者都要包含。
  • 选中 <intrins.h> 后使用 Keil 的 Open 功能,可以在头文件中看到 _nop_ 原型;Go To 无法跳转并不代表声明不存在,可能只是工程浏览信息或索引尚未生成。
  • 修复后应重新 Build,确认 0 Error(s),再检查 HEX 文件更新时间并重新下载。视频中四个 LED 随后每隔约 1 秒依次点亮,说明“编译、生成新 HEX、烧录、硬件运行”整条链路均已成功。

三、移位操作

  • 移位是对二进制位的位置进行整体移动。左移运算符是 <<,右移运算符是 >>。本节通过反复左移 P1,使低位不断补 0;由于板载 LED 低电平有效,变成 0 的位对应 LED 点亮。

3.1 移位操作的概念

  • 对无符号数左移一位时,所有位向高位方向移动,超出数据宽度的高位丢弃,最低位补 0。在不溢出的理想数学模型中,左移一位相当于乘 2。
  • 对无符号数右移一位时,所有位向低位方向移动,最低位丢弃,最高位补 0;右移有符号负数时最高位如何填充与实现有关,初学阶段应优先用无符号类型练习位运算。
  • P1 是 8 位特殊功能寄存器,只能保存最低 8 位。C 表达式计算时还存在整数提升:P1 << 1 通常先按 int 计算,再赋回 P1 并截断到 8 位。因此“0xFF 左移后等于 0xFE”不是完整数学等式,而是左移结果写回 8 位寄存器后的结果
0x00FF << 1 = 0x01FE       C 表达式的中间结果
                   │
                   └── 赋给 8 位 P1,只保留低 8 位
                                ▼
                              0xFE

3.2 移位操作示例

  • 从 8 位视角观察 0xFF 连续左移并写回 P1,可得到下面的序列:
左移次数P1 的 8 位二进制值P1 十六进制值低位变化
01111 11110xFF尚无 0
11111 11100xFEbit0 补 0
21111 11000xFCbit1~bit0 为 0
31111 10000xF8bit2~bit0 为 0
41111 00000xF0bit3~bit0 为 0
51110 00000xE0bit4~bit0 为 0
61100 00000xC0bit5~bit0 为 0
71000 00000x80bit6~bit0 为 0
80000 00000x008 位全部为 0
  • 每次执行 P1 = P1 << 1;,都会先读取当前 P1 值、左移一位,再把低 8 位写回 P1。它与始终拿原始 0xFF 分别计算 0xFF << n 的书写方式不同,但在本例低 8 位序列上结果一致。
  • 可简写为 P1 <<= 1;,但简写并不会降低风险:它仍然会修改 P1 的全部 8 位。

3.3 例题1: 通过移位操作改变P1的值

  • 视频先选中四条直接赋值语句并使用 Keil 注释快捷按钮,使它们变成 // 注释;这些语句仅作为写法示例保留,被注释后不会参与编译。

  • 随后在 while (1) 中先写 P1 = 0xFF;,再通过 for (i = 0; i < 4; i++) 重复左移和延时:

    P1 = 0xff;
    Delay1000ms();
    for (i = 0; i < 4; i++)
    {
        P1 = P1 << 1;
        Delay1000ms();
    }
    
  • 配套源码在文件顶部使用 int i;。Keil C51 中 int 通常为 16 位、占 2 字节;但 ISO C 并不规定 int 必须是 16 位,在电脑编译器中常见 32 位。本循环只需要 0~3,使用 unsigned char i 也足够且更节省 8051 RAM。

  • i++ 在每轮结束后加 1,i < 4 控制总共执行 4 次;它控制的是移位次数,不是直接代表某一盏灯。

3.4 移位操作与延时循环的结合

  • P1 = 0xFF 时,P1.0~P1.3 全为 1,四灯全灭。此后每轮左移增加一个低电平位,四盏灯按“累计点亮”方式变化:
P1=FF:○ ○ ○ ○   四灯全灭
   左移 1 位
P1=FE:● ○ ○ ○   LED1 亮
   左移 1 位
P1=FC:● ● ○ ○   LED1、LED2 亮
   左移 1 位
P1=F8:● ● ● ○   LED1~LED3 亮
   左移 1 位
P1=F0:● ● ● ●   四灯全亮

● = 低电平点亮,○ = 高电平熄灭
  • 延时必须放在希望人眼看到的状态之后。若只在移位后延时而 P1 = 0xFF 后没有延时,程序会极快进入第一次移位,人眼几乎看不到“全灭”阶段。

3.5 例题2: 移位操作实现LED灯的逐个点亮

  • 视频中的“逐个点亮”是指每次多亮一盏灯,即 LED1 → LED1+LED2 → LED1+LED2+LED3 → 四灯全亮;它不是上一节“始终只有一盏灯在移动”的单点流水效果。
  • 若要始终只点亮一盏灯,应使用 0xFE、0xFD、0xFB、0xF7 四个模式,或对单个 0 位做循环移位/查表,不能直接用本节的连续左移累积 0。
  • 第四次左移后 P1 为 0xF0。下一轮 while (1) 再写入 0xFF,四灯恢复全灭,然后重新开始。

3.6 移位操作中的问题与解决

  • 初次实验看不到全灭阶段,是因为 P1 = 0xFF 到第一次左移之间只有极短的指令执行时间。解决方法是在 P1 = 0xFF; 后增加 Delay1000ms();,让全灭状态保持约 1 秒。
  • 修改后应重新编译,确认 HEX 时间戳,再选择正确串口进行下载。视频中可观察到“全灭 → 亮一盏 → 亮两盏 → 亮三盏 → 四灯全亮”的完整周期。
  • 寄存器有 8 位,不代表为了实验就应该盲目移动 8 次。移位次数必须同时考虑 P1.4~P1.7 连接了什么硬件,否则会产生 LED 之外的副作用。

3.7 移位操作的总结与扩展

  • 左移的核心规则是“向高位移动、低位补 0、超出保存宽度的高位丢弃”。连续左移 8 次并写回 8 位 P1 后,结果为 0x00
  • 对一个宽度为 W 的 C 整数表达式,移位次数大于或等于 W 会产生未定义行为。这里 P1 会先发生整数提升,而循环每次只移 1 位并立即写回,避免了直接写 P1 << 16 之类的错误,但仍应养成检查类型宽度的习惯。
  • 移位运算改变的是数值的所有相关位。用于硬件寄存器时,每一位都可能连接真实外设,必须把二进制结果与原理图逐位对照,而不能只看十六进制数是否“算对”。

四、移位操作知识拓展

  • 本板 P1 的 8 个端口位并不只连接四个 LED。正式原理图显示:
P1 位网络/外设有效逻辑连续左移的影响
P1.0LED1低电平点亮第 1 次左移后亮
P1.1LED2低电平点亮第 2 次左移后亮
P1.2LED3低电平点亮第 3 次左移后亮
P1.3LED4低电平点亮第 4 次左移后亮
P1.4OLED SDAI²C 数据线第 5 次左移后被拉低
P1.5OLED SCLI²C 时钟线第 6 次左移后被拉低
P1.6蜂鸣器 Q1 控制端低电平使 Q1 导通第 7 次左移后蜂鸣器响
P1.7排针引出/普通端口取决于外接电路第 8 次左移后被拉低
  • 因此把循环上限从 4 改成 8 后,前四次 LED 现象仍正常;第五、六次会改变 OLED 总线;第七次使 P1.6 变为 0,蜂鸣器开始响;第八次则把 P1 全部写成 0。视频中听到蜂鸣器并不是程序“莫名出错”,而是整个 P1 寄存器被正确写成了会启动蜂鸣器的值。
  • 蜂鸣器不能由 P1.6 直接承担主电流,原理图使用 Q1(S9012,PNP 三极管)作为高边开关:发射极接 +5V,集电极接 BELL1,基极通过 R3(1kΩ)接 P1.6,蜂鸣器另一端接 GND。
                              Q1:S9012(PNP 高边开关)
                         E
+5V ─────────────────────┤
                         │\
P1.6 ───── R3 1kΩ ───────┤ >──── C ───► BELL1 ───► GND
                         │/

P1.6 = 1:基极接近 +5V,VEB 接近 0     → Q1 截止 → 蜂鸣器不响
P1.6 = 0:基极电位被拉低,PNP 结正向偏置 → Q1 导通 → 蜂鸣器响
  • P1.6 为低电平时,较小的基极电流经 +5V → Q1 发射结 → R3 → P1.6 流入 MCU;Q1 导通后的蜂鸣器主电流走 +5V → Q1 → BELL1 → GND。R3 限制基极电流,Q1 让 MCU 引脚只负责控制而不直接驱动负载。
  • 这说明调试嵌入式异常必须联合分析:软件层面列出寄存器每一步的二进制值,硬件层面再查询每一位连接的网络和有效电平。
P1 = 0x80 = 1000 0000
                    │
                    ├── P1.0~P1.3 = 0:四个 LED 全亮
                    ├── P1.4/P1.5 = 0:OLED SDA/SCL 被拉低
                    └── P1.6 = 0:蜂鸣器导通
  • 若只想控制 LED,稳妥方法是写入明确的 LED 模式,并保持 P1.4~P1.7 为 1;更复杂程序应维护 P1 的影子变量或使用位操作,避免一次整体赋值意外改变 OLED 和蜂鸣器。
  • 烧录时串口号必须选择当前 CH340 对应的 COM 口。视频中选错串口且一度关闭电源,下载软件持续检测目标单片机;恢复供电并改回正确的 COM3 后才成功。COM3 只是视频机器上的示例,实际端口应以设备管理器为准。

五、按位操作

  • sbit 是 Keil C51 扩展关键字,用于给可位寻址 SFR 的某一位建立别名,不属于标准 ISO C。配套源码将四个 LED 定义为:

    sbit LED1 = P1^0;
    sbit LED2 = P1^1;
    sbit LED3 = P1^2;
    sbit LED4 = P1^3;
    
  • P1^0sbit 声明中表示 P1 的 bit0,不是普通 C 运算表达式中的异或。定义完成后,给 LED1 赋值就是直接修改 P1.0,而不必重写 P1 的其他 7 位。

  • sbit 声明应放在函数外的文件作用域。真正执行的赋值语句应写在 mainwhile (1) 大括号内,才能随主循环持续运行。

  • 本板 LED 低电平有效,因此 LED1 = 0; 点亮 LED1,LED1 = 1; 熄灭 LED1。视频同时令 LED1、LED3 为 0,实物上第 1、3 盏灯点亮,验证了位别名与硬件引脚的映射。

  • 按位操作的优势是只改变目标位。例如修改 LED1 不会连带改变 P1.4、P1.5 的 OLED 信号或 P1.6 的蜂鸣器控制信号。

六、单片机的按位取反操作

  • 位取反运算符 ~ 会把操作数的每一位反转:0 变 1,1 变 0。对 sbit 变量赋值时,最终只保存最低目标位,因此 LED1 = ~LED1; 在 Keil C51 中可实现 LED1 状态翻转。
  • 更能表达“布尔状态翻转”意图的写法是 LED1 = !LED1;:逻辑非 ! 明确产生 0 或 1;而 ~ 会先发生整数提升并反转整个整数的所有位。配套源码使用的是 ~,应理解其生效原因,而不是把两种运算符混为一谈。
  • 若对整个端口执行 P1 = ~P1;,则 P1 的 8 位都会翻转,LED、OLED 和蜂鸣器可能同时受影响;这与只对一个 sbit 变量取反完全不同。

6.1 例题1: 通过按位取反实现LED灯状态切换

  • 显式赋值实现闪烁的写法是:先把 LED1、LED3 置 0并延时,再置 1并延时。取反写法则在循环中每次翻转并延时:

    LED1 = ~LED1;
    LED3 = ~LED3;
    Delay1000ms();
    
  • 当初始状态已知且循环持续执行时,两种写法都可产生约 1 秒亮、1 秒灭的闪烁。若初始值不确定,显式赋值更容易保证第一次出现的状态;取反写法更简洁,但第一帧取决于复位后的端口状态。

  • 配套源码当前选择 LED2、LED4 执行取反,所以实物上第 2、4 盏灯同步闪烁;先前 LED1、LED3 的显式赋值代码被注释保留。

6.2 知识点: 单片机在线升级编程仿真调试平台软件的使用

  • 每次切换实验代码后,都要确认需要执行的部分已经取消 //,其余互斥示例仍保持注释,否则多个代码块可能在同一循环中相互覆盖输出状态。
  • 编译前检查括号、分号、变量名和头文件;编译后检查 Error/Warning 数量及 HEX 更新时间;下载时检查芯片型号、串口、电源与目标文件;最后再用实物现象验证逻辑。
  • “编译成功”只能证明语法和链接通过,不代表硬件现象一定正确。若 LED 选择或闪烁模式不符,应查看当前参与编译的是 LED1/LED3 还是 LED2/LED4,并结合低电平有效逻辑分析。

6.3 例题2: 通过按位取反实现多个LED灯状态切换

  • 视频把取反对象从 LED1、LED3 改为 LED2、LED4,重新编译和烧录后,第 2、4 盏灯同步闪烁。
  • 这里操作的是 P1 寄存器的两个独立位,LED2LED4 分别是 P1.1、P1.3 的易读别名。
  • 多个位可以分别取反,也可以通过掩码整体异或,例如 P1 ^= 0x0A 会翻转 bit1 和 bit3。但整体读改写可能涉及端口锁存器和其他外设,初学阶段使用 sbit 更直观安全。

6.4 知识点: sbit与P1寄存器操作

  • 本节涉及的两个准确术语是 P1 寄存器sbit。P1 是 8 位特殊功能寄存器,sbit 用来为其中可位寻址的单个位定义别名。
  • P1 = value; 是整体写入 8 位,适合一次输出完整模式;sbit LED1 = P1^0; 建立位别名后,LED1 = value; 只修改 P1.0。
  • 两种方式不是任意可互换的。例如 P1 = 0x01 会把 P1.1~P1.7 全部写成 0,并非“只操作 P1.0”;在本板上这甚至可能点亮多个 LED 和蜂鸣器。只控制 P1.0 应使用 LED1 = 0/1 或经过正确掩码的读改写。

6.5 知识点: 软件延时计算器的使用

  • 在 STC 下载软件的工具区域找到“软件延时计算器”,系统频率选择本板晶振 11.0592MHz,时间单位选择毫秒,按需要填写 1000ms 或 2000ms,指令集选择 STC89,再生成代码。
  • 复制生成的函数后,若其中调用 _nop_(),必须包含 <intrins.h>;调用时写 Delay1000ms();,不能把函数定义开头的 void 一起复制到调用位置。
  • 软件延时是占用 CPU 的忙等待,实际时间受晶振、1T/6T/12T 模式、编译器版本和优化设置影响。流水灯入门实验可以使用,后续需要并行处理按键或串口时应改用硬件定时器。

七、任务信息资源导图回到P1寄存器定义的介绍

  • 本节复盘 P1 寄存器在哪里定义,以及源代码中的名称如何对应到单片机硬件地址。
  • 在 Keil 中选中 P1 并执行 Go To Definition,正常情况下可跳转到 reg52.h 中的 sfr P1 = 0x90;。若无法跳转,可直接选中 reg52.h 使用 Open 打开,或检查头文件搜索路径和 Browse Information 是否启用。
  • 无论执行 P1 = 0xFF; 整体赋值,还是通过 sbit LED1 = P1^0; 操作单个位,最终访问的都是地址为 0x90 的 P1 特殊功能寄存器及其位地址。
源代码中的 P1
      │
      ▼
reg52.h:sfr P1 = 0x90
      │
      ▼
CPU 访问 SFR 地址 0x90
      │
      ▼
P1 端口锁存/输出电路 ──► P1.0~P1.7 实际引脚 ──► LED/OLED/蜂鸣器

八、P1寄存器的定义

  • reg52.h 使用 Keil C51 的 sfr 扩展关键字,把 P1 寄存器等名称映射到固定 SFR 地址,例如:
头文件声明SFR 地址主要作用
sfr P0 = 0x80;0x80P0 端口
sfr P1 = 0x90;0x90P1 端口,本节操作对象
sfr P2 = 0xA0;0xA0P2 端口
sfr P3 = 0xB0;0xB0P3 端口及复用功能
sfr TCON = 0x88;0x88定时器/中断控制
sfr TMOD = 0x89;0x89定时器模式选择
sfr TL0 = 0x8A; / TL1 = 0x8B;0x8A/0x8B定时器低字节
sfr TH0 = 0x8C; / TH1 = 0x8D;0x8C/0x8D定时器高字节
sfr IE = 0xA8;0xA8中断允许寄存器
  • 定时器模式寄存器的名称是 TMOD,定时器 1 低字节寄存器的名称是 TL1。头文件还以 /* 8052 Extensions */ 标出 8052 相对 8051 增加的定时器 2 等定义。
  • reg52.h 主要覆盖经典 8051/8052 架构的标准寄存器和位别名,不一定包含 STC89C52RC 的全部厂商扩展寄存器;使用扩展功能时仍要查 STC 数据手册和相应厂商头文件。
  • 可位寻址 SFR 还可用 sbit 给某个位命名。例如头文件中可以看到 sbit EA = IE^7;sbit ES = IE^4;sbit ET0 = IE^1;。其中 ES 是串口中断允许位,EA 是总中断允许位,ET0 是定时器 0 中断允许位,后续中断和串口课程会使用这些位。
  • 删除或注释 #include "reg52.h" 后,编译器不再知道 P1、TCON 等名称与地址的对应关系,因此会报告 P1 未定义。这不是芯片中“没有 P1”,而是当前 C 源文件缺少让编译器识别该硬件地址的声明。