大一新生 · 已学 C / 电路前三章 / 数电到 TTL

51 单片机详细教程

按【普中官方】《手把手教你学 51 单片机》的 35 章顺序编排,每章都是「电路图 → 原理 → 代码 → 逐行拆解 → 易错点 → 练习」。

全部 35 章已上线
单文件离线可用 · 自动记录进度 · 支持深色模式

怎么用这个教程

  1. 按章节顺序走,不要跳。第 5 章(最小系统)和第 4 章(位运算)是后面所有实验的地基。
  2. 每章的代码都要自己敲一遍,编译、烧录、看现象。看懂 ≠ 会做。
  3. 每章末尾有练习,做完再进下一章。
  4. 左侧可以搜索关键词(如「定时器」「TMOD」「晶振」)。

你的现有基础怎么用

你学过的在哪里直接用上
C 语言全程。单片机只用到基础语法 + 位运算
电路前三章(KCL/分压/欧姆定律)第 5 章复位电路、第 8 章 LED 限流电阻
数电到 TTL 门电路第 8 章「为什么低电平点亮」、第 10 章数码管、第 14 章 74HC595
好消息:你刚学的 TTL 门电路,正好解释了第 8 章那个反直觉的现象 —— 为什么单片机输出 0 反而让 LED 亮。第 4 章会把这条线接起来。

模块一 · 入门准备 第 1-7 章

模块二 · GPIO 与外设 第 8-17 章

模块三 · 中断·定时器·串口 第 18-21 章

模块四 · 总线与传感器 第 22-27 章

模块五 · 显示与扩展 第 28-35 章

1 如何学习单片机

1.1 先说结论:单片机是「用软件控制硬件」

你已经会写 C 程序了,但你的程序现在只能:读键盘、算数、打印到屏幕。

单片机会让你的程序长出手脚 —— 同一段 C 代码,可以让一个灯闪、让电机转、让屏幕显示温度。

一句话理解:单片机 = 一个能跑你程序的微型电脑 + 一堆可以直接控制的引脚。

1.2 为什么从 51 开始,而不是 STM32

对比项51(STC89C52)STM32(ARM)
结构简单,寄存器少复杂,寄存器上千个
上手一周能点亮灯、能中断光配时钟就要学几天
资料中文资料极多,普中/郭天祥英文手册为主
用途教学、小家电、简单控制工业、消费电子主流

51 是「最小可用的完整模型」:CPU、存储器、定时器、中断、串口全都有,但每个都简单到能手算。学完再上 STM32,你会发现只是「同样的东西,多了十倍选项」。

1.3 学习方法:三条铁律

① 一定要动手,而且要先「抄」再「改」
看懂 ≠ 会写。官方的例程先原样跑通,再改一个数字看现象怎么变 —— 这是最快的路径。
② 每一行代码都要能说出「它让哪个引脚变成了什么电平」
单片机编程最大的坑是「代码跑对了但硬件没反应」。养成习惯:写 P2=0xFE 就问自己 P2.0 是 0 还是 1。
③ 不要背寄存器,要会查手册
TMOD 的 8 个位没人背得下来。手册 + 一份自己的笔记就够了。

1.4 学习前准备清单

项目说明你有吗
开发板普中-2/3/4 任意一款✓
USB 线能传数据的(不是只能充电的)要确认
Keil C51编译软件,本机已装在 D:\keli4✓
烧录软件普中下载软件 / STC-ISP资料 5--开发工具 里
串口驱动CH340 或 PL2303资料 5--开发工具 里

1.5 常见疑问

Q:C 语言要学到什么程度?
你这门课已经够了。单片机只用:变量、if/for/while、函数、数组、指针(少量)、位操作。用不到结构体指针、链表、动态内存。
Q:数电要学多深?
二极管门、TTL、逻辑门、真值表、触发器 —— 这些在「数码管译码」「74HC595」「锁存器」几章会直接用上。CMOS 传输门之后的可以不急。
Q:电路要学到什么程度?
前三章(KCL/KVL、串并联、分压)足够。后面 LED 限流电阻、按键上拉、三极管驱动都会用到分压和欧姆定律。

常见错误速查

① 一上来就啃寄存器手册 —— 手册是查的,不是读的。先跟着例子跑通,卡住再查。
② 只看视频不动手 —— 单片机是"手上功夫",看十遍不如敲一遍。
③ 买回来就拆 —— 先跑通例程确认板子是好的,再折腾。

1.6 本章练习

  1. 说出单片机和你电脑的区别(至少三条)。
  2. 在资料里找到「5--开发工具」,列出里面有几个软件、各自干什么。
  3. 给自己定一个目标:两周后能独立做出「按键控制 LED 流水灯」。
📖 参考答案(点击展开)
  1. ① 单片机是「专用」的,只跑一个程序;电脑是「通用」的,能装各种软件。② 单片机资源极小(8KB Flash / 512B RAM),电脑是 GB 级。③ 单片机没有操作系统(裸机),程序直接从 main 开始。④ 单片机靠引脚直接控制硬件。
  2. 打开 `5--开发工具`,通常有:Keil C51(编译)、普中下载软件或 STC-ISP(烧录)、CH340/PL2303 驱动(USB 转串口)、可能还有取模软件。具体以你本机目录为准。
  3. 参考答案不重要,关键是写下日期并贴在显示器旁。建议:第 1 周跑通 1-13 章,第 2 周做 12/13/18-21 章,最后做「按键控制流水灯」。
2 开发板与准备工作

2.1 开发板上有什么

普中-2/3/4 属于「全功能学习板」,资源很全。你只需要先认识这几块:

模块接在哪个口从第几章开始用
8 个 LEDP2 口(P2.0~P2.7)第 8 章
蜂鸣器P2.5第 9 章
数码管P0(段码)+ P2.2/P2.3/P2.4(位选)第 10-11 章
独立按键P3.0~P3.3第 12 章
矩阵按键P1 口第 13 章
串口P3.0(RXD) / P3.1(TXD)第 21 章
先记这一条:P2 口是 LED,P3.0~P3.3 是按键。这两组贯穿整个基础部分。

2.2 板子的「三个开关」

拿到板子第一件事不是写程序,是确认这三个地方:

  1. 电源开关 —— 拨到 ON,电源指示灯亮。
  2. 下载/运行切换(部分型号有)—— 烧录时拨到「下载」,运行拨到「运行」。
  3. USB 口 —— 有的板子有两个口(一个供电一个下载),插错就烧不进去。

2.3 烧录软件怎么用

普中板子用的是 STC 芯片,烧录流程(STC-ISP 或普中下载软件都一样):

步骤操作注意
1选芯片型号STC89C52RC(板子上丝印为准)
2选串口设备管理器里看 COM 几
3打开 .hex 文件Keil 编译后生成的那个
4点「下载/编程」然后才给板子上电或按复位
最常见的坑:点了下载但一直停在「正在检测目标单片机…」。原因是 STC 芯片必须断电重启才进下载模式 —— 点完下载要拔插一下 USB 或按一下复位键。

2.4 驱动装不上怎么办

插上 USB 后设备管理器里出现黄色感叹号,说明缺驱动:

  • CH340 芯片 → 装 CH341SER.EXE
  • PL2303 芯片 → 装 PL2303 驱动(Win10 以上要用老版本 3.3.2.102)
  • 装完重新插拔,设备管理器里应出现 USB-SERIAL CH340 (COMx)

常见错误速查

① 点下载后一直停在「正在检测目标单片机」 —— STC 芯片必须断电重启才进下载模式。点完下载要拔插 USB 或按复位键。
② 串口号选错 —— 设备管理器里确认 COM 号,插拔后可能变。
③ 用了只能充电的 USB 线 —— 数据线必须有 D+/D-,否则电脑认不到板子。
④ 驱动没装 —— 设备管理器有黄色感叹号,装 CH340 或 PL2303 驱动。

2.5 本章练习

  1. 在设备管理器里找到你的串口号,记下来。
  2. 把板子通电,找出电源灯、8 个 LED、4 个独立按键的物理位置。
  3. 用万用表(或看丝印)确认 LED 是接 P2 口、按键接 P3 口。
📖 参考答案(点击展开)
  1. 设备管理器 → 端口(COM 和 LPT) → 看到 `USB-SERIAL CH340 (COMx)`,那个 x 就是串口号。插拔 USB 会变,烧录前确认一次。
  2. 电源灯通常在板子一角常亮;8 个 LED 排成一排标 D1~D8;4 个独立按键标 K1~K4,通常靠近边缘。
  3. 看丝印:LED 旁边应印 `P20`~`P27`(即 P2.0~P2.7);按键旁边印 `P30`~`P33`。用万用表蜂鸣档量 LED 阴极到 P2 引脚应导通。
3 51 单片机介绍

3.1 一颗芯片里的五样东西

m04_内部资源

这颗芯片里有五个部分,你后面 30 多章做的事,本质上就是学会操作这五样东西:

部件STC89C52 的规格用来干什么对应章节
ROM(程序存储器)8 KB Flash存你编译出来的程序全部
RAM(数据存储器)512 B存变量、数组、堆栈全部
I/O 口P0~P3,共 32 根接 LED、按键、屏幕8-17
定时器/计数器T0、T1,16 位精确定时、数脉冲20
中断系统5 个中断源让 CPU 响应突发事件18-19
串口 UART1 个全双工和电脑/模块通信21
注意 RAM 只有 512 字节。你在 C 里随手写个 char buf[1000]; 就会编译不过或运行乱掉 —— 这和电脑上写程序很不一样。

3.2 引脚怎么认

m02_引脚分布

40 根引脚分成几类:

类别引脚作用
电源40 (VCC)、20 (GND)供电,各接 5V 和地
时钟18 (XTAL2)、19 (XTAL1)接晶振
复位9 (RST)高电平复位
I/O 口P0、P1、P2、P3 各 8 根通用输入输出
控制29 (PSEN)、30 (ALE)、31 (EA)扩展外部存储器用,基础实验基本不接
P3 口是「第二功能口」:P3.0/P3.1 是串口,P3.2/P3.3 是外部中断,P3.4/P3.5 是定时器输入 —— 后面几章会反复用到。

3.3 型号怎么读

STC89C52RC-40I-PDIP40

字段含义
STC厂商(宏晶科技)
898051 内核
CCMOS 工艺
52ROM 8KB、RAM 512B(51 是 4KB/128B)
RCRAM 加大版
40I最高 40 MHz,工业级
PDIP4040 脚双列直插封装

常见错误速查

① 把 P0 和 P2 搞混 —— LED 在 P2、数码管段码在 P0。写错了现象是"灯不亮但数码管乱闪"。
② 以为 RAM 很大 —— 只有 512 B,一个 char buf[600] 就爆了。
③ 引脚数反了 —— DIP 封装从左上角圆点开始逆时针数。

3.4 本章练习

  1. 不看图,说出 VCC、GND、RST、XTAL1、XTAL2 分别是第几脚。
  2. P2.3 是第几脚?(提示:P2.0 是 21 脚,往下数)
  3. 如果程序要存 20KB,STC89C52 够吗?为什么?
📖 参考答案(点击展开)
  1. VCC = 40 脚;GND = 20 脚;RST = 9 脚;XTAL2 = 18 脚;XTAL1 = 19 脚。
  2. P2.0 是 21 脚,往下数 P2.1=22、P2.2=23 → P2.3 = 24 脚。
  3. 不够。STC89C52 只有 8KB Flash,20KB 装不下。需要换 32KB 以上的型号(如 STC89C52RC 实际是 8K,要更大得选 STC12C5A60S2 等)。
4 数电与 C 语言的衔接

4.1 你已经会的,这里全用得上

这一章不重复讲你学过的东西,只讲「它们在这里长出什么新用法」。

你学过的在单片机里变成什么时候用
数电:逻辑门、真值表数码管段码、74HC595 移位第 10、14 章
数电:TTL 电平0 = 0V、1 = 5V,但灌电流/拉电流不同第 8 章 LED 为什么低电平亮
数电:触发器74HC595 内部的移位寄存器第 14 章
电路:分压、欧姆定律算 LED 限流电阻第 8 章
电路:RC 充放电复位电路、按键消抖第 5、12 章
C:位运算直接操作寄存器全部

4.2 最关键的衔接:位运算就是「控制引脚」

单片机的每个寄存器都是 8 位,每一位对应一个引脚或一个功能开关。你在 C 里学的位运算,在这里就是硬件控制。

写法效果典型用途
P2 = 0xFE;1111 1110 → P2.0 为 0,其余为 1只点亮 D1
P2 &= 0xFE;把最低位清 0,不影响其他位只熄灭 D1,保留其他 LED
P2 |= 0x01;把最低位置 1只点亮/熄灭某一位
P2 ^= 0xFF;全部取反8 个 LED 一起翻转
P2 = (P2 << 1) | 0x01;左移一位再补 1流水灯
if (P3 & 0x01)只判断 P3.0 这一位读按键
为什么用 &= 和 |=,而不是直接 =?
因为一个端口有 8 个引脚。写 P2 = 0xFE 会把 8 个引脚全部重设一遍;写 P2 &= 0xFE 只动最低位,其余 7 个引脚保持原样。后面驱动数码管、LCD 时这个区别至关重要。

4.3 三个单片机专用的 C 语法

语法作用例子
sbit把某一位定义成变量名sbit LED1 = P2^0; 之后可以写 LED1 = 0;
interrupt n把函数声明成中断服务程序void timer0() interrupt 1
#include <reg52.h>引入 51 的寄存器定义有了它才能写 P0/P1/TMOD
sbit 的 ^ 不是异或!
sbit LED = P2^0; 里的 ^ 是「取第 0 位」的专用语法,只能在定义时用。在普通语句里 P2^0 才是异或。这是新手最容易搞混的地方。

4.4 中断号必须记

中断号中断源对应章节
0外部中断 0(INT0,P3.2)第 19 章
1定时器 0第 20 章
2外部中断 1(INT1,P3.3)第 19 章
3定时器 1第 20 章
4串口第 21 章

常见错误速查

① sbit 的 ^ 当成异或 —— 定义时可以写 sbit LED=P2^0,但语句里 P2^0 是异或,含义完全不同。
② 用 = 而不是 &=/|= —— 会把整个端口 8 位一起重置,影响其他引脚。
③ sbit 写在函数里 —— 必须写在函数外(全局),否则编译报语法错。

4.5 本章练习

  1. 写一行代码:让 P2.3 变 0,其他位不变。
  2. 写一行代码:让 P1 的高 4 位变 1,低 4 位不变。
  3. 判断 P3.2 是否为 0,用一行 if 写出来。
  4. 说出 sbit KEY = P3^2; 里的 ^ 和普通异或的区别。
📖 参考答案(点击展开)
  1. P2 &= 0xF7; (P2.3 是 bit3,掩码 1111 0111 = 0xF7)
  2. P1 |= 0xF0; (高 4 位置 1,低 4 位不变)
  3. if ((P3 & 0x04) == 0) { ... } (P3.2 是 bit2,掩码 0x04)
  4. sbit KEY = P3^2; 里的 ^ 是定义位变量的专用语法,只在定义时用;普通语句里 a ^ b 是按位异或。两者含义完全不同。
5 最小系统

5.1 最小系统 = 芯片能跑起来的最低配置

m01_最小系统

把这三样接对,芯片就会开始执行 ROM 里的程序。普中开发板已经把这些做好了,你只需要理解原理 —— 因为一旦板子不工作,排查顺序就是这个顺序。

5.2 时钟电路:为什么是 11.0592 MHz 而不是 12 MHz?

这是个必考问题。答案只有一句话:为了串口波特率能整除。

51 的定时器时钟 = 晶振频率 ÷ 12。串口方式 1 的波特率公式(SMOD=1 时):

波特率 = (2^SMOD / 32) × (晶振 / (12 × (256 - TH1)))

代入 SMOD=1、TH1=0xFA(即 250,重装值 6):

波特率 = (2 / 32) × (晶振 / (12 × 6)) = 晶振 / 1152

要 9600:晶振 = 9600 × 1152 = 11059200 Hz = 11.0592 MHz  ✓ 正好
用 12 MHz:12 000 000 / 1152 = 10416.7 → 误差 8.5%  ✗ 通信必乱
所以 11.0592 MHz 是「为了通信」特意选的怪数字。如果只是做定时,12 MHz 更好算(1 个机器周期正好 1 μs);但一旦要用串口,就必须 11.0592 MHz。

5.3 复位电路:两个作用

方式过程作用
上电复位上电瞬间电容充电,RST 保持高电平约 10 ms保证上电时程序从头开始
按键复位按下按键,RST 直接接 VCC程序跑飞时手动重启

51 是高电平复位:RST 脚持续 2 个机器周期以上的高电平就复位。而 STM32 是低电平复位 —— 不要搞混。

5.4 电源与去耦电容

每个电源引脚旁边都要接一个 0.1 μF 的陶瓷电容到地。作用是:

  • 滤掉高频噪声(防止误动作、程序跑飞)
  • 就近提供瞬时电流(芯片瞬间拉电流时电容顶上)
新手最容易忽略:自己焊最小系统板时忘了接去耦电容,结果程序偶尔乱跑 —— 这种「偶发」故障极难查,因为万用表看不出来。

5.5 排查顺序(背下来)

  1. 量电源:VCC 对 GND 是不是 5 V。
  2. 量复位:正常运行时 RST 应该是低电平。
  3. 量晶振:用示波器看 XTAL2 有没有起振(万用表量不出来)。
  4. 看 EA 脚(31 脚):接高电平才是从内部 ROM 执行。

常见错误速查

① 晶振没起振 —— 万用表量不出来,要用示波器看 XTAL2。
② 忘了接去耦电容 —— 现象是"偶发乱跑",极难查。
③ EA 脚悬空 —— 31 脚必须接高电平,否则不从内部 ROM 执行。

5.6 本章练习

  1. 用 11.0592 MHz 算:定时器方式 1 要定时 1 ms,初值是多少?
  2. 如果晶振换成 12 MHz,同样初值实际定时多久?
  3. 说出复位电路中电容和电阻各自的作用。
  4. 为什么 RST 脚平时必须是低电平?
📖 参考答案(点击展开)
  1. 机器周期 = 12/11.0592MHz = 1.0850694 μs。1ms 需要 1000/1.0850694 = 921.6 → 取 922 个计数。初值 = 65536 − 922 = 64614 = 0xFC66(TH0=0xFC, TL0=0x66)。
  2. 同样初值 0xFC66 在 12MHz 下:机器周期 = 1 μs,922 × 1 μs = 0.922 ms(偏短 7.8%)。
    反过来说:若用 0xFC18(1000 计数),在 12MHz 下正好 1.000 ms —— 所以 12MHz 做定时更规整,11.0592MHz 是为了串口。
  3. 电容:上电瞬间相当于短路,把 RST 拉高,随后充电到 5V,RST 慢慢降下来 —— 提供复位所需的高电平宽度。电阻:给电容提供放电回路,保证断电后能再次复位;同时限制按键按下时的电流。
  4. RST 是高电平复位。平时必须是低电平,否则芯片会一直处于复位状态,程序根本不会跑。
6 Keil C51 安装与配置

6.1 你机器上的情况(已确认)

路径版本有没有 C51用途
D:\keli4\UV4\Uv4.exeμVision 4有(C51 V9.01,完整授权)51 就用这个
D:\UV4\UV4.exeμVision 5 / MDK 5.40无(只有 ARM)STM32 用这个
这是你现在最容易踩的坑:用 MDK5 打开 51 工程会报「找不到 MCS-51 工具链」。记住:51 项目一律用 D:\keli4\UV4\Uv4.exe。

6.2 新建工程的完整步骤

  1. Project → New μVision Project,选一个纯英文路径(如 D:\keil_proj\test1)。
  2. 选芯片:Atmel → AT89C52(STC89C52 与它兼容)。
  3. 弹出「Copy Standard 8051 Startup Code?」→ 选否(普中模板不需要)。
  4. 新建文件 main.c 并 Add to Group。
  5. 打开 Project → Options for Target:
    • Target 页:Xtal 填 11.0592
    • Output 页:勾选 Create HEX File ← 不勾就烧不了
  6. F7 编译。成功会显示 0 Error(s) 并生成 .hex。

6.3 编译输出怎么看

Build target 'Target 1'
compiling main.c...
linking...
Program Size: data=9.0 xdata=0 code=30
creating hex file...
"test1" - 0 Error(s), 0 Warning(s).
字段含义要看什么
data占用 RAM 字节数不能超过 128(这是 51 的 data 区)
code占用 ROM 字节数不能超过 8192
0 Error(s)编译成功有 Error 时看最后几行定位
Warning 也要看:最常见的是 "unreferenced local variable"(变量没用)和 "conditional expression is constant"(while(1) 被警告,无害)。

6.4 常见编译错误

报错原因解决
undefined identifier 'P2'没 include加 #include <reg52.h>
syntax error near 'sbit'sbit 写在函数里了sbit 必须写在函数外(全局)
data space memory overlapRAM 用超了把大数组改小,或用 xdata
can't open file 'xxx.h'头文件路径不对Options → C51 → Include Paths 加上

常见错误速查

① 用 MDK5 打开 51 工程 —— 会报"找不到 MCS-51 工具链"。51 一律用 D:\keli4\UV4\Uv4.exe。
② 没勾 Create HEX File —— 编译成功但找不到 .hex,烧录软件无文件可选。
③ 工程放在中文/带空格路径 —— 编译可能报奇怪的错,用纯英文路径。

6.5 本章练习

  1. 新建一个空工程,编译通过(0 Error),确认生成了 .hex 文件。
  2. 故意把 #include <reg52.h> 删掉,看报什么错,再改回来。
  3. 把 Output 页的 Create HEX File 取消,编译一次,看看输出目录里少了什么。
📖 参考答案(点击展开)
  1. Project → New μVision Project → 选路径 → 选 AT89C52 → 不复制启动代码 → 新建 main.c 并 Add → Options 里 Xtal 填 11.0592、勾 Create HEX File → F7。看到 `0 Error(s)` 且目录出现 .hex 即为成功。
  2. 会报 undefined identifier 'P2' 或类似「未定义」的错误 —— 因为 P0/P1/TMOD 这些名字都定义在 reg52.h 里。
  3. 编译仍显示 0 Error,但输出目录里没有 .hex 文件(只有 .obj/.M51)。烧录软件会找不到文件。
7 工程模板与第一个程序

7.1 工程模板是什么

普中资料里每个实验都是一个压缩包,解压后是同一套文件结构:

3-LED闪烁实验\
├─ main.c              你的代码
├─ template.uvproj     Keil 工程文件(双击它打开)
├─ template.uvopt      选项文件(调试器设置,丢了会自动重建)
├─ template.hex        编译产物,烧录用
├─ template.M51        链接映射文件(看内存占用)
└─ template            无扩展名,编译中间产物
打开工程一定要双击 .uvproj,不是双击 main.c。而且必须先解压再打开 —— 从压缩包里直接双击会报 unable to open primary document entity(临时目录被清了)。

7.2 第一个程序:点亮一个 LED

m03_LED驱动
#include "reg52.h"

sbit LED1 = P2^0;      // 把 P2.0 这个引脚起名叫 LED1

void main()
{
    LED1 = 0;          // 输出低电平 → 电流从 VCC 流过 LED 进引脚 → 灯亮
    while(1)           // 停在这里,别让程序跑飞
    {
    }
}

逐行拆解

代码作用为什么必须这么写
#include "reg52.h"引入 8052 的寄存器定义没有它,P0/P1/TMOD 这些名字编译器不认识
sbit LED1 = P2^0;定义位变量之后写 LED1=0 比 P2 &= 0xFE 更直观
LED1 = 0;P2.0 输出低板上 LED 阳极接 VCC,低电平才形成回路
while(1) {}死循环单片机的 main 不能返回,返回后程序会跑飞
为什么 LED1 = 0 是亮?
看第 8 章的图:LED 阳极经 1 kΩ 电阻接 +5V,阴极接 P2.0。只有 P2.0 是低电平时,才有电流从 5V 经电阻、LED 流进引脚到地。所以低电平点亮,写 1 反而灭。这和很多人的直觉相反,务必记牢。

7.3 编译 → 烧录 → 看现象

步骤操作看到什么
1Keil 里按 F7底部显示 0 Error(s),生成 template.hex
2打开烧录软件,选芯片和 COM 口—
3载入 template.hex显示文件大小
4点「下载」,然后按板子复位进度条走完,提示成功
5观察板子D1 常亮

7.4 如果灯不亮:五步排查

  1. 烧进去了吗 —— 软件提示成功了吗?
  2. 程序真的在跑吗 —— 把 LED1=0 改成 LED1=0; while(1){ LED1=!LED1; },看是否闪烁(闪 = 程序在跑,说明是引脚/硬件问题)。
  3. 引脚对吗 —— 板子上 D1 是不是真的接 P2.0?(看原理图)
  4. 写的电平对吗 —— 试着改成 LED1=1 看会不会亮(排除板上电路不同)。
  5. 芯片选对了吗 —— 烧录软件里型号必须和板上芯片一致。

常见错误速查

① 从压缩包里直接打开工程 —— 会报 unable to open primary document entity,必须先解压。
② main 忘了 while(1) —— 程序跑飞,现象是灯乱闪或全灭。
③ 以为 LED1=0 是灭 —— 普中板是低电平点亮,写 0 才亮。

7.5 本章练习

  1. 点亮 D1,然后改成点亮 D8(提示:P2.7)。
  2. 让 8 个 LED 全亮(一行代码)。
  3. 让 8 个 LED 全亮,但只用 |= 不用 =(体会区别)。
  4. 如果把 while(1) 删掉会怎样?试一次,看现象。
📖 参考答案(点击展开)
  1. sbit LED8 = P2^7; LED8 = 0; 或直接 P2 &= 0x7F;
  2. P2 = 0x00;
  3. P2 |= 0x00; 这句什么也不会改变(或运算 0 等于不变)。
    如果本意是「全亮」,正确写法是 P2 &= 0x00;(等价于 P2=0)。这个练习就是让你体会 |= 不能用于清零。
  4. main 返回后程序会跑飞(PC 跳到未知地址)。现象:LED 保持亮着或乱闪,行为不可预测。所以 main 末尾必须有 while(1)。
8 点亮第一个 LED

8.1 GPIO 输出:让引脚听话

P2 口有 8 个引脚,每个引脚可以输出 0(约 0.3 V)或 1(约 5 V)。这就是全部的秘密。

写法结果板上的现象
P2 = 0xFE;1111 1110,只有 P2.0 = 0D1 亮,D2~D8 灭
P2 = 0x7F;0111 1111,只有 P2.7 = 0D8 亮
P2 = 0x00;全 08 个 LED 全亮
P2 = 0xFF;全 18 个全灭
速算法:想让第 n 位(n=0~7)为 0,就是 0xFF & ~(1<<n)。比如 n=3:~(1<<3) = 0xF7 → P2=0xF7 就是 D4 亮。

8.2 三种常用花样

① 流水灯(一个一个亮过去)

#include "reg52.h"

void delay_ms(unsigned int ms)      // 约 1ms 的延时
{
    unsigned int i, j;
    for (i = ms; i > 0; i--)
        for (j = 110; j > 0; j--);  // 11.0592MHz 下约 1ms
}

void main()
{
    unsigned char i;
    while (1)
    {
        for (i = 0; i < 8; i++)
        {
            P2 = ~(1 << i);          // 第 i 位为 0,其余为 1
            delay_ms(200);
        }
    }
}

② 全部闪烁

while (1)
{
    P2 = 0x00;  delay_ms(300);   // 全亮
    P2 = 0xFF;  delay_ms(300);   // 全灭
}

③ 左右来回(跑马灯)

unsigned char i;
while (1)
{
    for (i = 0; i < 8; i++) { P2 = ~(1<<i); delay_ms(120); }  // 左到右
    for (i = 6; i > 0; i--) { P2 = ~(1<<i); delay_ms(120); }  // 右到左
}

8.3 逐行拆解:P2 = ~(1 << i)

i1<<i~ 之后亮的 LED
00x010xFED1
10x020xFDD2
20x040xFBD3
30x080xF7D4
70x800x7FD8

8.4 易错点

① 延时函数被编译器优化掉
如果开了优化,空循环可能被删掉,灯就不闪了。解决:循环变量加 volatile,或者在 Keil 里把优化等级设成 Level 0。
② 延时不准
上面的 delay_ms 是按 11.0592 MHz、12T 模式估算的,实际偏差可能有 10%。需要精确定时就用定时器(第 20 章),别用空循环。
③ 忘了 while(1)
main 返回后程序会跑飞,现象是灯乱闪或者全灭。

8.5 练习

  1. 写一个「4 个亮 4 个灭」的效果(P2 = 0x0F)。
  2. 写一个「从两边向中间合拢」的效果。
  3. 让流水灯的速度从慢到快(延时可变的循环)。
📖 参考答案(点击展开)
  1. 让低 4 位亮:P2 = 0xF0;(D1~D4 亮、D5~D8 灭)。
  2. unsigned char a[8]={0x7E,0xBD,0xDB,0xE7,0xE7,0xDB,0xBD,0x7E}; 循环送即可(每次两侧各灭一个)。
  3. for(d=50; d<500; d+=50) { P2=~(1<<i); delay_ms(d); } —— 延时参数递增,速度就越来越慢(想变快就递减)。
9 蜂鸣器

9.1 有源蜂鸣器 vs 无源蜂鸣器

类型内部控制方式现象
有源自带振荡电路给低电平就响只能一种音调
无源只有一个发声片必须给方波频率变,音调就变

普中板上是无源蜂鸣器(BEEP = P2.5),所以要用方波驱动。

9.2 为什么方波能让它响

无源蜂鸣器内部是一个压电陶瓷片。给它一个方波,它就会跟着方波的频率振动 —— 频率决定音调,占空比影响音色。

#include "reg52.h"

sbit BEEP = P2^5;

void delay_10us(unsigned int ten_us)
{
    while (ten_us--);          // ten_us = 1 时约 10us
}

void main()
{
    unsigned int i = 2000;
    while (1)
    {
        while (i--)            // 循环 2000 次
        {
            BEEP = !BEEP;      // 翻转,产生方波
            delay_10us(100);   // 每半个周期约 1ms
        }
        i = 0;
        BEEP = 0;              // 关掉(防止一直通电发热)
    }
}

9.3 频率怎么算

半个周期 = delay_10us(100) ≈ 1 ms,所以:

一个完整周期 = 2 ms  →  频率 = 1 / 0.002 s = 500 Hz
delay 参数半周期频率听感
500.5 ms1000 Hz较尖
1001 ms500 Hz中等
2002 ms250 Hz低沉
想做「do re mi」?查一下音阶频率表,把 delay_10us 的参数换成对应值即可。C 调 do = 262 Hz → 半周期 1.9 ms → 参数约 190。

9.4 易错点

① 以为给 0 就响 —— 那是有源蜂鸣器的用法。无源必须给方波,给恒定电平只会「咔」一声。
② 方波频率太低 —— 低于 20 Hz 人耳听不到(只能看到纸盆在动)。常用 200~4000 Hz。
③ 一直通电 —— 程序结束前把 BEEP 置 0,否则长时间通电可能损坏。

9.5 练习

  1. 让蜂鸣器发出 1 kHz 的声音。
  2. 做「响 0.5 秒、停 0.5 秒」的报警声。
  3. (进阶)用数组存三个音阶的参数,循环播放「do re mi」。
📖 参考答案(点击展开)
  1. 1kHz → 周期 1ms → 半周期 0.5ms。用 delay_10us(50)(约 0.5ms)。
  2. while(1){ for(i=0;i<25;i++){BEEP=!BEEP; delay_10us(100);} BEEP=0; delay_ms(500); } (25 × 2ms = 50ms 响,然后停 500ms;按需调整)
  3. do(262Hz) 半周期 1.9ms → 参数约 190;re(294Hz) → 170;mi(330Hz) → 151。把这三个数放进数组循环。
10 静态数码管

10.1 数码管是怎么显示数字的

m05_数码管

一个数码管就是 8 个 LED 排成「日」字形:a~g 七段 + dp 小数点。
点亮不同的段,就能拼出不同的数字 —— 本质和你在数电课学的「译码器」是一回事。

10.2 段码怎么来的

把 8 个段看成一个字节的 8 位:

位:   dp  g  f  e  d  c  b  a
      7   6  5  4  3  2  1  0

要显示「0」,需要 a b c d e f 亮、g 灭:

a b c d e f 亮 → 0011 1111 → 0x3F

要显示「1」,只需要 b c 亮:

b c 亮 → 0000 0110 → 0x06

普中代码里的段码表就是这么一个数组(共阴):

unsigned char code gsmg_code[16] = {
    0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07,
    0x7F, 0x6F, 0x77, 0x7C, 0x39, 0x5E, 0x79, 0x71
};   // 对应 0 1 2 3 4 5 6 7 8 9 A B C D E F
注意 code 关键字:加了它,数组放到 ROM(Flash)里,不占宝贵的 RAM。不加的话 16 个字节就吃掉 512B 里的 16B。

10.3 静态显示:一位数码管

#include "reg52.h"

#define SMG_A_DP_PORT  P0        // 段码口接 P0

unsigned char code gsmg_code[16] = {
    0x3F,0x06,0x5B,0x4F,0x66,0x6D,0x7D,0x07,
    0x7F,0x6F,0x77,0x7C,0x39,0x5E,0x79,0x71
};

sbit LSA = P2^2;                 // 位选,74HC138 的三根地址线
sbit LSB = P2^3;
sbit LSC = P2^4;

void main()
{
    LSA = 0; LSB = 0; LSC = 0;   // 选中第 1 位
    SMG_A_DP_PORT = gsmg_code[0];// 显示 "0"
    while (1);
}

逐行拆解

代码作用
#define SMG_A_DP_PORT P0以后写 SMG_A_DP_PORT 就是写 P0,方便移植
code把数组放 Flash,省 RAM
LSA/LSB/LSC = 074HC138 输入 000 → 输出 Y0 有效 → 第 1 位数码管通电
P0 = 0x3F8 个段输出对应电平 → 显示「0」

10.4 为什么用 74HC138 而不是直接 8 根线

8 位数码管如果每位一根使能线,要 8 根 I/O。用 74HC138(3-8 译码器)只需要 3 根:

LSC LSB LSA选中LSC LSB LSA选中
0 0 0第 1 位1 0 0第 5 位
0 0 1第 2 位1 0 1第 6 位
0 1 0第 3 位1 1 0第 7 位
0 1 1第 4 位1 1 1第 8 位
这就是你数电课学的译码器:3 根输入线 → 8 根输出线里只有一根有效。省了 5 根 I/O,代价是「同一时刻只能亮一位」—— 于是就有了下一章的动态扫描。

10.5 易错点

① 段码表是共阴还是共阳搞错 —— 共阳要取反(0x3F → 0xC0)。现象是「该亮的灭、该灭的亮」。
② 忘了选位 —— LSA/LSB/LSC 不设,可能选中了别的位,你看到的是别的数码管在亮。
③ 数组没加 code —— 编译报 data space memory overlap 或 RAM 不够。

10.6 练习

  1. 让第 1 位数码管显示 8。
  2. 让第 8 位数码管显示 F。
  3. 轮流显示 0~9,每秒变一次。
  4. 算出显示「7」的段码(只亮 a b c)。
📖 参考答案(点击展开)
  1. 第 1 位:LSA=LSB=LSC=0;段码 0x7F(全亮)。
  2. 第 8 位:LSC=1; LSB=1; LSA=1;;段码 F = 0x71。
  3. for(i=0;i<10;i++){ P0=gsmg_code[i]; delay_ms(1000); }
  4. 只亮 a b c → 位 0,1,2 置 1 → 0b00000111 = 0x07。
11 动态数码管

11.1 8 位数码管只有一套段码线,怎么办?

8 位数码管的 a~g 段是并联在 P0 上的,只有位选(74HC138)不同。所以:

同一时刻,只能让一位亮 —— 否则 8 位会显示同样的数字

解决办法是动态扫描:快速轮流点亮每一位,每位移一次就换成那一位该显示的数字。

11.2 视觉暂留:为什么看起来是同时亮的

人眼有 0.05~0.2 秒的视觉暂留。只要每位每秒刷新超过 50 次,看起来就是稳定不闪的。

8 位扫描一遍刷新率效果
每位 1 ms125 Hz很稳
每位 2 ms62.5 Hz稳定(普中常用)
每位 5 ms25 Hz轻微闪烁
每位 10 ms12.5 Hz明显闪

11.3 完整代码

#include "reg52.h"

#define SMG_A_DP_PORT  P0
sbit LSA = P2^2;
sbit LSB = P2^3;
sbit LSC = P2^4;

unsigned char code gsmg_code[16] = {
    0x3F,0x06,0x5B,0x4F,0x66,0x6D,0x7D,0x07,
    0x7F,0x6F,0x77,0x7C,0x39,0x5E,0x79,0x71
};

void delay_10us(unsigned int ten_us)
{
    while (ten_us--);
}

void smg_display(void)
{
    unsigned char i;
    for (i = 0; i < 8; i++)
    {
        switch (i)                       // 选位
        {
            case 0: LSC=0; LSB=0; LSA=0; break;
            case 1: LSC=0; LSB=0; LSA=1; break;
            case 2: LSC=0; LSB=1; LSA=0; break;
            case 3: LSC=0; LSB=1; LSA=1; break;
            case 4: LSC=1; LSB=0; LSA=0; break;
            case 5: LSC=1; LSB=0; LSA=1; break;
            case 6: LSC=1; LSB=1; LSA=0; break;
            case 7: LSC=1; LSB=1; LSA=1; break;
        }
        SMG_A_DP_PORT = gsmg_code[i];    // 送段码
        delay_10us(100);                 // 亮约 1ms
        SMG_A_DP_PORT = 0x00;            // ★ 消影
    }
}

void main()
{
    while (1)
    {
        smg_display();
    }
}

11.4 ★ 消影:这一步为什么不能省

看代码里 SMG_A_DP_PORT = 0x00; 那一行。它的作用是在切换位之前先把段码清掉。

不加会怎样?
第 1 位显示「1」(0x06),然后切换到第 2 位。但在切换的那一瞬间,P0 还是 0x06,而位选已经变成第 2 位了 —— 于是第 2 位会「闪一下 1」。
8 位轮流下来,你看到的就是所有位都有一层淡淡的 ghost(重影)。

加了消影之后,切换瞬间 P0 = 0x00(全灭),再换位,再送新段码 —— 干净了。

11.5 扫描速度与亮度

delay 参数每位亮的时间亮度
500.5 ms较暗但刷新快
1001 ms适中(推荐)
2002 ms较亮,接近临界闪烁
亮度不够怎么办?不要靠加大 delay(会闪)。正确做法是提高扫描频率 + 减小限流电阻,或者用专门的驱动芯片。

11.6 易错点

① 忘了消影 → 全屏重影(这是新手第一大坑)。
② 扫描太慢 → 肉眼看到滚动闪烁。
③ 在中断里扫描 → 如果主循环里有 delay,扫描会被打断,导致某一位亮得特别久。要么把扫描放主循环、放中断里用定时器(第 20 章)。

11.7 练习

  1. 让 8 位数码管显示「01234567」。
  2. 让显示内容每秒整体左移一位(滚动效果)。
  3. 把消影那行注释掉,观察重影现象,再改回来。
📖 参考答案(点击展开)
  1. 用一个数组存 0-7:unsigned char t[8]={0,1,2,3,4,5,6,7};,扫描时 P0=gsmg_code[t[i]];。
  2. 每秒把数组整体左移:tmp=t[0]; for(i=0;i<7;i++) t[i]=t[i+1]; t[7]=tmp;
  3. 会看到每一位都带着淡淡的前一位数字(重影)。原因是切换位时 P0 还是上一个段码,新位把它显示出来了。
12 独立按键

12.1 按键接在哪个口

普中板 4 个独立按键:

按键引脚按下时
K1P3.1读到 0
K2P3.0读到 0
K3P3.2读到 0
K4P3.3读到 0
注意 K1/K2 的顺序:K1 是 P3.1、K2 是 P3.0,不是顺着来的。写代码前先看板子丝印或原理图。

12.2 为什么必须消抖

m06_按键消抖

机械按键在闭合和断开的瞬间会有 5~20 ms 的抖动。单片机一个循环只要几微秒,会把这串抖动读成「按了十几次」。

12.3 软件消抖的三步

unsigned char key_scan(void)
{
    static unsigned char key_up = 1;   // 松手标志

    if (key_up && (KEY1 == 0 || KEY2 == 0 || KEY3 == 0 || KEY4 == 0))
    {
        delay_10us(1000);              // ① 等 10ms,躲开抖动
        key_up = 0;                    // ② 标记「已经按下」
        if (KEY1 == 0) return 1;
        else if (KEY2 == 0) return 2;
        else if (KEY3 == 0) return 3;
        else if (KEY4 == 0) return 4;
    }
    else if (KEY1 == 1 && KEY2 == 1 && KEY3 == 1 && KEY4 == 1)
    {
        key_up = 1;                    // ③ 松手了,恢复
    }
    return 0;
}

逐行拆解

关键点为什么
static函数返回后值不丢,下次进来还记得「上次是否按下」
key_up && (...)只有「上次是松开的」才处理 → 按住不放只触发一次
delay_10us(1000)10 ms,比抖动时间长,抖完再读就稳定了
key_up = 0立刻标记,防止 10ms 后被重复触发
最后 else if 全 1四个都松开了,才允许下一次触发

12.4 两种模式:单次触发 vs 连续触发

模式行为适用场景
单次(mode=0)按住不放只算一次切换开关、菜单选择
连续(mode=1)按住会一直触发音量加减、连续调参

实现方法就是开头加一句 if (mode) key_up = 1; —— 强制让标志永远是「松开」,于是每次都触发。

12.5 易错点

① 不消抖 → 按一下触发好几次,现象是「LED 闪两下」或「数字跳 2」。
② 忘了 static → 每次进函数 key_up 都重置成 1,按住会疯狂触发。
③ 拿按键去驱动 LED 但没加延时 → 程序跑得太快,看起来像没反应。

12.6 练习

  1. 按 K1 点亮 D1,再按一次熄灭(用状态翻转)。
  2. 按 K1~K4 分别点亮 D1~D4。
  3. 把消抖延时改成 1 ms,看会不会误触发。
  4. (进阶)实现「按 K1 流水灯开始,按 K2 停止」。
📖 参考答案(点击展开)
  1. 用 static bit flag=0;:if(key==1){ flag=!flag; LED1=flag; }
  2. if(key==1)LED1=0; if(key==2)LED2=0; if(key==3)LED3=0; if(key==4)LED4=0;
  3. 1ms 比抖动时间(5~20ms)短,会误触发——按一下可能翻好几次。所以消抖至少 10ms。
  4. 加一个 run 标志:K1 时置 1,K2 时清 0;主循环里 if(run){流水灯推进}。
13 矩阵按键

13.1 为什么要矩阵按键

要 16 个按键,独立按键要 16 根 I/O。用 4 行 × 4 列 矩阵,只要 8 根。

普中板矩阵按键接在 P1 口:

行: P1.7  P1.6  P1.5  P1.4
列: P1.3  P1.2  P1.1  P1.0

13.2 原理:一行一行地「扫」

把某一列拉低、其余列拉高,然后读行。如果某一行是低,说明「这一行 × 这一列」的那个键被按下了。

第1列拉低: P1 = 0xF7  → 读 P1
  P1 = 0x77 → 第1行第1列 → 键值 1
  P1 = 0xB7 → 第2行第1列 → 键值 5
  P1 = 0xD7 → 第3行第1列 → 键值 9
  P1 = 0xE7 → 第4行第1列 → 键值 13

为什么键值是 1、5、9、13?因为板子把 16 个键按 列优先 编号:第一列从上到下是 1、5、9、13,第二列是 2、6、10、14 ……

13.3 列扫描代码(简化版)

unsigned char key_matrix_scan(void)
{
    unsigned char key_value = 0;

    P1 = 0xF7;                       // 第 1 列拉低
    if (P1 != 0xF7)                  // 有键按下
    {
        delay_10us(1000);            // 消抖
        if (P1 != 0xF7)
        {
            switch (P1)
            {
                case 0x77: key_value = 1;  break;
                case 0xB7: key_value = 5;  break;
                case 0xD7: key_value = 9;  break;
                case 0xE7: key_value = 13; break;
            }
            while (P1 != 0xF7);      // 等松手
        }
    }
    /* 第 2/3/4 列同理,0xFB / 0xFD / 0xFE */
    return key_value;
}

13.4 更聪明的「翻转法」

列扫描要写 4 段几乎一样的代码。翻转法只要两段:

① 先给所有行低、列高:P1 = 0x0F
   读 P1 的低 4 位 → 知道「按在哪一列」

② 再给所有列低、行高:P1 = 0xF0
   读 P1 的高 4 位 → 知道「按在哪一行」

③ 行号 × 4 + 列号 → 键值
第一步读到列第二步读到行
0x0710x701
0x0B20xB02
0x0D30xD03
0x0E40xE04

13.5 易错点

① 忘了读之前先写 —— 直接用 P1 读到的可能是上次的值。必须先 P1 = 0x0F; 再读。
② 行列搞反 —— 现象是键值全错。对照上表核对。
③ 忘了等松手 —— 会连续触发,数字狂跳。

13.6 练习

  1. 按下矩阵按键,把键值显示在数码管第 1 位(键值 10 以上显示 A-F)。
  2. 用翻转法重写一遍,对比代码长度。
  3. 做成「按一个键,数码管显示它的键值」的完整程序。
📖 参考答案(点击展开)
  1. 扫描到键值 k 后:P0 = gsmg_code[k-1];(k=1..16 → 下标 0..15,正好对应 0-F)。
  2. 列扫描要写 4 段几乎相同的代码(约 60 行);翻转法只要两段(约 25 行),代码短一半且不易写错。
  3. 把 key_matrix_flip_scan() 的返回值送数码管:注意键值 1-16 要减 1 才是数组下标。
14 IO 扩展 74HC595

14.1 三根线变八根线

74HC595 是一个 8 位串入并出移位寄存器。你用 3 根 I/O 线,就能控制 8 个输出。

引脚接哪作用
SER (DS)P3.4串行数据输入,一位一位送
SRCLK (SHCP)P3.6移位时钟,上升沿移一位
RCLK (STCP)P3.5锁存时钟,上升沿把 8 位一起送到输出

14.2 时序:两个时钟分工不同

m07_595时序
一句话记住:SRCLK 管「搬进去」,RCLK 管「亮出来」。
只发 SRCLK 不发 RCLK,数据在芯片里但输出端没变化 —— 这是新手最常见的「灯不亮」。

14.3 代码

#include "reg52.h"

sbit SRCLK = P3^6;      // 移位时钟
sbit RCLK  = P3^5;      // 锁存时钟
sbit SER   = P3^4;      // 串行数据

void hc595_write_data(unsigned char dat)
{
    unsigned char i;
    for (i = 0; i < 8; i++)          // 送 8 位
    {
        SER = dat & 0x80;            // 取最高位
        dat <<= 1;                   // 左移一位
        SRCLK = 0;                   // 时钟拉低
        SRCLK = 1;                   // 上升沿 → 移入
    }
    RCLK = 0;
    RCLK = 1;                        // 上升沿 → 8 位一起输出
}

逐行拆解

代码作用
dat & 0x80取出最高位。结果只可能是 0x80 或 0x00
dat <<= 1左移,把下一位挪到最高位,为下次取做准备
SRCLK=0; SRCLK=1;造一个上升沿,595 在上升沿采样 SER
循环 8 次8 位全部送完
RCLK=0; RCLK=1;最后一个上升沿,把 8 位搬到输出端
为什么先送高位?因为 595 是「先入先出」的移位链:第一个送进去的位,移 8 次后到达最后一级(Q7)。所以要送的数据位顺序是 bit7 → bit0。送反了,输出的 8 位就整体倒过来。

14.4 级联:16 位、24 位

把第一片的 Q7' 接到第二片的 SER,就能级联。软件上就是连续调用两次 hc595_write_data,最后再统一 RCLK。

14.5 易错点

① 忘了 RCLK → 数据进去了但输出不变(灯不亮)。
② 位序反了 → 想显示 0x01 结果跑了 0x80。
③ 时钟脉冲太快 → 595 最高能到几十 MHz,51 跑不到那么快,一般不用管。但如果用 STM32 就要看手册。

14.6 练习

  1. 用 595 驱动 8 个 LED 做流水灯。
  2. 送 0x01、0x02、0x04 … 观察哪个 LED 亮,验证位序。
  3. 把 RCLK 那两行删掉,看现象,再改回来。
📖 参考答案(点击展开)
  1. 循环送 hc595_write_data(1<<i),i=0..7。
  2. 该位为 1 的 LED 亮。0x01 → 只有 D1 亮,说明先送的高位最后落在 Q7,位序是对的。
  3. 数据进了移位寄存器但输出端不变 —— 8 个 LED 全不亮(或保持上次状态)。这就是「两个时钟分工不同」的实证。
15 LED 点阵

15.1 点阵的结构:行列交叉

8×8 LED 点阵里有 64 个 LED,但它们只引出 16 根线:8 行 + 8 列。

某个 LED 亮  ⇔  它的「行」和「列」同时有效

普中板的接法:

线接哪作用
行(阳极)74HC595 输出决定哪一行通电
列(阴极)P0 口决定这一行里哪几列亮

15.2 扫描:一行一行地亮

for (i = 0; i < 8; i++)
{
    hc595_write_data(0x01 << i);   // 选中第 i 行
    P0 = 字模[i];                   // 送这一行的列数据
    delay_10us(100);                // 亮 1ms
    P0 = 0xFF;                      // 消影
}

和动态数码管一个道理:8 行轮流亮,每行亮 1 ms,一帧 8 ms → 刷新率 125 Hz,看起来就是一幅稳定的图。

15.3 字模是怎么来的

把一幅 8×8 的图,逐行写成 8 个字节。1 = 亮,0 = 灭。

心形图案:
. . X X X X . .     → 0x3C
. X X X X X X .     → 0x7E
X X X X X X X X     → 0xFF
X X X X X X X X     → 0xFF
. X X X X X X .     → 0x7E
. . X X X X . .     → 0x3C
. . . X X . . .     → 0x18
. . . . . . . .     → 0x00

15.4 易错点

① 忘了消影 → 整屏发灰、有重影。
② 行和列的方向搞反 → 显示出来是转置的(上下变左右)。
③ 字模取反了 → 该亮的不亮、该灭的亮。把字模按位取反试试。

15.5 练习

  1. 显示一个「笑脸」字模(自己画格子写出来)。
  2. 让显示的字模逐行向下滚动。
  3. 显示「1」这个数字的点阵字模。
📖 参考答案(点击展开)
  1. 笑脸字模示例:{0x3C,0x42,0xA5,0x81,0xA5,0x99,0x42,0x3C}
  2. 把字模数组当成队列,每次循环整体下移一行(最后一个移到第一个),延时后重画。
  3. 数字「1」:{0x00,0x00,0x18,0x38,0x18,0x18,0x7E,0x00}
16 直流电机

16.1 为什么电机不能直接接单片机

直流电机启动电流可达几百 mA,而单片机一个引脚最多 20 mA。直接接会烧引脚。

普中板用三极管(或驱动芯片)做「开关」:

单片机 P1.0 ── 基极电阻 ── 三极管基极
                           集电极 ── 电机 ── VCC
                           发射极 ── GND
P1.0三极管电机
1饱和导通转
0截止停
这里正好用上你数电学的 TTL:三极管当开关用,和 TTL 门里那个 T4 下拉管是同一个原理 —— 输入高就导通,把负载拉到地。

16.2 最简单:开 5 秒停 5 秒

#include "reg52.h"

sbit DC_Motor = P1^0;

#define DC_MOTOR_RUN_TIME  5000     // 运行 5000ms

void delay_ms(unsigned int ms)
{
    unsigned int i, j;
    for (i = ms; i > 0; i--)
        for (j = 110; j > 0; j--);
}

void main()
{
    while (1)
    {
        DC_Motor = 1;              // 转
        delay_ms(DC_MOTOR_RUN_TIME);
        DC_Motor = 0;              // 停
        delay_ms(DC_MOTOR_RUN_TIME);
    }
}

16.3 调速:PWM 是什么

直流电机的转速和平均电压成正比。用「快速开关」来调平均电压,就是 PWM(脉宽调制)。

占空比 = 高电平时间 / 周期

占空比 100% → 全速
占空比 50%  → 半速
占空比 10%  → 很慢
周期占空比平均电压转速
1 ms100%5 V满速
1 ms50%2.5 V约半速
1 ms20%1 V很慢
频率要够高:PWM 频率低于 50 Hz 会听到「嗡嗡」声并看到抖动。电机常用 1~20 kHz。51 没有硬件 PWM,要用定时器软件产生(第 20 章学完再做)。

16.4 易错点

① 直接接引脚 → 烧引脚或带不动。
② 忘了续流二极管 → 电机断电瞬间产生反向电动势,可能击穿三极管。
③ 用 delay 做 PWM → 主循环被占满,其他事干不了。要用定时器中断。

16.5 练习

  1. 让电机转 2 秒、停 1 秒,循环。
  2. 用按键控制电机的启停。
  3. (进阶)用定时器做一个 1 kHz 的 PWM,占空比 30%。
📖 参考答案(点击展开)
  1. DC_Motor=1; delay_ms(2000); DC_Motor=0; delay_ms(1000); 放在 while(1) 里。
  2. 主循环里查按键:按下则翻转一个 run 标志,DC_Motor = run;
  3. 1kHz → 周期 1ms。用定时器每 10μs 中断一次、100 次一周期:if(cnt<30) PWM=1; else PWM=0;(30% 占空比)。初值 = 65536 − 9 ≈ 0xFFF7。
17 步进电机

17.1 步进电机和直流电机的区别

直流电机步进电机
控制给电就转给一个脉冲转一步
定位不能定位可以精确控制角度
用途风扇、玩具车3D 打印机、云台、机械臂

17.2 28BYJ-48 的结构

m08_步进相序

四相五线:4 个线圈(A/B/C/D)+ 1 根公共端。给哪个线圈通电,转子就转向哪里。

参数值
步距角5.625° / 64(内部减速比)
转一圈64 × 64 = 4096 步(八拍时)
接线IN1~IN4 → P1.0 ~ P1.3

17.3 八拍相序

拍通电相P1 的值
1A0x08
2AB0x0C
3B0x04
4BC0x06
5C0x02
6CD0x03
7D0x01
8DA0x09
unsigned char code FFW[8] = {0x08,0x0C,0x04,0x06,0x02,0x03,0x01,0x09};

17.4 完整代码骨架

#include "reg52.h"

sbit IN1_A = P1^0;
sbit IN2_B = P1^1;
sbit IN3_C = P1^2;
sbit IN4_D = P1^3;

unsigned char code FFW[8] = {0x08,0x0C,0x04,0x06,0x02,0x03,0x01,0x09};
unsigned char code REV[8] = {0x09,0x01,0x03,0x02,0x06,0x04,0x0C,0x08};

void delay_ms(unsigned int ms)
{
    unsigned int i, j;
    for (i = ms; i > 0; i--)
        for (j = 110; j > 0; j--);
}

void step_motor_send_pulse(unsigned char step, unsigned char dir)
{
    if (dir == 0)  P1 = FFW[step];       // 正转
    else           P1 = REV[step];       // 反转
    delay_ms(2);                         // 每拍间隔,决定转速
}

void main()
{
    unsigned char i = 0;
    while (1)
    {
        for (i = 0; i < 8; i++)
            step_motor_send_pulse(i, 0);       // 正转
    }
}

17.5 转速怎么算

每拍间隔 2ms → 8 拍一轮 = 16ms
4096 步 / 8 拍 = 512 轮
转一圈时间 = 512 × 16ms = 8.2 秒
转速 = 60 / 8.2 ≈ 7.3 转/分
每拍间隔转一圈转速
2 ms8.2 s7.3 rpm
5 ms20.5 s2.9 rpm
1 ms4.1 s14.6 rpm
间隔不能太小:低于 1 ms 时电机可能「跟不上」,出现失步(原地抖动不转)。这是步进电机的通病 —— 启动有最高频率限制。

17.6 易错点

① 相序表用错 → 电机只抖不转,或者反转。
② 间隔太小 → 失步、堵转、电流大、电机发烫。
③ 忘了共地 → 电机驱动模块和单片机必须共地,否则信号无效。

17.7 练习

  1. 让电机正转一圈,停 1 秒,再反转一圈。
  2. 按键 K1 加速、K2 减速(改变 delay 参数)。
  3. 算出「每拍 3 ms 时转一圈要多久」。
📖 参考答案(点击展开)
  1. 正转一圈:for(n=0;n<512;n++) for(i=0;i<8;i++) step_motor_send_pulse(i,0); (512 轮 × 8 拍 = 4096 步)
  2. 把 delay 参数做成变量 spd,K1 时减小(加速)、K2 时增大(减速),范围限制在 1~5ms。
  3. 8 拍 × 3ms = 24ms/轮;512 轮 → 12.29 秒转一圈。
18 中断系统

18.1 中断解决了什么问题

假设你的程序在跑流水灯,同时要检测按键。不用中断,你只能:

while (1) { 跑一步流水灯(); 查一下按键(); // ← 不停地问「按了没」 }

这就是查询方式。它的毛病是:跑灯和查键被绑死,灯慢下来按键就迟钝。

中断换了个思路:平时专心跑灯,按键按下时硬件主动打断你,处理完再回来。

18.2 一次中断的完整过程

m09_中断流程

18.3 51 的 5 个中断源

中断号中断源触发条件标志位入口地址
0外部中断 0(INT0,P3.2)引脚下降沿/低电平IE00003H
1定时器 0T0 溢出TF0000BH
2外部中断 1(INT1,P3.3)引脚下降沿/低电平IE10013H
3定时器 1T1 溢出TF1001BH
4串口收到/发完一个字节RI / TI0023H
中断号必须记住,因为它写在函数声明里:void f() interrupt 1 表示这是定时器 0 的中断函数。

18.4 三个必须开的开关

中断默认是关着的,要一层层打开:

寄存器位作用写法
EA(总开关)所有中断的总闸EA = 1;
EX0 / ET0 …(分开关)某一个中断的允许EX0 = 1; 或 ET0 = 1;
IT0 / IT1(触发方式)外部中断用边沿还是电平IT0 = 1;(下降沿)
最常见的错:只写了 EX0 = 1 忘了 EA = 1 —— 现象是「中断死活不进」。
反过来,只写 EA = 1 没写分开关,同样不进。

18.5 中断优先级

51 有两级优先级(高/低),由 IP 寄存器控制。同级中断按自然优先级排队:

INT0 > T0 > INT1 > T1 > 串口

(注意:这个顺序是硬件固定的,和中断号不完全一样 —— 中断号是入口地址顺序。)

高优先级中断可以打断低优先级中断(中断嵌套);同级不能互相打断。

18.6 易错点

① 中断函数里加返回值 —— void f() interrupt 0 必须是 void,也不能有参数。
② 中断函数里做长延时 —— 会拖慢整个系统,甚至丢掉后续中断。
③ 忘了清标志 —— 定时器和外部中断的硬件标志在响应后自动清;但串口的 RI/TI 要手动清(第 21 章)。

18.7 练习

  1. 默写 5 个中断源的中断号和入口地址。
  2. 说出「只开 EA 不开 EX0」会怎样。
  3. 为什么中断函数要尽量短?
📖 参考答案(点击展开)
  1. INT0=0(0003H)、T0=1(000BH)、INT1=2(0013H)、T1=3(001BH)、串口=4(0023H)。
  2. 总开关开了但没有分开关,等于这个中断源的请求被屏蔽,中断永远不会进入。必须 EA=1; EX0=1; 两个都开。
  3. 因为中断是「插队」。ISR 执行期间主程序停摆,太长会导致响应变慢、其他中断被延迟甚至丢失(同级不能嵌套)。
19 外部中断

19.1 外部中断接在哪

引脚板上按键中断号控制位
INT0P3.2K30IT0 / EX0 / IE0
INT1P3.3K42IT1 / EX1 / IE1

19.2 两种触发方式

IT0方式什么时候触发适合
0低电平触发引脚一直是低就一直触发外部芯片给的电平信号
1下降沿触发只在「1 → 0」那一瞬间触发一次按键(推荐)
按键一定要用下降沿。用低电平的话,你按住不放会不停进中断,程序直接卡死。

19.3 完整代码:按键中断翻转 LED

#include "reg52.h"

sbit LED1 = P2^0;
sbit KEY3 = P3^2;              // INT0 引脚

void delay_10us(unsigned int ten_us)
{
    while (ten_us--);
}

void exti0_init(void)
{
    IT0 = 1;                   // ① 下降沿触发
    EX0 = 1;                   // ② 允许 INT0 中断
    EA  = 1;                   // ③ 打开总中断
}

void main()
{
    exti0_init();
    while (1)
    {
        /* 主程序可以干别的事,按键来了会被打断 */
    }
}

void exti0() interrupt 0       // ④ 中断服务函数,中断号 0
{
    delay_10us(1000);          // 消抖 10ms
    if (KEY3 == 0)             // 再确认真的按下了
    {
        LED1 = !LED1;          // 翻转 LED
    }
}

逐行拆解

代码为什么
IT0 = 1不用边沿触发的话,按住会反复进中断
EX0 = 1; EA = 1两级开关,缺一不进
interrupt 0告诉编译器把这个函数放到 0003H 去
delay_10us(1000)ISR 里也要消抖(中断是硬件触发的,抖动会触发多次)
if (KEY3 == 0)双重确认,防止抖动误触发

19.4 中断 vs 查询,怎么选

查询(轮询)中断
CPU 占用一直问,浪费平时不占,来了才处理
响应速度取决于循环周期快(微秒级)
代码简单直观要多配寄存器
适合少量、不紧急实时、突发

19.5 易错点

① 用低电平触发接按键 → 按住就把程序卡死。
② 中断函数名随便起 → 名字无所谓,但 interrupt 0 的数字必须是 0(INT1 是 2,不是 1!)。
③ 忘了消抖 → 按一下 LED 翻好几次。

19.6 练习

  1. 用 INT1(K4)实现同样的功能。
  2. 改成「按一下亮,再按一下灭」(现在的 !LED1 就是)。
  3. 如果把 IT0=1 改成 IT0=0,按住按键观察现象。
📖 参考答案(点击展开)
  1. 把 IT0→IT1、EX0→EX1、KEY3→KEY4 改掉,中断函数写 interrupt 2(不是 1)。
  2. 现在的 LED1 = !LED1; 就是这个效果,无需改动。
  3. 改成 IT0=0(低电平触发)后,按住不放会不停进中断,LED 疯狂翻转甚至卡死。这就是按键必须用下降沿的原因。
20 定时器中断

20.1 定时器是什么

定时器就是一个自动加 1 的计数器,数到溢出就置标志位(并可以产生中断)。

它数的不是秒,是机器周期:

1 个机器周期 = 12 / 晶振频率
11.0592 MHz → 12 / 11059200 = 1.0850694 μs

20.2 两个寄存器就够

寄存器作用常用值
TMOD选定时器/计数器和方式0x01(T0 方式 1)
0x20(T1 方式 2,串口用)
TCON启动位 TR0/TR1、标志位 TF0/TF1TR0 = 1 启动

TMOD 的 8 个位:

TMOD = 0x01
位:  GATE C/T  M1 M0 | GATE C/T  M1 M0
      └── 定时器1 ──┘  └── 定时器0 ──┘

M1 M0 = 00 → 方式0(13位)
M1 M0 = 01 → 方式1(16位)★ 最常用
M1 M0 = 10 → 方式2(8位自动重装)★ 串口用
M1 M0 = 11 → 方式3(拆分)

20.3 两种工作方式

m10_定时器方式

20.4 ★ 初值怎么算(核心)

要定时的机器周期数 = 定时时间 / 1.0850694μs
初值 = 65536 - 机器周期数
TH0 = 初值 / 256      TL0 = 初值 % 256

算例一:普中例程的「1ms」

TH0 = 0xFC, TL0 = 0x18  →  初值 = 0xFC18 = 64536
机器周期数 = 65536 - 64536 = 1000
实际定时 = 1000 × 1.0850694μs = 1.085 ms
注意:它其实不是 1 ms,是 1.085 ms。因为 11.0592 MHz 除不尽。做 LED 闪烁无所谓,但做时钟就会累积误差。

算例二:真正精确的 1 ms

机器周期数 = 1000 μs / 1.0850694 μs = 921.6 → 取 922
初值 = 65536 - 922 = 64614 = 0xFC66
TH0 = 0xFC, TL0 = 0x66
实际 = 922 × 1.0850694 = 1.0004 ms  (误差 0.04%)

算例三:50 ms(最常用的值)

机器周期数 = 50000 / 1.0850694 = 46080
初值 = 65536 - 46080 = 19456 = 0x4C00
TH0 = 0x4C, TL0 = 0x00
实际 = 46080 × 1.0850694 = 50.000 ms  ✓ 几乎完美
为什么 50 ms 反而比 1 ms 准?因为 46080 是整数,而 921.6 不是。这也解释了为什么很多例程都用 50 ms 做基准。

20.5 完整代码:1 秒闪灯(50ms × 20 次)

#include "reg52.h"

sbit LED1 = P2^0;

void time0_init(void)
{
    TMOD |= 0x01;              // T0 方式 1(16 位)
    TH0 = 0x4C;                // 50ms 初值
    TL0 = 0x00;
    ET0 = 1;                   // 允许 T0 中断
    EA  = 1;                   // 总中断
    TR0 = 1;                   // 启动定时器
}

void main()
{
    time0_init();
    while (1);
}

void time0() interrupt 1       // 中断号 1
{
    static unsigned char cnt = 0;

    TH0 = 0x4C;                // ★ 方式 1 必须重装初值
    TL0 = 0x00;

    cnt++;
    if (cnt >= 20)             // 20 × 50ms = 1000ms
    {
        cnt = 0;
        LED1 = !LED1;          // 每 1 秒翻转一次
    }
}

为什么中断里要重装初值

方式 1 是 16 位计数器,溢出后从 0 开始,不是从初值开始。所以每次中断都要重新写 TH0/TL0 —— 否则下次就是 65536 个周期(71 ms)。

方式 2 就不用重装(硬件自动从 TH0 装到 TL0),这也是串口波特率用方式 2 的原因。

20.6 用定时器代替 delay 的好处

delay_ms() 空循环定时器中断
精度差(受优化影响)准(晶振决定)
CPU 占用100% 死等几乎不占
同时干别的不行可以

20.7 易错点

① 忘了 TR0 = 1 → 定时器根本没启动,中断永远不来。
② 忘了在中断里重装初值(方式 1)→ 定时变成 71 ms。
③ TMOD = 0x01 写成 |= 和 = 混用 → = 会清掉定时器 1 的设置(串口就用不了)。建议用 |=。

20.8 练习

  1. 用 50 ms 定时器做一个「1 秒闪灯」。
  2. 改成 0.5 秒闪灯(cnt 阈值改成 10)。
  3. 算一下:定时 20 ms,初值是多少?(提示:20000/1.0850694 ≈ 18432,初值 0xB800)
  4. 用定时器在数码管上做一个秒表(00~99)。
📖 参考答案(点击展开)
  1. 50ms 初值 0x4C00,20 次到 1 秒:if(++cnt>=20){cnt=0; LED1=!LED1;}
  2. 把 cnt>=20 改成 cnt>=10(10 × 50ms = 500ms)。
  3. 20ms 需要 20000/1.0850694 = 18432 个计数;初值 = 65536 − 18432 = 47104 = 0xB800(TH0=0xB8, TL0=0x00)。
  4. 两位秒表:一个变量 sec,每 1 秒 +1,到 99 归零;用两位扫描显示 gsmg_code[sec/10] 和 gsmg_code[sec%10]。
21 串口通信

21.1 串口是什么

串口把 8 位数据一位一位地在一根线上发出去。51 有一个全双工串口(能同时收发)。

引脚作用
P3.0 (RXD)接收
P3.1 (TXD)发送
注意:P3.0/P3.1 同时是板上 K2/K1 按键的引脚。用串口时这两个按键会有干扰,最好别按。

21.2 一帧数据长什么样

m11_串口帧

21.3 四个寄存器

寄存器作用典型值
SCON串口工作方式0x50(方式 1,允许接收)
PCON波特率加倍位 SMOD0x80(SMOD=1)
TMODT1 用方式 2 产生波特率0x20
SBUF数据缓冲(读=收,写=发)—

21.4 ★ 波特率怎么算

方式 1 波特率 = (2^SMOD / 32) × (晶振 / (12 × (256 - TH1)))

反推:TH1 = 256 - 晶振 / (12 × 16 × 波特率)     (SMOD=1 时)

11.0592 MHz、SMOD=1 时:

TH1 = 256 - 11059200 / (192 × 9600) = 256 - 6 = 250 = 0xFA

常用波特率对照表(11.0592 MHz,SMOD=1):

波特率TH1每位时间一帧 10 位
12000xD0 (208)833 μs8.33 ms
24000xE8 (232)417 μs4.17 ms
48000xF4 (244)208 μs2.08 ms
96000xFA (250)104 μs1.04 ms
192000xFD (253)52 μs0.52 ms
为什么又是 11.0592 MHz?因为只有它能让这些波特率全部整除。用 12 MHz 算 9600 会得到 10416.7,误差 8.5%,通信必乱 —— 这就是第 5 章那个问题的答案。

21.5 完整代码:发一个字节

#include "reg52.h"

void uart_init(unsigned char baud)
{
    TMOD |= 0x20;              // T1 方式 2(8 位自动重装)
    SCON  = 0x50;              // 方式 1,允许接收
    PCON  = 0x80;              // SMOD = 1,波特率加倍
    TH1   = baud;              // 重装值
    TL1   = baud;
    TR1   = 1;                 // 启动 T1
}

void uart_send_byte(unsigned char dat)
{
    SBUF = dat;                // 写 SBUF 就开始发送
    while (TI == 0);           // 等发完(TI 由硬件置 1)
    TI = 0;                    // ★ 必须手动清
}

void main()
{
    uart_init(0xFA);           // 9600 波特
    while (1)
    {
        uart_send_byte('A');
        /* 这里可以加延时,否则会刷屏 */
    }
}

逐行拆解

代码为什么
TMOD |= 0x20T1 方式 2;用 |= 不影响 T0 的设置
SCON = 0x500101 0000:方式 1 + REN=1(允许接收)
PCON = 0x80SMOD=1,波特率翻倍
TH1 = TL1 = 0xFA方式 2 下两个都要写(TL1 是当前值,TH1 是重装值)
TR1 = 1启动 T1,波特率时钟才开始走
SBUF = dat写 SBUF 触发发送,硬件自动一位位发出去
while (TI == 0)TI 是发送完成标志,发完硬件置 1
TI = 0硬件不会自动清,必须手动清

21.6 中断方式接收

void uart() interrupt 4
{
    unsigned char dat;

    if (RI == 1)               // 收到数据
    {
        dat = SBUF;
        RI = 0;                // ★ 手动清接收标志
        SBUF = dat;            // 原样回发(回显)
    }
    if (TI == 1)               // 发送完成
    {
        TI = 0;
    }
}
RI 和 TI 都必须手动清,这一点和定时器/外部中断(硬件自动清)不同,是串口最容易忘的地方。

21.7 和电脑通信

  1. 打开串口助手(普中下载软件里自带,或 SSCOM)。
  2. 选 COM 口、波特率 9600、8 位数据、1 位停止、无校验。
  3. 点「打开串口」。
  4. 板子复位,应该能看到 'A' 刷屏。
乱码怎么办:① 波特率不对(最常见)② 晶振不是 11.0592 ③ 勾了校验位 ④ 中文编码问题(发中文要 UTF-8)。

21.8 易错点

① 忘了 TR1 = 1 → 波特率时钟没启动,什么也发不出去。
② 忘了清 TI/RI → 只发一次就卡住,或者收不到第二个字节。
③ SCON 写成 0x40 → 少了 REN 位,能发不能收。
④ TMOD 用 = 不用 |= → 把定时器 0 的配置清掉了。

21.9 练习

  1. 让板子每秒发一次 "Hello"。
  2. 改成 19200 波特,算出 TH1 应该是多少。
  3. 实现「电脑发什么,板子回什么」(回显)。
  4. (进阶)电脑发 '1' 点亮 LED,发 '0' 熄灭。
📖 参考答案(点击展开)
  1. uart_send_byte('H'); 逐字符发 「Hello」,末尾加 delay_ms(1000) 否则会刷屏。
  2. TH1 = 256 − 11059200/(192 × 19200) = 256 − 3 = 253 = 0xFD。
  3. 在 interrupt 4 里:if(RI){ dat=SBUF; RI=0; SBUF=dat; while(!TI); TI=0; }
  4. 收到后判断:if(dat=='1') LED1=0; if(dat=='0') LED1=1;
22 I2C 与 EEPROM

22.1 I2C 是什么

I2C 是两根线的总线,可以让多个芯片挂在同一条线上:

线普中板作用
SCLP2.1时钟,主机产生
SDAP2.0数据,双向
注意:P2.0 同时是板上 D1 的 LED。做 I2C 实验时 D1 会跟着闪,这是正常的。

两根线都是开漏输出 + 上拉电阻。所以任何设备都能把它拉低,但没人能"强行拉高" —— 这就是多设备共享一根线还不短路的原因。

22.2 时序:全部规则就一句话

m12_I2C时序
SCL 高电平期间,SDA 必须稳定 —— 变了就是起始/停止

22.3 四个基本动作

动作时序
起始 SSCL 高时,SDA 由 1 → 0
停止 PSCL 高时,SDA 由 0 → 1
发一位SCL 低时改 SDA,SCL 高时从机采样
应答 ACK发完 8 位后,接收方把 SDA 拉低一个时钟

22.4 AT24C02:一颗 256 字节的"小硬盘"

参数值
容量2 Kbit = 256 字节
地址范围0x00 ~ 0xFF(8 位地址)
器件地址1010 A2 A1 A0 → 写 0xA0 / 读 0xA1
断电数据不丢(EEPROM 特性)
寿命约 100 万次写入

22.5 写一个字节的完整流程

起始 → 0xA0(写) → ACK → 字地址 → ACK → 数据 → ACK → 停止
void at24c02_write(unsigned char addr, unsigned char dat)
{
    iic_start();
    iic_write_byte(0xA0);      // 器件地址 + 写
    iic_wait_ack();
    iic_write_byte(addr);      // 字地址
    iic_wait_ack();
    iic_write_byte(dat);       // 数据
    iic_wait_ack();
    iic_stop();
    delay_10us(1000);          // ★ 等 10ms 让 EEPROM 内部写完
}
最后那个延时不能省。EEPROM 写入是"慢操作",写完一个字节需要约 5 ms,这期间它不响应任何命令。不等就会写失败(现象:读出来还是旧值)。

22.6 读一个字节

起始 → 0xA0(写) → ACK → 字地址 → ACK
     → 起始 → 0xA1(读) → ACK → 读数据 → NACK → 停止
为什么读要比写多一个起始?因为要先告诉它读哪个地址(用写的方式设地址),然后重新起始并切换成"读"模式。这叫"伪写"。

最后那个 NACK 是主机发的,意思是"我只要一个字节,别发了"。

22.7 易错点

① 写完不等 5~10ms → 数据没存进去。
② 读的时候忘了 "伪写" → 读出来是随机的(地址没设对)。
③ 不加应答等待 → 从机没准备好你也继续发,数据全乱。
④ 器件地址搞错 → 0xA0 是写、0xA1 是读,差最后一位。

22.8 练习

  1. 往地址 0x00 写 0x55,断电再上电读出来,验证掉电保存。
  2. 连续写 8 个字节(页写),再连续读出来。
  3. 做一个「开机次数计数器」:每次上电从 EEPROM 读、加 1、写回、显示。
📖 参考答案(点击展开)
  1. 写 0x55 到 0x00,断电重上电再读。若还是 0x55,说明掉电保存成功(这就是 EEPROM 与 RAM 的区别)。
  2. 页写:连续调用 write,地址递增。AT24C02 一页 8 字节,跨页要分开写,否则会回卷覆盖。
  3. 上电流程:读 addr0 → 值 +1 → 写回 → 数码管显示。注意每次写完要延时 10ms。
23 DS18B20 温度传感器

23.1 单总线:一根线怎么传数据

DS18B20 只用一根线(DQ = P3.7),既传数据又供电(寄生供电)。

没有时钟线,怎么知道哪一位开始了?靠时间长短:

信号怎么表示
写 0拉低 ≥ 60 μs
写 1拉低 1~15 μs 后立即释放
读主机先拉低 1 μs,然后 15 μs 内采样

关键洞察:写 0 和写 1 的区别不是电平高低(都是先拉低),而是低电平持续多久。

23.2 复位 + 存在脉冲

m13_DS18B20时序
主机拉低 750 μs  →  释放  →  从机拉低 60~240 μs(表示"我在")
规格要求 ≥480 μs,普中代码用 750 μs(delay_10us(75))。多留余量是稳妥做法 —— 如果复位失败,读出来永远是 0 或 85。

85 这个值是个信号:它正好是 DS18B20 上电默认值(+85°C),说明你根本没读到它,线没接好或时序不对。

23.3 三个命令就够

命令含义
0xCCSKIP ROM —— 总线上只有一个器件,跳过寻址
0x44CONVERT T —— 开始测温(要等 750 ms)
0xBEREAD SCRATCHPAD —— 读出 9 个字节
复位 → 0xCC → 0x44        (启动转换)
复位 → 0xCC → 0xBE        (读结果)
读低字节 datl → 读高字节 dath

23.4 温度怎么算

原始值 = (dath << 8) | datl        (16 位有符号)
温度 = 原始值 × 0.0625  ℃

0.0625 从哪来?12 位分辨率下,最低位代表 1/16 = 0.0625 ℃。

原始值温度
0x0191 (401)401 × 0.0625 = 25.06 ℃
0x00C0 (192)192 × 0.0625 = 12.00 ℃
0xFF38 (-200)-200 × 0.0625 = -12.5 ℃(负数要按补码处理)
负温度要小心:原始值是 16 位有符号数。0xFF38 如果当成无符号数就是 65336,乘出来是 4083℃。必须用 short / int 而不是 unsigned。

23.5 实验现象

数码管显示当前温度,格式如 25.06。用手指按一下传感器,数字会慢慢上升。

23.6 易错点

① 显示 85℃ 不变 → 没读到器件(接线/上拉电阻/时序)。
② 读出来全是 0xFF → DQ 线没有上拉电阻(4.7k)。
③ 温度值离谱 → 用了无符号类型,负温时溢出。
④ 转换后立刻就读 → 12 位精度要 750 ms,读太早拿到旧值。

23.7 练习

  1. 读出温度并用串口打印。
  2. 算一下:原始值 0x02A8 是多少度?(提示:680 × 0.0625)
  3. 显示保留一位小数(乘 10 取整再插小数点)。
📖 参考答案(点击展开)
  1. 直接调 ds18b20_read_temp(),把结果拆分整数/小数用串口打印。
  2. 0x02A8 = 680;680 × 0.0625 = 42.5 ℃。
  3. 温度值 × 10 取整 → 如 425 → 显示 42.5:整数部分 425/10,小数部分 425%10。
24 DS1302 时钟

24.1 DS1302 三根线

线普中板作用
RSTP3.5片选(拉高才能通信)
CLKP3.6时钟
I/OP3.4数据(双向)
这三根线和 74HC595 完全重合(P3.4/3.5/3.6)。所以两个实验不能同时做 —— 但在不同工程里各自独立,不冲突。

24.2 BCD 码:为什么时间要"多存一份"

DS1302 里的时间按 BCD 码存:一个字节的高 4 位存十位、低 4 位存个位。

十进制二进制(普通)BCD
250x190x25
590x3B0x59
120x0C0x12

好处是显示方便(一个字节直接对应两位数字),坏处是运算前要转换:

BCD → 十进制:  dec = (bcd >> 4) * 10 + (bcd & 0x0F);
十进制 → BCD:  bcd = (dec / 10 << 4) | (dec % 10);

24.3 寄存器地址

寄存器读地址写地址范围
秒0x810x8000~59
分0x830x8200~59
时0x850x8400~23
日0x870x8601~31
月0x890x8801~12
星期0x8B0x8A01~07
年0x8D0x8C00~99

规律:写地址是偶数,读地址 = 写地址 + 1(最低位是读写标志)。

24.4 读写一个字节

void ds1302_write(unsigned char addr, unsigned char dat)
{
    unsigned char i;
    DS1302_RST = 0;            // 复位,准备开始
    DS1302_CLK = 0;
    DS1302_RST = 1;            // 拉高片选

    for (i = 0; i < 8; i++)     // 先送地址(低位先出)
    {
        DS1302_IO = addr & 0x01;
        addr >>= 1;
        DS1302_CLK = 1;
        DS1302_CLK = 0;
    }
    for (i = 0; i < 8; i++)     // 再送数据
    {
        DS1302_IO = dat & 0x01;
        dat >>= 1;
        DS1302_CLK = 1;
        DS1302_CLK = 0;
    }
    DS1302_RST = 0;            // 结束
}

24.5 易错点

① BCD 当成二进制 → 显示 0x25 却算成 37。
② RST 忘了拉高 → 芯片一直不响应。
③ 读地址写成写地址 → 读到 0xFF 或者写坏时间。
④ 电池没装 → 断电后时间归零。

24.6 练习

  1. 把 25 分钟转换成 BCD 码(答案 0x25)。
  2. 读出当前时间显示在数码管上(格式 HH.MM.SS)。
  3. (进阶)用按键改成"调时模式"。
📖 参考答案(点击展开)
  1. 25 → 十位 2、个位 5 → (2<<4)|5 = 0x25。
  2. 读 0x81/0x83/0x85 三个寄存器(得到 BCD),转十进制后送数码管,用小数点分隔。
  3. 加一个「设置模式」标志:正常显示时只读;进入设置后按 K1 选中字段、K2 加 1,写回寄存器。
25 红外遥控

25.1 红外遥控的基本原理

遥控器里有一颗红外 LED,按键时用 38 kHz 的脉冲快速开关它(人眼看不见)。接收头解调出这个 38 kHz,输出干净的 0/1 给单片机。

项目说明
接收引脚P3.2(同时也是 INT0)
协议NEC(最常见)
载波38 kHz

25.2 NEC 协议的时序

部分时长说明
引导码9 ms 低 + 4.5 ms 高一帧的开始
数据位 0560 μs 低 + 560 μs 高总长 1.12 ms
数据位 1560 μs 低 + 1690 μs 高总长 2.25 ms
结束位560 μs 低—

又是"靠时间长短区分":0 和 1 的低电平都是 560 μs,区别在高电平持续多久。

25.3 一帧有 32 位

字节内容
1地址(用户码)
2地址反码(用于校验)
3按键值(命令)
4命令反码
校验:第 2 字节必须是第 1 字节的反码,第 4 是第 3 的反码。不满足就是传输出错,丢弃。

25.4 两种解码方式

方式做法优缺点
外部中断 + 定时器下降沿进中断,用定时器量高电平宽度准,但要配两个外设
纯轮询主循环里死等电平变化,用 delay 计时简单,但会占满 CPU

普中代码用的是轮询 + delay:等 9ms 低、等 4.5ms 高、然后逐位判断高电平有多长。

25.5 关键判断

if (高电平时间 > 1ms)  这一位是 1
else                    这一位是 0

因为 0 的高电平是 560 μs、1 的高电平是 1690 μs —— 用 1 ms 做分界正好。

25.6 易错点

① 用主循环死等导致其他功能卡住 → 一帧要 67 ms,这期间什么也干不了。
② 不分 0 和 1 的阈值 → 键值全错。
③ 忘了校验反码 → 偶尔收到乱码也当成正确按键。
④ 接收头供电不足 → 距离很短或完全无反应(接收头要 5V,不能 3.3V)。

25.7 练习

  1. 按下遥控器的「1」,串口打印出它的键值。
  2. 用遥控器控制 LED 亮灭(不同键值对应不同灯)。
  3. 算一下:一帧 32 位如果全是 1,总共要多久?(提示:9+4.5+32×2.25+0.56 ms)
📖 参考答案(点击展开)
  1. 用串口打印 ired_data[2](命令字节),记下「1」对应的值(常见如 0x45 或 0x0C,不同遥控器不同)。
  2. switch(键值){ case 0x45: LED1=!LED1; break; ... }
  3. 9 + 4.5 + 32×2.25 + 0.56 = 9 + 4.5 + 72 + 0.56 = 86.06 ms。这是最坏情况(32 位全 1),典型帧约 67.5ms。
26 ADC 模数转换

26.1 ADC 在做什么

m14_ADC与DAC

把连续变化的电压换成有限个数字。XPT2046 是 12 位 ADC,所以:

数字量范围 0 ~ 4095(2^12 - 1)
分辨率 = 5 V / 4096 = 1.22 mV

26.2 XPT2046 的接法(类 SPI)

引脚普中板作用
CSP3.5片选,低有效
CLKP3.6时钟
DINP3.4主机 → 芯片(发命令)
DOUTP3.7芯片 → 主机(回数据)
和 I2C 的区别:SPI 类接口用 4 根线、有独立的收发线,速度更快,但没有应答机制。
和 74HC595 一样,它靠时钟上升沿移位。

26.3 读一次电压

unsigned int xpt2046_read_adc(unsigned char cmd)
{
    unsigned char i;
    unsigned int dat = 0;

    XPT2046_CS = 0;                    // 片选
    for (i = 0; i < 8; i++)            // 发 8 位命令
    {
        XPT2046_DIN = cmd & 0x80;
        cmd <<= 1;
        XPT2046_CLK = 0;
        XPT2046_CLK = 1;               // 上升沿移入
    }
    for (i = 0; i < 16; i++)           // 读 16 位(12 位有效)
    {
        dat <<= 1;
        XPT2046_CLK = 0;
        XPT2046_CLK = 1;
        if (XPT2046_DOUT) dat |= 0x01;
    }
    XPT2046_CS = 1;
    return dat >> 4;                  // 低 4 位是无效的
}
为什么要 >> 4:XPT2046 一次返回 16 位,但只有高 12 位是有效数据,低 4 位是填充。不移位的话读出来是真实值的 16 倍。

26.4 数字量 → 电压

电压 = 数字量 × 5.0 / 4096
数字量电压
00 V
10241.25 V
20482.50 V
40954.9988 V(不是 5V!)

又一次遇到"最大值不是满量程" —— 和第 12 篇 A/D 那章一个道理:4096 个格子里最高只能走到第 4095 格。

26.5 实验现象

转动电位器,数码管上的电压值跟着变(0.00 ~ 4.99 V)。

26.6 易错点

① 忘了 >> 4 → 显示值比实际大 16 倍。
② 片选没拉低 → 芯片不响应。
③ 用整数算电压 → dat * 5 / 4096 里 dat*5 可能溢出(int 只有 16 位)。要先乘 500 再除,或用 long。

26.7 练习

  1. 读电位器电压显示成 0.00~4.99 两位小数。
  2. 数字量 2048 时电压是多少?
  3. (进阶)接光敏电阻,做一个「天黑自动亮灯」。
📖 参考答案(点击展开)
  1. 读 AD → voltage = ad * 5000 / 4096;(单位 mV,用 unsigned int 防溢出)→ 显示 voltage/1000 和 (voltage%1000)/10。
  2. 2048 × 5 / 4096 = 2.500 V。
  3. 光敏电阻分压 → ADC 读值;设一个阈值,暗时(AD 大于阈值)点亮 LED。
27 DAC 数模转换

27.1 51 没有 DAC,怎么办

51 单片机没有硬件 D/A 转换器。要用它输出模拟电压,只能靠 PWM + RC 滤波:

PWM 方波 → RC 低通滤波 → 平滑的直流电压

平均电压 = 高电平电压 × 占空比

27.2 PWM 是什么

m14_ADC与DAC
占空比平均电压
100%5.00 V
75%3.75 V
50%2.50 V
20%1.00 V
0%0 V

27.3 用定时器产生 PWM

#include "reg52.h"

sbit PWM = P2^1;

unsigned char pwm_duty = 50;       // 占空比 50%

void time0_init(void)
{
    TMOD |= 0x01;
    TH0 = 0xFF;                    // 每 100μs 进一次中断
    TL0 = 0x9C;
    ET0 = 1;
    EA  = 1;
    TR0 = 1;
}

void main()
{
    time0_init();
    while (1);
}

void time0() interrupt 1
{
    static unsigned char cnt = 0;

    TH0 = 0xFF;                    // 重装
    TL0 = 0x9C;

    cnt++;
    if (cnt >= 100) cnt = 0;       // 100 次 = 一个周期
    if (cnt < pwm_duty) PWM = 1;   // 前 duty% 为高
    else                PWM = 0;   // 其余为低
}

27.4 ★ PWM 频率算一下

初值 0xFF9C = 65436 → 计数 = 100
每次中断 = 100 × 1.0850694 μs = 108.5 μs
一个周期 = 100 次中断 = 10.85 ms
频率 = 1 / 10.85 ms = 92 Hz
92 Hz 太低了!输出会有明显的"嗡嗡"声和纹波。想让 RC 滤波后干净,PWM 频率最好在 1 kHz 以上。

改进办法:减小每次中断的间隔(把计数从 100 改小),或者用更高的晶振。比如每次中断约 10 μs(计数 9)、100 次一周期 → 1.023 kHz。

每次中断周期PWM 频率效果
108.5 μs10.85 ms92 Hz差(有纹波)
10.85 μs1.085 ms922 Hz可接受
9.77 μs(计数 9)0.977 ms1023 Hz好

27.5 RC 滤波怎么选

截止频率 fc = 1 / (2πRC) 要远低于 PWM 频率

比如 PWM 1 kHz,取 fc = 100 Hz:R = 1kΩ, C = 1.6μF(1/(2π×1000×1.6e-6) ≈ 100 Hz)。

fc 太低 → 电压跟不上变化(反应慢);fc 太接近 PWM 频率 → 滤不干净(有纹波)。一般取 PWM 频率的 1/10 ~ 1/100。

27.6 实验现象

PWM 引脚接一个 LED,能看出亮度变化(占空比 10% 暗、90% 亮)。用万用表量 RC 滤波后的电压,会看到 0~5V 连续变化。

27.7 易错点

① PWM 频率太低 → 电机嗡嗡响、LED 闪、滤波后有大纹波。
② 占空比变量被优化 → 加 volatile。
③ 定时器中断里做太多事 → 频率算不准。

27.8 练习

  1. 让 LED 的亮度从暗到亮再到暗(呼吸灯)。
  2. 把 PWM 频率改到 1 kHz,算出新的初值。
  3. 占空比 30% 时,RC 滤波后的电压是多少?
📖 参考答案(点击展开)
  1. 主循环里让 pwm_duty 从 0 增到 100 再减回 0,每步延时 20ms。
  2. 1kHz → 周期 1ms → 100 次中断则每次 10μs → 计数 = 10/1.0850694 ≈ 9 → 初值 = 65536−9 = 0xFFF7。
  3. 占空比 30%:5V × 0.3 = 1.5 V。
28 LCD1602

28.1 LCD1602 是什么

16 列 × 2 行的字符液晶,每个字符用 5×8 点阵拼出来。它自带字库,你只要送 ASCII 码就能显示。

引脚普中板作用
RSP2.61 = 发数据,0 = 发命令
RWP2.51 = 读,0 = 写
EP2.7使能,下降沿完成写入
D0~D7P08 位数据
注意:P2.5 也是蜂鸣器、P2.6/P2.7 空着。用 LCD 时蜂鸣器会受影响。

28.2 写时序

m15_LCD1602时序

28.3 三个基本函数

void lcd1602_write_cmd(unsigned char cmd)   // 写命令
{
    LCD1602_RS = 0;        // 命令
    LCD1602_RW = 0;        // 写
    LCD1602_DATAPORT = cmd;
    LCD1602_E = 1;
    delay_10us(1);         // 保持 >450ns
    LCD1602_E = 0;
    delay_10us(1);         // 等 LCD 处理
}

void lcd1602_write_data(unsigned char dat)   // 写数据(显示一个字符)
{
    LCD1602_RS = 1;        // 数据
    LCD1602_RW = 0;
    LCD1602_DATAPORT = dat;
    LCD1602_E = 1;
    delay_10us(1);
    LCD1602_E = 0;
    delay_10us(1);
}

两个函数只差 RS 一位 —— 这就是为什么 RS 叫"数据/命令选择"。

28.4 初始化:必须按顺序发这几条

lcd1602_write_cmd(0x38);   // 8 位数据、2 行显示、5×7 点阵
lcd1602_write_cmd(0x0C);   // 开显示、关光标、不闪烁
lcd1602_write_cmd(0x06);   // 写入一个字符后地址自动 +1
lcd1602_write_cmd(0x01);   // 清屏(要延时 2ms 以上!)
命令含义
0x380011 1000:DL=1(8 位)、N=1(2 行)、F=0(5×7)
0x0C0000 1100:D=1 开显示、C=0 无光标、B=0 不闪
0x060000 0110:I/D=1 地址加、S=0 不移动画面
0x01清屏,耗时约 1.5 ms

28.5 在哪显示:地址

第一行:0x00 ~ 0x0F
第二行:0x40 ~ 0x4F
lcd1602_write_cmd(0x80 | 0x00);   // 光标到第一行第 1 格
lcd1602_write_data('H');          // 显示 H
0x80 是"设置 DDRAM 地址"的命令头,后面 7 位才是地址。所以第一行第 3 格是 0x80|0x02。

28.6 易错点

① 清屏后不等 → 后面发的命令全被吃掉(0x01 要等 1.5ms 以上)。
② 对比度不对 → 屏幕一片蓝或一片白,调背面的电位器。
③ 数据口接反 → 显示乱码。
④ 忘了初始化 → 什么都不显示。

28.7 练习

  1. 第一行显示 "Hello",第二行显示 "51 MCU"。
  2. 做一个 1 秒加 1 的计数器显示。
  3. 把 0x0C 改成 0x0F,观察光标出现。
📖 参考答案(点击展开)
  1. write_cmd(0x80|0x00); 逐字符写 「Hello」;write_cmd(0x80|0x40); 写 「51 MCU」。
  2. 每秒 cnt++,用 sprintf 格式化后定位到第 1 行显示。
  3. 0x0C = 开显示、无光标;0x0F = 开显示 + 有光标 + 闪烁。改成 0x0F 后能看到闪烁的方块光标。
29 LCD12864

29.1 12864 和 1602 的区别

LCD1602LCD12864
分辨率16×2 字符128×64 点
显示内容只有 ASCII图形 + 汉字(带字库)
控制8 位并口并口或串行
引脚RS/RW/E/D0-D7RS/WR/E/PSB + 数据
引脚普中板
RSP2.6
WRP2.5
EP2.7
PSBP3.2(1=并口,0=串口)

29.2 串行模式为什么省线

PSB = 0 时进入串行模式,只用 3 根线传数据:

一个字节 = 起始位(1) + 高4位(低位先) + 0 + 低4位 + 0
           (每个"位"由 E 的脉冲送出)

代价是慢(要发 24 个时钟才传 1 个字节),好处是省 I/O —— 和 74HC595 的取舍正好相反。

29.3 显示汉字

带字库的 12864(ST7920 芯片)内置 GB2312 字库,直接发两个字节的区位码就能显示汉字:

// 汉字「中」的 GB2312 编码是 0xD6 0xD0 lcd12864_write_data(0xD6); lcd12864_write_data(0xD0);
不用自己取模!这是带字库版本最大的好处。不带字库的(如 12864-无字库版)要自己存点阵数据。

29.4 基本操作命令

命令作用
0x30基本指令集
0x0C开显示
0x01清屏
0x80 + 地址设置 DDRAM 地址(字符定位)

一行 8 个汉字 = 16 字节地址(0x80~0x8F 第一行,0x90~0x9F 第二行)。

29.5 易错点

① PSB 没设为 0 → 串行模式下屏幕没反应。
② 汉字编码错 → 显示乱码或空白(注意是 GB2312 不是 UTF-8)。
③ 清屏后不等 → 和 1602 一样要延时。

29.6 练习

  1. 第一行显示「你好世界」。
  2. 查一下「单」字的 GB2312 编码是多少。
  3. 把 PSB 改成 1(并口),对比接线数量和速度。
📖 参考答案(点击展开)
  1. write_cmd(0x80); 然后依次写「你(0xC4 0xE3) 好(0xBA 0xC3) 世(0xCA 0xC0) 界(0xBD 0xE7)」的 GB2312 编码。
  2. 「单」的 GB2312 编码是 0xB5 0xA5。
  3. 串行只需 3 根线(RS/WR/E 复用),并口要 8 根数据线 + 3 根控制线。并口快约 8 倍,但多占 8 个 I/O。
30 TFTLCD

30.1 TFT 彩屏和单色屏的区别

LCD1602/12864TFTLCD 彩屏
颜色单色16 位真彩(65536 色)
分辨率很低常见 240×320
接口并口/串口SPI 或 8080 并口
数据量几十字节一屏 = 240×320×2 = 150 KB
关键矛盾:一屏 150 KB,而 STC89C52 只有 8 KB Flash、512 B RAM。整屏图片根本存不下,只能显示小图标或用取模软件压缩。

30.2 16 位颜色怎么表示

RGB565:  RRRRR GGGGGG BBBBB
          5 位   6 位   5 位

红色 0xF800   绿色 0x07E0   蓝色 0x001F
白色 0xFFFF   黑色 0x0000

30.3 显示一个字符的流程

  1. 用取模软件(资料里的「文字-图片取模软件」)把字或图转成点阵数组
  2. 用 code 关键字把数组存进 Flash
  3. 设置显示窗口(起始/结束坐标)
  4. 逐个送像素数据
void lcd_show_char(unsigned int x, unsigned int y, ...)
{
    lcd_set_window(x, y, x+16, y+16);   // 开窗
    for (i = 0; i < 512; i++)           // 16×16 个像素
    {
        写一个像素();
    }
}

30.4 为什么彩屏要用 SPI

8080 并口要 16~18 根线,SPI 只要 4~5 根。在 51 这种 I/O 紧张的单片机上,省线比速度更重要。

30.5 易错点

① 数组不加 code → 512B RAM 立刻爆掉。
② 取模方向错 → 显示出来是镜像或乱码。
③ 刷新整屏 → 51 跑不动,一屏要好几秒。只刷新局部。

30.6 练习

  1. 用取模软件取一个 16×16 汉字,显示出来。
  2. 算出 240×320 的 16 位图要多少字节。
  3. 画一个红色的矩形。
📖 参考答案(点击展开)
  1. 取模软件选 16×16、阴码/逐行式、十六进制,生成 32 字节数组;用 code 关键字存 Flash 后逐点送。
  2. 240 × 320 = 76800 个像素;16 位色 → 76800 × 2 = 153600 字节 = 150 KB。
  3. 设窗口 → 循环送 0xF800(红色)若干次 → 关闭窗口。整个矩形要送 w×h 个像素。
31 触摸屏

31.1 电阻触摸屏的原理

触摸屏是两层透明导电膜(X 层和 Y 层)。按下时两层接触,通过测电压分压比算出坐标。

步骤做法读到
测 XX 层两端加电压,Y 层当探针Y 层的电压 ∝ X 坐标
测 Y换成 Y 层加电压,X 层当探针X 层的电压 ∝ Y 坐标

测电压用的还是 XPT2046 —— 和第 26 章 ADC 是同一颗芯片(顺便也能测触摸)

31.2 坐标换算

屏幕坐标 = (AD 值 - AD 最小值) × 屏幕宽度 / (AD 最大值 - AD 最小值)
必须校准:每块屏的 AD 范围和方向都不一样。做法是在四角各点一下,记下 AD 值,算出比例和偏移。

31.3 判断"按下了"

if (AD_Y < 阈值)    // 按下时 Y 层会被拉低
    有触摸;
else
    没触摸;

因为没按时两层不接触,Y 层被上拉电阻拉高;按下后才和 X 层导通。

31.4 易错点

① 不校准 → 点哪里都不是哪里。
② 不消抖 → 一次触摸触发很多次(和按键一个道理)。
③ X/Y 接反 → 左右上下颠倒。

31.5 练习

  1. 读出触摸点的原始 AD 值。
  2. 在四个角各点一下,记录 AD 范围。
  3. (进阶)点屏幕上的按钮控制 LED。
📖 参考答案(点击展开)
  1. 直接读 XPT2046 的 X/Y 通道原始值并串口打印。
  2. 四角各点一下,记录 X 的 min/max 和 Y 的 min/max,作为校准参数。
  3. 把屏幕划分成若干矩形区域,判断触摸点落在哪个区域,执行对应动作(如点亮不同 LED)。
32 继电器

32.1 继电器是什么

继电器 = 用电磁铁控制机械触点的开关。它的价值是电气隔离:用小电流控制大电流、用 5V 控制 220V。

端子说明
线圈 +接单片机(经三极管)
线圈 −接 GND
COM公共端
NO(常开)线圈不通时断开,通电才闭合
NC(常闭)线圈不通时闭合,通电才断开
注意:本机资料里没有继电器的源码(只有 PPT)。具体接哪个引脚请对照板子丝印或原理图,下面代码里的引脚名请自行替换。

32.2 为什么不能直连

继电器线圈要 30~70 mA,而单片机引脚最多 20 mA。必须用三极管(或 ULN2003 驱动芯片)放大。

P1.x ── 1kΩ ── 三极管基极
                集电极 ── 继电器线圈 ── +5V
                发射极 ── GND
        ★ 线圈两端并联一个续流二极管(1N4148)
续流二极管不能省:线圈断电瞬间会产生几百伏的反向电动势,没有二极管泄放,三极管会被击穿。这和你在电路课学的电感特性完全一致。

32.3 代码

sbit RELAY = P1^4;      // 引脚以实际板子为准

void main()
{
    while (1)
    {
        RELAY = 1;      // 吸合(常开端子闭合)
        delay_ms(1000);
        RELAY = 0;      // 释放
        delay_ms(1000);
    }
}

32.4 易错点

① 忘续流二极管 → 三极管烧掉。
② 直连引脚 → 引脚烧掉。
③ 用常开端还是常闭端搞错 → 逻辑反了。
④ 触 220V → 务必断电操作,不要徒手碰端子。

32.5 练习

  1. 让继电器每 2 秒吸合一次,听"嗒嗒"声。
  2. 用按键控制继电器。
  3. 想一想:为什么继电器能做"隔离",而三极管不行?
📖 参考答案(点击展开)
  1. RELAY=1; delay_ms(2000); RELAY=0; delay_ms(2000); 放在 while(1) 里,能听到「嗒」的吸合声。
  2. 查按键,按下时翻转 RELAY。
  3. 继电器靠电磁铁 + 机械触点,线圈和触点之间只有磁耦合、没有电气连接,所以能隔离高压。三极管是半导体器件,集电极和基极仍有漏电流和击穿通路,不能真正隔离。
33 IO 扩展 74HC165

33.1 74HC165 是 595 的反向

74HC59574HC165
方向串入 → 并出并入 → 串出
用途用 3 根线驱动 8 个输出用 3 根线读 8 个输入

两者配合就是:595 扩展输出、165 扩展输入。

33.2 三根线

引脚作用
SH/LD1 = 移位,0 = 装载(把 8 个输入读进寄存器)
CLK时钟,上升沿移一位
Q7串行输出,接到单片机
SH/LD 别写反:低电平才是"装载"。很多人在这一步把芯片搞得完全不响应。

33.3 读 8 个输入的流程

PL = 0;   delay();   PL = 1;    // ① 装载(把 8 个引脚状态读进芯片)
for (i = 0; i < 8; i++)
{
    dat <<= 1;
    if (Q7) dat |= 0x01;         // ② 读当前位
    CLK = 0;  CLK = 1;           // ③ 移位,准备下一位
}

先读后移还是先移后读,取决于第一个有效位是 D7 还是 D0 —— 读出来顺序反了就调整循环。

33.4 级联

165 的 Q7 接下一片的 SER,就能读 16 位、24 位输入。软件上循环 16 次即可。

33.5 易错点

① SH/LD 电平反了 → 一直读到同一个值。
② 移位顺序搞反 → 读到的 8 位是倒过来的。
③ 忘了装载 → 读的是上一次的旧数据。

33.6 练习

  1. 用 165 读 8 个拨码开关的状态,显示在 LED 上。
  2. 把读到的值用串口打印出来。
  3. 对比 165 和 595 的时序,找出异同。
📖 参考答案(点击展开)
  1. PL=0; delay(); PL=1; 装载,然后循环 8 次「读 Q7 → 移位」,把结果送 P2 显示。
  2. 同样的 8 位数据用 uart_send_byte(dat) 打印,注意串口助手选 HEX 显示更直观。
  3. 相同:都是靠时钟上升沿移位、都串行。不同:595 是串入并出(写出去),165 是并入串出(读进来);165 多一个 SH/LD 装载信号。
34 四线双极性步进电机

34.1 四线双极性和五线单极性

五线(28BYJ-48)四线双极性
线圈4 相 + 公共端2 组独立线圈(A+/A-、B+/B-)
驱动单极性(每相一个开关)双极性(每相要 H 桥换向)
扭矩较小较大
驱动芯片ULN2003L298N / A4988
注意:本机资料里没有四线步进的源码(只有 PPT)。本章讲原理和通用写法。

34.2 为什么要 H 桥

双极性电机需要电流反向(A+ 到 A- 或者反过来)。四个开关组成 H 桥:

正转:S1 和 S4 通  →  电流 A+ → A-
反转:S2 和 S3 通  →  电流 A- → A+
停止:全断
刹车:S1S2 通 或 S3S4 通(线圈短路)
绝不能让 S1 和 S2 同时通 —— 那是电源直接短路,瞬间烧管(叫"直通")。

34.3 相序(两相四拍)

拍A+A-B+B-
11001
20101
30110
41010

八拍版本在每两拍之间加一个"两相都通电"的过渡,转动更平滑。

34.4 易错点

① 同侧两管同时导通 → 直通,烧驱动芯片。
② 相序错 → 抖动不转或反转。
③ 加速太快 → 失步(步进电机不能突变速度,要缓慢加减速)。

34.5 练习

  1. 让电机正转一圈再反转一圈。
  2. 把四拍改成八拍,比较平滑度。
  3. 想一想:"直通"为什么会烧管?(用电路课的 KVL 解释)
📖 参考答案(点击展开)
  1. 先正转 4096 步(或对应圈数的拍数),停 1 秒,再反转变。
  2. 八拍在两拍之间插入「两相同时通电」的过渡状态,转动更平滑、噪音更小,但同样转速下需要多一倍的拍数。
  3. 同侧两管(如 S1 和 S2)同时导通时,电流从 VCC 直接经两个管子流到 GND,中间没有负载。按 KVL:负载电压为 0,电流只受管子内阻限制 → 瞬间几百 mA 甚至几 A,管子发热烧毁。这叫「直通」。
35 RS485 通信

35.1 RS485 解决什么问题

串口 TTLRS485
信号单端(对地)差分(A、B 两线)
距离几米1200 米
抗干扰差强(干扰同时叠加在两线上,相减抵消)
设备数点对点一条总线挂 32~128 个
为什么差分抗干扰:干扰是共模的,A 和 B 同时被抬高同样的电压,接收端取的是 VA - VB,干扰被减掉了。这正是你在电路课学的"共模抑制"。

35.2 用 MAX485 转换

引脚作用
RO接收输出 → 单片机 RXD
DI发送输入 ← 单片机 TXD
DE发送使能(1 = 发送)
RE接收使能(0 = 接收),通常和 DE 连一起
A / B差分总线
注意:本机资料里没有 RS485 的源码(只有 PPT)。引脚请对照原理图。

35.3 半双工:一次只能一个方向

RS485 是半双工:同一时刻要么发要么收,不能同时。所以要有一个方向控制引脚:

sbit RS485_DE = P1^5;      // 引脚以实际板子为准

void rs485_send(unsigned char dat)
{
    RS485_DE = 1;          // 切到发送
    uart_send_byte(dat);
    while (TI == 0);       // 等发完
    TI = 0;
    RS485_DE = 0;          // ★ 立刻切回接收
}
最后那行不能忘:不切回接收,你就永远收不到别人的数据。这是 RS485 调试最经典的"能发不能收"。

35.4 终端电阻

总线两端各接一个 120 Ω 电阻,用来消除信号反射。

情况要不要接
短距离(< 10m)、低速可以不接
长距离、高速必须接,否则误码

35.5 易错点

① 忘了切回接收 → 能发不能收。
② A/B 接反 → 完全收不到(对调试试)。
③ 多设备同时发 → 总线冲突(要约定主从协议)。
④ 没共地 → 长距离时 A/B 的共模电压超范围。

35.6 练习

  1. 两块板子用 RS485 互发数据。
  2. 把 DE 那行去掉,观察"能发不能收"的现象。
  3. 查一下:为什么终端电阻是 120Ω 而不是 100Ω 或 200Ω?(提示:双绞线特性阻抗)
📖 参考答案(点击展开)
  1. 两块板 A 接 A、B 接 B(必要时对调),各自初始化串口为同一波特率,一块发一块收。
  2. 去掉 RS485_DE=0; 后,芯片一直处于发送模式、接收器被关断,于是发得出去、收不到任何东西。
  3. 因为标准 RS485 用的双绞线特性阻抗约 120Ω。终端电阻等于特性阻抗时反射最小;用 100Ω 或 200Ω 都会因阻抗不匹配产生反射,长距离下误码率上升。