第4章 数据链路层(一):HDLC协议帧结构,标志字节与比特填充,CRC-16/CCITT校验

各位同学,咱们今天正式进入数据链路层。说实话,这一层在牵引控制单元(TCU)的通信协议栈里,是真正干“苦活累活”的地方。物理层只管把0和1发出去,但发出去之后,怎么让接收方知道“这一帧从哪开始、到哪结束”?怎么保证数据没被干扰?这些就是数据链路层要解决的问题。

我最早接触HDLC协议,是在一个老款的机车通信项目上。那时候刚入行,觉得这协议“又老又土”,后来才发现,越是恶劣的工业环境,HDLC这种简单可靠的设计反而越吃香。你想想看,牵引控制单元里电磁干扰那么强,你要是用个花里胡哨的协议,可能帧都收不全。

4.1 HDLC协议帧结构

HDLC的全称是高级数据链路控制。名字听着唬人,其实帧结构就那么几个部分。我习惯把它拆成三块:头、数据、尾。

字段 长度(字节) 说明
标志(Flag) 1 固定为0x7E,帧的开始和结束标记
地址(Address) 1~2 标识从站地址,TCU中常用1字节
控制(Control) 1~2 帧类型:信息帧、监控帧、无编号帧
信息(Information) 可变 实际要传的数据,比如牵引力指令
帧校验(FCS) 2 CRC-16/CCITT校验结果
标志(Flag) 1 同样是0x7E,表示帧结束

这里有个细节我要强调一下:起始标志和结束标志都是0x7E。也就是说,接收方只要检测到0x7E,就知道一帧开始了,再检测到下一个0x7E,就知道帧结束了。听起来很简单对吧?但问题来了——如果数据里恰好也出现了0x7E怎么办?

核心要点:HDLC协议规定,帧头和帧尾的标志字节是唯一的。数据中不允许出现连续的0x7E,否则接收方会误判帧边界。

4.2 标志字节(0x7E)与比特填充机制

好,咱们接着聊这个“0x7E撞车”的问题。0x7E的二进制是01111110,你看,连续6个1。HDLC的解决方案很巧妙——比特填充

规则只有一句话:发送方在数据中每遇到连续的5个1,就自动在后面插入一个0。接收方收到数据后,每检测到5个连续的1,就把后面的0删掉。

举个例子,假设数据里有这么一段:01111110。发送方看到“011111”这5个1,就在后面加个0,变成011111010。接收方收到后,看到“0111110”,知道这是填充的0,删掉,还原成01111110

我曾经在一个项目中遇到过这样的坑:有个同事自己实现了HDLC协议,但比特填充的逻辑写错了,只在数据中间做填充,忽略了地址字段和控制字段也可能出现0x7E。结果呢?帧同步老是丢,找了两天bug才发现是填充范围没覆盖全。

避坑指南:比特填充必须作用于整个帧内容(地址+控制+信息+FCS),但不包括起始和结束标志。我曾经见过有人把标志字节也拿去填充了,结果接收方根本找不到帧头。

你可能会问:那接收方怎么区分“真正的0x7E”和“填充后的0x7E”?很简单——真正的0x7E是连续的6个1,而填充后的0x7E是5个1加1个0再加1个1。接收方只要看到6个连续的1,就知道这是帧标志,不是数据。

4.3 CRC-16/CCITT校验算法实现

帧结构搭好了,比特填充也解决了,但还有一个关键问题:数据在传输过程中被干扰了怎么办?牵引控制单元的工作环境,电机一启动,电磁干扰能把信号打成筛子。这时候就需要CRC校验了。

HDLC协议用的是CRC-16/CCITT,生成多项式是:x^16 + x^12 + x^5 + 1,对应的二进制是0x1021。这个多项式是国际标准,咱们直接拿来用就行。

我个人的习惯是,在嵌入式系统里用查表法实现CRC,因为计算速度快,适合实时通信。下面是我在TCU项目里用过的代码,你可以直接参考:

// CRC-16/CCITT 查表法实现
// 生成多项式: 0x1021 (x^16 + x^12 + x^5 + 1)
// 初始值: 0xFFFF
// 结果异或值: 0x0000

#include <stdint.h>

// 预计算CRC表
static uint16_t crc_table[256];
static uint8_t table_initialized = 0;

void init_crc16_table(void) {
    uint16_t crc;
    for (uint16_t i = 0; i < 256; i++) {
        crc = i << 8;
        for (uint8_t j = 0; j < 8; j++) {
            if (crc & 0x8000)
                crc = (crc << 1) ^ 0x1021;
            else
                crc = crc << 1;
        }
        crc_table[i] = crc;
    }
    table_initialized = 1;
}

// 计算CRC-16/CCITT
uint16_t calc_crc16(uint8_t *data, uint16_t len) {
    if (!table_initialized) {
        init_crc16_table();
    }
    
    uint16_t crc = 0xFFFF;  // 初始值
    for (uint16_t i = 0; i < len; i++) {
        uint8_t index = ((crc >> 8) ^ data[i]) & 0xFF;
        crc = (crc << 8) ^ crc_table[index];
    }
    return crc;  // 结果不需要异或
}

注意:CRC计算的范围是地址字段 + 控制字段 + 信息字段,不包括标志字节。另外,发送时CRC结果要按高字节在前、低字节在后的顺序放入FCS字段。我见过有人把字节序搞反了,结果两边CRC永远对不上。

接收方收到一帧后,会做同样的CRC计算。如果计算结果和收到的FCS字段一致,说明数据没被篡改;如果不一致,直接丢弃这一帧,并请求重传。在牵引控制单元里,我一般会设置连续3次CRC错误就报警,让司机知道通信链路可能出问题了。

嗯,这里再补充一个经验。CRC-16/CCITT的检错能力很强,能检测出所有单比特错误、双比特错误、奇数个比特错误,以及长度不超过16比特的突发错误。但在强电磁干扰环境下,我建议你再加一层“超时重传”机制。CRC只能告诉你“数据坏了”,但没法保证“数据一定能到”。

4.4 小结

这一章咱们把HDLC协议的核心机制捋了一遍。帧结构就是“标志-地址-控制-信息-FCS-标志”,比特填充解决了0x7E冲突的问题,CRC-16/CCITT保证了数据完整性。这三个东西组合起来,就是数据链路层最基础的“可靠传输”能力。

下一章我会接着讲HDLC的三种帧类型——信息帧、监控帧、无编号帧,以及它们是怎么配合实现流量控制和差错控制的。到时候咱们再聊。