跳到主要内容

I2C 通信

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

I2C 具有以下特点:

  • 两线通信:只需要 SDA(数据线)和 SCL(时钟线)两根信号线。
  • 主从架构:支持多个主设备和从设备在同一总线上。通常为一主多从的结构。
  • 地址寻址:每个设备都有唯一的 7 位或 10 位地址。
  • 同步通信:通过时钟线进行同步,数据传输更可靠。

I2C 总线包含以下信号线:

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

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

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

关于上拉电阻

I2C 总线采用开漏(Open-Drain)或开集(Open-Collector)电路结构,因此 SDA 和 SCL 线路在空闲时处于高阻态。为确保总线能被拉高至高电平,规范要求这两条线通过上拉电阻连接到电源(通常是 3.3V),以确保信号稳定。虽然 ESP32 部分 GPIO 支持内部弱上拉,但在实际连线时(特别是外接模块或多板通信时),建议在 SDA 和 SCL 各自加一个 4.7kΩ 上拉电阻到 3.3V,以提升通信的可靠性。

I2C 连接

1. ESP32 中的 I2C

ESP32 芯片有 2 个 I2C 硬件控制器,每个都可作为主设备或从设备,且 可以分配到绝大多数 GPIO 引脚上

在 Arduino IDE 中,I2C 通信通常通过 Wire 库 进行操作。

2. 示例 1:I2C Scanner

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

2.1 搭建电路

需要使用的器件有:

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

ESP32-S3-Zero 引脚图

ESP32-S3-Zero-Pinout

接线图

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(): 初始化 I2C 总线。在 ESP32 上,该函数有多种形式:
    • Wire.begin(): 使用默认的 SDA (GPIO 21) 和 SCL (GPIO 22) 引脚。大部分 ESP32 开发板以 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

  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 搭建电路

需要使用的器件有:

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

ESP32-S3-Zero 引脚图

ESP32-S3-Zero-Pinout

接线图

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 // 复位引脚

#define I2C_SPEED 100000 // I2C 速度 100kHz

// 创建 I2C 实例
TwoWire oledI2C = TwoWire(0);

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

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

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 引脚来驱动第三方库:

  1. TwoWire oledI2C = TwoWire(0): 创建一个自定义的 I2C 实例,使用 ESP32 的第 0 个 I2C 控制器。
  2. oledI2C.begin(SDA_PIN, SCL_PIN, I2C_SPEED): 初始化 I2C 总线并将其分配到指定的 GPIO 引脚。
    • SDA_PIN, SCL_PIN: 将 I2C 功能分配给指定的引脚。
    • I2C_SPEED: 设置 I2C 通信频率为 100kHz。
  3. Adafruit_SSD1327 display(128, 128, &oledI2C, OLED_RESET): 创建显示器对象。
    • 128, 128: 屏幕分辨率。
    • &oledI2C: 传递自定义的 I2C 实例给库,而不是使用默认的 Wire 对象。
    • OLED_RESET: 复位引脚设置。
    • 在 SSD1327 库中,默认示例代码用的是 Adafruit_SSD1327 display(128, 128, &Wire, OLED_RESET);。可以参考 官方示例 对比两者的区别。

3.4 运行结果

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

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

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

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

4.1 搭建电路

需要使用的器件有:

  • 面包板 * 2
  • 导线
  • ESP32 开发板 * 2

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

ESP32-S3-Zero 引脚图

ESP32-S3-Zero-Pinout

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

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

主设备代码

#include <Wire.h>

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

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

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

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);
}

从设备代码

#include <Wire.h>

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

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

void loop() {
delay(100);
}

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

代码解析

主设备代码

  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 地址。
    • 后续参数指定了引脚和时钟频率。
  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 个字节。

运行结果

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

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

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

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

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

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

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

主设备代码

#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 毫秒
}

从设备代码

#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); // 换行打印数字
}

代码解析

主设备代码

  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 打印到串口监视器。

运行结果

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

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

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

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

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

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

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

5. 相关链接