4. 数据链路层实现:帧结构设计、CRC校验实现、数据封装与解封装、错误检测与重传机制
数据链路层,说白了就是给原始比特流「穿上衣服」。我刚开始做运动控制时,总觉得这层就是个打包拆包的活儿,没什么技术含量。直到有一次,现场总线上的数据莫名其妙地乱跳,电机跟着抽风……嗯,从那以后我再也不敢小看这一层了。
这一层要解决的核心问题就三个:怎么把数据包好?怎么保证数据没坏?坏了怎么办? 咱们一个一个来拆解。
4.1 帧结构设计:给数据画个框
你想想看,物理层传过来的就是一堆0和1,接收方怎么知道从哪里开始读、到哪里结束?所以我们需要一个「帧」的概念——就像寄快递,你得有个包裹,上面写清楚地址、收件人、里面装了什么。
我个人习惯的帧结构长这样:
| 帧头(2B) | 长度(1B) | 类型(1B) | 数据(NB) | CRC(2B) | 帧尾(1B) |
每个字段的讲究:
- 帧头:固定值,比如
0xAA 0x55。接收方看到这个就知道「新的一帧来了」。我在项目中遇到过,帧头选得太简单(比如单字节0xAA),结果数据里偶尔也出现0xAA,导致帧同步错乱。后来改成双字节,问题就解决了。 - 长度:数据域的实际字节数。注意,这个长度不包括帧头、CRC和帧尾。为什么?因为接收方要先知道数据有多长,才能正确提取。
- 类型:用来区分是控制指令、状态反馈还是心跳包。比如
0x01表示位置指令,0x02表示速度指令。 - 数据:真正的载荷。比如电机目标位置、PID参数等。
- CRC:校验码,后面细说。
- 帧尾:固定值,比如
0x0D。其实有了长度字段,帧尾不是必须的。但我习惯加一个,作为「二次确认」——万一长度字段传错了呢?
4.2 CRC校验实现:给数据加把锁
数据在总线上传输,难免受到干扰。比如电机启动时的大电流,就可能让某个bit翻转。CRC(循环冗余校验)就是用来检测这种错误的。
CRC的原理不复杂:把数据看作一个多项式,用约定的生成多项式去除,余数就是CRC码。接收方用同样的方法计算,如果余数不为0,说明数据出错了。
我常用的CRC-16-IBM多项式:x^16 + x^15 + x^2 + 1,对应十六进制 0x8005。
直接上代码,这是我项目里用烂了的实现:
uint16_t crc16_ibm(uint8_t *data, uint16_t len) {
uint16_t crc = 0x0000; // 初始值
uint16_t poly = 0x8005; // 生成多项式
for (uint16_t i = 0; i < len; i++) {
crc ^= (data[i] << 8); // 把数据字节移到高位
for (uint8_t j = 0; j < 8; j++) {
if (crc & 0x8000) {
crc = (crc << 1) ^ poly;
} else {
crc <<= 1;
}
}
}
return crc;
}
0xFFFF,有的用 0x0000。如果两端不一致,永远校验不过。所以一定要和通信对端确认好初始值和多项式。
实际项目中,我建议用查表法代替逐位计算。查表法提前算好256个字节对应的CRC值,运行时直接查表,速度能快十几倍。对于运动控制这种实时性要求高的场景,这点优化很关键。
4.3 数据封装与解封装:打包与拆包
封装,就是把上层传下来的数据,加上帧头、长度、类型、CRC、帧尾,变成一个完整的帧。解封装就是反过来,从收到的字节流里提取出原始数据。
封装函数很简单:
void frame_pack(uint8_t type, uint8_t *data, uint16_t len, uint8_t *frame, uint16_t *frame_len) {
uint16_t idx = 0;
frame[idx++] = 0xAA; // 帧头
frame[idx++] = 0x55;
frame[idx++] = len; // 长度
frame[idx++] = type; // 类型
memcpy(&frame[idx], data, len); // 数据
idx += len;
uint16_t crc = crc16_ibm(&frame[2], idx - 2); // 从长度字段开始算CRC
frame[idx++] = (crc >> 8) & 0xFF;
frame[idx++] = crc & 0xFF;
frame[idx++] = 0x0D; // 帧尾
*frame_len = idx;
}
解封装就麻烦一些。因为数据是流式到达的,你没法保证一次收完一整个帧。所以需要一个状态机来逐字节处理:
typedef enum {
WAIT_HEAD1,
WAIT_HEAD2,
WAIT_LEN,
WAIT_TYPE,
WAIT_DATA,
WAIT_CRC_H,
WAIT_CRC_L,
WAIT_TAIL
} frame_state_t;
frame_state_t state = WAIT_HEAD1;
uint8_t rx_buf[256];
uint16_t rx_idx = 0;
uint8_t expected_len = 0;
void frame_unpack(uint8_t byte) {
switch (state) {
case WAIT_HEAD1:
if (byte == 0xAA) state = WAIT_HEAD2;
break;
case WAIT_HEAD2:
if (byte == 0x55) state = WAIT_LEN;
else state = WAIT_HEAD1; // 重新同步
break;
case WAIT_LEN:
expected_len = byte;
rx_idx = 0;
state = WAIT_TYPE;
break;
case WAIT_TYPE:
rx_buf[rx_idx++] = byte;
state = WAIT_DATA;
break;
case WAIT_DATA:
rx_buf[rx_idx++] = byte;
if (rx_idx == expected_len + 1) state = WAIT_CRC_H;
break;
case WAIT_CRC_H:
rx_buf[rx_idx++] = byte;
state = WAIT_CRC_L;
break;
case WAIT_CRC_L:
rx_buf[rx_idx++] = byte;
state = WAIT_TAIL;
break;
case WAIT_TAIL:
if (byte == 0x0D) {
// 校验CRC
uint16_t calc_crc = crc16_ibm(&rx_buf[0], expected_len + 1);
uint16_t recv_crc = (rx_buf[expected_len + 1] << 8) | rx_buf[expected_len + 2];
if (calc_crc == recv_crc) {
// 帧有效,回调上层
frame_callback(rx_buf[0], &rx_buf[1], expected_len);
}
}
state = WAIT_HEAD1;
break;
}
}
WAIT_HEAD2 状态,如果第二个字节不是 0x55,不能直接丢弃,而是回到 WAIT_HEAD1 重新判断当前字节是不是帧头。否则可能漏掉一帧。
4.4 错误检测与重传机制:坏了怎么办?
CRC能检测出错误,但检测出来之后呢?总不能让数据就这么丢了。运动控制里,丢一帧指令,电机可能就多转一圈,这在精密定位里是致命的。
我常用的策略是「超时重传 + 序列号」:
- 发送方:每发一帧,启动一个定时器。如果在规定时间内没收到确认帧(ACK),就重传。重传次数上限一般是3次,超过就报错。
- 接收方:收到一帧后,先校验CRC。如果正确,回复ACK;如果错误,直接丢弃,不回复任何东西。发送方收不到ACK自然会重传。
这里有个坑:如果ACK本身丢了怎么办?发送方会重传,接收方就会收到重复帧。所以帧里要加一个序列号(比如1字节,0~255循环),接收方根据序列号判断是不是重复帧,是的话直接丢弃,但还是要回复ACK。
伪代码逻辑:
// 发送方
uint8_t seq = 0;
void send_frame_with_retry(uint8_t *data, uint16_t len) {
for (int retry = 0; retry < 3; retry++) {
frame[2] = seq; // 序列号放在长度字段后面
send_frame(frame);
if (wait_ack(100)) { // 等待100ms
seq++;
return;
}
}
// 重试3次都失败,上报错误
error_handler("通信超时");
}
// 接收方
uint8_t last_seq = 0;
void on_frame_received(uint8_t *frame) {
uint8_t seq = frame[2];
if (crc_check(frame) == OK) {
if (seq != last_seq) {
process_frame(frame); // 新帧,处理
last_seq = seq;
}
send_ack(seq); // 无论是否重复,都回复ACK
}
// CRC错误,直接丢弃
}
最后,用一张图总结数据链路层的核心流程:
这张图把发送方和接收方的完整交互画出来了。你可以看到,数据链路层本质上就是个「可靠传输」的保障层。帧结构是骨架,CRC是眼睛,重传机制是手脚——三者缺一不可。
嗯,这一层写好了,上层协议才能安心地发数据。下一层咱们再聊网络层怎么把数据送到正确的节点上。
公众号:蓝海资料掘金营,微信deep3321