3、数据链路层核心:帧结构设计、差错控制(CRC校验)、流量控制与滑动窗口协议

各位同学,咱们今天聊点硬核的。数据链路层,说白了就是给物理层那根“裸线”穿上衣服、定好规矩。你想想看,物理层只管把0和1扔到线上去,但谁发的?发给谁?发对了没有?发太快了对方接不住怎么办?这些事儿,全得靠数据链路层来搞定。

我个人习惯把这一层比作“快递分拣中心”。帧就是包裹,地址是面单,差错控制是验货,流量控制就是控制传送带的速度。好,咱们一个一个拆开讲。

3.1 帧结构设计:给数据“打包”

帧,就是数据链路层的数据单元。我刚开始做机载通信那会儿,总觉得帧结构不就是包头加数据加包尾嘛,有啥好设计的?直到有一次,我在调试一个ARINC 429总线接口时,发现接收端老是丢帧。查了三天,最后发现是帧起始标志和结束标志选得太“普通”,数据里偶尔出现一样的比特序列,导致接收端误判了帧边界。

嗯,这里要注意:帧结构设计,核心就是解决三个问题——定界、寻址、校验

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

| 帧起始标志 | 目的地址 | 源地址 | 长度/类型 | 数据载荷 | 帧校验序列(FCS) | 帧结束标志 |
  • 帧起始/结束标志:告诉接收端“我开始了”和“我结束了”。常用特殊比特序列,比如HDLC里的01111110。为了避免数据里出现同样的序列,还得用“比特填充”技术——连续5个1后面自动加个0。这个坑我踩过,后面讲。
  • 地址字段:机载环境里,地址可能是物理端口号,也可能是逻辑ID。我建议尽量用短地址,因为机载总线带宽金贵。
  • 长度/类型:告诉上层这个帧里装的是什么协议的数据,或者数据有多长。
  • 数据载荷:最大长度叫MTU(最大传输单元)。以太网是1500字节,机载数据链可能更小,比如1553B总线只有32个字。
  • 帧校验序列:这就是咱们下面要讲的CRC。

避坑指南:我曾经设计过一个机载无线数据链的帧结构,为了省带宽把地址字段压缩到4比特。结果飞机编队一超过16架,地址就不够用了。后来不得不改协议,折腾了两个月。所以,帧结构设计一定要留余量,尤其是地址字段。

3.2 差错控制:CRC校验,到底怎么算?

数据在传输过程中,受电磁干扰、信号衰减影响,比特翻转是常事。机载环境尤其恶劣,发动机点火、雷达辐射,分分钟给你来几个误码。所以,差错控制是刚需。

差错控制分两种:检错(发现错了,请求重传)和纠错(发现错了,自己修好)。机载数据链路里,大部分场景用检错+重传,因为纠错码开销太大。但像卫星链路这种延迟高的,就得用前向纠错(FEC)。

咱们重点讲CRC(循环冗余校验)。为什么不用简单的奇偶校验?因为奇偶校验只能检测奇数个比特错误,偶数个错误它就“瞎”了。CRC不一样,它用多项式除法,检错能力极强。

CRC的原理,说白了就是:发送方把数据看作一个巨大的二进制数,除以一个约定的“生成多项式”,把余数附在数据后面发出去。接收方用同样的多项式去除收到的数据,如果余数为0,说明没错;否则,数据有问题。

常用的CRC多项式有:

名称 多项式 应用场景
CRC-8 0x07 简单传感器、1-Wire总线
CRC-16-IBM 0x8005 Modbus、USB
CRC-32 0x04C11DB7 以太网、ZIP、机载数据链
CRC-CCITT 0x1021 HDLC、XMODEM

我给你们写个简单的CRC-16计算代码,用查表法,效率高:

// CRC-16查表法实现(多项式0x8005)
uint16_t crc16_table[256];

void crc16_init_table() {
    for (int i = 0; i < 256; i++) {
        uint16_t crc = i << 8;
        for (int j = 0; j < 8; j++) {
            if (crc & 0x8000)
                crc = (crc << 1) ^ 0x8005;
            else
                crc = crc << 1;
        }
        crc16_table[i] = crc;
    }
}

uint16_t crc16_calc(uint8_t *data, int len) {
    uint16_t crc = 0x0000;  // 初始值
    for (int i = 0; i < len; i++) {
        uint8_t index = (crc >> 8) ^ data[i];
        crc = (crc << 8) ^ crc16_table[index];
    }
    return crc;
}

小技巧:实际项目中,CRC的初始值、输出是否异或、字节顺序(大端/小端)都可能不同。不同厂家对同一多项式的实现可能有细微差别。我建议你在协议文档里明确写出“初始值=0xFFFF,输出异或=0x0000”,避免联调时扯皮。

3.3 流量控制:别把接收端“撑死”

发送方拼命发,接收方处理不过来,数据就会丢失。流量控制就是解决这个问题的。机载环境里,不同设备处理能力差异很大——飞控计算机可能很强,但一个简单的传感器节点可能很弱。你不能让传感器去处理飞控级别的数据量。

流量控制有两种基本方式:

  • 停等协议:发一帧,等确认,再发下一帧。简单,但效率低。信道利用率只有1/(2a+1),a是传播延迟与传输时间的比值。卫星链路用这个就惨了,延迟几百毫秒,效率极低。
  • 滑动窗口协议:允许连续发多帧,接收方确认后,窗口向前滑动。效率高,但实现复杂。

我个人在机载数据链项目中,大部分场景用滑动窗口。因为机载总线(比如AFDX、ARINC 664)要求确定性延迟,停等协议会导致延迟抖动太大。

3.4 滑动窗口协议:核心机制与实现

滑动窗口协议,说白了就是:发送方维护一个“发送窗口”,窗口内的帧可以连续发送,不用等确认。接收方维护一个“接收窗口”,只接收窗口内的帧。

窗口大小是关键参数。窗口太小,效率低;窗口太大,接收方可能被淹没。机载环境里,窗口大小通常根据带宽-延迟积来计算:

窗口大小 = (带宽 × 往返延迟) / 帧长

举个例子:一条机载数据链,带宽100Mbps,往返延迟2ms,帧长1500字节。那么窗口大小至少是:

(100 × 10^6 × 2 × 10^-3) / (1500 × 8) ≈ 16.7,取整为17帧。

滑动窗口协议有三种变体:

协议类型 接收方行为 重传策略 适用场景
停等协议 只收一帧 超时重传 简单、低带宽
后退N帧(GBN) 只收顺序帧 从出错帧开始全部重传 信道质量好
选择重传(SR) 缓存乱序帧 只重传出错帧 信道质量差、延迟高

机载数据链里,我推荐用选择重传。为什么?因为机载信道虽然相对稳定,但一旦出现干扰(比如飞过雷达站),往往是突发错误,连续好几帧都错。后退N帧在这种情况下要重传大量数据,效率极低。选择重传只重传坏掉的帧,好帧留着,效率高得多。

注意:选择重传需要接收方有较大的缓存空间,而且序号字段要足够大(至少是窗口大小的两倍),否则会出现“序号回绕”问题。我曾经在一个项目中,序号字段只用了3比特,窗口大小设成4,结果序号0和序号8分不清,数据全乱了。后来改成序号字段8比特,窗口大小128,问题解决。

最后,我给你们一个滑动窗口发送方的伪代码,帮助理解:

// 滑动窗口发送方伪代码
base = 0          // 窗口基序号
next_seq = 0      // 下一个要发送的序号
window_size = N   // 窗口大小

while (有数据要发送) {
    if (next_seq < base + window_size) {
        // 窗口未满,可以发送
        发送帧(next_seq)
        if (base == next_seq) {
            启动定时器
        }
        next_seq++
    } else {
        // 窗口已满,等待确认
        等待ACK或超时
    }

    // 收到ACK
    if (收到ACK(seq)) {
        base = seq + 1
        停止定时器
        if (base != next_seq) {
            重启定时器
        }
    }

    // 超时处理
    if (定时器超时) {
        重传从base到next_seq-1的所有帧
        重启定时器
    }
}

嗯,到这里,数据链路层的三个核心——帧结构、差错控制、流量控制——就讲完了。你想想看,这三个东西其实是一体的:帧结构是骨架,差错控制是免疫系统,流量控制是血液循环。缺一个,整个链路就跑不起来。

下一章咱们讲MAC子层,也就是谁先说话的问题。机载环境里,这个“谁先说话”的规矩,可比以太网的CSMA/CD复杂多了。