Modbus RTU (phần 2): ESP32 làm thiết bị tớ, tự xử lý khung tin và mã lỗi

Hướng dẫn biến ESP32 thành thiết bị tớ Modbus RTU qua RS-485: lập bảng thanh ghi, tách khung bằng khoảng lặng, kiểm tra CRC, xử lý mã hàm 0x01, 0x03, 0x05, 0x06, 0x10 và trả lời mã lỗi, không dùng thư viện.

Phần 1 đã dùng ESP32 làm thiết bị chủ để hỏi một thiết bị Modbus RTU. Phần 2 làm chiều ngược lại: biến ESP32 thành một thiết bị tớ (slave) mà PLC, màn hình HMI, phần mềm trên máy tính hay một mạch ESP32 khác đọc và điều khiển được qua RS-485. Chương trình tự phân tích khung tin, kiểm tra CRC, thực hiện năm mã hàm thông dụng và trả lời bằng mã lỗi khi yêu cầu không hợp lệ, không dùng thư viện.

Bài dùng lại sơ đồ nối module RS-485 và kiến thức về khung tin của các bài trước trong loạt.

1. Thiết bị tớ phải làm những gì?

  • Giữ một bảng thanh ghi: dữ liệu mà thiết bị chủ được đọc hoặc ghi.
  • Lắng nghe đường dây liên tục và nhận ra đâu là một khung tin trọn vẹn.
  • Chỉ trả lời khung gửi đúng địa chỉ của mình và có CRC đúng.
  • Trả lời trong thời gian ngắn, vì thiết bị chủ chỉ chờ có hạn.
  • Không bao giờ tự phát lên đường dây khi chưa được hỏi.

2. Bảng thanh ghi của thiết bị trong bài

Bảng thanh ghi của thiết bị tớ Modbus làm bằng ESP32: thanh ghi giữ 0 là nhiệt độ nhân 10, 1 là độ ẩm nhân 10, 2 là số giây đã chạy, 3 là điểm đặt nhân 10, 4 đến 9 để trống; cuộn 0 điều khiển LED GPIO2
Mười thanh ghi giữ và tám cuộn của thiết bị tớ trong bài.
  • Thanh ghi 0 và 1 chứa nhiệt độ và độ ẩm nhân 10. Trong bài đây là giá trị giả lập; khi dùng thật, thay bằng số đọc từ cảm biến.
  • Thanh ghi 2 là số giây từ lúc khởi động, tiện để thấy dữ liệu thay đổi qua mỗi lần đọc.
  • Thanh ghi 3 là điểm đặt, thiết bị chủ ghi được.
  • Cuộn 0 nối với LED trên mạch (GPIO2): thiết bị chủ bật tắt LED bằng mã hàm 0x05.

3. Xử lý một khung tin

Lưu đồ xử lý khung tin của thiết bị tớ Modbus RTU: nhận khung khi đường dây im lặng 5 ms, kiểm tra CRC, kiểm tra địa chỉ, kiểm tra mã hàm, kiểm tra địa chỉ thanh ghi và số lượng, rồi thực hiện và trả lời hoặc báo lỗi 01, 02, 03
Các bước kiểm tra của thiết bị tớ trước khi thực hiện một yêu cầu.
  1. Tách khung: gom các byte tới vào bộ đệm. Khi đường dây im lặng 5 ms (hơn 3,5 ký tự ở 9600 baud), coi như đã nhận xong một khung.
  2. Kiểm tra CRC: sai thì bỏ, không trả lời.
  3. Kiểm tra địa chỉ: không phải của mình thì bỏ qua. Trên đường dây có nhiều thiết bị, phần lớn khung là gửi cho thiết bị khác.
  4. Kiểm tra mã hàm: không hỗ trợ thì trả lời mã lỗi 01.
  5. Kiểm tra phạm vi: địa chỉ thanh ghi ngoài bảng thì trả lời mã lỗi 02; số lượng hoặc giá trị không hợp lệ thì mã lỗi 03.
  6. Thực hiện và trả lời.

4. Các khung tin cần hiểu

Ba ví dụ khung tin Modbus RTU: mã hàm 0x06 ghi thanh ghi 3 bằng 300, mã hàm 0x10 ghi hai thanh ghi từ địa chỉ 4 kèm khung trả lời, mã hàm 0x01 đọc tám cuộn kèm khung trả lời một byte
Ghi một thanh ghi, ghi nhiều thanh ghi và đọc cuộn (CRC tính đúng theo từng khung).
Mã hàmKhung hỏi (sau địa chỉ và mã hàm)Khung trả lời
0x03 đọc thanh ghi giữThanh ghi đầu, số lượngSố byte, rồi 2 byte cho mỗi thanh ghi
0x06 ghi một thanh ghiSố thanh ghi, giá trịNguyên khung hỏi
0x10 ghi nhiều thanh ghiThanh ghi đầu, số lượng, số byte, các giá trịThanh ghi đầu, số lượng
0x01 đọc cuộnCuộn đầu, số lượngSố byte, rồi mỗi cuộn một bit, cuộn đầu ở bit thấp nhất
0x05 ghi một cuộnSố cuộn, FF 00 hoặc 00 00Nguyên khung hỏi

5. Chuẩn bị

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

6. Chương trình thiết bị tớ

/*
  PYWORLD.VN - Modbus RTU với ESP32 làm thiết bị tớ (slave): tự phân tích khung tin, trả lời và báo lỗi, không dùng thư viện
  Hỗ trợ mã hàm 0x01, 0x03, 0x05, 0x06, 0x10; có 10 thanh ghi giữ và 8 cuộn, cuộn 0 điều khiển LED GPIO2
  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
#define LED_PIN 2

const uint32_t TOC_DO_BAUD = 9600;
const uint8_t DIA_CHI_CUA_TOI = 1;
const unsigned long KHOANG_LANG_MS = 5;   // Im lặng dài hơn 3,5 ký tự là hết một khung

const uint16_t SO_THANH_GHI = 10;
const uint16_t SO_CUON = 8;
uint16_t thanhGhi[SO_THANH_GHI];   // 0: nhiệt độ x10, 1: độ ẩm x10, 2: số giây đã chạy, 3: điểm đặt x10, 4-9: tự do
bool cuon[SO_CUON];                // 0: LED trên mạch

const uint8_t LOI_MA_HAM = 0x01;
const uint8_t LOI_DIA_CHI = 0x02;
const uint8_t LOI_GIA_TRI = 0x03;

uint8_t khung[260];
int soByteNhan = 0;
unsigned long lucNhanCuoi = 0;

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;
}

uint16_t lay16(const uint8_t *viTri) {
  return (viTri[0] << 8) | viTri[1];   // Modbus gửi byte cao trước
}

// Thêm CRC rồi phát khung trả lời
void guiTraLoi(uint8_t *traLoi, int soByte) {
  uint16_t crc = tinhCrc16(traLoi, soByte);
  traLoi[soByte] = crc & 0xFF;
  traLoi[soByte + 1] = crc >> 8;
  digitalWrite(DE_RE_PIN, HIGH);
  Serial2.write(traLoi, soByte + 2);
  Serial2.flush();
  digitalWrite(DE_RE_PIN, LOW);
}

// Khung báo lỗi: địa chỉ, mã hàm cộng 0x80, mã lỗi
void guiLoi(uint8_t maHam, uint8_t maLoi) {
  uint8_t traLoi[5] = {DIA_CHI_CUA_TOI, (uint8_t)(maHam | 0x80), maLoi};
  guiTraLoi(traLoi, 3);
  Serial.printf("  -> bao loi 0x%02X\n", maLoi);
}

void xuLyKhung(int soByte) {
  if (soByte < 4) {
    return;                          // Quá ngắn, coi như nhiễu
  }
  uint16_t crcNhan = khung[soByte - 2] | (khung[soByte - 1] << 8);
  if (tinhCrc16(khung, soByte - 2) != crcNhan) {
    Serial.println("Khung sai CRC: bo qua, khong tra loi");
    return;
  }
  if (khung[0] != DIA_CHI_CUA_TOI) {
    return;                          // Khung gửi cho thiết bị khác
  }

  uint8_t maHam = khung[1];
  bool coHoTro = maHam == 0x01 || maHam == 0x03 || maHam == 0x05 || maHam == 0x06 || maHam == 0x10;
  if (!coHoTro) {
    Serial.printf("Nhan ma ham 0x%02X khong ho tro\n", maHam);
    guiLoi(maHam, LOI_MA_HAM);
    return;
  }
  if (soByte < 8) {
    return;                          // Khung hỏi của các mã hàm này dài ít nhất 8 byte
  }

  uint16_t dau = lay16(&khung[2]);
  uint16_t giaTri = lay16(&khung[4]);
  uint8_t traLoi[260];
  traLoi[0] = DIA_CHI_CUA_TOI;
  traLoi[1] = maHam;
  Serial.printf("Nhan ma ham 0x%02X, dia chi %u, gia tri %u\n", maHam, dau, giaTri);

  switch (maHam) {
    case 0x03: {   // Đọc thanh ghi giữ: giaTri là số lượng
      if (giaTri < 1 || giaTri > 125) {
        guiLoi(maHam, LOI_GIA_TRI);
      } else if (dau + giaTri > SO_THANH_GHI) {
        guiLoi(maHam, LOI_DIA_CHI);
      } else {
        traLoi[2] = giaTri * 2;
        for (uint16_t i = 0; i < giaTri; i++) {
          traLoi[3 + i * 2] = thanhGhi[dau + i] >> 8;
          traLoi[4 + i * 2] = thanhGhi[dau + i] & 0xFF;
        }
        guiTraLoi(traLoi, 3 + giaTri * 2);
      }
      break;
    }
    case 0x06: {   // Ghi một thanh ghi: trả lời là nguyên khung hỏi
      if (dau >= SO_THANH_GHI) {
        guiLoi(maHam, LOI_DIA_CHI);
      } else {
        thanhGhi[dau] = giaTri;
        memcpy(traLoi, khung, 6);
        guiTraLoi(traLoi, 6);
      }
      break;
    }
    case 0x10: {   // Ghi nhiều thanh ghi: sau số lượng là số byte và dữ liệu
      uint8_t soByteDuLieu = khung[6];
      if (giaTri < 1 || giaTri > 123 || soByteDuLieu != giaTri * 2 || soByte != 9 + soByteDuLieu) {
        guiLoi(maHam, LOI_GIA_TRI);
      } else if (dau + giaTri > SO_THANH_GHI) {
        guiLoi(maHam, LOI_DIA_CHI);
      } else {
        for (uint16_t i = 0; i < giaTri; i++) {
          thanhGhi[dau + i] = lay16(&khung[7 + i * 2]);
        }
        memcpy(traLoi, khung, 6);
        guiTraLoi(traLoi, 6);
      }
      break;
    }
    case 0x01: {   // Đọc cuộn: mỗi cuộn một bit, cuộn đầu ở bit thấp nhất
      if (giaTri < 1 || giaTri > 2000) {
        guiLoi(maHam, LOI_GIA_TRI);
      } else if (dau + giaTri > SO_CUON) {
        guiLoi(maHam, LOI_DIA_CHI);
      } else {
        uint8_t soByteDuLieu = (giaTri + 7) / 8;
        traLoi[2] = soByteDuLieu;
        memset(&traLoi[3], 0, soByteDuLieu);
        for (uint16_t i = 0; i < giaTri; i++) {
          if (cuon[dau + i]) {
            traLoi[3 + i / 8] |= (1 << (i % 8));
          }
        }
        guiTraLoi(traLoi, 3 + soByteDuLieu);
      }
      break;
    }
    case 0x05: {   // Ghi một cuộn: 0xFF00 là bật, 0x0000 là tắt
      if (dau >= SO_CUON) {
        guiLoi(maHam, LOI_DIA_CHI);
      } else if (giaTri != 0xFF00 && giaTri != 0x0000) {
        guiLoi(maHam, LOI_GIA_TRI);
      } else {
        cuon[dau] = (giaTri == 0xFF00);
        memcpy(traLoi, khung, 6);
        guiTraLoi(traLoi, 6);
      }
      break;
    }
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);
  pinMode(DE_RE_PIN, OUTPUT);
  digitalWrite(DE_RE_PIN, LOW);
  Serial2.begin(TOC_DO_BAUD, SERIAL_8N1, RX2_PIN, TX2_PIN);

  thanhGhi[0] = 285;   // 28,5 độ C (giá trị giả lập)
  thanhGhi[1] = 652;   // 65,2 %
  thanhGhi[3] = 250;   // Điểm đặt 25,0 độ C, thiết bị chủ ghi được
  Serial.printf("\nModbus RTU slave dia chi %u, %u baud 8N1\n", DIA_CHI_CUA_TOI, (unsigned)TOC_DO_BAUD);
}

void loop() {
  // Gom byte vào bộ đệm; đường dây im lặng đủ lâu thì coi như đã hết một khung
  while (Serial2.available()) {
    uint8_t giaTri = Serial2.read();
    if (soByteNhan < (int)sizeof(khung)) {
      khung[soByteNhan++] = giaTri;
    }
    lucNhanCuoi = millis();
  }
  if (soByteNhan > 0 && millis() - lucNhanCuoi >= KHOANG_LANG_MS) {
    xuLyKhung(soByteNhan);
    soByteNhan = 0;
  }

  // Cập nhật dữ liệu của thiết bị: số giây đã chạy và trạng thái LED theo cuộn 0
  thanhGhi[2] = (uint16_t)(millis() / 1000);
  digitalWrite(LED_PIN, cuon[0] ? HIGH : LOW);
}

Vài điểm đáng chú ý:

  • Hàm loop() không có delay(). Nó chỉ gom byte và kiểm tra khoảng lặng, nên trả lời được trong vài mili giây sau khi khung hỏi kết thúc.
  • Mọi yêu cầu đều được kiểm tra phạm vi trước khi đụng tới mảng thanhGhi hay cuon. Thiếu bước này, một khung hỏi sai có thể làm chương trình đọc hoặc ghi ra ngoài mảng.
  • Để đưa số đo thật vào, chỉ cần gán giá trị cảm biến cho thanhGhi[0], thanhGhi[1] trong loop(). Để hành động theo lệnh của thiết bị chủ, đọc thanhGhi[3] hoặc cuon[] như cách chương trình điều khiển LED.

7. Thử thiết bị tớ

Hai cách thử thiết bị tớ Modbus RTU làm bằng ESP32: dùng mạch ESP32 thứ hai chạy chương trình thiết bị chủ, hoặc dùng máy tính với bộ chuyển USB sang RS-485 và phần mềm Modbus master
Thiết bị chủ là mạch ESP32 của phần 1 hoặc một máy tính có bộ chuyển USB sang RS-485.
  • Với mạch ESP32 của phần 1: nối A với A, B với B. Thiết bị chủ đọc bốn thanh ghi mỗi 2 giây và cứ 5 lần lại đổi trạng thái cuộn 0.
  • Với máy tính: trong phần mềm Modbus master, chọn cổng COM của bộ chuyển, 9600 baud, 8 bit dữ liệu, không parity, 1 bit kết thúc, địa chỉ thiết bị 1. Đọc thanh ghi giữ từ địa chỉ 0, số lượng 4. Nếu phần mềm đánh số từ 1 hoặc dùng kiểu 40001, thanh ghi đầu tiên của bài ứng với 40001.

8. Kết quả mong đợi

Hình minh họa Serial Monitor của ESP32 làm thiết bị tớ Modbus RTU: nhật ký các mã hàm 0x03, 0x05, 0x06 nhận được, báo lỗi 0x02 khi địa chỉ vượt bảng, báo lỗi 0x01 với mã hàm không hỗ trợ và thông báo bỏ khung sai CRC
Nhật ký trên Serial Monitor của thiết bị tớ (hình minh họa).
  1. Mở Tools > Serial Monitor ở 115200 baud. Mỗi khung hỏi hợp lệ hiện một dòng với mã hàm, địa chỉ và giá trị.
  2. Khi thiết bị chủ bật cuộn 0, LED xanh trên mạch tớ sáng.
  3. Thử đọc 4 thanh ghi từ địa chỉ 8: thiết bị tớ báo lỗi 0x02 vì bảng chỉ có 10 thanh ghi.
  4. Ghi thanh ghi 3 một giá trị mới rồi đọc lại: giá trị được giữ cho đến khi mạch khởi động lại.

9. Mở rộng

  • Giữ giá trị khi mất điện: lưu điểm đặt vào bộ nhớ flash mỗi khi thiết bị chủ ghi, theo bài lưu dữ liệu bằng Preferences.
  • Địa chỉ và tốc độ cấu hình được: thiết bị thương mại thường cho đổi địa chỉ và tốc độ baud qua một thanh ghi hoặc công tắc gạt.
  • Thêm mã hàm 0x04: nhiều thiết bị chủ quen đọc số đo ở thanh ghi đầu vào. Có thể cho mã hàm 0x04 đọc cùng bảng với 0x03.
  • Số 32 bit và số thực: chiếm hai thanh ghi liền nhau. Thứ tự hai thanh ghi không thống nhất giữa các hãng, cần ghi rõ trong bảng thanh ghi.
  • Điều khiển tải thật: cuộn có thể nối với relay; xem lưu ý an toàn trong bài ESP32 điều khiển module relay.

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

Hiện tượngNguyên nhân thường gặpCách xử lý
Thiết bị tớ không in gì khi được hỏiSai tốc độ baud, nối ngược A với B, chân RO chưa nối vào RX2Đối chiếu tốc độ; đổi chéo hai dây; kiểm tra dây RO và mạch chia áp
In ra Khung sai CRC liên tụcThiết bị chủ dùng parity chẵn (8E1), hoặc đường dây nhiễuĐặt thiết bị chủ về 8N1, hoặc đổi chương trình sang SERIAL_8E1
Thiết bị tớ nhận được nhưng thiết bị chủ báo hết thời gianChân DE không được điều khiển nên câu trả lời không ra đường dâyKiểm tra dây nối DE, RE với D4
Thiết bị chủ nhận khung trả lời bị cắtChương trình tớ có đoạn chặn lâu giữa lúc gửiKhông thêm delay() hay lệnh in dài trong lúc gửi trả lời
Phần mềm máy tính đọc lệch một thanh ghiPhần mềm đánh số thanh ghi từ 1Chọn kiểu đánh địa chỉ từ 0, hoặc nhập số lớn hơn 1 đơn vị
Hai thiết bị tớ cùng trả lời, khung hỏngTrùng địa chỉMỗi thiết bị trên đường dây một địa chỉ riêng

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