单路隔离 RS485 诊断小板
一块挂在 RS485 总线上的被动监听节点:不发送任何数据, 只记录总线上每一个字节和它与前一个字节之间的时间间隔。 通过间隔时间就能把字节流还原成一帧一帧的报文,进而统计超时、CRC 错误、 异常码、应答延迟与重试次数,并在超出阈值的时刻把前后原始报文落盘保存。 目前已完成方案设计与固件的帧重组部分,硬件在打样验证阶段。
一、为什么需要一块“只听话”的板子
排查 RS485 通信问题,通常的手段是用电脑加一个 USB 转 485 转换器接上去抓包。 这个办法在实验室里很好用,在现场有两个问题:
- 电脑不方便长期挂在那里。偶发故障可能几天才出现一次, 而人不可能在配电柜旁边守三天。
- USB 转换器的软件层会把一些细节吃掉。大多数串口助手只给你“收到了什么”, 不给你“两个字节之间隔了多久”——而后者恰恰是判断波特率不匹配、 总线冲突、发送方被中断打断这类问题的关键证据。
所以这块板子的定位很明确:一个能长期挂着、只记录不干扰、并且记录字节级时间信息的节点。 它不替代协议分析仪,它解决的是“故障发生的那一刻没人在场”这个问题。
二、三个反直觉的硬件约束
设计这块板子的时候,我踩到的坑都不是“电路画错了”,而是“想法本身有问题”。 一个诊断工具如果自己改变了被测对象,那它测出来的就不是原来的问题了。
2.1 监听节点必须“永远不会”驱动总线
RS485 是半双工,收发共用一对差分线。正常情况下固件靠一个方向控制脚 (通常标记为 DE/RE)在收和发之间切换。但在诊断板上,这个脚必须被硬件固定在接收状态, 而不是“由固件保证不去发送”。
原因很直接:如果方向脚由固件控制,那么固件跑飞、复位期间引脚处于高阻或上拉状态、 或者调试时不小心执行了一次发送,这块板子就会在总线上乱说话—— 在一个本来就有通信问题的现场,再插入一个乱说话的节点,问题会立刻变得更复杂。
/* 诊断板的方向控制:不接 MCU,硬件直接下拉到接收态 */
/*
* 收发器 DE/RE 引脚 —— 10kΩ 下拉到 GND
* (低电平 = 接收使能、发送禁止)
*
* 绝不要把 DE/RE 接到 MCU 的 GPIO 上,
* 哪怕固件里"保证"不会置高。
* 这是一个硬件层面的互锁,不是软件约定。
*/2.2 隔离要隔两样东西,不只是信号
只给 A/B 信号线加隔离是不够的。真正要断的是地环路: 如果诊断板和被测设备分别接在不同的供电回路上,两个“地”之间存在电位差, 这个电位差会通过信号地形成环流,轻则引入噪声、重则损坏接口。
所以隔离方案必须同时包含:
- 信号隔离:数字隔离器或光耦,把 MCU 侧的串口信号与总线侧隔开;
- 电源隔离:隔离型 DC-DC,让总线侧那一小部分电路有自己的浮地电源。
只做信号隔离、电源仍共地,等于没做——这是我在看别人的设计时发现的一个常见误解。
2.3 不要随手再加一个终端电阻
RS485 总线两端各有一个 120 Ω 终端电阻,这是常识。但“再加一个节点”的时候, 很容易顺手把诊断板也做成“带 120 Ω 终端”的设计——结果是总线上多并了一个 120 Ω, 总线负载从 60 Ω 变成 40 Ω,驱动能力不足的收发器可能因此通信质量变差。
诊断板应该做成高输入阻抗、终端电阻可用跳线断开的形式, 并且默认不接。如果要接,也要先确认原总线的终端位置。 同样地,节点的输入电容越小越好——多一个节点就多一份负载, 而这个负载是诊断工具自己引入的。
三、核心:用“字节间隔”把字节流切成帧
被动监听拿到的是一个连续的字节流,没有帧的边界。而 Modbus RTU 的帧边界是 由时间定义的——标准规定帧内字节间隔不能超过 3.5 个字符时间, 超过就认为是新的一帧开始。
所以诊断板的核心机制很简单:给每一个字节打一个时间戳,记录它与前一个字节的间隔。 有了间隔,帧边界就自然出来了:
/* 字节级记录:只存"间隔"而不是"绝对时间",可以大幅压缩数据量 */
typedef struct {
uint8_t data; /* 收到的字节 */
uint16_t gap_us; /* 与上一个字节的间隔,单位微秒(上限截断) */
} byte_record_t;
/* 帧边界的判定:间隔超过 3.5 个字符时间就切帧 */
/* 9600 bps、8N1 时一个字符 10 bit,字符时间约 1.04 ms,3.5 字符约 3.65 ms */
#define CHAR_TIME_US(baud) ((10000000UL) / (baud)) /* 10 bit / 波特率 */
#define FRAME_GAP_US(baud) ((CHAR_TIME_US(baud) * 7UL) / 2UL) /* 3.5 字符 */
static uint8_t is_frame_boundary(uint16_t gap_us, uint32_t baud)
{
return (gap_us > FRAME_GAP_US(baud)) ? 1u : 0u;
}为什么存“间隔”而不是“绝对时间”?因为间隔的数值范围很小—— 正常帧内间隔只有几十到几百微秒,用一个 16 位字段就能覆盖(超出就截断), 而绝对时间戳需要 32 位甚至 64 位。同样的存储空间,存间隔能多记一倍以上的字节。
四、从字节流到“问题报告”
仅有原始字节流还不够用——现场最需要的是结论。 所以固件在切帧之后还要做一层统计与判定。我打算统计的量如下:
| 统计项 | 怎么算 | 指向什么问题 |
|---|---|---|
| 帧间隔分布 | 每两帧之间的时间差 | 轮询周期是否稳定;是否有设备长时间占着总线 |
| 请求→应答延迟 | 请求帧结束到应答帧开始的时间 | 从站响应是否超时;主站超时时间设置是否太紧 |
| 无应答次数 | 发出请求后超时仍无帧出现 | 从站掉线、地址冲突、总线被拉死 |
| CRC 错误次数 | 按协议校验每一帧 | 干扰、终端电阻不当、线缆过长、波特率偏差 |
| 异常码分布 | 统计异常应答的功能码与异常码 | 功能码不支持、地址越界、参数被写坏 |
| 帧内最大间隔 | 单帧内相邻字节的最大间隔 | 发送方被中断打断、DMA 断流、缓冲区不足 |
| 重试次数 | 相同请求连续出现的次数 | 主站的超时与重试策略是否合理 |
| “只有一个字节”事件 | 出现单字节帧且其后长时间静默 | 接收方串口进入异常状态,见下文 |
最后一项是我特意加的,因为它对应我在另一块板子上真实遇到过的一个故障: 接收方在异常之后只能收到 1 个字节,之后再无数据。 这个现象在总线上看起来就是“主站问了,从站只吐了一个字节就哑了”, 如果没有单独统计,很容易被当成随机的 CRC 错误糊过去。
五、现场该保留哪些证据
这是我做这块板子过程中最大的收获:排查偶发故障,关键不在于“看到了什么”, 而在于“当时保留了什么”。 等故障过去、人到了现场,一切已经恢复正常, 能依靠的只有当时留下的记录。
5.1 我会保留的七类证据
- 原始报文(含字节间隔),故障前后各若干帧,不是只留出错那一帧。 因为原因往往在故障之前就埋下了。
- 时间锚点:设备开机时长 + 一个粗粒度的绝对时间(来自 RTC)。 只有这样,“昨天下午三点左右出的问题”才能对上号。
- 设备侧的状态:如果条件允许,从站自己记录复位原因、故障码、 参数校验结果。总线上看不到的东西,只能靠设备自己说。
- 电气层面的观察:用示波器看差分电平幅度、上升沿、 空闲时的共模电压。通信问题时,示波器往往比串口助手有用得多。
- 环境与工况:出故障时是不是有大功率设备在启停、 是不是在某个温度点、是不是刚有人动过线。这类信息只能靠现场记录。
- 总线拓扑:节点数、终端电阻位置、线缆长度与走向、屏蔽层怎么接的。 很多“偶发”问题其实是拓扑问题,一直在那里,只是偶尔才表现出来。
- 对照实验的结论:换线之后好了没有、降波特率之后好了没有、 减少节点之后好了没有。这些结论比任何单次记录都有价值。
六、记录怎么存:环形缓冲 + 事件快照
长期挂机记录会产生大量数据,不可能都存下来,所以需要两级策略。
6.1 第一级:常驻的统计量
上面那张表里的统计项都是计数器,每个只占几个字节, 掉电前写一次存储即可。它们告诉你“有没有问题”,但不告诉你“问题长什么样”。
6.2 第二级:事件触发的原始记录快照
在 SPI Flash 里开一个环形区域,平时只记录最近一段时间的原始字节流, 写满就覆盖最旧的数据。当统计量越过阈值(例如 CRC 错误连续出现、 或出现“只有一个字节”事件)时,锁定当前这段记录并单独保存一份, 这样故障前后各一段报文都被留下来。
/* 两级记录:环形区一直滚,命中事件时把当前窗口另存为快照 */
typedef struct {
uint32_t total_frames; /* 累计帧数 */
uint32_t crc_errors; /* CRC 错误累计 */
uint32_t timeouts; /* 无应答累计 */
uint32_t short_events; /* "只有一个字节"事件累计 */
uint32_t max_gap_us; /* 观察到的帧内最大字节间隔 */
} diag_stats_t;
/* 命中任一条件即触发快照(阈值由上位机配置,不写死在固件里) */
static uint8_t trigger_hit(const diag_stats_t *s, const diag_stats_t *prev)
{
if ((s->crc_errors - prev->crc_errors) >= crc_err_burst_th) return 1;
if ((s->timeouts - prev->timeouts) >= timeout_burst_th) return 1;
if ((s->short_events > prev->short_events)) return 1;
if (s->max_gap_us >= gap_th_us) return 1;
return 0;
}阈值刻意做成由上位机配置而不是固件里的常量。 原因是不同现场的总线负载差别很大,同一个阈值在这个现场天天触发、 在另一个现场永远不触发。做成可配置之后,一个固件就能适配不同场合。
七、问题与解决
下面几条是设计过程中遇到并解决的,属于“现象 → 原因 → 办法”的记录。
现象:电脑上抓到的报文顺序是对的,但没有任何时间信息, 无法判断某两个字节之间是否存在异常停顿。
原因:USB 转串口的驱动与上位机软件把 USB 传输的时间特性抹掉了, 而且 USB 轮询本身有毫秒级的抖动,远大于我们关心的几百微秒。
办法:在 MCU 这一侧用硬件定时器给每个字节打时间戳, 而不是依赖上位机的接收时间。这也正是这块板子存在的理由。
现象:调试阶段一切正常,但心里始终不踏实。
原因:只要方向脚由固件控制,就存在“固件异常时驱动总线”的可能。
办法:改为硬件下拉固定到接收态,从物理上取消这个可能性。 诊断工具的第一原则是不改变被测对象。
现象:按 3.5 字符时间切帧,在高波特率下没问题, 在某些低速配置下偶尔把一帧切成两帧。
原因:3.5 个字符时间是理论值,而实际发送方可能因为 中断延迟导致帧内字节间隔偶尔超过它。
办法:把切帧阈值做成可配置, 并且默认取比理论值更宽松的档位(例如按 4 个字符时间), 同时保留“帧头 + 长度 + CRC”的交叉校验—— 宁可少切一帧,也不要把一帧切错。
八、局限与还没做完的部分
- 硬件还在打样验证阶段。目前完成的是方案设计与固件中的 帧重组、统计与记录格式部分,隔离与总线负载这些硬件特性还需要实测确认。
- 只支持单路。现在的设计是一条 RS485 总线, 如果现场有多条总线同时需要记录,得插多块板子,时间基准无法统一。
- 时间戳的精度还没实测。用 MCU 定时器打时间戳, 理论分辨率够用,但中断响应抖动会引入误差,需要用示波器标定一下实际误差范围。
- 没有做总线的电气量采集。差分电平、共模电压这些只能靠示波器, 板子上没有加这方面的前端。将来如果做第二版,会考虑加一个简单的差分电压监测。
- 上位机还没有。现在只能把原始记录导出来手工分析, 没有自动生成“问题报告”的工具。这部分工作量其实不小。
- 没有校准过的波特率自适应。目前需要手动配置波特率, 如果配错了就只能看到一堆垃圾数据,不能自动识别。
最后一条其实是个挺实际的问题:现场排查时,第一件要做的事往往就是“确认波特率到底是多少”。 如果这块板子能自动测出总线的波特率,那它单独就已经很有用了。
参考资料与说明
- Modbus 应用协议规范中关于 RTU 传输模式、帧间隔(3.5 字符时间)与异常码的定义。
- TIA/EIA-485 串行通信接口标准中关于差分电平、终端匹配与总线负载的通用要求。
- 所用 MCU 参考手册中关于定时器输入捕获与时基精度的章节。
- 本文涉及的平台型号、设计参数与进度描述均为个人学习项目中的实际状态; 文中示例代码为按理解重写的片段,不代表任何产品或交付代码。
- 文中芯片与器件型号仅用于说明技术方案,与相关厂商无隶属或授权关系。