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?

So sánh khung Modbus RTU gồm địa chỉ, mã hàm, dữ liệu, CRC với khung Modbus TCP gồm phần đầu MBAP bảy byte là số giao dịch, mã giao thức, độ dài, mã thiết bị, rồi tới mã hàm và dữ liệu giống hệt
Phần mã hàm và dữ liệu (PDU) giống nhau; Modbus TCP thay địa chỉ và CRC bằng phần đầu MBAP.
Trường của MBAPSố byteÝ nghĩa
Số giao dịch2Má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ức2Luôn bằng 0 với Modbus
Độ dài2Số byte đứng sau trường này: mã thiết bị cộng PDU
Mã thiết bị1Tươ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

Ví dụ khung Modbus TCP: khung hỏi 12 byte đọc bốn thanh ghi giữ, khung trả lời 17 byte với trường độ dài bằng 11, khung hỏi sai địa chỉ và khung báo lỗi mang mã hàm 0x83, mã lỗi 02
Khung hỏi, khung trả lời và khung báo lỗi của Modbus TCP.
  • 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

Sơ đồ mạng Modbus TCP: máy tính hoặc HMI làm máy khách nối qua router Wi-Fi tới ESP32 làm máy chủ ở cổng 502, kèm bảng thiết lập địa chỉ IP, cổng, mã thiết bị và địa chỉ thanh ghi trên máy khách
Máy khách kết nối tới địa chỉ IP của ESP32, cổng 502, qua mạng nội bộ.
  • 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

Hình minh họa Serial Monitor của ESP32 làm máy chủ Modbus TCP: địa chỉ IP và cổng 502, máy khách kết nối, nhật ký các mã hàm 0x03, 0x05, 0x06 và báo lỗi 0x02 khi địa chỉ vượt bảng thanh ghi
Nhật ký của máy chủ trên Serial Monitor (hình minh họa; địa chỉ IP là ví dụ).
  1. 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.
  2. 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.
  3. Ghi cuộn 0 bằng mã hàm 0x05: LED xanh trên mạch sáng hoặc tắt.
  4. 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ượngNguyên nhân thường gặpCách xử lý
Máy khách không kết nối đượcSai địa chỉ IP, khác mạng, hoặc router cách ly các thiết bị Wi-FiXem 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 gianPhần mềm đang ở chế độ "RTU qua TCP" thay vì Modbus TCPChọn đúng kiểu Modbus TCP (có phần đầu MBAP, không CRC)
In ra Phan dau MBAP khong hop leChương trình khác đang gửi dữ liệu không phải Modbus tới cổng 502Kiể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ênPhần mềm đánh số từ 1 nên gửi địa chỉ lệchChọ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 đượcChương trình chỉ phục vụ một máy kháchNgắ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ùngMáy chủ đóng kết nối im lặng quá 60 giâyCho máy khách đọc định kỳ, hoặc tăng THOI_GIAN_IM_LANG_MS

8. 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.

TCP và UDP là gì: địa chỉ IP, cổng, kết nối và cách dùng trên ESP32
TCP và UDP là gì: địa chỉ IP, cổng, kết nối và cách dùng trên ESP32

Giải thích TCP và UDP cho người làm vi điều khiển: địa chỉ IP, cổng, kết nối, xác nhận, vì sao TCP là dòng byte. Kèm hai chương trình ESP32: máy chủ TCP nhận lệnh và phát, nhận gói UDP trong mạng Wi-Fi.


Bình luận
Đã thêm vào giỏ hàng