4、接收态详解:帧起始检测、地址匹配、CRC校验。接收超时与帧长度异常处理

好,咱们今天聊聊从站状态机里最核心的一个环节——接收态。说白了,就是从站怎么判断「这包数据是发给我的吗?数据有没有坏?我该不该收?」

我做了这么多年Modbus,发现很多新手甚至老手,都在接收态上栽过跟头。不是地址配错了,就是CRC算反了,再不然就是超时没处理好,导致整个从站卡死。嗯,咱们今天就把这些坑一个一个填平。

4.1 帧起始检测:从沉默中苏醒

接收态的第一步,是检测帧起始。Modbus是异步串行通信,没有专门的帧起始信号。那怎么知道一帧开始了?

答案是:总线空闲后的第一个字节

我个人的习惯是,在接收中断里维护一个「总线空闲计时器」。只要总线上有数据,计时器就清零;一旦总线空闲超过3.5个字符时间,我就认为上一帧结束了,下一帧即将开始。

这里有个关键点:3.5字符时间怎么算?

波特率 1个字符时间(10位) 3.5字符时间
9600 1.04 ms 3.65 ms
19200 0.52 ms 1.82 ms
115200 0.087 ms 0.30 ms

你看,波特率越高,时间越短。我见过有人用固定1ms做超时,结果9600能跑,115200就丢帧。为什么?因为115200的3.5字符时间才0.3ms,1ms的计时器根本来不及复位。

我的小技巧: 用定时器捕获模式,或者用硬件UART的空闲中断。很多MCU的UART自带「总线空闲检测」功能,比软件轮询准得多。

4.2 地址匹配:这包数据是给我的吗?

检测到帧起始后,第一个字节就是从站地址。这时候要做两件事:

  1. 判断地址是否匹配——是自己的地址吗?
  2. 判断是否是广播地址——0x00是广播,所有从站都要收。

代码实现其实很简单:

// 接收中断服务函数
void UART_IRQHandler(void) {
    uint8_t rx_byte = UART->DR;
    
    if (bus_idle_timer > 3.5_char_time) {
        // 新帧开始
        rx_index = 0;
        rx_buffer[rx_index++] = rx_byte;
        frame_started = 1;
    } else if (frame_started) {
        // 继续接收
        rx_buffer[rx_index++] = rx_byte;
        // 检查是否超长
        if (rx_index > MAX_FRAME_LEN) {
            // 帧长度异常,丢弃
            frame_started = 0;
            rx_index = 0;
        }
    }
    
    // 复位总线空闲计时器
    bus_idle_timer = 0;
}

注意看,我在接收第一个字节后,并没有立即做地址匹配。为什么?因为地址匹配可以放在帧接收完成后统一处理。你想想看,如果第一个字节地址不匹配,后面的数据还要不要收?

我的建议是:收完整个帧再做地址匹配。 原因有二:

  • 万一地址不匹配,但后面有CRC错误,你提前丢弃了,主站重发时你反而收不到。
  • 广播地址0x00需要接收完整帧,不能因为地址不是自己的就丢弃。
注意: 如果你用的是RS485半双工,地址不匹配时,千万不要往总线上发任何数据。我曾经见过一个项目,从站地址不匹配时,居然发了一个「地址错误」的异常响应,结果整个总线被它搞乱了。

4.3 CRC校验:最后的防线

帧收完了,地址也匹配了,接下来就是CRC校验。Modbus RTU用的是CRC-16,多项式是0x8005,初始值0xFFFF。

我见过很多人直接在网上复制CRC代码,结果高低字节搞反了。Modbus的CRC是低字节在前,高字节在后。也就是说,收到的CRC字节顺序是:CRC_L, CRC_H。

校验逻辑很简单:

uint16_t calc_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;  // 0xA001是0x8005的反转
            } else {
                crc >>= 1;
            }
        }
    }
    return crc;
}

// 校验时,计算除最后2个字节外的所有数据的CRC
uint16_t calc_crc = calc_crc16(rx_buffer, rx_len - 2);
uint16_t recv_crc = (rx_buffer[rx_len - 1] << 8) | rx_buffer[rx_len - 2];

if (calc_crc != recv_crc) {
    // CRC错误,丢弃帧
    // 注意:不要发送任何响应
}

这里有个细节:CRC校验失败后,从站应该怎么做?

答案是:什么都不做。不要发异常响应,不要发任何东西。因为CRC错误说明数据可能被干扰了,你发的任何响应都可能造成总线冲突。

核心原则: 只要CRC校验失败,就当这帧数据没收到。主站超时后会重发。

4.4 接收超时与帧长度异常处理

这两个问题,是实际项目中最容易出Bug的地方。

4.4.1 接收超时

什么叫接收超时?就是一帧数据收了一半,突然停了。比如主站发了地址和功能码,然后掉线了。从站不能一直等下去,必须有个超时机制。

我个人的做法是:帧间超时用3.5字符时间,帧内超时用1.5字符时间

  • 帧间超时(3.5字符):用于判断上一帧结束,下一帧开始。
  • 帧内超时(1.5字符):用于判断接收过程中,字节间隔是否过长。

如果帧内超时触发,说明数据不完整,直接丢弃当前帧,复位接收状态机。

// 定时器中断,每1ms执行一次
void Timer_IRQHandler(void) {
    if (frame_started) {
        byte_gap_timer++;
        if (byte_gap_timer > 1.5_char_time_ms) {
            // 帧内超时,丢弃
            frame_started = 0;
            rx_index = 0;
            byte_gap_timer = 0;
        }
    }
    
    bus_idle_timer++;
    if (bus_idle_timer > 3.5_char_time_ms) {
        // 总线空闲超过3.5字符,标记帧结束
        if (frame_started && rx_index > 0) {
            // 处理完整帧
            process_frame();
        }
        frame_started = 0;
        rx_index = 0;
    }
}

4.4.2 帧长度异常

帧长度异常有两种情况:

  1. 太短:比如只有2个字节(地址+功能码),没有数据和CRC。
  2. 太长:超过了Modbus规定的最大帧长度(RTU模式下通常是256字节)。

对于太短的帧,我的做法是:等超时后丢弃。因为你不确定后面还有没有数据,万一只是字节间隔长了点呢?

对于太长的帧,我的做法是:立即丢弃,并复位接收缓冲区。因为Modbus协议规定,一帧数据不可能超过256字节。如果超过了,要么是干扰,要么是协议错误,没必要再收下去。

我曾经踩过的坑: 有一次做项目,从站接收缓冲区只有128字节。结果主站发了一帧150字节的数据,我的缓冲区溢出了,把后面的变量全覆盖了。从那以后,我规定接收缓冲区至少256字节,并且每次接收前检查剩余空间。

4.5 知识体系总览

说了这么多,咱们用一张图来总结一下接收态的核心逻辑:

接收态状态机核心流程 总线空闲 检测到第一个字节 帧起始检测 3.5字符空闲后首字节 地址匹配 本机地址 / 广播地址 不匹配 丢弃帧,继续监听 匹配 CRC校验 计算CRC-16,比对 失败 成功 处理帧 异常处理 • 接收超时 → 丢弃帧 • 帧长度异常 → 立即丢弃

这张图把接收态的核心逻辑串起来了。你仔细看,从总线空闲开始,到帧起始检测、地址匹配、CRC校验,每一步都有分支。不匹配或校验失败,就回到空闲状态继续监听;只有全部通过,才进入帧处理。

嗯,接收态的内容就这些。说白了就是三个判断:是不是给我的?数据坏没坏?收完整没有? 把这三点做好了,你的从站就稳了八成。

最后说一句: 调试接收态的时候,我建议你用逻辑分析仪抓一下波形。看看帧间隔、字节间隔是不是符合3.5/1.5字符时间的要求。很多时候,问题出在时序上,而不是代码逻辑上。