ESP32 接传感器,十次里有八次卡在 I2C 上:线接好了,串口一片安静;扫出来的地址和手册对不上;想挂第二个同型号模块,两个 0x76 直接打架。这几件事看着散,其实是一套东西——总线、地址、上拉,再加上 ESP32 自己那两路 I2C 怎么分配。
下面按现场排错的顺序走:先接线定引脚,再扫地址确认总线活着,然后改引脚、挂多设备,最后把两路总线一起开,让两块 ESP32 一主一从对拉。代码全部照官方原文搬,能直接编译。

一、接线和上拉:先把两根线搞对
I2C 只用两根线:SCL 走时钟,SDA 收发数据。麻烦在于模块丝印不统一,不少板子把 SDA 印成 SDI、SCL 印成 SCK。BME280 模块就是典型,六个脚排下来是 VCC、GND、SDI、SCK、CSB、SDO,不认这层对应关系,线一接就是错的。

ESP32 这头,Arduino IDE 环境下默认拿 GPIO 22 当 SCL、GPIO 21 当 SDA,四根线接完事:
- SDA → GPIO 21(默认)
- SCL → GPIO 22(默认)
- GND → GND
- VCC → 通常 3.3V,按模块标称来
SDA 和 SCL 是低电平有效,必须上拉。典型值记两个就够:5V 器件用 4.7kΩ,3.3V 器件用 2.4kΩ。买来的模块多半自带这两颗电阻,不用外加;自己画板、或者飞线拉得比较长,这两个电阻就得补上,否则时序一塌糊涂。
再往下是协议层面的参数。ESP32 有两个 I2C 总线接口,当主当从都能配。数据手册给的速率是标准模式 100 Kbit/s、快速模式 400 Kbit/s,最高能到 5 MHz,但实际跑多高要看 SDA 上拉够不够强;寻址支持 7 位和 10 位,另外还有双地址模式,可以通过命令寄存器切。

这张图画的就是一根总线挂四块从机:OLED 标 0x3C、BME280 标 0x76,另外两块标着 0x27 和 0x68,右上角两个电阻把 SDA、SCL 拉到 VCC。地址互不重复,线就可以全部并在同一对线上。
协议本身允许多主多从。一个主机挂多个从机是最常见的用法,比如 ESP32 从 BME280 读温湿度、再把读数写到 I2C 的 OLED 上;反过来多个主机控同一个从机也行,两块 ESP32 往同一块 OLED 写画面就是这种。做机器人项目基本都是第一种,主机只有 ESP32 一个,从机换着花样往上加。
这也是为什么地址比引脚更值得先搞清楚:引脚接错了,线一挪就好了;地址判断错了,代码会一直提示找不到设备,人却盯着接线看半天。
二、先扫地址,别急着写业务代码
每个从机都有自己的地址,一般是十六进制。手册上写了就照手册,翻不到就扫。这段扫描代码是万能起手式,一个字都不用改。
/*********
Rui Santos
Complete project details at https://randomnerdtutorials.com
*********/
#include "Wire.h"
void setup() {
Wire.begin();
Serial.begin(115200);
Serial.println("\nI2C Scanner");
}
void loop() {
byte error, address;
int nDevices;
Serial.println("Scanning...");
nDevices = 0;
for(address = 1; address < 127; address++ ) {
Wire.beginTransmission(address);
error = Wire.endTransmission();
if (error == 0) {
Serial.print("I2C device found at address 0x");
if (address<16) {
Serial.print("0");
}
Serial.println(address,HEX);
nDevices++;
}
else if (error==4) {
Serial.print("Unknow error at address 0x");
if (address<16) {
Serial.print("0");
}
Serial.println(address,HEX);
}
}
if (nDevices == 0) {
Serial.println("No I2C devices found\n");
}
else {
Serial.println("done\n");
}
delay(5000);
}挂一块 I2C 字符屏上去跑,串口大概率先打一行 Scanning...,接着冒出 I2C device found at address 0x27,最后一句 done。三种结果对应三条路:
- 打出 0x 开头的地址:总线是通的,把地址抄下来写进业务代码
- 一直 No I2C devices found:先量 VCC 和 GND 有没有 3.3V,再查 SDA、SCL 有没有接反
- 冒出 Unknow error at address 0x..(原文里一直这么拼):多半是线路干扰或者上拉不够
扫描间隔是 delay(5000),五秒一轮。别嫌慢,挂的设备一多,扫一轮本来就要这个时间。
读输出有个小细节:循环从 1 扫到 126,碰到小于 16 的地址会先补一个 0 再打印,所以屏幕上是 0x27、0x3C、0x76 这种整齐的两步十六进制。看到 0x3C 就照 0x3C 写进代码,别自己在前面加 0x0 折腾。
顺手记住三个字符串,它们在后面对应三条不同的毛病:No I2C devices found 是总线完全没通;Could not find a valid BME280 sensor, check wiring! 是线通了但库按指定地址没应答;SSD1306 allocation failed 是屏幕这边根本没起来。这三句报错指向的位置完全不一样,别混着一块查。
三、默认 21/22 被占了,就自己指定引脚
GPIO 21 和 22 经常被别的外设占着,比如摄像头、板载屏。ESP32 几乎所有引脚都能做 I2C,改法就两步:自己建一个 TwoWire 实例,再把它传给传感器库的 begin()。
下面这段把 BME280 挪到 GPIO 33(SDA)和 GPIO 32(SCL),速率写 100000。

/*
Rui Santos
Complete project details at https://RandomNerdTutorials.com/esp32-i2c-communication-arduino-ide/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
*/
#include "Wire.h"
#include "Adafruit_Sensor.h"
#include "Adafruit_BME280.h"
#define I2C_SDA 33
#define I2C_SCL 32
#define SEALEVELPRESSURE_HPA (1013.25)
TwoWire I2CBME = TwoWire(0);
Adafruit_BME280 bme;
unsigned long delayTime;
void setup() {
Serial.begin(115200);
Serial.println(F("BME280 test"));
I2CBME.begin(I2C_SDA, I2C_SCL, 100000);
bool status;
// default settings
// (you can also pass in a Wire library object like &Wire2)
status = bme.begin(0x76, &I2CBME);
if (!status) {
Serial.println("Could not find a valid BME280 sensor, check wiring!");
while (1);
}
Serial.println("-- Default Test --");
delayTime = 1000;
Serial.println();
}
void loop() {
printValues();
delay(delayTime);
}
void printValues() {
Serial.print("Temperature = ");
Serial.print(bme.readTemperature());
Serial.println(" *C");
// Convert temperature to Fahrenheit
/*Serial.print("Temperature = ");
Serial.print(1.8 * bme.readTemperature() + 32);
Serial.println(" *F");*/
Serial.print("Pressure = ");
Serial.print(bme.readPressure() / 100.0F);
Serial.println(" hPa");
Serial.print("Approx. Altitude = ");
Serial.print(bme.readAltitude(SEALEVELPRESSURE_HPA));
Serial.println(" m");
Serial.print("Humidity = ");
Serial.print(bme.readHumidity());
Serial.println(" %");
Serial.println();
}关键就三行:TwoWire I2CBME = TwoWire(0) 建出一路总线;I2CBME.begin(I2C_SDA, I2C_SCL, 100000) 把引脚和速率定死;bme.begin(0x76, &I2CBME) 把这路总线交给库。
坑在库里面。有些库在自己的 .cpp 里写死了一句 wire.begin(),你不把自己的实例传进去,它照样按默认引脚初始化,代码看着全对,就是读不到数。遇到这种得翻库文件,把那行注释掉,或者找接收 TwoWire 参数的重载。Adafruit 的 BME280 库是收参数的,begin() 第二个参数直接给 &I2CBME 就行。
原文这里有个小矛盾值得提:讲解段落写的是 400000,示例代码里是 100000。以代码为准,先跑 100k,稳了再往上提到 400k。速率一高,飞线长、上拉弱的问题立刻暴露成乱码或者读出 0。
改引脚时最容易忽略的两件事
第一件是库的初始化顺序。TwoWire 实例必须在库 begin() 之前就 begin 好,顺序反了库拿到的是一路没初始化完的总线,表现同样是读不到数。第二件是引脚本身的状态,挑引脚时避开启动阶段被拉高拉低的那几个,也别用只能输入不能输出的脚,否则扫地址那一轮就会卡住。
这两条都不是理论风险,是实机上最常见的两种“代码没错但就是不工作”。碰到的时候先看串口停在哪一句,再回头核对初始化的先后。
四、一条总线挂多个设备,地址撞了才要动手
地址不同的设备,直接并到同一对 SDA/SCL 上就行。最典型的是 BME280 读环境、OLED 显示,0x76 和 0x3C 各占一个,代码里分别初始化,互不打架。
/*
Rui Santos
Complete project details at https://RandomNerdTutorials.com/esp32-i2c-communication-arduino-ide/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
*/
#include "Wire.h"
#include "Adafruit_GFX.h"
#include "Adafruit_SSD1306.h"
#include "Adafruit_Sensor.h"
#include "Adafruit_BME280.h"
#define SCREEN_WIDTH 128 // OLED display width, in pixels
#define SCREEN_HEIGHT 64 // OLED display height, in pixels
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
Adafruit_BME280 bme;
void setup() {
Serial.begin(115200);
if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println(F("SSD1306 allocation failed"));
for(;;);
}
bool status = bme.begin(0x76);
if (!status) {
Serial.println("Could not find a valid BME280 sensor, check wiring!");
while (1);
}
delay(2000);
display.clearDisplay();
display.setTextColor(WHITE);
}
void loop() {
display.clearDisplay();
// display temperature
display.setTextSize(1);
display.setCursor(0,0);
display.print("Temperature: ");
display.setTextSize(2);
display.setCursor(0,10);
display.print(String(bme.readTemperature()));
display.print(" ");
display.setTextSize(1);
display.cp437(true);
display.write(167);
display.setTextSize(2);
display.print("C");
// display humidity
display.setTextSize(1);
display.setCursor(0, 35);
display.print("Humidity: ");
display.setTextSize(2);
display.setCursor(0, 45);
display.print(String(bme.readHumidity()));
display.print(" %");
display.display();
delay(1000);
}两个 begin 的第二个参数就是地址:display.begin(SSD1306_SWITCHCAPVCC, 0x3C) 对应 OLED,bme.begin(0x76) 对应传感器。地址写错的表现非常统一——串口打出 Could not find a valid BME280 sensor, check wiring!,然后死在 while(1) 里,屏幕也不会亮。
真正麻烦的是两个同型号设备,比如两块 BME280 都想挂,默认全是 0x76。第一条路是改地址:OLED 背面那排焊盘就是干这个的,把电阻挪到另一边,0x78 那一档对应 0x3C,0x7A 那一档对应 0x3D,一条总线上最多凑两个。

这条路的局限也明显:只能加一个,而且不是每块板都留了改址焊盘。第二条路是加多路复用器,TCA9548A 能把一条总线掰成 8 路,每路挂一个同地址设备,靠切通道来访问。设备超过两个,就走这条。
写代码前还得确认一件事:0x3C、0x76 这些是 7 位地址,扫描器和库用的都是 7 位形式。有些数据手册给的是 8 位写法,左右各差一位,照手册直接填进去必然读不到,得先右移一位再填。这是新手最容易踩的一个数值坑,报错表现和接错线一模一样。
五、两路总线同时开:双 BME280 与双板对拉
ESP32 有两个 I2C 外设,落到代码里就是 TwoWire(0) 和 TwoWire(1)。要同时用,建两个实例,各自 begin 自己的引脚即可。
示例里总线 1 用 GPIO 27(SDA)、GPIO 26(SCL),总线 2 用 GPIO 33(SDA)、GPIO 32(SCL),两路速率都是 100000。

/*
Rui Santos
Complete project details at https://RandomNerdTutorials.com/esp32-i2c-communication-arduino-ide/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
*/
#include "Wire.h"
#include "Adafruit_Sensor.h"
#include "Adafruit_BME280.h"
#define SDA_1 27
#define SCL_1 26
#define SDA_2 33
#define SCL_2 32
TwoWire I2Cone = TwoWire(0);
TwoWire I2Ctwo = TwoWire(1);
Adafruit_BME280 bme1;
Adafruit_BME280 bme2;
void setup() {
Serial.begin(115200);
Serial.println(F("BME280 test"));
I2Cone.begin(SDA_1, SCL_1, 100000);
I2Ctwo.begin(SDA_2, SCL_2, 100000);
bool status1 = bme1.begin(0x76, &I2Cone);
if (!status1) {
Serial.println("Could not find a valid BME280_1 sensor, check wiring!");
while (1);
}
bool status2 = bme2.begin(0x76, &I2Ctwo);
if (!status2) {
Serial.println("Could not find a valid BME280_2 sensor, check wiring!");
while (1);
}
Serial.println();
}
void loop() {
// Read from bme1
Serial.print("Temperature from BME1= ");
Serial.print(bme1.readTemperature());
Serial.println(" *C");
Serial.print("Humidity from BME1 = ");
Serial.print(bme1.readHumidity());
Serial.println(" %");
Serial.print("Pressure from BME1 = ");
Serial.print(bme1.readPressure() / 100.0F);
Serial.println(" hPa");
Serial.println("--------------------");
// Read from bme2
Serial.print("Temperature from BME2 = ");
Serial.print(bme2.readTemperature());
Serial.println(" *C");
Serial.print("Humidity from BME2 = ");
Serial.print(bme2.readHumidity());
Serial.println(" %");
Serial.print("Pressure from BME2 = ");
Serial.print(bme2.readPressure() / 100.0F);
Serial.println(" hPa");
Serial.println("--------------------");
delay(5000);
}两块传感器地址都是 0x76,但分在两条总线上,谁也不冲突。这是双总线最实用的场景:同型号传感器不用改址、不用加复用器,多开一路就完事,代价只是多占两个引脚。
接线时守住一条:两路总线的四根数据线彼此独立,别把两路的 SDA 并到一起。并了之后两条总线在电气上就是一条,地址冲突照样发生,而且比单总线更难查——你以为分开了,实际没分开。飞线颜色建议一路一色,两路都用红色很容易接串。
还有更省事的写法,用预定义对象。Wire() 走默认的 GPIO 21/22,Wire1.begin(SDA_2, SCL_2) 走自定义引脚,第二块传感器初始化时把 &Wire1 传进去。
#define SDA_2 33
#define SCL_2 32
Adafruit_BME280 bme1;
Adafruit_BME280 bme2;
void setup() {
Serial.begin(115200);
Serial.println(F("BME280 test"));
Wire.begin();
Wire1.begin(SDA_2, SCL_2);
bool status1 = bme1.begin(0x76);
if (!status1) {
Serial.println("Could not find a valid BME280_1 sensor, check wiring!");
while (1);
}
bool status2 = bme2.begin(0x76, &Wire1);
if (!status2) {
Serial.println("Could not find a valid BME280_2 sensor, check wiring!");
while (1);
}
Serial.println();
}跑起来串口是这么两段:BME1 26.30 °C、58.28%、1004.04 hPa,BME2 26.45 °C、57.52%、1003.95 hPa。两个传感器挨着放,温度差 0.15 度、气压差不到 0.1 hPa、湿度差不到 1 个百分点,读数对得上,说明两路总线都在正常干活;差得离谱就是有一路没接好,或者 &I2Cone、&I2Ctwo 传串了。

两块 ESP32 一主一从
再往前一步,两块 ESP32 之间也能用 I2C 通信:一块设成从机存数据,另一块当主机来取。接线就三根,SCL 对 SCL、SDA 对 SDA、GND 对 GND。

地一定要共。不共地,两边电平没有共同参考点,读出来全是乱的,而且这种错最难查,因为线看着都接上了。图上两块都是 ESP32 DOIT V1,默认 I2C 就是 21/22;换成 ESP32-C3、S3 或者别的型号,默认引脚未必是这两个,得先查自己板子的引脚图,或者干脆按第三节的办法自己指定。
把上面这些串起来,I2C 排错其实是一条固定路径:先确认 VCC、GND 和上拉,再跑扫描看地址出不出来;地址扫不到就去改引脚,同时查库有没有覆盖掉你的 Wire 实例;设备多了先看地址撞没撞,撞了就改址或者上 TCA9548A 这类复用器;还不够就开第二路总线。
这套流程在 ESP32 上特别好用,因为它有两路 I2C,引脚又几乎可以随便指,很多在别的板子上要加硬件的场景,这里改几行代码就过去了。先把扫描那步跑通,剩下的都是加法。











扫地址那步真救命。我第一次接 BME280 把 SCK 当成 SDA 了,串口一直 No I2C devices found,照着先量电压再查线的顺序,两分钟就找出来了。
两块同款 BME280 我折腾过一整晚,两个 0x76 挂一条线上怎么都不认。后来按文里说的开了第二路总线,GPIO27/26 一路、33/32 一路,代码里 &I2Cone 和 &I2Ctwo 别传串,一次就通了。改址焊盘那种板子也试过,只能加一个,超过两个还是得上 TCA9548A。
库文件里写死 wire.begin() 这个坑是真有,我用过一个国产气压计库就是这样,外面怎么改引脚都没用,最后把那行注释掉才走通。Adafruit 这套能直接把 TwoWire 传进 begin(),省事不少。
21/22 被摄像头占了这事我遇到过,当时不知道能改引脚,硬是把摄像头挪走了。