3、IEC60870-5-101链路层编程:串口通信基础、CRC校验实现、链路层状态机编程

好,咱们今天来啃一块硬骨头——IEC 101的链路层编程。说实话,很多做电力通信的工程师,一提到链路层就头疼。为什么?因为串口通信本身就不太稳定,再加上CRC校验、状态机这些概念,确实容易让人绕晕。

但我个人觉得,只要把基础打牢,链路层其实没那么可怕。咱们今天就从串口通信基础开始,一步步把CRC校验和状态机都讲透。

3.1 串口通信基础:别小看这根线

串口通信,说白了就是一根线发数据,一根线收数据。IEC 101协议在串口上跑,用的是RS-232或RS-485标准。我刚开始做项目时,总觉得串口通信太简单了,结果在调试现场吃了不少亏。

先看几个关键参数:

参数 典型值 说明
波特率 9600、19200 电力系统常用,别用太高,容易丢包
数据位 8位 IEC 101固定8位
停止位 1位 也可以2位,但1位够用
校验位 偶校验 很多老设备要求偶校验

嗯,这里要注意:波特率不是越高越好。我见过有人把波特率设到115200,结果在强电磁干扰环境下,数据全乱了。电力系统里,9600是黄金选择。

我的经验:调试串口时,先用示波器看波形。如果波形边沿不陡峭,说明阻抗不匹配,加个120欧姆终端电阻试试。

3.2 CRC校验实现:别让数据出错

CRC校验,全称是循环冗余校验。IEC 101用的是CRC-16,多项式是x^16 + x^12 + x^5 + 1。说白了,就是给数据包加个“指纹”,接收方用同样的算法算一遍,指纹对得上就说明数据没被篡改。

我曾经在一个变电站项目里,因为CRC实现有bug,导致遥测数据偶尔跳变。排查了三天,最后发现是查表法里的多项式写错了。从那以后,我写CRC代码都会反复核对多项式。

下面是我常用的CRC-16实现,查表法,效率高:

// CRC-16查表法实现
// 多项式:0x8005 (x^16 + x^12 + x^5 + 1)
uint16_t crc16_table[256];

void crc16_init_table() {
    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];
        crc = (crc << 8) ^ crc16_table[index];
    }
    return crc;  // 不需要取反
}
避坑指南:我曾经遇到过一个问题——CRC算出来总是差几个字节。后来发现,IEC 101的CRC计算范围不包括起始字符和长度字节。你想想看,如果算多了,校验肯定对不上。

3.3 链路层状态机编程:让通信有序进行

链路层状态机,说白了就是管理通信流程的“交通警察”。IEC 101的链路层有几种状态:空闲态、发送态、接收态、等待确认态。为什么需要状态机?因为串口通信是半双工的,同一时间只能发或收,状态机就是用来协调这个的。

我画了一张状态机图,方便你理解:

空闲态 发送态 接收态 等待确认态 有数据要发 收到起始字符 发送完成 收到确认 超时重发 接收完成 IEC 101 链路层状态机

状态机的实现,我习惯用switch-case结构,清晰又好维护。下面是一个简化版:

typedef enum {
    LINK_IDLE,      // 空闲态
    LINK_SEND,      // 发送态
    LINK_RECV,      // 接收态
    LINK_WAIT_ACK   // 等待确认态
} LinkState;

LinkState current_state = LINK_IDLE;

void link_state_machine(uint8_t event) {
    switch (current_state) {
        case LINK_IDLE:
            if (event == EVENT_SEND_REQ) {
                // 有数据要发送
                uart_send_frame();
                current_state = LINK_SEND;
            } else if (event == EVENT_RECV_START) {
                // 收到起始字符
                uart_recv_frame();
                current_state = LINK_RECV;
            }
            break;
            
        case LINK_SEND:
            if (event == EVENT_SEND_DONE) {
                // 发送完成,等待确认
                start_timer(TIMEOUT_100MS);
                current_state = LINK_WAIT_ACK;
            }
            break;
            
        case LINK_RECV:
            if (event == EVENT_RECV_DONE) {
                // 接收完成,校验CRC
                if (crc16_calc(rx_buf, rx_len) == 0) {
                    process_frame();
                }
                current_state = LINK_IDLE;
            }
            break;
            
        case LINK_WAIT_ACK:
            if (event == EVENT_ACK_RECV) {
                // 收到确认
                stop_timer();
                current_state = LINK_IDLE;
            } else if (event == EVENT_TIMEOUT) {
                // 超时,重发
                uart_send_frame();
                start_timer(TIMEOUT_100MS);
                // 重发次数限制,超过则报错
            }
            break;
    }
}
核心要点:状态机编程的关键是“事件驱动”。每个状态只关心自己能处理的事件,处理完就切换到下一个状态。别想着一个状态干所有事,那样代码会乱成一锅粥。

3.4 实战中的坑与经验

最后,分享几个我在项目中踩过的坑:

  • 串口缓冲区溢出:我曾经在接收数据时,没及时清空缓冲区,结果下一帧数据来了,把上一帧覆盖了。解决方案是用环形缓冲区。
  • CRC计算范围搞错:IEC 101的CRC只算数据部分,不包括起始字符和长度。我一开始没注意,调试了两天才发现。
  • 状态机死锁:有一次,设备发送数据后没收到确认,状态机卡在等待确认态,再也不动了。后来加了超时机制,问题解决。

嗯,链路层编程就是这样,细节决定成败。你只要把串口通信、CRC校验、状态机这三个基础打牢,IEC 101的链路层就没什么能难住你的了。


专注资料整理