4. Arduino框架下I2C基础驱动:Wire库实战
好,咱们开始啃I2C驱动这块硬骨头。Arduino框架下操作I2C,核心就是Wire库。说白了,它就是ESP32跟传感器对话的翻译官。我最早用Wire库时,觉得不就是几个函数嘛,调就完了。结果项目现场死活读不到数据,折腾半天才发现是地址写错了。嗯,咱们今天就把这几个函数彻底搞透。
4.1 Wire库的核心函数全景
Wire库的函数不多,但每个都有它的脾气。我习惯把它们分成三组:初始化组、主机发送组、主机接收组。先看个总览表格:
| 函数 | 所属分组 | 核心作用 | 我踩过的坑 |
|---|---|---|---|
Wire.begin() |
初始化 | 启动I2C总线,配置引脚 | 默认引脚跟硬件对不上 |
Wire.beginTransmission() |
主机发送 | 开始向指定从机发送数据 | 地址忘了左移一位 |
Wire.write() |
主机发送 | 写入要发送的字节 | 一次写太多,缓冲区溢出 |
Wire.endTransmission() |
主机发送 | 结束发送,释放总线 | 返回值没检查,死等 |
Wire.requestFrom() |
主机接收 | 向从机请求N个字节 | 请求数量跟实际不匹配 |
Wire.available() |
主机接收 | 检查接收缓冲区有多少字节 | 没等数据到齐就读 |
Wire.read() |
主机接收 | 从缓冲区读取一个字节 | 读的顺序跟发送顺序不一致 |
核心原则:发送和接收是两条独立的流水线。发送用write()填数据,用endTransmission()发出去;接收用requestFrom()触发,用available()和read()取数据。别搞混了。
4.2 Wire.begin() —— 总线的起点
这个函数干两件事:初始化I2C外设,把ESP32的SDA和SCL引脚配置成开漏输出。默认情况下,ESP32用GPIO21做SDA,GPIO22做SCL。但很多开发板不是这么接的。
我记得有一次用ESP32-C3的板子,默认引脚是GPIO8和GPIO9。我按老习惯写Wire.begin(),结果总线一直拉低。查了半天手册才发现引脚不一样。从那以后,我写Wire.begin()必带引脚参数:
// 标准写法:使用默认引脚
Wire.begin();
// 推荐写法:显式指定引脚,避免板子差异
Wire.begin(21, 22); // SDA = GPIO21, SCL = GPIO22
// 对于ESP32-S3,可能是不同引脚
// Wire.begin(8, 9);
我的习惯:永远显式传引脚参数。哪怕你确定默认引脚是对的,也写上。这样代码移植到其他板子时,改两个数字就行,不用翻手册。
4.3 beginTransmission() 与 endTransmission() —— 发送的起止
这两个函数成对出现,中间夹着write()。逻辑很简单:先告诉Wire库「我要跟谁说话」,然后往缓冲区里塞数据,最后说「发出去」。
但有个细节很多人忽略——地址格式。I2C地址有7位和10位两种,传感器几乎都是7位。你拿到的手册上写的地址,比如0x76,那是7位地址。但Wire库要求传入的是8位地址(左移一位,最低位是读写标志)。
为什么会这样?因为I2C协议里,地址字节的高7位是设备地址,最低位是R/W位。Wire库帮你处理了这个移位,所以你传7位地址就行。但有些库不是这样,我吃过亏。
// 传感器地址 0x76(7位)
uint8_t sensorAddr = 0x76;
// 开始发送
Wire.beginTransmission(sensorAddr);
// 写入寄存器地址 + 要发送的数据
Wire.write(0x00); // 寄存器地址
Wire.write(0x01); // 要写入的值
// 结束发送,检查返回值
uint8_t error = Wire.endTransmission();
if (error != 0) {
Serial.printf("发送失败,错误码:%d\n", error);
}
endTransmission()的返回值很关键。0表示成功,1表示数据过长,2表示从机无应答,3表示总线错误。我曾经在项目里没检查这个返回值,传感器没焊好都不知道,数据全是乱的。
| 返回值 | 含义 | 常见原因 |
|---|---|---|
| 0 | 成功 | 一切正常 |
| 1 | 数据过长 | 一次write()超过32字节 |
| 2 | 从机无应答 | 地址错误、传感器没上电、接线松了 |
| 3 | 总线错误 | SDA/SCL被拉死、上拉电阻问题 |
避坑指南:我曾经在批量生产时,有一批板子读不到传感器。排查到最后,发现是endTransmission()返回2,但代码里没处理。从那以后,我每个I2C操作后面都加错误检查。别偷懒,这行代码能救你一命。
4.4 requestFrom() —— 主动要数据
传感器通常不会主动发数据,你得去「要」。requestFrom()就是干这个的。它告诉从机:「我要读N个字节,你准备好。」
这里有个坑:requestFrom()只是发起请求,数据不会立刻到齐。它需要时间。你想想看,传感器收到请求后,要采集数据、打包、放到发送缓冲区,这都需要时间。如果你立刻去读,可能读到空数据或者旧数据。
// 向传感器请求6个字节
Wire.requestFrom(sensorAddr, 6);
// 等待数据到齐
// 错误做法:直接读
// uint8_t data = Wire.read(); // 可能读到0xFF
// 正确做法:等available()返回足够数量
while (Wire.available() < 6) {
delay(1); // 给传感器一点时间
}
// 或者用超时机制
uint32_t timeout = millis() + 100;
while (Wire.available() < 6) {
if (millis() > timeout) {
Serial.println("读取超时");
break;
}
}
我的经验:大多数传感器在100kHz时钟下,请求6个字节,1-2毫秒内就能准备好。但有些慢速传感器(比如温湿度传感器SHT30)可能需要更久。我习惯加一个10ms的超时,既保证可靠性,又不阻塞太久。
4.5 available() 与 read() —— 取数据的正确姿势
available()返回缓冲区里有多少字节,read()从缓冲区取出一个字节。这两个函数配合使用,就像从队列里取数据。顺序很重要——先入先出。
我见过有人这么写:
// 错误示范
Wire.requestFrom(addr, 6);
uint8_t a = Wire.read();
uint8_t b = Wire.read();
uint8_t c = Wire.read();
// 假设传感器返回的是6个字节,这里只读了3个
// 剩下的3个字节会留在缓冲区,影响下一次读取
正确做法是循环读取,确保读够数量:
// 正确示范
Wire.requestFrom(sensorAddr, 6);
uint8_t buffer[6];
uint8_t index = 0;
while (Wire.available() && index < 6) {
buffer[index++] = Wire.read();
}
if (index == 6) {
// 数据完整,可以处理
uint16_t temp = (buffer[0] << 8) | buffer[1];
uint16_t humid = (buffer[2] << 8) | buffer[3];
} else {
Serial.println("数据不完整");
}
4.6 write() —— 发送数据的细节
write()可以发送单字节、多字节数组、字符串。但有个限制:Wire库内部缓冲区默认只有32字节。一次连续write()超过32字节,后面的数据会丢失。
为什么会这样?因为ESP32的I2C硬件FIFO就这么大。你要发更多数据,得分批发送,每次endTransmission()后重新beginTransmission()。
// 发送配置数据,超过32字节时需分批
uint8_t configData[] = {0x01, 0x02, 0x03, /* ... 共50字节 */};
Wire.beginTransmission(sensorAddr);
for (int i = 0; i < 32; i++) {
Wire.write(configData[i]);
}
Wire.endTransmission();
delay(10); // 给传感器处理时间
Wire.beginTransmission(sensorAddr);
for (int i = 32; i < 50; i++) {
Wire.write(configData[i]);
}
Wire.endTransmission();
4.7 完整实战:读取温度传感器数据
把上面所有函数串起来,写一个完整的传感器读取流程。假设我们用的是BMP280气压温度传感器,地址0x76,温度寄存器地址0xFA。
#include <Wire.h>
#define BMP280_ADDR 0x76
#define TEMP_REG 0xFA
void setup() {
Serial.begin(115200);
Wire.begin(21, 22); // 显式指定引脚
// 检查传感器是否在线
Wire.beginTransmission(BMP280_ADDR);
uint8_t error = Wire.endTransmission();
if (error != 0) {
Serial.println("传感器未找到!");
while (1);
}
Serial.println("传感器已连接");
}
void loop() {
// 第一步:写入要读取的寄存器地址
Wire.beginTransmission(BMP280_ADDR);
Wire.write(TEMP_REG);
Wire.endTransmission();
// 第二步:请求3个字节的温度数据
Wire.requestFrom(BMP280_ADDR, 3);
// 第三步:读取数据
uint8_t buffer[3];
uint8_t index = 0;
uint32_t timeout = millis() + 50;
while (Wire.available() && index < 3) {
if (millis() > timeout) {
Serial.println("读取超时");
break;
}
buffer[index++] = Wire.read();
}
if (index == 3) {
// 解析温度值(BMP280的原始数据格式)
int32_t tempRaw = ((int32_t)buffer[0] << 12) |
((int32_t)buffer[1] << 4) |
(buffer[2] >> 4);
float temperature = tempRaw * 0.025 + 40.0;
Serial.printf("温度:%.2f°C\n", temperature);
}
delay(1000);
}
关键流程总结:
- 写寄存器地址:beginTransmission() → write(寄存器地址) → endTransmission()
- 读数据:requestFrom() → 等待available() → 循环read()
- 错误处理:检查endTransmission()返回值,检查读取字节数是否匹配
4.8 知识体系图:Wire库操作流程
下面这张图把整个I2C读写流程串起来了。你写驱动时,脑子里要有这张图。
这张图把读写两条路径分得很清楚。写操作是「三段式」:开始→写数据→结束。读操作是「两段式」:先请求→再读取。中间的错误检查不能省。
嗯,到这里Wire库的核心函数就讲完了。你写驱动时,就按这个流程来。刚开始可能觉得啰嗦,但习惯之后,你会发现这套流程能覆盖90%的I2C传感器。剩下的10%,无非是时序微调或者特殊协议,但底子都是这些函数。