Modbus RTU 从站接收状态机实现:IDLE 中断省掉了一个定时器,也顺手省掉了半帧数据
上一篇把透传链路那笔账算完,结论是”帧边界只能由会算帧长的一端负责”。这次把视角换到另一端:设备自己就是那个从站,USART 收字节、DMA 搬缓冲、IDLE 判帧——这套写法在教程里到处都是,也确实省掉了一个 T3.5 定时器。
省掉的不止定时器。
现场是三台储能网关挂着 485,主站是客户的 EMS。上线半年,攒下三个症状,按出现频率排:
- 产线工装连不上,同一台设备、同一根线,接客户 EMS 一切正常。工装在另外两个型号上也好使。
- 日志里每天几十条 CRC 校验错,重发一次就好,没人当回事。
- 上个月一台设备的”额定功率”被写成了一串十位数,正好是那天广播校时报文里的 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%
但长度判帧有三个必须一起写的边界,少一个就会从”更稳”变成”更脆”:
- 未知功能码:长度返回 0,不能”收满就判”,必须退回静默判帧,收完回异常码 01。
- 超长帧:主站发了 40 字节而期望 29,第 29 字节处判帧,剩下的 11 字节必须在静默期内全部丢弃,不能当新帧头。
- 广播(地址 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。
本文为个人工程实践记录,具体设计请以所用芯片手册与协议规范为准。