4. 数据链路层核心:帧结构设计、CRC校验实现、流量控制与差错重传机制

数据链路层,说白了就是给物理层那串「0和1」的比特流套上缰绳。我做了这么多年协议栈,最深的体会就是:链路层要是没设计好,上层协议再花哨也是白搭。今天咱们就把帧结构、CRC、流量控制和重传这几个硬骨头啃下来。

4.1 帧结构设计——给数据穿上马甲

帧是什么?就是链路层传输的基本单元。你想想看,物理层只管发比特流,它可不管哪一段是地址、哪一段是数据。所以我们必须自己定义一套格式,让收发双方能看懂。

一个典型的帧结构长这样:

| 前导码(8B) | 帧定界符(1B) | 目的地址(6B) | 源地址(6B) | 长度/类型(2B) | 数据(46-1500B) | FCS(4B) |

这里我重点说几个坑:

  • 前导码和定界符:这是让接收端做时钟同步用的。我记得有一次调试,发现接收端老是丢帧,查了半天——原来是前导码的比特模式写错了,接收端根本找不到帧的起点。
  • 地址字段:MAC地址是6字节,但有些私有协议为了省带宽只用2字节。我个人建议,除非你的网络规模极小(比如几十个节点),否则别省这个空间。地址冲突的后果太严重了。
  • 数据长度:以太网规定最小46字节,最大1500字节。为什么有下限?因为要保证碰撞检测能正常工作。我曾经在项目中把最小帧长改小了,结果网络一忙起来,CRC错误率飙升。

避坑指南:我曾经设计过一个物联网协议,为了省字节把地址字段压缩到1字节。结果设备一多,地址重复导致数据乱窜。后来老老实实改回6字节,虽然开销大了点,但系统稳定多了。

4.2 CRC校验实现——揪出传输中的坏比特

帧发出去,经过电缆、无线、光纤,难免会有比特被干扰。CRC就是用来检测这些错误的。它的原理不复杂:发送方对数据做多项式除法,把余数附在帧尾;接收方用同样的多项式再算一遍,余数不为0就说明有错。

实际工程中,最常用的是CRC-32(以太网)和CRC-16(很多工业协议)。我给出一个C语言的查表法实现,这是嵌入式系统里效率最高的做法:

// CRC-32 查表法实现
uint32_t crc32_table[256];

void crc32_init_table() {
    for (uint32_t i = 0; i < 256; i++) {
        uint32_t crc = i;
        for (int j = 0; j < 8; j++) {
            if (crc & 1)
                crc = (crc >> 1) ^ 0xEDB88320;
            else
                crc >>= 1;
        }
        crc32_table[i] = crc;
    }
}

uint32_t crc32_calc(uint8_t *data, uint32_t len) {
    uint32_t crc = 0xFFFFFFFF;
    for (uint32_t i = 0; i < len; i++) {
        uint8_t index = (crc ^ data[i]) & 0xFF;
        crc = (crc >> 8) ^ crc32_table[index];
    }
    return crc ^ 0xFFFFFFFF;
}

这里有个细节:初始值为什么是0xFFFFFFFF?最后为什么还要异或一次?这是为了检测前导零。如果初始值是0,那么数据前面加多少个0,CRC结果都一样——这显然不合理。

注意:CRC只能检测错误,不能纠正错误。它也不是100%可靠——对于CRC-32,不同数据产生相同校验值的概率大约是1/2^32。但在实际工程中,这个概率已经足够低了。

4.3 流量控制——别让发送方把接收方撑死

发送方拼命发,接收方处理不过来,结果就是丢帧。流量控制就是解决这个问题的。常见的机制有两种:

机制 原理 适用场景
停等协议 发一帧,等确认,再发下一帧 简单、低速链路
滑动窗口 允许连续发N帧,窗口大小动态调整 高速、高延迟链路

停等协议虽然简单,但效率太低了。你想想看,卫星通信来回延迟几百毫秒,发一帧等半天,带宽全浪费了。所以实际项目中,滑动窗口才是主流。

滑动窗口的核心是:发送方维护一个窗口,窗口内的帧可以连续发送,不需要等待确认。接收方收到后,回复确认帧,窗口就往前滑动。我见过一个典型的实现:

// 滑动窗口状态机(简化版)
typedef struct {
    uint8_t window_size;    // 窗口大小
    uint8_t base;           // 窗口基序号
    uint8_t next_seq;       // 下一个待发序号
    uint8_t ack_received;   // 已确认序号
} sliding_window_t;

// 发送一帧
int send_frame(sliding_window_t *sw, frame_t *frame) {
    if ((sw->next_seq - sw->base) >= sw->window_size) {
        return -1;  // 窗口已满,不能发送
    }
    frame->seq = sw->next_seq;
    // 实际发送代码...
    sw->next_seq++;
    return 0;
}

// 收到确认
void process_ack(sliding_window_t *sw, uint8_t ack_seq) {
    if (ack_seq > sw->base) {
        sw->base = ack_seq;
    }
}

经验之谈:窗口大小怎么定?我一般建议取「带宽×延迟」的2倍。比如链路带宽1Mbps,延迟100ms,那么窗口至少要有 1Mbps × 0.1s × 2 = 200Kb,换算成帧数就是200Kb/帧长。

4.4 差错重传机制——丢了就补,错了就重来

CRC检测到错误怎么办?重传。但怎么重传,这里面的门道可不少。

最常见的两种重传策略:

  • 停等ARQ:发一帧,等确认。超时没收到确认就重传。简单,但效率低。
  • 选择重传ARQ:只重传出错的帧,其他帧照常处理。效率高,但实现复杂。

我实际项目中用得最多的是选择重传。为什么?因为停等ARQ在高速链路上简直就是灾难。举个例子:你发10帧,第3帧丢了。停等ARQ会重传第3帧,然后从第4帧开始全部重传——明明后面7帧都好好的,白白浪费带宽。

选择重传的实现要点:

  1. 发送方要维护一个重传缓冲区,存放已发送但未确认的帧。
  2. 接收方要有一个接收缓冲区,存放乱序到达的帧。
  3. 超时定时器要精确——太短会导致不必要的重传,太长会降低响应速度。

我曾经踩过的坑:有一次把超时时间设得太短,结果链路稍微有点抖动,大量帧被重传,网络直接崩溃。后来我把超时时间设为RTT(往返时间)的3倍,并加入了指数退避算法——第一次超时等1倍RTT,第二次等2倍,第三次等4倍...这样网络拥塞时自动降速,效果很好。

4.5 知识体系总览

说了这么多,咱们用一张图把整个链路层的核心逻辑串起来:

数据链路层核心知识体系 帧结构设计 CRC校验实现 流量控制 差错重传机制 • 前导码与定界符 • MAC地址设计 • 数据长度与填充 • FCS校验字段 • 多项式选择 • 查表法优化 • 初始值与异或 • 错误检测能力 • 停等协议 • 滑动窗口 • 窗口大小计算 • 确认与超时 • 停等ARQ • 选择重传ARQ • 超时与退避 • 缓冲区管理 核心设计原则 1. 帧结构要兼顾效率与健壮性——字段不是越少越好 2. CRC是链路层的最后一道防线,务必选择合适的多项式 3. 流量控制与重传机制要匹配链路特性,避免过度设计

嗯,链路层的内容就这些。帧结构是骨架,CRC是体检医生,流量控制是交通警察,重传机制是快递小哥——四者配合好了,上层协议才能安心工作。实际开发中,我建议你先把帧结构和CRC调稳,再上流量控制和重传,一步步来,别想一口吃成胖子。

专注资料整理