Chapter 10 · Arduino 入门

I2C 与 SPI 总线

两大主流通信总线:I2C 两根线挂一堆设备靠地址寻址,SPI 四根线换来高速全双工。

课程进度83%

关键概念

总线 (Bus)
多个设备共享的一组通信线路。相比每个设备单独拉线,总线用固定几根线就能挂载多个外设,靠"地址"或"片选"区分它们在跟谁说话。I2C 和 SPI 是嵌入式里最常见的两种同步串行总线,传感器、显示屏、存储器几乎都用其一。
I2C(两线制)
Inter-Integrated Circuit,只用两根线:SDA(数据)、SCL(时钟)。半双工、主从架构,同一时刻只有一个主机发起通信。速度较慢(标准 100kHz、快速 400kHz),但接线极省、支持多主多从。Uno 的 I2C 固定在 A4(SDA)/A5(SCL)。
从机地址 (Address)
I2C 总线上每个从设备有唯一的 7 位地址(如 0x3C、0x68)。主机发起通信时先广播地址,只有地址匹配的从机才响应。地址由芯片厂商固定+可配置引脚决定,同型号多片时靠改地址脚区分。地址冲突是 I2C 挂多设备时最常见的坑。
上拉电阻 (Pull-up)
I2C 的 SDA/SCL 是"开漏"输出,必须外接上拉电阻(常 4.7kΩ 到 VCC)才能拉出高电平。多数模块板已自带上拉,但若总线上挂很多设备、或用长线,可能需要额外调整。没有上拉,I2C 根本通不了。
SPI(四线制)
Serial Peripheral Interface,四根线:SCK(时钟)、MOSI(主出从入)、MISO(主入从出)、SS/CS(片选)。全双工、速度快(可达数 MHz~几十 MHz),适合高速大数据(SD 卡、TFT 屏、高速 ADC)。缺点:每多一个从机就多一根片选线。Uno:D13=SCK、D11=MOSI、D12=MISO。
片选 (Chip Select / SS)
SPI 靠片选线选中要通信的从机:主机把某从机的 CS 拉低,该从机才响应,其余从机忽略总线。所以 N 个 SPI 从机需要 N 根独立 CS(可用任意数字引脚),而 SCK/MOSI/MISO 三根共享。这就是 SPI"速度快但费引脚"的原因。
Wire 库 / SPI 库
Arduino 自带的两大总线库。Wire 驱动 I2C:begin() 入总线、beginTransmission(addr)+write()+endTransmission() 发数据、requestFrom()+read() 收数据。SPI 驱动 SPI:begin()beginTransaction() 配时钟/模式、transfer() 收发一个字节。大多数传感器/屏幕的专用库底层都在调它俩。
SPI 模式 (CPOL/CPHA)
SPI 有 4 种模式(Mode 0~3),由时钟极性 CPOL(空闲时高还是低)和相位 CPHA(在时钟哪个边沿采样)组合而成。主从必须用同一模式才能正确通信。查器件手册确定,用 SPISettings(速度, 位序, 模式) 设置。最常见是 Mode 0(SPI_MODE0)。

I2C 读取:以 MPU6050 为例

大多数时候你会用现成库,但理解底层 Wire 收发很重要。下例直接读 MPU6050(地址 0x68)的加速度寄存器:

#include <Wire.h>

const int MPU = 0x68;   // MPU6050 的 I2C 地址

void setup() {
  Serial.begin(9600);
  Wire.begin();                  // 作为主机加入 I2C 总线

  // 唤醒 MPU6050:向电源管理寄存器 0x6B 写 0
  Wire.beginTransmission(MPU);   // 开始跟 0x68 通信
  Wire.write(0x6B);              // 指定寄存器地址
  Wire.write(0);                 // 写入的值:0=退出睡眠
  Wire.endTransmission(true);   // 结束并发送
}

void loop() {
  // 从加速度寄存器 0x3B 开始连读 6 字节(X/Y/Z 各 2 字节)
  Wire.beginTransmission(MPU);
  Wire.write(0x3B);
  Wire.endTransmission(false);   // false=保持连接,准备读
  Wire.requestFrom(MPU, 6, true); // 请求 6 字节

  int16_t ax = (Wire.read() << 8) | Wire.read();  // 高字节<<8 拼低字节
  int16_t ay = (Wire.read() << 8) | Wire.read();
  int16_t az = (Wire.read() << 8) | Wire.read();

  Serial.print("ax="); Serial.print(ax);
  Serial.print(" ay="); Serial.print(ay);
  Serial.print(" az="); Serial.println(az);
  delay(200);
}
I2C 一次"写寄存器地址再读数据"的流程 主机 ──START──▶ 从机地址+写 ──▶ 寄存器地址 0x3B ──repeated START──▶ 从机地址+读 ◀──── 数据字节1 ── 数据字节2 …… ──── ──STOP SDA ‾‾\_/‾\__/‾‾ 数据线,每位跟着 SCL 时钟节拍变化 SCL __/‾\_/‾\_/‾ 时钟线,由主机产生 endTransmission(false) 发的是 repeated START(不释放总线), 紧接着 requestFrom 才能连贯地"先定位寄存器再读它"。
优先用现成库

上面手撕 Wire 是为了让你理解原理。实际项目里,MPU6050、BMP280、DS3231 等常用器件都有成熟库(如 Adafruit_MPU6050),几行就能读到已换算好的物理量,不必自己拼字节、查寄存器手册。理解底层 + 用好库 = 又懂又快。

SPI 通信:基本收发

SPI 靠片选选设备、transfer() 同时收发一个字节(全双工)。下例是读写 SPI 器件的通用骨架:

#include <SPI.h>

const int CS = 10;   // 片选引脚,可用任意数字脚

void setup() {
  pinMode(CS, OUTPUT);
  digitalWrite(CS, HIGH);   // 空闲时片选拉高(不选中)
  SPI.begin();               // 初始化 SCK/MOSI/MISO
}

byte readRegister(byte reg) {
  // 配置:1MHz 速率, 高位先发, 模式 0
  SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0));
  digitalWrite(CS, LOW);          // 拉低片选,选中该从机

  SPI.transfer(reg | 0x80);      // 发寄存器地址(最高位1常表示读)
  byte value = SPI.transfer(0x00); // 发空字节,同时收回数据

  digitalWrite(CS, HIGH);         // 拉高片选,释放
  SPI.endTransaction();
  return value;
}

void loop() {
  byte id = readRegister(0x0F);   // 比如读器件 ID 寄存器
  Serial.println(id, HEX);
  delay(500);
}
SPI 四线接法(多从机共享三线,各自独立片选) Arduino 从机1 从机2 ───────── ───── ───── D13 SCK ──┬──────────▶ SCK ──┬──▶ SCK D11 MOSI ──┼──────────▶ SDI ──┼──▶ SDI D12 MISO ──┴──────────◀ SDO ──┴──◀ SDO D10 CS1 ────────────▶ CS D9 CS2 ─────────────────────────▶ CS SCK/MOSI/MISO 三线【并联共享】, 每个从机一根【独立片选】,拉低谁就跟谁通信。
SPI 电平与片选别搞混

① 同一时刻只能拉低一个片选,否则多个从机同时往 MISO 输出会冲突;② 5V Uno 与 3.3V SPI 器件(如很多 TFT/SD 模块)之间要做电平转换,直连可能烧器件;③ MOSI/MISO 别接反(主出接从入、主入接从出),有的模块把 MOSI 标成 SDI、MISO 标成 SDO,看清楚。

板载 EEPROM:掉电不丢的存储

ATmega328P 自带 1KB EEPROM,断电后数据仍在,适合存配置/校准值。用自带 EEPROM 库(注意它不是总线,是片内存储,这里一并讲):

#include <EEPROM.h>

void setup() {
  Serial.begin(9600);

  // 写:把开机次数 +1 存回 EEPROM 地址 0
  byte boots = EEPROM.read(0);   // 读地址 0
  boots++;
  EEPROM.write(0, boots);      // 写回(仅在变化时写,省寿命)

  Serial.print("这是第 ");
  Serial.print(boots);
  Serial.println(" 次开机");

  // 存多字节(如 int/float)用 put/get
  float cal = 3.14;
  EEPROM.put(10, cal);         // 从地址 10 存一个 float
  float back;
  EEPROM.get(10, back);        // 读回
}

void loop() {}
EEPROM 写入次数有限

EEPROM 每个单元约 10 万次擦写寿命,写太频繁会写坏。切忌在 loop 里高频 write!只在数据真正变化时写(EEPROM.update 会先比较、相同就不写,更省寿命)。需要频繁记录用外接 FRAM 或 SD 卡。ESP32 没有真 EEPROM,用 Preferences 库存 Flash 模拟。

I2C vs SPI 选型对比

特性I2CSPI
线数2 (SDA/SCL)4 (SCK/MOSI/MISO/CS)
速度慢 (100k~400kHz)快 (数 MHz~几十 MHz)
多设备靠地址,不加线每个从机加一根 CS
双工半双工全双工
上拉电阻需要不需要
典型器件OLED、RTC、小传感器SD卡、TFT屏、高速ADC
一句话省线、慢、挂得多费线、快、传得多
本章小结

I2C 和 SPI 是嵌入式两大主流总线。I2C 只用 SDA/SCL 两根线,半双工、靠 7 位地址寻址,一根总线能挂多个设备(不加线),速度较慢(100k/400kHz),需外接上拉电阻(模块多自带);用 Wire 库:beginTransmission/write/endTransmission 发、requestFrom/read 收,读寄存器的经典套路是"写寄存器地址→repeated START→读数据"(endTransmission(false) 保持总线)。SPI 用 SCK/MOSI/MISO/CS 四线,全双工、速度快,SCK/MOSI/MISO 三线共享、每个从机一根独立片选(拉低选中);用 SPI 库:beginTransaction(SPISettings(速度,位序,模式))+transfer(),主从必须用同一 SPI 模式(CPOL/CPHA,常用 Mode0)。片内 EEPROM(328P 1KB)断电不丢,用 EEPROM.read/write/put/get 存配置,注意约 10 万次写寿命、切忌 loop 高频写(用 update 省寿命)。选型口诀:省线挂得多用 I2C,要快传得多用 SPI。

⚒️ 配套开发者工具箱 · 17 款免费在线工具