Modbus RTU (phần 1): khung tin, mã hàm, CRC và ESP32 làm thiết bị chủ

Giải thích Modbus RTU ở mức từng byte: khung tin, bốn vùng dữ liệu, mã hàm đọc ghi, khung báo lỗi. Kèm chương trình ESP32 làm thiết bị chủ đọc thanh ghi và ghi cuộn qua RS-485, không dùng thư viện.

Modbus RTU là giao thức công nghiệp phổ biến nhất chạy trên RS-485: biến tần, đồng hồ đo điện, bộ điều khiển nhiệt độ, cảm biến công nghiệp phần lớn đều hỗ trợ. Giao thức này đơn giản đến mức có thể tự viết trong vài chục dòng lệnh, và hiểu nó ở mức từng byte giúp đọc được tài liệu của bất kỳ thiết bị Modbus nào. Bài viết giải thích khung tin, bốn vùng dữ liệu, các mã hàm thường dùng và khung báo lỗi, rồi dùng ESP32 làm thiết bị chủ (master) tự đóng khung và tính CRC, không dùng thư viện.

Đây là phần 1. Phần 2 dùng ESP32 làm thiết bị tớ (slave); hai phần ghép lại thành một bộ thử nghiệm hoàn chỉnh. Lớp dây dẫn RS-485 và mã CRC-16 đã có bài riêng trong loạt này.

1. Modbus RTU là gì?

  • Trên một đường dây có một thiết bị chủ và nhiều thiết bị tớ, mỗi thiết bị tớ có một địa chỉ từ 1 đến 247.
  • Chỉ thiết bị chủ được khởi đầu: nó gửi một khung hỏi tới một địa chỉ; thiết bị tớ có địa chỉ đó trả lời bằng một khung. Thiết bị tớ không bao giờ tự gửi.
  • Dữ liệu của thiết bị tớ được xếp thành các bảng thanh ghi và bit. Thiết bị chủ đọc hoặc ghi các bảng này bằng mã hàm.
  • "RTU" là kiểu truyền dạng nhị phân, kiểm tra lỗi bằng CRC-16. Lớp dưới thường là RS-485 với UART 8 bit.

2. Cấu trúc khung tin

Cấu trúc khung tin Modbus RTU: khoảng lặng ít nhất 3,5 ký tự, một byte địa chỉ, một byte mã hàm, phần dữ liệu, hai byte CRC-16 gửi byte thấp trước, rồi lại khoảng lặng
Khung tin Modbus RTU: địa chỉ, mã hàm, dữ liệu và CRC, tách nhau bằng khoảng lặng.
  • Địa chỉ (1 byte): thiết bị tớ được hỏi. Khung trả lời mang lại chính địa chỉ đó.
  • Mã hàm (1 byte): việc cần làm.
  • Dữ liệu: tùy mã hàm. Số 16 bit gửi byte cao trước.
  • CRC-16 (2 byte): tính trên mọi byte đứng trước, gửi byte thấp trước.
  • Khoảng lặng: Modbus RTU không có byte bắt đầu hay độ dài. Một khung kết thúc khi đường dây im lặng ít nhất 3,5 ký tự, khoảng 4 ms ở 9600 baud. Các byte trong một khung phải đi liền nhau.

3. Bốn vùng dữ liệu và mã hàm

Bốn vùng dữ liệu của Modbus gồm cuộn, đầu vào số, thanh ghi đầu vào và thanh ghi giữ, kèm các mã hàm đọc ghi tương ứng 0x01, 0x05, 0x0F, 0x02, 0x04, 0x03, 0x06, 0x10
Bốn bảng dữ liệu và các mã hàm đọc, ghi của từng bảng.
Mã hàmViệc làmDữ liệu trong khung hỏi
0x01Đọc cuộnCuộn đầu, số lượng
0x02Đọc đầu vào sốĐầu vào đầu, số lượng
0x03Đọc thanh ghi giữThanh ghi đầu, số lượng (tối đa 125)
0x04Đọc thanh ghi đầu vàoThanh ghi đầu, số lượng
0x05Ghi một cuộnSố cuộn, giá trị FF 00 (bật) hoặc 00 00 (tắt)
0x06Ghi một thanh ghi giữSố thanh ghi, giá trị
0x10Ghi nhiều thanh ghi giữThanh ghi đầu, số lượng, số byte, các giá trị
  • Mỗi vùng đánh địa chỉ riêng, bắt đầu từ 0. Nhiều tài liệu ghi theo kiểu cũ, ví dụ 40001: chữ số đầu chỉ vùng (4 là thanh ghi giữ), phần còn lại trừ đi 1 là địa chỉ thật. 40001 là thanh ghi giữ có địa chỉ 0.
  • Một thanh ghi chỉ là số 16 bit. Số thực thường được nhân 10 hoặc 100 để gửi dạng số nguyên; hệ số do nhà sản xuất quy định.

4. Ví dụ: đọc bốn thanh ghi giữ

Ví dụ khung hỏi và khung trả lời của mã hàm 0x03 trong Modbus RTU: hỏi 01 03 00 00 00 04 44 09 và trả lời 01 03 08 kèm tám byte dữ liệu của bốn thanh ghi 285, 652, 42, 250 cùng hai byte CRC
Khung hỏi 8 byte và khung trả lời 13 byte khi đọc bốn thanh ghi giữ.
  • Khung hỏi luôn dài 8 byte: địa chỉ, mã hàm, thanh ghi đầu (2 byte), số lượng (2 byte), CRC (2 byte).
  • Khung trả lời có thêm một byte cho biết số byte dữ liệu, bằng hai lần số thanh ghi.

5. Ghi một cuộn và khung báo lỗi

Ví dụ khung ghi một cuộn 01 05 00 00 FF 00 8C 3A, khung hỏi sai địa chỉ thanh ghi và khung báo lỗi 01 83 02 C0 F1, kèm bảng bốn mã lỗi Modbus từ 01 đến 04
Lệnh ghi được xác nhận bằng chính khung đó; yêu cầu sai được trả lời bằng khung báo lỗi.
  • Với lệnh ghi một cuộn hoặc một thanh ghi, thiết bị tớ gửi lại nguyên khung để xác nhận.
  • Khi yêu cầu không thực hiện được, thiết bị tớ trả lời bằng mã hàm cộng 0x80 và một byte mã lỗi. Ví dụ hỏi bằng mã hàm 0x03 mà sai địa chỉ thì nhận về 0x83 và mã lỗi 02.
  • Khi khung hỏi sai CRC hoặc sai địa chỉ, thiết bị tớ im lặng. Thiết bị chủ nhận biết bằng thời gian chờ.

6. Chuẩn bị

  • Một mạch ESP32 DevKit V1 30 chân (hoặc bản dùng chip CH340) làm thiết bị chủ, và một module chuyển đổi RS-485.
  • Một thiết bị tớ Modbus RTU để hỏi: thiết bị có sẵn, hoặc mạch ESP32 thứ hai chạy chương trình của phần 2.
  • 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.

7. Sơ đồ nối

Sơ đồ khối ESP32 làm thiết bị chủ Modbus RTU: ESP32 nối module MAX485, cặp dây xoắn A và B nối sang module MAX485 và ESP32 làm thiết bị tớ địa chỉ 1, kèm bảng dây nối DI, RO, DE, RE, GND, VCC
Hai nút RS-485; mỗi nút nối module như trong bài RS-485.
  • Mỗi nút nối module RS-485 đúng như bài RS-485: DI vào TX2, RO vào RX2 (qua mạch chia áp nếu module chạy 5 V), DE và RE nối chung vào D4.
  • Thiết bị chủ và thiết bị tớ phải cùng tốc độ baud và cùng định dạng khung. Bài dùng 9600 baud, 8N1. Tiêu chuẩn Modbus lấy 8E1 làm mặc định; nhiều thiết bị cho chọn cả hai, xem tài liệu của thiết bị.

8. Chương trình thiết bị chủ

Chương trình mỗi 2 giây đọc bốn thanh ghi giữ của thiết bị tớ địa chỉ 1 và in cả khung gửi, khung nhận. Cứ 5 lần đọc, nó đổi trạng thái cuộn 0 bằng mã hàm 0x05. Mọi khung trả lời đều được kiểm tra độ dài, CRC, địa chỉ và mã hàm trước khi dùng.

/*
  PYWORLD.VN - Modbus RTU với ESP32 làm thiết bị chủ (master): tự đóng khung tin và tính CRC, không dùng thư viện
  Mỗi 2 giây đọc 4 thanh ghi giữ (mã hàm 0x03) của thiết bị tớ địa chỉ 1; mỗi 5 lần đọc thì đổi trạng thái cuộn 0 (mã hàm 0x05)
  Module RS-485: DI = TX2 (GPIO17), RO = RX2 (GPIO16), DE và RE = D4; khung truyền 9600 baud, 8N1
*/

#define RX2_PIN 16
#define TX2_PIN 17
#define DE_RE_PIN 4

const uint32_t TOC_DO_BAUD = 9600;
const uint8_t DIA_CHI_TO = 1;
const unsigned long THOI_GIAN_CHO_TRA_LOI_MS = 500;
const unsigned long KHOANG_LANG_MS = 5;   // Im lặng dài hơn 3,5 ký tự (khoảng 4 ms ở 9600 baud) là hết một khung

uint16_t tinhCrc16(const uint8_t *duLieu, size_t soByte) {
  uint16_t crc = 0xFFFF;
  for (size_t i = 0; i < soByte; i++) {
    crc ^= duLieu[i];
    for (int k = 0; k < 8; k++) {
      crc = (crc & 0x0001) ? (crc >> 1) ^ 0xA001 : crc >> 1;
    }
  }
  return crc;
}

void inKhung(const char *nhan, const uint8_t *khung, int soByte) {
  Serial.print(nhan);
  for (int i = 0; i < soByte; i++) {
    Serial.printf(" %02X", khung[i]);
  }
  Serial.println();
}

// Thêm 2 byte CRC (byte thấp trước) rồi phát khung lên đường dây RS-485
void guiKhung(uint8_t *khung, int soByte) {
  uint16_t crc = tinhCrc16(khung, soByte);
  khung[soByte] = crc & 0xFF;
  khung[soByte + 1] = crc >> 8;

  while (Serial2.available()) {
    Serial2.read();               // Bỏ dữ liệu cũ còn sót
  }
  delay(KHOANG_LANG_MS);          // Khoảng lặng trước khung
  digitalWrite(DE_RE_PIN, HIGH);
  Serial2.write(khung, soByte + 2);
  Serial2.flush();
  digitalWrite(DE_RE_PIN, LOW);
  inKhung("Gui :", khung, soByte + 2);
}

// Nhận một khung: chờ byte đầu trong thời gian giới hạn, sau đó gom byte cho tới khi đường dây im lặng
int nhanKhung(uint8_t *khung, int toiDa) {
  int soByte = 0;
  unsigned long batDau = millis();
  while (!Serial2.available()) {
    if (millis() - batDau > THOI_GIAN_CHO_TRA_LOI_MS) {
      return 0;
    }
    delay(1);
  }
  unsigned long lucNhanCuoi = millis();
  while (millis() - lucNhanCuoi < KHOANG_LANG_MS) {
    if (Serial2.available()) {
      uint8_t giaTri = Serial2.read();
      if (soByte < toiDa) {
        khung[soByte++] = giaTri;
      }
      lucNhanCuoi = millis();
    }
  }
  return soByte;
}

const char *tenLoiModbus(uint8_t ma) {
  switch (ma) {
    case 0x01: return "ma ham khong duoc ho tro";
    case 0x02: return "dia chi thanh ghi khong hop le";
    case 0x03: return "gia tri khong hop le";
    case 0x04: return "thiet bi gap su co";
    default: return "loi khac";
  }
}

// Kiểm tra phần chung của mọi khung trả lời: độ dài, CRC, địa chỉ, mã hàm hoặc mã lỗi
bool kiemTraTraLoi(const uint8_t *khung, int soByte, uint8_t diaChi, uint8_t maHam) {
  if (soByte == 0) {
    Serial.println("  Khong co tra loi (qua thoi gian)");
    return false;
  }
  inKhung("Nhan:", khung, soByte);
  if (soByte < 5) {
    Serial.println("  Khung tra loi qua ngan");
    return false;
  }
  uint16_t crcNhan = khung[soByte - 2] | (khung[soByte - 1] << 8);
  if (tinhCrc16(khung, soByte - 2) != crcNhan) {
    Serial.println("  Sai CRC: bo khung");
    return false;
  }
  if (khung[0] != diaChi) {
    Serial.println("  Tra loi tu dia chi khac");
    return false;
  }
  if (khung[1] == (maHam | 0x80)) {
    Serial.printf("  Thiet bi bao loi 0x%02X: %s\n", khung[2], tenLoiModbus(khung[2]));
    return false;
  }
  if (khung[1] != maHam) {
    Serial.println("  Ma ham tra loi khong khop");
    return false;
  }
  return true;
}

// Mã hàm 0x03: đọc nhiều thanh ghi giữ. Khung hỏi: địa chỉ, 03, thanh ghi đầu (2 byte), số lượng (2 byte)
bool docThanhGhiGiu(uint8_t diaChi, uint16_t thanhGhiDau, uint16_t soLuong, uint16_t *ketQua) {
  uint8_t khung[64];
  khung[0] = diaChi;
  khung[1] = 0x03;
  khung[2] = thanhGhiDau >> 8;    // Số 16 bit trong Modbus gửi byte cao trước
  khung[3] = thanhGhiDau & 0xFF;
  khung[4] = soLuong >> 8;
  khung[5] = soLuong & 0xFF;
  guiKhung(khung, 6);

  int soByte = nhanKhung(khung, sizeof(khung));
  if (!kiemTraTraLoi(khung, soByte, diaChi, 0x03)) {
    return false;
  }
  // Khung trả lời: địa chỉ, 03, số byte dữ liệu, dữ liệu (2 byte mỗi thanh ghi), CRC
  if (khung[2] != soLuong * 2 || soByte != 5 + soLuong * 2) {
    Serial.println("  So byte du lieu khong khop");
    return false;
  }
  for (uint16_t i = 0; i < soLuong; i++) {
    ketQua[i] = (khung[3 + i * 2] << 8) | khung[4 + i * 2];
  }
  return true;
}

// Mã hàm 0x05: ghi một cuộn (đầu ra số). Giá trị 0xFF00 là bật, 0x0000 là tắt; thiết bị tớ gửi lại nguyên khung
bool ghiMotCuon(uint8_t diaChi, uint16_t cuon, bool bat) {
  uint8_t khung[16];
  khung[0] = diaChi;
  khung[1] = 0x05;
  khung[2] = cuon >> 8;
  khung[3] = cuon & 0xFF;
  khung[4] = bat ? 0xFF : 0x00;
  khung[5] = 0x00;
  guiKhung(khung, 6);

  int soByte = nhanKhung(khung, sizeof(khung));
  return kiemTraTraLoi(khung, soByte, diaChi, 0x05);
}

void setup() {
  Serial.begin(115200);
  pinMode(DE_RE_PIN, OUTPUT);
  digitalWrite(DE_RE_PIN, LOW);
  Serial2.begin(TOC_DO_BAUD, SERIAL_8N1, RX2_PIN, TX2_PIN);
  Serial.printf("\nModbus RTU master, %u baud 8N1, hoi thiet bi dia chi %u\n", (unsigned)TOC_DO_BAUD, DIA_CHI_TO);
}

void loop() {
  static uint32_t soLan = 0;
  static bool trangThaiCuon = false;
  soLan++;

  Serial.printf("\nLan %u: doc 4 thanh ghi giu tu dia chi 0\n", (unsigned)soLan);
  uint16_t giaTri[4];
  if (docThanhGhiGiu(DIA_CHI_TO, 0, 4, giaTri)) {
    Serial.printf("  Nhiet do %.1f C | do am %.1f %% | thoi gian chay %u s | diem dat %.1f C\n",
                  (int16_t)giaTri[0] / 10.0, giaTri[1] / 10.0, giaTri[2], (int16_t)giaTri[3] / 10.0);
  }

  if (soLan % 5 == 0) {
    trangThaiCuon = !trangThaiCuon;
    Serial.printf("Ghi cuon 0 = %s\n", trangThaiCuon ? "BAT" : "TAT");
    if (ghiMotCuon(DIA_CHI_TO, 0, trangThaiCuon)) {
      Serial.println("  Thiet bi da xac nhan");
    }
  }
  delay(2000);
}

Để dùng với một thiết bị khác, sửa DIA_CHI_TO, tốc độ baud, địa chỉ thanh ghi và cách đổi giá trị theo bảng thanh ghi của thiết bị đó. Nếu thiết bị dùng thanh ghi đầu vào thì đổi mã hàm 0x03 thành 0x04; cấu trúc khung giống hệt.

9. Kết quả mong đợi

Hình minh họa Serial Monitor của ESP32 làm thiết bị chủ Modbus RTU: khung gửi 01 03 00 00 00 04 44 09, khung nhận 13 byte, các giá trị nhiệt độ, độ ẩm, thời gian chạy, điểm đặt, lệnh ghi cuộn được xác nhận và thông báo không có trả lời
Kết quả mong đợi trên Serial Monitor (hình minh họa; khung tin có CRC đúng, số đo là ví dụ).
  1. Mở Tools > Serial Monitor ở 115200 baud. Mỗi lần đọc in ra khung gửi, khung nhận và bốn giá trị đã đổi đơn vị.
  2. Ở lần thứ 5, 10, 15... thiết bị chủ ghi cuộn 0; LED trên mạch tớ của phần 2 sáng rồi tắt theo.
  3. Rút một dây A hoặc B: thiết bị chủ báo không có trả lời sau 500 ms rồi tiếp tục hỏi ở chu kỳ sau.

10. Xử lý lỗi thường gặp

Hiện tượngNguyên nhân thường gặpCách xử lý
Luôn báo không có trả lờiSai địa chỉ thiết bị, sai tốc độ baud hoặc định dạng khung, nối ngược A với BĐối chiếu ba thông số với thiết bị; đổi chéo hai dây
Báo sai CRCNhiễu, thiếu điện trở đầu cuối, hoặc thiết bị dùng 8E1 trong khi chương trình dùng 8N1Đổi SERIAL_8N1 thành SERIAL_8E1 nếu thiết bị dùng parity chẵn; kiểm tra đường dây
Thiết bị báo lỗi 02Địa chỉ thanh ghi sai, thường do lệch 1 so với cách ghi kiểu 40001Lấy số trong tài liệu trừ 40001 để ra địa chỉ thật
Thiết bị báo lỗi 01Dùng mã hàm thiết bị không hỗ trợ, ví dụ 0x03 trong khi dữ liệu nằm ở thanh ghi đầu vàoThử mã hàm 0x04
Giá trị đọc ra vô nghĩaHiểu sai hệ số, kiểu có dấu, hoặc giá trị 32 bit nằm trên hai thanh ghiTra bảng thanh ghi: hệ số, kiểu dữ liệu, thứ tự hai thanh ghi
Khung nhận bị cắt làm haiThiết bị trả lời có khoảng ngắt giữa các byte dài hơn khoảng lặng đang đặtTăng KHOANG_LANG_MS lên 10 đến 20 ms

11. 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