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.
Modbus TCP/IP là Modbus chạy trên mạng Ethernet hoặc Wi-Fi thay cho RS-485. Mã hàm, bảng thanh ghi và ý nghĩa dữ liệu giữ nguyên như Modbus RTU; chỉ có phần bao quanh khung tin thay đổi. Nhờ vậy một màn hình HMI, phần mềm SCADA hay máy tính trong mạng nội bộ đọc được dữ liệu của thiết bị mà không cần kéo dây RS-485. Bài viết giải thích phần đầu MBAP của Modbus TCP và dùng ESP32 làm máy chủ (server) qua Wi-Fi, tự phân tích khung tin, không dùng thư viện.
Đây là phần 1 của cặp bài Modbus TCP/IP. Phần 2 dùng ESP32 làm máy khách và làm cổng chuyển đổi sang Modbus RTU.
1. Modbus TCP khác Modbus RTU ở đâu?
| Trường của MBAP | Số byte | Ý nghĩa |
|---|---|---|
| Số giao dịch | 2 | Máy khách tự đặt, thường tăng dần. Máy chủ gửi lại đúng số đó để máy khách ghép câu trả lời với câu hỏi |
| Mã giao thức | 2 | Luôn bằng 0 với Modbus |
| Độ dài | 2 | Số byte đứng sau trường này: mã thiết bị cộng PDU |
| Mã thiết bị | 1 | Tương ứng địa chỉ thiết bị tớ của Modbus RTU; quan trọng khi đi qua cổng chuyển đổi sang RS-485 |
- Không có CRC: TCP và các lớp mạng bên dưới đã kiểm tra lỗi.
- Không có khoảng lặng: TCP là dòng byte, nên khung được tách bằng trường độ dài.
- Tên gọi: thiết bị chủ của Modbus RTU nay là máy khách (client), thiết bị tớ là máy chủ (server). Máy chủ chờ ở cổng 502.
- Nhiều máy khách có thể cùng kết nối tới một máy chủ; mỗi máy khách có một kết nối TCP riêng.
2. Ví dụ khung tin
- Khung hỏi đọc thanh ghi dài 12 byte: 7 byte MBAP và 5 byte PDU.
- Trường độ dài của khung hỏi là 6: một byte mã thiết bị và 5 byte PDU.
- Khung báo lỗi dùng cùng quy tắc với Modbus RTU: mã hàm cộng 0x80 rồi tới mã lỗi.
3. Sơ đồ mạng
- Không cần module hay dây nối thêm: chỉ cần ESP32 và mạng Wi-Fi.
- Máy khách phải biết địa chỉ IP của ESP32. Nên đặt IP tĩnh hoặc gán địa chỉ cố định trong router để địa chỉ không đổi.
- Modbus TCP không có mật khẩu hay mã hóa. Chỉ dùng trong mạng nội bộ tin cậy, không mở cổng 502 ra Internet.
4. Chuẩn bị
- Một mạch ESP32 DevKit V1 30 chân (hoặc bản dùng chip CH340) và mạng Wi-Fi 2,4 GHz.
- Một máy khách Modbus TCP để hỏi: phần mềm Modbus TCP client trên máy tính, hoặc mạch ESP32 thứ hai chạy chương trình của phần 2.
- 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.
5. Chương trình máy chủ Modbus TCP
Máy chủ giữ 10 thanh ghi giữ và 8 cuộn, giống thiết bị tớ trong bài Modbus RTU: thanh ghi 0 và 1 là nhiệt độ và độ ẩm nhân 10 (giá trị giả lập), thanh ghi 2 là số giây đã chạy, thanh ghi 3 là điểm đặt, cuộn 0 điều khiển LED trên mạch.
/*
PYWORLD.VN - Modbus TCP với ESP32 làm máy chủ (server): tự phân tích phần đầu MBAP và trả lời, không dùng thư viện
ESP32 nối Wi-Fi, mở cổng 502; hỗ trợ mã hàm 0x01, 0x03, 0x05, 0x06, 0x10 trên 10 thanh ghi giữ và 8 cuộn
Cuộn 0 điều khiển LED GPIO2; dùng phần mềm Modbus TCP client trên máy tính hoặc một mạch ESP32 khác để hỏi
*/
#include <WiFi.h>
const char *WIFI_SSID = "TEN_WIFI";
const char *WIFI_PASS = "MAT_KHAU_WIFI";
#define LED_PIN 2
const uint16_t CONG_MODBUS = 502;
const unsigned long THOI_GIAN_IM_LANG_MS = 60000;
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
WiFiServer mayChu(CONG_MODBUS);
WiFiClient mayKhach;
uint8_t boDem[300];
int soByte = 0;
unsigned long lucNhanCuoi = 0;
uint16_t lay16(const uint8_t *viTri) {
return (viTri[0] << 8) | viTri[1]; // Modbus gửi byte cao trước
}
int taoLoi(uint8_t maHam, uint8_t maLoi, uint8_t *traLoi) {
traLoi[0] = maHam | 0x80;
traLoi[1] = maLoi;
Serial.printf(" -> bao loi 0x%02X\n", maLoi);
return 2;
}
// Xử lý phần PDU (mã hàm + dữ liệu), giống hệt Modbus RTU. Trả về số byte của PDU trả lời.
int xuLyPdu(const uint8_t *hoi, int doDai, uint8_t *traLoi) {
uint8_t maHam = hoi[0];
bool coHoTro = maHam == 0x01 || maHam == 0x03 || maHam == 0x05 || maHam == 0x06 || maHam == 0x10;
if (!coHoTro) {
Serial.printf("Ma ham 0x%02X khong ho tro\n", maHam);
return taoLoi(maHam, 0x01, traLoi);
}
if (doDai < 5) {
return taoLoi(maHam, 0x03, traLoi);
}
uint16_t dau = lay16(&hoi[1]);
uint16_t giaTri = lay16(&hoi[3]);
Serial.printf("Ma ham 0x%02X, dia chi %u, gia tri %u\n", maHam, dau, giaTri);
traLoi[0] = maHam;
switch (maHam) {
case 0x03: // Đọc thanh ghi giữ
if (giaTri < 1 || giaTri > 125) {
return taoLoi(maHam, 0x03, traLoi);
}
if (dau + giaTri > SO_THANH_GHI) {
return taoLoi(maHam, 0x02, traLoi);
}
traLoi[1] = giaTri * 2;
for (uint16_t i = 0; i < giaTri; i++) {
traLoi[2 + i * 2] = thanhGhi[dau + i] >> 8;
traLoi[3 + i * 2] = thanhGhi[dau + i] & 0xFF;
}
return 2 + giaTri * 2;
case 0x06: // Ghi một thanh ghi: trả lời là nguyên PDU hỏi
if (dau >= SO_THANH_GHI) {
return taoLoi(maHam, 0x02, traLoi);
}
thanhGhi[dau] = giaTri;
memcpy(traLoi, hoi, 5);
return 5;
case 0x10: { // Ghi nhiều thanh ghi
if (doDai < 6 || giaTri < 1 || giaTri > 123 || hoi[5] != giaTri * 2 || doDai != 6 + hoi[5]) {
return taoLoi(maHam, 0x03, traLoi);
}
if (dau + giaTri > SO_THANH_GHI) {
return taoLoi(maHam, 0x02, traLoi);
}
for (uint16_t i = 0; i < giaTri; i++) {
thanhGhi[dau + i] = lay16(&hoi[6 + i * 2]);
}
memcpy(traLoi, hoi, 5);
return 5;
}
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) {
return taoLoi(maHam, 0x03, traLoi);
}
if (dau + giaTri > SO_CUON) {
return taoLoi(maHam, 0x02, traLoi);
}
uint8_t soByteDuLieu = (giaTri + 7) / 8;
traLoi[1] = soByteDuLieu;
memset(&traLoi[2], 0, soByteDuLieu);
for (uint16_t i = 0; i < giaTri; i++) {
if (cuon[dau + i]) {
traLoi[2 + i / 8] |= (1 << (i % 8));
}
}
return 2 + soByteDuLieu;
}
default: // 0x05: ghi một cuộn
if (dau >= SO_CUON) {
return taoLoi(maHam, 0x02, traLoi);
}
if (giaTri != 0xFF00 && giaTri != 0x0000) {
return taoLoi(maHam, 0x03, traLoi);
}
cuon[dau] = (giaTri == 0xFF00);
memcpy(traLoi, hoi, 5);
return 5;
}
}
// Một khung Modbus TCP: 7 byte đầu MBAP (số giao dịch 2, mã giao thức 2, độ dài 2, mã thiết bị 1) rồi tới PDU
void xuLyBoDem() {
while (soByte >= 7) {
uint16_t maGiaoThuc = lay16(&boDem[2]);
uint16_t doDai = lay16(&boDem[4]); // Số byte đứng sau trường độ dài: mã thiết bị + PDU
if (maGiaoThuc != 0 || doDai < 2 || doDai > 254) {
Serial.println("Phan dau MBAP khong hop le: dong ket noi");
mayKhach.stop();
soByte = 0;
return;
}
int doDaiKhung = 6 + doDai;
if (soByte < doDaiKhung) {
return; // Chưa nhận đủ khung, chờ thêm
}
uint8_t traLoi[260];
memcpy(traLoi, boDem, 4); // Giữ nguyên số giao dịch và mã giao thức
traLoi[6] = boDem[6]; // Giữ nguyên mã thiết bị
int doDaiPdu = xuLyPdu(&boDem[7], doDai - 1, &traLoi[7]);
traLoi[4] = (doDaiPdu + 1) >> 8;
traLoi[5] = (doDaiPdu + 1) & 0xFF;
mayKhach.write(traLoi, 7 + doDaiPdu);
// Bỏ khung vừa xử lý khỏi bộ đệm; phần còn lại (nếu có) là khung kế tiếp
soByte -= doDaiKhung;
memmove(boDem, boDem + doDaiKhung, soByte);
}
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
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
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
Serial.printf("\nDang ket noi WiFi %s", WIFI_SSID);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
mayChu.begin();
Serial.printf("\nModbus TCP server: %s cong %u\n", WiFi.localIP().toString().c_str(), CONG_MODBUS);
}
void loop() {
thanhGhi[2] = (uint16_t)(millis() / 1000);
digitalWrite(LED_PIN, cuon[0] ? HIGH : LOW);
if (!mayKhach || !mayKhach.connected()) {
WiFiClient moi = mayChu.accept();
if (moi) {
mayKhach = moi;
soByte = 0;
lucNhanCuoi = millis();
Serial.printf("May khach %s da ket noi\n", mayKhach.remoteIP().toString().c_str());
}
return;
}
while (mayKhach.available() && soByte < (int)sizeof(boDem)) {
boDem[soByte++] = mayKhach.read();
lucNhanCuoi = millis();
}
xuLyBoDem();
if (millis() - lucNhanCuoi > THOI_GIAN_IM_LANG_MS) {
Serial.println("May khach im lang qua lau: dong ket noi");
mayKhach.stop();
}
}
- Hàm
xuLyPdu()chỉ làm việc với mã hàm và dữ liệu. Nó không biết khung tới từ TCP hay RS-485; đây chính là phần chung giữa Modbus RTU và Modbus TCP. - Hàm
xuLyBoDem()lo phần riêng của TCP: chờ đủ 7 byte đầu, đọc trường độ dài, chờ đủ cả khung, rồi mới xử lý. Nếu trong bộ đệm còn dư byte, đó là khung kế tiếp. - Chương trình phục vụ một máy khách tại một thời điểm và đóng kết nối nếu máy khách im lặng quá 60 giây.
6. Kết quả mong đợi
- Sửa tên và mật khẩu Wi-Fi, nạp chương trình, mở Tools > Serial Monitor ở 115200 baud để xem địa chỉ IP.
- Trong phần mềm Modbus TCP client: nhập địa chỉ IP đó, cổng 502, mã thiết bị 1; đọc thanh ghi giữ từ địa chỉ 0, số lượng 4. Kết quả là 285, 652, số giây đã chạy và 250.
- Ghi cuộn 0 bằng mã hàm
0x05: LED xanh trên mạch sáng hoặc tắt. - Ghi thanh ghi 3 một giá trị mới rồi đọc lại để thấy giá trị được giữ.
7. Xử lý lỗi thường gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| Máy khách không kết nối được | Sai địa chỉ IP, khác mạng, hoặc router cách ly các thiết bị Wi-Fi | Xem lại IP trên Serial Monitor; nối cùng mạng; tắt chế độ cách ly thiết bị trên router |
| Kết nối được nhưng mọi lần đọc đều hết thời gian | Phần mềm đang ở chế độ "RTU qua TCP" thay vì Modbus TCP | Chọn đúng kiểu Modbus TCP (có phần đầu MBAP, không CRC) |
In ra Phan dau MBAP khong hop le | Chương trình khác đang gửi dữ liệu không phải Modbus tới cổng 502 | Kiểm tra máy khách; kết nối bị đóng là để bảo vệ máy chủ |
| Báo lỗi 02 khi đọc thanh ghi đầu tiên | Phần mềm đánh số từ 1 nên gửi địa chỉ lệch | Chọn kiểu đánh địa chỉ từ 0, hoặc dùng số 40001 nếu phần mềm theo kiểu đó |
| Máy khách thứ hai không kết nối được | Chương trình chỉ phục vụ một máy khách | Ngắt máy khách thứ nhất, hoặc mở rộng chương trình để giữ nhiều kết nối |
| Mất kết nối sau một lúc không dùng | Máy chủ đóng kết nối im lặng quá 60 giây | Cho máy khách đọc định kỳ, hoặc tăng THOI_GIAN_IM_LANG_MS |
8. Xem thêm
- Kết nối Wi-Fi trên ESP32: IP tĩnh và tự kết nối lại.
- ESP32 làm web server điều khiển relay: một kiểu máy chủ TCP khác.
- Dùng MQTT với ESP32, lựa chọn khác để đưa dữ liệu lên mạng.
Xem thêm