06_ESP32 I2C 通信
06_ESP32 I2C 通信
I2C(Inter-Integrated Circuit),也称为 I²C 或 IIC,是一种广泛使用的两线制串行通信协议。I2C 协议允许设备间通过两根信号线进行通信,常用于连接传感器、显示器、存储器等外设。
I2C 具有以下特点:
-
两线通信:只需要 SDA(数据线)和 SCL(时钟线)两根信号线。
-
主从架构:支持多个主设备(控制器)和从设备(目标)在同一总线上。
-
地址寻址:每个设备都有唯一的 7 位或 10 位地址。
-
同步通信:通过时钟线进行同步,数据传输更可靠。
I2C 总线包含以下信号线:
- SDA (Serial Data Line):串行数据线,用于传输数据
- SCL (Serial Clock Line):串行时钟线,由主设备提供时钟信号
实际硬件连接时所有 I2C 设备还需连接地线(GND),以保证电路共地。

关于上拉电阻
I2C 协议规范要求 SDA 和 SCL 两条线路上必须有上拉电阻。这是因为 I2C 总线采用开漏(Open-Drain)电路结构,设备只能将信号线拉到低电平,而不能主动输出高电平。上拉电阻的作用就是在总线空闲时,将信号线拉回到高电平,确保通信正常。
添加外部上拉电阻的情况:
- 实际连线时(特别是外接模块或多板通信),建议在 SDA 和 SCL 各连接一个 4.7kΩ 上拉电阻至 3.3V,提升通信可靠性。
- 总线较长、设备较多或通信不稳定时,必须使用外部上拉电阻。
可以省略外部上拉电阻的情况:
- 许多 I2C 模块内部已集成上拉电阻。使用此类模块时通常可直接连接,无需额外添加上拉电阻。
- ESP32 GPIO 支持内部弱上拉,简单应用中可能够用。但最佳做法仍是添加外部上拉电阻以确保通信稳定。
如不确定模块是否包含上拉电阻,建议查看模块原理图或数据手册。
ESP32 中的 I2C
ESP32 系列芯片内置的 I2C 控制器数量 [因具体型号而异](https://products.espressif.com/static/Espressif SoC Product Portfolio.pdf)(通常为 1 个或 2 个),本教程使用的 ESP32-S3 开发板具有 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)占用的引脚。具体可用引脚请参考所用开发板的原理图或引脚图。
示例 1:I2C Scanner
在连接一个新 I2C 模块时,首先需要知道它的地址。很多模块并不标明地址,或地址可通过跳线更改。I2C 扫描仪程序可以快速检测并报告总线上所有设备的地址,是进行 I2C 开发与调试的重要工具。
| 开发板引脚 | OLED 模块 | 说明 |
|---|---|---|
| GPIO 1 | DIN(SDA) | I2C 数据线。按需外接 4.7kΩ 上拉电阻至 3.3V |
| GPIO 2 | CLK(SCL) | I2C 时钟线。按需外接 4.7kΩ 上拉电阻至 3.3V |
| 3.3V | VCC | 电源正极 |
| GND | GND | 电源负极 |
#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");
}
}
代码解析
-
#include <Wire.h>: 引入 Arduino 的 I2C 通信库。 -
Wire.begin(SDA_PIN, SCL_PIN)- 初始化 I2C 总线作为主设备。在 ESP32 上,该函数有多种形式:
Wire.begin(): 不指定引脚,使用为当前开发板定义的默认 I2C 引脚。比如 GPIO 21(SDA) 和 GPIO 22(SCL)。以所用开发板的原理图或引脚定义为准。Wire.begin(SDA_PIN, SCL_PIN): 使用指定的 GPIO 引脚。需要确保代码中定义的引脚号与硬件的物理连线一致。
-
for (address = 0x01; address < 0x7f; address++): 循环遍历所有可能的 7 位 I2C 地址。 -
Wire.beginTransmission(address): ESP32(主设备)尝试与指定的address建立通信。 -
Wire.endTransmission()- 结束通信尝试,并返回一个状态码。
0: 成功,从设备已应答 (ACK)。2: 从设备在接收地址时未应答 (NACK)。这是最常见的情况,表示该地址无设备。3: 从设备在接收数据时未应答 (NACK)。4: 其他错误。
-
Serial.printf("I2C device found at address 0x%02X\n", address);: 如果找到设备,就以十六进制格式(如0x27或0x3C)打印出它的地址。
运行结果
-
上传代码,打开串口监视器,设置合适的波特率(9600)。串口监视器将显示 "I2C device found at address ..." 的信息。
后面跟着的地址就表示该 I2C 设备的地址,比如下图中的
0x3D。 -
程序每 5 秒运行一次,串口监视器会持续刷新。
-
断开 I2C 设备的连接后,串口监视器将显示 "No I2C devices found" 的提示。
示例 2:用 I2C 与模块交互
在实际应用中,开发者通常无需自行编写底层的 I2C 数据收发代码,而是直接使用针对特定硬件的库。本示例将演示如何驱动一个采用 SSD1327 控制芯片的 OLED 屏幕,这是一个典型的 I2C 应用场景。
值得注意的是,许多 Arduino 库最初是为具有固定 I2C 引脚(如 Arduino Uno)的开发板设计的。相比之下,ESP32 的 I2C 功能非常灵活,可以映射到多数 GPIO 引脚。因此,掌握如何为这些库配置自定义的 I2C 引脚是一项关键技能。
| 开发板引脚 | OLED 模块 | 说明 |
|---|---|---|
| GPIO 1 | DIN(SDA) | I2C 数据线。按需外接 4.7kΩ 上拉电阻至 3.3V |
| GPIO 2 | CLK(SCL) | I2C 时钟线。按需外接 4.7kΩ 上拉电阻至 3.3V |
| 3.3V | VCC | 电源正极 |
| GND | GND | 电源负极 |
// 包含Arduino核心库,让ESP32能正常运行基础功能
#include <Arduino.h>
// 包含I2C通信库,OLED屏幕是通过I2C协议通信的
#include <Wire.h>
// 包含图形显示库,负责在屏幕上画文字、图形等
#include <Adafruit_GFX.h>
// 包含OLED驱动库,专门驱动0.96寸SSD1306屏幕
#include <Adafruit_SSD1306.h>
// ==================== I2C引脚自定义设置 ====================
#define SDA_PIN 1 // 定义I2C数据线引脚 = ESP32的GPIO1
#define SCL_PIN 2 // 定义I2C时钟线引脚 = ESP32的GPIO2
// ==================== OLED屏幕参数设置 ====================
#define SCREEN_WIDTH 128 // 屏幕宽度:128个像素点
#define SCREEN_HEIGHT 64 // 屏幕高度:64个像素点
#define OLED_RESET -1 // 屏幕复位引脚:没有就填-1
#define SCREEN_ADDRESS 0x3C // OLED的I2C地址(0.96寸常用0x3C)
// 创建OLED屏幕对象,告诉系统屏幕大小、用I2C通信、复位引脚
Adafruit_SSD1306 OLED(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
// ==================== 初始化函数(只运行1次) ====================
void setup() {
// 启动I2C通信,并使用上面定义的引脚:GPIO1(SDA)、GPIO2(SCL)
Wire.begin(SDA_PIN, SCL_PIN);
// 初始化OLED屏幕,让屏幕开始工作
OLED.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS);
// 清除屏幕上所有内容(黑屏)
OLED.clearDisplay();
// 设置文字颜色:白色(OLED只有黑白)
OLED.setTextColor(SSD1306_WHITE);
// 设置文字大小:1号字体(最小)
OLED.setTextSize(1);
// 设置文字显示的起始坐标:x=5, y=5
OLED.setCursor(5, 5);
// 在屏幕上打印文字
OLED.println("Hello, world!");
// 把文字刷新到屏幕上(不写屏幕不显示)
OLED.display();
}
// ==================== 循环函数(重复运行) ====================
void loop() {
// 这里暂时空着,因为只需要显示一次文字
}
示例 3:ESP32 之间 I2C 通信
拓展示例将展示两个 ESP32 开发板之间如何通过 I2C 进行通信,其中一个作为控制器(主设备),另一个作为目标(从设备)。示例将演示两种通信模式:主设备请求数据与主设备发送数据。
上拉电阻连接:
此示例在不外接上拉电阻情况下也能运行。但为了保证信号稳定,建议连接上拉电阻 ( 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 |
| GND | GND | 公共地线 |
主设备请求数据,从设备发送
主设备代码
#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);
}
从设备代码
#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”
}
主设备代码
#define SDA 1/#define SCL 2: 使用宏定义为 I2C 的 SDA 和 SCL 线指定了 GPIO 1 和 GPIO 2。Wire.begin(SDA, SCL, 100000): 初始化 I2C 总线。SDA,SCL: 将 I2C 功能分配给指定的引脚。100000: 设置 I2C 的时钟频率为 100kHz(标准模式)。ESP32 支持标准模式(100kHz)、快速模式(400kHz)以及更高频率(理论上可至 1MHz,但实际取决于硬件和接线质量)。
int dataLength = Wire.requestFrom(8, 5): 这是主设备的核心操作。- 它向 I2C 地址为
8的从设备请求5个字节的数据。 - 函数返回从设备实际发送的字节数,并存入
dataLength变量。
- 它向 I2C 地址为
while (Wire.available()): 检查 I2C 接收缓冲区中是否还有数据可读。char c = Wire.read(): 从缓冲区中读取一个字节,用于打印到串口监视器。
从设备代码
#define SDA 12/#define SCL 11: 为从设备的 I2C 指定了 GPIO 12 和 GPIO 11。Wire.begin(8, SDA, SCL, 100000): 初始化 I2C 总线并将其配置为从设备。- 第一个参数
8是此从设备的 I2C 地址。提供一个 I2C 地址(如此处的8)会将设备初始化为从模式,而省略该地址则默认为主模式。 - 后续参数指定了引脚和时钟频率。
- 第一个参数
Wire.onRequest(requestEvent): 这是从设备的关键。它注册了一个回调函数requestEvent。当主设备向此从设备地址(8)发起数据请求时(即调用requestFrom),requestEvent函数就会被自动执行。requestEvent()函数: 在主设备请求数据时被调用。Wire.write((const uint8_t*)"hello", 5): 在这个函数内部,我们使用Wire.write()来准备好要发送给主设备的数据。根据官方文档,这个函数主要有两种使用形式(即函数重载):write(uint8_t data): 用于发送单个字节。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 个字节。
- 第一个参数:
运行结果
-
准备两块 ESP32 开发板,并按照电路图正确连接。
-
分别将 【主设备代码】 和 【从设备代码】 上传到两块开发板上。
-
使用 USB 线将主设备连接到电脑,并打开串口监视器窗口,选择正确的 COM 端口和波特率(9600)。
-
此时可以观察到以下现象:
-
主设备 的串口监视器会每隔 500 毫秒打印一次:
-
这表明主设备成功地通过 I2C 总线,从指定地址的从设备那里请求并接收到了数据。
示例 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); // 换行打印数字
}
主设备代码
byte x = 0;: 定义一个字节类型的变量x并初始化为 0,用于计数。Wire.beginTransmission(8): 准备开始向地址为8的设备发送数据。Wire.write(...): 将数据放入发送缓冲区。这里先后放入了字符串 "x is " 和变量x的值。此时数据还未真正发送。Wire.endTransmission(): 将缓冲区中的所有数据通过 I2C 总线一次性发送出去,并结束本次通信。x++: 每次循环将x的值加一。
从设备代码
-
Wire.onReceive(receiveEvent): 注册接收事件的回调函数receiveEvent。当主设备完成一次传输(调用endTransmission())后,此函数就会被自动执行。 -
receiveEvent(int len): 这个函数在被调用时,会自动接收一个整形参数,该参数表示主设备本次传输的数据字节总数。Wire库的设计规定了onReceive的回调函数需要接收这个整型参数,因为库会固定将接收到的字节数传递过来。- 在此代码中,通过
Wire.available()来判断缓冲区里还有多少数据,这是一种灵活的处理方式。 - 但在其他场景下,
len非常有用。例如,可以在读取数据前检查if (len != 6)来验证接收的数据长度是否符合你的协议预期,从而增加代码的健壮性。
- 在此代码中,通过
-
while (Wire.available() > 1):Wire.available()返回接收缓冲区中可读的字节数。这个循环会一直读取并打印字符,直到缓冲区中只剩下最后一个字节。 -
int x = Wire.read(): 读取缓冲区中剩下的最后一个字节。根据主设备的代码,这个字节就是变量x的值。 -
Serial.println(x): 将接收到的数字x打印到串口监视器。
运行结果
-
准备两块 ESP32 开发板,并按照电路图正确连接。
-
分别将 【主设备代码】 和 【从设备代码】 上传到两块开发板上。
-
使用 USB 线将从设备连接到电脑,并打开串口监视器窗口,选择正确的 COM 端口和波特率(9600)。
-
此时可以观察到以下现象:
-
从设备 的串口监视器会每隔 500 毫秒接收到一次数据,并打印出来,内容会像这样不断递增:
并且数字会一直增加,直到 byte类型的变量x溢出后从 0 重新开始(0-255)。
-
这表明主设备成功地将一个组合了字符串和变量的数据包发送给了从设备,并且从设备能够正确地接收、解析和显示它。
浙公网安备 33010602011771号