3. 数据链路层设计:帧结构定义与字节填充
好,咱们进入数据链路层。这一层说白了,就是给原始数据「穿上衣服」。你想想看,物理层只管收发比特流,它可不管这串01到底是什么意思。那怎么让接收方知道「一帧数据从哪开始、到哪结束、中间有没有被干扰」?这就是数据链路层要干的事。
我个人习惯把数据链路层比作「快递包裹」。你得有包装盒(帧结构)、有收件人地址(地址域)、有封条(校验)、还得有防拆设计(转义)。今天咱们就重点聊聊这个包裹怎么设计。
3.1 帧结构定义:一个靠谱的包裹长什么样
先看一个我常用的帧结构模板。这个结构我在三个量产项目里验证过,稳定跑了五六年没出过问题:
| 起始符(1B) | 长度(1B) | 地址(1B) | 命令(1B) | 数据(NB) | 校验(1B) | 结束符(1B) |
| 0x7E | N+5 | 0x01 | 0x10 | ... | CRC8 | 0x7F |
每个字段都有它的讲究。咱们一个一个拆开看:
| 字段 | 长度 | 说明 | 我的建议 |
|---|---|---|---|
| 起始符 | 1字节 | 帧开始的标志 | 选0x7E,避开常见ASCII字符 |
| 长度 | 1字节 | 从地址到校验的总字节数 | 最大255,够用 |
| 地址 | 1字节 | 设备地址或广播地址 | 0xFF做广播 |
| 命令 | 1字节 | 功能码 | 0x00-0x7F为系统保留 |
| 数据 | N字节 | 有效载荷 | 长度由长度字段决定 |
| 校验 | 1字节 | CRC8或累加和 | CRC8更可靠 |
| 结束符 | 1字节 | 帧结束标志 | 选0x7F,与起始符不同 |
核心原则:起始符和结束符必须不同。我见过有人偷懒用同一个字符,结果数据里出现这个字符时,接收方直接懵了——到底是帧结束还是帧开始?
3.2 长度字段:别小看这一个字节
长度字段的设计,我踩过坑。早期我做的一个项目,长度字段只包含数据域的长度。结果接收方收到帧后,得先解析长度,再回头算地址+命令+数据的总长。代码写起来绕来绕去,调试时差点没把自己绕晕。
我的改进方案:长度字段包含从地址到校验的所有字节数。这样接收方拿到长度后,直接就能算出「还需要收多少字节」。代码干净利落:
// 接收一帧数据
uint8_t frame_len = rx_buffer[1]; // 长度字段
// 还需要接收的字节数 = frame_len - 已收到的字节数
uint8_t remain = frame_len - 3; // 减掉起始符、长度、结束符
小技巧:长度字段的最大值要留够。如果你的数据域可能达到200字节,长度字段用1字节(最大255)就够。如果数据域可能超过250字节,建议用2字节长度字段。我在一个图像传输项目里就吃过这个亏,后来改成2字节才搞定。
3.3 校验:别让噪声毁了你的数据
校验方式我推荐CRC8。为什么不是累加和?说白了,累加和太容易被「骗」了。比如数据里两个字节同时出错,累加和可能刚好抵消,接收方还以为数据是对的。
CRC8的查表法实现,我直接贴代码:
// CRC8查表法,多项式0x31(x8 + x5 + x4 + 1)
uint8_t crc8_table[256];
void crc8_init() {
for (uint16_t i = 0; i < 256; i++) {
uint8_t crc = i;
for (uint8_t j = 0; j < 8; j++) {
if (crc & 0x80)
crc = (crc << 1) ^ 0x31;
else
crc <<= 1;
}
crc8_table[i] = crc;
}
}
uint8_t crc8_calc(uint8_t *data, uint8_t len) {
uint8_t crc = 0;
for (uint8_t i = 0; i < len; i++) {
crc = crc8_table[crc ^ data[i]];
}
return crc;
}
注意:CRC的初始值和异或值要跟接收方约定好。我曾经在两个团队联调时,双方用的CRC初始值不一样,查了三天才找到原因。嗯,从那以后我都在协议文档里把CRC参数写得明明白白。
3.4 字节填充与转义:解决「数据冲突」的终极方案
好,现在问题来了。如果数据域里恰好出现了0x7E(起始符)或者0x7F(结束符),接收方会怎么处理?它会以为帧提前结束了,或者新帧开始了。这就是所谓的「数据冲突」。
解决方案:字节填充(Byte Stuffing)。说白了,就是在发送前把数据里的特殊字符「藏起来」,接收后再「还原」。我常用的转义规则:
- 转义字符:0x7D
- 规则:
- 遇到0x7E → 替换为 0x7D 0x5E
- 遇到0x7F → 替换为 0x7D 0x5F
- 遇到0x7D → 替换为 0x7D 0x5D
为什么选0x5E、0x5F、0x5D?其实就是把原字节的第5位取反。这样接收方看到0x7D就知道后面跟着的是转义后的数据,把0x7D去掉,再把后面的字节第5位取反就还原了。
发送端的实现:
uint8_t tx_buffer[256];
uint8_t stuff_frame(uint8_t *raw, uint8_t raw_len) {
uint8_t idx = 0;
tx_buffer[idx++] = 0x7E; // 起始符
for (uint8_t i = 0; i < raw_len; i++) {
if (raw[i] == 0x7E) {
tx_buffer[idx++] = 0x7D;
tx_buffer[idx++] = 0x5E;
} else if (raw[i] == 0x7F) {
tx_buffer[idx++] = 0x7D;
tx_buffer[idx++] = 0x5F;
} else if (raw[i] == 0x7D) {
tx_buffer[idx++] = 0x7D;
tx_buffer[idx++] = 0x5D;
} else {
tx_buffer[idx++] = raw[i];
}
}
tx_buffer[idx++] = 0x7F; // 结束符
return idx;
}
接收端的实现:
uint8_t rx_buffer[256];
uint8_t unstuff_byte(uint8_t *data, uint8_t *idx, uint8_t len) {
if (*idx >= len) return 0;
uint8_t byte = data[*idx];
(*idx)++;
if (byte == 0x7D) {
// 转义字符,下一个字节需要还原
if (*idx >= len) return 0;
uint8_t next = data[*idx];
(*idx)++;
return next ^ 0x20; // 第5位取反
}
return byte;
}
避坑指南:我曾经在一个项目里忘了对转义字符本身做转义。结果数据里出现0x7D时,接收方直接把它当成转义字符处理,把后面的正常数据也给「还原」了。那叫一个惨,整个数据流全乱了。记住:转义字符本身也要转义!
3.5 实际项目中的经验总结
做了这么多年嵌入式,我总结了几条数据链路层的「铁律」:
- 起始符和结束符一定要不同。别问我为什么,问就是血的教训。
- 长度字段要包含校验域。这样接收方可以提前知道总帧长,方便分配缓冲区。
- 转义规则要覆盖所有特殊字符。包括起始符、结束符、转义符本身。
- 校验要覆盖从地址到数据的全部内容。起始符和结束符不需要校验,因为它们只是帧边界。
- 接收方要有超时机制。如果收到起始符后长时间没收到结束符,要清空缓冲区重新开始。我在一个485总线项目里,就是因为没做超时,结果一帧坏数据卡死了整个通信链路。
好了,数据链路层的帧结构和字节填充就聊到这儿。下一节咱们聊聊更高级的话题——怎么在不可靠的链路上保证数据可靠传输。说白了,就是「丢了怎么办、错了怎么办、重了怎么办」。到时候见。