学习目标(3 周达成)
学习通信协议不需要学完整数电课程,只需要掌握以下核心概念即可开始。
- 看懂通信协议的时序图
- 理解高电平/低电平、上升沿/下降沿
- 知道推挽/开漏输出的区别
- 会看芯片手册的引脚定义
- 理解时钟、波特率等基本概念
复杂的逻辑门电路设计、卡诺图化简、时序电路分析(那是数电考试内容)
第一周:核心概念速成
1.1 数字信号 vs 模拟信号
🌊 模拟信号(连续变化)
📍 声音、温度传感器输出
电压在 0V~5V 之间平滑过渡
📶 数字信号(只有 0 和 1)
📍 MCU 的 GPIO 引脚输出
电压只有 高 5V 或 低 0V
数字电路只关心两个状态:高(1)或低(0),中间的过渡过程很快,可以忽略。
1.2 电平标准(最基础但最重要)
🔵 TTL 电平(5V 系统)
典型应用:51单片机、74HC系列
🟢 CMOS 电平(3.3V 系统)
典型应用:STM32、ESP32、树莓派
// 51单片机 GPIO 输出高电平
P1_0 = 1; // 或 P1 |= 0x01;
// 此时 P1.0 引脚电压 ≈ 5V
// 输出低电平
P1_0 = 0; // 或 P1 &= ~0x01;
// 此时 P1.0 引脚电压 ≈ 0V
1.3 GPIO工作模式
GPIO(通用输入输出)引脚通常有 8 种工作模式,这些模式决定了引脚如何与外部电路进行交互。它们可以分为四大类:输入模式、输出模式、复用功能模式和模拟模式。
📥 输入模式
引脚用于读取外部信号
• 浮空输入
• 上拉输入
• 下拉输入
📤 输出模式
引脚主动驱动电平
• 推挽输出
• 开漏输出
🔀 复用功能模式
引脚由片上外设控制
• 复用推挽输出
• 复用开漏输出
〰️ 模拟模式
引脚直接连接 ADC/DAC
• 模拟输入/输出
一、输入模式(3 种)
① 浮空输入
引脚内部既无上拉也无下拉电阻,电平完全由外部电路决定。
⚠️ 若引脚悬空,读取值不确定(易受干扰)。
典型应用:UART RX、外部已有确定电平驱动时
② 上拉输入
内部接上拉电阻到 VDD,默认读取高电平 1。
按键按下接地 → 读到 0。
典型应用:按键输入、I²C SDA/SCL
③ 下拉输入
内部接下拉电阻到 GND,默认读取低电平 0。
外部驱动高电平 → 读到 1。
典型应用:确保引脚空闲时保持低电平
二、输出模式(2 种)
⚡ 推挽输出(Push-Pull)
P-MOS + N-MOS 互补驱动,可主动输出高/低电平。
输出1:P-MOS 导通 → VDD
输出0:N-MOS 导通 → GND
适用:LED 驱动、UART TX、SPI
🔓 开漏输出(Open-Drain)
只有 N-MOS,输出高电平需外部上拉电阻。
输出0:N-MOS 导通 → GND
输出1:N-MOS 断开 → 外部上拉
适用:I²C 总线、多设备共享总线
三、复用功能模式(2 种)
当引脚由片上外设(UART、SPI、I²C、PWM 等)接管控制时,称为复用功能模式。此时引脚不再由 CPU 的 GPIO 寄存器控制,而由对应外设的发送/接收逻辑直接驱动。
🔀 复用推挽输出
由外设控制,输出结构与推挽相同(P-MOS + N-MOS)。
可主动驱动高/低电平,无需外部上拉。
典型应用:UART TX、SPI MOSI/SCK、PWM 输出
🔀 复用开漏输出
由外设控制,输出结构与开漏相同(只有 N-MOS)。
需要外部上拉电阻才能输出高电平。
典型应用:I²C SDA/SCL(标准要求开漏)
四、模拟模式(1 种)
引脚直接与芯片内部的 ADC(模数转换)或 DAC(数模转换)相连,所有数字电路(施密特触发器、上/下拉电阻)均被禁用,信号以连续模拟量的形式传递。
- ✅ 采集温度、光照、声音等模拟信号时使用
- ✅ DAC 输出模拟波形时使用
- ❌ 不能用于读写数字高/低电平
八种工作模式汇总
| 类别 | 模式名称 | 内部结构 | 典型应用 |
|---|---|---|---|
| 输入模式 | ① 浮空输入 | 无上/下拉 | UART RX、外部有驱动源 |
| ② 上拉输入 | 内部上拉到 VDD | 按键、I²C | |
| ③ 下拉输入 | 内部下拉到 GND | 确保空闲低电平 | |
| 输出模式 | ④ 推挽输出 | P-MOS + N-MOS | LED、UART TX、SPI |
| ⑤ 开漏输出 | N-MOS + 外部上拉 | I²C 总线、多机共线 | |
| 复用功能 | ⑥ 复用推挽 | 外设控制 + 推挽 | UART TX、SPI SCK/MOSI |
| ⑦ 复用开漏 | 外设控制 + 开漏 | I²C SDA/SCL | |
| 模拟模式 | ⑧ 模拟输入/输出 | 直连 ADC/DAC | 传感器采集、波形输出 |
1.4 时序图阅读(重中之重)
基本符号
实战案例:UART 发送字节 0x41
- 起始位:必须是 0(从空闲状态下降沿触发)
- 数据位:LSB(最低位)先发
- 停止位:必须是 1(回到空闲状态)
SPI 时序图(CPOL=0, CPHA=0)
| 时序参数 | 含义 |
|---|---|
| tsu(Setup Time) | 数据在时钟沿之前稳定的时间 |
| th(Hold Time) | 数据在时钟沿之后保持的时间 |
| tpd(Propagation Delay) | 信号传输延迟 |
第二周:实验验证(必须动手)
实验 1:LED 闪烁(理解电平输出)
材料:STC89C52 开发板(或任意 51 单片机开发板)、LED + 1kΩ 限流电阻
电路连接
#include <reg52.h>
sbit LED = P1^0; // 定义 P1.0 为 LED 控制引脚
void delay_ms(unsigned int ms) {
unsigned int i, j;
for(i = ms; i > 0; i--)
for(j = 110; j > 0; j--); // 12MHz 晶振约 1ms
}
void main() {
while(1) {
LED = 0; // 输出低电平 → LED 亮(共阳极接法)
delay_ms(500);
LED = 1; // 输出高电平 → LED 灭
delay_ms(500);
}
}
- LED 灭时:P1.0 电压 ≈ 5V(高电平)
- LED 亮时:P1.0 电压 ≈ 0V(低电平)
注意:51单片机 I/O 口灌电流能力强,拉电流能力弱,常用低电平点亮 LED。
实验 2:按键输入(理解上拉电阻)
电路连接
#include <reg52.h>
sbit KEY = P3^2; // 按键接 P3.2
sbit LED = P1^0; // LED 接 P1.0
void main() {
KEY = 1; // 设置为输入(写1使能内部上拉)
while(1) {
if(KEY == 0) { // 按键按下(低电平)
LED = 0; // 点亮 LED
} else { // 按键松开(高电平)
LED = 1; // 熄灭 LED
}
}
}
51单片机 P1-P3 口内部有弱上拉电阻(约 40kΩ),读取前需先写 1。外部按键可不加上拉电阻,但加上会更稳定。
实验 3:用示波器/逻辑分析仪看时序
使用仿真软件:Proteus 或 Keil 调试模式(可观察虚拟示波器)
#include <reg52.h>
void uart_init() {
SCON = 0x50; // 8位数据,可变波特率
TMOD |= 0x20; // 定时器1,模式2(自动重装)
TH1 = 0xFD; // 波特率 9600(11.0592MHz 晶振)
TL1 = 0xFD;
TR1 = 1; // 启动定时器1
}
void uart_send(unsigned char dat) {
SBUF = dat;
while(!TI); // 等待发送完成
TI = 0; // 清除发送标志
}
void main() {
uart_init();
while(1) {
uart_send(0x55); // 发送 0b01010101(方波)
// 可用示波器观察 TXD (P3.1) 引脚波形
}
}
逻辑分析仪抓取结果
第三周:芯片手册阅读训练
芯片手册是最权威的资料,任何教程都是从手册中提取的。学会看手册 = 拥有"第一手信息"能力。
3.1 芯片手册结构概览
STC89C52 数据手册通常包含以下章节(快速定位你需要的内容):
| 章节 | 内容 | 什么时候看 |
|---|---|---|
| 产品概述 | 功能特性、型号对比 | 选型时 |
| 引脚定义 | 引脚功能、封装图 | 画原理图时 |
| 电气特性 | 电压、电流、时序参数 | 硬件设计时 |
| 存储器结构 | RAM、ROM、SFR 地址 | 编程时 |
| I/O 端口 | 端口结构、驱动能力 | 接外设时 |
| 定时器/计数器 | 工作模式、寄存器 | 定时、PWM 时 |
| 串行口 | UART 配置、波特率 | 通信时 |
| 中断系统 | 中断源、优先级 | 写中断程序时 |
3.2 引脚定义与封装图(DIP-40)
| 端口 | 特点 | 常见用途 |
|---|---|---|
| P0 | 开漏输出,需外接上拉电阻 | 数据总线、LCD 数据口 |
| P1 | 准双向口,内部弱上拉 | 通用 I/O、按键、LED |
| P2 | 准双向口,内部弱上拉 | 高 8 位地址线、通用 I/O |
| P3 | 准双向口,第二功能丰富 | 串口、中断、定时器 |
P0 口是开漏输出,作为通用 I/O 使用时必须外接 10kΩ 上拉电阻,否则无法输出高电平!
3.3 特殊功能寄存器(SFR)
51单片机通过配置 SFR(Special Function Register) 来控制各种外设。以下是最常用的寄存器:
I/O 端口寄存器
| 寄存器 | 地址 | 功能 | 复位值 |
|---|---|---|---|
| P0 | 0x80 | P0 口数据寄存器 | 0xFF |
| P1 | 0x90 | P1 口数据寄存器 | 0xFF |
| P2 | 0xA0 | P2 口数据寄存器 | 0xFF |
| P3 | 0xB0 | P3 口数据寄存器 | 0xFF |
// 方式1:整体操作
P1 = 0x00; // P1 口全部输出低电平
P1 = 0xFF; // P1 口全部输出高电平
P1 = 0x0F; // P1.0-P1.3 高,P1.4-P1.7 低
// 方式2:位操作(需要 sbit 定义)
sbit LED = P1^0; // 定义 P1.0
LED = 0; // P1.0 输出低电平
LED = 1; // P1.0 输出高电平
// 方式3:位运算
P1 |= 0x01; // P1.0 置 1,其他位不变
P1 &= ~0x01; // P1.0 置 0,其他位不变
P1 ^= 0x01; // P1.0 取反,其他位不变
3.4 串口寄存器详解(重点)
SCON - 串口控制寄存器(地址 0x98)
波特率计算(模式1,定时器1)
void uart_init() {
SCON = 0x50; // 模式1(SM0=0,SM1=1),允许接收(REN=1)
TMOD &= 0x0F; // 清除定时器1设置
TMOD |= 0x20; // 定时器1,模式2(8位自动重装)
TH1 = 0xFD; // 波特率 9600(11.0592MHz)
TL1 = 0xFD; // 初值
TR1 = 1; // 启动定时器1
ES = 1; // 使能串口中断(可选)
EA = 1; // 使能总中断(可选)
}
3.5 定时器寄存器详解
TMOD - 定时器模式寄存器(地址 0x89)
定时时间计算(模式1,16位)
void timer0_init() {
TMOD &= 0xF0; // 清除定时器0设置
TMOD |= 0x01; // 定时器0,模式1(16位)
TH0 = 0xFC; // 初值高8位
TL0 = 0x66; // 初值低8位
TR0 = 1; // 启动定时器0
ET0 = 1; // 使能定时器0中断
EA = 1; // 使能总中断
}
// 定时器0中断服务函数
void timer0_isr() interrupt 1 {
TH0 = 0xFC; // 重装初值
TL0 = 0x66;
// 每 1ms 执行一次的代码
}
3.6 中断系统
| 中断源 | 中断号 | 入口地址 | 触发条件 |
|---|---|---|---|
| 外部中断0 (INT0) | 0 | 0x0003 | P3.2 下降沿/低电平 |
| 定时器0 (T0) | 1 | 0x000B | 定时器溢出 |
| 外部中断1 (INT1) | 2 | 0x0013 | P3.3 下降沿/低电平 |
| 定时器1 (T1) | 3 | 0x001B | 定时器溢出 |
| 串口 (UART) | 4 | 0x0023 | 发送/接收完成 |
// 外部中断0(按键触发)
void int0_isr() interrupt 0 {
// 处理代码
}
// 定时器0中断
void timer0_isr() interrupt 1 {
TH0 = 0xFC; TL0 = 0x66; // 重装初值
}
// 串口中断
void uart_isr() interrupt 4 {
if(RI) { // 接收中断
unsigned char dat = SBUF;
RI = 0; // 清标志
}
if(TI) { // 发送中断
TI = 0; // 清标志
}
}
3.7 电气特性表
| 参数 | 最小值 | 典型值 | 最大值 | 单位 |
|---|---|---|---|---|
| 工作电压 VCC | 4.0 | 5.0 | 5.5 | V |
| 单个 I/O 灌电流 | - | - | 20 | mA |
| 单个 I/O 拉电流 | - | - | 1 | mA |
| 输入高电平 VIH | 2.0 | - | VCC+0.5 | V |
| 输入低电平 VIL | -0.5 | - | 0.8 | V |
| 工作温度 | -40 | 25 | 85 | °C |
| 工作频率 | 0 | 11.0592 | 40 | MHz |
- 灌电流强(20mA)→ 用低电平驱动 LED(LED 阳极接 VCC)
- 拉电流弱(1mA)→ 高电平无法直接驱动 LED
- 驱动继电器、电机 → 需要三极管或驱动芯片(如 ULN2003)
- P0 口 → 必须外接上拉电阻(10kΩ),否则只能输出低电平
3.8 手册阅读技巧总结
- 引脚定义 → 画原理图必看
- 电气特性 → 电压、电流限制
- I/O 端口 → P0 需要上拉!
- 定时器 → 延时、PWM 必用
- 串口 → 调试、通信必用
- 中断 → 实时响应必用
- SCON = 0x50:模式1 + 允许接收
- TMOD |= 0x20:定时器1 模式2
- TH1 = TL1 = 0xFD:波特率 9600
- TR1 = 1:启动定时器1
- (可选)ES = 1; EA = 1;:使能串口中断
3.9 外设芯片手册阅读(以 DS1302 为例)
单片机手册告诉你"我有什么功能",而外设芯片手册告诉你"怎么跟我通信"。阅读方法完全不同!
- 单片机手册 → 关注寄存器配置(我怎么工作)
- 外设芯片手册 → 关注通信时序(怎么跟我说话)
五步阅读法
第 1 步:这芯片是干嘛的?
看手册第 1 页的 FEATURES,快速提取关键信息:
第 2 步:怎么接线?
看 Pin Configuration 和 Typical Operating Circuit:
第 3 步:怎么通信?(⭐重点)
看 Timing Diagram(时序图)—— 这是外设芯片手册的核心!
sbit CE = P3^5;
sbit IO = P3^4;
sbit SCLK = P3^6;
// 写一个字节(对照时序图理解)
void ds1302_write_byte(unsigned char dat) {
unsigned char i;
for(i = 0; i < 8; i++) {
IO = dat & 0x01; // ① 取最低位放到 I/O 线
SCLK = 1; // ② 上升沿,DS1302 采样
SCLK = 0; // ③ 为下一位做准备
dat >>= 1; // ④ 右移,准备下一位
}
}
第 4 步:发什么命令?
看 Command Byte 和 Register Map:
| 功能 | 写命令 | 读命令 | 说明 |
|---|---|---|---|
| 秒 | 0x80 | 0x81 | bit7=CH(1=停止) |
| 分 | 0x82 | 0x83 | 0-59 |
| 时 | 0x84 | 0x85 | 1-12 或 0-23 |
| 日 | 0x86 | 0x87 | 1-31 |
| 月 | 0x88 | 0x89 | 1-12 |
| 星期 | 0x8A | 0x8B | 1-7 |
| 年 | 0x8C | 0x8D | 00-99 |
| 写保护 | 0x8E | 0x8F | bit7=WP(1=保护) |
第 5 步:数据格式?
DS1302 使用 BCD 编码(不是二进制!):
// 十进制 → BCD(写入 DS1302 前转换)
unsigned char dec_to_bcd(unsigned char dec) {
return ((dec / 10) << 4) | (dec % 10);
}
// BCD → 十进制(从 DS1302 读出后转换)
unsigned char bcd_to_dec(unsigned char bcd) {
return (bcd >> 4) * 10 + (bcd & 0x0F);
}
完整驱动示例
#include <reg52.h>
sbit CE = P3^5;
sbit IO = P3^4;
sbit SCLK = P3^6;
// 写一个字节
void ds1302_write_byte(unsigned char dat) {
unsigned char i;
for(i = 0; i < 8; i++) {
IO = dat & 0x01;
SCLK = 1; SCLK = 0;
dat >>= 1;
}
}
// 读一个字节
unsigned char ds1302_read_byte() {
unsigned char i, dat = 0;
for(i = 0; i < 8; i++) {
dat >>= 1;
if(IO) dat |= 0x80;
SCLK = 1; SCLK = 0;
}
return dat;
}
// 写寄存器
void ds1302_write(unsigned char addr, unsigned char dat) {
CE = 0; SCLK = 0;
CE = 1;
ds1302_write_byte(addr); // 发命令
ds1302_write_byte(dat); // 发数据
CE = 0;
}
// 读寄存器
unsigned char ds1302_read(unsigned char addr) {
unsigned char dat;
CE = 0; SCLK = 0;
CE = 1;
ds1302_write_byte(addr | 0x01); // 发读命令
dat = ds1302_read_byte();
CE = 0;
return dat;
}
// 使用示例
void main() {
unsigned char hour, min, sec;
ds1302_write(0x8E, 0x00); // 关闭写保护
ds1302_write(0x80, 0x00); // 秒=0,启动时钟
ds1302_write(0x82, 0x30); // 分=30
ds1302_write(0x84, 0x12); // 时=12
ds1302_write(0x8E, 0x80); // 开启写保护
while(1) {
sec = ds1302_read(0x81); // 读秒(BCD)
min = ds1302_read(0x83); // 读分(BCD)
hour = ds1302_read(0x85); // 读时(BCD)
// 显示到数码管(需转成十进制)
}
}
外设芯片手册阅读技巧总结
| 章节 | 关注点 | 什么时候看 |
|---|---|---|
| Features | 芯片功能、电压范围 | 选型时 |
| Pin Description | 每个引脚功能 | 画原理图时 |
| Typical Circuit | 接线参考电路 | 画原理图时 |
| Timing Diagram | 通信时序(⭐最重要) | 写驱动时 |
| Command/Register | 命令格式、寄存器地址 | 写驱动时 |
| Electrical Specs | 电压电流时序参数 | 调试问题时 |
- 时序方向搞反 → 仔细看"上升沿采样"还是"下降沿"
- MSB/LSB 顺序 → DS1302 是 LSB 先发
- BCD 编码忘转换 → 读出 0x23 不是十进制 23
- 写保护没关 → DS1302 需先写 0x8E=0x00
- CE 时序不对 → CE 要在 SCLK 之前拉高
ds1302_write(0x8E, 0x00); // 关闭写保护
ds1302_write(0x80, 0x00); // 秒 = 00(BCD)
ds1302_write(0x82, 0x30); // 分 = 30(BCD)
ds1302_write(0x84, 0x14); // 时 = 14(BCD)
ds1302_write(0x8E, 0x80); // 开启写保护
注意:时间值是 BCD 编码,14:30:00 写入 0x14、0x30、0x00
通信协议详解
掌握了前面的数电基础和芯片手册阅读方法,现在可以学习最实用的通信协议了!
单片机需要和各种外设(传感器、显示屏、存储器)"对话",通信协议就是双方约定的"语言规则"。
4.1 常用协议对比
| 协议 | 信号线 | 速度 | 设备数 | 典型应用 |
|---|---|---|---|---|
| UART | 2 线(TX/RX) | 较慢(9600-115200bps) | 点对点 | 调试串口、GPS、蓝牙模块 |
| I2C | 2 线(SCL/SDA) | 中等(100k-400kHz) | 多设备(地址区分) | EEPROM、温湿度传感器、OLED |
| SPI | 4 线(SCK/MOSI/MISO/CS) | 快(可达 MHz 级) | 多设备(CS 选择) | Flash、SD 卡、TFT 屏幕 |
| 1-Wire | 1 线(DQ) | 慢 | 多设备 | DS18B20 温度传感器 |
4.2 UART(串口通信)
UART 是最简单、最常用的通信协议,51单片机内置硬件支持。
基本概念
帧格式
51单片机 UART 配置
#include <reg52.h>
// UART 初始化(9600bps,11.0592MHz 晶振)
void uart_init() {
SCON = 0x50; // 模式1,8位数据,允许接收
TMOD &= 0x0F; // 清除定时器1设置
TMOD |= 0x20; // 定时器1,模式2(自动重装)
TH1 = 0xFD; // 波特率 9600
TL1 = 0xFD;
TR1 = 1; // 启动定时器1
}
// 发送一个字节
void uart_send_byte(unsigned char dat) {
SBUF = dat; // 数据放入发送缓冲区
while(!TI); // 等待发送完成
TI = 0; // 清除发送标志
}
// 发送字符串
void uart_send_string(char *str) {
while(*str) {
uart_send_byte(*str++);
}
}
// 接收一个字节(查询方式)
unsigned char uart_recv_byte() {
while(!RI); // 等待接收完成
RI = 0; // 清除接收标志
return SBUF; // 返回接收数据
}
void main() {
uart_init();
uart_send_string("Hello, 51!\r\n");
while(1) {
unsigned char ch = uart_recv_byte();
uart_send_byte(ch); // 回显
}
}
| 波特率 | TH1 | SMOD |
|---|---|---|
| 9600 | 0xFD | 0 |
| 4800 | 0xFA | 0 |
| 2400 | 0xF4 | 0 |
| 19200 | 0xFD | 1 |
printf 串口重定向(重点)
通过重定向 putchar 函数,可以让 printf 直接通过串口输出,方便调试。
#include <STC15F2K60S2.H>
#include <stdio.h>
// UART 初始化(9600bps,12MHz,使用定时器2)
void Uart1_Init(void) {
SCON = 0x50; // 8位数据,可变波特率
AUXR |= 0x01; // 串口1选择定时器2为波特率发生器
AUXR &= 0xFB; // 定时器时钟12T模式
T2L = 0xE6; // 设定定时初始值
T2H = 0xFF; // 设定定时初始值
AUXR |= 0x10; // 定时器2开始计时
ES = 1; // 使能串口1中断
EA = 1; // 开总中断
}
/*=====================================================
* 串口重定向 - 核心代码
* 重写 putchar 函数,使 printf 输出到串口
*====================================================*/
extern char putchar(char ch)
{
SBUF = ch; // 将字符写入发送缓冲区
while(TI == 0); // 等待发送完成(TI=1 表示发送完毕)
TI = 0; // 手动清除发送完成标志
return ch; // 返回发送的字符(标准要求)
}
void main() {
unsigned char count = 0;
Uart1_Init();
printf("System Start!\r\n");
while(1) {
printf("Count: %bu\r\n", count++); // %bu 是 Keil 特有格式
// 延时...
}
}
- %bu / %bd:Keil C51 特有格式符,用于 unsigned char / signed char
- 代码体积:printf 约占用 1-2KB ROM,资源紧张时考虑直接发送
- 阻塞等待:
while(TI==0)会阻塞,高频调用影响实时性 - \r\n:Windows 风格换行,串口助手显示更规范
/*=====================================================
* 带超时保护的串口重定向
* 避免硬件故障时程序卡死
*====================================================*/
extern int putchar(int ch)
{
unsigned int timeout = 10000; // 超时计数
SBUF = ch;
while(TI == 0) {
if(--timeout == 0)
return EOF; // 超时返回错误(EOF = -1)
}
TI = 0;
return ch; // 成功返回字符
}
// 使用示例
void debug_print() {
int result = printf("Test\r\n");
if(result < 0) {
// 发送失败处理...
}
}
| 返回值 | 含义 | 用途 |
|---|---|---|
| ch(字符本身) | 发送成功 | 确认输出完成 |
| EOF(-1) | 发送失败 | 触发错误处理 |
为什么用 int 而不是 char?
因为 EOF 是 -1,unsigned char 无法表示负数,必须用 int 才能区分成功和失败。
4.3 I2C(两线式接口)
I2C 只需要 2 根线就能连接多个设备,非常适合连接传感器。
| 信号线 | SCL(时钟,主机产生)+ SDA(数据,双向传输) |
| 寻址方式 | 每个从机有唯一 7 位地址,主机通过地址选择目标设备(最多 127 个) |
| 电气特性 | 开漏输出,必须外接 4.7kΩ 上拉电阻到 VCC,否则总线无法拉高 |
| 通信速度 | 标准模式 100kHz,快速模式 400kHz(51单片机软件模拟一般用100kHz以下) |
| 典型器件 | AT24C02(EEPROM)、OLED 显示屏、BMP280(气压传感器)、MPU6050(陀螺仪) |
基本概念
通信时序
| ① 起始条件 | SCL 高 时,SDA 下降沿 → 通知总线「要开始传输」 |
| ② 数据规则 | SCL 低 时 SDA 可改变;SCL 高 时 SDA 必须稳定(接收方在高电平期间采样) |
| ③ 应答位 | 第 9 个时钟,由接收方控制 SDA:拉低 = ACK(收到);保持高 = NACK(结束 / 错误) |
| ④ 停止条件 | SCL 高 时,SDA 上升沿 → 通知总线「传输结束,释放总线」 |
💡 记忆口诀:低时变,高时采;起始降,停止升;第九位,从机答
地址格式
数据帧格式
| 操作 | 帧格式口诀 |
|---|---|
| 写一个字节 | S → 地址W → 寄存器 → 数据 → P |
| 读一个字节 | S → 地址W → 寄存器 → S → 地址R → 数据 → P |
| 连续读N个 | S → 地址W → 寄存器 → S → 地址R → 数据ACK×(N-1) → 数据NACK → P |
S=起始,P=停止,W=写(0),R=读(1),红色S=重复起始
#include <reg52.h>
sbit SCL = P2^1;
sbit SDA = P2^0;
// 精确延时(12MHz 晶振,约 5μs)
#define I2C_DELAY() {_nop_();_nop_();_nop_();_nop_();_nop_();}
// 返回值定义
#define I2C_ACK 0 // 收到应答
#define I2C_NACK 1 // 未收到应答
//========== 底层时序函数 ==========
// 起始条件:SCL 高时,SDA 下降沿
void i2c_start(void) {
SDA = 1;
SCL = 1;
I2C_DELAY();
SDA = 0; // 关键:SCL 高时 SDA 下降
I2C_DELAY();
SCL = 0; // 钳住总线,准备传输
}
// 停止条件:SCL 高时,SDA 上升沿
void i2c_stop(void) {
SDA = 0; // 确保 SDA 为低
SCL = 1;
I2C_DELAY();
SDA = 1; // 关键:SCL 高时 SDA 上升
I2C_DELAY();
}
// 发送一个字节,返回 ACK 状态(0=有应答,1=无应答)
unsigned char i2c_send_byte(unsigned char dat) {
unsigned char i, ack;
for(i = 0; i < 8; i++) {
SDA = (dat & 0x80) ? 1 : 0; // MSB 先发
dat <<= 1;
I2C_DELAY();
SCL = 1;
I2C_DELAY();
SCL = 0;
}
SDA = 1; // 释放 SDA,准备读 ACK
I2C_DELAY();
SCL = 1;
I2C_DELAY();
ack = SDA; // 读取 ACK:0=从机应答,1=无应答
SCL = 0;
return ack;
}
// 接收一个字节,send_ack=1 发送 ACK,send_ack=0 发送 NACK
unsigned char i2c_recv_byte(unsigned char send_ack) {
unsigned char i, dat = 0;
SDA = 1; // 释放 SDA,准备读数据
for(i = 0; i < 8; i++) {
SCL = 1;
I2C_DELAY();
dat <<= 1;
if(SDA) dat |= 0x01; // MSB 先收
SCL = 0;
I2C_DELAY();
}
// 发送应答位
SDA = send_ack ? 0 : 1; // send_ack=1 发 ACK(拉低),=0 发 NACK(保持高)
I2C_DELAY();
SCL = 1;
I2C_DELAY();
SCL = 0;
SDA = 1; // 释放 SDA
return dat;
}
//========== 应用层函数(带错误检测)==========
// 向设备寄存器写一个字节,返回 0=成功
unsigned char i2c_write_reg(unsigned char addr, unsigned char reg, unsigned char dat) {
i2c_start();
if(i2c_send_byte(addr << 1)) { // 地址 + 写位(0)
i2c_stop();
return 1; // 设备无响应
}
if(i2c_send_byte(reg)) { // 寄存器地址
i2c_stop();
return 2; // 寄存器地址 NACK
}
if(i2c_send_byte(dat)) { // 写入数据
i2c_stop();
return 3; // 数据 NACK
}
i2c_stop();
return 0; // 成功
}
// 从设备寄存器读一个字节
unsigned char i2c_read_reg(unsigned char addr, unsigned char reg) {
unsigned char dat;
i2c_start();
i2c_send_byte(addr << 1); // 地址 + 写位(0)
i2c_send_byte(reg); // 寄存器地址
i2c_start(); // 重复起始条件(不发 Stop)
i2c_send_byte((addr << 1) | 1); // 地址 + 读位(1)
dat = i2c_recv_byte(0); // 读数据,发 NACK 表示结束
i2c_stop();
return dat;
}
// 连续读多个字节(适用于传感器批量读取)
void i2c_read_bytes(unsigned char addr, unsigned char reg,
unsigned char *buf, unsigned char len) {
unsigned char i;
i2c_start();
i2c_send_byte(addr << 1);
i2c_send_byte(reg);
i2c_start();
i2c_send_byte((addr << 1) | 1);
for(i = 0; i < len; i++) {
// 最后一个字节发 NACK,其余发 ACK
buf[i] = i2c_recv_byte(i < len - 1);
}
i2c_stop();
}
- 精确延时 → 使用
_nop_()代替不可控的 while 循环 - 错误检测 →
i2c_write_reg返回错误码,便于调试 - 参数语义清晰 →
send_ack=1表示"要发送ACK",直观易懂 - 批量读取 →
i2c_read_bytes支持连续读多字节
// AT24C02 设备地址(A2A1A0 引脚全接 GND → 地址 = 0x50)
#define AT24C02_ADDR 0x50
// 毫秒延时(12MHz 晶振)
void delay_ms(unsigned int ms) {
unsigned int i, j;
for(i = 0; i < ms; i++)
for(j = 0; j < 120; j++);
}
// 写一个字节到 AT24C02
// mem_addr: EEPROM 内部地址(0x00~0xFF,共 256 字节)
// dat: 要写入的数据
// 返回:0=成功,非0=失败
unsigned char at24c02_write_byte(unsigned char mem_addr, unsigned char dat) {
unsigned char ret = i2c_write_reg(AT24C02_ADDR, mem_addr, dat);
delay_ms(10); // ⚠️ 关键!AT24C02 内部写操作最长 5ms,写完必须等待
return ret;
}
// 从 AT24C02 读一个字节
unsigned char at24c02_read_byte(unsigned char mem_addr) {
return i2c_read_reg(AT24C02_ADDR, mem_addr);
}
// 主函数:写入数据后读回验证
void main(void) {
unsigned char wdata = 0xA5; // 要写入的测试数据
unsigned char rdata;
at24c02_write_byte(0x00, wdata); // 写入地址 0x00
rdata = at24c02_read_byte(0x00); // 从地址 0x00 读回
if(rdata == wdata) {
P1 = 0x00; // 读写一致 → LED 全亮(成功)
} else {
P1 = 0xFF; // 读写不一致 → LED 全灭(失败,检查接线/延时)
}
while(1);
}
- 写后必须等待 → AT24C02 内部写操作需要 ≤5ms,写完立刻读会得到错误数据(全0xFF)
- 地址范围 → AT24C02 内部共 256 字节,地址 0x00~0xFF
- 掉电保持 → 数据写入后断电不丢失,是 EEPROM 的核心价值
- 硬件地址确认 → 板上 A2A1A0 引脚全接 GND 时,器件地址 = 0x50;若有引脚接高,地址相应增加
- 必须上拉电阻 → 典型值 4.7kΩ(速度快可用 2.2kΩ,速度慢可用 10kΩ)
- MSB 先发 → 与 UART(LSB先发)、DS1302(LSB先发)不同!
- 开漏输出 → P0 口天然开漏适合 I2C,P1-P3 为准双向口需确保能被拉低
- 地址左移 → 7位地址要左移1位,最低位是读写位(0写1读)
- 重复起始 → 读操作需要"写寄存器地址→重复起始→读数据",不能中间发 Stop
- ACK/NACK → 连续读时,读到最后一个字节要发 NACK 通知从机结束
| 现象 | 可能原因 |
|---|---|
| 所有操作都 NACK | ①上拉电阻未接 ②地址错误 ③设备未上电 |
| 读出数据全是 0xFF | ①SDA 被拉高无法采样 ②时序过快从机跟不上 |
| 数据偶发错误 | ①上拉电阻太大(信号上升慢)②线太长干扰 |
| 总线死锁(SDA 一直低) | 复位时序不完整,发送 9 个 SCL 脉冲可恢复 |
4.4 SPI(串行外设接口)
SPI 是最快的串行协议,适合传输大量数据(如 Flash、LCD)。
基本概念
四种工作模式
#include <reg52.h>
sbit SCK = P1^5;
sbit MOSI = P1^6;
sbit MISO = P1^7;
sbit CS = P1^4;
// SPI 发送并接收一个字节(全双工)
unsigned char spi_transfer(unsigned char dat) {
unsigned char i, recv = 0;
for(i = 0; i < 8; i++) {
MOSI = (dat & 0x80) ? 1 : 0; // MSB 先发
dat <<= 1;
SCK = 1; // 上升沿
recv <<= 1;
if(MISO) recv |= 0x01; // 采样 MISO
SCK = 0; // 下降沿
}
return recv;
}
// 写寄存器
void spi_write_reg(unsigned char reg, unsigned char dat) {
CS = 0; // 片选
spi_transfer(reg); // 发送寄存器地址
spi_transfer(dat); // 发送数据
CS = 1; // 取消片选
}
// 读寄存器
unsigned char spi_read_reg(unsigned char reg) {
unsigned char dat;
CS = 0;
spi_transfer(reg | 0x80); // 最高位=1 表示读
dat = spi_transfer(0xFF); // 发送空数据,接收返回值
CS = 1;
return dat;
}
| 特性 | SPI | I2C |
|---|---|---|
| 速度 | 快(MHz级) | 慢(kHz级) |
| 线数 | 4线 + 每设备1根CS | 2线(共享) |
| 设备寻址 | CS 硬件选择 | 地址软件选择 |
| 通信方式 | 全双工 | 半双工 |
| 上拉电阻 | 不需要 | 必须 |
| 适用场景 | Flash、SD卡、屏幕 | 传感器、EEPROM |
4.5 1-Wire(单总线)
1-Wire 只需一根数据线,常见于 DS18B20 温度传感器。
基本概念
通信时序
#include <reg52.h>
sbit DQ = P3^7;
void delay_us(unsigned int us) {
while(us--);
}
// 复位并检测存在
unsigned char ds18b20_reset() {
unsigned char presence;
DQ = 0;
delay_us(480); // 拉低 480μs
DQ = 1;
delay_us(60); // 等待
presence = DQ; // 读取存在脉冲(0=设备存在)
delay_us(420); // 等待复位完成
return presence;
}
// 写一个字节
void ds18b20_write_byte(unsigned char dat) {
unsigned char i;
for(i = 0; i < 8; i++) {
DQ = 0;
delay_us(2);
DQ = dat & 0x01; // LSB 先发
delay_us(60);
DQ = 1;
dat >>= 1;
}
}
// 读一个字节
unsigned char ds18b20_read_byte() {
unsigned char i, dat = 0;
for(i = 0; i < 8; i++) {
DQ = 0;
delay_us(2);
DQ = 1;
delay_us(8);
dat >>= 1;
if(DQ) dat |= 0x80;
delay_us(50);
}
return dat;
}
// 读取温度(返回 0.1℃ 为单位)
int ds18b20_read_temp() {
unsigned char LSB, MSB;
int temp;
ds18b20_reset();
ds18b20_write_byte(0xCC); // 跳过 ROM
ds18b20_write_byte(0x44); // 启动温度转换
delay_us(750); // 等待转换完成(约 750ms)
ds18b20_reset();
ds18b20_write_byte(0xCC); // 跳过 ROM
ds18b20_write_byte(0xBE); // 读取暂存器
LSB = ds18b20_read_byte();
MSB = ds18b20_read_byte();
temp = (MSB << 8) | LSB;
return temp * 10 / 16; // 转换为 0.1℃
}
4.6 协议选择指南
- 初学者 → 先学 UART(最简单,调试必备)
- 接传感器 → 学 I2C(用得最多)
- 接屏幕/存储 → 学 SPI(速度快)
- 特定芯片 → 按手册来(如 DS1302 是 3 线 SPI 变种)
| 问题 | 答案 |
|---|---|
| UART 需要时钟线吗? | 不需要,靠波特率同步 |
| I2C 为什么需要上拉? | 开漏输出,需外部拉高 |
| SPI 怎么选择从机? | 用 CS 线(低有效) |
| 谁是 MSB 先发? | I2C、SPI 是 MSB;UART、1-Wire 是 LSB |
| 哪个协议最快? | SPI(可达 MHz) |
学习资源推荐
三周学习计划
Week 1:理论速成
| 天数 | 任务 | 验收标准 |
|---|---|---|
| Day 1-2 | 电平概念、推挽/开漏 | 能画出推挽输出电路 |
| Day 3-4 | 上拉/下拉电阻 | 知道 I2C 为什么要上拉 |
| Day 5-7 | 时序图阅读 | 能看懂 UART/SPI 时序图 |
Week 2:实验验证
| 天数 | 任务 | 材料 |
|---|---|---|
| Day 8-9 | LED 闪烁实验 | 51单片机开发板 + LED |
| Day 10-11 | 按键输入实验 | 按键 + 上拉电阻 |
| Day 12-14 | UART 收发实验 | USB-TTL 模块(CH340) |
Week 3:芯片手册
| 天数 | 任务 | 文档 |
|---|---|---|
| Day 15-17 | 读 STC89C52 数据手册 | 引脚定义、电气特性 |
| Day 18-19 | 读通信外设章节 | UART、定时器寄存器 |
| Day 20-21 | 综合项目 | 用 UART 控制 LED |
最小必要知识自检
如果能回答这些问题,说明数电基础合格:
推挽能主动输出高/低电平,开漏只能输出低电平,高电平需要外部上拉。
I2C 使用开漏输出,需要上拉电阻将信号拉高,同时允许多个设备共享总线。
高电平(1),起始位是下降沿(1→0)触发接收。
不能!需要电平转换,否则会烧坏 3.3V MCU 的输入引脚。
答案:CLK 上升沿(↑)采样,依次读到 1、0、1、0。
极速入门方案(最短 1 周)
如果时间紧急,按这个顺序:
先用起来,遇到问题再深入!
- 70% 时间写代码
- 20% 时间看资料
- 10% 时间调试硬件