3. 数据链路层设计:帧同步头、数据包结构、校验机制

好,咱们接着聊。上一章我们讲了物理层怎么把比特流送出去,但说实话,光有物理层是不够的。你想想看,接收端收到一串 0101,它怎么知道从哪里开始算一个完整的数据包?万一中间有干扰,数据错了怎么办?

这就是数据链路层要解决的问题。说白了,它要干三件事:让接收方找到帧的起点、定义数据怎么打包、确保数据没被篡改。我在做嵌入式显微镜项目时,最初就忽略了这层的设计,结果图像传过来全是花屏,排查了整整两天才发现是帧同步出了问题。

3.1 帧同步头:给数据流打上“起始标记”

帧同步头,也叫帧头。它的作用就一个:告诉接收方“注意,我要开始发数据了”。

我习惯用 0xAA 0x55 这种交替的比特模式作为帧头。为什么?因为 0xAA 是 10101010,0x55 是 01010101,这种高低电平频繁跳变的模式,在示波器上一眼就能认出来。而且,它不容易和普通数据混淆。

举个例子,我设计的一个简单帧头结构是这样的:

帧头: 0xAA 0x55 0xAA 0x55
长度: 2 字节 (表示后续数据长度)
数据: N 字节
校验: 2 字节 (CRC16)

你可能会问:为什么用 4 个字节的帧头?两个不行吗?嗯,我在项目里试过两个字节,结果发现当图像数据里恰好出现 0xAA 0x55 时,接收方就会误判。所以,帧头越长,误判概率越低。4 个字节的重复模式,基本可以保证 99.99% 的可靠性。

我的经验: 帧头不要用全 0 或全 1 的模式。比如 0xFF 0xFF,一旦线路出现长时间高电平,接收方就会误以为收到了帧头。交替模式是最安全的。

3.2 数据包结构:怎么装你的图像数据

帧头搞定了,接下来就是数据包怎么组织。对于显微镜图像,数据量通常比较大,比如一张 640x480 的灰度图,就有 307200 字节。你不能一口气全发出去,得拆成小包。

我常用的数据包结构如下:

字段 长度 说明
帧头 4 字节 0xAA 0x55 0xAA 0x55
包序号 2 字节 从 0 开始,每包递增
数据长度 2 字节 本包有效数据字节数
图像数据 N 字节 实际图像内容,最大 1024 字节
CRC16 2 字节 从包序号到数据的校验

注意这里的 包序号。我曾经犯过一个错误:没有加包序号,结果接收方收到的包顺序乱了,图像完全错位。加了序号之后,接收方可以按序号重组,就算丢包也能及时发现。

数据长度字段也很关键。它告诉接收方“这包里有多少有效数据”。为什么需要?因为最后一个包可能不满 1024 字节,没有长度字段,接收方就不知道从哪里截断。

3.3 校验机制:CRC 还是 Checksum?

数据在传输过程中,受电磁干扰、信号衰减等因素影响,比特翻转是常有的事。校验机制就是为了检测这些错误。

常用的有两种:Checksum(校验和)CRC(循环冗余校验)

3.3.1 Checksum:简单但不够可靠

Checksum 的做法很简单:把所有数据字节加起来,取低 16 位或 8 位作为校验值。比如:

uint16_t checksum = 0;
for (int i = 0; i < len; i++) {
    checksum += data[i];
}
// 发送 checksum

接收方收到数据后,重新计算一遍,和收到的 checksum 对比。如果一致,就认为数据没错。

但这里有个坑:Checksum 检测不出字节顺序交换的错误。比如数据 [0x01, 0x02] 和 [0x02, 0x01],它们的 checksum 都是 0x03。我在项目中就遇到过这种情况,图像出现条纹,但 checksum 却显示正确。

注意: Checksum 只适合对可靠性要求不高的场景。对于显微镜图像这种需要精确传输的数据,我强烈建议用 CRC。

3.3.2 CRC:更强大的错误检测能力

CRC 的原理比较复杂,简单说就是:把数据看作一个多项式,用生成多项式去除,余数就是 CRC 值。它能检测出几乎所有常见的错误类型,包括单比特错、双比特错、奇数个比特错、突发错误等。

我常用的 CRC16 生成多项式是 0x8005(x^16 + x^15 + x^2 + 1)。代码实现如下:

uint16_t crc16(uint8_t *data, uint16_t len) {
    uint16_t crc = 0xFFFF;
    for (uint16_t i = 0; i < len; i++) {
        crc ^= data[i];
        for (uint8_t j = 0; j < 8; j++) {
            if (crc & 0x0001) {
                crc = (crc >> 1) ^ 0xA001;
            } else {
                crc >>= 1;
            }
        }
    }
    return crc;
}

这段代码我用了很多年,从来没出过问题。注意初始值设为 0xFFFF,而不是 0x0000,这样可以检测出数据前面多余的 0 字节。

3.4 如何保证数据完整性?

光有校验还不够,还得有应对措施。我总结了一套“三板斧”:

  1. 发送方加校验:每包数据都带上 CRC16,接收方校验不通过就丢弃。
  2. 接收方反馈 ACK/NACK:收到正确数据包,回复 ACK;校验失败,回复 NACK,要求重传。
  3. 超时重传:发送方发出数据后,启动定时器。如果在规定时间内没收到 ACK,就重传该包。

举个例子,我设计的重传逻辑是这样的:

// 发送方
send_packet(pkt);
start_timer(100);  // 100ms 超时
while (1) {
    if (receive_ack()) {
        break;  // 收到 ACK,发送下一包
    }
    if (timer_expired()) {
        send_packet(pkt);  // 超时重传
        reset_timer();
    }
}

这套机制看起来简单,但实际效果非常好。我在一个 115200bps 的串口链路上测试过,误码率从 10^-4 降到了 10^-9 以下,基本可以忽略不计。

核心要点: 数据完整性不是靠单一机制保证的,而是帧头、包结构、校验、重传四者协同工作的结果。缺任何一个,都可能出问题。

3.5 避坑指南

最后,分享几个我踩过的坑:

  • 帧头不要用可变长度:我曾经想省带宽,让帧头长度随数据变化,结果接收方解析逻辑变得极其复杂,还容易出错。固定长度才是王道。
  • CRC 计算要包含包序号:如果只校验数据部分,包序号错了你也发现不了。一定要把包序号纳入 CRC 计算范围。
  • 重传次数要有限制:我曾经没设重传上限,结果线路断开后,发送方一直在重传,把缓冲区撑爆了。设个 3 次重传上限,超过就报错。

好了,数据链路层就讲到这里。下一章我们聊聊网络层,看看怎么在多个设备之间路由图像数据。到时候我会分享一个用 UDP 传显微镜图像的实战案例,保证干货满满。