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帧都好好的,白白浪费带宽。
选择重传的实现要点:
- 发送方要维护一个重传缓冲区,存放已发送但未确认的帧。
- 接收方要有一个接收缓冲区,存放乱序到达的帧。
- 超时定时器要精确——太短会导致不必要的重传,太长会降低响应速度。
我曾经踩过的坑:有一次把超时时间设得太短,结果链路稍微有点抖动,大量帧被重传,网络直接崩溃。后来我把超时时间设为RTT(往返时间)的3倍,并加入了指数退避算法——第一次超时等1倍RTT,第二次等2倍,第三次等4倍...这样网络拥塞时自动降速,效果很好。
4.5 知识体系总览
说了这么多,咱们用一张图把整个链路层的核心逻辑串起来:
嗯,链路层的内容就这些。帧结构是骨架,CRC是体检医生,流量控制是交通警察,重传机制是快递小哥——四者配合好了,上层协议才能安心工作。实际开发中,我建议你先把帧结构和CRC调稳,再上流量控制和重传,一步步来,别想一口吃成胖子。