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

Nguyên lý mã kiểm tra lỗi: bên gửi tính mã kiểm tra từ dữ liệu và gửi kèm, bên nhận tính lại rồi so sánh, hai mã khác nhau nghĩa là dữ liệu đã hỏng
Bên gửi gắn mã kiểm tra vào sau dữ liệu; bên nhận tính lại và so sánh.
  • 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?

Bảng so sánh khả năng phát hiện lỗi của bit chẵn lẻ, tổng 8 bit, XOR, CRC-8 và CRC-16 với ba kiểu lỗi: lật một bit, lật hai bit bù nhau và đổi chỗ hai byte, kèm số bit gắn thêm và xác suất bỏ sót lỗi ngẫu nhiên
Kết quả tính cho ba kiểu lỗi trên thông điệp "PYWORLD": chỉ CRC phát hiện được cả ba.
Mã kiểm traSố bit thêmBỏ sót lỗi ngẫu nhiênDùng ở đâu
Bit chẵn lẻ1 cho mỗi byte1/2UART (8E1, 8O1)
Tổng 8 bit81/256Tệp Intel HEX, nhiều giao thức đơn giản
XOR (LRC)81/256NMEA của GPS, Modbus ASCII
CRC-881/2561-Wire, nhiều cảm biến I2C
CRC-16161/65.536Modbus RTU, nhiều giao thức nối tiếp
CRC-3232Khoả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

Lưu đồ thuật toán CRC-16 của Modbus và bảng tám lần dịch bit cho byte đầu tiên 0x01: giá trị đầu 0xFFFF, XOR với byte, dịch phải, XOR với 0xA001 khi bit thấp nhất bằng 1
Các bước tính CRC-16 và 8 lần dịch cho byte đầu tiên của khung 01 03 00 00 00 02.
  1. Đặt CRC = 0xFFFF.
  2. Với mỗi byte dữ liệu: XOR byte đó vào 8 bit thấp của CRC.
  3. 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.
  4. 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ị

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

Hình minh họa Serial Monitor của chương trình kiểm tra lỗi trên ESP32: giá trị chuẩn CRC-8 0xA1 và CRC-16 0x4B37, hai byte CRC C4 0B của khung Modbus, và bảng mã nào phát hiện được ba kiểu lỗi
Kết quả mong đợi trên Serial Monitor (các giá trị tính bằng đúng thuật toán của chương trình).
  1. Mở Tools > Serial Monitor ở 115200 baud và nhấn nút EN.
  2. Hai dòng đầu xác nhận hai hàm CRC đúng: 0xA1, 0x4B37 và hai byte C4 0B của khung Modbus mẫu.
  3. 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ượngNguyên nhân thường gặpCá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 bitThử với chuỗi "123456789" và so với giá trị chuẩn của đúng loại CRC
Hai byte CRC bị ngượcGửi byte cao trước trong khi giao thức yêu cầu byte thấp trướcModbus RTU: byte thấp trước. Xem quy định của từng giao thức
CRC sai với mọi khung tinTính CRC trên sai phạm vi: thừa hoặc thiếu byte đầu, tính cả hai byte CRCModbus 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ĩaCRC 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 đúngKiểm tra thêm phạm vi giá trị ở lớp ứng dụng
Dùng biến 8 bit cho CRC-16Kết quả bị cắt còn 8 bitDùng kiểu uint16_t cho CRC-16

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