3、数据链路层:帧结构解析、起始位/停止位/校验位、CRC校验原理与实现、数据粘包与拆包
各位工程师朋友,咱们今天聊聊数据链路层。这一层,说白了就是给原始比特流「穿上衣服」——让它们能规规矩矩地在物理介质上跑起来。我当年刚入行时,总觉得这层没啥技术含量,不就是加个包头包尾嘛。直到有一次调试一个RS485总线上的多机通信,被各种奇奇怪怪的丢帧、错帧折磨了整整三天……嗯,从那以后我再也不敢小看数据链路层了。
3.1 帧结构:通信的「信封」
数据链路层最核心的工作,就是把物理层收到的比特流组织成「帧」。你可以把帧想象成一封信——有收件人地址、寄件人地址、正文内容,还有信封上的封口标记。
一个典型的帧结构长这样:
| 起始标志 | 目标地址 | 源地址 | 长度 | 数据载荷 | 校验码 | 结束标志 |
每个字段都有它的使命:
- 起始标志:告诉接收方「注意,我要开始发数据了」
- 目标地址/源地址:在总线型网络中,这俩字段决定了谁该收、谁发的
- 长度字段:让接收方知道数据部分有多长,避免傻等
- 数据载荷:真正要传的应用层数据
- 校验码:检查传输过程中有没有出错
- 结束标志:告诉接收方「这帧发完了」
我个人习惯在设计帧结构时,把起始标志设成一个不太可能出现在数据中的特殊字节,比如 0x7E。为什么?你想想看,如果起始标志和数据内容重复了,接收方就会误判帧的边界,那可就乱套了。
3.2 起始位、停止位、校验位:串口的「老规矩」
说到起始位和停止位,就不得不提UART串口通信。这是最古老、也最经典的异步通信方式。我在一个工业控制项目中,就遇到过因为停止位设置不一致,导致两个设备死活对不上话的情况。
UART一帧的数据格式是这样的:
| 起始位(1位) | 数据位(5~8位) | 校验位(可选) | 停止位(1/1.5/2位) |
关键点在于:
- 起始位:永远是低电平(逻辑0)。接收方检测到电平从高跳变到低,就知道「数据要来了」
- 数据位:通常7位或8位,低位在前
- 校验位:可选奇校验或偶校验。奇校验就是让数据位+校验位中1的个数为奇数
- 停止位:永远是高电平(逻辑1),给接收方一点时间处理刚收到的数据
我曾经调试过一个项目,两个设备波特率都是115200,但一个设了1位停止位,另一个设了2位停止位。结果呢?数据能收到,但偶尔会错位。这种问题最难查,因为不是每次都出错,而是概率性出现。后来用示波器一看波形,才发现停止位长度不匹配。
3.3 CRC校验原理与实现:数据完整性的「守护神」
校验位只能检测奇数个比特错误,对于突发性的多位错误,它基本无能为力。这时候就需要CRC(循环冗余校验)登场了。
CRC的原理,说白了就是:把要发送的数据看作一个巨大的二进制数,然后除以一个事先约定好的「生成多项式」,得到的余数就是CRC校验码。接收方收到数据后,用同样的多项式去除,如果余数为0,说明数据没出错。
常用的CRC多项式有:
| CRC类型 | 生成多项式 | 校验码长度 | 典型应用 |
|---|---|---|---|
| CRC-8 | 0x07 | 8位 | 1-Wire总线 |
| CRC-16-IBM | 0x8005 | 16位 | Modbus RTU |
| CRC-32 | 0x04C11DB7 | 32位 | 以太网、ZIP文件 |
下面是一个CRC-16的查表法实现,速度比直接计算快很多:
// CRC-16查表法实现(多项式0x8005)
uint16_t crc16_table[256];
void crc16_init_table(void) {
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, uint16_t len) {
uint16_t crc = 0xFFFF; // 初始值
for (uint16_t i = 0; i < len; i++) {
crc = (crc << 8) ^ crc16_table[((crc >> 8) ^ data[i]) & 0xFF];
}
return crc;
}
这段代码我用了好多年,在多个MCU平台上移植过。查表法的好处是快——每个字节只需要查一次表、做一次异或和移位,比逐位计算快了将近8倍。
3.4 数据粘包与拆包:TCP通信的「老大难」
粘包,是TCP通信中一个让人头疼的问题。为什么会有粘包?因为TCP是流式协议——它不关心你一次发了多少数据,只负责把字节流从A端送到B端。如果发送方连续发了两个小数据包,接收方可能一次就把它们全收进来了,这就是粘包。
拆包则是反过来——你发了一个大包,TCP把它切成了好几段,接收方分几次才收完。
解决粘包/拆包问题,常用的方法有四种:
- 固定长度法:每个数据包长度固定,比如都是256字节。接收方每次读256字节,不够就等。简单粗暴,但浪费带宽。
- 特殊分隔符法:在包尾加一个特殊标记,比如
\r\n或0x7E。接收方读到分隔符就认为一个包结束了。HTTP协议就是这么干的。 - 长度前缀法:在包头放一个长度字段,告诉接收方这个包有多长。这是最常用的方法,Modbus、MQTT都在用。
- TLV格式:Type-Length-Value,每个字段都带类型和长度,灵活性最高。
我个人最推荐长度前缀法。为什么?因为它既高效又可靠。固定长度法浪费带宽,分隔符法要处理转义,而长度前缀法只需要在包头放2个字节的长度信息,接收方先读长度,再读指定字节数的数据,逻辑清晰,不容易出错。
下面是一个简单的拆包处理逻辑:
// 假设帧结构:| 起始标志(1字节) | 长度(2字节) | 数据(N字节) | CRC16(2字节) |
// 接收缓冲区 recv_buf,当前已接收字节数 recv_len
void process_recv_data(uint8_t *recv_buf, uint16_t *recv_len) {
// 检查是否收到至少3个字节(起始标志+长度)
if (*recv_len < 3) return;
// 检查起始标志
if (recv_buf[0] != 0x7E) {
// 起始标志不对,丢弃第一个字节,继续找
memmove(recv_buf, recv_buf + 1, *recv_len - 1);
(*recv_len)--;
return;
}
// 解析长度字段(大端模式)
uint16_t data_len = (recv_buf[1] << 8) | recv_buf[2];
uint16_t frame_len = 1 + 2 + data_len + 2; // 起始+长度+数据+CRC
// 检查是否收够了整个帧
if (*recv_len < frame_len) return;
// 校验CRC(略)
// 处理完整的一帧数据
handle_frame(recv_buf, frame_len);
// 移除已处理的帧,继续处理剩余数据
uint16_t remain = *recv_len - frame_len;
memmove(recv_buf, recv_buf + frame_len, remain);
*recv_len = remain;
// 递归处理,防止一个缓冲区里有多个包
process_recv_data(recv_buf, recv_len);
}
这段代码里有个细节:处理完一帧后,我用了递归调用。为什么?因为TCP缓冲区里可能同时塞了好几个包,一次处理不完。递归能确保把缓冲区里的所有完整帧都处理掉。
3.5 本章知识体系总览
下面这张图,把数据链路层的核心知识点串起来了:
数据链路层,说白了就是给物理层「擦屁股」的——物理层只管发比特流,不管对错;数据链路层负责把比特流组织成帧,加上校验,处理粘包,确保上层应用拿到的是完整、正确的数据。这一层要是没做好,上层协议栈再牛也白搭。
好了,关于数据链路层的帧结构、串口协议、CRC校验和粘包拆包,咱们就聊到这儿。这些内容在实际项目中天天都会遇到,尤其是CRC和粘包处理,几乎是每个嵌入式通信工程师的必修课。希望今天的分享能帮你少踩几个坑。