3. 数据链路层解析:帧结构定义与通信保障

各位同学,咱们接着往下聊。上一节我们把物理层的那些“硬”东西讲清楚了,电平怎么跳、波特率怎么设。但光有这些还不够——你想想看,一堆0和1在线上跑,我怎么知道哪一段是地址、哪一段是数据?这就是数据链路层要干的事。

说白了,数据链路层就是给原始比特流“画格子”。它定义了一套规矩:从哪里开始读、读到多少算一个完整消息、怎么保证消息没被篡改。我在做窗帘电机项目时,最头疼的就是这层——物理层调通了,但数据链路层没设计好,电机照样乱动。

3.1 帧结构定义:通信的“信封”

先看帧结构。你可以把它想象成一封信:信封上写地址、信里装内容、最后还要贴个封条。我们的通信帧也一样,每个字段都有它的使命。

一个标准的窗帘电机通信帧,我习惯这样定义:

字段 长度(字节) 说明
起始位 1 固定值 0xAA,表示一帧开始
地址位 1 电机地址,范围 0x01~0xFE
控制位 1 命令类型,如开、关、停、查询
数据位 0~8 具体参数,比如行程值、速度值
校验位 2 CRC16 校验值
停止位 1 固定值 0x55,表示一帧结束

嗯,这里要注意:起始位和停止位是“帧同步”的关键。我曾经遇到过一个问题——电机偶尔会误动作,查了半天发现是起始位被干扰成了别的值,导致接收方把噪声当成了有效帧。后来我加了一个“连续两次收到起始位才确认”的机制,问题就解决了。

地址位怎么分配?我建议给每个电机一个唯一地址,0x00保留给广播(所有电机同时响应),0xFF保留给调试用。控制位我一般用枚举:0x01开、0x02关、0x03停、0x04查询状态。数据位看情况,比如开窗帘时带一个百分比参数。

3.2 CRC校验算法实现:别让数据“带病上岗”

校验位是帧的“体检医生”。为什么不用简单的累加和?因为累加和太容易被巧合蒙混过关了。CRC(循环冗余校验)才是工业级的做法。

我个人习惯用 CRC-16-IBM 多项式:0x8005。为什么选这个?因为它在嵌入式平台上计算量小,而且检错能力足够强——能检测出所有单比特、双比特、奇数位错误,以及长度不超过16位的突发错误。

来看代码实现。这是一个标准的查表法 CRC16:

// CRC16 查表法实现
// 多项式: 0x8005 (x^16 + x^15 + x^2 + 1)
// 初始值: 0x0000

uint16_t crc16_table[256];

void crc16_init_table(void) {
    uint16_t crc;
    for (uint16_t i = 0; i < 256; i++) {
        crc = i << 8;
        for (uint8_t j = 0; j < 8; j++) {
            if (crc & 0x8000)
                crc = (crc << 1) ^ 0x8005;
            else
                crc = crc << 1;
        }
        crc16_table[i] = crc;
    }
}

uint16_t crc16_calculate(uint8_t *data, uint16_t len) {
    uint16_t crc = 0x0000;
    for (uint16_t i = 0; i < len; i++) {
        uint8_t index = (crc >> 8) ^ data[i];
        crc = (crc << 8) ^ crc16_table[index];
    }
    return crc;
}

这段代码我用了很多年。查表法比直接计算快得多——你想想看,电机控制是实时系统,每多花1毫秒计算校验,电机响应就慢1毫秒。查表法把计算量从循环移位变成了查数组,速度提升了一个数量级。

小技巧: 初始化表只需要做一次,放在系统启动时。我习惯把表放在 Flash 里,不占 RAM。如果 MCU 的 Flash 够大,甚至可以预生成表直接烧进去。

3.3 数据透明传输与字节填充:别让“巧合”搞砸通信

好,帧结构有了,校验也有了。但还有一个坑——如果数据位里恰好出现了 0xAA(起始位)或 0x55(停止位),接收方就会误判帧边界。这就是“数据不透明”的问题。

怎么解决?字节填充(Byte Stuffing)。说白了,就是在发送数据时,如果遇到特殊字符,就把它“转义”一下。

我常用的方案是这样的:

  • 定义转义字符:0x7D
  • 如果数据中出现 0xAA,发送 0x7D 0x5A
  • 如果数据中出现 0x55,发送 0x7D 0x35
  • 如果数据中出现 0x7D,发送 0x7D 0x5D

接收方收到 0x7D 后,就知道下一个字节需要“反转义”。举个例子:收到 0x7D 0x5A,就还原成 0xAA

我曾经在这个地方栽过跟头。有一次调试,发现电机偶尔会丢失命令。查了半天,原来是数据位里有一个字节正好是 0xAA,接收方把它当成了新帧的起始位,导致当前帧被截断。加上字节填充后,问题再也没出现过。

注意: 字节填充会增加数据长度。如果数据位里连续出现多个特殊字符,帧长度会膨胀。我建议在协议设计时预留足够的缓冲区——比如最大数据位是8字节,但接收缓冲区至少准备16字节,以防填充后的数据溢出。

还有一个细节:起始位和停止位本身不需要填充。为什么?因为它们出现在帧头和帧尾,接收方已经通过状态机区分了。只有数据位和控制位里的内容才需要处理。

3.4 实战中的避坑指南

讲到这里,我总结几个实际项目中容易踩的坑:

  1. CRC 初始值别搞错。 不同协议用的初始值不一样,有的用 0x0000,有的用 0xFFFF。发送方和接收方必须一致,否则校验永远通不过。
  2. 字节填充要覆盖所有特殊字符。 除了起始位和停止位,转义字符本身也要处理。否则数据里出现 0x7D 时,接收方会误以为后面是转义序列。
  3. 帧超时要加。 如果收到起始位后,超过一定时间没收到停止位,就认为帧丢失,清空缓冲区。我一般设 10ms 超时——波特率 9600 时,最长帧也就 2ms 左右,10ms 足够宽松了。
  4. 地址过滤要在校验之前做。 先检查地址是不是自己的,如果不是,直接丢弃,不用浪费 CPU 算 CRC。这样可以减少不必要的计算。

嗯,数据链路层的内容就这些。下一节我们讲网络层——怎么在总线上挂多个电机,怎么避免冲突。到时候我会分享一个我踩过的“总线竞争”的坑,保证让你印象深刻。