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
- 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
- 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.
- Kiểm tra CRC: sai thì bỏ, không trả lời.
- 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.
- Kiểm tra mã hàm: không hỗ trợ thì trả lời mã lỗi
01. - 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ỗi03. - Thực hiện và trả lời.
4. Các khung tin cần hiểu
| Mã hàm | Khung hỏi (sau địa chỉ và mã hàm) | Khung trả lời |
|---|---|---|
0x03 đọc thanh ghi giữ | Thanh ghi đầu, số lượng | Số byte, rồi 2 byte cho mỗi thanh ghi |
0x06 ghi một thanh ghi | Số thanh ghi, giá trị | Nguyên khung hỏi |
0x10 ghi nhiều thanh ghi | Thanh ghi đầu, số lượng, số byte, các giá trị | Thanh ghi đầu, số lượng |
0x01 đọc cuộn | Cuộn đầu, số lượng | Số byte, rồi mỗi cuộn một bit, cuộn đầu ở bit thấp nhất |
0x05 ghi một cuộn | Số cuộn, FF 00 hoặc 00 00 | Nguyê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
thanhGhihaycuon. 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]trongloop(). Để hành động theo lệnh của thiết bị chủ, đọcthanhGhi[3]hoặccuon[]như cách chương trình điều khiển LED.
7. Thử thiết bị tớ
- 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
- 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ị.
- Khi thiết bị chủ bật cuộn 0, LED xanh trên mạch tớ sáng.
- Thử đọc 4 thanh ghi từ địa chỉ 8: thiết bị tớ báo lỗi
0x02vì bảng chỉ có 10 thanh ghi. - 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ới0x03. - 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ượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| Thiết bị tớ không in gì khi được hỏi | Sai 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ục | Thiế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 gian | Chân DE không được điều khiển nên câu trả lời không ra đường dây | Kiểm tra dây nối DE, RE với D4 |
| Thiết bị chủ nhận khung trả lời bị cắt | Chương trình tớ có đoạn chặn lâu giữa lúc gửi | Khô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 ghi | Phần mềm đánh số thanh ghi từ 1 | Chọ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ỏng | Trùng địa chỉ | Mỗi thiết bị trên đường dây một địa chỉ riêng |
11. Xem thêm
- Lưu dữ liệu vào bộ nhớ flash của ESP32 bằng Preferences.
- ESP32 điều khiển module relay 4 kênh an toàn.
- Các mạch ESP32 tại PYWORLD: danh mục ESP32.
Xem thêm