3. 数据链路层协议开发:HDLC帧结构解析、CRC校验算法实现、帧同步与透明传输、链路层状态机设计

好,咱们进入数据链路层。这一层说白了,就是给物理层传过来的比特流「定规矩」。没有规矩不成方圆,你想想看,如果接收端收到的是一堆乱码,它怎么知道哪里是帧头、哪里是帧尾?数据对不对?要不要重传?

HDLC(高级数据链路控制)协议,就是干这个的。我在做POS机项目时,很多老式的金融终端和密码键盘,底层用的就是HDLC的变种。别看它老,稳定可靠,至今仍在大量嵌入式设备里服役。

3.1 HDLC帧结构解析

先看帧结构。HDLC的帧,就像一列火车,每节车厢都有固定用途。

| 标志字段 | 地址字段 | 控制字段 | 信息字段 | 帧校验序列 | 标志字段 |
|  01111110 |  8位     |  8/16位  | 可变长度 |  16/32位   | 01111110 |
|  F        |  A       |  C       |  I       |  FCS       |  F       |

每个字段我都吃过亏,一个一个说。

标志字段(Flag):固定为 01111110(0x7E)。这是帧的「起跑线」和「终点线」。我遇到过一个问题:如果信息字段里恰好出现了0x7E怎么办?接收端会误判为帧结束。这就是后面要讲的「透明传输」要解决的问题。

地址字段(Address):在多点链路中,用来区分不同的站。POS机通常是一对一通信,地址字段往往固定,但别小看它。我记得有一次调试,发现两台设备地址配反了,数据死活不通,折腾了半天。

控制字段(Control):这是帧的「大脑」。它决定了帧的类型:

  • 信息帧(I帧):携带用户数据,有发送序号N(S)和接收序号N(R)。
  • 监控帧(S帧):用于流量控制和差错控制,比如RR(接收就绪)、RNR(接收未就绪)、REJ(拒绝)。
  • 无编号帧(U帧):用于链路建立、拆除等管理功能,比如SABM、DISC、UA、DM。

信息字段(Information):真正要传的数据。长度可变,但受限于链路层的缓冲区大小。POS机里,一个帧通常不超过256字节,太长的话重传成本太高。

帧校验序列(FCS):这就是CRC校验码,用来检测传输错误。下面重点讲。

核心要点:HDLC帧的边界由标志字段界定,帧内数据必须通过「0比特插入法」保证不会出现误判。这是协议栈开发中最容易出bug的地方之一。

3.2 CRC校验算法实现(CRC-16/CRC-32)

CRC,循环冗余校验。说白了,就是发送方根据数据算出一个「指纹」,接收方用同样的算法再算一次,比对指纹是否一致。不一致,说明数据被篡改了。

我在金融支付领域做过认证,CRC是底线。虽然它不能防恶意篡改(那是MAC的事),但能防线路噪声、硬件故障导致的随机错误。

3.2.1 CRC-16 实现

HDLC常用的CRC-16多项式是 x^16 + x^12 + x^5 + 1(0x8005)。我习惯用查表法,速度快,适合嵌入式实时系统。

// CRC-16 查表法实现
// 多项式: 0x8005, 初始值: 0xFFFF, 结果异或: 0x0000

static 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++) {
        uint8_t index = ((crc >> 8) ^ data[i]) & 0xFF;
        crc = (crc << 8) ^ crc16_table[index];
    }
    return crc;  // 不需要再异或,因为初始值和结果异或都是0xFFFF/0x0000
}

避坑指南:我曾经在移植CRC算法时,发现两台设备算出来的校验码总是不一样。查了半天,原来是「初始值」和「结果异或值」没对齐。不同协议标准对这两个参数定义不同,一定要看文档确认。HDLC用的是0xFFFF初始值,结果不异或。

3.2.2 CRC-32 实现

CRC-32在金融数据完整性校验中更常见。多项式是 x^32 + x^26 + x^23 + x^22 + x^16 + x^12 + x^11 + x^10 + x^8 + x^7 + x^5 + x^4 + x^2 + x + 1(0x04C11DB7)。

// CRC-32 查表法实现(简化版)
// 多项式: 0x04C11DB7, 初始值: 0xFFFFFFFF, 结果异或: 0xFFFFFFFF

static uint32_t crc32_table[256];

void crc32_init_table(void) {
    for (int i = 0; i < 256; i++) {
        uint32_t crc = i;
        for (int j = 0; j < 8; j++) {
            if (crc & 1)
                crc = (crc >> 1) ^ 0xEDB88320;  // 反射多项式
            else
                crc = crc >> 1;
        }
        crc32_table[i] = crc;
    }
}

uint32_t crc32_calc(uint8_t *data, uint16_t len) {
    uint32_t crc = 0xFFFFFFFF;
    for (uint16_t i = 0; i < len; i++) {
        uint8_t index = (crc ^ data[i]) & 0xFF;
        crc = (crc >> 8) ^ crc32_table[index];
    }
    return crc ^ 0xFFFFFFFF;
}

注意:CRC-32有两种常见变体——正向计算和反射计算。上面代码用的是反射版本(数据低位在前),这是以太网、ZIP等标准用的。如果你的设备用的是正向版本,多项式要换成0x04C11DB7,移位方向也要反过来。我建议你在开发初期就写一个自测用例,用已知的测试向量验证,避免踩坑。

3.3 帧同步与透明传输

帧同步,就是接收端如何从比特流里找到帧的边界。HDLC的做法是:不断搜索标志字段0x7E。一旦找到,就认为一个帧开始了。

但问题来了:如果数据里出现了0x7E怎么办?

透明传输就是解决这个问题的。HDLC采用「0比特插入法」:发送端在数据中每遇到连续的5个1,就在后面插入一个0。接收端则做逆操作:每遇到连续的5个1,就删除后面的0。

举个例子:

原始数据: 01111110  (这是0x7E,会误判为标志)
插入0后:  011111010 (在5个1后面插入了0)
接收端删除0: 01111110 (恢复原始数据)

我当年第一次实现这个逻辑时,犯了个低级错误:只对数据字段做了0比特插入,忘了对地址字段和控制字段也做。结果地址字段里出现0x7E时,接收端直接崩了。嗯,从那以后我学乖了——除了标志字段本身,帧内所有字段都要做透明传输处理。

实现要点

  • 发送时:在FCS计算之后做0比特插入。因为FCS是对原始数据算的,插入0会改变数据,导致校验失败。
  • 接收时:先做0比特删除,再计算FCS校验。顺序不能乱。

3.4 链路层状态机设计

链路层状态机,是协议栈的「大脑」。它管理着链路的建立、数据传输、差错恢复和链路拆除。

HDLC的状态机通常包含以下几个状态:

状态说明典型事件
DISCONNECTED链路断开,不通信收到SABM → 进入CONNECTING
CONNECTING正在建立链路收到UA → 进入CONNECTED;超时 → 重试或返回DISCONNECTED
CONNECTED链路已建立,可传数据收到I帧 → 处理数据;收到DISC → 进入DISCONNECTING
DISCONNECTING正在拆除链路收到UA或DM → 进入DISCONNECTED;超时 → 强制断开

我设计状态机时,有个习惯:把每个状态的「进入动作」、「退出动作」、「停留动作」都列清楚。比如进入CONNECTED状态时,要重置发送序号和接收序号、启动保活定时器。退出时,要清空发送缓冲区。

给你看一个简化版的状态机实现框架:

typedef enum {
    DISCONNECTED,
    CONNECTING,
    CONNECTED,
    DISCONNECTING
} hdlc_state_t;

typedef struct {
    hdlc_state_t state;
    uint8_t send_seq;   // 发送序号 N(S)
    uint8_t recv_seq;   // 接收序号 N(R)
    uint8_t retry_cnt;  // 重试计数
    // ... 其他状态变量
} hdlc_link_t;

void hdlc_state_machine(hdlc_link_t *link, hdlc_event_t event, uint8_t *frame) {
    switch (link->state) {
        case DISCONNECTED:
            if (event == EVT_RECV_SABM) {
                send_UA(frame);           // 发送无编号确认
                link->state = CONNECTED;  // 进入连接态
                link->send_seq = 0;
                link->recv_seq = 0;
                printf("[HDLC] 链路已建立\n");
            }
            break;

        case CONNECTED:
            if (event == EVT_RECV_I_FRAME) {
                // 检查序号,处理数据
                if (frame->ns == link->recv_seq) {
                    process_data(frame);
                    link->recv_seq++;
                    send_RR(link->recv_seq);  // 发送接收就绪
                } else {
                    send_REJ(link->recv_seq);  // 序号错,请求重传
                }
            } else if (event == EVT_RECV_DISC) {
                send_UA(frame);
                link->state = DISCONNECTED;
                printf("[HDLC] 链路已断开\n");
            }
            break;

        // ... 其他状态处理
    }
}

个人经验:状态机最怕「漏状态」。我建议你画一张状态转移图,把每个状态、每个事件、每个动作都标清楚。然后对着图写代码,写完再对着代码检查图。我曾经因为漏了「超时重试」这个事件,导致设备在信号不好的地方永远连不上——因为状态机卡在CONNECTING态,没人去触发重试。

好了,数据链路层的内容就这些。下一章我们进入网络层,看看POS机怎么在IP网络上安全地传输金融数据。记住,链路层是基础,基础不牢,地动山摇。你在调试时如果遇到莫名其妙的问题,先回头检查CRC对不对、帧同步有没有漏掉、状态机有没有死锁。八成问题出在这里。