Modbus RTU 从站接收状态机实现:IDLE 中断省掉了一个定时器,也顺手省掉了半帧数据

2026年10月30日 0 By admin

上一篇把透传链路那笔账算完,结论是”帧边界只能由会算帧长的一端负责”。这次把视角换到另一端:设备自己就是那个从站,USART 收字节、DMA 搬缓冲、IDLE 判帧——这套写法在教程里到处都是,也确实省掉了一个 T3.5 定时器。

省掉的不止定时器。

现场是三台储能网关挂着 485,主站是客户的 EMS。上线半年,攒下三个症状,按出现频率排:

  1. 产线工装连不上,同一台设备、同一根线,接客户 EMS 一切正常。工装在另外两个型号上也好使。
  2. 日志里每天几十条 CRC 校验错,重发一次就好,没人当回事。
  3. 上个月一台设备的”额定功率”被写成了一串十位数,正好是那天广播校时报文里的 Unix 秒——校时把时间写进了参数区。

三个症状查了三天,根都在同一处:USART_SR 里那个 IDLE 位。

先把结论放在前面:IDLE 中断的判帧阈值是 1 个字符时间,Modbus 规范要求的是 3.5 个。9600 8E1 下,1 个字符是 1.146ms,3.5 个是 4.010ms——它提前 71.4% 就把帧交出去了。

链路层的账在 DTU 透传模式下的 Modbus 实战 里算过,读侧对接在 储能 BMS 与 PCS 的 Modbus 对接实战 里,写侧权限在 Modbus 写参数的权限矩阵与防呆实战 里。这篇只讲一件事:从第一个字节进来到 CRC 校验完,这几十毫秒里发生了什么。


一、把规范的三条线和 IDLE 的一条线画在同一张图上

Modbus over Serial Line V1.02 定义了两个阈值:帧内字符间隔超过 T1.5 判本帧损坏,帧间静默超过 T3.5 判帧结束;波特率高于 19200 时,两者固定为 750µs 和 1.750ms,不再按字符时间算。

STM32 的 IDLE 是另一回事:接收线空闲满 1 个字符时间,IDLE 位置位。它不知道 Modbus,也不知道什么叫一帧。

$ python modbus_slave_rx_calc.py char
 波特率   字符ms   T1.5ms   T3.5ms  IDLE阈值ms  IDLE早判ms  早判占比
   1200   9.1667  13.7500  32.0833      9.1667    22.9167      71.4%
   2400   4.5833   6.8750  16.0417      4.5833    11.4583      71.4%
   4800   2.2917   3.4375   8.0208      2.2917     5.7292      71.4%
   9600   1.1458   1.7188   4.0104      1.1458     2.8646      71.4%
  19200   0.5729   0.8594   2.0052      0.5729     1.4323      71.4%
  38400   0.2865   0.7500   1.7500      0.2865     1.4635      83.6%
  57600   0.1910   0.7500   1.7500      0.1910     1.5590      89.1%
 115200   0.0955   0.7500   1.7500      0.0955     1.6545      94.5%

低波特率下早判 71.4%,这个比例是常数(1 – 1/3.5)。麻烦在高速端:波特率越高,IDLE 相对规范的窗口越小——115200 时 IDLE 只等 0.0955ms,规范要 1.75ms,早判 18.3 倍。

$ python modbus_slave_rx_calc.py idle
波特率 9600 8E1
  最后一个字节的停止位结束 -> IDLE 置位:1.1458 ms
  规范要求判帧静默           :4.0104 ms
  提前量                     :2.8646 ms(71.4%)
  T1.5 帧内非法阈值          :1.7188 ms
  IDLE 早于 T1.5 的窗口      :0.5729 ms(这一段里规范还认为帧没坏)

波特率 19200:IDLE=0.5729 ms,T3.5=2.0052 ms,早判 3.5 倍
波特率 115200:IDLE=0.0955 ms,T3.5=1.7500 ms,早判 18.3 倍

注意最后那行”0.5729ms 的窗口”。IDLE 比 T1.5 还早,也就是说存在一段字节间隔,规范认为”帧还在继续”,IDLE 已经判帧结束了。这个窗口在 9600 下只有 0.57ms 宽,但它就是第二个症状的全部。

顺带一个实现细节:IDLE 位是粘滞的,从置位到软件清掉之前一直有效。所以”中断来晚了”不会丢事件,但会丢长度——下一节说。

二、碎帧:1.15ms 和 4.01ms 之间的那条带

把主站的字节间隔从 0 扫到 5ms,三种判据给出的答案不一样:

$ python modbus_slave_rx_calc.py gap
波特率 9600 8E1:IDLE=1.1458 ms  T1.5=1.7188 ms  T3.5=4.0104 ms

 字节间隔ms    IDLE实现        规范判定        结果
     0.00    等              帧未坏            都不动作,正常
     0.50    等              帧未坏            都不动作,正常
     1.00    等              帧未坏            都不动作,正常
     1.15    已判帧            帧未坏            IDLE 已交帧,规范仍认为帧在继续 -> 碎帧
     1.20    已判帧            帧未坏            IDLE 已交帧,规范仍认为帧在继续 -> 碎帧
     1.50    已判帧            帧未坏            IDLE 已交帧,规范仍认为帧在继续 -> 碎帧
     1.72    已判帧            帧损坏            IDLE 已交帧,规范认为本帧作废
     2.00    已判帧            帧损坏            IDLE 已交帧,规范认为本帧作废
     3.00    已判帧            帧损坏            IDLE 已交帧,规范认为本帧作废
     4.01    已判帧            帧损坏            IDLE 已交帧,规范认为本帧作废
     5.00    已判帧            帧结束            两边都判帧,一致

三段:

  • 0 ~ 1.146ms:两边都不动作,正常。
  • 1.146 ~ 1.719ms:IDLE 交帧,规范认为帧还没坏。碎帧但”合法”——这一段最阴,因为它不违反 T1.5。
  • 1.719 ~ 4.010ms:规范认为本帧已经作废,IDLE 却已经把它当完整帧交上去了。从站的处理逻辑此刻会拿一段半截数据去算 CRC。

工装就落在第三段的头部。抓包看了下,它每发一个字节间隔 1.8~2.4ms(上位机逐字节 write + flush,每个字节一个 USB 事务,USB 帧周期 1ms 打底)。9600 下一个字符才 1.146ms,这 8 个字节在从站眼里是 8 个独立的 1 字节帧。

8 个 1 字节帧,每个都过不了长度检查(FC03 请求要 8 字节),全部丢弃。主站等 300ms 超时,报”无响应”。

$ python modbus_slave_rx_calc.py case
  工装逐字节:8B 请求 -> 8 段,每段 1B;从站收到 8 个 1B 帧,长度不足全部丢弃
  8 段共 25.2ms(整帧只要 9.17ms,慢 2.75 倍)

同一个工装接另外两个型号正常,因为那两个型号用的是 T3.5 软件定时器——定时器只认”静默够长没有”,2ms 的间隔根本不算帧结束。

客户的 EMS 一次 write 把 8 字节交给驱动,字节间隔就是线速本身(0.104ms),IDLE 永远等不到置位。所以这不是”设备坏了”,是”设备的判帧阈值绑在了主站的发送习惯上”。

这一条值得单独记住:判帧阈值不是一个实现细节,它是一个对外接口。你选了 1 个字符,就等于对主站提出了”字节间隔必须小于 1.146ms”的要求,而这个要求从来没写进任何一份对接文档。

三、四种判帧实现的取舍

实现 判帧阈值 误差 需要定时器 适用系列
IDLE 中断 1 字符 早判 71.4%(9600) 不需要 全部
USART RTO 3.5 字符(按位时间编程) 1 位 = 2.6% 不需要 F0/F3/F7/L4/G0/G4/H7
软件定时器 3.5 字符 ±1 tick = 12.5%(最坏) 需要 全部
长度状态机 收满即判 无(长度可推导时) 不需要 全部

RTO 是这四者里唯一”既准又不用 CPU”的。它按位时间计数,9600 8E1 下 1 位 = 0.1042ms,3.5 字符 = 38.5 位,写 39 就行:

$ python modbus_slave_rx_calc.py rto
波特率 9600 8E1:1 位时间 = 0.1042 ms,1 字符 = 1.1458 ms
  RTO(BLEN=0) 以位时间为单位,3.5 字符 = 38.5 位 -> 写 39
  量化误差 1 位 = 0.1042 ms,占 T3.5 的 2.60%
  1ms tick 软件定时器:取 4 tick 欠 -0.3%,取 5 tick 多 +24.7%
  RTO 误差是 tick 方案的 1/4.8(tick 最坏 ±0.5ms = 12.5%,RTO = 2.6%)

F1 和 F4 没有 RTO,这是这套固件留在 F103 上的代价:要么 IDLE,要么软件定时器,没有第三条路。

但如果功能码已知,其实还有第四条:长度可以直接算出来,根本不用等静默。

四、长度表:能算出来的,别用时间等

从站只收请求帧,请求长度是可推导的:

$ python modbus_slave_rx_calc.py len
 功能码  名称                    请求长度B   校验依据
  0x01  读线圈                           8   固定
  0x02  读离散输入                         8   固定
  0x03  读保持寄存器                        8   固定
  0x04  读输入寄存器                        8   固定
  0x05  写单线圈                          8   固定
  0x06  写单寄存器                         8   固定
  0x08  诊断                            8   固定
  0x0F  写多线圈(N=10)                   11   字节计数(按位打包)
  0x10  写多寄存器(N=10)                  29   字节计数字段
  0x17  读写多寄存器(N=10)                 33   字节计数字段

前七个功能码请求固定 8 字节,收满第 8 个字节(CRC 高字节)的那一刻就能判帧,不用等 4.01ms。FC15/FC16 是 9 + 数据字节数,N 从请求的第 5、6 字节取;FC23 是 13 + 2N。

这里有个容易写错的地方:FC15 的 N 是线圈个数,数据区按位打包,字节数是 (N+7)/8,不是 2N。N=10 时按位打包是 2 字节、整帧 11 字节;照抄 FC16 的 2N 会算成 20 字节、整帧 29 字节——正好等于 FC16 的正确帧长,所以拿 FC16 那行做对照反而看不出错,直到现场一次写 12 个线圈才炸。

/* 收满即判:把"帧结束"从时间问题变成算术问题 */
static uint16_t expect_len(uint8_t fc, const uint8_t *buf)
{
    uint16_t qty = ((uint16_t)buf[4] << 8) | buf[5];   /* 数量字段 */
    switch (fc) {
    case 0x01: case 0x02: case 0x03: case 0x04:
    case 0x05: case 0x06: case 0x08: return 8;
    case 0x0F: return 9u + (uint16_t)((qty + 7u) / 8u); /* 线圈按位打包 */
    case 0x10: return 9u + 2u * qty;
    case 0x17: {                                         /* 读写多寄存器 */
        uint16_t wqty = ((uint16_t)buf[8] << 8) | buf[9]; /* 写数量 */
        return 13u + 2u * wqty;
    }
    default:   return 0;                                 /* 未知功能码 */
    }
}

省下的时间是 4.01ms,占一次问答(8B 请求 + 25B 响应)的 9.6%:

    0x03 请求 8B 线上耗时 9.17 ms,省掉 4.01 ms 静默 = 13.18 ms 的判帧窗口
  一次问答(8B 请求 + 25B 响应):线上 9.17 + 28.65 ms;判帧窗口占 9.6%

但长度判帧有三个必须一起写的边界,少一个就会从”更稳”变成”更脆”:

  1. 未知功能码:长度返回 0,不能”收满就判”,必须退回静默判帧,收完回异常码 01。
  2. 超长帧:主站发了 40 字节而期望 29,第 29 字节处判帧,剩下的 11 字节必须在静默期内全部丢弃,不能当新帧头。
  3. 广播(地址 0):执行但不响应,且必须等静默再进下一帧——广播后面紧跟单播是常态,这一段正是第三个症状的入口。

五、DMA 环形缓冲的三个坑

标准写法是 USART1_RX + DMA1_Channel5 循环模式 + IDLE 中断。三个坑一个比一个深。

坑一:清 IDLE 要读 DR,读 DR 会和 DMA 抢字节

F1 的手册写得很清楚:IDLE 位由”读 SR 再读 DR”这个序列清除。问题在于,DR 同时是 DMA 的源地址。

/* 教科书写法,也是丢字节的写法 */
void USART1_IRQHandler(void)
{
    if (USART1->SR & USART_SR_IDLE) {
        (void)USART1->SR;          /* 第一步 */
        (void)USART1->DR;          /* 第二步:这一读,可能是 DMA 还没搬走的那一字节 */
        len = BUF_SZ - DMA1_Channel5->CNDTR;
        rx_frame_ready(buf, len);
    }
}

如果这一帧的最后一个字节刚到、RXNE 还挂着,DMA 还没来得及响应请求,CPU 这一步读就把字节吃掉了:DMA 永远拿不到它,缓冲里少一字节,后续所有字节整体前移一位,CRC 必错。

正确顺序是先把 USART 的 DMA 接收请求关掉,让 DMA 不可能在中间插进来,再判断 RXNE 决定这一读是”空读”还是”补字节”:

void USART1_IRQHandler(void)
{
    if (USART1->SR & USART_SR_IDLE) {
        USART1->CR3 &= ~USART_CR3_DMAR;        /* 1. 断开 DMA 请求,不断通道 */
        uint32_t sr = USART1->SR;              /* 2. 读 SR */
        uint16_t len = BUF_SZ - DMA1_Channel5->CNDTR;

        if (sr & USART_SR_RXNE) {              /* 3. DMA 落后一个字节 */
            buf[len++] = (uint8_t)USART1->DR;  /*    手工补进缓冲 */
        } else {
            (void)USART1->DR;                  /*    空读,只为清标志 */
        }
        USART1->CR3 |= USART_CR3_DMAR;         /* 4. 恢复 */

        rx_frame_ready(buf, len);              /* 长度已锁定 */
    }
}

用 CR3.DMAR 而不是关 DMA 通道,是因为关通道要等 EN 真正清零(有 1~2 个总线周期延迟),期间 CNDTR 可能还在动;而 DMAR 只影响请求的产生,CNDTR 立刻稳定。F0/F3/F7/L4/H7 上用 USART_ICR = USART_ICR_IDLECF 写 1 清零,整段都不需要,这也是换芯片时最值钱的一条差异。

坑二:队列里存指针,缓冲会被下一帧覆盖

这是第三个症状的直接原因。中断里把 buf + offset 和长度入队,主循环取出来处理。看起来没问题,直到主站把两帧背靠背发出来(广播校时 + 单播读,广播不需要响应,主站不会等):

$ python modbus_slave_rx_calc.py dma
覆盖窗口:从 IDLE 到把数据拷走的时间 vs 主站发下一帧的间隔
  场景                          拷贝窗口   下一帧间隔   是否覆盖
  一问一答 100ms 轮询                   0.10ms    100.00ms   不会
  一问一答 10ms 轮询                    0.10ms     10.00ms   不会
  广播后紧跟单播                         0.10ms      0.00ms   会
  广播后单播 + 处理里擦 Flash             40.00ms      0.00ms   会

广播帧还在队列里排着,单播帧已经写进了同一块环形缓冲。主循环处理广播帧时读到的是单播帧的数据、广播帧的长度——于是”把 UTC 时间戳写到参数区”就发生了。那台设备的额定功率寄存器是 32 位、单位 0.1kW,读出来正好是校时报文里的 1793347200。

修法只有一个:队列里存拷贝,不存指针。8 字节 memcpy 在 72MHz 上约 0.11µs,一天 8640 帧总共 0.95ms,这笔钱不值得省。

坑三:帧跨回绕边界

256 字节缓冲、8 字节帧,帧跨过 BUF_SZ 边界的概率是 7/256:

环形缓冲 256B,8B 请求 -> 容量 32 帧
  帧跨回绕边界的概率:2.73%(7/256)
  每天 8640 帧 -> 跨边界 236 次;忘了拼接就是这些帧 CRC 错

一天 236 次,正好对得上”每天几十到几百条 CRC 错”的量级。跨边界时帧被拆成 buf[250..255] 和 buf[0..1] 两段,必须 memcpy 拼一次再算 CRC。不做拼接的实现不会报错,只会 CRC 错——而 CRC 错在统计里和”干扰”长得一模一样,这就是它藏了半年的原因。

六、CRC 与地址过滤

CRC16 两种实现的差距是 8.8 倍:

$ python modbus_slave_rx_calc.py crc
 长度B   查表8c/B    位运算70c/B   倍数
     8     0.89us        7.78us    8.8x
    25     2.78us       24.31us    8.7x
    32     3.56us       31.11us    8.8x
   256    28.44us      248.89us    8.8x

8 字节请求上这点差距无关紧要(0.89µs vs 7.78µs,都在一个字符时间内)。但地址过滤必须发生在第 1 个字节:

  先比地址再算 CRC:8 台从站挂在同一条 485 上,只有 1/8 的帧需要算
    省下 87.5% 的 CRC 计算;8B 帧上省 0.78us,本身不多,省的是中断里的栈和分支
  但地址比较必须在第 1 个字节到达时做,不能等收完:
    收完 8B 再比地址 = 9600 下 9.17ms 白等;提前比 = 1.15ms 内丢弃

一条 485 上挂 8 台设备,87.5% 的帧不是给你的。第 1 个字节不对就立刻把状态机打回 IDLE、等静默——不要收完再丢,那 9.17ms 里下一帧可能已经开始了。

还有一条规范要求,很多实现会写错:CRC 错的帧必须静默丢弃,不能回异常码 04。回了会怎样?总线上所有从站都”有一点错”,多台同时回异常,冲突出错,主站看到的现象就完全对不上根因了。

七、DE 引脚:等 TC,不等 TXE

RS485 半双工的 DE 时序,物理层那部分在 RS485/Modbus 抗干扰实战 里讲过差分和偏置,这里只说切换时刻。

/* 发送:DE 拉高 -> 写 DR */
void rs485_send(const uint8_t *p, uint16_t n)
{
    RS485_DE_HIGH();
    for (uint16_t i = 0; i < n; i++) {          /* 或用 DMA,效果一样 */
        while (!(USART1->SR & USART_SR_TXE)) {}
        USART1->DR = p[i];
    }
    /* 不在这里拉低 DE */
}

/* TC 中断:最后一个停止位发完了 */
void USART1_IRQHandler(void)
{
    if (USART1->SR & USART_SR_TC) {
        (void)USART1->SR;
        (void)USART1->DR;
        RS485_DE_LOW();                          /* 此刻才拉低 */
        tx_done = 1;
    }
}
$ python modbus_slave_rx_calc.py de
波特率 9600:1 位 = 104.2us,1 字符 = 1.1458 ms
  TXE 置位(DR 空了)到 TC 置位(停止位发完)差 1 个字符 = 1.146 ms
  在 TXE 拉低 DE:最后一字节整 11 位被截断,从站收到残字节
  在 TC 拉低 DE:正好在停止位结束,多保持 0

TXE 的意思是”数据寄存器空了,可以写下一个字节”,此刻最后一个字节刚进移位寄存器,还没开始移。在这里拉低 DE,等于把这一字节的起始位、8 个数据位、校验位、停止位一共 11 位全掐掉。从站收到的是一帧短一个字节的数据,CRC 必错——每帧都错,而且错得很有规律:永远错最后一个字节。这个特征很好认,比 CRC 偶发错好查得多。

反过来,等 TC 的代价只是”DE 多保持一会儿”,而多保持是无害的:

  TC 中断延迟 5us -> DE 多保持 0.005 ms = 0.4% 字符(无害,只是总线多驱动一会)
  TC 中断延迟 50us -> DE 多保持 0.050 ms = 4.4% 字符(无害,只是总线多驱动一会)
  TC 中断延迟 200us -> DE 多保持 0.200 ms = 17.5% 字符(无害,只是总线多驱动一会)

真实工程里更常见的错法不是用 TXE,而是用 TXE + 软件延时 1 个字符来”代替 TC”:

  用 TXE + 软件延时 1 个字符代替 TC:1ms tick 量化误差 +/-0.5ms = +/-43.6% 字符

1ms tick 上延时 1 个字符,误差接近一半——运气好截掉半个字节,运气不好截掉一个。这个写法能”大部分时候正常”,所以最难查。

DE 建立时间留 1 位(104.2µs)就够。MAX3485 的驱动器使能 tZL/tZH 典型 50ns,收发器本身不是瓶颈;留这 1 位是为了让总线先进入确定电平,别让首字节的起始位被从站当成噪声。

八、自发自收:CRC 错计数翻倍的来源

第二个症状里有一半是假的。

如果 RE 常使能(或者 RE 与 DE 没并联),从站发出去的响应会被自己的接收端原样收回来:

$ python modbus_slave_rx_calc.py selfrx
RE 常使能 + 半双工:自己发出的响应会被自己的接收端收回来
  响应 25B 结构:[addr][0x03][bytecount][data...][crc]
  收到的前两字节 = 自己的地址 + 0x03 -> 长度表判 8B -> 收满 8B 算 CRC
  这 8B 是 bytecount + 前 6 个数据字节,CRC 必然不过(除非 1/65536 巧合)

  结果:每发一次响应,CRC 错计数 +1,诊断计数直接翻倍
  一天 8640 次问答 -> 日志里 8640 条 CRC 错,全是自己发的

于是你拿着”每天 8640 条 CRC 错”去查干扰、查终端电阻、查屏蔽接地,查了半年——一半的错是自己的接收端制造的。验证方法很简单:把 485 的 A/B 从总线上摘下来只发不收,如果 CRC 错计数还在涨,就是这个问题。

修法:发送期间关 RE,或者 DE/RE 并联用一个 GPIO 驱动,或者干脆在发送状态机里屏蔽接收(但要注意屏蔽期间不能把 RXNE 堆起来)。

九、一次响应的完整预算

把上面这些拼起来,一次 8 字节请求 + 25 字节响应的账是这样的:

$ python modbus_slave_rx_calc.py budget
 环节                耗时ms    占比
 判帧静默(IDLE)            1.146     3.8%
 CRC(8B 查表)            0.001     0.0%
 应用处理                  0.200     0.7%
 组帧+启动发送               0.050     0.2%
 发送 25B               28.646    95.4%
 合计                   30.043  100.0%

  主站超时 300ms -> 余量 270.0 ms(90.0%)
  改用长度判帧(省掉 1.146ms)-> 28.897 ms
  若在中断里擦一页 Flash(40ms)-> 68.897 ms,且这 40ms 内所有接收中断都被挡住

95.4% 的时间在发送,判帧只占 3.8%。所以”优化判帧能提速”是假的——省下 1.146ms 只让总时间从 30.043 变成 28.897ms。长度判帧真正的价值不是快,是把”帧结束”从一个会漂移的时间量变成一个确定的事件。

但这一栏要注意那个 40ms:参数写入要擦 Flash,F103 上擦一页 20~40ms,且从 Flash 取指的代码在擦写期间会停总线。这件事在 Flash 存储与参数管理实战 和 参数区双备份与出厂恢复流程实战 里算过擦除账,但那些账都在讲寿命。这里补一句运行时的:擦除绝对不能放在中断上下文里。放在中断里,这 40ms 内所有接收中断都被挡住,下一帧直接丢;主站超时重发,重发帧和当前帧在缓冲里撞上,就是前面那个”覆盖”。参数写 Flash 的路径必须走主循环 + 状态机,接收中断该多快还得多快。

十、上线自检清单

改完这版固件,我把下面这些做成了一个开机自检页(只读寄存器区),每次上电跑一遍,结果挂在诊断寄存器里:

  • ☐ 判帧阈值与波特率匹配:9600 用 4.010ms,115200 用 1.750ms,不按字符时间硬算
  • ☐ 长度表覆盖 FC01/02/03/04/05/06/08/0F/10/17,未知功能码退回静默判帧
  • ☐ 第 1 字节比地址,不符立刻回 IDLE 并等静默,不收满 8 字节
  • ☐ CRC 错静默丢弃,不回异常码,丢弃后清空缓冲 + 等静默
  • ☐ 清 IDLE 前先关 CR3.DMAR,并判断 RXNE 决定空读还是补字节
  • ☐ 队列存 8 字节拷贝,不存环形缓冲指针
  • ☐ 帧跨回绕边界时两段拼接,拼接后 CRC 与单段一致
  • ☐ DE 由 TC 中断拉低,不由 TXE 拉低,不用延时代替
  • ☐ 发送期间关闭 RE;断开 485 只发不收时 CRC 错计数应保持不变
  • ☐ 广播帧执行后不响应,且必须等静默再接收下一帧
  • ☐ 参数擦写不在中断上下文,单次阻塞时长记录到诊断寄存器

那张”CRC 错计数不变”的验证最有用。它是唯一一条不看协议、只看数字就能否掉一半假设的检查。

现场排查按这张表走,比翻代码快:

现象 先查什么 判据
换主站就坏 主站字节间隔 大于 1.146ms(9600)即碎帧
只有高波特率坏 IDLE 是否按字符时间硬算 高于 19200 阈值必须固定 1.75ms
每帧都 CRC 错 请求长度是不是 8 字节 数一下线上实际字节
CRC 错计数大得离谱 发送时 RE 有没有关 断开 485 只发不收,计数应归零
偶发丢 1 字节 清 IDLE 有没有读 DR F1 上先关 DMAR 再清
偶发回上一帧的数据 队列里存指针还是拷贝 打印队列里 buf 的地址
最后一字节总错 DE 是 TC 拉低还是 TXE 拉低 示波器看 DE 与最后一字节
广播后第一帧必错 广播后有没有等静默 广播不回,主站立刻发下一帧

十一、小结

回头看这三个症状,其实是同一个错误的三种表现:

  • 工装连不上:判帧阈值 1 字符,绑死了主站的发送习惯。
  • CRC 错计数偏高:自发自收 + 清 IDLE 抢字节 + 跨边界不拼接,三个来源叠在一起。
  • 校时写进参数区:队列存指针,缓冲被下一帧覆盖。

三个都不是”通信协议”的问题,是接收状态机的实现问题。而它们有一个共同点:都不报错。工装报的是”无响应”,CRC 错报的是”干扰”,参数错了没人报——直到逆变器限流异常。

改完之后:判帧换成 RTO(那批板子换成了 L4,F1 的老板子退回”软件定时器 + 长度判帧”双保险),队列改成拷贝,DE 改到 TC,发送期间关 RE。CRC 错计数从每天 8640 条降到 0~3 条(剩下的确实是现场干扰,接着查 RS485 抗干扰那篇 里的接地)。

最值得记的一条:IDLE 中断省掉的不是定时器,是”帧边界”这个概念。它给你的是”线路安静了一下”,不是”一帧收完了”。这两件事在 9600 下差 2.86ms,在 115200 下差 1.65ms——差的那段时间里,主站还在发。

下一篇预告:帧收完、CRC 过了,接下来要把 PDU 落到变量上。同一块电池的 32 位电度值,两个主站读出来差 100 倍;同一张点表,一方按大端拼、一方按小端拼;float 在寄存器里到底占一个还是两个——《Modbus 从站寄存器映射与字节序:同一个 32 位电度值,两个主站读出两个数》,把点表到结构体这一段拆开看。


本文所有数值由离线脚本 modbus_slave_rx_calc.py 复算(33 项自检全过),场景数据来自现场复盘,时间参数按 9600 8E1 取值,MCU 按 STM32F103 @72MHz。


本文为个人工程实践记录,具体设计请以所用芯片手册与协议规范为准。