
I2C là gì? Giao tiếp nhiều cảm biến trên cùng bus
Tìm hiểu nguyên lý giao thức I2C (SDA/SCL, địa chỉ 7-bit, ACK/NACK) qua ví dụ thực tế: ghép BMP280 (0x76) và OLED SSD1306 (0x3C) trên cùng 2 dây với ESP32 DevKit.
I2C (Inter-Integrated Circuit) là giao thức giao tiếp nối tiếp 2 dây (SDA - dữ liệu, SCL - xung nhịp) cho phép nhiều thiết bị (master/slave) dùng chung một bus. Mỗi thiết bị slave được nhận diện bằng một địa chỉ 7-bit duy nhất, nhờ đó một board ESP32 có thể đọc đồng thời nhiều cảm biến/màn hình chỉ với 2 dây tín hiệu (cộng nguồn/mass).
Bài này dùng BMP280 (địa chỉ 0x76) và OLED SSD1306 (địa chỉ 0x3C) trên cùng bus GPIO21 (SDA) / GPIO22 (SCL) của ESP32 DevKit để minh hoạ cơ chế chia sẻ bus bằng địa chỉ.
Hướng dẫn chi tiết
Nguyên lý I2C, cách nhiều thiết bị chia sẻ 2 dây SDA/SCL bằng địa chỉ 7-bit, ví dụ thực hành BMP280 + OLED SSD1306 trên ESP32.
1. Giới thiệu
I2C (Inter-Integrated Circuit, do Philips/NXP phát triển) là giao thức giao tiếp nối tiếp đồng bộ dùng 2 dây tín hiệu: SDA (Serial Data) truyền dữ liệu và SCL (Serial Clock) truyền xung nhịp do master (ở đây là ESP32) tạo ra. Cả hai dây đều là open-drain nên bắt buộc phải có điện trở kéo lên (pull-up, thường 4.7kΩ) về nguồn — hầu hết module cảm biến/màn hình giá rẻ đã tích hợp sẵn điện trở này trên board.
Điểm mạnh của I2C là nhiều thiết bị (multi-drop) có thể dùng chung đúng 2 dây SDA/SCL. Master phân biệt từng thiết bị bằng một địa chỉ 7-bit (0x08–0x77) gửi ngay sau bit START.
Thiết bị nào có địa chỉ trùng sẽ trả lời bằng bit ACK (kéo SDA xuống thấp); nếu không có thiết bị nào nhận, master đọc được NACK và biết bus "câm" tại địa chỉ đó. Bài này ghép BMP280 (địa chỉ mặc định 0x76, có thể đổi thành 0x77 bằng chân SDO) và màn hình OLED SSD1306 (địa chỉ cố định 0x3C) trên cùng 2 dây GPIO21 (SDA) / GPIO22 (SCL) của ESP32 DevKit để chứng minh: chỉ cần 2 dây, ESP32 vẫn đọc riêng biệt được cả hai thiết bị nhờ địa chỉ khác nhau.
2. Linh kiện cần có
Linh kiện | Số lượng | Ghi chú |
|---|---|---|
ESP32 DevKit V4 | 1 | Board chính, đóng vai I2C master |
BMP280 (áp suất/nhiệt độ, I2C) | 1 | Địa chỉ 0x76 (mặc định) hoặc 0x77 |
OLED 0.96" 128x64 I2C (SSD1306) | 1 | Địa chỉ 0x3C |
Dây jumper | 4-6 | SDA, SCL, VCC, GND cho mỗi module |
3. Sơ đồ kết nối
BMP280 | ESP32 |
|---|---|
VCC | 3V3 |
GND | GND |
SCL | GPIO22 |
SDA | GPIO21 |
OLED SSD1306 | ESP32 |
|---|---|
VCC | 3V3 |
GND | GND |
SCL | GPIO22 (chung dây với BMP280) |
SDA | GPIO21 (chung dây với BMP280) |
Lưu ý: SDA và SCL của cả hai module nối song song vào cùng 2 dây GPIO21/GPIO22 — đây chính là điểm khác biệt cốt lõi so với SPI, nơi mỗi thiết bị cần một chân CS riêng. Sơ đồ đầy đủ (kèm vị trí chân) xem ở tab Workspace Diagram / Kết nối của project.
4. Code ví dụ #1 — Quét bus và đọc cả hai thiết bị
Sketch dùng Wire.begin(21, 22), quét toàn bộ dải địa chỉ 1–126 để in ra thiết bị nào phản hồi ACK, sau đó khởi tạo song song Adafruit_BMP280 (0x76) và Adafruit_SSD1306 (0x3C) và đọc/hiển thị dữ liệu mỗi 2 giây.
/*
I2C là gì? — Ví dụ #1: Quét bus I2C và đọc 2 thiết bị khác địa chỉ
Board: ESP32 DevKit V4
Thiết bị trên cùng 1 bus I2C (SDA=GPIO21, SCL=GPIO22):
- BMP280 (áp suất/nhiệt độ) -> địa chỉ 0x76 (SDO nối GND) hoặc 0x77 (SDO nối VCC)
- OLED SSD1306 128x64 -> địa chỉ 0x3C
Ý tưởng minh hoạ: nhiều thiết bị có thể dùng chung 2 dây SDA/SCL vì mỗi
thiết bị được phân biệt bằng một địa chỉ I2C 7-bit duy nhất.
*/
#include <Wire.h>
#include <Adafruit_BMP280.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SDA_PIN 21
#define SCL_PIN 22
#define OLED_ADDR 0x3C
#define BMP280_ADDR 0x76
Adafruit_BMP280 bmp;
Adafruit_SSD1306 display(128, 64, &Wire, -1);
bool bmpReady = false;
bool oledReady = false;
void scanBus() {
Serial.println(F("Quet dia chi I2C tren bus..."));
uint8_t found = 0;
for (uint8_t addr = 1; addr < 127; addr++) {
Wire.beginTransmission(addr);
uint8_t err = Wire.endTransmission();
if (err == 0) {
Serial.printf(" -> Tim thay thiet bi tai 0x%02X\n", addr);
found++;
}
}
Serial.printf("Tong: %u thiet bi\n", found);
}
void setup() {
Serial.begin(115200);
delay(300);
Serial.println(F("=== I2C multi-device demo boot ==="));
Wire.begin(SDA_PIN, SCL_PIN);
Wire.setClock(100000); // 100kHz Standard-mode, an toan cho day breadboard dai
scanBus();
oledReady = display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);
if (!oledReady) {
Serial.println(F("[WARN] Khong tim thay OLED tai 0x3C"));
} else {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println(F("I2C multi-device"));
display.display();
}
bmpReady = bmp.begin(BMP280_ADDR);
if (!bmpReady) {
Serial.println(F("[WARN] Khong tim thay BMP280 tai 0x76 (thu 0x77?)"));
}
}
void loop() {
static unsigned long lastRead = 0;
if (millis() - lastRead >= 2000) {
lastRead = millis();
float tempC = bmpReady ? bmp.readTemperature() : NAN;
float pressHpa = bmpReady ? bmp.readPressure() / 100.0F : NAN;
if (bmpReady) {
Serial.printf("BMP280 (0x76): temp=%.2fC pressure=%.2fhPa\n", tempC, pressHpa);
} else {
Serial.println(F("BMP280 (0x76): khong co du lieu"));
}
if (oledReady) {
display.clearDisplay();
display.setCursor(0, 0);
display.println(F("I2C: BMP280 + OLED"));
display.setCursor(0, 16);
if (bmpReady) {
display.printf("T: %.1f C\n", tempC);
display.printf("P: %.1f hPa\n", pressHpa);
} else {
display.println(F("BMP280: N/A"));
}
display.setCursor(0, 48);
display.println(F("addr 0x76 / 0x3C"));
display.display();
}
}
}
5. Code ví dụ #2 — Ứng dụng thực tế: trạm hiển thị áp suất/nhiệt độ
Biến thể thứ hai bỏ qua bước quét thủ công, khởi tạo trực tiếp 2 thiết bị theo địa chỉ đã biết và hiển thị nhiệt độ, áp suất, độ cao ước tính (tính từ áp suất quy chiếu mực nước biển 1013.25 hPa) trực tiếp lên OLED.
/*
I2C là gì? — Ví dụ #2: Ứng dụng thực tế — Trạm hiển thị áp suất/nhiệt độ
Board: ESP32 DevKit V4 + BMP280 (0x76) + OLED SSD1306 (0x3C) trên chung bus I2C.
Không cần Serial để vận hành — toàn bộ dữ liệu hiển thị trực tiếp lên OLED,
minh hoạ ứng dụng thực tế của việc chia sẻ 1 bus cho nhiều cảm biến.
*/
#include <Wire.h>
#include <Adafruit_BMP280.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SDA_PIN 21
#define SCL_PIN 22
#define OLED_ADDR 0x3C
#define BMP280_ADDR 0x76
Adafruit_BMP280 bmp;
Adafruit_SSD1306 display(128, 64, &Wire, -1);
void setup() {
Serial.begin(115200);
delay(300);
Serial.println(F("=== Tram hien thi ap suat/nhiet do (I2C shared bus) ==="));
Wire.begin(SDA_PIN, SCL_PIN);
Wire.setClock(100000);
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
Serial.println(F("[ERROR] OLED khong phan hoi tai 0x3C"));
}
if (!bmp.begin(BMP280_ADDR)) {
Serial.println(F("[ERROR] BMP280 khong phan hoi tai 0x76"));
}
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println(F("Khoi dong tram do..."));
display.display();
}
void loop() {
float tempC = bmp.readTemperature();
float pressHpa = bmp.readPressure() / 100.0F;
float altitude = bmp.readAltitude(1013.25F); // ap suat quy chieu muc nuoc bien
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0, 0);
display.println(F("TRAM DO AP SUAT"));
display.drawLine(0, 10, 127, 10, SSD1306_WHITE);
display.setTextSize(2);
display.setCursor(0, 16);
display.printf("%.1fC\n", tempC);
display.setTextSize(1);
display.setCursor(0, 40);
display.printf("Ap suat: %.1f hPa\n", pressHpa);
display.setCursor(0, 52);
display.printf("Do cao ~%.0f m\n", altitude);
display.display();
delay(1000);
}
6. Ứng dụng thực tế & mở rộng
Vì I2C dùng địa chỉ để phân biệt thiết bị, một bus I2C của ESP32 (bus mặc định Wire, hoặc Wire1 cho bus thứ hai) có thể gắn hàng chục cảm biến/màn hình khác nhau miễn là không trùng địa chỉ. Một số IC cho phép đổi địa chỉ bằng chân cấu hình phần cứng (ví dụ BMP280 đổi 0x76/0x77 qua chân SDO) — hữu ích khi cần gắn 2 module cùng loại lên chung 1 bus.
Ứng dụng thực tế: trạm thời tiết mini, dashboard cảm biến nhiều kênh, module mở rộng I/O qua I2C (PCF8574, MCP23017) đều dựa trên đúng cơ chế địa chỉ hoá này.
7. Lỗi thường gặp
Lỗi | Nguyên nhân | Cách sửa |
|---|---|---|
Quét bus không thấy thiết bị nào | Thiếu điện trở pull-up, hoặc SDA/SCL nối ngược | Kiểm tra module có pull-up sẵn chưa; đảo lại SDA↔SCL |
BMP280 không phản hồi ở 0x76 | Module đang set địa chỉ 0x77 (SDO nối VCC) | Thử lại với |
OLED và BMP280 "đánh nhau", dữ liệu sai lệch | Trùng địa chỉ I2C giữa 2 thiết bị (hiếm với cặp này nhưng phổ biến khi thêm module thứ 3 cùng loại) | Đổi địa chỉ 1 trong 2 module qua chân cấu hình, hoặc dùng bus I2C thứ hai (Wire1) |
Dữ liệu bị nhiễu khi dây dài | Tốc độ I2C 400kHz quá cao cho dây breadboard dài | Hạ về 100kHz bằng |
8. Tổng kết
I2C giải quyết bài toán "nhiều thiết bị, ít dây" bằng cách địa chỉ hoá từng thiết bị trên cùng 2 dây SDA/SCL.
Đánh đổi là tốc độ thấp hơn SPI (thường 100kHz-400kHz so với vài MHz của SPI) và cần quản lý địa chỉ tránh trùng lặp. Đây là lựa chọn mặc định cho các cảm biến/màn hình nhỏ, ít dữ liệu, cần tiết kiệm chân GPIO trên ESP32.