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
| Phần | Số byte | Nội dung |
|---|---|---|
| Bắt đầu | 1 | Giá trị cố định 0xAA |
| Độ dài N | 1 | Số byte của phần dữ liệu, từ 0 đến 32 |
| Loại | 1 | Loại thông điệp, ví dụ 0x01 là số đo |
| Dữ liệu | N | Nội dung, ý nghĩa tùy theo loại |
| CRC | 2 | CRC-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
- 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
- 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.
- Tình huống 1 và 4: khung được nhận và giải mã ra 28,5 °C và 65,2 %.
- Tình huống 2: CRC nhận được khác CRC tính lại, khung bị bỏ.
- 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.
- 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
0xAAtrong 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ượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| Không nhận được khung nào | Chưa nối TX2 với RX2, hoặc sai tốc độ baud | Kiểm tra dây nối; hai bên dùng cùng tốc độ |
| Mọi khung đều sai CRC | Hai bên tính CRC trên phạm vi khác nhau hoặc gửi ngược thứ tự hai byte CRC | Thố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ấp | Thố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 gian | Bê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ập | Bộ đệ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
- Ngắt GPIO trên ESP32: một ví dụ khác về xử lý sự kiện không chặn chương trình.
- Dùng MQTT với ESP32: giao thức có sẵn khung tin và xác nhận, chạy trên TCP/IP.
- Các mạch ESP32 tại PYWORLD: danh mục ESP32.
Xem thêm