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.
Phần 1 đã dùng ESP32 làm máy chủ Modbus TCP. Phần 2 làm hai việc còn lại: dùng ESP32 làm máy khách (client) để đọc một thiết bị Modbus TCP có sẵn, và dùng ESP32 làm cổng chuyển đổi (gateway) giữa Modbus TCP và Modbus RTU. Cổng chuyển đổi là thứ rất hay gặp trong thực tế: các thiết bị cũ chỉ có RS-485, trong khi phần mềm giám sát chỉ nói Modbus TCP qua mạng. Cả hai chương trình đều tự xử lý khung tin, không dùng thư viện.
Bài dùng lại kiến thức về phần đầu MBAP, khung Modbus RTU và module RS-485 của các bài trước trong loạt.
1. Máy khách Modbus TCP làm những gì?
- Kết nối TCP tới địa chỉ IP của thiết bị, cổng 502. Một kết nối dùng cho nhiều lần hỏi.
- Gửi khung hỏi: 7 byte MBAP rồi tới PDU. Mỗi lần hỏi tăng số giao dịch lên 1.
- Đọc đủ 7 byte phần đầu của khung trả lời, có giới hạn thời gian.
- Đọc phần còn lại: số byte bằng trường độ dài trừ 1.
- Kiểm tra: số giao dịch phải trùng với khung hỏi; mã hàm đúng hoặc là mã báo lỗi; số byte dữ liệu khớp với số thanh ghi đã hỏi.
Vì TCP là dòng byte, khung trả lời có thể tới thành nhiều mẩu. Chương trình phải đọc cho đủ số byte cần, không được coi một lần đọc là một khung.
2. Chuẩn bị
- Một mạch ESP32 DevKit V1 30 chân (hoặc bản dùng chip CH340) cho mỗi vai trò, và mạng Wi-Fi 2,4 GHz.
- Cho chương trình 1: một máy chủ Modbus TCP để đọc, ví dụ mạch ESP32 chạy chương trình của phần 1.
- Cho chương trình 2: một module chuyển đổi RS-485 và ít nhất một thiết bị Modbus RTU, ví dụ mạch ESP32 chạy chương trình thiết bị tớ của bài Modbus RTU.
- 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.
3. Chương trình 1: ESP32 làm máy khách Modbus TCP
Sửa DIA_CHI_MAY_CHU thành địa chỉ IP của thiết bị cần đọc. Mỗi 2 giây chương trình đọc 4 thanh ghi giữ từ địa chỉ 0 và in cả khung gửi, khung nhận.
/*
PYWORLD.VN - Modbus TCP với ESP32 làm máy khách (client): kết nối tới một thiết bị Modbus TCP và đọc thanh ghi giữ
Mỗi 2 giây gửi một khung đọc 4 thanh ghi (mã hàm 0x03), in khung gửi, khung nhận và giá trị đọc được
Sửa DIA_CHI_MAY_CHU cho đúng địa chỉ IP của thiết bị cần đọc; cổng mặc định của Modbus TCP là 502
*/
#include <WiFi.h>
const char *WIFI_SSID = "TEN_WIFI";
const char *WIFI_PASS = "MAT_KHAU_WIFI";
const IPAddress DIA_CHI_MAY_CHU(192, 168, 1, 50);
const uint16_t CONG_MODBUS = 502;
const uint8_t MA_THIET_BI = 1;
const unsigned long THOI_GIAN_CHO_MS = 1000;
WiFiClient ketNoi;
uint16_t soGiaoDich = 0;
uint16_t lay16(const uint8_t *viTri) {
return (viTri[0] << 8) | viTri[1];
}
void dat16(uint8_t *viTri, uint16_t giaTri) {
viTri[0] = giaTri >> 8;
viTri[1] = giaTri & 0xFF;
}
void inKhung(const char *nhan, const uint8_t *dau, int soByteDau, const uint8_t *sau, int soByteSau) {
Serial.print(nhan);
for (int i = 0; i < soByteDau; i++) {
Serial.printf(" %02X", dau[i]);
}
for (int i = 0; i < soByteSau; i++) {
Serial.printf(" %02X", sau[i]);
}
Serial.println();
}
// TCP là dòng byte: phải đọc cho đủ đúng số byte cần, có giới hạn thời gian
bool docDuByte(uint8_t *dich, int soByte) {
int daDoc = 0;
unsigned long batDau = millis();
while (daDoc < soByte) {
if (ketNoi.available()) {
dich[daDoc++] = ketNoi.read();
} else if (!ketNoi.connected() || millis() - batDau > THOI_GIAN_CHO_MS) {
return false;
} else {
delay(1);
}
}
return true;
}
bool docThanhGhiGiu(uint16_t thanhGhiDau, uint16_t soLuong, uint16_t *ketQua) {
// Phần đầu MBAP 7 byte, rồi PDU 5 byte: mã hàm, thanh ghi đầu, số lượng
uint8_t khung[12];
soGiaoDich++;
dat16(&khung[0], soGiaoDich); // Số giao dịch: máy chủ gửi lại đúng số này trong khung trả lời
dat16(&khung[2], 0); // Mã giao thức: luôn là 0 với Modbus
dat16(&khung[4], 6); // Độ dài phần còn lại: mã thiết bị (1) + PDU (5)
khung[6] = MA_THIET_BI;
khung[7] = 0x03;
dat16(&khung[8], thanhGhiDau);
dat16(&khung[10], soLuong);
while (ketNoi.available()) {
ketNoi.read(); // Bỏ dữ liệu cũ còn sót
}
ketNoi.write(khung, sizeof(khung));
inKhung("Gui :", khung, sizeof(khung), NULL, 0);
uint8_t dau[7];
if (!docDuByte(dau, 7)) {
Serial.println(" Khong co tra loi: dong ket noi de ket noi lai");
ketNoi.stop();
return false;
}
uint16_t doDai = lay16(&dau[4]);
if (lay16(&dau[0]) != soGiaoDich || lay16(&dau[2]) != 0 || doDai < 3 || doDai > 254) {
Serial.println(" Phan dau tra loi khong hop le: dong ket noi");
ketNoi.stop();
return false;
}
uint8_t pdu[254];
if (!docDuByte(pdu, doDai - 1)) {
Serial.println(" Khung tra loi bi thieu: dong ket noi");
ketNoi.stop();
return false;
}
inKhung("Nhan:", dau, 7, pdu, doDai - 1);
if (pdu[0] == (0x03 | 0x80)) {
Serial.printf(" Thiet bi bao loi 0x%02X\n", pdu[1]);
return false;
}
if (pdu[0] != 0x03 || pdu[1] != soLuong * 2 || doDai - 1 != 2 + soLuong * 2) {
Serial.println(" Noi dung tra loi khong khop voi cau hoi");
return false;
}
for (uint16_t i = 0; i < soLuong; i++) {
ketQua[i] = lay16(&pdu[2 + i * 2]);
}
return true;
}
void setup() {
Serial.begin(115200);
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(".");
}
Serial.printf("\nIP cua mach nay: %s\n", WiFi.localIP().toString().c_str());
}
void loop() {
if (!ketNoi.connected()) {
Serial.printf("Dang ket noi toi %s cong %u...\n", DIA_CHI_MAY_CHU.toString().c_str(), CONG_MODBUS);
if (!ketNoi.connect(DIA_CHI_MAY_CHU, CONG_MODBUS, 3000)) {
Serial.println(" Khong ket noi duoc, thu lai sau 3 giay");
delay(3000);
return;
}
Serial.println(" Da ket noi");
}
uint16_t giaTri[4];
if (docThanhGhiGiu(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);
}
delay(2000);
}
- Hàm
docDuByte()đọc đúng số byte yêu cầu hoặc trả về thất bại khi quá 1 giây. - Khi không có trả lời hoặc phần đầu không hợp lệ, chương trình đóng kết nối. Lần lặp sau nó kết nối lại từ đầu; đây là cách chắc chắn nhất để hai bên đồng bộ lại.
- Để đọc một thiết bị khác, sửa địa chỉ IP, mã thiết bị, địa chỉ thanh ghi và cách đổi giá trị theo bảng thanh ghi của thiết bị đó.
4. Cổng chuyển đổi Modbus TCP sang RTU
- Phía mạng, cổng chuyển đổi là một máy chủ Modbus TCP ở cổng 502.
- Phía RS-485, nó là thiết bị chủ Modbus RTU.
- Mã thiết bị trong phần đầu MBAP trở thành địa chỉ thiết bị tớ trên RS-485. Nhờ đó một cổng chuyển đổi phục vụ được nhiều thiết bị trên cùng đường dây.
- Cổng chuyển đổi không cần hiểu mã hàm hay thanh ghi: nó chỉ đổi phần bao quanh khung.
| Bước | Cổng chuyển đổi làm gì |
|---|---|
| Nhận khung TCP | Chờ đủ khung theo trường độ dài; ghi nhớ số giao dịch |
| Gửi khung RTU | Lấy mã thiết bị làm địa chỉ, giữ nguyên PDU, thêm CRC-16, phát lên RS-485 |
| Chờ trả lời | Tối đa 500 ms; tách khung bằng khoảng lặng; kiểm tra CRC và địa chỉ |
| Trả khung TCP | Bỏ địa chỉ và CRC, gắn lại MBAP với số giao dịch cũ và độ dài mới |
| Không có trả lời hợp lệ | Tự trả lời bằng mã lỗi 0x0B: thiết bị đích không trả lời |
5. Chương trình 2: ESP32 làm cổng chuyển đổi
Module 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. Đường RS-485 chạy 9600 baud, 8N1.
/*
PYWORLD.VN - Cổng chuyển đổi Modbus TCP sang Modbus RTU bằng ESP32: hỏi qua Wi-Fi, ESP32 hỏi lại thiết bị trên RS-485
Nhận khung Modbus TCP ở cổng 502, đổi thành khung RTU (mã thiết bị thành địa chỉ, thêm CRC), gửi qua RS-485 rồi trả kết quả về
Module RS-485: DI = TX2 (GPIO17), RO = RX2 (GPIO16), DE và RE = D4; đường RS-485 chạy 9600 baud, 8N1
*/
#include <WiFi.h>
const char *WIFI_SSID = "TEN_WIFI";
const char *WIFI_PASS = "MAT_KHAU_WIFI";
#define RX2_PIN 16
#define TX2_PIN 17
#define DE_RE_PIN 4
const uint16_t CONG_MODBUS = 502;
const uint32_t TOC_DO_BAUD = 9600;
const unsigned long THOI_GIAN_CHO_RTU_MS = 500;
const unsigned long KHOANG_LANG_MS = 5;
const unsigned long THOI_GIAN_IM_LANG_MS = 60000;
const uint8_t LOI_DICH_KHONG_TRA_LOI = 0x0B; // Mã lỗi của cổng chuyển đổi: thiết bị đích không trả lời
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];
}
uint16_t tinhCrc16(const uint8_t *duLieu, size_t soByteDuLieu) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < soByteDuLieu; i++) {
crc ^= duLieu[i];
for (int k = 0; k < 8; k++) {
crc = (crc & 0x0001) ? (crc >> 1) ^ 0xA001 : crc >> 1;
}
}
return crc;
}
// Gửi một khung RTU (thêm CRC) rồi chờ khung trả lời; trả về số byte nhận được, 0 nếu quá thời gian
int hoiQuaRs485(uint8_t *khung, int doDai, int toiDa) {
uint16_t crc = tinhCrc16(khung, doDai);
khung[doDai] = crc & 0xFF;
khung[doDai + 1] = crc >> 8;
while (Serial2.available()) {
Serial2.read();
}
delay(KHOANG_LANG_MS);
digitalWrite(DE_RE_PIN, HIGH);
Serial2.write(khung, doDai + 2);
Serial2.flush();
digitalWrite(DE_RE_PIN, LOW);
unsigned long batDau = millis();
while (!Serial2.available()) {
if (millis() - batDau > THOI_GIAN_CHO_RTU_MS) {
return 0;
}
delay(1);
}
int daNhan = 0;
unsigned long lucByteCuoi = millis();
while (millis() - lucByteCuoi < KHOANG_LANG_MS) {
if (Serial2.available()) {
uint8_t giaTri = Serial2.read();
if (daNhan < toiDa) {
khung[daNhan++] = giaTri;
}
lucByteCuoi = millis();
}
}
return daNhan;
}
void xuLyBoDem() {
while (soByte >= 7) {
uint16_t maGiaoThuc = lay16(&boDem[2]);
uint16_t doDai = lay16(&boDem[4]); // 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;
}
// Khung RTU = mã thiết bị + PDU + CRC. Bảy byte MBAP chỉ dùng ở phía TCP
uint8_t diaChi = boDem[6];
uint8_t maHam = boDem[7];
uint8_t rtu[260];
memcpy(rtu, &boDem[6], doDai);
int daNhan = hoiQuaRs485(rtu, doDai, sizeof(rtu));
uint8_t traLoi[270];
memcpy(traLoi, boDem, 4); // Số giao dịch và mã giao thức giữ nguyên
traLoi[6] = diaChi;
int doDaiPdu;
bool hopLe = daNhan >= 5 && rtu[0] == diaChi
&& tinhCrc16(rtu, daNhan - 2) == (uint16_t)(rtu[daNhan - 2] | (rtu[daNhan - 1] << 8));
if (hopLe) {
doDaiPdu = daNhan - 3; // Bỏ byte địa chỉ và hai byte CRC
memcpy(&traLoi[7], &rtu[1], doDaiPdu);
Serial.printf("Dia chi %u, ma ham 0x%02X: thiet bi tra loi %d byte\n", diaChi, maHam, daNhan);
} else {
traLoi[7] = maHam | 0x80;
traLoi[8] = LOI_DICH_KHONG_TRA_LOI;
doDaiPdu = 2;
Serial.printf("Dia chi %u, ma ham 0x%02X: khong co tra loi hop le, bao loi 0x0B\n", diaChi, maHam);
}
traLoi[4] = (doDaiPdu + 1) >> 8;
traLoi[5] = (doDaiPdu + 1) & 0xFF;
mayKhach.write(traLoi, 7 + doDaiPdu);
soByte -= doDaiKhung;
memmove(boDem, boDem + doDaiKhung, soByte);
}
}
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);
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("\nCong chuyen doi Modbus TCP -> RTU: %s cong %u, RS-485 %u baud\n",
WiFi.localIP().toString().c_str(), CONG_MODBUS, (unsigned)TOC_DO_BAUD);
}
void loop() {
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();
}
}
- Trong lúc chờ thiết bị RS-485 trả lời (tối đa 500 ms), cổng chuyển đổi không nhận khung TCP mới. Với Modbus điều này là đúng: trên RS-485 mỗi lúc chỉ có một câu hỏi.
- Thời gian chờ của máy khách phía TCP phải dài hơn thời gian chờ phía RS-485, nếu không máy khách sẽ bỏ cuộc trước khi cổng chuyển đổi kịp báo lỗi.
- Khung trả lời báo lỗi của chính thiết bị RS-485 (ví dụ mã lỗi 02) được chuyển nguyên vẹn về máy khách.
6. Kết quả mong đợi
- Chương trình 1: mở Serial Monitor ở 115200 baud. Sau khi kết nối, mỗi 2 giây hiện một cặp khung và bốn giá trị. Tắt máy chủ: chương trình báo không kết nối được và tự thử lại.
- Chương trình 2: dùng một máy khách Modbus TCP (phần mềm trên máy tính hoặc chương trình 1) kết nối tới địa chỉ IP của cổng chuyển đổi, mã thiết bị 1. Dữ liệu đọc được là của thiết bị tớ trên RS-485.
- Hỏi một mã thiết bị không tồn tại, ví dụ 5: sau khoảng nửa giây máy khách nhận về mã lỗi
0x0B.
7. Lưu ý khi dùng thật
- Bảo mật: Modbus không có xác thực. Ai kết nối được tới cổng chuyển đổi cũng ghi được vào mọi thiết bị trên RS-485. Chỉ dùng trong mạng nội bộ tin cậy.
- Nhiều máy khách: chương trình phục vụ một máy khách. Cổng chuyển đổi thương mại xếp hàng các câu hỏi từ nhiều máy khách vì đường RS-485 chỉ xử lý được từng câu một.
- Mất Wi-Fi: nên thêm phần tự kết nối lại theo bài kết nối Wi-Fi trên ESP32, và đặt IP tĩnh để địa chỉ không đổi.
- Độ tin cậy: trong môi trường nhiều nhiễu sóng, Ethernet có dây ổn định hơn Wi-Fi; phần Modbus của chương trình không thay đổi.
8. 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 báo không kết nối được | Sai địa chỉ IP, thiết bị chưa bật, hoặc thiết bị đang bận với máy khách khác | Kiểm tra IP và mạng; ngắt máy khách khác |
| Phần đầu trả lời không hợp lệ | Số giao dịch lệch do còn sót khung cũ, hoặc thiết bị không nói Modbus TCP | Chương trình tự đóng và kết nối lại; kiểm tra thiết bị đúng là Modbus TCP |
| Qua cổng chuyển đổi luôn nhận lỗi 0x0B | Sai mã thiết bị, sai tốc độ baud hoặc định dạng khung phía RS-485, nối ngược A với B | Mã thiết bị phải bằng địa chỉ thiết bị tớ; đối chiếu thông số RS-485; đổi chéo hai dây |
| Máy khách hết thời gian chờ trước khi có trả lời | Thời gian chờ của máy khách ngắn hơn của cổng chuyển đổi | Đặt thời gian chờ của máy khách từ 1 giây trở lên |
| Thỉnh thoảng nhận lỗi 0x0B | Nhiễu trên RS-485 làm sai CRC, hoặc thiết bị trả lời chậm | Kiểm tra đường dây và điện trở đầu cuối; tăng THOI_GIAN_CHO_RTU_MS |
9. Xem thêm
- Kết nối Wi-Fi trên ESP32: tự kết nối lại và IP tĩnh.
- Dùng MQTT với ESP32: một hướng khác để đưa dữ liệu của thiết bị RS-485 lên mạng.
- Nạp chương trình cho ESP32 qua Wi-Fi (OTA), tiện cho thiết bị đặt trong tủ điện.
Xem thêm