跳到主要内容

I2C 通信


I2C(Inter-Integrated Circuit),也称为 I²CIIC,是一种广泛使用的两线制串行通信协议。I2C 协议允许设备间通过两根信号线进行通信,常用于连接传感器、显示器、存储器等外设。

I2C 具有以下特点:

  • 两线通信:只需要 SDA(数据线)和 SCL(时钟线)两根信号线。

  • 主从架构:支持多个主设备(控制器)和从设备(目标)在同一总线上。

    信息

    I²C 规范的第七次修订 已将传统的 “主/从 (Master/Slave)” 术语更新为 “控制器/目标 (Controller/Target)”。为确保与现有代码和文档的兼容性,本教程可能会根据上下文混用这两种表述。

  • 地址寻址:每个设备都有唯一的 7 位或 10 位地址。

  • 同步通信:通过时钟线进行同步,数据传输更可靠。


I2C 总线包含以下信号线:

  • SDA (Serial Data Line):串行数据线,用于传输数据
  • SCL (Serial Clock Line):串行时钟线,由主设备提供时钟信号

实际硬件连接时所有 I2C 设备还需连接地线(GND),以保证电路共地

I2C 连接

关于上拉电阻

I2C 协议规范要求 SDA 和 SCL 两条线路上必须有上拉电阻。这是因为 I2C 总线采用开漏(Open-Drain)电路结构,设备只能将信号线拉到低电平,而不能主动输出高电平。上拉电阻的作用就是在总线空闲时,将信号线拉回到高电平,确保通信正常。

添加外部上拉电阻的情况:

  • 实际连线时(特别是外接模块或多板通信),建议在 SDA 和 SCL 各连接一个 4.7kΩ 上拉电阻至 3.3V,提升通信可靠性。
  • 总线较长、设备较多或通信不稳定时,必须使用外部上拉电阻。

可以省略外部上拉电阻的情况:

  • 许多 I2C 模块(如本教程使用的 微雪 1.5 寸 OLED 模块)内部已集成上拉电阻。使用此类模块时通常可直接连接,无需额外添加上拉电阻。
  • ESP32 GPIO 支持内部弱上拉,简单应用中可能够用。但最佳做法仍是添加外部上拉电阻以确保通信稳定。

如不确定模块是否包含上拉电阻,建议查看模块原理图或数据手册。

1. ESP32 中的 I2C

ESP32 系列芯片内置的 I2C 控制器数量 因具体型号而异(通常为 1 个或 2 个),本教程使用的 ESP32-S3-Zero 开发板具有 2 个 I2C 控制器。每个 I2C 控制器都可作为主设备或从设备,且 可以分配到绝大多数 GPIO 引脚上

ESP32 I2C 库基于 Arduino Wire 库,并实现了一些额外的 API。详情见 此文档

  • Wire 对象:默认对应第一个 I2C 控制器(I2C0)。
  • Wire1 对象:对应第二个 I2C 控制器(I2C1),可以与 Wire 同时使用,实现两路独立的 I2C 通信。
  • 自定义引脚:你可以通过调用 Wire.begin(int sda, int scl) 来初始化 I2C 并指定 SDA 和 SCL 引脚。

选择 SDA/SCL 引脚时,应注意避开已被其他功能(如板载 UART、LED)占用的引脚。具体可用引脚请参考所用开发板的原理图或引脚图。

2. 示例 1:I2C Scanner

在连接一个新 I2C 模块时,首先需要知道它的地址。很多模块并不标明地址,或地址可通过跳线更改。I2C 扫描仪程序可以快速检测并报告总线上所有设备的地址,是进行 I2C 开发与调试的重要工具。

2.1 搭建电路

需要使用的器件有:

  • 微雪 1.5 寸 OLED 模块 * 1(也可以替换为其他 I2C 模块)
  • 4.7kΩ 电阻 * 2(可选,若 I2C 模块内置上拉电阻可省略)
  • 面包板 * 1
  • 导线
  • ESP32 开发板

按照下面接线图连接电路:

ESP32-S3-Zero 引脚图

ESP32-S3-Zero-Pinout

接线图
电路图说明

电路图中的 4.7kΩ 上拉电阻是 I2C 的标准接法。由于本教程使用中的 OLED 模块已内置上拉电阻,不连接这两个电阻,电路依然可以正常工作。


开发板引脚OLED 模块说明
GPIO 1DIN(SDA)I2C 数据线。按需外接 4.7kΩ 上拉电阻至 3.3V
GPIO 2CLK(SCL)I2C 时钟线。按需外接 4.7kΩ 上拉电阻至 3.3V
3.3VVCC电源正极
GNDGND电源负极

2.2 代码

#include <Wire.h>

#define SDA_PIN 1 // 可以选择任何可用的 GPIO,与实际接线对应
#define SCL_PIN 2 // 可以选择任何可用的 GPIO,与实际接线对应

void setup() {
Serial.begin(9600);
Wire.begin(SDA_PIN,SCL_PIN);
}

void loop() {
byte error, address;
int nDevices = 0;

delay(5000);

Serial.println("Scanning for I2C devices ...");
for (address = 0x01; address < 0x7f; address++) {
Wire.beginTransmission(address);
error = Wire.endTransmission();
if (error == 0) {
Serial.printf("I2C device found at address 0x%02X\n", address);
nDevices++;
} else if (error != 2) {
Serial.printf("Error %d at address 0x%02X\n", error, address);
}
}
if (nDevices == 0) {
Serial.println("No I2C devices found");
}
}

2.3 代码解析

  1. #include <Wire.h>: 引入 Arduino 的 I2C 通信库。
  2. Wire.begin(SDA_PIN, SCL_PIN): 初始化 I2C 总线作为主设备。在 ESP32 上,该函数有多种形式:
    • Wire.begin(): 不指定引脚,使用为当前开发板定义的默认 I2C 引脚。比如 GPIO 21(SDA) 和 GPIO 22(SCL)。以所用开发板的原理图或引脚定义为准。
    • Wire.begin(SDA_PIN, SCL_PIN): 使用指定的 GPIO 引脚。需要确保代码中定义的引脚号与硬件的物理连线一致。
  3. for (address = 0x01; address < 0x7f; address++) : 循环遍历所有可能的 7 位 I2C 地址。
  4. Wire.beginTransmission(address): ESP32(主设备)尝试与指定的 address 建立通信。
  5. Wire.endTransmission(): 结束通信尝试,并返回一个状态码。
    • 0: 成功,从设备已应答 (ACK)。
    • 2: 从设备在接收地址时未应答 (NACK)。这是最常见的情况,表示该地址无设备。
    • 3: 从设备在接收数据时未应答 (NACK)。
    • 4: 其他错误。
  6. Serial.printf("I2C device found at address 0x%02X\n", address);: 如果找到设备,就以十六进制格式(如 0x270x3C)打印出它的地址。

2.4 运行结果

  1. 上传代码,打开串口监视器,设置合适的波特率(9600)。串口监视器将显示 "I2C device found at address ..." 的信息。

    后面跟着的地址就表示该 I2C 设备的地址,比如下图中的 0x3D

    示例 1:I2C Scanner 运行结果

  2. 程序每 5 秒运行一次,串口监视器会持续刷新。

  3. 断开 I2C 设备的连接后,串口监视器将显示 "No I2C devices found" 的提示。

3. 示例 2:用 I2C 与模块交互

在实际应用中,开发者通常无需自行编写底层的 I2C 数据收发代码,而是直接使用针对特定硬件的库。本示例将演示如何驱动一个采用 SSD1327 控制芯片的 OLED 屏幕,这是一个典型的 I2C 应用场景。

值得注意的是,许多 Arduino 库最初是为具有固定 I2C 引脚(如 Arduino Uno)的开发板设计的。相比之下,ESP32 的 I2C 功能非常灵活,可以映射到多数 GPIO 引脚。因此,掌握如何为这些库配置自定义的 I2C 引脚是一项关键技能。

3.1 搭建电路

需要使用的器件有:

  • 微雪 1.5 寸 OLED 模块 * 1
  • 4.7kΩ 电阻 * 2(可选,若 I2C 模块内置上拉电阻可省略)
  • 面包板 * 1
  • 导线
  • ESP32 开发板

按照下面接线图连接电路:

ESP32-S3-Zero 引脚图

ESP32-S3-Zero-Pinout

接线图
电路图说明

电路图中的 4.7kΩ 上拉电阻是 I2C 的标准接法。由于本教程使用中的 OLED 模块已内置上拉电阻,不连接这两个电阻,电路依然可以正常工作。


开发板引脚OLED 模块说明
GPIO 1DIN(SDA)I2C 数据线。按需外接 4.7kΩ 上拉电阻至 3.3V
GPIO 2CLK(SCL)I2C 时钟线。按需外接 4.7kΩ 上拉电阻至 3.3V
3.3VVCC电源正极
GNDGND电源负极

3.2 代码

提示

此代码示例依赖 “Adafruit_SSD1327” 库。请在 Arduino IDE 的库管理器中搜索并安装 “Adafruit_SSD1327” 库。

安装方法请参考:Arduino 库管理教程

#include <Adafruit_SSD1327.h>  // SSD1327 OLED 显示屏库
#include <Wire.h> // I2C 通信库

// I2C 引脚定义
#define SDA_PIN 1 // 数据线
#define SCL_PIN 2 // 时钟线
#define OLED_RESET -1 // 复位引脚

// 创建显示器对象
Adafruit_SSD1327 display(128, 128, &Wire, OLED_RESET);

void setup() {
// 初始化 I2C 总线
Wire.begin(SDA_PIN, SCL_PIN);

Serial.begin(9600);
Serial.println("SSD1327 OLED test");

// 连接 I2C 设备,地址 0x3D
if (!display.begin(0x3D)) {
Serial.println("Unable to initialize OLED");
while (1) yield();
}

// 显示设置
display.clearDisplay();
display.setRotation(3);
display.setTextSize(2);
display.setTextColor(SSD1327_WHITE);

// 显示文本
display.setCursor(10, 10);
display.println("Hello,");
display.setCursor(40, 30);
display.setTextColor(SSD1327_BLACK, SSD1327_WHITE);
display.println(" World!");
display.display();

delay(1000);
}

void loop() {
}

3.3 代码解析

此示例展现了在 ESP32 上使用第三方库与自定义 I2C 引脚的典型流程,其核心在于正确地初始化 Wire 对象并将其传递给库。

  1. #define SDA_PIN 1#define SCL_PIN 2: 使用宏定义指定 I2C 通信所用的 GPIO 引脚。这使得代码易于修改和维护。

  2. Wire.begin(SDA_PIN, SCL_PIN);: 这是关键步骤。在 ESP32 平台上,Wire.begin(SDA_PIN, SCL_PIN) 函数可以将默认的 I2C 控制器(Wire 对象)重新映射到任意指定的 SDA 和 SCL 引脚上。执行这行代码后,Wire 对象的所有后续操作都将通过 GPIO 1 和 GPIO 2 进行。

  3. Adafruit_SSD1327 display(128, 128, &Wire, OLED_RESET);: 创建显示库的对象实例。

    • 128, 128: 屏幕的分辨率(宽度和高度)。
    • &Wire: 将已经配置好引脚的 Wire 对象实例的地址传递给库。Adafruit 库通过这个指针来调用 I2C 函数(如 beginTransmissionwriteendTransmission 等),从而与 OLED 屏幕通信。
    • OLED_RESET: 复位引脚。设置为 -1 表示不使用硬件复位。

这个流程利用了 ESP32 Arduino 核心库的灵活性,使得许多为标准 Arduino 编写的库无需修改即可在 ESP32 的自定义引脚上工作。

3.4 运行结果

  1. OLED 屏幕将被点亮,并显示以下内容:

    示例 2 运行结果
    • 第一行是白色字体的 “Hello,”。
    • 第二行是反色显示的 “ World!”(即黑色文字,白色背景)。

4. 拓展示例:ESP32 之间 I2C 通信

拓展示例将展示两个 ESP32 开发板之间如何通过 I2C 进行通信,其中一个作为控制器(主设备),另一个作为目标(从设备)。示例将演示两种通信模式:主设备请求数据与主设备发送数据。

4.1 搭建电路

需要使用的器件有:

  • 面包板 * 2
  • 4.7kΩ 电阻 * 2
  • 导线
  • ESP32 开发板 * 2

按照下面接线图连接电路:

ESP32-S3-Zero 引脚图

ESP32-S3-Zero-Pinout

接线图
上拉电阻连接:

此示例在不外接上拉电阻情况下也能运行。但为了保证信号稳定,建议连接上拉电阻 ( 3.3V 可以取自任意一块开发板):

将一个 4.7kΩ 电阻的一端连接到 SDA 线(即连接 GPIO 1 和 GPIO 12 的那条线),另一端连接到 3.3V。

将另一个 4.7kΩ 电阻的一端连接到 SCL 线(即连接 GPIO 2 和 GPIO 11 的那条线),另一端连接到 3.3V。

主设备 (开发板 A)从设备 (开发板 B)说明
GPIO 1 (SDA)GPIO 12 (SDA)在代码中设置的 SDA
GPIO 2 (SCL)GPIO 11 (SCL)在代码中设置的 SCL
GNDGND公共地线

4.2 示例 3:主设备请求数据,从设备发送

4.2.1 主设备代码

#include <Wire.h>

#define SDA 1 // 定义 SDA 引脚
#define SCL 2 // 定义 SCL 引脚

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

Wire.begin(SDA, SCL, 100000); // 初始化 I2C 主设备,频率 100 kHz
}

void loop() {
int dataLength = Wire.requestFrom(8, 5); // 向地址为 8 的从设备请求 5 个字节,返回实际收到的字节数

Serial.print("收到 " + String(dataLength) + " 个字符:");

while (Wire.available()) { // 逐个读取接收到的数据
char c = Wire.read();
Serial.print(c);
}

Serial.println();
delay(500);
}

4.2.2 从设备代码

#include <Wire.h>

#define SDA 12 // 定义 SDA 引脚
#define SCL 11 // 定义 SCL 引脚

void setup() {
Wire.begin(8, SDA, SCL, 100000); // 初始化 I2C 从设备,地址为 8,频率 100 kHz
Wire.onRequest(requestEvent); // 注册请求事件回调函数
}

void loop() {
delay(100);
}

// 当主设备请求数据时自动调用此函数
void requestEvent() {
Wire.write((const uint8_t*)"hello", 5); // 向主设备发送 5 个字节,内容为 “hello”
}

4.2.3 代码解析

主设备代码

  1. #define SDA 1 / #define SCL 2: 使用宏定义为 I2C 的 SDA 和 SCL 线指定了 GPIO 1 和 GPIO 2。
  2. Wire.begin(SDA, SCL, 100000): 初始化 I2C 总线。
    • SDA, SCL: 将 I2C 功能分配给指定的引脚。
    • 100000: 设置 I2C 的时钟频率为 100kHz(标准模式)。ESP32 支持标准模式(100kHz)、快速模式(400kHz)以及更高频率(理论上可至 1MHz,但实际取决于硬件和接线质量)。
  3. int dataLength = Wire.requestFrom(8, 5): 这是主设备的核心操作。
    • 它向 I2C 地址为 8 的从设备请求 5 个字节的数据。
    • 函数返回从设备实际发送的字节数,并存入 dataLength 变量。
  4. while (Wire.available()): 检查 I2C 接收缓冲区中是否还有数据可读。
  5. char c = Wire.read(): 从缓冲区中读取一个字节,用于打印到串口监视器。

从设备代码

  1. #define SDA 12 / #define SCL 11: 为从设备的 I2C 指定了 GPIO 12 和 GPIO 11。

  2. Wire.begin(8, SDA, SCL, 100000): 初始化 I2C 总线并将其配置为从设备

    • 第一个参数 8 是此从设备的 I2C 地址。提供一个 I2C 地址(如此处的 8)会将设备初始化为从模式,而省略该地址则默认为主模式。
    • 后续参数指定了引脚和时钟频率。
  3. Wire.onRequest(requestEvent): 这是从设备的关键。它注册了一个回调函数 requestEvent。当主设备向此从设备地址(8)发起数据请求时(即调用 requestFrom),requestEvent 函数就会被自动执行。

  4. requestEvent() 函数: 在主设备请求数据时被调用。

    • Wire.write((const uint8_t*)"hello", 5): 在这个函数内部,我们使用 Wire.write() 来准备好要发送给主设备的数据。根据官方文档,这个函数主要有两种使用形式(即函数重载):

      1. write(uint8_t data): 用于发送单个字节
      2. write(const uint8_t *data, size_t size): 用于发送一个数据块(或字节数组)。
    • 在代码 Wire.write((const uint8_t*)"hello", 5); 中,使用的是第二种形式,用于一次性发送多个字节。

      • 第一个参数: (const uint8_t*)"hello"
        • 这是要发送的数据。"hello" 是一个字符串字面量,它的类型是 const char* (指向常量字符的指针)。
        • 由于函数需要的参数类型是 const uint8_t* (指向无符号字节的指针),我们使用 (const uint8_t*) 进行了强制类型转换,以匹配函数的要求。这在处理底层字节流时是标准操作。
      • 第二个参数: 5
        • 这指定了我们要发送的数据长度。字符串 "hello" 包含 5 个字符,所以我们告诉函数发送 5 个字节。

4.2.4 运行结果

  1. 准备两块 ESP32 开发板,并按照电路图正确连接。

  2. 分别将 【主设备代码】【从设备代码】 上传到两块开发板上。

  3. 使用 USB 线将主设备连接到电脑,并打开串口监视器窗口,选择正确的 COM 端口和波特率(9600)。

  4. 此时可以观察到以下现象:

    • 主设备 的串口监视器会每隔 500 毫秒打印一次:

      示例 3 运行结果

这表明主设备成功地通过 I2C 总线,从指定地址的从设备那里请求并接收到了数据。

4.3 示例 4:主设备写数据,从设备读取

4.3.1 主设备代码

#include <Wire.h>

#define SDA 1 // 定义 SDA 引脚
#define SCL 2 // 定义 SCL 引脚

byte x = 0; // 定义计数器变量

void setup() {
Wire.begin(SDA, SCL, 100000); // 初始化 I2C 主设备,频率 100kHz
}

void loop() {
Wire.beginTransmission(8); // 开始向地址为 8 的从设备发送数据
Wire.write((const uint8_t*)"x is ", 5); // 发送字符串 "x is "(5 个字节)
Wire.write(x); // 发送数字 x
Wire.endTransmission(); // 结束传输

x++; // 计数器递增
delay(500); // 延时 500 毫秒
}

4.3.2 从设备代码

#include <Wire.h>

#define SDA 12 // 定义 SDA 引脚
#define SCL 11 // 定义 SCL 引脚

void setup() {
Wire.begin(8, SDA, SCL, 100000); // 初始化 I2C 从设备,地址为 8,频率 100kHz
Wire.onReceive(receiveEvent); // 注册接收事件回调函数
Serial.begin(9600); // 初始化串口通信
}

void loop() {
delay(100);
}

// 当收到主设备数据时自动调用此函数
void receiveEvent(int len) {
while (Wire.available() > 1) { // 读取除最后一个字节外的所有数据(字符串部分)
char c = Wire.read();
Serial.print(c);
}
int x = Wire.read(); // 读取最后一个字节(数字)
Serial.println(x); // 换行打印数字
}

4.3.3 代码解析

主设备代码

  1. byte x = 0;: 定义一个字节类型的变量 x 并初始化为 0,用于计数。
  2. Wire.beginTransmission(8): 准备开始向地址为 8 的设备发送数据。
  3. Wire.write(...): 将数据放入发送缓冲区。这里先后放入了字符串 "x is " 和变量 x 的值。此时数据还未真正发送。
  4. Wire.endTransmission(): 将缓冲区中的所有数据通过 I2C 总线一次性发送出去,并结束本次通信。
  5. x++: 每次循环将 x 的值加一。

从设备代码

  1. Wire.onReceive(receiveEvent): 注册接收事件的回调函数 receiveEvent。当主设备完成一次传输(调用endTransmission())后,此函数就会被自动执行。
  2. receiveEvent(int len): 这个函数在被调用时,会自动接收一个整形参数,该参数表示主设备本次传输的数据字节总数。Wire 库的设计规定了 onReceive 的回调函数需要接收这个整型参数,因为库会固定将接收到的字节数传递过来。
    • 在此代码中,通过 Wire.available() 来判断缓冲区里还有多少数据,这是一种灵活的处理方式。
    • 但在其他场景下,len 非常有用。例如,可以在读取数据前检查 if (len != 6) 来验证接收的数据长度是否符合你的协议预期,从而增加代码的健壮性。
  3. while (Wire.available() > 1): Wire.available() 返回接收缓冲区中可读的字节数。这个循环会一直读取并打印字符,直到缓冲区中只剩下最后一个字节。
  4. int x = Wire.read(): 读取缓冲区中剩下的最后一个字节。根据主设备的代码,这个字节就是变量 x 的值。
  5. Serial.println(x): 将接收到的数字 x 打印到串口监视器。

4.3.4 运行结果

  1. 准备两块 ESP32 开发板,并按照电路图正确连接。

  2. 分别将 【主设备代码】【从设备代码】 上传到两块开发板上。

  3. 使用 USB 线将从设备连接到电脑,并打开串口监视器窗口,选择正确的 COM 端口和波特率(9600)。

  4. 此时可以观察到以下现象:

    • 从设备 的串口监视器会每隔 500 毫秒接收到一次数据,并打印出来,内容会像这样不断递增:

      示例 4 运行结果

      并且数字会一直增加,直到 byte 类型的变量 x 溢出后从 0 重新开始(0-255)。

这表明主设备成功地将一个组合了字符串和变量的数据包发送给了从设备,并且从设备能够正确地接收、解析和显示它。

5. 相关链接