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ì?

Sơ đồ bus I2C: ESP32 làm thiết bị chủ và ba thiết bị tớ nối song song vào hai dây SDA, SCL, mỗi dây có một điện trở kéo lên 3,3 V
Mọi thiết bị nối song song vào hai dây SDA và SCL; mỗi dây có một điện trở kéo lên.
  • 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

Giản đồ tín hiệu I2C khi gọi địa chỉ 0x48: điều kiện START, bảy bit địa chỉ 1001000, bit ghi bằng 0, bit ACK do thiết bị tớ kéo xuống và điều kiện STOP trên hai dây SCL, SDA
START, 7 bit địa chỉ, bit ghi, ACK và STOP khi gọi địa chỉ 0x48 (hình vẽ minh họa).
  • 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

Các bước ghi và đọc thanh ghi qua I2C: ghi gồm START, địa chỉ kèm bit ghi, số thanh ghi, hai byte dữ liệu, STOP; đọc gồm chọn thanh ghi, START lặp lại, địa chỉ kèm bit đọc, nhận hai byte rồi STOP
Trình tự ghi và đọc một thanh ghi 16 bit; màu cho biết bên nào đang gửi.

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ị

5. Sơ đồ nối

Sơ đồ nối ESP32 DevKit V1 với module ADS1115 qua I2C: VDD nối 3V3, GND nối GND, SCL nối D22, SDA nối D21, chân A0 nối 3V3 làm điện áp thử
SDA nối D21, SCL nối D22, nguồn 3,3 V; A0 nối 3V3 để có điện áp biết trước.
  • 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ĩaThường do
0Thành côngThiết bị đã ACK mọi byte
1Dữ liệu dài hơn bộ đệmGhi quá nhiều byte trong một lần
2Không có ACK ở byte địa chỉSai địa chỉ, sai dây, thiết bị chưa có nguồn
3Không có ACK ở byte dữ liệuThiết bị từ chối số thanh ghi hoặc dữ liệu
5Quá thời gianSDA hoặc SCL bị giữ ở mức thấp

8. Kết quả mong đợi

Hình minh họa Serial Monitor của hai chương trình I2C trên ESP32: dãy bit khi gọi địa chỉ 0x48 có ACK, giá trị thanh ghi cấu hình 0x8583 và kết quả đo khoảng 3300 mV
Kết quả mong đợi trên Serial Monitor của hai chương trình (hình minh họa, kết quả đo là số ví dụ).
  1. 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.
  2. 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.
  3. 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ượngNguyên nhân thường gặpCách xử lý
Không tìm thấy thiết bị nàoNối nhầm SDA với SCL, thiếu nguồn hoặc thiếu GNDKiể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ấpChạm chập, hoặc một thiết bị đang giữ busThá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 ACKKiểm tra dây SDA và điện trở kéo lên
Lúc được lúc khôngDây dài, thiếu điện trở kéo lên, tốc độ quá caoRú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úcTrù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 0x8583Module 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

10. Xem thêm


Tin tức liên quan

Bluetooth Low Energy (BLE) là gì: quảng bá, GATT, dịch vụ, đặc tính và ESP32
Bluetooth Low Energy (BLE) là gì: quảng bá, GATT, dịch vụ, đặc tính và ESP32

Giải thích Bluetooth Low Energy: quảng bá và kết nối, cấu trúc GATT gồm dịch vụ, đặc tính, mô tả, UUID. Hướng dẫn cho ESP32 làm thiết bị BLE có đặc tính đọc, ghi, thông báo và thử bằng điện thoại.

Điều khiển hồng ngoại hoạt động thế nào: sóng mang 38 kHz, giao thức NEC và ESP32
Điều khiển hồng ngoại hoạt động thế nào: sóng mang 38 kHz, giao thức NEC và ESP32

Giải thích điều khiển hồng ngoại: sóng mang 38 kHz, mắt thu và giao thức NEC với khung 32 bit, mã lặp. Hướng dẫn tự giải mã phím điều khiển và tự phát lệnh bằng ESP32, không dùng thư viện ngoài.

RF 433 MHz là gì: điều chế ASK/OOK và tự viết giao thức phát, thu bằng ESP32
RF 433 MHz là gì: điều chế ASK/OOK và tự viết giao thức phát, thu bằng ESP32

Giải thích sóng RF 433 MHz và điều chế ASK/OOK, cách mã hóa bit bằng độ rộng xung và khung tin 24 bit. Hướng dẫn tự viết chương trình phát và thu trên hai mạch ESP32, không dùng thư viện ngoài.

Vòng dòng 4-20 mA là gì và cách đọc cảm biến 4-20 mA bằng ESP32
Vòng dòng 4-20 mA là gì và cách đọc cảm biến 4-20 mA bằng ESP32

Giải thích vòng dòng 4-20 mA: vì sao dùng dòng điện, vì sao bắt đầu từ 4 mA, cách đổi ra giá trị đo và ngưỡng báo lỗi. Hướng dẫn đọc bằng ESP32 qua điện trở shunt 120 Ω, với ADC có sẵn và với ADS1115.

CAN bus (phần 2): bộ lọc nhận, tốc độ bit và xử lý lỗi bus-off trên ESP32
CAN bus (phần 2): bộ lọc nhận, tốc độ bit và xử lý lỗi bus-off trên ESP32

Phần 2 về CAN bus với ESP32: đặt bộ lọc nhận theo mã lọc và mặt nạ, chọn tốc độ bit theo chiều dài dây, hiểu ba trạng thái lỗi của nút và viết chương trình theo dõi bộ đếm lỗi, tự hồi phục sau bus-off.

CAN bus (phần 1): tín hiệu, khung tin, phân xử ưu tiên và gửi nhận bằng ESP32
CAN bus (phần 1): tín hiệu, khung tin, phân xử ưu tiên và gửi nhận bằng ESP32

Giải thích CAN bus: hai dây CAN_H, CAN_L, mức trội và mức lặn, khung tin có ID 11 bit, phân xử ưu tiên, CRC và ACK. Kèm hai chương trình ESP32 dùng bộ điều khiển TWAI: tự kiểm tra và hai nút gửi nhận.

Modbus TCP/IP (phần 2): ESP32 làm máy khách và cổng chuyển đổi Modbus TCP sang RTU
Modbus TCP/IP (phần 2): ESP32 làm máy khách và cổng chuyển đổi Modbus TCP sang RTU

Hướng dẫn dùng ESP32 làm máy khách Modbus TCP đọc thanh ghi của một thiết bị trong mạng, và làm cổng chuyển đổi Modbus TCP sang Modbus RTU qua RS-485: cách đổi khung, mã thiết bị, thời gian chờ, mã lỗi 0x0B.

Modbus TCP/IP (phần 1): phần đầu MBAP và ESP32 làm máy chủ qua Wi-Fi
Modbus TCP/IP (phần 1): phần đầu MBAP và ESP32 làm máy chủ qua Wi-Fi

Giải thích Modbus TCP/IP: phần đầu MBAP, cổng 502, điểm khác với Modbus RTU. Kèm chương trình ESP32 làm máy chủ Modbus TCP qua Wi-Fi với 10 thanh ghi giữ và 8 cuộn, tự phân tích khung, không dùng thư viện.


Bình luận
Đã thêm vào giỏ hàng