4. 数据链路层实现:帧结构设计、CRC校验实现、数据封装与解封装、错误检测与重传机制

数据链路层,说白了就是给原始比特流「穿上衣服」。我刚开始做运动控制时,总觉得这层就是个打包拆包的活儿,没什么技术含量。直到有一次,现场总线上的数据莫名其妙地乱跳,电机跟着抽风……嗯,从那以后我再也不敢小看这一层了。

这一层要解决的核心问题就三个:怎么把数据包好?怎么保证数据没坏?坏了怎么办? 咱们一个一个来拆解。

4.1 帧结构设计:给数据画个框

你想想看,物理层传过来的就是一堆0和1,接收方怎么知道从哪里开始读、到哪里结束?所以我们需要一个「帧」的概念——就像寄快递,你得有个包裹,上面写清楚地址、收件人、里面装了什么。

我个人习惯的帧结构长这样:

| 帧头(2B) | 长度(1B) | 类型(1B) | 数据(NB) | CRC(2B) | 帧尾(1B) |

每个字段的讲究:

  • 帧头:固定值,比如 0xAA 0x55。接收方看到这个就知道「新的一帧来了」。我在项目中遇到过,帧头选得太简单(比如单字节 0xAA),结果数据里偶尔也出现 0xAA,导致帧同步错乱。后来改成双字节,问题就解决了。
  • 长度:数据域的实际字节数。注意,这个长度不包括帧头、CRC和帧尾。为什么?因为接收方要先知道数据有多长,才能正确提取。
  • 类型:用来区分是控制指令、状态反馈还是心跳包。比如 0x01 表示位置指令,0x02 表示速度指令。
  • 数据:真正的载荷。比如电机目标位置、PID参数等。
  • CRC:校验码,后面细说。
  • 帧尾:固定值,比如 0x0D。其实有了长度字段,帧尾不是必须的。但我习惯加一个,作为「二次确认」——万一长度字段传错了呢?
核心原则:帧结构要「自描述」。接收方不需要任何额外信息,仅凭收到的字节流就能完整解析出一帧数据。

4.2 CRC校验实现:给数据加把锁

数据在总线上传输,难免受到干扰。比如电机启动时的大电流,就可能让某个bit翻转。CRC(循环冗余校验)就是用来检测这种错误的。

CRC的原理不复杂:把数据看作一个多项式,用约定的生成多项式去除,余数就是CRC码。接收方用同样的方法计算,如果余数不为0,说明数据出错了。

我常用的CRC-16-IBM多项式:x^16 + x^15 + x^2 + 1,对应十六进制 0x8005

直接上代码,这是我项目里用烂了的实现:

uint16_t crc16_ibm(uint8_t *data, uint16_t len) {
    uint16_t crc = 0x0000;  // 初始值
    uint16_t poly = 0x8005; // 生成多项式

    for (uint16_t i = 0; i < len; i++) {
        crc ^= (data[i] << 8);  // 把数据字节移到高位
        for (uint8_t j = 0; j < 8; j++) {
            if (crc & 0x8000) {
                crc = (crc << 1) ^ poly;
            } else {
                crc <<= 1;
            }
        }
    }
    return crc;
}
避坑指南:我曾经在CRC初始值上吃过亏。有的协议用 0xFFFF,有的用 0x0000。如果两端不一致,永远校验不过。所以一定要和通信对端确认好初始值和多项式。

实际项目中,我建议用查表法代替逐位计算。查表法提前算好256个字节对应的CRC值,运行时直接查表,速度能快十几倍。对于运动控制这种实时性要求高的场景,这点优化很关键。

4.3 数据封装与解封装:打包与拆包

封装,就是把上层传下来的数据,加上帧头、长度、类型、CRC、帧尾,变成一个完整的帧。解封装就是反过来,从收到的字节流里提取出原始数据。

封装函数很简单:

void frame_pack(uint8_t type, uint8_t *data, uint16_t len, uint8_t *frame, uint16_t *frame_len) {
    uint16_t idx = 0;
    frame[idx++] = 0xAA;  // 帧头
    frame[idx++] = 0x55;
    frame[idx++] = len;   // 长度
    frame[idx++] = type;  // 类型
    memcpy(&frame[idx], data, len);  // 数据
    idx += len;
    uint16_t crc = crc16_ibm(&frame[2], idx - 2);  // 从长度字段开始算CRC
    frame[idx++] = (crc >> 8) & 0xFF;
    frame[idx++] = crc & 0xFF;
    frame[idx++] = 0x0D;  // 帧尾
    *frame_len = idx;
}

解封装就麻烦一些。因为数据是流式到达的,你没法保证一次收完一整个帧。所以需要一个状态机来逐字节处理:

typedef enum {
    WAIT_HEAD1,
    WAIT_HEAD2,
    WAIT_LEN,
    WAIT_TYPE,
    WAIT_DATA,
    WAIT_CRC_H,
    WAIT_CRC_L,
    WAIT_TAIL
} frame_state_t;

frame_state_t state = WAIT_HEAD1;
uint8_t rx_buf[256];
uint16_t rx_idx = 0;
uint8_t expected_len = 0;

void frame_unpack(uint8_t byte) {
    switch (state) {
        case WAIT_HEAD1:
            if (byte == 0xAA) state = WAIT_HEAD2;
            break;
        case WAIT_HEAD2:
            if (byte == 0x55) state = WAIT_LEN;
            else state = WAIT_HEAD1;  // 重新同步
            break;
        case WAIT_LEN:
            expected_len = byte;
            rx_idx = 0;
            state = WAIT_TYPE;
            break;
        case WAIT_TYPE:
            rx_buf[rx_idx++] = byte;
            state = WAIT_DATA;
            break;
        case WAIT_DATA:
            rx_buf[rx_idx++] = byte;
            if (rx_idx == expected_len + 1) state = WAIT_CRC_H;
            break;
        case WAIT_CRC_H:
            rx_buf[rx_idx++] = byte;
            state = WAIT_CRC_L;
            break;
        case WAIT_CRC_L:
            rx_buf[rx_idx++] = byte;
            state = WAIT_TAIL;
            break;
        case WAIT_TAIL:
            if (byte == 0x0D) {
                // 校验CRC
                uint16_t calc_crc = crc16_ibm(&rx_buf[0], expected_len + 1);
                uint16_t recv_crc = (rx_buf[expected_len + 1] << 8) | rx_buf[expected_len + 2];
                if (calc_crc == recv_crc) {
                    // 帧有效,回调上层
                    frame_callback(rx_buf[0], &rx_buf[1], expected_len);
                }
            }
            state = WAIT_HEAD1;
            break;
    }
}
注意:状态机里一定要有「失步重同步」机制。比如在 WAIT_HEAD2 状态,如果第二个字节不是 0x55,不能直接丢弃,而是回到 WAIT_HEAD1 重新判断当前字节是不是帧头。否则可能漏掉一帧。

4.4 错误检测与重传机制:坏了怎么办?

CRC能检测出错误,但检测出来之后呢?总不能让数据就这么丢了。运动控制里,丢一帧指令,电机可能就多转一圈,这在精密定位里是致命的。

我常用的策略是「超时重传 + 序列号」:

  • 发送方:每发一帧,启动一个定时器。如果在规定时间内没收到确认帧(ACK),就重传。重传次数上限一般是3次,超过就报错。
  • 接收方:收到一帧后,先校验CRC。如果正确,回复ACK;如果错误,直接丢弃,不回复任何东西。发送方收不到ACK自然会重传。

这里有个坑:如果ACK本身丢了怎么办?发送方会重传,接收方就会收到重复帧。所以帧里要加一个序列号(比如1字节,0~255循环),接收方根据序列号判断是不是重复帧,是的话直接丢弃,但还是要回复ACK。

伪代码逻辑:

// 发送方
uint8_t seq = 0;
void send_frame_with_retry(uint8_t *data, uint16_t len) {
    for (int retry = 0; retry < 3; retry++) {
        frame[2] = seq;  // 序列号放在长度字段后面
        send_frame(frame);
        if (wait_ack(100)) {  // 等待100ms
            seq++;
            return;
        }
    }
    // 重试3次都失败,上报错误
    error_handler("通信超时");
}

// 接收方
uint8_t last_seq = 0;
void on_frame_received(uint8_t *frame) {
    uint8_t seq = frame[2];
    if (crc_check(frame) == OK) {
        if (seq != last_seq) {
            process_frame(frame);  // 新帧,处理
            last_seq = seq;
        }
        send_ack(seq);  // 无论是否重复,都回复ACK
    }
    // CRC错误,直接丢弃
}
经验之谈:重传超时时间怎么定?我一般取「正常往返时间 × 3」。比如从发指令到收到ACK平均需要5ms,那超时设15ms。太短容易误重传,太长影响响应速度。

最后,用一张图总结数据链路层的核心流程:

数据链路层核心流程 发送方 1. 封装帧(加头尾、CRC) 2. 发送帧,启动超时定时器 3. 等待ACK 超时 重传(最多3次) 收到ACK 发送成功 物理层传输 接收方 1. 逐字节状态机解帧 2. CRC校验 错误 丢弃帧,不回复 正确 处理帧,回复ACK ACK返回

这张图把发送方和接收方的完整交互画出来了。你可以看到,数据链路层本质上就是个「可靠传输」的保障层。帧结构是骨架,CRC是眼睛,重传机制是手脚——三者缺一不可。

嗯,这一层写好了,上层协议才能安心地发数据。下一层咱们再聊网络层怎么把数据送到正确的节点上。


公众号:蓝海资料掘金营,微信deep3321