导读与学习地图
学完本章你能
- 说清楚“学 51 之前”和“学 51 本身”分别包含哪些知识
- 判断自己该从第几章开始读,避免重复或跳级
- 知道每一章的知识在后面的代码/电路里会以什么形式出现
很多同学第一次学单片机是这样的:拿到一块开发板,抄一段流水灯代码,编译、烧录、灯亮了,然后……就卡住了。因为看不懂 P1 = 0xFE; 到底在干什么,也解释不了为什么 LED 要串一个电阻,更不知道为什么有的引脚不接上拉电阻就没反应。
问题不在 51,而在于地基缺了几块。单片机是一门“软硬交界”的学科,它站在三个地方的交汇点上:
电路与电学
电压、电流、电阻、上拉下拉、驱动能力。决定你“为什么这样接线”,接错就是烧芯片。
数字与逻辑
二进制、十六进制、按位运算、逻辑门。决定你“为什么这样写寄存器值”。
程序与计算机结构
CPU/存储器/外设/总线、地址、时钟、中断。决定你“程序为什么这样组织”。
依赖关系:为什么必须按这个顺序学
下面这张图是从零到“写出第一个能跑的程序”的知识依赖链。箭头表示“必须先懂前者,后者才讲得通”。
电压/电流/电阻/地 ──► 高低电平 ──► 二进制 0 和 1 ──► 十六进制书写
│ │
▼ ▼
上拉/下拉/限流电阻 位运算(& | ^ ~ << >>)
│ │
└──────────────┬─────────────────────┘
▼
CPU + 存储器 + 总线 + 时钟 + 中断 ← 计算机组成原理最小版
▼
8051 内核:累加器 / SFR / 4 组工作寄存器 / 4 个 IO 口
▼
定时器 · 中断控制器 · 串口 ·(外部:I2C/SPI/ADC)
▼
C51 语言要点(volatile / sbit / sfr / 位操作)
▼
编译器 → HEX 文件 → 烧录器 → 芯片复位运行 → 用眼睛看现象
怎么读这篇教程
| 你的状态 | 建议起点 | 说明 |
|---|---|---|
| 纯零基础,没碰过电路 | 第 1 章 | 按顺序读,不要跳 |
| 高中物理/信息课学过二进制和串并联 | 第 2 章快速扫一遍 | 重点看 2.4 上拉下拉 |
| 会写 C 语言、做过 Arduino | 第 4 章 | 重点看位运算与寄存器思维 |
| 只想马上点亮 LED | 第 12 章 | 但回头一定要补第 2、7 章 |
每章的“交互演示”都要亲手拖一拖、点一点再往下看。单片机是操作性学科,看会不等于会。每章末尾有“本章小结”,读完后先用自己的话复述一遍再看下一章。
本章小结
- 51 单片机 = 电路 + 数字逻辑 + 计算机结构 + 编程,四块地基缺一不可。
- 学习顺序有严格依赖:先电学 → 再进制与位运算 → 再内核结构 → 最后才是写程序。
- 抄代码能点亮灯,但只有懂原理才能在灯不亮时找到原因。
单片机到底是什么
学完本章你能
- 用自己的话说出单片机(MCU)与 CPU、电脑、Arduino 的关系
- 说清“51”这个名字的由来,以及 8051 / STC89C52 / STM32 的区别
- 列出开发一块 51 板子所需要的最少硬件与软件清单
1.1 从电脑到芯片:一脉相承的结构
你熟悉的电脑由 CPU、内存、硬盘、主板和各种外设组成。单片机做的事一模一样,只是把这一整套东西集成到了一个芯片里:
| 电脑里的部件 | 单片机里的对应物 | 8051 里的具体名字 |
|---|---|---|
| CPU 处理器 | CPU 内核 | 8 位 CPU 内核(累加器 ACC、ALU、控制器) |
| 内存条 RAM | 片上 RAM | 128 / 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 章会逐步拆开讲。
写源码
在 Keil 里创建工程,写
main.c,配置芯片型号和晶振频率。编译 / 链接
编译器把 C 代码变成机器码,链接器把它放到正确的地址上,最后输出
.hex文件。下载
用 STC-ISP 把 HEX 写进芯片的 Flash。此时芯片里有了“新程序”。
复位运行
芯片复位后,CPU 从 Flash 的 0000H 地址开始一条一条取指令、执行指令。
观察现象并调试
看 LED、听蜂鸣器、看串口输出、用万用表量电平,再修改代码回到第 1 步。
本章小结
- 单片机 = 集成在一颗芯片上的完整计算机:CPU + ROM + RAM + 外设 + IO。
- “51”指 Intel MCS-51(8051)指令兼容内核;STC89C52 是最常见的教学型号。
- 学习 51 的价值在于结构简单、寄存器少,能让你看清计算机的每一层。
- 最小装备:开发板 + USB 线 + Keil C51 + STC-ISP;再补面包板、电阻、LED、万用表。
电学与数字电路常识
学完本章你能
- 用电压、电流、电阻的关系解释“为什么必须串限流电阻”
- 理解数字电路为什么只用两个电压区间表示 0 和 1,以及什么是噪声容限
- 看懂上拉电阻、下拉电阻、开漏输出的作用,并解释 P0 口为什么要外接上拉
- 给 LED 算出正确的限流电阻,并说清按键抖动为什么要消抖
2.1 三个最基本的量:电压、电流、电阻
把电想象成水:电压是水位差(水压),电流是水流的大小,电阻是水管的粗细限制。三者由欧姆定律联系起来:
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 通常 5V | STC89 系列: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 口能力弱
2.3 数字信号:为什么“电压”能表示 0 和 1
现实世界的电压是连续的(模拟量),但数字电路故意只承认两个区间:靠近 0V 的叫低电平 0,靠近 VCC 的叫高电平 1。中间那一大段是“不确定区/禁止区”,正常设计要避免停在那里。
| 区间(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(灌电流,能力强)
“输出高靠电阻,输出低靠管子。”凡是你看到某个引脚输出高电平没力气、必须靠外接电阻才能拉高,它多半是开漏输出。开漏的好处是可以把多个引脚直接并到一根线上(“线与”),谁都不会打架,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 ✗ 几乎看不见
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 逻辑门与组合逻辑(认识即可)
数字电路的最小运算单元是逻辑门。单片机内部由成千上万个门构成,而你写代码时用到的位运算(下一章)正是它们的软件版本。
| 门 | 符号/表达式 | 规则(一句话) | 典型用途 |
|---|---|---|---|
| 与 AND | Y = A · B | 全 1 才输出 1 | 做掩码、屏蔽某些位、按键组合 |
| 或 OR | Y = A + B | 有 1 就输出 1 | 置位、合并标志 |
| 非 NOT | Y = A̅ | 取反 | 低电平有效信号转换 |
| 与非 NAND | Y = (A·B)̅ | 全 1 输出 0 | 最常用的“万能门” |
| 或非 NOR | Y = (A+B)̅ | 有 1 输出 0 | SR 锁存器 |
| 异或 XOR | Y = A ⊕ B | 不同为 1,相同为 0 | 翻转特定位、奇偶校验、加法器 |
组合逻辑 = 输出只取决于当前输入;时序逻辑 = 输出还取决于过去的输入(含触发器/寄存器)。单片机的 IO 口、定时器、串口都属于时序逻辑,因为它们需要时钟来推进状态。
本章小结
- 电路的一切都从
U = I × R出发;调试第一件事是确认回路完整、共地可靠。 - 数字电路只用两个电压区间表示 0/1,中间留出不确定区以抵抗干扰。
- 悬空引脚状态不定,靠上拉/下拉电阻给它默认状态;51 的 P0 是开漏结构,必须外接上拉。
- LED 必须串联限流电阻,用
R = (VCC − V_F) / I计算,取标准值。 - 机械按键有抖动,必须用延时或电容消抖,否则一次按下会被算成多次。
进制与二进制运算
学完本章你能
- 在二进制、十六进制、十进制之间快速换算,看 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 语言写法 | 汇编写法 | 基数 | 例子 |
|---|---|---|---|---|
| 二进制 Binary | 0b1111_1110 | 11111110B | 2 | 写作 0b11111110 |
| 八进制 Octal | 0362 | 362Q / 362O | 8 | 0362 = 0xF2 = 242 |
| 十进制 Decimal | 254 | 254 | 10 | 默认,不加前缀 |
| 十六进制 Hex | 0xFE | 0FEH | 16 | 最常用,务必记住 0x |
P1 = FE; 是错的(编译器会把它当成一个名叫 FE 的变量或直接报错);必须是 P1 = 0xFE;。另一个常见错误是 0xFG——十六进制里没有 G,只有 0~9 和 A~F。
换算法则:一句话记住
- 二 → 十六:从右往左每 4 位分一组,每组查表。例:
1111 1110→FE→0xFE。 - 十六 → 二:每位十六进制展开成 4 位。例:
0x5A→0101 1010。 - 二 → 十:按权相加。例:
10110011= 128+32+16+2+1 =179。 - 十 → 二:不断除以 2 取余数,倒着读。
在任意一栏输入,其他栏会同步刷新。也可以直接点下面的 8 个格子来翻转某一位,亲眼看看它是哪一位变成了 1。
3.3 十进制与十六进制的常见对照(背下来会快很多)
| 十进制 | 二进制 | 十六进制 | 它在 51 里常常意味着 |
|---|---|---|---|
| 0 | 0000 0000 | 0x00 | 全低:8 个 LED 全亮(若低电平点亮) |
| 1 | 0000 0001 | 0x01 | 只有最低位 P1.0 为高 |
| 15 | 0000 1111 | 0x0F | 低 4 位掩码,常用于取低半字节 |
| 16 | 0001 0000 | 0x10 | 第 4 位为 1 |
| 127 | 0111 1111 | 0x7F | 有符号数最大正值 |
| 128 | 1000 0000 | 0x80 | 最高位为 1,定时器/串口配置里极常见 |
| 240 | 1111 0000 | 0xF0 | 高 4 位掩码 |
| 254 | 1111 1110 | 0xFE | 只有 P1.0 为低:最经典的“点亮第一个 LED” |
| 255 | 1111 1111 | 0xFF | 全高;也常作为“无数据”的初值 |
因为一位十六进制 = 正好 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 char | 255 | 0 ~ 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 ~ 9 | 0000 ~ 1001 | 0000 ~ 1001(相同) |
| 10 | 0001 0000 | 0000 1010(相同) |
| 25 | 0010 0101 | 0001 1001(不同!) |
| 99 | 1001 1001 | 0110 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 位存一位十进制数字,只在数码管、时钟芯片等特定场合使用。
位运算:单片机的母语
学完本章你能
- 熟练读写
&|^~<<>>六个位运算符 - 不看代码就能说出“把某位置 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 六个运算符与真值表
| 运算符 | 名称 | 运算规则(逐位独立进行) | 一句话记忆 |
|---|---|---|---|
| & | 按位与 AND | 1&1=1,其余为 0 | 有 0 就是 0 → 用来清 0 |
| | | 按位或 OR | 0|0=0,其余为 1 | 有 1 就是 1 → 用来置 1 |
| ^ | 按位异或 XOR | 相同为 0,不同为 1 | 与 1 异或翻转,与 0 异或不变 → 用来取反某位 |
| ~ | 按位取反 NOT | 0→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 / 清 0 | P1_0 = 1 ; P1_0 = 0 |
| RL / RR / RLC / RRC | 循环左移/右移(带或不带进位) | 无直接对应,C 里要手写 |
| JB / JNB / JBC | 判断某位为 1 / 0 / 为 1 则跳转并清 0 | if (bit) ... |
因为 C 编译器最终就生成这些指令。当你发现“位操作比赋值慢”或者“某段代码没达到预期时序”,往往要看反汇编才知道编译器把你的位运算优化成了什么。同时,51 里有位寻址空间,某些位地址操作比其他写法快得多(第 7 章会讲)。
本章小结
- 位运算的目的只有一个:只修改我想改的位,其余保持不变。
- 置 1 用
|= BIT(n),清 0 用&= ~BIT(n),翻转用^= BIT(n),读取用& BIT(n)。 - 掩码用
1 << n生成;取高 4 位用>> 4,取低 4 位用& 0x0F。 - 位运算符优先级反直觉,任何混合表达式都加括号。
- 别把
&写成&&、|写成||、==写成=——这三条能省下你大量调试时间。
51 内核架构与引脚
学完本章你能
- 画出 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、传感器…
架构上要区分的一个概念:冯·诺依曼 vs 哈佛
| 架构 | 特点 | 代表 | 51 属于哪种 |
|---|---|---|---|
| 冯·诺依曼 | 程序和数据存在同一空间,共用总线 | 电脑 x86 | 51 属于哈佛结构(改进型):程序存储器与数据存储器地址空间独立,可同时访问,速度更快。这也是第 7 章要划清“四个地址空间”的原因。 |
| 哈佛 | 程序与数据分开存储、各有总线 | 多数单片机 |
5.2 内核里的六个关键寄存器
“寄存器”就是 CPU 内部的一个小盒子,容量通常只有 1 字节,但 CPU 可以直接读写它,速度最快。51 里你必须认识这几个:
| 名称 | 全称 | 宽度 | 作用(生活化比喻) |
|---|---|---|---|
| ACC | 累加器 Accumulator | 8 位 | 算盘珠子。绝大多数运算的出发点和落脚点,代码里常写 A。很多指令隐含使用它,所以它是最忙的寄存器。 |
| B | B 寄存器 | 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 脚。点任意一只脚看它的功能和典型接法。
← 点击任意一只引脚
这里会显示该引脚的全称、功能分类和典型接法。
提示:P0/P1/P2/P3 各 8 位引脚分别对应端口寄存器的 bit0~bit7,代码里写 P1 = 0xFE 控制的就是第 1、2 脚。
5.4 引脚归类记忆法
| 类别 | 引脚 | 数量 | 说明 |
|---|---|---|---|
| 电源 | VCC(40) / GND(20) | 2 | 5V 供电,务必与所有外设共地 |
| 时钟 | XTAL1(19) / XTAL2(18) | 2 | 接晶体振荡器 + 两个 30pF 电容,或用内部时钟(STC 可省) |
| 复位 | RST(9) | 1 | 高电平复位,靠 RC 电路实现上电自动复位 |
| 通用 IO | P0/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,用作手动复位。
不同芯片复位极性不同:51 是高电平复位;STM32 等很多芯片是低电平复位(NRST)。接线前一定查手册,把复位极性搞反会导致“程序一上电就没反应”或“芯片一直处于复位状态”。
5.6 CPU 的心跳:取指—译码—执行
CPU 永远在重复三件事,一秒钟重复上百万次:
取指(Fetch)
PC 指向的地址被送到程序存储器,取出指令字节,同时 PC 自动加 1(指向下一条)。
译码(Decode)
控制电路看懂这条指令要做什么:是加法?是读引脚?还是跳转?需要哪些操作数?
执行(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 永远在执行“取指—译码—执行”,由时钟节拍驱动。
IO 端口:单片机如何驱动世界
学完本章你能
- 说清 P0/P1/P2/P3 的内部结构和四个口的分工差异
- 解释“读引脚”和“读锁存器”的区别,明白为什么要先写 1 再读
- 正确计算 IO 口的驱动能力,知道什么时候必须加驱动芯片或三极管
- 写出可靠的“按键读入 + 消抖 + 控制 LED”的完整代码
6.1 四个口的分工
| 端口 | 引脚 | 内部结构 | 默认用途 | 使用注意 |
|---|---|---|---|---|
| P0 | P0.0~P0.7 (39~32) | 开漏,无内部上拉 | 扩展时作低 8 位地址/数据总线(分时复用) | 当普通 IO 用必须外接 10kΩ 上拉电阻,否则输出不了高电平 |
| P1 | P1.0~P1.7 (1~8) | 准双向,内部弱上拉 | 纯用户 IO,最“干净”的一个口 | 推荐把 LED、按键优先接在这里,教学实验最常用 |
| P2 | P2.0~P2.7 (21~28) | 准双向,内部弱上拉 | 扩展时作高 8 位地址 | 不扩展存储器时可当普通 IO 用 |
| P3 | P3.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)。但代价是——
因为如果这一位之前被写成 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); /* 等松手,保证按一次只翻转一次 */
}
}
}
}
板上按键常接在 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 灌电流能力强、拉电流能力弱,小电流负载用灌电流接法,大电流负载必须加三极管或驱动芯片。
- 完整的按键控制代码 = 端口初始化 + 消抖 + 单次触发处理 + 松手检测。
存储器组织:数据住在哪里
学完本章你能
- 准确说出 51 的四个地址空间,并说明程序与数据的区分
- 画出内部 RAM 的完整地图:工作寄存器区、位寻址区、用户 RAM 区
- 解释位寻址的地址算法,说清
sbit是怎么算出来的 - 认出常用 SFR 的名字、地址和用途,知道哪些能被位寻址
- 说清
data / idata / xdata / code四种存储类型的区别
这一章名词最多,但逻辑其实很整齐。记住一句话就够了:“51 有四个平行的地址空间,用不同的指令/关键字去访问,它们互相不冲突。”后面的复杂感,全部来自“同一个数字在不同空间里代表不同东西”。
7.1 四个地址空间
| 空间 | 大小(标准 8051) | 存放什么 | C 关键字 | 典型访问方式 |
|---|---|---|---|---|
| 程序存储器(片内 Flash) | 4KB | 你的程序代码、常量表 | code | CPU 自动取指;查表用 MOVC |
| 片外程序存储器 | 64KB | 程序太大时的扩展 ROM | code | EA=0 或超出片内空间时 |
| 片内数据存储器 | 128B / 256B | 变量、工作寄存器、堆栈、位变量 | data / idata | 最快,MOV 直接寻址 |
| 片外数据存储器 | 64KB | 大数据缓冲(如显示缓存、通信缓冲) | xdata | 用 MOVX + DPTR/Ri |
注意 STC89C52 是增强型号:Flash 有 8KB、RAM 有 512B(其中 256B 在片内,另外 256B 通过 MOVX 访问,属于“片内扩展 RAM”)。做题时以你芯片的手册为准。
点击任意区块或表格行,右侧显示它的详细说明与使用要点。
← 点击左侧任意区块
这里会显示该区域的地址范围、容量以及"你会在什么场景下用到它"。
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 的两种解读方式。
7.3 位寻址:51 的独门绝技
51 有两个“位寻址区”,可以直接对单独一个 bit 读写,不需要读—改—写整个字节。这是它控制能力强的重要原因。
| 位寻址区 | 字节地址范围 | 位地址范围 | 数量 |
|---|---|---|---|
| 内部 RAM 的位寻址区 | 20H ~ 2FH | 00H ~ 7FH | 16 字节 = 128 位 |
| 地址能被 8 整除的 SFR | 80H, 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,只能用直接寻址访问。下面是你在入门阶段一定会用到的:
| 地址 | 名称 | 可位寻址 | 作用 |
|---|---|---|---|
| 80H | P0 | ✔ | 端口 0 数据寄存器 |
| 81H | SP | ✗ | 堆栈指针 |
| 82H | DPL | ✗ | 数据指针低 8 位 |
| 83H | DPH | ✗ | 数据指针高 8 位 |
| 87H | PCON | ✗ | 电源控制:SMOD(波特率加倍)、掉电/空闲模式 |
| 88H | TCON | ✔ | 定时器控制:TF1/TF0 溢出标志、TR1/TR0 启停、IT0/IT1 中断触发方式 |
| 89H | TMOD | ✗ | 定时器工作方式选择(T1 高 4 位、T0 低 4 位) |
| 8AH / 8BH | TL0 / TL1 | ✗ | 定时器低 8 位计数 |
| 8CH / 8DH | TH0 / TH1 | ✗ | 定时器高 8 位计数(赋初值就写这两个) |
| 90H | P1 | ✔ | 端口 1 数据寄存器 |
| 98H | SCON | ✔ | 串口控制:SM0/SM1 方式、REN 允许接收、TI/RI 标志 |
| 99H | SBUF | ✗ | 串口数据缓冲,写=发送,读=接收 |
| A0H | P2 | ✔ | 端口 2 数据寄存器 |
| A8H | IE | ✔ | 中断允许控制:EA 总开关、ET0/ET1/ES/EX0/EX1 分开关 |
| B0H | P3 | ✔ | 端口 3 数据寄存器 |
| B8H | IP | ✔ | 中断优先级控制 |
| D0H | PSW | ✔ | 程序状态字(CY、RS1/RS0、OV 等) |
| E0H | ACC | ✔ | 累加器 |
| F0H | B | ✔ | 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(前提是片内实在放不下) */
标准 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。
时钟与定时器 / 计数器
学完本章你能
- 解释时钟周期、机器周期、指令周期的关系,算出 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 个机器周期 ← 不同指令耗时不同
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,可位寻址)——启停与标志
| 位 | 名称 | 含义 | 常用操作 |
|---|---|---|---|
| D7 | TF1 | 定时器 1 溢出标志,溢出时硬件置 1 | 中断中硬件自动清除;查询法要软件清 0 |
| D6 | TR1 | 定时器 1 运行控制:1 = 启动,0 = 停止 | TR1 = 1; |
| D5 | TF0 | 定时器 0 溢出标志 | while(!TF0); TF0 = 0; |
| D4 | TR0 | 定时器 0 运行控制 | TR0 = 1; |
| D3/D2 | IE1/IT1 | 外部中断 1 标志 / 触发方式(0 低电平,1 下降沿) | IT1 = 1; |
| D1/D0 | IE0/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 溢出后计数器从 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) { /* 主程序空着也行,定时完全由中断完成 */ }
}
因为方式 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 自动重载且常用于串口波特率。
- 秒级以上定时用“硬件定时器 + 中断里软件计数”,并注意中断服务函数要短。
中断系统
学完本章你能
- 用自己的话解释中断、中断源、中断向量、中断优先级、中断嵌套
- 写出任意一个中断的完整配置流程(开分开关 + 开总开关 + 写中断函数)
- 正确写出 C51 的中断服务函数,知道
interrupt n与using m的含义 - 诊断“进了中断出不来”“中断只触发一次”这类典型故障
9.1 一个生活类比就懂了
你在写作业(主程序)。突然手机响了(中断请求)。你把笔放下、在书页上夹个书签记住写到哪(保护现场 / 压栈返回地址),接完电话(执行中断服务函数),回到书签处继续写(恢复现场 / 返回)。
如果电话是妈妈打来的(高优先级)而门铃也响了(低优先级),你会先接电话再接门铃——这就是中断优先级。如果接电话时又来一条更重要的短信(更高优先级中断),你先看短信再继续打电话,这就是中断嵌套。
① 实时响应:不用傻等(查询法要一直轮询,事件可能稍纵即失)。
② 解放 CPU:主程序可以专注干活,事件来了才切换。
③ 精确计时:定时器中断让动作与时钟严格对齐,比空转延时准得多。
9.2 51 的中断源与中断向量表
“中断向量”就是每个中断对应的固定入口地址。CPU 一旦响应某个中断,就自动跳到这个地址执行你写的中断函数。这是硬件规定死的,必须记住:
| 中断源 | 触发条件 | 向量地址 | 中断号 interrupt n | 分开关(IE 中的位) |
|---|---|---|---|---|
| 外部中断 0 INT0 (P3.2) | P3.2 出现低电平或下降沿 | 0003H | 0 | EX0 |
| 定时器 0 TF0 | T0 计数溢出 | 000BH | 1 | ET0 |
| 外部中断 1 INT1 (P3.3) | P3.3 出现低电平或下降沿 | 0013H | 2 | EX1 |
| 定时器 1 TF1 | T1 计数溢出 | 001BH | 3 | ET1 |
| 串口 RI 或 TI | 收到一字节 或 发送完成 | 0023H | 4 | ES |
| (52 增强)定时器 2 | TF2 / EXF2 | 002BH | 5 | ET2 |
为什么地址间隔是 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; /* 位操作本质就是这一句 */
只写 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。
判断是否响应
要同时满足:该中断的分开关和 EA 都打开、没有更高优先级中断在执行、当前指令已执行完。
保护断点
硬件自动把 PC 的值压入堆栈(记住回来的路),然后跳到中断向量地址。
执行中断服务函数
你的代码运行。C 编译器还会自动保护用到的寄存器(压栈),函数结束时恢复。
清除标志并返回
定时器/外部中断的标志通常由硬件自动清除(串口的 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 中断里必须重装初值。
串口通信与常见总线
学完本章你能
- 解释串行与并行的区别,以及 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 字节/秒
这也是为什么串口打印多了会"卡":它真的很慢。
对 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 位自动重载)产生。公式是:
其中 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 = 9600 × 1152,能被各种标准波特率整除,算出的 TH1 都是整数,误差为 0。而 12MHz 虽然定时器算起来漂亮(1µs 一个机器周期),但波特率误差大。结论:要用串口就用 11.0592MHz;只做定时不做串口,12MHz 更省心。(可用下面的计算器亲自验证)
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);
}
}
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 上尽量用整数或定点数打印。
① 忘了清 TI/RI → 一直重复进中断,程序像死机。
② TX/RX 没交叉 → 完全没有数据。正确接法:51 的 TXD → TTL 模块的 RXD。
③ 没有共地 → 收到乱码或收不到。
④ 波特率不一致或晶振设错 → 满屏乱码。Keil 工程里也要把晶振频率填对,否则 DelayMs 也不准。
⑤ 烧录时串口被占用 → 下载失败。下载前先关掉串口助手,或使用带自动断电下载的板子。
10.6 三种常用同步总线对比
UART 是异步的(无时钟线)。接传感器、存储器时,更常用带时钟线的同步总线:
| 总线 | 线数 | 引脚 | 速度 | 特点 | 典型器件 |
|---|---|---|---|---|---|
| I2C | 2 | SCL, SDA | 100k/400kHz | 两根线可挂多个器件(靠 7 位地址区分);必须外接上拉电阻(开漏);有应答机制 | AT24C02、PCF8574、OLED、MPU6050 |
| SPI | 4 | SCK, MOSI, MISO, CS | 几 MHz~几十 MHz | 最快、全双工、无地址(每个从机一根 CS 片选);线多 | W25Q Flash、NRF24L01、TFT 屏、SD 卡 |
| 单总线 | 1 | DQ | 较慢 | 一根线既供电又通信(寄生供电);对时序要求极严格(微秒级) | DS18B20 温度传感器、DHT11 |
传统 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 靠软件模拟。
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 1 | n 是中断号,编译器负责入口与 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 中字节数 | 取值范围 | 使用建议 |
|---|---|---|---|
| bit | 1 位 | 0 / 1 | 标志位首选,最省内存 |
| unsigned char / u8 | 1 | 0 ~ 255 | 最常用,计数器、寄存器值、数组元素 |
| signed char | 1 | −128 ~ 127 | 需要负数时用 |
| unsigned int / u16 | 2 | 0 ~ 65535 | 延时循环、定时器计数、地址 |
| signed int | 2 | −32768 ~ 32767 | 需要负数的中间计算 |
| unsigned long / u32 | 4 | 0 ~ 4294967295 | 少用:运算慢、占内存 |
| float | 4 | 约 ±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、外部芯片)。它们的配置思路完全一致,照这五步做就不会乱:
查手册确认寄存器
找到相关的寄存器(如 TMOD、TCON)和它的每一位含义。不要凭记忆。
确定“保留位”和“目标位”
哪些位是别人的(比如 T1 的配置)、哪些是你要改的。能用位操作就别整字节赋值。
按“读—改—写”方式修改
TMOD &= 0xF0; TMOD |= 0x01;—— 先清目标位,再置新值,保留其他位。打开允许/中断开关
例如 ET0 = 1; EA = 1; 或 REN = 1; 开关没开,配置再好也不工作。
启动并验证
例如 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:
• 中断服务函数与主程序共享的变量;
• 映射到硬件寄存器的变量(值随硬件变化,例如 ADC 结果寄存器);
• 在多任务/RTOS 中被其他任务修改的变量。
它只影响编译器优化,不改变程序逻辑,代价只是效率略降——该加就加,别省。
本章小结
- 按“头文件 → 类型别名 → 引脚定义 → 全局变量 → 函数 → main → 实现”的骨架写代码,可读性立刻提升。
sbit定义单个引脚(原子位操作),sfr给寄存器地址起名,bit声明位变量,volatile防止优化出错。- 8 位机上首选
unsigned char,慎用int/long/float;注意整数溢出、整数除法、有无符号混用。 - 配置外设的标准五步:查手册 → 分清位 → 读改写 → 开开关 → 启动验证。
- 清位用
&= ~mask,置位用|= mask,不要整字节乱覆盖。
工具链与上电全流程
学完本章你能
- 在 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 / .uvprojx | Keil 工程文件 | 记录了芯片型号、晶振、输出设置 |
| 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 建工程:八步走
新建工程
Project → New µVision Project,建议每个实验建一个独立文件夹,路径不要有中文和空格(很多人被这个坑掉半小时)。
选择芯片型号
在 Device 里搜索你的芯片:
AT89C52、STC89C52(若列表没有 STC,选 Atmel 的 AT89C52 即可,寄存器兼容)。是否复制启动代码
弹出 “Copy Standard 8051 Startup Code?” 时选是(生成 STARTUP.A51,负责清零 RAM)。
添加源文件
新建
main.c并保存到工程目录,然后右键 Source Group 1 → Add Existing Files 加入工程。设置晶振频率
Project → Options for Target → Target 页,把 Xtal 改成你板子的实际频率(11.0592 或 12MHz)。这会影响下载工具的波特率计算和调试时序。
生成 HEX 文件
Options for Target → Output 页,勾选 “Create HEX File”。不勾选的话编译器不会生成 hex,STC-ISP 里就找不到文件。
编译
按 F7(Build)。底部 Build Output 出现
0 Error(s)即成功;若有 warning 尽量也解决掉。找到 HEX
HEX 文件在工程目录下的
Objects(或你设置的 Output)文件夹里,文件名通常是工程名.hex。
未授权的 Keil C51 评估版有 2KB 代码大小限制。入门实验(点灯、按键、定时器、串口)基本都在 2KB 以内,可以正常学习;一旦程序变大报 “RESTRICTED VERSION” 或 “code size limit exceeded”,就需要购买授权或改用 SDCC。请不要使用来源不明的破解文件,教学实验用评估版完全够用。
12.3 下载:STC-ISP 五步走
连接硬件
USB 线接开发板;若是外接 USB-TTL 模块,注意 TXD↔RXD 交叉、GND 共地。板子先不要上电(或准备好断电重上)。
选择芯片型号
在 STC-ISP 左上角选对型号(如 STC89C52RC / STC89C52RC/LE52RC)。型号选错会导致下载失败或参数错误。
选择串口号
插上 USB 后,在设备管理器里找到 “USB-SERIAL CH340 (COMx)”,在软件里选同一个 COM 口。看不到 COM 口说明驱动没装。
打开 HEX 文件
点“打开程序文件”,选择 Keil 生成的 .hex。软件会显示程序占用的字节数。
冷启动下载
点“下载/编程”,然后给开发板断电再上电(或按板上的电源开关/复位键)。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) 主循环,同时中断在后台随时插队
│
└──► 直到下一次复位或断电
启动代码已经初始化了堆栈相关设置。如果你手动改 SP,要确保新位置不与工作寄存器区、位寻址区、其他变量重叠,否则会出现“函数一返回就跑飞”的诡异问题。除非确实遇到堆栈不够,一般不需要动它。
12.5 调试方法:没有断点,怎么找问题
51 的开发板通常没有硬件调试器,所以你要掌握的“穷人调试法”是这几件:
① LED 当探针
在可疑代码前后翻转一个 LED,用现象判断程序是否执行到那里。这是最原始也最有效的方法。
② 串口打印
用 printf 输出变量值、进入函数的标记。注意波特率要对、别在中断里打印。
③ 万用表/示波器量电平
怀疑引脚没动?直接量电压。怀疑时序不对?用逻辑分析仪抓波形,和手册的时序图对照。
④ Keil 软件仿真
Options → Debug → Use Simulator,可以单步执行、看寄存器/变量/IO 口状态,不需要硬件。查逻辑错误非常好用。
12.6 “编译通过但没现象”排查流程
确认程序真的下进去了
STC-ISP 是否提示“操作成功”?重新下一个最简单的点灯程序验证整条链路。
确认端口号对不对
拿原理图核对:LED 到底接在 P1 还是 P2?是高电平点亮还是低电平?这一步能解决 80% 的“没现象”。
确认电路本身没问题
LED 极性是否接反、限流电阻是否虚焊、排线是否插错位、共地是否可靠。
确认程序在运行
用万用表量 ALE 引脚(第 30 脚),正常运行时应有约 1/6 晶振频率的方波(12MHz → 约 500kHz)。没有波形说明芯片没跑起来(复位电路、晶振或 EA 接法有问题)。
缩小范围二分定位
把代码注释掉一半,看现象是否变化,逐步缩小到出问题的那几行。不要靠猜。
检查那三个“隐藏杀手”
忘了开总中断 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、串口、万用表和软件仿真这四种方法调试。
- “没现象”时按固定顺序排查:下载是否成功 → 端口/极性是否对 → 电路是否通 → 芯片是否在跑。
避坑指南与自测
学完本章你能
- 对着症状表快速定位大部分入门故障
- 建立一套“先硬件后软件、先简单后复杂”的排错习惯
- 通过 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 没置 1 | 51 的 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.1 SFR 速查
| 寄存器 | 地址 | 位寻址 | 一句话功能 | 典型初值 |
|---|---|---|---|---|
| P0 P1 P2 P3 | 80/90/A0/B0H | ✔ | 四个 8 位 IO 口数据寄存器 | 0xFF(输入态) |
| SP | 81H | ✗ | 堆栈指针 | 0x07(复位值) |
| DPTR (DPH,DPL) | 83H,82H | ✗ | 16 位数据指针 | 0x0000 |
| PCON | 87H | ✗ | SMOD 波特率倍速、掉电/空闲模式 | 0x00 |
| TCON | 88H | ✔ | 定时器启停与溢出标志、外部中断触发方式 | 0x00 |
| TMOD | 89H | ✗ | 定时器工作方式选择 | 0x01 / 0x20 |
| TL0/TH0/TL1/TH1 | 8A~8DH | ✗ | 定时器计数值 | 按计算填写 |
| SCON | 98H | ✔ | 串口方式、允许接收、TI/RI 标志 | 0x50 |
| SBUF | 99H | ✗ | 串口收发缓冲(写=发,读=收) | — |
| IE | A8H | ✔ | 中断允许(EA 总开关 + 5 个分开关) | 0x00 |
| IP | B8H | ✔ | 中断优先级 | 0x00 |
| PSW | D0H | ✔ | CY / RS1 RS0 / OV / P 等状态 | 0x00 |
| ACC / B | E0H / F0H | ✔ | 累加器 / 乘除法辅助寄存器 | — |
14.2 引脚速查
| 引脚(DIP40) | 名称 | 功能要点 |
|---|---|---|
| 1 ~ 8 | P1.0 ~ P1.7 | 纯用户 IO,最推荐接 LED/按键 |
| 9 | RST | 高电平复位,RC 电路 + 可选按键 |
| 10 ~ 17 | P3.0 ~ P3.7 | 复用:RXD/TXD/INT0/INT1/T0/T1/WR/RD |
| 18 / 19 | XTAL2 / XTAL1 | 接晶振(+30pF×2 到 GND)或内部时钟 |
| 20 | GND | 地,必须与所有外设共地 |
| 21 ~ 28 | P2.0 ~ P2.7 | 扩展时作高 8 位地址,否则可作普通 IO |
| 29 | PSEN | 读片外程序存储器选通 |
| 30 | ALE | 地址锁存使能;可用示波器测它判断芯片是否在跑 |
| 31 | EA | 1/VCC = 用片内 Flash(常规);0/GND = 只用片外 ROM |
| 32 ~ 39 | P0.7 ~ P0.0 | 开漏,作 IO 必须外接上拉;扩展时作低 8 位地址/数据 |
| 40 | VCC | 电源 +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_osc | 12MHz → 1µs |
| 机器周期(1T) | T = 1 / f_osc | STC8/15 等增强型 |
| 定时初值(方式 1) | 初值 = 65536 − t / T | t 为定时时间,T 为机器周期 |
| 定时初值(方式 2) | 初值 = 256 − t / T | 自动重载,仅 8 位 |
| 最大单次定时 | 65536 × T | 12MHz 约 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 下一步学什么
把外设一个个打穿
数码管(静态/动态扫描)→ 独立按键与矩阵键盘 → 蜂鸣器 → 继电器 → LCD1602 → DS18B20 → AT24C02 → 红外遥控 → 步进电机。
把协议吃透
UART / I2C / SPI / 单总线的手册时序,自己用 IO 口模拟一遍,再看示波器波形对照。
做一个完整的作品
把多个模块组合成有实际用途的东西(电子钟、温控风扇、计数器),会逼你处理状态机、按键交互、异常情况。
然后升级到 STM32 / 国产 32 位 MCU
你会惊喜地发现:GPIO、定时器、中断、串口这些概念完全复用,只是寄存器变多、工具链变现代、能跑 RTOS。
单片机不是靠“看完教程”学会的,是靠把灯点亮、把波形调出来、把 bug 修掉学会的。今天就从第 12 章的“点亮第一个 LED”开始,把这篇教程当成你手边的参考书,遇到问题回来翻对应章节。
全书回顾
- 地基(第 1-4 章):电压电流电阻 → 高低电平 → 二进制与十六进制 → 位运算。这四章决定了你后面能不能"看懂"代码。
- 内核(第 5-7 章):40 个引脚的分工、IO 口的准双向与开漏结构、四个地址空间与 SFR。这三章决定了你能不能"看懂"手册。
- 外设(第 8-10 章):时钟与定时器、中断系统、串口与总线。这三章决定了你能做多复杂的项目。
- 动手(第 11-12 章):C51 的关键字与语法、工具链与下载流程。这两章决定了你的代码能不能跑起来。
- 收尾(第 13-14 章):故障排查表、自测题与速查表。这两章是长期陪伴你的工具页,建议打印出来贴在桌前。