Tự thiết kế khung tin trên UART: byte bắt đầu, độ dài, CRC và thời gian chờ

Hướng dẫn thiết kế một khung tin trên UART gồm byte bắt đầu, độ dài, loại, dữ liệu và CRC-16, cùng bộ nhận dạng máy trạng thái trên ESP32 biết bỏ khung hỏng và tự hồi phục khi mất đồng bộ.

UART chỉ truyền một dòng byte liên tục: nó không biết thông điệp bắt đầu ở đâu, dài bao nhiêu và có bị hỏng trên đường đi không. Gửi chuỗi ký tự kết thúc bằng dấu xuống dòng là đủ cho việc thử nghiệm, nhưng khi truyền số nhị phân hoặc lệnh điều khiển thì cần một khung tin (frame) chặt chẽ hơn. Bài viết thiết kế một khung tin đơn giản gồm byte bắt đầu, độ dài, loại, dữ liệu và CRC-16, rồi viết bộ nhận dạng máy trạng thái trên ESP32, biết tự bỏ khung hỏng và tự hồi phục khi mất đồng bộ.

Các giao thức như Modbus RTU cũng giải quyết đúng những vấn đề này. Hiểu được khung tin tự thiết kế thì đọc các giao thức có sẵn sẽ dễ hơn nhiều.

1. Vì sao cần khung tin?

  • Tìm điểm đầu: bộ nhận có thể bật lên giữa chừng, hoặc mất vài byte do nhiễu. Nó cần một dấu hiệu để biết thông điệp mới bắt đầu ở đâu.
  • Biết độ dài: dữ liệu nhị phân có thể chứa bất kỳ giá trị nào, kể cả giá trị trùng với ký tự xuống dòng, nên không thể dùng một ký tự làm dấu kết thúc.
  • Phát hiện lỗi: một bit bị lật có thể biến lệnh này thành lệnh khác. Khung tin cần mã kiểm tra để bộ nhận biết mà bỏ đi.
  • Không bị treo: nếu bên gửi dừng giữa chừng, bộ nhận không được chờ mãi phần còn lại.

2. Cấu trúc khung tin

Cấu trúc khung tin tự thiết kế trên UART: byte bắt đầu 0xAA, byte độ dài, byte loại, N byte dữ liệu, hai byte CRC-16; kèm ví dụ khung AA 04 01 1D 01 8C 02 8E F2 mang nhiệt độ 28,5 độ C và độ ẩm 65,2 phần trăm
Sáu phần của khung tin và một khung ví dụ mang hai số đo.
PhầnSố byteNội dung
Bắt đầu1Giá trị cố định 0xAA
Độ dài N1Số byte của phần dữ liệu, từ 0 đến 32
Loại1Loại thông điệp, ví dụ 0x01 là số đo
Dữ liệuNNội dung, ý nghĩa tùy theo loại
CRC2CRC-16 tính từ byte độ dài đến hết dữ liệu, byte thấp gửi trước
  • Số nhiều byte trong phần dữ liệu gửi byte thấp trước. Hai bên phải thống nhất điều này; quy ước ngược lại (byte cao trước) cũng phổ biến, ví dụ trong Modbus.
  • Nhiệt độ và độ ẩm được nhân 10 rồi gửi dạng số nguyên: 28,5 °C thành 285. Cách này tránh phải truyền số thực.
  • Giới hạn độ dài 32 byte giúp bộ nhận phát hiện sớm byte độ dài bị hỏng và không cần bộ đệm lớn.

3. Bộ nhận là một máy trạng thái

Sơ đồ máy trạng thái của bộ nhận khung tin UART gồm sáu trạng thái: chờ bắt đầu, đọc độ dài, đọc loại, đọc dữ liệu, đọc CRC thấp, đọc CRC cao; lỗi độ dài hoặc quá thời gian chờ đưa bộ nhận về trạng thái chờ
Mỗi byte tới làm bộ nhận tiến một bước; lỗi ở bước nào cũng đưa nó về trạng thái chờ.
  • Bộ nhận xử lý từng byte một và nhớ mình đang ở bước nào. Nó không dùng delay() và không đứng chờ đủ cả khung, nên chương trình vẫn làm được việc khác.
  • Ở trạng thái chờ, mọi byte khác 0xAA đều bị bỏ qua. Nhờ vậy vài byte rác trước khung không gây hại.
  • Byte độ dài lớn hơn 32 bị coi là lỗi ngay.
  • Nếu đang nhận dở mà quá 50 ms không có byte mới, phần đã nhận bị bỏ. Đây là cách bộ nhận tự hồi phục khi bên gửi dừng giữa chừng.
  • Chỉ khi CRC khớp, khung mới được giao cho phần còn lại của chương trình.

4. Chuẩn bị

  • Một mạch ESP32 DevKit V1 30 chân (hoặc bản dùng chip CH340).
  • Một dây cắm nối TX2 (GPIO17) sang RX2 (GPIO16) để mạch tự gửi tự nhận.
  • 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

Chương trình tự gửi bốn tình huống qua Serial2 và in ra cách bộ nhận xử lý: một khung đúng, một khung bị lật một bit, một khung bị cắt giữa chừng, và một khung đúng đứng sau ba byte rác.

/*
  PYWORLD.VN - Tự thiết kế khung tin trên UART: byte bắt đầu, độ dài, loại, dữ liệu và CRC-16
  Nối một dây từ TX2 (GPIO17) sang RX2 (GPIO16); chương trình tự gửi bốn tình huống và cho thấy bộ nhận xử lý ra sao
  Khung: 0xAA | độ dài N | loại | N byte dữ liệu | CRC thấp | CRC cao (CRC tính từ byte độ dài đến hết dữ liệu)
*/

#define RX2_PIN 16
#define TX2_PIN 17

const uint8_t BYTE_BAT_DAU = 0xAA;
const uint8_t DO_DAI_TOI_DA = 32;
const unsigned long THOI_GIAN_CHO_BYTE_MS = 50;   // Im lặng lâu hơn mức này giữa hai byte thì bỏ khung đang nhận
const uint8_t LOAI_SO_DO = 0x01;

enum TrangThai { CHO_BAT_DAU, DOC_DO_DAI, DOC_LOAI, DOC_DU_LIEU, DOC_CRC_THAP, DOC_CRC_CAO };

TrangThai trangThai = CHO_BAT_DAU;
uint8_t doDaiNhan = 0;
uint8_t loaiNhan = 0;
uint8_t duLieuNhan[DO_DAI_TOI_DA];
uint8_t soByteDaNhan = 0;
uint8_t crcThapNhan = 0;
unsigned long lucNhanCuoi = 0;

int soKhungDung = 0, soSaiCrc = 0, soQuaThoiGian = 0, soSaiDoDai = 0;

// CRC-16 kiểu Modbus; cho phép tính nối tiếp qua nhiều đoạn bằng tham số crc
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;
}

// Đóng gói và gửi một khung; soByteGui cho phép gửi thiếu, lamHong lật một bit để thử bộ nhận
void guiKhung(uint8_t loai, const uint8_t *duLieu, uint8_t doDai, bool lamHong = false, int soByteGui = -1) {
  uint8_t khung[DO_DAI_TOI_DA + 5];
  khung[0] = BYTE_BAT_DAU;
  khung[1] = doDai;
  khung[2] = loai;
  memcpy(&khung[3], duLieu, doDai);
  uint16_t crc = tinhCrc16(&khung[1], doDai + 2);
  khung[3 + doDai] = crc & 0xFF;
  khung[4 + doDai] = crc >> 8;
  if (lamHong) {
    khung[3] ^= 0x10;
  }
  Serial2.write(khung, soByteGui < 0 ? doDai + 5 : soByteGui);
}

// Được gọi khi đã nhận trọn một khung có CRC đúng
void khiCoKhung(uint8_t loai, const uint8_t *duLieu, uint8_t doDai) {
  soKhungDung++;
  Serial.printf("   Nhan khung loai 0x%02X, %u byte:", loai, doDai);
  for (uint8_t i = 0; i < doDai; i++) {
    Serial.printf(" %02X", duLieu[i]);
  }
  if (loai == LOAI_SO_DO && doDai == 4) {
    int16_t nhietDo = (int16_t)(duLieu[0] | (duLieu[1] << 8));    // Byte thấp trước, đơn vị 0,1 độ C
    uint16_t doAm = duLieu[2] | (duLieu[3] << 8);                 // Đơn vị 0,1 %
    Serial.printf(" -> nhiet do %.1f C, do am %.1f %%", nhietDo / 10.0, doAm / 10.0);
  }
  Serial.println();
}

// Máy trạng thái: mỗi byte tới làm bộ nhận chuyển sang bước kế tiếp
void nhanByte(uint8_t giaTri) {
  switch (trangThai) {
    case CHO_BAT_DAU:
      if (giaTri == BYTE_BAT_DAU) {
        trangThai = DOC_DO_DAI;
      }
      break;
    case DOC_DO_DAI:
      if (giaTri > DO_DAI_TOI_DA) {
        soSaiDoDai++;
        Serial.println("   Do dai khong hop le: bo khung");
        trangThai = CHO_BAT_DAU;
      } else {
        doDaiNhan = giaTri;
        soByteDaNhan = 0;
        trangThai = DOC_LOAI;
      }
      break;
    case DOC_LOAI:
      loaiNhan = giaTri;
      trangThai = doDaiNhan > 0 ? DOC_DU_LIEU : DOC_CRC_THAP;
      break;
    case DOC_DU_LIEU:
      duLieuNhan[soByteDaNhan++] = giaTri;
      if (soByteDaNhan == doDaiNhan) {
        trangThai = DOC_CRC_THAP;
      }
      break;
    case DOC_CRC_THAP:
      crcThapNhan = giaTri;
      trangThai = DOC_CRC_CAO;
      break;
    case DOC_CRC_CAO: {
      uint16_t crcNhan = crcThapNhan | (giaTri << 8);
      uint8_t dau[2] = {doDaiNhan, loaiNhan};
      uint16_t crcTinh = tinhCrc16(duLieuNhan, doDaiNhan, tinhCrc16(dau, 2));
      if (crcNhan == crcTinh) {
        khiCoKhung(loaiNhan, duLieuNhan, doDaiNhan);
      } else {
        soSaiCrc++;
        Serial.printf("   Sai CRC (nhan 0x%04X, tinh 0x%04X): bo khung\n", crcNhan, crcTinh);
      }
      trangThai = CHO_BAT_DAU;
      break;
    }
  }
}

// Đọc mọi byte đang chờ và kiểm tra thời gian im lặng giữa khung
void xuLyNhan() {
  while (Serial2.available()) {
    nhanByte(Serial2.read());
    lucNhanCuoi = millis();
  }
  if (trangThai != CHO_BAT_DAU && millis() - lucNhanCuoi > THOI_GIAN_CHO_BYTE_MS) {
    soQuaThoiGian++;
    Serial.println("   Qua thoi gian cho byte tiep theo: bo phan da nhan");
    trangThai = CHO_BAT_DAU;
  }
}

void choVaNhan(unsigned long thoiGianMs) {
  unsigned long batDau = millis();
  while (millis() - batDau < thoiGianMs) {
    xuLyNhan();
    delay(1);
  }
}

void setup() {
  Serial.begin(115200);
  Serial2.begin(115200, SERIAL_8N1, RX2_PIN, TX2_PIN);
  delay(500);
}

void loop() {
  // Số đo mẫu: 28,5 độ C và 65,2 %, mỗi giá trị 2 byte, byte thấp trước
  const uint8_t soDo[4] = {0x1D, 0x01, 0x8C, 0x02};
  soKhungDung = soSaiCrc = soQuaThoiGian = soSaiDoDai = 0;

  Serial.println("\n1. Gui mot khung dung");
  guiKhung(LOAI_SO_DO, soDo, 4);
  choVaNhan(200);

  Serial.println("2. Gui khung bi lat mot bit tren duong truyen");
  guiKhung(LOAI_SO_DO, soDo, 4, true);
  choVaNhan(200);

  Serial.println("3. Gui nua khung roi im lang");
  guiKhung(LOAI_SO_DO, soDo, 4, false, 5);
  choVaNhan(200);

  Serial.println("4. Gui ba byte rac roi mot khung dung");
  const uint8_t rac[3] = {0x00, 0x55, 0xFF};
  Serial2.write(rac, 3);
  guiKhung(LOAI_SO_DO, soDo, 4);
  choVaNhan(200);

  Serial.printf("Tong ket: %d khung dung, %d sai CRC, %d qua thoi gian, %d sai do dai\n",
                soKhungDung, soSaiCrc, soQuaThoiGian, soSaiDoDai);
  delay(5000);
}

6. Kết quả mong đợi

Hình minh họa Serial Monitor của chương trình khung tin UART trên ESP32: khung đúng được nhận và giải mã, khung lật một bit bị báo sai CRC, khung bị cắt bị bỏ sau thời gian chờ, khung sau ba byte rác vẫn được nhận
Kết quả mong đợi trên Serial Monitor (các dòng tạo bằng đúng thuật toán của chương trình).
  1. Mở Tools > Serial Monitor ở 115200 baud. Mỗi 5 giây chương trình chạy lại bốn tình huống.
  2. Tình huống 1 và 4: khung được nhận và giải mã ra 28,5 °C và 65,2 %.
  3. Tình huống 2: CRC nhận được khác CRC tính lại, khung bị bỏ.
  4. Tình huống 3: sau 50 ms không có byte mới, phần đã nhận bị bỏ và khung kế tiếp không bị ảnh hưởng.
  5. Rút dây nối TX2 với RX2: không còn khung nào được nhận, dòng tổng kết báo 0 khung đúng.

7. Những điểm cần cân nhắc khi dùng thật

  • Byte bắt đầu xuất hiện trong dữ liệu: khi đang đồng bộ thì không sao, vì bộ nhận đếm theo độ dài. Nhưng nếu mất đồng bộ đúng lúc đó, bộ nhận có thể bắt nhầm một byte 0xAA trong dữ liệu làm điểm đầu. Khung nhầm này gần như chắc chắn sai CRC và bị bỏ; bộ nhận tìm lại được điểm đầu ở khung sau.
  • Trả lời và gửi lại: với lệnh quan trọng, bên nhận nên gửi lại một khung xác nhận. Bên gửi chờ xác nhận trong một khoảng thời gian; không có thì gửi lại.
  • Số thứ tự: thêm một byte đếm vào khung giúp phát hiện khung bị mất hoặc bị lặp do gửi lại.
  • Địa chỉ: khi nhiều thiết bị dùng chung một đường dây (RS-485), thêm một byte địa chỉ để biết khung gửi cho ai. Đây chính là cách Modbus RTU làm.
  • Thời gian chờ: chọn theo tốc độ baud. Ở 9600 baud một byte mất khoảng 1 ms, nên 50 ms là rộng rãi; với đường truyền có độ trễ lớn thì tăng lên.

8. Xử lý lỗi thường gặp

Hiện tượngNguyên nhân thường gặpCách xử lý
Không nhận được khung nàoChưa nối TX2 với RX2, hoặc sai tốc độ baudKiểm tra dây nối; hai bên dùng cùng tốc độ
Mọi khung đều sai CRCHai bên tính CRC trên phạm vi khác nhau hoặc gửi ngược thứ tự hai byte CRCThống nhất: CRC tính từ byte độ dài đến hết dữ liệu, byte thấp gửi trước
Số đo nhận được sai hẳn (ví dụ 7425 thay vì 285)Hai bên hiểu ngược thứ tự byte cao, byte thấpThống nhất thứ tự byte và ghi rõ trong tài liệu giao thức
Thường xuyên báo quá thời gianBên gửi gửi từng byte cách xa nhau, hoặc chương trình nhận bị chặn bởi delay()Gửi cả khung bằng một lệnh write(); gọi hàm nhận thường xuyên trong loop()
Mất khung khi gửi dồn dậpBộ đệm nhận của UART bị đầyĐọc thường xuyên hơn, hoặc tăng bộ đệm bằng setRxBufferSize() trước begin()

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

Modbus TCP/IP (phần 1): phần đầu MBAP và ESP32 làm máy chủ qua Wi-Fi
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.


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