Appearance
SPI 与 I2C 总线
概念
上一章 UART 解决的是两个设备之间怎么传字节。但真实板子上往往要同时接 Flash、传感器、显示屏、EEPROM ——一堆设备,怎么共用有限的几根线?
一句话说清它是什么:SPI 和 I2C 是两种"把多个从设备挂到同一组线上"的板级串行总线,区别在于"怎么选中要跟谁说话"。
| 总线 | 线数 | 选设备的方式 | 全双工 |
|---|---|---|---|
| SPI | 4(SCK / MOSI / MISO / SS) | 一根片选线选一个 | 是 |
| I2C | 2(SCL / SDA) | 靠地址寻址 | 半双工 |
这张表的第二列与第三列互为因果:SPI 用更多线换速度和全双工;I2C 用更少线换"能挂更多设备",代价是慢、且要处理冲突。
这一层回答了上一层什么问题:上一章的 UART 一次只能连两个设备(点对点)。SPI 和 I2C 补的是一对多的寻址与仲裁——这正是总线这个概念在板级尺度的最小实现。
原理
一、多设备带来的两个新问题
把 N 个设备挂到同一组线上,立刻多出两个问题:
- 怎么指定"这句话是说给谁听的" —— 寻址问题。
- 两个设备同时开口怎么办 —— 冲突问题。
两种总线给出的答案不同:
| SPI | I2C | |
|---|---|---|
| 寻址 | 物理选线:每个从机一根 SS | 逻辑地址:每个从机一个 7 位地址 |
| 冲突 | 不冲突(片选保证只有一个从机响应) | 靠仲裁(谁先发低谁赢) |
| 线数增长 | 每加一个从机多一根 SS | 不增加线,只占地址 |
这就是"线数换复杂度"的经典权衡:SPI 简单粗暴但引脚消耗快(8 个从机就是 8 根片选),I2C 省线但要处理地址冲突与仲裁。
二、SPI:四线全双工,靠片选选设备
SPI(Serial Peripheral Interface)的四根线分工:
| 信号 | 全称 | 方向 | 说明 |
|---|---|---|---|
| SCK | Serial Clock | 主 → 从 | 时钟,由主机产生 |
| MOSI | Master Out Slave In | 主 → 从 | 主机发给从机的数据 |
| MISO | Master In Slave Out | 从 → 主 | 从机回给主机的数据 |
| SS | Slave Select | 主 → 从 | 低有效,拉低才跟这个从机通讯 |
它的数据传输是两个移位寄存器首尾相接,形成一个环:
text
主机移位寄存器 从机移位寄存器
+----------------+ +----------------+
| 1 0 1 0 0 1 0 1| --MOSI--------> | |
| | <-------MISO--- | 0 0 1 1 1 1 0 0|
+----------------+ +----------------+
^ ^
+-------------- SCK ----------------+每个时钟沿两边各移一位,8 个时钟之后,两个寄存器整个交换。所以:
- SPI 一定是全双工:你发一个字节的同时必然收到一个字节。想"只读"就发一个哑字节(
0xFF)把对方的字节"换"回来。 - 速率不依赖设备数量:加从机只多一根 SS,SCK 频率不变。
- 必须有一根片选:同一时刻只拉低一个 SS,否则多个从机同时驱动 MISO 就会短路。
三、SPI 的四种模式(CPOL / CPHA)
SPI 多出两个参数,是因为"时钟空闲时是高是低"和"在哪个边沿采样"没有统一规定:
| 参数 | 取值 | 含义 |
|---|---|---|
| CPOL | 0 | 时钟空闲为低 |
| 1 | 时钟空闲为高 | |
| CPHA | 0 | 在第 1 个边沿采样(上升沿,若 CPOL=0) |
| 1 | 在第 2 个边沿采样(下降沿,若 CPOL=0) |
两个参数一组合就是四种模式:
模式 0: CPOL=0(空闲低),CPHA=0(第1个边沿采样)
模式 1: CPOL=0(空闲低),CPHA=1(第2个边沿采样)
模式 2: CPOL=1(空闲高),CPHA=0(第1个边沿采样)
模式 3: CPOL=1(空闲高),CPHA=1(第2个边沿采样)模式 0 是最常用的,也是绝大多数 Flash、传感器芯片的默认。主机与从机的模式必须一致,否则数据整体错位一位——这是"SPI 读出来数据总是差一倍"的典型原因。
四、I2C:两线,靠地址选设备
I2C(Inter-Integrated Circuit)只用两根线:
| 信号 | 方向 | 说明 |
|---|---|---|
| SCL | 主 → 从 | 时钟 |
| SDA | 双向 | 数据 |
它靠从机地址寻址,流程是固定的一串:
text
START -> 7 位从机地址 + 1 位方向(0写/1读) -> ACK -> 数据字节 -> ACK -> ... -> STOP7 位地址决定容量:理论上 2^7 = 128 个地址,去掉保留的,实际可用约 112 个。地址的高 4 位通常由芯片型号固定(比如 EEPROM 是 1010),低 3 位由引脚 A2/A1/A0 决定,所以同一型号最多挂 8 片——这就是为什么很多开发板上要挂多片同型号传感器时,得先看它的地址引脚怎么接。
读写位是地址字节的最低位:所以 7 位地址 0x50 的"写地址"是 0xA0、"读地址"是 0xA1。很多驱动代码里写 0xA0 还配一句"7 位地址是 0x50",原因就在这里。
五、I2C 的电气:开漏 + 上拉电阻
I2C 的 SDA 与 SCL 都是开漏输出,两端都没有驱动高电平的能力——高电平只能靠上拉电阻拉起来:
text
Vcc
|
[R] <- 上拉电阻
|
+-----+-----+
| |
主机 从机
(开漏) (开漏)好处有两个,都是开漏带来的:
- 线与:任何一方拉低,整条线就是低;只要有一方不拉,上拉就把它抬起来。这就实现了"多设备共享一根线而不短路"。
- 仲裁可行:两个主机同时发,一个发 1(放开)、一个发 0(拉低),线是低——发 1 的那个发现自己听到的不是自己发的,就主动退出。冲突不损坏器件,只是输的一方退让。
代价是上拉电阻的取值被两个边界夹住:
| 边界 | 由什么决定 | 公式 |
|---|---|---|
下界 R_min | 输出低电平时不能灌太多电流 | R_min = (Vcc - V_OL) / I_OL |
上界 R_max | 上升沿不能太慢,要满足规定的 t_r | R_max = t_r / (0.8473 × C_b) |
两个数字要理解为什么是这两个值:
- 下界:芯片输出低电平时,电流从 Vcc 经上拉电阻灌进芯片的引脚。电阻越小电流越大,超过芯片能承受的灌电流
I_OL就会把低电平抬上去(甚至损坏)。所以电阻不能太小。 - 上界:线容
C_b与电阻构成 RC 电路。电阻越大,充电越慢,上升沿越缓。I2C 规定了最大上升时间t_r(标准模式 1000 ns),超了就采不到正确的电平。所以电阻不能太大。
那个 0.8473 也有来历。I2C 的上升时间定义为电压从 0.3 Vcc 升到 0.7 Vcc 的时间。RC 充电是
text
V(t) = Vcc x (1 - e^(-t / (R x C)))解出两个时刻:
text
t(0.7 Vcc) = R x C x ln(1/0.3) = 1.2040 x R x C
t(0.3 Vcc) = R x C x ln(1/0.7) = 0.3567 x R x C
上升时间 = (1.2040 - 0.3567) x R x C = 0.8473 x R x C反解出 R_max = t_r / (0.8473 × C_b)。这个 0.8473 不是经验系数,是 ln(0.7/0.3)。
六、I2C 的时序:起始、停止、应答、仲裁
SCL 与 SDA 的配合有几个"不像普通数据"的时刻:
| 事件 | 条件 | 含义 |
|---|---|---|
| 起始条件 S | SCL 为高时,SDA 由高变低 | 开始一帧 |
| 停止条件 P | SCL 为高时,SDA 由低变高 | 结束一帧 |
| 数据位 | SCL 高电平期间 SDA 必须稳定 | SCL 高时变 SDA 会被当成起始/停止 |
| 应答 ACK | 第 9 个时钟,收方把 SDA 拉低 | 收到了 |
| 非应答 NACK | 第 9 个时钟,SDA 保持高 | 没收到 / 读完了 |
规则一句话:数据只能在 SCL 为低时改变,SCL 为高时必须稳定。所以起始和停止是"故意破坏规则"的两个特例——正因为正常数据不会在 SCL 高时跳变,这两个跳变才能被识别为帧边界。
时钟同步:SCL 也是线与的,任何一方拉低 SCL,时钟就进低电平。所以慢的从机可以把主机"拖慢"——主机拉高 SCL 后要等实际变成高才继续,这就是 I2C 的流控机制。
七、SPI 与 I2C 怎么选
| 维度 | SPI | I2C |
|---|---|---|
| 线数 | 3 + N(N 个片选) | 2(与设备数无关) |
| 速率 | 数十 MHz | 100 k / 400 k / 1 M / 3.4 M |
| 全双工 | 是 | 半双工 |
| 寻址 | 片选(物理) | 地址(逻辑) |
| 多主机 | 少见 | 原生支持(含仲裁) |
| 开销 | 无协议开销 | 每字节带 ACK、帧带地址 |
| 典型设备 | Flash、SD 卡、显示屏、ADC | EEPROM、温度/加速度传感器、RTC |
选型直觉:
- 要快、要连续大数据 → SPI(比如读 Flash、刷屏幕)。
- 要省引脚、设备多而零散 → I2C(比如板上一堆小传感器)。
- 两者都要 → 很多 MCU 同时提供多路 SPI 与 I2C,各用各的。
示例
例 1:上拉电阻的上下界
取 Vcc = 3.3 V、低电平输出 V_OL = 0.4 V、灌电流上限 I_OL = 3 mA:
text
R_min = (3.3 - 0.4) / 0.003 = 966.67 Ω上界随模式变(t_r 与 C_b 由标准规定):
标准 100k : t_r = 1000 ns, Cb = 200 pF -> R_max = 5901.1 Ω
快速 400k : t_r = 300 ns, Cb = 200 pF -> R_max = 1770.3 Ω
快速+ 1M : t_r = 120 ns, Cb = 100 pF -> R_max = 1416.3 Ω于是标准模式下的合法区间是 967 Ω ~ 5901 Ω,典型取值 4.7 kΩ 正好落在中间。快速模式下上界掉到 1.77 kΩ,所以高速 I2C 常用 2.2 kΩ 甚至 1.5 kΩ。
"越快的总线,上拉电阻越小" 这句话可以从上界公式直接读出来:t_r 变小 → R_max 变小。
例 2:SPI 四种模式
模式 0: CPOL=0(空闲低),CPHA=0(第1个边沿采样)
模式 1: CPOL=0(空闲低),CPHA=1(第2个边沿采样)
模式 2: CPOL=1(空闲高),CPHA=0(第1个边沿采样)
模式 3: CPOL=1(空闲高),CPHA=1(第2个边沿采样)模式 0 与模式 3 最常用。记法:CPOL 决定"闲时高还是低",CPHA 决定"第一个沿采还是第二个沿采",两者独立组合。
例 3:传 256 字节:SPI vs I2C
同样传 256 字节:
SPI @ 8 MHz -> 256.0 us (每字节 8 位,无开销)
I2C @ 400 kHz(含 ACK) -> 5.760 ms (每字节 9 位)
SPI 快 22.5 倍I2C 每个字节要多一位 ACK,所以是 9 × 字节数;再加起始/停止/地址字节,实际还要更慢。22.5 倍这个量级差异说明:要吞吐量就别选 I2C。
例 4:AT24C02 的写周期账
AT24C02 是一片 2 Kbit 的 I2C EEPROM:
容量 = 2048 bit = 256 B;页写 8 B/页 -> 32 页
字节写:256 次 x 5 ms = 1.280 s
页写 : 32 次 x 5 ms = 0.160 s写 256 字节,逐字节写要 1.28 秒,按页写只要 0.16 秒——差 8 倍。因为 EEPROM 每次写操作后要等约 5 ms 的内部擦写周期,页写把 8 个字节合并进一次周期。这就是"页写"存在的理由。
例 5:C 实现——SPI 环形移位与 I2C 单字节写
把 SPI 的"移位寄存器环"和 I2C 的"地址 + 读写位"两件事写成可跑的模型。
/* spi_i2c.c —— 用 C 模拟 SPI 环形移位交换与 I2C 单字节写 */
#include <stdio.h>
#include <stdint.h>
/* ---------- SPI:主从各一个移位寄存器,8 个时钟后整体交换 ---------- */
static uint8_t spi_exchange(uint8_t master_tx, uint8_t slave_data)
{
uint8_t m_out = master_tx, s_out = slave_data;
uint8_t m_in = 0, s_in = 0;
int i;
for (i = 0; i < 8; i++) { /* MSB 先出 */
s_in = (uint8_t)((s_in << 1) | ((m_out >> 7) & 1u));
m_in = (uint8_t)((m_in << 1) | ((s_out >> 7) & 1u));
m_out = (uint8_t)(m_out << 1);
s_out = (uint8_t)(s_out << 1);
}
printf("SPI: master tx 0x%02X, slave tx 0x%02X -> master rx 0x%02X, slave rx 0x%02X (full duplex)\n",
master_tx, slave_data, m_in, s_in);
return m_in;
}
/* ---------- I2C:START + 地址(含读写位) + 数据 ---------- */
static void i2c_write(uint8_t dev7, uint8_t byte)
{
uint8_t addr_rw = (uint8_t)((dev7 << 1) | 0u); /* 最低位 = 方向 */
printf("I2C: START -> addr 0x%02X -> ACK -> data 0x%02X -> ACK -> STOP\n",
addr_rw, byte);
}
int main(void)
{
printf("=== 1. SPI ring shift exchange ===\n");
spi_exchange(0xA5, 0x3C);
printf("\n=== 2. I2C write to AT24C02 (1010 + A2A1A0) ===\n");
i2c_write(0x50, 0x5A); /* 7 位地址 0x50 = 1010000 */
printf("\n=== 3. pull-up resistor ===\n");
printf("R_min = (3.3 - 0.4)/3mA = %.2f ohm\n", (3.3 - 0.4) / 3e-3);
printf("R_max(100 kHz, Cb=200pF) = 1000ns/(0.8473*200pF) = %.1f ohm\n",
1000e-9 / (0.8473 * 200e-12));
printf("valid range %.0f ~ %.0f ohm, typical 4.7 kohm\n",
(3.3 - 0.4) / 3e-3, 1000e-9 / (0.8473 * 200e-12));
return 0;
}
c 本站为静态站,不提供在线运行;可复制到本地用 gcc / python 执行
预期输出:
=== 1. SPI ring shift exchange ===
SPI: master tx 0xA5, slave tx 0x3C -> master rx 0x3C, slave rx 0xA5 (full duplex)
=== 2. I2C write to AT24C02 (1010 + A2A1A0) ===
I2C: START -> addr 0xA0 -> ACK -> data 0x5A -> ACK -> STOP
=== 3. pull-up resistor ===
R_min = (3.3 - 0.4)/3mA = 966.67 ohm
R_max(100 kHz, Cb=200pF) = 1000ns/(0.8473*200pF) = 5901.1 ohm
valid range 967 ~ 5901 ohm, typical 4.7 kohm第一行是两个方向都换到了对方的原值(主机收到 0x3C、从机收到 0xA5),这就是"环形交换"的直接结果:收发是同一个动作,不会只发不收。
例 6:Python——上拉、模式与速率验算
# SPI 与 I2C:上拉电阻、模式、速率对比
print("=== 一、I2C 上拉电阻的边界 ===")
vcc, vol, iol = 3.3, 0.4, 3e-3
print(f"R_min = (Vcc - V_OL) / I_OL = ({vcc} - {vol}) / {iol * 1e3:.0f} mA = {(vcc - vol) / iol:.2f} Ω")
for mode, tr_ns, cb_pf in [("标准 100k", 1000, 200), ("快速 400k", 300, 200), ("快速+ 1M", 120, 100)]:
print(f"{mode:9s} t_r={tr_ns:4d} ns, Cb={cb_pf:3d} pF -> R_max = {tr_ns * 1e-9 / (0.8473 * cb_pf * 1e-12):8.1f} Ω")
print("\n=== 二、0.8473 的来历(ln(0.7/0.3))===")
import math
t_lo = math.log(1 / 0.3) # 升到 0.7 Vcc 的时刻系数
t_hi = math.log(1 / 0.7) # 升到 0.3 Vcc 的时刻系数
print(f"t(0.7Vcc) = {t_lo:.4f} x RC, t(0.3Vcc) = {t_hi:.4f} x RC")
print(f"上升时间系数 = {t_lo - t_hi:.4f} = ln(0.7/0.3) = {math.log(0.7 / 0.3):.4f}")
print("\n=== 三、SPI 四种模式(CPOL / CPHA)===")
for m in range(4):
cpol, cpha = m >> 1, m & 1
print(f"模式 {m}: CPOL={cpol}(空闲{'低' if cpol == 0 else '高'}),CPHA={cpha}(第{'1' if cpha == 0 else '2'}个边沿采样)")
print("\n=== 四、传 256 字节:SPI vs I2C ===")
n = 256
t_spi = n * 8 / 8_000_000
t_i2c = n * 9 / 400_000
print(f"SPI @ 8 MHz -> {t_spi * 1e6:8.1f} us")
print(f"I2C @ 400 kHz(含 ACK) -> {t_i2c * 1e3:8.3f} ms")
print(f"SPI 快 {t_i2c / t_spi:.1f} 倍")
print("\n=== 五、AT24C02(2 Kbit EEPROM)===")
cap_b = 2 * 1024 // 8
print(f"容量 = {2 * 1024} bit = {cap_b} B;页写 8 B/页 -> {cap_b // 8} 页")
print(f"字节写:{cap_b} 次 x 5 ms = {cap_b * 5e-3:.3f} s")
print(f"页写 :{cap_b // 8} 次 x 5 ms = {cap_b // 8 * 5e-3:.3f} s")
print("\n=== 六、7 位从机地址 + 读写位 ===")
base = 0b1010000
for a in (0b000, 0b111):
addr = base | a
print(f"A2A1A0={a:03b} -> 7 位地址 0x{addr:02X};写 0x{addr << 1:02X} / 读 0x{(addr << 1) | 1:02X}")
print("\n=== 七、SPI 时钟分频(f_pclk = 72 MHz)===")
for div in (2, 4, 8, 16, 64, 256):
print(f"分频 {div:3d} -> SCLK = {72_000_000 / div / 1e6:8.3f} MHz")
python 本站为静态站,不提供在线运行;可复制到本地用 gcc / python 执行
预期输出:
=== 一、I2C 上拉电阻的边界 ===
R_min = (Vcc - V_OL) / I_OL = (3.3 - 0.4) / 3 mA = 966.67 Ω
标准 100k t_r=1000 ns, Cb=200 pF -> R_max = 5901.1 Ω
快速 400k t_r= 300 ns, Cb=200 pF -> R_max = 1770.3 Ω
快速+ 1M t_r= 120 ns, Cb=100 pF -> R_max = 1416.3 Ω
=== 二、0.8473 的来历(ln(0.7/0.3))===
t(0.7Vcc) = 1.2040 x RC, t(0.3Vcc) = 0.3567 x RC
上升时间系数 = 0.8473 = ln(0.7/0.3) = 0.8473
=== 三、SPI 四种模式(CPOL / CPHA)===
模式 0: CPOL=0(空闲低),CPHA=0(第1个边沿采样)
模式 1: CPOL=0(空闲低),CPHA=1(第2个边沿采样)
模式 2: CPOL=1(空闲高),CPHA=0(第1个边沿采样)
模式 3: CPOL=1(空闲高),CPHA=1(第2个边沿采样)
=== 四、传 256 字节:SPI vs I2C ===
SPI @ 8 MHz -> 256.0 us
I2C @ 400 kHz(含 ACK) -> 5.760 ms
SPI 快 22.5 倍
=== 五、AT24C02(2 Kbit EEPROM)===
容量 = 2048 bit = 256 B;页写 8 B/页 -> 32 页
字节写:256 次 x 5 ms = 1.280 s
页写 :32 次 x 5 ms = 0.160 s
=== 六、7 位从机地址 + 读写位 ===
A2A1A0=000 -> 7 位地址 0x50;写 0xA0 / 读 0xA1
A2A1A0=111 -> 7 位地址 0x57;写 0xAE / 读 0xAF
=== 七、SPI 时钟分频(f_pclk = 72 MHz)===
分频 2 -> SCLK = 36.000 MHz
分频 4 -> SCLK = 18.000 MHz
分频 8 -> SCLK = 9.000 MHz
分频 16 -> SCLK = 4.500 MHz
分频 64 -> SCLK = 1.125 MHz
分频 256 -> SCLK = 0.281 MHz考点
- SPI 四根线各自的名字与方向:SCK(时钟)、MOSI(主出从入)、MISO(主入从出)、SS(片选,低有效)。MOSI 与 MISO 的方向别记反。
- SPI 必定全双工:它本质是两个移位寄存器环接,发一个字节必然收一个字节。想只读就发哑字节。
- CPOL 与 CPHA 的组合就是四种模式:模式 0 最常用;主从模式必须一致,不一致会导致数据整体错位。
- I2C 只用两根线、靠地址寻址:7 位地址 + 1 位读写位;同一型号芯片最多挂 8 片(A2/A1/A0 三个引脚)。
- I2C 是开漏 + 上拉:开漏才有"线与",才能挂多设备、才能仲裁。没有上拉电阻高电平就出不来。
- 上拉电阻的双边界:
R_min = (Vcc - V_OL)/I_OL(太小灌电流超标)、R_max = t_r/(0.8473 × C_b)(太大上升沿太慢)。0.8473 = ln(0.7/0.3),是 RC 充电从 0.3 Vcc 到 0.7 Vcc 的系数。 - 起始/停止条件的判据:SCL 为高时 SDA 的跳变。数据只能在 SCL 低时改变。第 9 个时钟是 ACK,拉低是应答、保持高是非应答。
- 速率对比量级:SPI 可到几十 MHz,I2C 标准 100 kHz、快速 400 kHz、高速 3.4 MHz。传同样数据,SPI 快一个数量级以上。
- 页写为什么存在:EEPROM 每次写周期约 5 ms,逐字节写比页写慢 8 倍(256 次 vs 32 次)。
- 易错:把 I2C 的"7 位地址"当成"8 位地址"(写入时左移一位再加读写位);把 RS-485 与 I2C 混为一谈(前者是差分物理层、后者是协议);忘记 SPI 从机的 MISO 在未选中时应为高阻(否则会与其他从机冲突)。
小结
- 多设备挂一组线带来两个问题:怎么寻址、冲突怎么办。SPI 用片选(物理)解决,I2C 用地址 + 仲裁(逻辑)解决。
- SPI = 四线 + 片选 + 环形移位:全双工、速率高、线随设备数增长(每加一片多一根 SS)。
- SPI 有 CPOL/CPHA 四种模式,模式 0 最常用,主从必须一致。
- I2C = 两线 + 7 位地址:线与 SCL/SDA,靠地址选设备,线数与设备数无关。
- I2C 的电气是开漏 + 上拉:上拉电阻被
(Vcc-V_OL)/I_OL与t_r/(0.8473 × C_b)夹在中间,标准模式典型 4.7 kΩ。 - I2C 的时序关键在"SCL 高时 SDA 稳定",起始与停止是这个规则的故意例外;第 9 个时钟是 ACK。
- 选型:要快要大流量选 SPI,要省引脚设备多选 I2C。
回到主线:这一章把前两章的"一根线、一对设备"扩展到了板级总线的完整形态——寻址、仲裁、电气约束、时序规则俱全。它与 arch 里 总线的分类与仲裁 是同一个问题在两个尺度上的答案(片内总线 vs 板级总线),也与 silicon 里 CMOS 互补结构与开漏输出 的电路结构直接对应。到这一篇,embed 基础篇的"外设接口三件套"(GPIO、UART、SPI/I2C)就齐了。
这一章只答了"怎么跟外面的芯片打交道",还没答"多个任务怎么在同一颗芯片里轮流跑"。下一篇给裸机程序装上调度器。
下一篇:实时操作系统(RTOS)
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。