● 大一新生 · 零基础起步

51 单片机入门前置知识 从电压和二进制,到点亮第一颗 LED 之前你需要知道的一切

这不是一份“复制粘贴代码就完事”的教程。它把学习 8051 之前真正必须掌握的知识,按依赖顺序拆成 14 章:先学会“电流和二进制”,再理解“内核与存储器”,最后才是“写代码、烧程序”。每章都配了可直接上手玩的交互小工具,用来把抽象概念变成看得见的东西。

14章递进内容
12个交互演示
40引脚逐个讲解
~2h精读一遍
00

导读与学习地图

// 先看地图再走路:知道每一块知识为什么必须存在

学完本章你能

  • 说清楚“学 51 之前”和“学 51 本身”分别包含哪些知识
  • 判断自己该从第几章开始读,避免重复或跳级
  • 知道每一章的知识在后面的代码/电路里会以什么形式出现

很多同学第一次学单片机是这样的:拿到一块开发板,抄一段流水灯代码,编译、烧录、灯亮了,然后……就卡住了。因为看不懂 P1 = 0xFE; 到底在干什么,也解释不了为什么 LED 要串一个电阻,更不知道为什么有的引脚不接上拉电阻就没反应。

问题不在 51,而在于地基缺了几块。单片机是一门“软硬交界”的学科,它站在三个地方的交汇点上:

⚡

电路与电学

电压、电流、电阻、上拉下拉、驱动能力。决定你“为什么这样接线”,接错就是烧芯片。

🔢

数字与逻辑

二进制、十六进制、按位运算、逻辑门。决定你“为什么这样写寄存器值”。

💻

程序与计算机结构

CPU/存储器/外设/总线、地址、时钟、中断。决定你“程序为什么这样组织”。

依赖关系:为什么必须按这个顺序学

下面这张图是从零到“写出第一个能跑的程序”的知识依赖链。箭头表示“必须先懂前者,后者才讲得通”。

电压/电流/电阻/地 ──► 高低电平 ──► 二进制 0 和 1 ──► 十六进制书写
      │                                    │
      ▼                                    ▼
 上拉/下拉/限流电阻                  位运算(& | ^ ~ << >>)
      │                                    │
      └──────────────┬─────────────────────┘
                     ▼
              CPU + 存储器 + 总线 + 时钟 + 中断      ← 计算机组成原理最小版
                     ▼
              8051 内核:累加器 / SFR / 4 组工作寄存器 / 4 个 IO 口
                     ▼
        定时器 · 中断控制器 · 串口 ·(外部:I2C/SPI/ADC)
                     ▼
        C51 语言要点(volatile / sbit / sfr / 位操作)
                     ▼
      编译器 → HEX 文件 → 烧录器 → 芯片复位运行 → 用眼睛看现象
▲ 本教程的结构完全对应这条链:第 1-4 章补地基,第 5-7 章讲内核,第 8-10 章讲外设,第 11-12 章讲动手。

怎么读这篇教程

你的状态建议起点说明
纯零基础,没碰过电路第 1 章按顺序读,不要跳
高中物理/信息课学过二进制和串并联第 2 章快速扫一遍重点看 2.4 上拉下拉
会写 C 语言、做过 Arduino第 4 章重点看位运算与寄存器思维
只想马上点亮 LED第 12 章但回头一定要补第 2、7 章
✔ 使用方法建议

每章的“交互演示”都要亲手拖一拖、点一点再往下看。单片机是操作性学科,看会不等于会。每章末尾有“本章小结”,读完后先用自己的话复述一遍再看下一章。

本章小结

  • 51 单片机 = 电路 + 数字逻辑 + 计算机结构 + 编程,四块地基缺一不可。
  • 学习顺序有严格依赖:先电学 → 再进制与位运算 → 再内核结构 → 最后才是写程序。
  • 抄代码能点亮灯,但只有懂原理才能在灯不亮时找到原因。
01

单片机到底是什么

// 一台塞进指甲盖大小的完整计算机

学完本章你能

  • 用自己的话说出单片机(MCU)与 CPU、电脑、Arduino 的关系
  • 说清“51”这个名字的由来,以及 8051 / STC89C52 / STM32 的区别
  • 列出开发一块 51 板子所需要的最少硬件与软件清单

1.1 从电脑到芯片:一脉相承的结构

你熟悉的电脑由 CPU、内存、硬盘、主板和各种外设组成。单片机做的事一模一样,只是把这一整套东西集成到了一个芯片里:

电脑里的部件单片机里的对应物8051 里的具体名字
CPU 处理器CPU 内核8 位 CPU 内核(累加器 ACC、ALU、控制器)
内存条 RAM片上 RAM128 / 256 字节 RAM
固态硬盘 / 系统盘片上程序存储器4KB~64KB Flash(ROM)
主板芯片组 / 各种扩展卡片上外设定时器、串口、中断控制器、IO 口
USB / 网口 / 显卡接口IO 引脚P0/P1/P2/P3 共 32 个 IO
电源 + 晶振(时钟发生器)电源引脚 + 时钟源VCC/GND + XTAL1/XTAL2 或内部振荡器

所以单片机常被形容为“麻雀虽小,五脏俱全的微型计算机”。它的特点不是快,而是小、便宜、省电、能直接接传感器和执行器——这正是嵌入式控制需要的能力。

ℹ 几个必须分清的名词

MCU(Microcontroller Unit,微控制器):CPU + 存储器 + 外设都在一颗芯片上,用于控制,例如 8051、STM32、AVR。
MPU(Microprocessor Unit,微处理器):只有 CPU,内存和外设都在外面,性能强,例如电脑的 CPU、树莓派里的芯片。
SoC(System on Chip,片上系统):把 MCU/MPU 和更多功能(WiFi、GPU、显示控制器)堆在一颗芯片上,例如 ESP32、手机芯片。
日常口语里说的“单片机”基本等同于 MCU。

1.2 “51”是什么?为什么它 1980 年出生却还在教

1980 年 Intel 推出 MCS-51 系列,其中最著名的是 8051。后来 Intel 把内核授权出去,大量厂商生产“指令兼容”的芯片,这类芯片统称 8051 内核单片机,国内习惯简称 51 单片机。今天你买到的板子上多半是:

STC89C52RC

国内教学最常见、最便宜,8KB Flash、512B RAM、可以串口直接下载(不用专用编程器)。

AT89C51 / AT89S52

Atmel 的经典型号,教材里的“原味 8051”,AT89S52 需 ISP 下载。

STC8 / STC15 系列

现代增强版 51:1T 指令周期(更快)、带 ADC/PWM/内部晶振,写起来体验接近 STM32。

为什么现在还要学它?因为它是结构最干净、寄存器最少、资料最全的入门芯片。你会亲手操作每一个寄存器、数清楚每一字节内存,这种“把机器看透”的经历,在 STM32 那种几千页手册的芯片上是很难快速获得的。学完 51 再学 STM32,你会发现概念是复用的。

★ 一句话记住

8051 是一个 8 位、冯·诺依曼与哈佛混合的经典微控制器内核;51 单片机是所有兼容这个内核的芯片的统称。“8 位”意味着它一次处理 8 个二进制位(1 字节),这也是为什么后面所有寄存器都是 8 位的。

1.3 学 51 需要的“全套家当”

在打开软件之前,先确认你有这些东西——缺的可以按这个清单去配,总价通常不到 100 元。

硬件部分

物品作用选购提示
51 开发板芯片、晶振、复位、LED、按键、下载电路都已焊好强烈建议第一块用开发板,别自己从零搭
USB 数据线供电 + 下载程序注意区分“充电线”和“数据线”
USB 转 TTL 模块(CH340)给没有板载下载芯片的板子下载程序要选 3.3V/5V 可选、带 DTR/RTS 的
面包板 + 杜邦线搭外设电路:LED、按键、传感器面包板孔距 2.54mm
限流电阻若干(220Ω~1kΩ)LED 限流、上拉下拉LED 必须串电阻,否则烧灯或烧口
LED、按键、蜂鸣器最基础的现象器件LED 有极性,长脚是正极
万用表量电压、查通断、测引脚电平调试时它比调试器还有用
(进阶)ST-Link / 逻辑分析仪看时序波形、通信波形串口调试通了可以先不买

软件部分

① 代码编辑 + 编译

Keil µVision(C51):教学最主流,必须装 C51 版(不是 MDK-ARM)。
SDCC:免费开源,命令行/VS Code 可用。
PlatformIO:VS Code 插件,适合喜欢现代工具链的人。

② 下载(烧录)工具

STC-ISP:深圳宏晶官方,STC 系列芯片必用。
串口助手:既可以当串口调试,也能观察程序 printf 输出。
下载的本质是:把编译器生成的 HEX 文件通过串口/编程器写进芯片 Flash。

✔ 新手最省事的组合

STC89C52 开发板 + Keil C51 + STC-ISP + 一根 USB 线。这条链路成熟、教程最多、遇到问题最容易搜到答案。第一周不要追求“高级工具链”,先把灯点亮。

1.4 认识“一条程序的生命周期”

以后你每天都会重复这个循环,先记住它,第 12 章会逐步拆开讲。

  1. 写源码

    在 Keil 里创建工程,写 main.c,配置芯片型号和晶振频率。

  2. 编译 / 链接

    编译器把 C 代码变成机器码,链接器把它放到正确的地址上,最后输出 .hex 文件。

  3. 下载

    用 STC-ISP 把 HEX 写进芯片的 Flash。此时芯片里有了“新程序”。

  4. 复位运行

    芯片复位后,CPU 从 Flash 的 0000H 地址开始一条一条取指令、执行指令。

  5. 观察现象并调试

    看 LED、听蜂鸣器、看串口输出、用万用表量电平,再修改代码回到第 1 步。

本章小结

  • 单片机 = 集成在一颗芯片上的完整计算机:CPU + ROM + RAM + 外设 + IO。
  • “51”指 Intel MCS-51(8051)指令兼容内核;STC89C52 是最常见的教学型号。
  • 学习 51 的价值在于结构简单、寄存器少,能让你看清计算机的每一层。
  • 最小装备:开发板 + USB 线 + Keil C51 + STC-ISP;再补面包板、电阻、LED、万用表。
02

电学与数字电路常识

// 引脚上到底发生了什么:电压、电流与 0/1

学完本章你能

  • 用电压、电流、电阻的关系解释“为什么必须串限流电阻”
  • 理解数字电路为什么只用两个电压区间表示 0 和 1,以及什么是噪声容限
  • 看懂上拉电阻、下拉电阻、开漏输出的作用,并解释 P0 口为什么要外接上拉
  • 给 LED 算出正确的限流电阻,并说清按键抖动为什么要消抖

2.1 三个最基本的量:电压、电流、电阻

把电想象成水:电压是水位差(水压),电流是水流的大小,电阻是水管的粗细限制。三者由欧姆定律联系起来:

U = I × R   →   I = U / R   →   R = U / I

U 电压,单位伏特 V | I 电流,单位安培 A(1A = 1000mA) | R 电阻,单位欧姆 Ω(1kΩ = 1000Ω)

概念含义单片机里最常见的数值
电压 U两点之间的电位差,是“推动电流的原因”5V 系统:VCC = 5V,GND = 0V
电流 I单位时间内通过的电荷量LED 正常 3~10mA;IO 口安全上限约 10~20mA
电阻 R对电流的阻碍能力限流 220Ω~1kΩ;上拉 4.7kΩ~10kΩ
地 GND电路的公共参考点,定义为 0V,是所有电流的回路板子上所有 GND 必须连在一起
VCC电源正极(供电电压),51 通常 5VSTC89 系列:3.3V~5.5V 均可工作
⚠ 新手第一坑:忘了共地

两块板子或模块互连时,除了信号线,必须把 GND 接在一起。不共地就没有统一的 0V 参考,信号电压无从判断,现象就是“怎么都不通”。这是调试时排在第一位的检查项。

2.2 电路基础:串联、并联与回路

串联(首尾相接)

电流只有一条路可走,处处电流相同,电压按电阻比例分配,总电阻相加:R = R1 + R2。

典型场景:LED 与限流电阻串联。电阻“吃掉”多余的电压,让 LED 上的电压和电流都合适。

并联(并排相接)

电流有多条路可走,各支路电压相同,总电流相加,总电阻反而变小。

典型场景:多个 LED 各自串自己的电阻再并到电源上(注意:不能几个 LED 共用一个电阻并联,亮度会不均)。

回路是电流能走通一圈的路径。只有“电源正极 → 元件 → 电源负极(GND)”构成完整回路,电流才会流动,元件才会工作。单片机控制一个 LED 也是回路思维:

灌电流(sink,低电平点亮,51 常用)       拉电流(source,高电平点亮)
                                           
   VCC(5V)                                    P1.0 ──►|── 220Ω ── GND
     │                                         高电平(5V)          (0V)
     ├──►|── 220Ω ── P1.0
     │   LED        输出低(0V)
    GND
                                              
 电流从 VCC 经 LED、电阻流入引脚            电流从引脚经 LED、电阻流向 GND
 引脚“吸入”电流,驱动能力强                引脚“吐出”电流,多数 51 口能力弱
▲ 同一个 LED,接法不同,代码里写的电平正好相反:灌电流是“输出 0 点亮”。

2.3 数字信号:为什么“电压”能表示 0 和 1

现实世界的电压是连续的(模拟量),但数字电路故意只承认两个区间:靠近 0V 的叫低电平 0,靠近 VCC 的叫高电平 1。中间那一大段是“不确定区/禁止区”,正常设计要避免停在那里。

🔌 交互演示:拖动电压,看它被判成 0 还是 1拖动试试
5.00 V
LED
区间(5V 系统)判定
0V ~ 1.5V逻辑 0(低)
1.5V ~ 3.5V不确定,禁止
3.5V ~ 5V逻辑 1(高)

阈值并非绝对,实际取决于芯片手册的 VIL/VIH 参数。5V 供电时常见的门限大约是 0.3×VCC 与 0.7×VCC。

为什么中间要留这么大的不确定区?因为它是对抗干扰的“安全垫”。真实电路里电压会有毛刺、抖动、压降,如果把判定阈值压得很紧,稍有干扰就会把 0 读成 1。留出宽裕的不确定区,就得到了噪声容限——这是数字电路比模拟电路抗干扰强得多的根本原因。

ℹ 你要记住的三个电压概念

VIH(输入高电平最小值):高于它就一定被读成 1。
VIL(输入低电平最大值):低于它就一定被读成 0。
VOH / VOL:本芯片输出高/低电平时能保证的电压值,且必须在指定电流下成立——这就是“驱动能力”。

2.4 上拉电阻与下拉电阻(必考、必用)

如果一个引脚什么都不接,它叫悬空(浮空)。悬空引脚的电平是不确定的:可能因为一点点静电、邻近走线就飘到高或低,读到的值随机跳变。解决办法是给它一个“默认状态”,这就是上拉/下拉电阻。

上拉电阻 → 默认高电平

把引脚通过一个电阻(通常 4.7kΩ~10kΩ)接到 VCC。

作用:松开按键时读到 1;配合按键接地,按下时读到 0(低电平有效)。

电阻不能太小(费电、按键按下时电流大),也不能太大(抗干扰弱、上升沿变慢)。

下拉电阻 → 默认低电平

把引脚通过电阻接到 GND。

作用:默认读到 0,按键接 VCC,按下读到 1(高电平有效)。

51 的 IO 口内部只有弱上拉、没有下拉,所以需要默认低时通常靠外部下拉或改变电路接法。

内部上拉与开漏输出:为什么 P0 口一定要外接上拉

51 的 P1/P2/P3 口内部结构是“弱上拉 + 强下拉”,所以它们可以只靠内部上拉输出高电平(但拉电流能力很弱,约几十微安到几百微安,点亮 LED 基本不够)。而 P0 口是真正的开漏(open-drain)结构:

开漏输出的等效结构(P0 的每一位)

   VCC ──[ 外部上拉电阻 ]──┬── 引脚
                            │
                          [ MOS 管 ]   ← 只负责“拉低”
                            │
                           GND

 写 1 → MOS 管关断 → 引脚被外部上拉电阻拉高(所以外部电阻必须有)
 写 0 → MOS 管导通 → 引脚被强行拉到接近 0V(灌电流,能力强)
▲ 这就是“P0 口当普通 IO 用时必须外接 10kΩ 上拉电阻”的原因;也是 I2C 总线(SDA/SCL 都是开漏)必须外接上拉电阻的同一个道理。
★ 一条经验法则

“输出高靠电阻,输出低靠管子。”凡是你看到某个引脚输出高电平没力气、必须靠外接电阻才能拉高,它多半是开漏输出。开漏的好处是可以把多个引脚直接并到一根线上(“线与”),谁都不会打架,I2C 就是靠这个特性工作的。

2.5 LED 与限流电阻:动手算一次

LED 不是电阻,它有正向压降 VF(红光约 1.8~2.0V,绿/蓝光约 2.0~3.2V)和额定电流(普通指示灯 5~10mA 足够亮)。超过额定电流,几毫秒就能烧掉它,也可能烧掉 IO 口。

🧮 交互演示:算限流电阻算一算

手算示例:5V 供电,红灯 VF = 1.9V,希望电流 8mA。电阻要承受的电压是 5 - 1.9 = 3.1V,于是

R = U / I = 3.1V / 0.008A = 387.5Ω  →  取最接近的标准值 390Ω

校验实际电流:3.1V / 390Ω ≈ 7.9mA   ✓ 安全且足够亮
若用 220Ω:3.1V / 220Ω ≈ 14mA       ⚠ 偏大,长期点可能伤口
若用 10kΩ:3.1V / 10000Ω = 0.31mA    ✗ 几乎看不见
▲ 标准电阻系列(E24):220、240、270、300、330、360、390、430、470、510、560、680、820、1k…
✔ LED 极性

LED 是二极管,长脚(或平面侧相反的那一侧)是正极 A(阳极),短脚是负极 K(阴极)。反向接一般不会亮,只要电压不高也不至于立刻损坏,但千万不要忘记串电阻——那才是真会烧的。

2.6 按键与“抖动”:为什么要有消抖

机械按键在按下和弹起的一瞬间,金属触点会弹跳几次,产生几十微秒到十几毫秒的一连串高低电平脉冲,这叫抖动(bounce)。如果 CPU 一检测到电平变化就计数,按 1 次可能被算成 5 次。所以必须消抖:

硬件消抖

在按键两端并一个 0.1µF 电容,或使用专用的消抖芯片/RC 电路。成本略高但省 CPU。

软件消抖(最常用)

检测到电平变化后延时 10~20ms 再读一次,两次一致才认为是真的按键;松开时同样处理。

/* 软件消抖的标准写法:低电平有效的按键,接在 P3.2 */
if (KEY == 0) {                /* 第一次检测到按下 */
    DelayMs(15);              /* 等抖动结束 */
    if (KEY == 0) {            /* 再确认一次,仍是按下才算数 */
        /* 这里处理一次按键动作 */
        while (KEY == 0);      /* 可选:等松手,避免长按被当成多次 */
    }
}
▲ KEY 是用 sbit 定义的引脚,第 11 章会讲怎么定义。

2.7 逻辑门与组合逻辑(认识即可)

数字电路的最小运算单元是逻辑门。单片机内部由成千上万个门构成,而你写代码时用到的位运算(下一章)正是它们的软件版本。

门符号/表达式规则(一句话)典型用途
与 ANDY = A · B全 1 才输出 1做掩码、屏蔽某些位、按键组合
或 ORY = A + B有 1 就输出 1置位、合并标志
非 NOTY = A̅取反低电平有效信号转换
与非 NANDY = (A·B)̅全 1 输出 0最常用的“万能门”
或非 NORY = (A+B)̅有 1 输出 0SR 锁存器
异或 XORY = A ⊕ B不同为 1,相同为 0翻转特定位、奇偶校验、加法器

组合逻辑 = 输出只取决于当前输入;时序逻辑 = 输出还取决于过去的输入(含触发器/寄存器)。单片机的 IO 口、定时器、串口都属于时序逻辑,因为它们需要时钟来推进状态。

本章小结

  • 电路的一切都从 U = I × R 出发;调试第一件事是确认回路完整、共地可靠。
  • 数字电路只用两个电压区间表示 0/1,中间留出不确定区以抵抗干扰。
  • 悬空引脚状态不定,靠上拉/下拉电阻给它默认状态;51 的 P0 是开漏结构,必须外接上拉。
  • LED 必须串联限流电阻,用 R = (VCC − V_F) / I 计算,取标准值。
  • 机械按键有抖动,必须用延时或电容消抖,否则一次按下会被算成多次。
03

进制与二进制运算

// 0xFE、0b11111110、254 其实是同一个东西

学完本章你能

  • 在二进制、十六进制、十进制之间快速换算,看 8 位二进制能立刻说出十六进制
  • 解释为什么单片机工程师偏爱十六进制,以及 0x、0b、H 后缀的含义
  • 说清位(bit)、字节(byte)、字(word)以及 8 位单片机的取值范围
  • 理解补码为什么能“用加法实现减法”,以及 BCD 为什么不等于二进制

3.1 为什么必须是二进制

因为电路里最容易做到的两件事就是“有电压”和“没电压”。做成开关最简单、最抗干扰、最省成本。十进制需要把电压精确分成十档,稍有干扰就出错。所以数字电路只认两个状态,用 0 和 1 表示,每一个 0 或 1 称为一个 位(bit)。

而 8 个 bit 组成一个字节(byte)——这正是 51 单片机一次能处理的数据宽度,所以 51 叫“8 位单片机”。

1 bit

0 或 1

一个引脚的一次状态

4 bit = 1 半字节

正好 1 位十六进制

0x0 ~ 0xF

8 bit = 1 字节

0x00 ~ 0xFF

51 里所有寄存器的宽度

16 bit = 1 字

0x0000 ~ 0xFFFF

地址、定时器初值常用

3.2 三种进制的写法与换算

同一个数可以被写成不同进制。C 语言里区分的办法是前缀:

进制C 语言写法汇编写法基数例子
二进制 Binary0b1111_111011111110B2写作 0b11111110
八进制 Octal0362362Q / 362O80362 = 0xF2 = 242
十进制 Decimal25425410默认,不加前缀
十六进制 Hex0xFE0FEH16最常用,务必记住 0x
⚠ 别把 0x 写掉了

P1 = FE; 是错的(编译器会把它当成一个名叫 FE 的变量或直接报错);必须是 P1 = 0xFE;。另一个常见错误是 0xFG——十六进制里没有 G,只有 0~9 和 A~F。

换算法则:一句话记住

  • 二 → 十六:从右往左每 4 位分一组,每组查表。例:1111 1110 → F E → 0xFE。
  • 十六 → 二:每位十六进制展开成 4 位。例:0x5A → 0101 1010。
  • 二 → 十:按权相加。例:10110011 = 128+32+16+2+1 = 179。
  • 十 → 二:不断除以 2 取余数,倒着读。
🔢 交互演示:四进制换算器 + 8 位位图实时联动

在任意一栏输入,其他栏会同步刷新。也可以直接点下面的 8 个格子来翻转某一位,亲眼看看它是哪一位变成了 1。

位图(点击翻转)

3.3 十进制与十六进制的常见对照(背下来会快很多)

十进制二进制十六进制它在 51 里常常意味着
00000 00000x00全低:8 个 LED 全亮(若低电平点亮)
10000 00010x01只有最低位 P1.0 为高
150000 11110x0F低 4 位掩码,常用于取低半字节
160001 00000x10第 4 位为 1
1270111 11110x7F有符号数最大正值
1281000 00000x80最高位为 1,定时器/串口配置里极常见
2401111 00000xF0高 4 位掩码
2541111 11100xFE只有 P1.0 为低:最经典的“点亮第一个 LED”
2551111 11110xFF全高;也常作为“无数据”的初值
★ 为什么工程师爱用十六进制

因为一位十六进制 = 正好 4 位二进制,写起来短、看起来直观、还不会数错。用二进制写 11111110 你看不出是哪一位变了;写 0xFE 一眼就知道是低半字节的 E = 1110。看波形、看寄存器值时,脑子里要能立刻完成“十六进制 ↔ 二进制”的转换。

3.4 二进制的四则运算与移位

虽然单片机硬件会替你算,但理解这些运算才能看懂溢出、定时器溢出、移位流水灯等逻辑。

加法:逢二进一

  0+0=0   0+1=1   1+0=1   1+1=10(本位 0,进位 1)

     0000 1010   (10)
   + 0000 0110   ( 6)
   ────────────
     0001 0000   (16)    ← 第 4 位产生进位

8 位寄存器里若发生“进位丢掉”的情况,就叫溢出:
     1111 1111   (255)
   + 0000 0001   (  1)
   ────────────
   1 0000 0000   → 低 8 位是 0x00,最高位进位移入进位标志 CY

乘法与除法:用移位代替(重要)

在二进制里,左移一位等于乘 2,右移一位等于除以 2(丢掉余数)。51 是 8 位机,做乘除法比较慢,所以聪明的代码常用移位和加减来代替乘法:

a = a << 1;        /* a × 2      */
a = a << 3;        /* a × 8      */
a = (a << 2) + a;  /* a × 5      */
a = (a << 1) + (a << 3); /* a × 10 */
a = a >> 1;        /* a ÷ 2      */
a = a >> 4;        /* a ÷ 16     */
ℹ 移位的两个陷阱

① 溢出丢位:0x80 << 1 得到 0x00,最高位的 1 被移出了字节,不是 0x100。
② 有符号右移:对负数做 >> 时编译器可能做算术右移(补符号位),结果和你以为的“除以 2”不同。涉及正负时要用明确的无符号类型。

3.5 有符号数:补码与 0xFF 到底是 255 还是 −1

8 个 bit 没有“符号”概念,它只是 8 个 0/1。约定用最高位当符号位后,同一个 0xFF 就有了两种解释:

情况类型0xFF 的值取值范围
不把最高位当符号unsigned char2550 ~ 255
最高位是符号位(0 正 1 负)signed char−1−128 ~ +127

负数用补码表示,规则很简单:取反加一。

求 −5 的 8 位补码:
   5      = 0000 0101
   取反   = 1111 1010
   加一   = 1111 1011 = 0xFB      ← 这就是 −5

验算(用加法做减法):8 + (−5) = 3
     0000 1000   (8)
   + 1111 1011   (−5)
   ────────────
   1 0000 0011   → 丢掉进位,低 8 位是 0000 0011 = 3   ✓

所以 −1 = 0xFF,−128 = 0x80。这就是为什么 0x80 在配置寄存器时常出现:
它既表示“第 7 位置 1”,也可能表示“−128”,要看上下文。
🧮 交互演示:补码计算器正负都要会

3.6 BCD 码:用十六进制“假装”十进制

BCD(Binary-Coded Decimal)用每 4 个 bit 表示一位十进制数字,即 0~9,剩下的 1010~1111 不使用。

十进制 254 的两种编码:
   纯二进制  = 1111 1110        = 0xFE
   BCD 码    = 0010 0101 0100   = 每 4 位一个数字 2 / 5 / 4

十进制 254 的 BCD 十六进制写法:0x0254
   ← 注意!0x0254 的“数值”是 596,但按 BCD 读却是 254
十进制BCD(4 位一组)纯二进制
0 ~ 90000 ~ 10010000 ~ 1001(相同)
100001 00000000 1010(相同)
250010 01010001 1001(不同!)
991001 10010110 0011(不同!)

用在哪:数码管、DS1302 时钟芯片(时间寄存器就是 BCD)、部分传感器。典型写法:

/* 二进制 → BCD:先除 10 取十位,再取个位,拼成一个字节 */
u8 bcd = (value / 10) << 4 | (value % 10);

/* BCD → 二进制:高 4 位乘 10 再加低 4 位 */
u8 dec = (bcd >> 4) * 10 + (bcd & 0x0F);

本章小结

  • 数字电路只认 0/1;8 个 bit 是 1 字节,也是 51 一次处理的数据宽度。
  • 一位十六进制 = 4 位二进制,这是十六进制在单片机里盛行的唯一原因。
  • 左移一位等于乘 2,右移一位等于除 2;移位会丢位、会溢出,要心里有数。
  • 补码 = 取反加一,让加法器顺便完成减法;0xFF 既可能是 255 也可能是 −1。
  • BCD 每 4 位存一位十进制数字,只在数码管、时钟芯片等特定场合使用。
04

位运算:单片机的母语

// 想控制 8 个引脚中的某一个,只有这一条路

学完本章你能

  • 熟练读写 & | ^ ~ << >> 六个位运算符
  • 不看代码就能说出“把某位置 1 / 清 0 / 翻转 / 读取”的标准写法
  • 看懂并自己写出流水灯、按键映射、寄存器配置这类典型代码
  • 避开 = 与 ==、& 与 && 这些经典致命错误

4.1 为什么要位运算:一个字节,八个引脚

51 的 P1 是一个 8 位寄存器,它的 8 个 bit 直接对应 P1.0 ~ P1.7 八个物理引脚。当你写 P1 = 0xFE; 时,你其实是同时给 8 个引脚各送了一个电平:

P1 = 0xFE = 1111 1110
             │││││││└── P1.0 = 0   ← 唯一被拉低的引脚
             ││││││└─── P1.1 = 1
             │││││└──── P1.2 = 1
             ││││└───── P1.3 = 1
             │││└────── P1.4 = 1
             ││└─────── P1.5 = 1
             │└──────── P1.6 = 1
             └───────── P1.7 = 1

问题来了:如果我只想改变 P1.0,不想动其他 7 个引脚,直接写 P1 = 0; 会把 8 个灯全点亮。所以必须用一种只影响某几位的运算——这就是位运算存在的理由。

★ 核心思想

位运算 = “只对我想动的位动手,其他位原样保留”。记住这个目标,四种基本操作的写法就都能自己推出来。

4.2 六个运算符与真值表

运算符名称运算规则(逐位独立进行)一句话记忆
&按位与 AND1&1=1,其余为 0有 0 就是 0 → 用来清 0
|按位或 OR0|0=0,其余为 1有 1 就是 1 → 用来置 1
^按位异或 XOR相同为 0,不同为 1与 1 异或翻转,与 0 异或不变 → 用来取反某位
~按位取反 NOT0→1,1→0全字节反转
<<左移整体左移,低位补 0乘 2;用来生成掩码
>>右移整体右移,高位补 0除 2;用来取值
🧮 交互演示:位运算计算器实时计算

修改两个操作数(十进制、十六进制或二进制都行),下面所有运算结果会同步刷新,逐位对照着看最容易记住规律。

4.3 四个必须形成肌肉记忆的写法

把下面四行抄到你的笔记本第一页。几乎所有单片机代码的底层都是它们。

/* 假设 x 是目标字节,bit 是位号 0~7 */
#define BIT(n)  (1 << (n))      /* 生成只有第 n 位为 1 的掩码 */

/* ① 置位:把第 n 位变成 1,其他位不变 */
x |=  BIT(n);
/* 拆开看:  x = x | (1<<n)
   原值 1010 0101,n = 1
   掩码 0000 0010
   或   ─────────  => 1010 0111   ✓ 只有 bit1 被置 1 */

/* ② 清零:把第 n 位变成 0,其他位不变 */
x &= ~BIT(n);
/* 拆开看:  x = x & ~(1<<n)
   原值 1010 0101,n = 2
   ~(1<<2) = 1111 1011
   与      ─────────  => 1010 0001   ✓ 只有 bit2 被清 0 */

/* ③ 翻转:第 n 位取反,其他位不变(异或的独门绝技) */
x ^=  BIT(n);

/* ④ 读取:判断第 n 位是 0 还是 1 */
if (x & BIT(n)) { /* 第 n 位是 1 */ }
else            { /* 第 n 位是 0 */ }
ℹ 为什么清零要用 &= ~BIT(n)

因为不存在“按位清零”运算符。~BIT(n) 把掩码变成“除了第 n 位是 0,其他都是 1”,再与原值相与,就只把那一位压成 0。这一步几乎每个初学者都会写错成 x &= BIT(n);——那样会把其他所有位全清掉。

4.4 经典应用:流水灯与按键状态

流水灯:用移位 + 取反点亮

/* 低电平点亮的流水灯:每次只让一个引脚为 0 */
while (1) {
    u8 i;
    for (i = 0; i < 8; i++) {
        P1 = ~(1 << i);   /* i=0 → 0xFE,i=1 → 0xFD,i=2 → 0xFB ... */
        DelayMs(200);
    }
}

/* 等价的手写序列,方便你对照二进制 */
/* 0xFE=1111 1110  0xFD=1111 1101  0xFB=1111 1011  0xF7=1111 0111 */
/* 0xEF=1110 1111  0xDF=1101 1111  0xBF=1011 1111  0x7F=0111 1111 */

按键状态:一个字节装 8 个按键

/* P3 口接了 8 个按键,读一次就能得到全部状态 */
u8 keys = P3;                     /* 例如读到 0b1110_1101 */

if ((keys & BIT(1)) == 0) { /* P3.1 被按下(低电平有效) */ }
if ((keys & BIT(4)) == 0) { /* P3.4 被按下 */ }

/* 把某几位单独取出来(例如取低 4 位) */
u8 low4  = keys & 0x0F;
u8 high4 = keys >> 4;          /* 或 (keys & 0xF0) >> 4 */
💡 交互演示:异或翻转一位点一点

点下面的按钮执行 P1 ^= BIT(n)。注意:无论当前是什么状态,只有你点的那一位在变,其他 7 位始终不动——这就是异或翻转的价值。

选择位号:

4.5 一个几乎人人踩过的坑:优先级

C 语言里位运算符的优先级低于比较运算符,高于逻辑运算符,顺序很反直觉。下面两行结果完全不同:

/* ✗ 错误:先算 (flags & 0x02) == 0,结果等价于 flags & 1 */
if (flags & 0x02 == 0) { ... }

/* ✓ 正确:永远加括号 */
if ((flags & 0x02) == 0) { ... }
易混组合含义典型误用后果
x & y按位与,逐位运算写成 && 后,keys & 0x01 变成“判断 keys 和 1 是否都不为 0”,恒为真
x && y逻辑与,返回 0 或 1
x | y按位或写成 || 后置位全失效,P1 |= 0x01 会变成 1
x || y逻辑或,返回 0 或 1
a = b赋值if (KEY = 0) 永远为假(0 被赋给 KEY 后作为条件),且顺手改坏了 KEY
a == b比较
~x按位取反用 ! 会把 ~0x01 的 0xFE 变成 0
!x逻辑非(0 变 1,非 0 变 0)
✗ 最重要的一条防御习惯

写 if (KEY == 0) 时,可以故意写成 if (0 == KEY)(尤达表达式)。万一漏掉一个等号,编译器会立刻报错 “cannot assign to constant”,而不是悄悄让你 debug 三小时。

4.6 汇编里的对应指令(了解即可)

51 的汇编指令直接内建了位运算能力,这也是它“控制能力强”的体现。等你以后看反汇编或写底层时会遇到:

指令功能等价的 C 写法
ANL A, #data累加器与立即数按位与A &= data
ORL A, #data按位或A |= data
XRL A, #data按位异或A ^= data
CPL A / CPL P1.0按位取反(可直接对某一位取反)A = ~A ; P1^0 = !P1^0
SETB P1.0 / CLR P1.0把某一位直接置 1 / 清 0P1_0 = 1 ; P1_0 = 0
RL / RR / RLC / RRC循环左移/右移(带或不带进位)无直接对应,C 里要手写
JB / JNB / JBC判断某位为 1 / 0 / 为 1 则跳转并清 0if (bit) ...
✔ 为什么用 C 还有必要懂这些

因为 C 编译器最终就生成这些指令。当你发现“位操作比赋值慢”或者“某段代码没达到预期时序”,往往要看反汇编才知道编译器把你的位运算优化成了什么。同时,51 里有位寻址空间,某些位地址操作比其他写法快得多(第 7 章会讲)。

本章小结

  • 位运算的目的只有一个:只修改我想改的位,其余保持不变。
  • 置 1 用 |= BIT(n),清 0 用 &= ~BIT(n),翻转用 ^= BIT(n),读取用 & BIT(n)。
  • 掩码用 1 << n 生成;取高 4 位用 >> 4,取低 4 位用 & 0x0F。
  • 位运算符优先级反直觉,任何混合表达式都加括号。
  • 别把 & 写成 &&、| 写成 ||、== 写成 =——这三条能省下你大量调试时间。
05

51 内核架构与引脚

// 40 只脚,每一只都有明确分工

学完本章你能

  • 画出 8051 的内部结构框图,说出累加器、PC、PSW、SP、DPTR 各自的作用
  • 看懂 40 引脚 DIP 封装图,说出每一类引脚的用途与典型接法
  • 解释复位电路和晶体振荡电路为什么那样接,以及 EA 引脚为什么通常接 VCC
  • 说清“取指—译码—执行”这个 CPU 心跳过程

5.1 内部结构:一台 8 位小电脑

把 51 拆开看,它由 6 个部分组成。你要记住的不是细节,而是数据怎么在这些部件之间流动。

                         ┌──────────────── 8051 内部 ────────────────┐
                         │                                          │
   时钟 XTAL1/XTAL2 ────►│  时钟与定时控制电路                        │
                         │        │                                 │
                         │        ▼                                 │
                         │  ┌───────────┐   8 位内部数据总线         │
                         │  │  CPU 内核  │◄──────────────────────┐   │
                         │  │ 控制器/ALU │                       │   │
                         │  │  ACC  B   │                       │   │
                         │  │  PSW  SP  │                       │   │
                         │  └─────┬─────┘                       │   │
                         │        │ 16 位地址总线                │   │
                         │        ▼                             │   │
                         │  ┌──────────────┐   ┌──────────────┐ │   │
                         │  │ 程序存储器    │   │ 数据存储器    │ │   │
                         │  │ Flash 4~64KB │   │ RAM 128/256B │ │   │
                         │  │ (只读执行)  │   │(读写)       │ │   │
                         │  └──────────────┘   └──────────────┘ │   │
                         │                                      │   │
                         │  ┌────────────┬───────────┬────────┐ │   │
                         │  │ 定时器 T0/T1│ 串口 UART │中断控制 │ │   │
                         │  └────────────┴───────────┴────────┘ │   │
                         │        │            │          │     │   │
                         │        ▼            ▼          ▼     │   │
                         │   ┌────────────────────────────────┐ │   │
                         │   │  P0    P1    P2    P3  (各 8 位)│◄┘   │
                         └───┴────────────────────────────────┴─────┘
                                        │
                                        ▼
                                 32 根 IO 引脚 ──► 外设、LED、传感器…
▲ 一句话概括:CPU 从程序存储器取指令,通过数据总线读写数据存储器与外设寄存器,最后把结果送到 IO 引脚。

架构上要区分的一个概念:冯·诺依曼 vs 哈佛

架构特点代表51 属于哪种
冯·诺依曼程序和数据存在同一空间,共用总线电脑 x8651 属于哈佛结构(改进型):程序存储器与数据存储器地址空间独立,可同时访问,速度更快。这也是第 7 章要划清“四个地址空间”的原因。
哈佛程序与数据分开存储、各有总线多数单片机

5.2 内核里的六个关键寄存器

“寄存器”就是 CPU 内部的一个小盒子,容量通常只有 1 字节,但 CPU 可以直接读写它,速度最快。51 里你必须认识这几个:

名称全称宽度作用(生活化比喻)
ACC累加器 Accumulator8 位算盘珠子。绝大多数运算的出发点和落脚点,代码里常写 A。很多指令隐含使用它,所以它是最忙的寄存器。
BB 寄存器8 位乘除法的搭档。乘法 MUL AB 结果放 A(低 8 位) 和 B(高 8 位);除法 DIV AB 商在 A、余数在 B。
PSW程序状态字8 位体检报告。记录上一次运算的结果状态:是否有进位 CY、是否有溢出 OV、奇偶标志 P,以及当前用的是哪一组工作寄存器 RS1/RS0。
PC程序计数器16 位书签。永远指向“下一条要执行的指令地址”。复位后 PC = 0000H,所以你写在 main 里的第一句实际上是跳转指令。
SP堆栈指针8 位叠盘子的手。指向栈顶。51 的栈是向上生长的(压栈时 SP 先加 1),复位初值 07H,所以栈通常放在 30H 之后的 RAM 区。
DPTR数据指针16 位外部世界的地址簿。由 DPH、DPL 两个 8 位寄存器拼成,专门用于访问片外 RAM 和查表。

PSW 的每一位(配置位,重要)

PSW  (位地址 D0H,可位寻址)
 ┌───┬───┬───┬───┬───┬───┬───┬───┐
 │CY │AC │F0 │RS1│RS0│OV │  —│ P │
 └───┴───┴───┴───┴───┴───┴───┴───┘
  D7  D6  D5  D4  D3  D2  D1  D0

 CY  进位/借位标志:加法有进位或减法有借位时置 1
 AC  辅助进位:低 4 位向高 4 位进位(BCD 调整时用)
 F0  用户自定义标志位(可自由使用的一位)
 RS1 RS0 工作寄存器组选择:00→第0组, 01→第1组, 10→第2组, 11→第3组
 OV  溢出标志:有符号运算结果超出范围时置 1
 P   奇偶标志:A 中 1 的个数为奇数时置 1(串口校验会用到)
ℹ 工作寄存器组是什么

51 内部有 4 组 R0~R7,每组 8 字节,都映射在 RAM 的 00H~1FH。当前用哪一组由 PSW 的 RS1:RS0 决定。切换一组相当于瞬间换掉 8 个变量,在中断里保存/恢复现场非常快。

5.3 40 引脚 DIP 封装:逐个点开看

下面是可以点击的 8051 引脚图。左边是 DIP-40 的 1~20 脚(逆时针从左上角开始数),右边是 21~40 脚。点任意一只脚看它的功能和典型接法。

📌 交互演示:8051 DIP-40 引脚图点击引脚
8051 DIP-40

← 点击任意一只引脚

这里会显示该引脚的全称、功能分类和典型接法。

提示:P0/P1/P2/P3 各 8 位引脚分别对应端口寄存器的 bit0~bit7,代码里写 P1 = 0xFE 控制的就是第 1、2 脚。

5.4 引脚归类记忆法

类别引脚数量说明
电源VCC(40) / GND(20)25V 供电,务必与所有外设共地
时钟XTAL1(19) / XTAL2(18)2接晶体振荡器 + 两个 30pF 电容,或用内部时钟(STC 可省)
复位RST(9)1高电平复位,靠 RC 电路实现上电自动复位
通用 IOP0/P1/P2/P3 各 8 位32最常用的部分,P1 通常留给用户自由使用
扩展总线控制ALE(30) / PSEN(29) / EA(31)3只有扩展片外存储器时才需要关心
★ 三个“高级引脚”的真实含义

EA(External Access,31 脚):程序从哪里取。EA = 1(接 VCC)表示先执行片内 Flash 的程序——现在绝大多数开发板都这么接;EA = 0(接 GND)表示只从外部 ROM 取指令,老式扩展实验才用。接错会导致程序“完全不跑”。
ALE(Address Latch Enable,30 脚):扩展片外存储器时,P0 口要同时充当“低 8 位地址”和“数据”,靠 ALE 的下降沿把地址锁进 74LS373。不扩展时它是 1/6 晶振频率的方波输出,可以用示波器看,判断芯片是否在正常运行。
PSEN(Program Store Enable,29 脚):读片外程序存储器时的选通信号,接外部 ROM 的 OE。

5.5 时钟电路与复位电路:最小系统的两件大事

① 晶体振荡电路

        XTAL1 ──┬──┤├──┬── GND
                │  11.0592MHz │
                │  30pF       │
        XTAL2 ──┴─────┬──────┘
                      │
                    30pF
                      │
                     GND

晶振决定“CPU 一秒走多少步”。11.0592MHz 是 51 的经典选择:因为它能被 9600、19200 等串口波特率整除,算出来的定时器初值恰好是整数,串口通信不会累积误差。若不用串口,12MHz 也可以。

② 上电复位电路

        VCC ──[ 10µF ]──┬── RST(9)
                        │
                     [ 10kΩ ]
                        │
                       GND

51 的 RST 是高电平复位:上电瞬间电容未充电,RST 被拉高;电容充满后 RST 回到低电平,芯片开始运行。这个“高→低”的过程保证了 CPU 有足够时间稳定,不会在电压还没上来时就乱执行。

有些板子额外并联一个按键到 VCC,用作手动复位。

⚠ RST 为什么是“高电平复位”,而不是低

不同芯片复位极性不同:51 是高电平复位;STM32 等很多芯片是低电平复位(NRST)。接线前一定查手册,把复位极性搞反会导致“程序一上电就没反应”或“芯片一直处于复位状态”。

5.6 CPU 的心跳:取指—译码—执行

CPU 永远在重复三件事,一秒钟重复上百万次:

  1. 取指(Fetch)

    PC 指向的地址被送到程序存储器,取出指令字节,同时 PC 自动加 1(指向下一条)。

  2. 译码(Decode)

    控制电路看懂这条指令要做什么:是加法?是读引脚?还是跳转?需要哪些操作数?

  3. 执行(Execute)

    ALU 完成运算,结果写回寄存器或送到 IO 引脚;如果影响标志位(CY/OV/P),同步更新 PSW。

这三步的时间由时钟推动。标准 8051 每 12 个时钟周期构成一个机器周期,一条指令需要 1~4 个机器周期(1~4µs @12MHz)。这就是为什么 DelayMs() 这种“空转延时”能被算得比较准——它本质上是在数机器周期。(详情见第 8 章)

✔ 动手小任务

打开你手上的开发板原理图,找出这 5 个东西:晶振频率、RST 的 RC 参数、EA 接的是 VCC 还是 GND、LED 接在哪个口、按键接在哪个口。这 5 个信息决定了你后面所有代码怎么写。如果找不到原理图,用万用表蜂鸣档从芯片引脚反查。

本章小结

  • 51 = CPU 内核 + 程序存储器 + 数据存储器 + 定时器/串口/中断 + 4 个 IO 口,属于哈佛结构。
  • ACC 负责运算、PC 指向下一条指令、SP 管堆栈、DPTR 管片外寻址、PSW 记录状态与寄存器组选择。
  • 40 只引脚分成:电源、时钟、复位、32 个 IO、3 个总线控制。
  • EA 通常接 VCC 用片内 Flash;RST 是高电平复位,靠 RC 电路自动完成。
  • CPU 永远在执行“取指—译码—执行”,由时钟节拍驱动。
06

IO 端口:单片机如何驱动世界

// 写一个寄存器,改变 8 个引脚的电平

学完本章你能

  • 说清 P0/P1/P2/P3 的内部结构和四个口的分工差异
  • 解释“读引脚”和“读锁存器”的区别,明白为什么要先写 1 再读
  • 正确计算 IO 口的驱动能力,知道什么时候必须加驱动芯片或三极管
  • 写出可靠的“按键读入 + 消抖 + 控制 LED”的完整代码

6.1 四个口的分工

端口引脚内部结构默认用途使用注意
P0P0.0~P0.7 (39~32) 开漏,无内部上拉 扩展时作低 8 位地址/数据总线(分时复用) 当普通 IO 用必须外接 10kΩ 上拉电阻,否则输出不了高电平
P1P1.0~P1.7 (1~8) 准双向,内部弱上拉 纯用户 IO,最“干净”的一个口 推荐把 LED、按键优先接在这里,教学实验最常用
P2P2.0~P2.7 (21~28) 准双向,内部弱上拉 扩展时作高 8 位地址 不扩展存储器时可当普通 IO 用
P3P3.0~P3.7 (10~17) 准双向,内部弱上拉 复用:串口、外部中断、定时器输入、读写选通 引出复用功能时要规划好,避免和外设冲突

P3 口的第二职业(必须知道)

P3.0  RXD   串口接收数据          P3.4  T0    定时器/计数器 0 外部输入
P3.1  TXD   串口发送数据          P3.5  T1    定时器/计数器 1 外部输入
P3.2  INT0  外部中断 0            P3.6  WR    片外数据存储器写选通
P3.3  INT1  外部中断 1            P3.7  RD    片外数据存储器读选通
⚠ 复用引脚的典型冲突

如果你用 P3.0/P3.1 做串口通信,就不能再把这两个脚当普通 LED 控制脚用;如果外部中断接在 P3.2,那么对 P3.2 的普通读写会影响中断触发。遇到“外设一开,某个功能就失效”,先检查引脚复用。

6.2 内部结构:为什么叫“准双向口”

51 的 P1/P2/P3 每个位内部结构可以简化成下面这样(注意那个“弱上拉”):

P1 的一位内部结构(准双向)

   VCC
    │
  [ 弱上拉 ]         ← 大约 20~50µA 的电流能力,仅供“拉高”用
    │
    ├──────────┬──► 到内部总线(读锁存器 Q)
    │          │
  [ 强下拉 ]   └──► 引脚 P1.x ──► 外部电路
  (MOS 管)          │
    │            读引脚缓冲器 ──► 到内部总线
   GND

  写 1 → 下拉管关断 → 靠弱上拉把引脚拉高(但没力气驱动负载)
  写 0 → 下拉管导通 → 引脚被强力拉到 0V(灌电流能力强)

“准双向”的意思是:同一个引脚既能输出也能输入,不用像 AVR 那样去配置方向寄存器(DDR)。但代价是——

★ 黄金规则:读引脚之前,先给这一位写 1

因为如果这一位之前被写成 0,那个强下拉管是导通的,会把引脚死死按在 0V。此时无论外部电路给什么电平,读回来永远是 0,输入功能就废了。写 1 是关掉下拉管,让引脚“释放”给外部电路去驱动。

/* 错误示例:上一句把 P1.0 拉低,下一句想读按键,永远读到 0 */
P1_0 = 0;                  /* 点亮 LED,下拉管导通 */
if (P1_0 == 0) { ... }     /* ✗ 恒成立,因为引脚被自己拉低了 */

/* 正确做法:读之前先把它置为输入态(写 1) */
P1_0 = 1;                  /* 关下拉管,引脚交给外部 */
if (P1_0 == 0) { ... }     /* ✓ 现在读到的是外部真实电平 */

/* 用 P1 口读按键时,通常整个口先写 1 */
P1 = 0xFF;                  /* 全口置 1 = 全部成为输入态 */
u8 keys = P1;               /* 一次读回 8 个按键 */

“读引脚”还是“读锁存器”?

这是 51 的一个精巧设计,理解了它就不会对某些指令感到奇怪:

读取方式读的是什么使用它的指令原因
读锁存器 读“你上次写进去的值”(输出寄存器) ANL P1,#data
ORL P1,A
INC P1
CPL P1.0
SETB/CLR 位操作
这类指令要基于上次的输出值做修改再写回(读—改—写)。若读引脚,外部负载可能把电平拉偏,会改错其他位。
读引脚 读引脚上的真实电压 MOV A, P1
MOV C, P1.0
这类指令的目的就是获取外部信号(按键、传感器),当然要读引脚。

好在如果你遵守了“读之前先写 1”的规则,锁存器里的值就等于引脚被释放后的状态,两者基本一致,问题就不会暴露。这也是为什么很多教程只强调写 1,而不展开这个细节。

6.3 驱动能力:什么时候必须加驱动

51 的 IO 口能力很有限,超限会永久损坏引脚或整颗芯片。记住这组经验数据:

单个引脚

灌电流(拉低)约 10~20mA 安全;拉电流(输出高)只有几十~几百 µA,几乎不能点亮 LED。

整个端口

8 个引脚同时灌电流建议合计不超过 50~70mA,具体查手册的 IOL 总和限制。

整颗芯片

所有 IO 总电流有上限(常见 71mA 左右),大量驱动 LED 时要算总账。

三种典型驱动方案

① 直接驱动(小电流负载,推荐)
   LED 用灌电流接法,电流控制在 5~10mA,每路独立限流电阻。

② 三极管驱动(继电器、蜂鸣器、电机等大电流负载)
        VCC ── 负载 ── 集电极 C
                        │
   P1.0 ──[1kΩ]── 基极 B │  NPN(如 S8050)
                      发射极 E ── GND
   * 感性负载(继电器/电机/蜂鸣器)必须并联续流二极管,否则关断高压会打坏三极管

③ 专用驱动芯片(多路、大电流、隔离)
   74HC245 / ULN2003(达林顿阵列,8 路,可直接驱动继电器)/ 光耦隔离
✗ 最常见的烧芯片方式

① 直接用 IO 口驱动电机或继电器线圈,且不加续流二极管;② LED 不串限流电阻;③ 把两个 IO 口一个输出高、一个输出低直接对接(短路打架);④ 5V 的 IO 口直接接 3.3V 器件上拉系统。做这四件事之前先停下来算电流。

6.4 完整实战:按键控制 LED(含消抖)

这是把前面所有知识串起来的最小完整程序:上拉/下拉、限流电阻、位操作、消抖、IO 读写规则全在里面。

/* ------------------------------------------------------------
   现象:按下 K1,LED1 翻转一次;按下时灯的状态保持,松手不改变
   硬件:LED 接 P1.0(低电平点亮,串 470Ω)
         按键 K1 接 P3.2 与 GND 之间(低电平有效,靠内部上拉)
   ------------------------------------------------------------ */
#include <reg52.h>
#include <intrins.h>

sbit LED1 = P1^0;        /* 定义单个引脚,这是 51 的专属语法 */
sbit KEY1 = P3^2;

void DelayMs(unsigned int ms)     /* @12MHz 粗略延时 */
{
    unsigned int i, j;
    for (i = 0; i < ms; i++)
        for (j = 0; j < 110; j++);
}

void main(void)
{
    LED1 = 1;               /* 初始熄灭(低电平点亮,所以写 1 是灭)*/
    KEY1 = 1;               /* 关键:先把引脚置 1,才能作为输入读取 */

    while (1) {
        if (KEY1 == 0) {            /* 检测到疑似按下 */
            DelayMs(15);          /* 等抖动结束 */
            if (KEY1 == 0) {        /* 复检确认 */
                LED1 = ~LED1;     /* 翻转 LED,位变量取反 */
                while (KEY1 == 0);  /* 等松手,保证按一次只翻转一次 */
            }
        }
    }
}
▲ 注意三个细节:① 读 KEY1 之前先写 1;② 两次检测 + 15ms 延时构成软件消抖;③ 等松手避免长按被当成连击。
✔ 如果你用的是 STC 开发板

板上按键常接在 P3.0~P3.3 并外部上拉,LED 常接 P2 口且低电平点亮。写代码前请对照原理图确认端口,不要照抄别人的引脚号——这是初学者最常浪费时间的错误。

6.5 端口相关的其他实用知识

为什么有些口写 1 之后用万用表量不是 5V?

因为内部上拉只是“弱上拉”(几十微安级)。如果外部有负载(例如直接接 LED 到 GND),电流被拉走,电压就掉下来了。空载时通常能测到接近 VCC;一旦接上负载就明显下降,说明该换灌电流接法或加驱动。

IO 口可以直接接 3.3V 模块吗?

要分方向:5V 输出接到 3.3V 器件输入可能超过其耐压,需要电平转换或串电阻分压;3.3V 输出接到 5V 单片机输入通常可以,因为 3.3V 一般高于 51 的高电平门限(约 0.7×5V=3.5V 时要谨慎,实测为准)。稳妥方案是用电平转换芯片或专用模块。

为什么用 P1 = 0xFE 会“闪一下”其他 LED?

因为这是整字节赋值,8 个位会被同时改写。如果你只想改一位,应该用 P1 &= ~0x01; 或直接用位变量 P1_0 = 0;。整字节赋值在流水灯里是故意的,但在“只改一个灯”的场合就是 bug。

引脚不够用怎么办?

三条路:① 用矩阵扫描(8 个引脚控制 16 个按键、8 个引脚控制 64 个 LED,靠行列扫描分时点亮);② 用串行扩展芯片,如 74HC595(输出扩展,3 根线控制 8/16 路)、PCF8574(I2C 扩展 8 路 IO);③ 换引脚更多的型号或用 P0/P2 口。

本章小结

  • P0 是开漏口,当普通 IO 必须外接上拉;P1 最干净,P2 兼作高 8 位地址,P3 带串口/中断复用功能。
  • “准双向口”允许直接当输入用,但读引脚前必须先写 1,否则读到的永远是自己拉出的低电平。
  • 位运算类指令读锁存器(读—改—写),MOV 类指令读引脚,这是 51 的设计精髓。
  • 51 的 IO 灌电流能力强、拉电流能力弱,小电流负载用灌电流接法,大电流负载必须加三极管或驱动芯片。
  • 完整的按键控制代码 = 端口初始化 + 消抖 + 单次触发处理 + 松手检测。
07

存储器组织:数据住在哪里

// 51 最烧脑也最关键的一章

学完本章你能

  • 准确说出 51 的四个地址空间,并说明程序与数据的区分
  • 画出内部 RAM 的完整地图:工作寄存器区、位寻址区、用户 RAM 区
  • 解释位寻址的地址算法,说清 sbit 是怎么算出来的
  • 认出常用 SFR 的名字、地址和用途,知道哪些能被位寻址
  • 说清 data / idata / xdata / code 四种存储类型的区别
ℹ 先给一颗定心丸

这一章名词最多,但逻辑其实很整齐。记住一句话就够了:“51 有四个平行的地址空间,用不同的指令/关键字去访问,它们互相不冲突。”后面的复杂感,全部来自“同一个数字在不同空间里代表不同东西”。

7.1 四个地址空间

空间大小(标准 8051)存放什么C 关键字典型访问方式
程序存储器(片内 Flash)4KB你的程序代码、常量表codeCPU 自动取指;查表用 MOVC
片外程序存储器64KB程序太大时的扩展 ROMcodeEA=0 或超出片内空间时
片内数据存储器128B / 256B变量、工作寄存器、堆栈、位变量data / idata最快,MOV 直接寻址
片外数据存储器64KB大数据缓冲(如显示缓存、通信缓冲)xdata用 MOVX + DPTR/Ri

注意 STC89C52 是增强型号:Flash 有 8KB、RAM 有 512B(其中 256B 在片内,另外 256B 通过 MOVX 访问,属于“片内扩展 RAM”)。做题时以你芯片的手册为准。

🗺 交互演示:51 存储器地图点击查看

点击任意区块或表格行,右侧显示它的详细说明与使用要点。

← 点击左侧任意区块

这里会显示该区域的地址范围、容量以及"你会在什么场景下用到它"。

▲ 左侧是程序存储器(64KB 空间),右侧是数据存储器(片内 RAM + SFR + 片外 64KB)。

7.2 内部 RAM 详解:256 字节怎么分

标准 8051 的内部 RAM 是 256 字节(地址 00H~FFH),但它被切成两块,访问方式不同:

地址        区域                     容量    访问方式
─────────────────────────────────────────────────────────────
00H ─ 1FH   工作寄存器区(4 组 × R0~R7)  32B   直接/寄存器寻址
20H ─ 2FH   位寻址区(16 字节 = 128 位)   16B   直接寻址 + 位寻址(00H~7FH)
30H ─ 7FH   用户 RAM 区                   80B   直接/间接寻址(默认堆栈也在这)
──────────────────────────  低 128 字节:直接寻址可达 ←────────────
80H ─ FFH   高 128 字节(仅 52 系列有)    128B  只能间接寻址(@R0/@R1)
            其中 80H~FFH 与 SFR 地址重叠!              ↑
            ── 同一地址,用不同寻址方式访问到不同的东西 ──
80H ─ FFH   特殊功能寄存器 SFR              128B  只能直接寻址
✗ 全章最反直觉的一点

地址 80H~FFH 被“一址两用”:
• 用直接寻址(如 MOV A, 90H)访问的是 SFR(90H 就是 P1 口)。
• 用间接寻址(如 MOV A, @R0 且 R0=90H)访问的是高 128 字节 RAM。
这就是为什么 C 语言里要区分 data(直接寻址区)和 idata(可间接寻址的全部内部 RAM)。编译器会自动帮你选择,但你要理解它为什么存在。

工作寄存器区:4 组 R0~R7

00H~1FH 这 32 字节被分成 4 组,每组 8 个字节,名字都叫 R0~R7,靠 PSW 里的 RS1/RS0 决定当前用哪一组。下面的演示可以让你“切换寄存器组”,观察同一段 RAM 的两种解读方式。

🔄 交互演示:寄存器组切换与位寻址地址点按钮
切换当前寄存器组:
位寻址计算: 字节地址 位号 (0~7)

7.3 位寻址:51 的独门绝技

51 有两个“位寻址区”,可以直接对单独一个 bit 读写,不需要读—改—写整个字节。这是它控制能力强的重要原因。

位寻址区字节地址范围位地址范围数量
内部 RAM 的位寻址区20H ~ 2FH00H ~ 7FH16 字节 = 128 位
地址能被 8 整除的 SFR80H, 88H, 90H, 98H, A0H, A8H, B0H, B8H, D0H, E0H, F0H …80H ~ FFH(部分)11 个 SFR 共 83 位

位地址计算公式(背下来就不怕 sbit)

位地址 = 字节地址 × 8 + 位号

例 1:RAM 20H 的第 0 位    →  20H × 8 + 0 = 00H      (位寻址区的起点)
例 2:RAM 2AH 的第 5 位    →  2AH × 8 + 5 = 165H     见下方说明
例 3:P1 口地址是 90H,求 P1.3 → 90H × 8 + 3 = 483

※ 位地址超过 7FH 时,汇编里写“字节地址.位号”更直观:2AH.5、90H.3
※ C51 里直接用 sbit P1_3 = P1^3; 或 sbit b = 0x2A^5; 由编译器帮你算
★ 为什么要位寻址

因为有它,P1_0 = 1; 在汇编里是一条 SETB P1.0,只操作一位、不影响同口其他引脚、且是原子操作(不会被中断打断)。而 P1 |= 0x01; 是读—改—写三步,如果中途来了中断也改了 P1,就可能丢改动。这是位寻址在实际项目中的真实价值。

7.4 特殊功能寄存器 SFR:81 个“开关盒子”

所有外设(IO 口、定时器、串口、中断)都是通过读写 SFR 来控制的。SFR 分布在 80H~FFH,只能用直接寻址访问。下面是你在入门阶段一定会用到的:

地址名称可位寻址作用
80HP0✔端口 0 数据寄存器
81HSP✗堆栈指针
82HDPL✗数据指针低 8 位
83HDPH✗数据指针高 8 位
87HPCON✗电源控制:SMOD(波特率加倍)、掉电/空闲模式
88HTCON✔定时器控制:TF1/TF0 溢出标志、TR1/TR0 启停、IT0/IT1 中断触发方式
89HTMOD✗定时器工作方式选择(T1 高 4 位、T0 低 4 位)
8AH / 8BHTL0 / TL1✗定时器低 8 位计数
8CH / 8DHTH0 / TH1✗定时器高 8 位计数(赋初值就写这两个)
90HP1✔端口 1 数据寄存器
98HSCON✔串口控制:SM0/SM1 方式、REN 允许接收、TI/RI 标志
99HSBUF✗串口数据缓冲,写=发送,读=接收
A0HP2✔端口 2 数据寄存器
A8HIE✔中断允许控制:EA 总开关、ET0/ET1/ES/EX0/EX1 分开关
B0HP3✔端口 3 数据寄存器
B8HIP✔中断优先级控制
D0HPSW✔程序状态字(CY、RS1/RS0、OV 等)
E0HACC✔累加器
F0HB✔B 寄存器(乘除法用)

完整列表在 Keil 的 reg51.h / reg52.h 头文件里可以逐条对照,建议打开看一次,你会立刻明白代码里的 TMOD、SCON 到底是什么。

7.5 C 语言的存储类型:data / idata / xdata / code

C51 编译器扩展了 4 个关键字来指定变量住在哪个空间。明白它们,你就能看懂“变量为什么突然丢了”或“程序为什么放不下”。

unsigned char data   flag;    /* 低 128B 内部 RAM,访问最快(默认首选) */
unsigned char idata  buf[32]; /* 全部内部 RAM(含高 128B),需间接寻址,稍慢 */
unsigned char xdata  big[512];/* 片外 RAM,用 MOVX 访问,最慢但容量大 */
unsigned char code   tab[] = {1,2,3}; /* 存进 Flash,只读,省 RAM */

/* 最实用的三条经验:
   ① 频繁访问的变量 → data
   ② 常量表、字库、点阵数据 → code(否则会吃掉宝贵的 RAM)
   ③ 大数组、缓冲区 → xdata(前提是片内实在放不下) */
⚠ 51 的 RAM 真的很少

标准 8051 只有 128 字节 RAM,去掉工作寄存器和位寻址区,真正能当变量用的只有 80 字节左右,还要留一部分给堆栈。所以写 51 程序时要数着字节用内存:能放 code 的表不要放 RAM,能用一个 bit 就不要用一个 char,能用局部变量就不要用全局大数组。这是 51 编程和 PC 编程最大的思维差异。

7.6 堆栈:地址在里面怎么走

复位后 SP = 07H(在寄存器组 0 内,很危险,必须自己重设!)

典型的初始化:
    SP = 0x60;      /* 把堆栈放到用户 RAM 区,避开 R0~R7 和位寻址区 */

51 的堆栈是"向上生长"的:
    压栈 PUSH:先 SP = SP + 1,再把数据放到 SP 指向的单元
    弹栈 POP :先取 SP 指向的数据,再 SP = SP - 1

调用子函数、进中断时,返回地址会自动压栈;所以堆栈空间不够会直接
导致程序跑飞(返回地址被变量覆盖)。这是 51 项目最难查的 bug 之一。
✔ 堆栈不够的典型症状

程序运行一会儿就乱跳、进了中断就出不来、串口突然乱码。检查方法:把 SP 设到 RAM 高端(如 SP = 0x60 或更高),并减少函数嵌套层数与局部数组大小。

本章小结

  • 四个地址空间:程序(code)、片内数据(data/idata)、片外数据(xdata)、位寻址空间。
  • 内部 RAM:00~1FH 工作寄存器 4 组、20~2FH 位寻址区、30~7FH 用户 RAM、高 128B 只能间接寻址。
  • 80H~FFH 一址两用:直接寻址是 SFR,间接寻址是高 128B RAM。
  • 位地址 = 字节地址 × 8 + 位号;位寻址区与可位寻址 SFR 支持单 bit 的原子操作。
  • SFR 是控制一切外设的开关盒子;常用 20 个左右必须记住名字与作用。
  • 51 的 RAM 极少,常量放 code、少用大数组、牢记重设 SP。
08

时钟与定时器 / 计数器

// 单片机的“秒表”和“数数的手”

学完本章你能

  • 解释时钟周期、机器周期、指令周期的关系,算出 12MHz 下 1 个机器周期是多少微秒
  • 说清定时器模式和计数器模式的区别,以及 TMOD 每一位的含义
  • 独立算出定时初值,并用查询法或中断法写出精确定时程序
  • 明白标准 51 定时器为什么“最多只能定 60ms 左右”,以及如何扩展到秒级

8.1 时钟:一切时序的源头

时钟周期(振荡周期)= 1 / 晶振频率        ← 最小的节拍
     12MHz 时 = 1/12µs ≈ 0.0833µs

机器周期 = 12 × 时钟周期               ← 标准 51 的"一步"
     12MHz 时 = 12/12µs = 1µs           ← 好记:12MHz → 1µs
     11.0592MHz 时 ≈ 1.085µs

指令周期 = 1 ~ 4 个机器周期            ← 不同指令耗时不同
ℹ 现代增强型 51 的差异

STC 的 1T 系列(如 STC8、STC15)把机器周期缩短为 1 个时钟周期,速度是传统 51 的 12 倍;同时它们的定时器还带预分频和自动重载,配置方式更像 STM32。本教程按传统 12T 讲,理解原理后再看 STC 手册会很容易。

8.2 定时器 / 计数器:同一个硬件的两种用法

定时器模式(C/T = 0)

计数脉冲来自内部机器周期。每过一个机器周期,计数值加 1。因为机器周期时间是固定的,所以“数了多少个数”就等于“过了多少时间”。

用途:精确定时、周期性任务、软件延时替代、产生波特率。

计数器模式(C/T = 1)

计数脉冲来自外部引脚 T0(P3.4) / T1(P3.5) 的下降沿。外部来一个脉冲就加 1。

用途:统计外部事件次数(按键次数、编码器脉冲、转速测量)。

核心原理:溢出即"到点"

51 的定时器是加计数的:给 TH/TL 装一个初值,然后让它往上数,数到最大值再加 1 就“溢出”——溢出时硬件自动把溢出标志 TF0/TF1 置 1。所以:

需要的计数值 = 定时时间 / 机器周期
定时初值     = 最大值 - 需要的计数值

方式 1(16 位):最大值 = 65536,初值写入 TH 和 TL
方式 2(8 位自动重载):最大值 = 256,初值只写 TH,TL 溢出后自动从 TH 重载

例:12MHz 晶振,机器周期 1µs,要定时 50ms
    需要的计数 = 50000
    初值 = 65536 − 50000 = 15536 = 0x3CB0
    所以  TH0 = 0x3C;  TL0 = 0xB0;
⏱ 交互演示:定时器初值计算器改数字看结果

8.3 TMOD 与 TCON:两个必须会“拼”的寄存器

TMOD(89H,不可位寻址)——选工作方式

TMOD  ┌────┬────┬────┬────┬────┬────┬────┬────┐
      │ GATE│ C/T│ M1 │ M0 │ GATE│ C/T│ M1 │ M0 │
      └────┴────┴────┴────┴────┴────┴────┴────┘
        D7   D6   D5   D4   D3   D2   D1   D0
        └──── 定时器 1 ────┘  └──── 定时器 0 ────┘

M1 M0   工作方式
 0  0   方式 0:13 位定时器(为了兼容老芯片,很少用)
 0  1   方式 1:16 位定时器/计数器(最常用)
 1  0   方式 2:8 位自动重载(做串口波特率发生器必用)
 1  1   方式 3:T0 拆成两个 8 位定时器(特殊用途)

C/T :0 = 定时器(内部时钟);1 = 计数器(外部引脚脉冲)
GATE:0 = 只要 TR 置 1 就启动(99% 的情况用 0)
      1 = 还要 INT 引脚为高才启动(测脉冲宽度时用)

典型写法:TMOD = 0x01; 表示定时器 1 不用、定时器 0 工作在方式 1 定时模式。

TCON(88H,可位寻址)——启停与标志

位名称含义常用操作
D7TF1定时器 1 溢出标志,溢出时硬件置 1中断中硬件自动清除;查询法要软件清 0
D6TR1定时器 1 运行控制:1 = 启动,0 = 停止TR1 = 1;
D5TF0定时器 0 溢出标志while(!TF0); TF0 = 0;
D4TR0定时器 0 运行控制TR0 = 1;
D3/D2IE1/IT1外部中断 1 标志 / 触发方式(0 低电平,1 下降沿)IT1 = 1;
D1/D0IE0/IT0外部中断 0 标志 / 触发方式IT0 = 1;

8.4 两种编程方式:查询法与中断法

方式一:查询法(不占中断资源,简单但不精确)

/* 定时 50ms,查询 TF0 */
void Timer0_Init(void)
{
    TMOD &= 0xF0;        /* 只清 T0 的 4 位,保留 T1 的配置(好习惯)*/
    TMOD |= 0x01;        /* T0 = 方式 1,定时器模式 */
    TH0 = 0x3C;          /* 初值高 8 位 */
    TL0 = 0xB0;          /* 初值低 8 位 */
    TR0 = 1;             /* 启动 */
}

void Delay50ms(void)
{
    while (!TF0);          /* 原地等待溢出 */
    TF0 = 0;              /* 软件清零,为下一次做准备 */
    TH0 = 0x3C;          /* 重装初值(方式 1 必须手动重装!)*/
    TL0 = 0xB0;
}
⚠ 方式 1 必须手动重装初值

方式 1 溢出后计数器从 0 重新开始,所以每次都要重新写 TH/TL,否则下一次定时会变成 65.5ms。如果你忘了重装,现象就是“第一次准,后面越来越慢”。方式 2 会自动重载,但只有 8 位范围。

方式二:中断法(推荐,不阻塞主程序)

/* 1 秒闪烁 LED:50ms × 20 次
   LED 接 P1.0,低电平点亮;晶振 12MHz */
#include <reg52.h>
sbit LED = P1^0;
u8 cnt = 0;

void Timer0_Init(void)
{
    TMOD &= 0xF0;  TMOD |= 0x01;   /* T0 方式 1 定时 */
    TH0 = 0x3C;  TL0 = 0xB0;          /* 50ms @12MHz */
    ET0 = 1;                          /* 允许 T0 中断 */
    EA  = 1;                          /* 打开总中断 */
    TR0 = 1;                          /* 启动定时器 */
}

void Timer0_ISR(void) interrupt 1    /* T0 的中断号是 1 */
{
    TH0 = 0x3C;  TL0 = 0xB0;          /* 先重装初值,减少误差 */
    if (++cnt >= 20) {                /* 20 × 50ms = 1s */
        cnt = 0;
        LED = ~LED;                   /* 每秒翻转一次 */
    }
}

void main(void)
{
    Timer0_Init();
    while (1) { /* 主程序空着也行,定时完全由中断完成 */ }
}
★ 为什么必须“50ms × 20 次”而不能一次定时 1 秒

因为方式 1 最多计数 65536 个机器周期,12MHz 下就是 65536µs ≈ 65.5ms。所以秒级、分钟级定时必须靠软件累加:在中断里数次数,凑够目标时间再动作。这就是“硬件定时 + 软件计数”的标准套路。

8.5 定时精度:误差从哪来

误差来源说明改善办法
晶振本身精度普通晶振 ±20~50ppm,一天可能差几秒要求高时用温补晶振或 DS1302/RTC 芯片校时
初值取整误差算出的初值不是整数时只能四舍五入用 11.0592MHz 晶振让串口相关计算整除;或补偿初值
中断响应延迟进中断前要完成当前指令,且要保护现场,有微秒级延迟中断函数越短越好;关键任务用硬件 PWM
重装初值的时间方式 1 手动重装期间计数器仍在走重装语句放在中断最前面;或用方式 2 自动重载
✔ 为什么延时函数不用定时器?

for(i=0;i<110;i++); 这种空转延时简单但有两个问题:① 时间不准(编译器优化等级一改就变);② 期间 CPU 什么也做不了。定时器中断才是正道。空转延时只适合“初始化等待传感器就绪”这种对精度无所谓的场合。真正需要高精度周期波形时,应该用硬件 PWM。

本章小结

  • 12MHz 晶振下 1 个机器周期 = 1µs,这是所有定时计算的基础。
  • 定时器数内部机器周期,计数器数外部引脚脉冲,由 TMOD 的 C/T 位选择。
  • 定时初值 = 最大值 − 定时时间 ÷ 机器周期;方式 1 最大约 65.5ms。
  • 方式 1 必须手动重装初值;方式 2 自动重载且常用于串口波特率。
  • 秒级以上定时用“硬件定时器 + 中断里软件计数”,并注意中断服务函数要短。
09

中断系统

// 让 CPU 学会“被打断后还能回来接着干”

学完本章你能

  • 用自己的话解释中断、中断源、中断向量、中断优先级、中断嵌套
  • 写出任意一个中断的完整配置流程(开分开关 + 开总开关 + 写中断函数)
  • 正确写出 C51 的中断服务函数,知道 interrupt n 与 using m 的含义
  • 诊断“进了中断出不来”“中断只触发一次”这类典型故障

9.1 一个生活类比就懂了

你在写作业(主程序)。突然手机响了(中断请求)。你把笔放下、在书页上夹个书签记住写到哪(保护现场 / 压栈返回地址),接完电话(执行中断服务函数),回到书签处继续写(恢复现场 / 返回)。

如果电话是妈妈打来的(高优先级)而门铃也响了(低优先级),你会先接电话再接门铃——这就是中断优先级。如果接电话时又来一条更重要的短信(更高优先级中断),你先看短信再继续打电话,这就是中断嵌套。

★ 中断的三大价值

① 实时响应:不用傻等(查询法要一直轮询,事件可能稍纵即失)。
② 解放 CPU:主程序可以专注干活,事件来了才切换。
③ 精确计时:定时器中断让动作与时钟严格对齐,比空转延时准得多。

9.2 51 的中断源与中断向量表

“中断向量”就是每个中断对应的固定入口地址。CPU 一旦响应某个中断,就自动跳到这个地址执行你写的中断函数。这是硬件规定死的,必须记住:

中断源触发条件向量地址中断号 interrupt n分开关(IE 中的位)
外部中断 0 INT0 (P3.2)P3.2 出现低电平或下降沿0003H0EX0
定时器 0 TF0T0 计数溢出000BH1ET0
外部中断 1 INT1 (P3.3)P3.3 出现低电平或下降沿0013H2EX1
定时器 1 TF1T1 计数溢出001BH3ET1
串口 RI 或 TI收到一字节 或 发送完成0023H4ES
(52 增强)定时器 2TF2 / EXF2002BH5ET2

为什么地址间隔是 8 字节?因为每个中断入口只留了 8 个字节放跳转指令。真正的中断函数放在程序的其他位置,入口处只是一条跳转。这就是“中断向量”这个说法的由来。

✔ 背表格的窍门

顺序是 外部0 → 定时0 → 外部1 → 定时1 → 串口,地址从 0003H 起每 8 字节一个:0003、000B、0013、001B、0023。记住“外0定0外1定1串”和“3 起、8 步进”,中断号 0~4 的顺序也与之一致。

9.3 两个控制寄存器:IE 与 IP

IE(A8H,可位寻址)——中断允许(开关)

IE   ┌────┬────┬────┬────┬────┬────┬────┬────┐
     │ EA │ —  │ —  │ ES │ET1 │EX1 │ET0 │EX0 │
     └────┴────┴────┴────┴────┴────┴────┴────┘
       D7   D6   D5   D4   D3   D2   D1   D0

EA :总开关(必开)。EA = 1 才允许任何中断
ES :串口中断允许      ET1:定时器 1 中断允许
EX1:外部中断 1 允许   ET0:定时器 0 中断允许
EX0:外部中断 0 允许

典型写法:ET0 = 1; EA = 1;      /* 用位操作,清晰不易错 */
也可以:IE = 0x82;              /* 位操作本质就是这一句 */
⚠ 忘了开 EA 是 90% 的“中断不响应”原因

只写 ET0 = 1; 而不写 EA = 1;,中断永远不会来。反过来,如果所有功能都“莫名不工作”了,先检查是不是误开了某个中断却没写中断函数。

IP(B8H,可位寻址)——中断优先级

IP   ┌────┬────┬────┬────┬────┬────┬────┬────┐
     │ —  │ —  │ —  │ PS │PT1 │PX1 │PT0 │PX0 │
     └────┴────┴────┴────┴────┴────┴────┴────┘
      相应位置 1 = 高优先级,置 0 = 低优先级(默认全部为 0)

规则:
 ① 高优先级中断可以打断正在执行的低优先级中断(中断嵌套)
 ② 同优先级中断不能互相打断,按"内部查询顺序"排队:
    外部0 → 定时0 → 外部1 → 定时1 → 串口
 ③ 任何中断都不能打断同级或高优先级中断
✗ 新手最容易犯的错:在中断里等太久

如果在高优先级中断里做长延时、串口打印、等待按键,会把低优先级中断全部堵死。规则是:中断函数只做“必须马上做的事”,其余交给主程序通过标志位处理。例如中断里只 flag = 1;,主循环里看到 flag 再执行耗时操作。

9.4 CPU 响应中断的完整过程

  1. 中断请求

    某个中断源满足条件(定时器溢出、引脚电平变化、串口收到数据),对应标志位置 1。

  2. 判断是否响应

    要同时满足:该中断的分开关和 EA 都打开、没有更高优先级中断在执行、当前指令已执行完。

  3. 保护断点

    硬件自动把 PC 的值压入堆栈(记住回来的路),然后跳到中断向量地址。

  4. 执行中断服务函数

    你的代码运行。C 编译器还会自动保护用到的寄存器(压栈),函数结束时恢复。

  5. 清除标志并返回

    定时器/外部中断的标志通常由硬件自动清除(串口的 TI/RI 必须软件清!),然后执行 RETI 弹出 PC,继续主程序。

ℹ 中断标志谁负责清?

定时器中断(TF0/TF1):进入中断时硬件自动清 0,你不用管。
外部中断边沿触发(IE0/IE1):硬件自动清 0。
外部中断电平触发:不会自动清,必须靠外部电平撤销,否则会反复触发。
串口中断(TI/RI):必须软件清 0,忘了清就会一直重复进中断——这是串口调试最常见的“死循环进中断”原因。

9.5 外部中断的两种触发方式

方式IT0/IT1 设置触发条件适用场景
低电平触发IT0 = 0引脚保持低电平期间持续请求需要持续响应(如按键按住重复动作);注意必须及时撤销低电平
下降沿触发IT0 = 1引脚由高变低的那一瞬间触发一次按键、脉冲计数、外部事件捕获(最常用)
/* 外部中断 0 完整配置:P3.2 下降沿触发,按一下 LED 翻转一次 */
void ExtInt0_Init(void)
{
    IT0 = 1;      /* 下降沿触发(写 0 则是低电平触发)*/
    EX0 = 1;      /* 允许外部中断 0 */
    EA  = 1;      /* 打开总中断 */
}

void ExtInt0_ISR(void) interrupt 0   /* 中断号 0 = 外部中断 0 */
{
    DelayMs(10);               /* 若接按键,这里做一次消抖 */
    if (INT0_PIN == 0) {       /* 确认真按下 */
        LED = ~LED;
    }
}

/* 主函数里要注意:INT0 引脚必须先置 1(读引脚规则),
   而且中断函数执行期间该引脚应保持高电平 */

9.6 C51 的中断函数语法

void 函数名(void) interrupt 中断号 [using 寄存器组号]
{
    /* 中断服务代码 */
}

/* 例子:定时器 0 中断,使用第 1 组工作寄存器 */
void T0_ISR(void) interrupt 1 using 1
{
    TH0 = 0x3C;  TL0 = 0xB0;
}
关键点说明
函数必须无形参、无返回值写成 void xxx(void),不能传参,因为跳转是硬件行为
不要手动调用它它是被硬件“调用”的,你在 main 里写 T0_ISR(); 是错的
interrupt n 决定入口地址n 就是中断号(0~4),编译器自动生成入口跳转和 RETI
using m 是可选优化指定该函数使用哪组工作寄存器,避免压栈保护,加快响应;混用不当会出错,初学可以省略
共享变量要加 volatile主程序和中断都会访问的变量必须声明 volatile,否则编译器优化后主程序读不到变化
中断函数要短避免浮点运算、长延时、串口打印;复杂处理交给主循环
/* volatile 的典型场景:中断置标志,主程序检查 */
volatile bit g_tick = 0;      /* 必须 volatile,否则主程序可能永远读到 0 */

void T0_ISR(void) interrupt 1
{
    TH0 = 0x3C;  TL0 = 0xB0;
    g_tick = 1;               /* 只做最简单的事 */
}

void main(void)
{
    Timer0_Init();
    while (1) {
        if (g_tick) {
            g_tick = 0;
            /* 耗时的活儿放在这里做,不阻塞中断 */
        }
    }
}

9.7 常见故障速查

现象最可能的原因
中断完全不进忘了 EA = 1;忘了开分开关;中断号写错;中断源条件没满足(如 IT0 写反、定时器没 TR0=1)
只进一次就再也不进串口中断忘了清 TI/RI;方式 1 定时器忘了重装初值;电平触发的外部中断没撤销低电平
进中断后程序跑飞中断函数里堆栈用太多(大数组、深层调用)导致溢出;using 寄存器组冲突
主程序里读不到中断改的变量变量没加 volatile,被编译器优化掉了
系统变慢、按键无响应中断服务函数太长,或高优先级中断把 CPU 占满

本章小结

  • 中断 = 硬件事件打断主程序,保存断点后跳到固定向量地址执行,处理完再返回。
  • 记住向量表:外0=0003H、定0=000BH、外1=0013H、定1=001BH、串口=0023H,中断号依次为 0~4。
  • 使用任何中断都要“开分开关 + 开总开关 EA + 写中断函数”三件套。
  • 中断函数要短,耗时操作通过 volatile 标志位交给主循环。
  • 串口中断的 TI/RI 必须软件清零;定时器方式 1 中断里必须重装初值。
10

串口通信与常见总线

// 让单片机开口说话:调试、组网、接模块

学完本章你能

  • 解释串行与并行的区别,以及 UART 一帧数据的组成
  • 独立配置串口(SCON、TMOD、TH1、波特率),并写出收发程序
  • 算清波特率误差,理解为什么串口调试优选 11.0592MHz 晶振
  • 说清 I2C、SPI、单总线的相同点与区别,知道各自适合什么器件

10.1 为什么要“串行”

并行通信串行通信
数据线数量8 根同时传 1 字节1 根(+时钟线)逐位传
速度同频率下更快较慢,但现代接口频率已极高
引脚/成本费引脚、线多、板子复杂省引脚、接线简单、可拉远
单片机里的例子P0 口扩展总线、LCD1602 的 8 位模式UART、I2C、SPI、单总线

51 引脚本来就少,所以外设几乎都用串行接口。UART(通用异步收发器)是最常用的一个:它不需要时钟线,双方事先约定好同样的波特率就能通信。

10.2 UART 一帧数据长什么样

空闲(高)  起始位  D0  D1  D2  D3  D4  D5  D6  D7  停止位  空闲(高)
────────┐      ┌──┬──┬──┬──┬──┬──┬──┬──┐        ┌──────────
        └──────┘  │  │  │  │  │  │  │  └────────┘
        低电平   LSB                     MSB   高电平

一帧 = 1 起始位 + 8 数据位 + 1 停止位 = 10 位      (最常用的 8N1 格式)
"8N1" 读作:8 位数据、无校验(None)、1 位停止位

波特率 9600 表示每秒传 9600 个位 → 一字节 10 位 → 约 960 字节/秒
这也是为什么串口打印多了会"卡":它真的很慢。
ℹ 波特率 vs 比特率

对 UART 这种每个符号只表示 1 位的调制方式,两者数值相同,日常可以混用。常见档位:4800、9600、19200、38400、57600、115200。收发双方的波特率必须一致,不一致的表现是乱码(收到一堆看不懂的字符)。

10.3 SCON:串口控制寄存器(98H,可位寻址)

SCON ┌────┬────┬────┬────┬────┬────┬────┬────┐
     │SM0 │SM1 │SM2 │REN │TB8 │RB8 │TI  │RI  │
     └────┴────┴────┴────┴────┴────┴────┴────┘
       D7   D6   D5   D4   D3   D2   D1   D0

SM0 SM1  工作方式
 0   0   方式 0:同步移位寄存器(扩展 IO,不常用)
 0   1   方式 1:8 位 UART,波特率可变          ← 最常用
 1   0   方式 2:9 位 UART,波特率固定
 1   1   方式 3:9 位 UART,波特率可变

REN :允许接收(1 = 允许)。忘了置 1 就永远收不到数据
TI  :发送中断标志。写 SBUF 后自动开始发送,发完硬件置 1,必须软件清 0
RI  :接收中断标志。收到完整一帧后硬件置 1,必须软件清 0

常用写法:SCON = 0x50;   /* SM0=0 SM1=1 方式1,REN=1 允许接收 */

10.4 波特率是怎么产生的

方式 1 和方式 3 的波特率由定时器 1 工作在方式 2(8 位自动重载)产生。公式是:

波特率 = (2SMOD / 32) × ( fosc / (12 × (256 − TH1)) )
其中 PCON 的 SMOD 位 = 0 或 1(可让波特率翻倍)

反过来算 TH1:

TH1 = 256 − f_osc / (12 × 32 × 波特率 / 2^SMOD)

例:11.0592MHz,9600bps,SMOD = 0
    TH1 = 256 − 11059200 / (12 × 32 × 9600)
        = 256 − 11059200 / 3686400
        = 256 − 3
        = 253 = 0xFD      ← 恰好是整数!误差为 0

同一个波特率若用 12MHz 晶振:
    TH1 = 256 − 12000000 / 3686400 = 256 − 3.255 = 252.7 → 只能取 253 或 252
    取 253 时实际波特率 ≈ 10416.7,误差约 8.5%   ← 太大了,通信会出错!
★ 为什么教材都推荐 11.0592MHz

因为 11.0592MHz = 9600 × 1152,能被各种标准波特率整除,算出的 TH1 都是整数,误差为 0。而 12MHz 虽然定时器算起来漂亮(1µs 一个机器周期),但波特率误差大。结论:要用串口就用 11.0592MHz;只做定时不做串口,12MHz 更省心。(可用下面的计算器亲自验证)

📡 交互演示:波特率与 TH1 计算对比两种晶振

10.5 完整的串口收发程序

/* ------------------------------------------------------------
   串口演示:收到什么就回显什么(回环),并每秒主动发一句问候
   硬件:11.0592MHz 晶振,波特率 9600,8N1,通过 USB-TTL 接电脑
   注意:51 的 TXD 接 TTL 模块的 RXD,RXD 接 TXD(交叉接线),GND 共地
   ------------------------------------------------------------ */
#include <reg52.h>

void Uart_Init(void)
{
    SCON = 0x50;        /* 方式 1,允许接收 REN = 1 */
    TMOD &= 0x0F;        /* 清 T1 的配置位,保留 T0 */
    TMOD |= 0x20;        /* T1 = 方式 2(8 位自动重载),作波特率发生器 */
    TH1  = 0xFD;        /* 9600bps @11.0592MHz,SMOD=0 */
    TL1  = 0xFD;        /* 方式 2 下 TL1 会自动从 TH1 重载 */
    PCON &= 0x7F;       /* SMOD = 0(若要倍速则写 PCON |= 0x80)*/
    TR1  = 1;           /* 启动 T1 */
    ES   = 1;           /* 允许串口中断 */
    EA   = 1;           /* 打开总中断 */
}

/* 发送一个字节(查询法,简单可靠)*/
void Uart_SendByte(unsigned char dat)
{
    SBUF = dat;             /* 写入 SBUF 即开始发送 */
    while (!TI);             /* 等待发送完成(TI 被硬件置 1)*/
    TI = 0;                /* 必须软件清 0!*/
}

/* 发送字符串 */
void Uart_SendStr(char *s)
{
    while (*s) { Uart_SendByte(*s++); }
}

/* 接收中断:收到一个字节就原样发回去 */
void Uart_ISR(void) interrupt 4
{
    if (RI) {                   /* 接收中断 */
        RI = 0;               /* 先清标志,再处理 */
        Uart_SendByte(SBUF);    /* 回显 */
    }
    if (TI) { TI = 0; }        /* 发送中断也要清(本程序用查询法发送)*/
}

void main(void)
{
    Uart_Init();
    Uart_SendStr("UART ready!\r\n");

    while (1) {
        Uart_SendStr("hello 8051\r\n");
        DelayMs(1000);
    }
}
✔ 让串口支持 printf:调试神器

Keil C51 可以重定向 printf,只要自己实现一个 putchar:

#include <stdio.h>
char putchar(char c)          /* 库函数 printf 最终会调用它 */
{
    if (c == '\n') { Uart_SendByte('\r'); }  /* 串口助手需要 \r\n 才换行 */
    Uart_SendByte(c);
    return c;
}
/* 之后就能用了: */
printf("value = %d, hex = 0x%02X\r\n", val, val);

注意:%f 浮点格式化会占用大量 Flash,51 上尽量用整数或定点数打印。

⚠ 串口调试最常见的 5 个坑

① 忘了清 TI/RI → 一直重复进中断,程序像死机。
② TX/RX 没交叉 → 完全没有数据。正确接法:51 的 TXD → TTL 模块的 RXD。
③ 没有共地 → 收到乱码或收不到。
④ 波特率不一致或晶振设错 → 满屏乱码。Keil 工程里也要把晶振频率填对,否则 DelayMs 也不准。
⑤ 烧录时串口被占用 → 下载失败。下载前先关掉串口助手,或使用带自动断电下载的板子。

10.6 三种常用同步总线对比

UART 是异步的(无时钟线)。接传感器、存储器时,更常用带时钟线的同步总线:

总线线数引脚速度特点典型器件
I2C2SCL, SDA100k/400kHz 两根线可挂多个器件(靠 7 位地址区分);必须外接上拉电阻(开漏);有应答机制 AT24C02、PCF8574、OLED、MPU6050
SPI4SCK, MOSI, MISO, CS几 MHz~几十 MHz 最快、全双工、无地址(每个从机一根 CS 片选);线多 W25Q Flash、NRF24L01、TFT 屏、SD 卡
单总线1DQ较慢 一根线既供电又通信(寄生供电);对时序要求极严格(微秒级) DS18B20 温度传感器、DHT11
ℹ 标准 51 没有硬件 I2C/SPI

传统 8051 只内置 UART,I2C 和 SPI 需要用普通 IO 口软件模拟时序(这叫“软件 I2C/软件 SPI”),也就是按手册的时序图,精确地拉高拉低引脚、读引脚。这是学习 51 时非常有价值的一课:你会真正理解“时序图”怎么变成代码。STC 的一些新型号内置了硬件 I2C/SPI,用起来就只要配置寄存器了。

本章小结

  • 串行省引脚、接线简单;UART 一帧 = 起始位 + 8 数据位 + 停止位(8N1)。
  • 配置串口四件事:SCON(方式 + 允许接收)、TMOD 让 T1 工作在方式 2、算 TH1、开 ES/EA。
  • TI 和 RI 必须软件清零;TXD 与 RXD 要交叉连接,且必须共地。
  • 11.0592MHz 能让标准波特率的误差为 0,是串口应用的首选晶振。
  • I2C 两根线多器件、SPI 四根线最快、单总线一根线但时序苛刻;标准 51 靠软件模拟。
11

51 的 C 语言必备要点

// 把前面所有原理,变成能编译运行的代码

学完本章你能

  • 写出一份规范的 51 程序骨架:头文件、类型定义、引脚定义、初始化、主循环
  • 说清 sbit / sfr / bit / volatile / interrupt 这些 51 扩展关键字的作用
  • 在资源受限的 8 位机上做出正确的数据类型与内存选择
  • 读懂寄存器配置代码,并能自己查手册配置一个新外设
ℹ 前提说明

本章假设你已经会 C 语言的基本语法(变量、循环、if、函数、数组)。如果你还没学过 C,建议先花一周学完基础语法再回来——51 的 C 只需要基础语法加上少量扩展关键字。以下内容全部是普通 C 之上、51 独有的部分。

11.1 程序骨架:从今天起就按这个结构写

/* ============================================================
 * 文件名 : main.c
 * 芯片   : STC89C52RC @ 11.0592MHz
 * 功能   : 按键控制 LED,并用串口回显(一句话说明现象)
 * 硬件   : LED 接 P1.0(低电平点亮) 按键接 P3.2(低电平有效)
 * 日期   : 2025-xx-xx
 * ============================================================ */
#include <reg52.h>        /* 51 寄存器定义头文件(必加)*/
#include <intrins.h>       /* 需要 _nop_() 循环移位等内部函数时加 */

/* ---------- 1. 类型别名:让代码更短、更清晰 ---------- */
typedef unsigned char u8;   /* 0~255,单片机的"万能类型" */
typedef unsigned int  u16;  /* 0~65535,用于延时、计数值 */
typedef unsigned long u32;

/* ---------- 2. 引脚与常量定义:集中管理,方便换板子 ---------- */
sbit LED1 = P1^0;
sbit KEY1 = P3^2;
#define LED_ON   0          /* 低电平点亮,用宏表达语义 */
#define LED_OFF  1

/* ---------- 3. 全局变量:尽量少,够用就行 ---------- */
volatile u8 g_tick = 0;       /* 中断与主程序共享 → 必须 volatile */

/* ---------- 4. 函数声明 ---------- */
void System_Init(void);
void DelayMs(u16 ms);

/* ---------- 5. 主函数 ---------- */
void main(void)
{
    System_Init();                   /* 所有初始化集中在这里 */
    while (1) {
        /* 主循环只做逻辑,不做长阻塞 */
    }
}

/* ---------- 6. 函数实现 ---------- */
void System_Init(void)
{
    LED1 = LED_OFF;                  /* 先给引脚确定状态,避免上电闪一下 */
    KEY1 = 1;                       /* 读引脚前先写 1 */
}

void DelayMs(u16 ms)
{
    u16 i, j;
    for (i = 0; i < ms; i++)
        for (j = 0; j < 110; j++);
}
✔ 为什么强调“先给引脚确定状态”

51 复位后所有 IO 都是高电平(P0 是浮空)。如果你的 LED 是低电平点亮,那上电瞬间灯是灭的,看起来“正常”;但如果外接的是继电器、电机驱动,上电瞬间的高电平可能让设备误动作。工业项目里必须在上电后立刻把所有输出设成安全状态。

11.2 51 独有的扩展关键字

关键字用途示例说明
sbit定义一个可位寻址的位sbit LED = P1^0;只有 51 有。等价于汇编的 SETB/CLR 某一位,是原子操作
sfr把一个8 位寄存器地址起个名字sfr P1 = 0x90;头文件里已经写好了,自己定义新寄存器时才用
sfr16定义 16 位寄存器sfr16 DPTR = 0x82;如 DPTR、部分新型号定时器
bit声明位变量(放在位寻址区)bit flag;省内存(1 位而不是 1 字节),只能 0/1
volatile告诉编译器“这个变量可能被意外改变”volatile u8 adc;中断共享变量、硬件寄存器必须加
interrupt n把函数声明成中断服务函数void T0() interrupt 1n 是中断号,编译器负责入口与 RETI
using m指定中断函数使用哪组工作寄存器... interrupt 1 using 1可选优化,初学建议省略
code/data/idata/xdata指定变量存放的存储空间u8 code tab[]={1,2};见第 7 章
reentrant可重入函数(用软件栈)void f() reentrant递归或被中断调用的函数需要,速度慢、慎用
/* sbit 的三种写法(等价) */
sbit P1_0 = P1^0;      /* 最常用:口地址 + 位号 */
sbit P1_0 = 0x90^0;    /* 直接写寄存器地址 + 位号 */
sbit P1_0 = 0x90;       /* 位地址形式(这里 0x90 恰好是 P1.0 的位地址)*/

/* 使用位变量,注意它只能取 0 或 1 */
bit key_pressed = 0;
if (KEY1 == 0) { key_pressed = 1; }

/* 声明一个新寄存器(例如某型号的 PWM 寄存器在 0xCC) */
sfr  PWM_CTL = 0xCC;
sbit PWM_EN  = PWM_CTL^7;

11.3 数据类型与取值范围(8 位机必须精打细算)

类型C51 中字节数取值范围使用建议
bit1 位0 / 1标志位首选,最省内存
unsigned char / u810 ~ 255最常用,计数器、寄存器值、数组元素
signed char1−128 ~ 127需要负数时用
unsigned int / u1620 ~ 65535延时循环、定时器计数、地址
signed int2−32768 ~ 32767需要负数的中间计算
unsigned long / u3240 ~ 4294967295少用:运算慢、占内存
float4约 ±1.2e−38 ~ 3.4e38尽量避免:51 无硬件浮点,一次浮点乘法几百微秒
✗ 三个高频数值陷阱

① 整数溢出:unsigned char n = 250; n = n + 10; 结果不是 260 而是 4(260 − 256)。计数器到边界要特别注意。
② 整数除法:5 / 2 在整数世界里等于 2,余数要用 5 % 2 取。做比例运算时要先乘后除:(a * 100) / b 才能保留精度。
③ 有符号与无符号混用:unsigned char a = 200; if (a > −1) 这种比较会因为隐式类型转换得出奇怪结果,尽量避免混用。

11.4 配置一个外设的标准五步法

以后你会配置几十种外设(定时器、串口、ADC、PWM、外部芯片)。它们的配置思路完全一致,照这五步做就不会乱:

  1. 查手册确认寄存器

    找到相关的寄存器(如 TMOD、TCON)和它的每一位含义。不要凭记忆。

  2. 确定“保留位”和“目标位”

    哪些位是别人的(比如 T1 的配置)、哪些是你要改的。能用位操作就别整字节赋值。

  3. 按“读—改—写”方式修改

    TMOD &= 0xF0; TMOD |= 0x01; —— 先清目标位,再置新值,保留其他位。

  4. 打开允许/中断开关

    例如 ET0 = 1; EA = 1; 或 REN = 1; 开关没开,配置再好也不工作。

  5. 启动并验证

    例如 TR0 = 1; 然后用 LED、串口、万用表验证现象是否符合预期。

/* 对照示例:同一份需求,两种写法的差别 */

/* ✗ 危险写法:整字节覆盖,把 T1 的配置也清掉了 */
TMOD = 0x01;

/* ✓ 推荐写法:只动 T0 的 4 位,T1 的设置保持原样 */
TMOD &= 0xF0;      /* 清低 4 位(T0 部分)*/
TMOD |= 0x01;      /* 写入 T0 = 方式 1 定时 */

/* 记忆口诀:清位用 &= ~掩码,置位用 |= 掩码 */

11.5 头文件与工程组织

/* ---------- 单文件小程序:一个 main.c 就够了 ---------- */

/* ---------- 多文件工程:推荐这样分 ---------- */
/* timer.h */
#ifndef __TIMER_H__          /* 头文件保护,防止重复包含 */
#define __TIMER_H__
void Timer0_Init(void);
void DelayMs(unsigned int ms);
#endif

/* timer.c */
#include "timer.h"
#include <reg52.h>
void Timer0_Init(void) { /* ... */ }

/* main.c */
#include "timer.h"
void main(void) { Timer0_Init(); while(1){} }

Keil 里要记得把 timer.c 加入工程(Add Files to Group),只创建文件不加入工程会报“undefined symbol”。

11.6 编译器的“好意”:优化与 volatile

/* 场景:中断里改变量,主程序等待它 */
u8 flag = 0;                    /* ✗ 少了 volatile */

void T0_ISR(void) interrupt 1 { flag = 1; }

void main(void)
{
    Timer0_Init();
    while (flag == 0);          /* 编译器可能认为 flag 永不改变,
                                       直接优化成 while(1);  → 死循环! */
}
★ 什么时候必须加 volatile

只要一个变量可能被“你看不见的代码”修改,就必须加 volatile:
• 中断服务函数与主程序共享的变量;
• 映射到硬件寄存器的变量(值随硬件变化,例如 ADC 结果寄存器);
• 在多任务/RTOS 中被其他任务修改的变量。
它只影响编译器优化,不改变程序逻辑,代价只是效率略降——该加就加,别省。

本章小结

  • 按“头文件 → 类型别名 → 引脚定义 → 全局变量 → 函数 → main → 实现”的骨架写代码,可读性立刻提升。
  • sbit 定义单个引脚(原子位操作),sfr 给寄存器地址起名,bit 声明位变量,volatile 防止优化出错。
  • 8 位机上首选 unsigned char,慎用 int/long/float;注意整数溢出、整数除法、有无符号混用。
  • 配置外设的标准五步:查手册 → 分清位 → 读改写 → 开开关 → 启动验证。
  • 清位用 &= ~mask,置位用 |= mask,不要整字节乱覆盖。
12

工具链与上电全流程

// 从一段文字代码,到芯片里跑起来的机器码

学完本章你能

  • 在 Keil 里从零建一个 51 工程并成功编译出 HEX 文件
  • 用 STC-ISP 正确下载程序,并理解“冷启动下载”的原理
  • 说清从 .c 到芯片运行的完整链路,以及启动文件做了什么
  • 遇到“编译通过但没现象”时,按标准流程逐项排查

12.1 工具链全貌

你写的代码                工具                       产物
──────────────       ──────────────────       ──────────────
 main.c        ──►   C51 编译器 (Keil)   ──►    main.obj(目标文件)
                                                      │
 启动文件 STARTUP.A51 ► 汇编器 ──► STARTUP.obj        │
                                                      ▼
                       链接器 (L51/BL51)  ──►  main.hex / main.bin
                       分配地址、解决符号引用        │
                                                    ▼
                       STC-ISP(串口)或编程器   ──►  写进芯片 Flash
                                                    │
                                                    ▼
                       芯片复位,PC = 0000H 开始执行你的程序
文件作用要注意什么
.c / .h你的源代码与声明只有加入工程的 .c 会被编译
.uvproj / .uvprojxKeil 工程文件记录了芯片型号、晶振、输出设置
STARTUP.A51启动代码,由编译器提供负责清零 RAM、设置 SP 初值、跳到 main
.hex最终烧录文件必须在 Output 里勾选“Create HEX File”才会生成
.M51 / .lst映射/清单文件排查“程序放不下”“变量地址冲突”时查看

三种常见工具链的选择

方案优点缺点适合谁
Keil µVision(C51)教学资料最多、报错信息友好、能看反汇编、一键下载配置商业软件(有评估版限制代码大小)、界面较老初学者、课程实验(推荐)
SDCC + VS Code完全免费开源、跨平台、可脚本化报错信息晦涩、语法扩展略有差异、配置麻烦喜欢 Linux/命令行的进阶者
PlatformIO现代 IDE 体验、依赖管理好51 平台支持较弱、资料少玩过 Arduino/ESP32 的人

12.2 Keil 建工程:八步走

  1. 新建工程

    Project → New µVision Project,建议每个实验建一个独立文件夹,路径不要有中文和空格(很多人被这个坑掉半小时)。

  2. 选择芯片型号

    在 Device 里搜索你的芯片:AT89C52、STC89C52(若列表没有 STC,选 Atmel 的 AT89C52 即可,寄存器兼容)。

  3. 是否复制启动代码

    弹出 “Copy Standard 8051 Startup Code?” 时选是(生成 STARTUP.A51,负责清零 RAM)。

  4. 添加源文件

    新建 main.c 并保存到工程目录,然后右键 Source Group 1 → Add Existing Files 加入工程。

  5. 设置晶振频率

    Project → Options for Target → Target 页,把 Xtal 改成你板子的实际频率(11.0592 或 12MHz)。这会影响下载工具的波特率计算和调试时序。

  6. 生成 HEX 文件

    Options for Target → Output 页,勾选 “Create HEX File”。不勾选的话编译器不会生成 hex,STC-ISP 里就找不到文件。

  7. 编译

    按 F7(Build)。底部 Build Output 出现 0 Error(s) 即成功;若有 warning 尽量也解决掉。

  8. 找到 HEX

    HEX 文件在工程目录下的 Objects(或你设置的 Output)文件夹里,文件名通常是工程名.hex。

⚠ 关于 Keil 的评估版限制

未授权的 Keil C51 评估版有 2KB 代码大小限制。入门实验(点灯、按键、定时器、串口)基本都在 2KB 以内,可以正常学习;一旦程序变大报 “RESTRICTED VERSION” 或 “code size limit exceeded”,就需要购买授权或改用 SDCC。请不要使用来源不明的破解文件,教学实验用评估版完全够用。

12.3 下载:STC-ISP 五步走

  1. 连接硬件

    USB 线接开发板;若是外接 USB-TTL 模块,注意 TXD↔RXD 交叉、GND 共地。板子先不要上电(或准备好断电重上)。

  2. 选择芯片型号

    在 STC-ISP 左上角选对型号(如 STC89C52RC / STC89C52RC/LE52RC)。型号选错会导致下载失败或参数错误。

  3. 选择串口号

    插上 USB 后,在设备管理器里找到 “USB-SERIAL CH340 (COMx)”,在软件里选同一个 COM 口。看不到 COM 口说明驱动没装。

  4. 打开 HEX 文件

    点“打开程序文件”,选择 Keil 生成的 .hex。软件会显示程序占用的字节数。

  5. 冷启动下载

    点“下载/编程”,然后给开发板断电再上电(或按板上的电源开关/复位键)。STC 芯片上电时先运行固化在内部的 Bootloader,通过串口接收新程序再写进 Flash。

★ 为什么要“先点下载,再上电”

因为 STC 单片机上电后只在极短时间内检测串口是否有下载命令,检测不到就直接跳去运行用户程序。所以必须让芯片在“等待下载”的窗口期内收到命令。这就是所谓的冷启动下载:先让软件进入等待状态,再给板子上电。很多开发板做了自动断电控制,你只要点一下“下载”它自己就会重新上电。

下载失败的常见原因

提示/现象原因处理
一直在“正在检测目标单片机…”没有冷启动;串口选错;线接错断电重新上电;核对 COM 口;检查 TX/RX 交叉与共地
“串口已被占用”串口助手/另一个 STC-ISP 占着 COM 口关掉串口助手等所有占用程序,再点下载
端口列表里没有 COM 口CH340/CP2102 驱动未安装安装对应驱动,或换一根数据线(很多线只能充电)
下载成功但程序没反应串口和用户程序抢用 P3.0/P3.1;或程序本身有问题先跑一个最简单的点灯程序验证链路

12.4 芯片启动后发生了什么

上电 / 复位
   │
   ▼
PC = 0000H ──► 执行启动代码 STARTUP.A51
   │            ① 清零内部 RAM(保证变量初值为 0)
   │            ② 按需初始化变量区
   │            ③ 设置 SP(你若不手动设,默认 07H,很危险)
   │            ④ 跳到 main()
   ▼
main() 里的初始化(配置 IO、定时器、串口、中断)
   │
   ▼
while (1) 主循环,同时中断在后台随时插队
   │
   └──► 直到下一次复位或断电
⚠ 别急着在 main 里写 SP = 0x60

启动代码已经初始化了堆栈相关设置。如果你手动改 SP,要确保新位置不与工作寄存器区、位寻址区、其他变量重叠,否则会出现“函数一返回就跑飞”的诡异问题。除非确实遇到堆栈不够,一般不需要动它。

12.5 调试方法:没有断点,怎么找问题

51 的开发板通常没有硬件调试器,所以你要掌握的“穷人调试法”是这几件:

① LED 当探针

在可疑代码前后翻转一个 LED,用现象判断程序是否执行到那里。这是最原始也最有效的方法。

② 串口打印

用 printf 输出变量值、进入函数的标记。注意波特率要对、别在中断里打印。

③ 万用表/示波器量电平

怀疑引脚没动?直接量电压。怀疑时序不对?用逻辑分析仪抓波形,和手册的时序图对照。

④ Keil 软件仿真

Options → Debug → Use Simulator,可以单步执行、看寄存器/变量/IO 口状态,不需要硬件。查逻辑错误非常好用。

12.6 “编译通过但没现象”排查流程

  1. 确认程序真的下进去了

    STC-ISP 是否提示“操作成功”?重新下一个最简单的点灯程序验证整条链路。

  2. 确认端口号对不对

    拿原理图核对:LED 到底接在 P1 还是 P2?是高电平点亮还是低电平?这一步能解决 80% 的“没现象”。

  3. 确认电路本身没问题

    LED 极性是否接反、限流电阻是否虚焊、排线是否插错位、共地是否可靠。

  4. 确认程序在运行

    用万用表量 ALE 引脚(第 30 脚),正常运行时应有约 1/6 晶振频率的方波(12MHz → 约 500kHz)。没有波形说明芯片没跑起来(复位电路、晶振或 EA 接法有问题)。

  5. 缩小范围二分定位

    把代码注释掉一半,看现象是否变化,逐步缩小到出问题的那几行。不要靠猜。

  6. 检查那三个“隐藏杀手”

    忘了开总中断 EA;读引脚前没写 1;整字节赋值覆盖了其他引脚的状态。

12.7 你的第一周任务清单

阶段目标验收标准
第 1 天装好工具链,点亮一个 LED能独立完成“建工程 → 编译出 HEX → 冷启动下载 → 灯亮”全流程
第 2 天流水灯 + 位运算8 个灯依次点亮;能解释 P1 = ~(1<<i) 的每一位
第 3 天按键控制(消抖)按一次灯翻转一次,长按不会连击
第 4 天定时器中断用定时器实现精确 1 秒闪烁,不占用主循环
第 5 天串口通信电脑串口助手能收发数据,printf 能打印变量
第 6-7 天综合小项目例如“按键设置定时时间 + 数码管显示 + 到点报警”
✔ 学习心态

第一天折腾三小时才点亮一个 LED 是完全正常的。嵌入式开发的难点从来不是写代码,而是把代码、电路、工具三者的关系搞清楚。每解决一个“灯不亮”的问题,你都真正学会了一样东西。

本章小结

  • 链路:.c → 编译 → 链接 → .hex → 下载 → Flash → 复位后从 0000H 执行。
  • Keil 建工程要点:选对芯片、设对晶振、勾选 Create HEX File、文件要加入工程。
  • STC 下载必须冷启动:先点下载再上电,芯片靠内部 Bootloader 接收新程序。
  • 没有断点就用 LED、串口、万用表和软件仿真这四种方法调试。
  • “没现象”时按固定顺序排查:下载是否成功 → 端口/极性是否对 → 电路是否通 → 芯片是否在跑。
13

避坑指南与自测

// 别人踩过的坑,你可以只踩一遍

学完本章你能

  • 对着症状表快速定位大部分入门故障
  • 建立一套“先硬件后软件、先简单后复杂”的排错习惯
  • 通过 15 道自测题检验自己对前置知识的掌握程度

13.1 症状 → 原因速查表

现象最可能的原因解决方向
灯完全不亮极性接反 / 端口号写错 / 忘串电阻导致没回路 / 程序没下进去查原理图确认端口与极性;用万用表量引脚电平;重新下载点灯程序
灯常亮,不受控LED 直接接 VCC 与 GND(没经过引脚);或引脚一直是低检查接线是否绕过 IO 口;量引脚电压是否变化
灯很暗限流电阻过大(如 10kΩ);或用拉电流方式驱动(51 拉电流很弱)改用灌电流接法,电阻降到 220Ω~1kΩ
按键没反应读引脚前没写 1;引脚定义错;上拉/下拉缺失导致悬空初始化时把该位写 1;确认外部有上拉或下拉
按一下跳好几下没有消抖加 10~20ms 延时二次确认;加“等松手”循环
定时不准晶振频率与代码假设不符;方式 1 忘重装初值;Delay 靠循环次数用串口/示波器实测;中断里先重装 TH/TL;尽量用定时器而非空转
中断完全不进忘写 EA = 1;忘开分开关;中断号写错对照中断号表逐项检查;先写一个最简单的定时器中断验证
只进一次中断串口 TI/RI 没清;方式 1 没重装初值;电平触发没撤销中断里显式清零标志;改成下降沿触发
串口全是乱码波特率不一致;晶振不是 11.0592MHz;Keil 里晶振频率填错两端统一 9600;换 11.0592MHz 晶振;改工程 Xtal 设置
串口收不到任何数据TX/RX 没交叉;没共地;REN 没置 151 的 TXD 接对方 RXD;连 GND;SCON |= 0x10
下载一直停在“检测目标”没有冷启动;串口被占用;COM 口选错;驱动没装先点下载再上电;关掉串口助手;装 CH340 驱动
改了变量主程序读不到变量没加 volatile,被编译器优化中断与主程序共享的变量一律加 volatile
程序跑一会儿就乱跳堆栈溢出(局部数组太大、嵌套太深);数组越界覆盖返回地址减小局部变量、降低嵌套、检查数组下标
程序提示放不下常量表放在 RAM 里;用了大量 int/long/float常量加 code;改用 unsigned char;检查 Keil 版本限制
整口赋值后其他灯闪一下用了 P1 = 0xFE 这类整字节覆盖只改某位时用 P1 &= ~0x01 或 sbit 位变量
外设一开某个功能就失效引脚复用冲突(P3.0/P3.1 串口、P3.2/P3.3 外部中断)检查该引脚是否被两个功能同时占用

13.2 排错的四条纪律

① 先怀疑自己,再怀疑芯片

99% 的问题在接线、端口号和代码里,而不是芯片坏了。不要一上来就换芯片。

② 一次只改一个变量

同时换端口、改电路、改代码,即使好了你也不知道是哪个起了作用,下次还会踩。

③ 从最小可运行程序开始

不要在一个复杂工程里 debug。先让一个 5 行的点灯程序跑通,再往上加功能。

④ 用仪器代替猜测

万用表量电平、逻辑分析仪看波形、串口打印变量。现象比推理可靠。

13.3 自测:15 题检验你的地基

点击你认为正确的选项,会立即给出对错和解析。全部做完后上方会显示得分。

本章小结

  • 入门阶段的故障 90% 集中在:端口号/极性错、没消抖、没开中断、没清标志、没共地、波特率不匹配。
  • 排错纪律:先硬件后软件、一次只改一处、从最小程序开始、用仪器验证。
  • 自测题答对 12 题以上,说明前置知识已经扎实,可以开始做综合项目了。
14

速查表与延伸阅读

// 打印这一章贴在书桌前

学完本章你能

  • 一页查到最常用的寄存器、公式、指令与对照表
  • 知道遇到问题时该查哪份官方文档
  • 拿到一份继续学习的路线建议

14.1 SFR 速查

寄存器地址位寻址一句话功能典型初值
P0 P1 P2 P380/90/A0/B0H✔四个 8 位 IO 口数据寄存器0xFF(输入态)
SP81H✗堆栈指针0x07(复位值)
DPTR (DPH,DPL)83H,82H✗16 位数据指针0x0000
PCON87H✗SMOD 波特率倍速、掉电/空闲模式0x00
TCON88H✔定时器启停与溢出标志、外部中断触发方式0x00
TMOD89H✗定时器工作方式选择0x01 / 0x20
TL0/TH0/TL1/TH18A~8DH✗定时器计数值按计算填写
SCON98H✔串口方式、允许接收、TI/RI 标志0x50
SBUF99H✗串口收发缓冲(写=发,读=收)—
IEA8H✔中断允许(EA 总开关 + 5 个分开关)0x00
IPB8H✔中断优先级0x00
PSWD0H✔CY / RS1 RS0 / OV / P 等状态0x00
ACC / BE0H / F0H✔累加器 / 乘除法辅助寄存器—

14.2 引脚速查

引脚(DIP40)名称功能要点
1 ~ 8P1.0 ~ P1.7纯用户 IO,最推荐接 LED/按键
9RST高电平复位,RC 电路 + 可选按键
10 ~ 17P3.0 ~ P3.7复用:RXD/TXD/INT0/INT1/T0/T1/WR/RD
18 / 19XTAL2 / XTAL1接晶振(+30pF×2 到 GND)或内部时钟
20GND地,必须与所有外设共地
21 ~ 28P2.0 ~ P2.7扩展时作高 8 位地址,否则可作普通 IO
29PSEN读片外程序存储器选通
30ALE地址锁存使能;可用示波器测它判断芯片是否在跑
31EA1/VCC = 用片内 Flash(常规);0/GND = 只用片外 ROM
32 ~ 39P0.7 ~ P0.0开漏,作 IO 必须外接上拉;扩展时作低 8 位地址/数据
40VCC电源 +5V(STC89 支持 3.3~5.5V)

注意 P0 的引脚编号方向:Pin 32 是 P0.7,Pin 39 是 P0.0,与 P1/P2/P3 的顺序相反,抄原理图时容易看错。

14.3 公式速查

用途公式备注
欧姆定律U = I × R算限流、算上拉都靠它
LED 限流电阻R = (VCC − V_F) / I取标准系列值,向大取更安全
机器周期(12T)T = 12 / f_osc12MHz → 1µs
机器周期(1T)T = 1 / f_oscSTC8/15 等增强型
定时初值(方式 1)初值 = 65536 − t / Tt 为定时时间,T 为机器周期
定时初值(方式 2)初值 = 256 − t / T自动重载,仅 8 位
最大单次定时65536 × T12MHz 约 65.5ms,再长要靠软件计数
波特率(方式 1/3)波特率 = (2SMOD/32) × f_osc/(12×(256−TH1))常用 TH1 = 0xFD 得 9600 @11.0592MHz
位地址位地址 = 字节地址 × 8 + 位号用于 sbit 与位寻址区
数码管动态扫描每位数码管点亮 1~5ms,整屏刷新 < 20ms低于 50Hz 会看到闪烁

14.4 常用 C 代码片段

/* ① 位操作宏 */
#define BIT(n)      (1 << (n))
#define SET_BIT(x,n)   ((x) |=  BIT(n))
#define CLR_BIT(x,n)   ((x) &= ~BIT(n))
#define TOG_BIT(x,n)   ((x) ^=  BIT(n))
#define GET_BIT(x,n)   (((x) >> (n)) & 1)
#define ARRAY_LEN(a)   (sizeof(a) / sizeof((a)[0]))

/* ② 毫秒级空转延时(@12MHz,粗略值,仅供非精确场合)*/
void DelayMs(unsigned int ms)
{
    unsigned int i, j;
    for (i = 0; i < ms; i++)
        for (j = 0; j < 110; j++);
}

/* ③ 定时器 0 初始化:50ms @12MHz,中断方式 */
void Timer0_Init(void)
{
    TMOD &= 0xF0;  TMOD |= 0x01;   /* T0 方式 1 定时 */
    TH0 = 0x3C;  TL0 = 0xB0;          /* 65536 − 50000 = 15536 */
    ET0 = 1;  EA = 1;  TR0 = 1;
}

/* ④ 串口初始化:9600 @11.0592MHz,8N1,允许接收 */
void Uart_Init(void)
{
    SCON = 0x50;
    TMOD &= 0x0F;  TMOD |= 0x20;   /* T1 方式 2 作波特率发生器 */
    TH1 = 0xFD;  TL1 = 0xFD;
    TR1 = 1;  ES = 1;  EA = 1;
}

/* ⑤ 按键消抖读取(低电平有效)*/
u8 Key_Scan(u8 mode)
{
    static u8 key_up = 1;
    if (mode) key_up = 1;               /* 支持连按 */
    if (key_up && KEY1 == 0) {
        DelayMs(15);
        key_up = 0;
        if (KEY1 == 0) return 1;       /* 确认按下 */
    } else if (KEY1 == 1) {
        key_up = 1;
    }
    return 0;
}

14.5 术语表

MCU
Microcontroller Unit,微控制器。CPU、存储器、外设集成在一颗芯片上。
SFR
Special Function Register,特殊功能寄存器。控制外设的“开关盒子”,分布在 80H~FFH。
准双向口
51 的 IO 口结构:内部弱上拉 + 强下拉,可直接当输入,但读之前必须先写 1。
开漏输出
只能拉低、不能主动拉高,输出高必须靠外部上拉电阻。P0 口就是这种结构。
机器周期
标准 51 中 12 个时钟周期构成的基本时间单位,12MHz 下等于 1µs。
中断向量
每个中断固定对应的入口地址(外0=0003H,定0=000BH…)。
波特率
串口每秒传输的位数,常用 9600、115200。收发双方必须一致。
BCD
用 4 个二进制位表示一位十进制数字的编码方式,数码管、时钟芯片常用。
消抖
消除机械按键触点弹跳造成的多次误触发,常用 10~20ms 延时法。
上拉/下拉
给悬空引脚一个确定的默认电平,避免状态随机。
灌电流 / 拉电流
电流流入引脚(引脚输出低)/ 电流流出引脚(引脚输出高)。51 的灌电流能力远强于拉电流。
HEX 文件
编译器输出的烧录文件,用十六进制文本记录每个地址上的机器码。
冷启动下载
STC 芯片的下载方式:先点下载、再给板子上电,让芯片进入 Bootloader 接收程序。

14.6 延伸阅读与官方资料

资料为什么值得看怎么找
芯片数据手册(Datasheet) 一切参数的最终依据:IO 电流、时序、寄存器全表、电气特性。学会读手册是从入门到进阶的分水岭。 搜索“STC89C52RC 数据手册 pdf”或“AT89C52 datasheet”;STC 官网 stcmcudata.com 有中文手册
Keil 官方文档 C51 编译器手册详述了 sbit、存储模式、优化等级、启动代码等所有扩展语法。 Keil 安装目录下的 C51\HLP 或官网文档中心
《8051 单片机原理与应用》类教材 指令系统与硬件结构讲得最系统,适合配合课程学习。 学校图书馆 / 课程指定教材
经典入门书 郭天祥《新概念 51 单片机 C 语言教程》、宋雪松《手把手教你学 51 单片机》——配套视频与实验较多,适合动手型学习者。 书店 / 电子书平台
开源社区 遇到具体问题(如“DS18B20 时序读不出来”)时,搜索现成代码并对照手册理解是最快的学习方式。 电子发烧友、CSDN、GitHub 上搜索器件型号 + 51

14.7 下一步学什么

  1. 把外设一个个打穿

    数码管(静态/动态扫描)→ 独立按键与矩阵键盘 → 蜂鸣器 → 继电器 → LCD1602 → DS18B20 → AT24C02 → 红外遥控 → 步进电机。

  2. 把协议吃透

    UART / I2C / SPI / 单总线的手册时序,自己用 IO 口模拟一遍,再看示波器波形对照。

  3. 做一个完整的作品

    把多个模块组合成有实际用途的东西(电子钟、温控风扇、计数器),会逼你处理状态机、按键交互、异常情况。

  4. 然后升级到 STM32 / 国产 32 位 MCU

    你会惊喜地发现:GPIO、定时器、中断、串口这些概念完全复用,只是寄存器变多、工具链变现代、能跑 RTOS。

★ 最后一句提醒

单片机不是靠“看完教程”学会的,是靠把灯点亮、把波形调出来、把 bug 修掉学会的。今天就从第 12 章的“点亮第一个 LED”开始,把这篇教程当成你手边的参考书,遇到问题回来翻对应章节。

全书回顾

  • 地基(第 1-4 章):电压电流电阻 → 高低电平 → 二进制与十六进制 → 位运算。这四章决定了你后面能不能"看懂"代码。
  • 内核(第 5-7 章):40 个引脚的分工、IO 口的准双向与开漏结构、四个地址空间与 SFR。这三章决定了你能不能"看懂"手册。
  • 外设(第 8-10 章):时钟与定时器、中断系统、串口与总线。这三章决定了你能做多复杂的项目。
  • 动手(第 11-12 章):C51 的关键字与语法、工具链与下载流程。这两章决定了你的代码能不能跑起来。
  • 收尾(第 13-14 章):故障排查表、自测题与速查表。这两章是长期陪伴你的工具页,建议打印出来贴在桌前。