Kiểm tra lỗi truyền dữ liệu: bit chẵn lẻ, checksum và CRC-16 của Modbus
So sánh bốn cách phát hiện lỗi truyền dữ liệu: bit chẵn lẻ, tổng kiểm tra, XOR và CRC. Giải thích từng bước thuật toán CRC-16 của Modbus và chương trình ESP32 cho thấy mã nào phát hiện được lỗi nào.
Dữ liệu đi trên dây dẫn hoặc sóng vô tuyến luôn có thể bị nhiễu làm đổi vài bit. Một lệnh đóng relay bị sai một bit có thể thành lệnh khác hẳn, vì vậy hầu hết giao thức đều gắn thêm một mã kiểm tra để bên nhận biết dữ liệu có còn nguyên vẹn không. Bài viết so sánh bốn cách thường gặp: bit chẵn lẻ, tổng kiểm tra (checksum), XOR và CRC; giải thích từng bước thuật toán CRC-16 của Modbus và dùng ESP32 để thấy mã nào phát hiện được kiểu lỗi nào.
1. Nguyên lý chung
- Bên gửi tính một giá trị từ dữ liệu theo công thức đã thống nhất và gửi kèm theo dữ liệu.
- Bên nhận tính lại giá trị đó từ dữ liệu vừa nhận. Nếu khác với giá trị nhận được thì dữ liệu đã bị hỏng và phải bỏ đi.
- Mã kiểm tra chỉ phát hiện lỗi, không sửa được lỗi. Việc gửi lại do giao thức ở lớp trên quyết định.
- Không mã nào phát hiện được mọi lỗi. Mã càng dài và công thức càng tốt thì càng ít bỏ sót.
2. Bốn cách thường gặp
Bit chẵn lẻ (parity)
- Thêm một bit sao cho tổng số bit 1 luôn là số chẵn (hoặc luôn là số lẻ). UART có sẵn tùy chọn này: định dạng 8E1 là 8 bit dữ liệu, parity chẵn, 1 bit kết thúc.
- Phát hiện được khi số bit bị lật là số lẻ. Hai bit cùng lật thì parity không đổi và lỗi lọt qua.
Tổng kiểm tra (checksum)
- Cộng mọi byte của thông điệp, chỉ giữ 8 bit thấp. Đơn giản và nhanh, dùng trong nhiều giao thức cũ và trong tệp Intel HEX.
- Không phát hiện được khi các byte đổi chỗ cho nhau, hoặc khi một byte tăng lên đúng bằng lượng byte khác giảm đi.
XOR từng byte (LRC)
- XOR mọi byte với nhau. Câu lệnh NMEA của module GPS dùng cách này.
- Có điểm yếu giống tổng kiểm tra: hai bit cùng vị trí ở hai byte cùng lật thì kết quả không đổi; đổi chỗ byte cũng không phát hiện được.
CRC
- CRC (Cyclic Redundancy Check) coi thông điệp là một số nhị phân rất dài, đem chia cho một số cố định gọi là đa thức sinh, và lấy phần dư làm mã kiểm tra. Phép chia này dùng XOR thay cho phép trừ nên chỉ cần dịch bit và XOR.
- Mỗi bit của thông điệp ảnh hưởng tới nhiều bit của kết quả theo cách phụ thuộc vào vị trí, nên các lỗi bù trừ nhau hay đổi chỗ byte đều làm kết quả thay đổi.
- CRC-16 phát hiện mọi lỗi 1 bit, 2 bit và mọi chùm lỗi liền nhau dài tới 16 bit trong các khung tin thông thường.
3. Mã nào phát hiện được lỗi nào?
| Mã kiểm tra | Số bit thêm | Bỏ sót lỗi ngẫu nhiên | Dùng ở đâu |
|---|---|---|---|
| Bit chẵn lẻ | 1 cho mỗi byte | 1/2 | UART (8E1, 8O1) |
| Tổng 8 bit | 8 | 1/256 | Tệp Intel HEX, nhiều giao thức đơn giản |
| XOR (LRC) | 8 | 1/256 | NMEA của GPS, Modbus ASCII |
| CRC-8 | 8 | 1/256 | 1-Wire, nhiều cảm biến I2C |
| CRC-16 | 16 | 1/65.536 | Modbus RTU, nhiều giao thức nối tiếp |
| CRC-32 | 32 | Khoảng 1 phần 4 tỉ | Ethernet, tệp ZIP, ảnh PNG |
4. Thuật toán CRC-16 của Modbus từng bước
- Đặt CRC =
0xFFFF. - Với mỗi byte dữ liệu: XOR byte đó vào 8 bit thấp của CRC.
- Lặp 8 lần: nếu bit thấp nhất của CRC là 1 thì dịch phải 1 bit rồi XOR với
0xA001; nếu là 0 thì chỉ dịch phải. - Sau byte cuối cùng, CRC là kết quả. Modbus RTU gửi byte thấp trước, byte cao sau.
Mỗi loại CRC được xác định bởi bốn thông số: số bit, đa thức sinh, giá trị đầu và chiều xử lý bit. Hai CRC cùng 16 bit nhưng khác một trong các thông số này sẽ cho kết quả khác nhau. Vì vậy mỗi loại CRC có một giá trị chuẩn tính trên chuỗi "123456789" để kiểm tra chương trình: với CRC-16 của Modbus là 0x4B37, với CRC-8 của 1-Wire là 0xA1.
5. Chuẩn bị
- Một mạch ESP32 DevKit V1 30 chân (hoặc bản dùng chip CH340) và cáp USB có truyền dữ liệu. Không cần nối thêm linh kiệ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.
6. Chương trình so sánh năm mã kiểm tra
Chương trình tính bit chẵn lẻ, tổng 8 bit, XOR, CRC-8 và CRC-16 cho thông điệp PYWORLD. Sau đó nó làm hỏng thông điệp theo ba kiểu và so mã của bản hỏng với mã của bản gốc: mã nào khác đi nghĩa là mã đó phát hiện được lỗi.
/*
PYWORLD.VN - Kiểm tra lỗi truyền dữ liệu: so sánh bit chẵn lẻ, tổng kiểm tra, XOR, CRC-8 và CRC-16
Chương trình tính năm mã kiểm tra cho một thông điệp, rồi cố ý làm hỏng thông điệp theo ba kiểu
và cho biết mã nào phát hiện được lỗi. Chỉ cần mạch ESP32, không cần nối thêm gì
*/
struct MaKiemTra {
uint8_t chanLe;
uint8_t tong;
uint8_t phepXor;
uint8_t crc8;
uint16_t crc16;
};
// Bit chẵn lẻ của cả thông điệp: 1 nếu tổng số bit 1 là số lẻ
uint8_t tinhChanLe(const uint8_t *duLieu, size_t soByte) {
uint8_t ketQua = 0;
for (size_t i = 0; i < soByte; i++) {
for (int k = 0; k < 8; k++) {
ketQua ^= (duLieu[i] >> k) & 1;
}
}
return ketQua;
}
// Tổng kiểm tra: cộng mọi byte, chỉ giữ 8 bit thấp
uint8_t tinhTong(const uint8_t *duLieu, size_t soByte) {
uint8_t tong = 0;
for (size_t i = 0; i < soByte; i++) {
tong += duLieu[i];
}
return tong;
}
// XOR mọi byte với nhau (còn gọi là LRC)
uint8_t tinhXor(const uint8_t *duLieu, size_t soByte) {
uint8_t ketQua = 0;
for (size_t i = 0; i < soByte; i++) {
ketQua ^= duLieu[i];
}
return ketQua;
}
// CRC-8 của 1-Wire: đa thức x^8 + x^5 + x^4 + 1 (0x31, viết đảo là 0x8C), giá trị đầu 0
uint8_t tinhCrc8(const uint8_t *duLieu, size_t soByte) {
uint8_t crc = 0;
for (size_t i = 0; i < soByte; i++) {
crc ^= duLieu[i];
for (int k = 0; k < 8; k++) {
crc = (crc & 0x01) ? (crc >> 1) ^ 0x8C : crc >> 1;
}
}
return crc;
}
// CRC-16 của Modbus: đa thức x^16 + x^15 + x^2 + 1 (0x8005, viết đảo là 0xA001), giá trị đầu 0xFFFF
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;
}
MaKiemTra tinhTatCa(const uint8_t *duLieu, size_t soByte) {
MaKiemTra ma;
ma.chanLe = tinhChanLe(duLieu, soByte);
ma.tong = tinhTong(duLieu, soByte);
ma.phepXor = tinhXor(duLieu, soByte);
ma.crc8 = tinhCrc8(duLieu, soByte);
ma.crc16 = tinhCrc16(duLieu, soByte);
return ma;
}
const char *ketQua(bool phatHien) {
return phatHien ? "co " : "KHONG";
}
// So mã kiểm tra của thông điệp bị hỏng với mã của thông điệp gốc: khác nhau nghĩa là phát hiện được lỗi
void soSanh(const char *tenLoi, const uint8_t *hong, size_t soByte, const MaKiemTra &goc) {
MaKiemTra ma = tinhTatCa(hong, soByte);
Serial.printf("%-24s chan le %s | tong %s | XOR %s | CRC-8 %s | CRC-16 %s\n", tenLoi,
ketQua(ma.chanLe != goc.chanLe), ketQua(ma.tong != goc.tong), ketQua(ma.phepXor != goc.phepXor),
ketQua(ma.crc8 != goc.crc8), ketQua(ma.crc16 != goc.crc16));
}
void setup() {
Serial.begin(115200);
delay(500);
// Kiểm tra hai hàm CRC bằng chuỗi chuẩn "123456789": kết quả đúng là 0xA1 và 0x4B37
const uint8_t chuoiChuan[] = "123456789";
Serial.printf("\nChuoi chuan \"123456789\": CRC-8 = 0x%02X, CRC-16 = 0x%04X\n",
tinhCrc8(chuoiChuan, 9), tinhCrc16(chuoiChuan, 9));
// Khung Modbus đọc 2 thanh ghi: 01 03 00 00 00 02, CRC gửi byte thấp trước
const uint8_t khungModbus[] = {0x01, 0x03, 0x00, 0x00, 0x00, 0x02};
uint16_t crc = tinhCrc16(khungModbus, sizeof(khungModbus));
Serial.printf("Khung Modbus 01 03 00 00 00 02: hai byte CRC tren day la %02X %02X\n", crc & 0xFF, crc >> 8);
const uint8_t goc[] = {'P', 'Y', 'W', 'O', 'R', 'L', 'D'};
const size_t soByte = sizeof(goc);
MaKiemTra maGoc = tinhTatCa(goc, soByte);
Serial.printf("\nThong diep \"PYWORLD\": chan le %u, tong 0x%02X, XOR 0x%02X, CRC-8 0x%02X, CRC-16 0x%04X\n",
maGoc.chanLe, maGoc.tong, maGoc.phepXor, maGoc.crc8, maGoc.crc16);
Serial.println("Ma nao phat hien duoc tung kieu loi?");
uint8_t hong[sizeof(goc)];
// Lỗi 1: lật một bit
memcpy(hong, goc, soByte);
hong[0] ^= 0x04;
soSanh("Lat 1 bit:", hong, soByte, maGoc);
// Lỗi 2: lật hai bit cùng vị trí ở hai byte, một bit từ 0 lên 1 và một bit từ 1 xuống 0
memcpy(hong, goc, soByte);
hong[0] ^= 0x04;
hong[2] ^= 0x04;
soSanh("Lat 2 bit bu nhau:", hong, soByte, maGoc);
// Lỗi 3: hai byte đổi chỗ cho nhau
memcpy(hong, goc, soByte);
hong[2] = goc[3];
hong[3] = goc[2];
soSanh("Doi cho 2 byte:", hong, soByte, maGoc);
}
void loop() {
}
Hai hàm tinhCrc8() và tinhCrc16() tính từng bit nên dễ đọc. Khi cần tốc độ, có thể đổi sang cách tra bảng 256 phần tử: mỗi byte chỉ cần một lần tra bảng thay cho 8 lần dịch.
7. Kết quả mong đợi
- Mở Tools > Serial Monitor ở 115200 baud và nhấn nút EN.
- Hai dòng đầu xác nhận hai hàm CRC đúng:
0xA1,0x4B37và hai byteC4 0Bcủa khung Modbus mẫu. - Ba dòng cuối cho thấy: lật 1 bit thì mã nào cũng phát hiện; lật 2 bit bù nhau và đổi chỗ 2 byte thì chỉ CRC phát hiện.
8. Chọn mã nào?
- Khung tin ngắn trên dây ngắn, ít nhiễu: tổng 8 bit hoặc XOR có thể đủ.
- Dây dài, môi trường công nghiệp, lệnh điều khiển: dùng CRC-16. Chi phí chỉ là hai byte và vài chục phép tính cho mỗi byte.
- Tệp lớn, cập nhật chương trình: dùng CRC-32 hoặc mạnh hơn.
- Bit chẵn lẻ của UART và CRC của khung tin có thể dùng cùng lúc: parity bảo vệ từng byte, CRC bảo vệ cả khung.
9. Lỗi thường gặp khi tự viết CRC
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| CRC tính ra không khớp với thiết bị | Dùng nhầm loại CRC: sai đa thức, sai giá trị đầu hoặc sai chiều bit | Thử với chuỗi "123456789" và so với giá trị chuẩn của đúng loại CRC |
| Hai byte CRC bị ngược | Gửi byte cao trước trong khi giao thức yêu cầu byte thấp trước | Modbus RTU: byte thấp trước. Xem quy định của từng giao thức |
| CRC sai với mọi khung tin | Tính CRC trên sai phạm vi: thừa hoặc thiếu byte đầu, tính cả hai byte CRC | Modbus RTU tính từ byte địa chỉ đến hết dữ liệu, không gồm hai byte CRC |
| CRC đúng nhưng dữ liệu vẫn vô nghĩa | CRC chỉ bảo đảm dữ liệu không đổi trên đường truyền, không bảo đảm bên gửi gửi đúng | Kiểm tra thêm phạm vi giá trị ở lớp ứng dụng |
| Dùng biến 8 bit cho CRC-16 | Kết quả bị cắt còn 8 bit | Dùng kiểu uint16_t cho CRC-16 |
10. Xem thêm
- Dùng MQTT với ESP32: ở đó việc kiểm tra lỗi do TCP/IP lo.
- Lưu dữ liệu vào bộ nhớ flash của ESP32: mã kiểm tra cũng dùng để phát hiện dữ liệu lưu trữ bị hỏng.
- Các mạch ESP32 tại PYWORLD: danh mục ESP32.
Xem thêm