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
- Đị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
| Mã hàm | Việc làm | Dữ liệu trong khung hỏi |
|---|---|---|
0x01 | Đọc cuộn | Cuộ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ào | Thanh ghi đầu, số lượng |
0x05 | Ghi một cuộn | Số cuộn, giá trị FF 00 (bật) hoặc 00 00 (tắt) |
0x06 | Ghi một thanh ghi giữ | Số thanh ghi, giá trị |
0x10 | Ghi 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.40001là 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ữ
- 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ớ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
0x03mà sai địa chỉ thì nhận về0x83và mã lỗi02. - 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
- 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
- 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ị.
- Ở 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.
- 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ượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| Luôn báo không có trả lời | Sai đị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 CRC | Nhiễ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 40001 | Lấy số trong tài liệu trừ 40001 để ra địa chỉ thật |
| Thiết bị báo lỗi 01 | Dù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ào | Thử mã hàm 0x04 |
| Giá trị đọc ra vô nghĩa | Hiểu sai hệ số, kiểu có dấu, hoặc giá trị 32 bit nằm trên hai thanh ghi | Tra bảng thanh ghi: hệ số, kiểu dữ liệu, thứ tự hai thanh ghi |
| Khung nhận bị cắt làm hai | Thiết bị trả lời có khoảng ngắt giữa các byte dài hơn khoảng lặng đang đặt | Tăng KHOANG_LANG_MS lên 10 đến 20 ms |
11. Xem thêm
- Dùng MQTT với ESP32: đưa số liệu đọc từ thiết bị Modbus lên mạng.
- Dùng module thẻ nhớ Micro SD với ESP32: ghi lại số liệu đọc được theo thời gian.
- Các mạch ESP32 tại PYWORLD: danh mục ESP32.
Xem thêm