I2C là gì: START, STOP, địa chỉ, ACK và cách đọc ghi thanh ghi trên ESP32
Giải thích giao tiếp I2C ở mức từng bit: hai dây SDA, SCL, điện trở kéo lên, START, địa chỉ, ACK, STOP. Kèm hai chương trình ESP32: tự tạo tín hiệu I2C bằng GPIO và đọc ghi thanh ghi bằng Wire.
I2C cho phép một vi điều khiển nói chuyện với hàng chục cảm biến, màn hình, bộ nhớ chỉ bằng hai dây. Phần lớn người dùng chỉ gọi thư viện của từng module mà không biết trên hai dây đó đang diễn ra điều gì, nên khi gặp lỗi thì khó tìm nguyên nhân. Bài viết giải thích tín hiệu I2C ở mức từng bit: START, địa chỉ, ACK, STOP; sau đó dùng ESP32 tự tạo các tín hiệu này bằng tay và đọc ghi thanh ghi của một IC chỉ với thư viện Wire.
Bài này đi vào nguyên lý. Cách quét địa chỉ, đổi chân SDA, SCL và nối nhiều module đã có trong bài giao tiếp I2C trên ESP32.
1. Bus I2C gồm những gì?
- SDA mang dữ liệu, SCL mang xung clock. Cả hai dây dùng chung cho mọi thiết bị trên bus.
- Thiết bị chủ (master, ở đây là ESP32) phát clock và quyết định nói chuyện với ai. Thiết bị tớ (slave) chỉ trả lời khi được gọi đúng địa chỉ.
- Các chân I2C thuộc loại cực máng hở: thiết bị chỉ kéo được dây xuống 0 V hoặc nhả ra. Mức cao do điện trở kéo lên tạo ra. Nhờ vậy hai thiết bị không bao giờ "đấu" nhau trên cùng một dây.
- Điện trở kéo lên thường là 4,7 kΩ ở 100 kHz. Đa số module đã hàn sẵn; nối nhiều module thì các điện trở này mắc song song và giá trị tổng giảm xuống.
- Tốc độ chuẩn là 100 kbit/s, chế độ nhanh là 400 kbit/s. Bus chỉ dành cho khoảng cách ngắn, trong một bo mạch hoặc vài chục centimet dây.
2. Tín hiệu trên hai dây
- START: SDA đi xuống trong lúc SCL đang cao. Mọi thiết bị trên bus bắt đầu lắng nghe.
- Bit dữ liệu: SDA chỉ được đổi mức khi SCL thấp; bên nhận đọc SDA khi SCL cao. Mỗi byte gửi bit cao nhất trước.
- Byte địa chỉ: byte đầu tiên gồm 7 bit địa chỉ và 1 bit hướng: 0 là ghi, 1 là đọc. Địa chỉ 0x48 kèm bit ghi thành byte 0x90.
- ACK: sau mỗi byte có nhịp clock thứ 9. Bên nhận kéo SDA xuống thấp để báo đã nhận. Nếu SDA vẫn cao (NACK) thì không có thiết bị nào ở địa chỉ đó, hoặc thiết bị từ chối byte vừa gửi.
- STOP: SDA đi lên trong lúc SCL đang cao. Bus trở về trạng thái rảnh.
Địa chỉ 7 bit cho 128 giá trị, trong đó dùng được từ 0x08 đến 0x77. Địa chỉ của mỗi IC do nhà sản xuất đặt; một số IC có chân chọn để đổi giữa vài địa chỉ.
3. Đọc và ghi thanh ghi
Hầu hết IC dùng I2C được tổ chức thành các thanh ghi, mỗi thanh ghi có một số thứ tự. Mọi thao tác quy về hai kiểu:
- Ghi: START, địa chỉ kèm bit ghi, số thanh ghi, các byte dữ liệu, STOP.
- Đọc: trước hết ghi số thanh ghi muốn đọc; sau đó phát START lần nữa (START lặp lại), gửi địa chỉ kèm bit đọc rồi nhận dữ liệu. Thiết bị chủ trả NACK ở byte cuối để báo không cần đọc thêm.
Số thanh ghi, số byte và ý nghĩa từng bit phải tra trong tài liệu kỹ thuật của IC. Thư viện của từng module thực chất chỉ gói các thao tác này lại.
4. Chuẩn bị
- Một mạch ESP32 DevKit V1 30 chân (hoặc bản dùng chip CH340).
- Một module ADS1115 làm thiết bị tớ để thử. Chương trình 1 chạy được với bất kỳ module I2C nào, ví dụ màn hình OLED 0.96 inch.
- Breadboard và dây cắm.
- Arduino IDE 2.x với gói esp32 by Espressif Systems (bài dùng phiên bản 3.3.10). Không cần cài thêm thư viện.
5. Sơ đồ nối
- Cấp nguồn 3,3 V cho module để mức tín hiệu trên SDA, SCL hợp với ESP32.
- Chân ADDR để trống hoặc nối GND thì địa chỉ là 0x48.
- Nối A0 với 3V3 để kết quả đo ra khoảng 3300 mV, dễ kiểm tra.
6. Chương trình 1: tự tạo tín hiệu I2C bằng tay
Chương trình không dùng thư viện Wire. Nó điều khiển trực tiếp hai chân GPIO để tạo START, gửi từng bit của byte địa chỉ, đọc bit ACK rồi tạo STOP, lần lượt cho mọi địa chỉ từ 0x08 đến 0x77. Mỗi hàm trong chương trình ứng với một phần của hình ở mục 2.
/*
PYWORLD.VN - I2C trên ESP32: tự tạo từng bit bằng GPIO để thấy START, byte địa chỉ, bit ACK và STOP
Chương trình không dùng thư viện Wire; nó gọi lần lượt các địa chỉ 0x08 đến 0x77 và xem thiết bị nào trả lời ACK
SDA = GPIO21, SCL = GPIO22; hai dây cần điện trở kéo lên 3,3 V (đa số module I2C đã có sẵn)
*/
#define SDA_PIN 21
#define SCL_PIN 22
const int NUA_CHU_KY_US = 10; // Nửa chu kỳ clock: 10 micro giây, tức khoảng 50 kHz
// Chân cực máng hở: ghi LOW là kéo dây xuống 0 V, ghi HIGH là nhả dây để điện trở kéo lên
void datSda(bool muc) { digitalWrite(SDA_PIN, muc); }
void datScl(bool muc) { digitalWrite(SCL_PIN, muc); }
void cho() { delayMicroseconds(NUA_CHU_KY_US); }
// START: SDA đi xuống trong lúc SCL đang ở mức cao
void batDau() {
datSda(HIGH);
datScl(HIGH);
cho();
datSda(LOW);
cho();
datScl(LOW);
cho();
}
// STOP: SDA đi lên trong lúc SCL đang ở mức cao
void ketThuc() {
datSda(LOW);
cho();
datScl(HIGH);
cho();
datSda(HIGH);
cho();
}
// Gửi 8 bit, bit cao trước; SDA chỉ đổi khi SCL thấp. Trả về true nếu thiết bị kéo SDA xuống ở nhịp thứ 9 (ACK)
bool guiByte(uint8_t giaTri) {
for (int i = 7; i >= 0; i--) {
datSda((giaTri >> i) & 1);
cho();
datScl(HIGH);
cho();
datScl(LOW);
}
datSda(HIGH); // Nhả SDA để thiết bị trả lời
cho();
datScl(HIGH);
cho();
bool coAck = digitalRead(SDA_PIN) == LOW;
datScl(LOW);
cho();
return coAck;
}
void quetDiaChi() {
int soThietBi = 0;
Serial.println("\nQuet dia chi 0x08 den 0x77 (byte gui di = dia chi dich trai 1 bit, bit cuoi 0 la ghi)");
for (uint8_t diaChi = 0x08; diaChi <= 0x77; diaChi++) {
uint8_t byteDiaChi = diaChi << 1; // 7 bit địa chỉ + bit ghi (0)
batDau();
bool coAck = guiByte(byteDiaChi);
ketThuc();
if (coAck) {
soThietBi++;
Serial.printf(" 0x%02X: START | ", diaChi);
for (int i = 7; i >= 1; i--) {
Serial.print((byteDiaChi >> i) & 1);
}
Serial.println(" | 0 (ghi) | ACK | STOP");
}
delay(2);
}
Serial.printf("Tim thay %d thiet bi\n", soThietBi);
}
void setup() {
Serial.begin(115200);
digitalWrite(SDA_PIN, HIGH);
digitalWrite(SCL_PIN, HIGH);
pinMode(SDA_PIN, OUTPUT_OPEN_DRAIN | PULLUP);
pinMode(SCL_PIN, OUTPUT_OPEN_DRAIN | PULLUP);
delay(100);
if (digitalRead(SDA_PIN) == LOW || digitalRead(SCL_PIN) == LOW) {
Serial.println("\nSDA hoac SCL dang bi keo xuong muc thap: kiem tra day noi va dien tro keo len");
}
}
void loop() {
quetDiaChi();
delay(5000);
}
Tự tạo tín hiệu như vậy chỉ để học. Trong ứng dụng thật nên dùng bộ I2C phần cứng qua thư viện Wire: nhanh hơn, đúng thời gian hơn và xử lý được các tình huống như thiết bị tớ giữ SCL ở mức thấp để xin thêm thời gian.
7. Chương trình 2: đọc ghi thanh ghi bằng Wire
ADS1115 có bốn thanh ghi 16 bit; bài dùng hai: thanh ghi kết quả (0x00) và thanh ghi cấu hình (0x01). Chương trình đọc cấu hình hiện tại, ghi giá trị 0xC383 để bắt đầu một lần đo kênh A0 ở thang ±4,096 V, chờ 10 ms rồi đọc kết quả.
/*
PYWORLD.VN - I2C trên ESP32: đọc và ghi thanh ghi của một thiết bị chỉ bằng thư viện Wire
Ví dụ với module ADS1115 ở địa chỉ 0x48: đọc thanh ghi cấu hình, ghi cấu hình đo kênh A0 rồi đọc kết quả
SDA = GPIO21, SCL = GPIO22; mỗi bước in mã lỗi của Wire để biết thiết bị có trả lời hay không
*/
#include <Wire.h>
#define SDA_PIN 21
#define SCL_PIN 22
const uint8_t DIA_CHI = 0x48;
const uint8_t THANH_GHI_KET_QUA = 0x00;
const uint8_t THANH_GHI_CAU_HINH = 0x01;
// Bắt đầu một lần đo, kênh A0 so với GND, thang ±4,096 V, chế độ đo một lần, 128 mẫu mỗi giây
const uint16_t CAU_HINH_DO_A0 = 0xC383;
const float MV_MOI_BUOC = 0.125; // Ở thang ±4,096 V, mỗi bước của kết quả 16 bit là 0,125 mV
const char *tenLoi(uint8_t ma) {
switch (ma) {
case 0: return "thanh cong";
case 1: return "du lieu dai hon bo dem";
case 2: return "khong co ACK o byte dia chi";
case 3: return "khong co ACK o byte du lieu";
case 5: return "qua thoi gian";
default: return "loi khac";
}
}
// Ghi: START, địa chỉ + bit ghi, số thanh ghi, byte cao, byte thấp, STOP
uint8_t ghiThanhGhi(uint8_t thanhGhi, uint16_t giaTri) {
Wire.beginTransmission(DIA_CHI);
Wire.write(thanhGhi);
Wire.write((uint8_t)(giaTri >> 8));
Wire.write((uint8_t)(giaTri & 0xFF));
return Wire.endTransmission();
}
// Đọc: START, địa chỉ + bit ghi, số thanh ghi, START lặp lại, địa chỉ + bit đọc, nhận 2 byte, STOP
bool docThanhGhi(uint8_t thanhGhi, uint16_t &giaTri) {
Wire.beginTransmission(DIA_CHI);
Wire.write(thanhGhi);
uint8_t ma = Wire.endTransmission(false); // false: chưa gửi STOP, giữ bus cho lần đọc ngay sau
if (ma != 0) {
Serial.printf(" Chon thanh ghi 0x%02X: %s\n", thanhGhi, tenLoi(ma));
return false;
}
if (Wire.requestFrom(DIA_CHI, (uint8_t)2) != 2) {
Serial.println(" Khong nhan du 2 byte");
return false;
}
uint8_t byteCao = Wire.read();
uint8_t byteThap = Wire.read();
giaTri = ((uint16_t)byteCao << 8) | byteThap;
return true;
}
void setup() {
Serial.begin(115200);
Wire.begin(SDA_PIN, SCL_PIN, 100000);
// Bước 1: gọi địa chỉ, không gửi dữ liệu, để xem thiết bị có trả lời ACK không
Wire.beginTransmission(DIA_CHI);
uint8_t ma = Wire.endTransmission();
Serial.printf("\nGoi dia chi 0x%02X: %s\n", DIA_CHI, tenLoi(ma));
// Bước 2: đọc thanh ghi cấu hình
uint16_t cauHinh = 0;
if (docThanhGhi(THANH_GHI_CAU_HINH, cauHinh)) {
Serial.printf("Thanh ghi cau hinh dang la 0x%04X\n", cauHinh);
}
}
void loop() {
// Bước 3: ghi cấu hình để bắt đầu một lần đo, chờ đo xong rồi đọc thanh ghi kết quả
uint8_t ma = ghiThanhGhi(THANH_GHI_CAU_HINH, CAU_HINH_DO_A0);
if (ma != 0) {
Serial.printf("Ghi cau hinh: %s\n", tenLoi(ma));
delay(2000);
return;
}
delay(10); // Ở 128 mẫu mỗi giây, một lần đo mất khoảng 8 ms
uint16_t ketQua = 0;
if (docThanhGhi(THANH_GHI_KET_QUA, ketQua)) {
int16_t coDau = (int16_t)ketQua; // Kết quả là số có dấu 16 bit
Serial.printf("Ket qua 0x%04X = %d buoc = %.1f mV\n", ketQua, coDau, coDau * MV_MOI_BUOC);
}
delay(1000);
}
Hàm endTransmission() trả về một mã cho biết lỗi xảy ra ở bước nào:
| Mã | Ý nghĩa | Thường do |
|---|---|---|
| 0 | Thành công | Thiết bị đã ACK mọi byte |
| 1 | Dữ liệu dài hơn bộ đệm | Ghi quá nhiều byte trong một lần |
| 2 | Không có ACK ở byte địa chỉ | Sai địa chỉ, sai dây, thiết bị chưa có nguồn |
| 3 | Không có ACK ở byte dữ liệu | Thiết bị từ chối số thanh ghi hoặc dữ liệu |
| 5 | Quá thời gian | SDA hoặc SCL bị giữ ở mức thấp |
8. Kết quả mong đợi
- Chương trình 1: mở Tools > Serial Monitor ở 115200 baud. Mỗi 5 giây chương trình in các địa chỉ có trả lời, kèm dãy bit đã gửi. Với ADS1115 là 0x48; với màn hình OLED thường là 0x3C.
- Chương trình 2: ngay sau khi cấp nguồn, thanh ghi cấu hình của ADS1115 có giá trị 0x8583. Kết quả đo ở quanh 3300 mV khi A0 nối 3V3.
- Rút dây SDA trong lúc chương trình 2 đang chạy: xuất hiện thông báo không có ACK ở byte địa chỉ.
9. Xử lý lỗi thường gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| Không tìm thấy thiết bị nào | Nối nhầm SDA với SCL, thiếu nguồn hoặc thiếu GND | Kiểm tra bốn dây; đo điện áp trên SDA, SCL khi rảnh phải gần 3,3 V |
| Báo SDA hoặc SCL bị kéo xuống mức thấp | Chạm chập, hoặc một thiết bị đang giữ bus | Tháo từng module để tìm; ngắt nguồn rồi cấp lại |
| Tìm thấy mọi địa chỉ | SDA bị nối xuống GND nên lúc nào cũng đọc ra ACK | Kiểm tra dây SDA và điện trở kéo lên |
| Lúc được lúc không | Dây dài, thiếu điện trở kéo lên, tốc độ quá cao | Rút ngắn dây, thêm điện trở 4,7 kΩ lên 3,3 V, dùng 100 kHz |
| Hai module giống nhau không chạy cùng lúc | Trùng địa chỉ | Đổi địa chỉ bằng chân chọn của module, hoặc dùng bus I2C thứ hai |
| Thanh ghi cấu hình không phải 0x8583 | Module chưa bị ngắt nguồn từ lần chạy trước nên còn giữ cấu hình cũ | Bình thường; ngắt nguồn rồi cấp lại nếu muốn thấy giá trị mặc định |
Xem thêm