3. 数据链路层设计:帧结构定义与字节填充

好,咱们进入数据链路层。这一层说白了,就是给原始数据「穿上衣服」。你想想看,物理层只管收发比特流,它可不管这串01到底是什么意思。那怎么让接收方知道「一帧数据从哪开始、到哪结束、中间有没有被干扰」?这就是数据链路层要干的事。

我个人习惯把数据链路层比作「快递包裹」。你得有包装盒(帧结构)、有收件人地址(地址域)、有封条(校验)、还得有防拆设计(转义)。今天咱们就重点聊聊这个包裹怎么设计。

3.1 帧结构定义:一个靠谱的包裹长什么样

先看一个我常用的帧结构模板。这个结构我在三个量产项目里验证过,稳定跑了五六年没出过问题:

| 起始符(1B) | 长度(1B) | 地址(1B) | 命令(1B) | 数据(NB) | 校验(1B) | 结束符(1B) |
|    0x7E    |   N+5    |  0x01    |  0x10    |  ...     |  CRC8    |   0x7F    |

每个字段都有它的讲究。咱们一个一个拆开看:

字段 长度 说明 我的建议
起始符 1字节 帧开始的标志 选0x7E,避开常见ASCII字符
长度 1字节 从地址到校验的总字节数 最大255,够用
地址 1字节 设备地址或广播地址 0xFF做广播
命令 1字节 功能码 0x00-0x7F为系统保留
数据 N字节 有效载荷 长度由长度字段决定
校验 1字节 CRC8或累加和 CRC8更可靠
结束符 1字节 帧结束标志 选0x7F,与起始符不同

核心原则:起始符和结束符必须不同。我见过有人偷懒用同一个字符,结果数据里出现这个字符时,接收方直接懵了——到底是帧结束还是帧开始?

3.2 长度字段:别小看这一个字节

长度字段的设计,我踩过坑。早期我做的一个项目,长度字段只包含数据域的长度。结果接收方收到帧后,得先解析长度,再回头算地址+命令+数据的总长。代码写起来绕来绕去,调试时差点没把自己绕晕。

我的改进方案:长度字段包含从地址到校验的所有字节数。这样接收方拿到长度后,直接就能算出「还需要收多少字节」。代码干净利落:

// 接收一帧数据
uint8_t frame_len = rx_buffer[1];  // 长度字段
// 还需要接收的字节数 = frame_len - 已收到的字节数
uint8_t remain = frame_len - 3;    // 减掉起始符、长度、结束符

小技巧:长度字段的最大值要留够。如果你的数据域可能达到200字节,长度字段用1字节(最大255)就够。如果数据域可能超过250字节,建议用2字节长度字段。我在一个图像传输项目里就吃过这个亏,后来改成2字节才搞定。

3.3 校验:别让噪声毁了你的数据

校验方式我推荐CRC8。为什么不是累加和?说白了,累加和太容易被「骗」了。比如数据里两个字节同时出错,累加和可能刚好抵消,接收方还以为数据是对的。

CRC8的查表法实现,我直接贴代码:

// CRC8查表法,多项式0x31(x8 + x5 + x4 + 1)
uint8_t crc8_table[256];
void crc8_init() {
    for (uint16_t i = 0; i < 256; i++) {
        uint8_t crc = i;
        for (uint8_t j = 0; j < 8; j++) {
            if (crc & 0x80)
                crc = (crc << 1) ^ 0x31;
            else
                crc <<= 1;
        }
        crc8_table[i] = crc;
    }
}

uint8_t crc8_calc(uint8_t *data, uint8_t len) {
    uint8_t crc = 0;
    for (uint8_t i = 0; i < len; i++) {
        crc = crc8_table[crc ^ data[i]];
    }
    return crc;
}

注意:CRC的初始值和异或值要跟接收方约定好。我曾经在两个团队联调时,双方用的CRC初始值不一样,查了三天才找到原因。嗯,从那以后我都在协议文档里把CRC参数写得明明白白。

3.4 字节填充与转义:解决「数据冲突」的终极方案

好,现在问题来了。如果数据域里恰好出现了0x7E(起始符)或者0x7F(结束符),接收方会怎么处理?它会以为帧提前结束了,或者新帧开始了。这就是所谓的「数据冲突」。

解决方案:字节填充(Byte Stuffing)。说白了,就是在发送前把数据里的特殊字符「藏起来」,接收后再「还原」。我常用的转义规则:

  • 转义字符:0x7D
  • 规则:
    • 遇到0x7E → 替换为 0x7D 0x5E
    • 遇到0x7F → 替换为 0x7D 0x5F
    • 遇到0x7D → 替换为 0x7D 0x5D

为什么选0x5E、0x5F、0x5D?其实就是把原字节的第5位取反。这样接收方看到0x7D就知道后面跟着的是转义后的数据,把0x7D去掉,再把后面的字节第5位取反就还原了。

发送端的实现:

uint8_t tx_buffer[256];
uint8_t stuff_frame(uint8_t *raw, uint8_t raw_len) {
    uint8_t idx = 0;
    tx_buffer[idx++] = 0x7E;  // 起始符
    for (uint8_t i = 0; i < raw_len; i++) {
        if (raw[i] == 0x7E) {
            tx_buffer[idx++] = 0x7D;
            tx_buffer[idx++] = 0x5E;
        } else if (raw[i] == 0x7F) {
            tx_buffer[idx++] = 0x7D;
            tx_buffer[idx++] = 0x5F;
        } else if (raw[i] == 0x7D) {
            tx_buffer[idx++] = 0x7D;
            tx_buffer[idx++] = 0x5D;
        } else {
            tx_buffer[idx++] = raw[i];
        }
    }
    tx_buffer[idx++] = 0x7F;  // 结束符
    return idx;
}

接收端的实现:

uint8_t rx_buffer[256];
uint8_t unstuff_byte(uint8_t *data, uint8_t *idx, uint8_t len) {
    if (*idx >= len) return 0;
    uint8_t byte = data[*idx];
    (*idx)++;
    if (byte == 0x7D) {
        // 转义字符,下一个字节需要还原
        if (*idx >= len) return 0;
        uint8_t next = data[*idx];
        (*idx)++;
        return next ^ 0x20;  // 第5位取反
    }
    return byte;
}

避坑指南:我曾经在一个项目里忘了对转义字符本身做转义。结果数据里出现0x7D时,接收方直接把它当成转义字符处理,把后面的正常数据也给「还原」了。那叫一个惨,整个数据流全乱了。记住:转义字符本身也要转义!

3.5 实际项目中的经验总结

做了这么多年嵌入式,我总结了几条数据链路层的「铁律」:

  1. 起始符和结束符一定要不同。别问我为什么,问就是血的教训。
  2. 长度字段要包含校验域。这样接收方可以提前知道总帧长,方便分配缓冲区。
  3. 转义规则要覆盖所有特殊字符。包括起始符、结束符、转义符本身。
  4. 校验要覆盖从地址到数据的全部内容。起始符和结束符不需要校验,因为它们只是帧边界。
  5. 接收方要有超时机制。如果收到起始符后长时间没收到结束符,要清空缓冲区重新开始。我在一个485总线项目里,就是因为没做超时,结果一帧坏数据卡死了整个通信链路。

好了,数据链路层的帧结构和字节填充就聊到这儿。下一节咱们聊聊更高级的话题——怎么在不可靠的链路上保证数据可靠传输。说白了,就是「丢了怎么办、错了怎么办、重了怎么办」。到时候见。