一、一个系统里为什么会有三种总线
我做过的一套多板卡控制系统,物理上大概是这样:一块人机输入面板,一块控制主控板, 一台上位机/显控机,下面挂着一串现场执行机构——几台驱动板、几路断路器、一组光源。 这套东西里同时存在三种通信方式:面板与主控之间走以太网(小包、高频、双向), 主控与上位机之间走以太网长连接(整包状态上报 + 命令下行), 主控与执行机构之间走 RS485 上的 Modbus RTU(一主多从、轮询)。 另一个采集类系统里也是类似的分层:主控向上走以太网并外接蜂窝模块上云, 向下用两路 RS485 轮询采集单元与执行单元。
三种总线不是设计出来的,是被约束逼出来的。把每一层的需求摊开看就很清楚:
| 这一层 | 数据特征 | 拓扑 | 真正的约束 |
|---|---|---|---|
| 人机输入 → 主控 | 每包几十字节,周期很短,丢一帧下一帧就补上 | 点对点 | 延迟要低,不能因为一次丢包把后面的新数据堵住 |
| 主控 → 上位机 | 整包状态,字段多,缺一个就不完整 | 点对点 | 要完整、要有顺序,还要能判断链路是不是还活着 |
| 主控 → 现场执行机构 | 每台几个寄存器,一问一答 | 一主多从,一根线挂一串 | 节点多、单主机、距离远、必须确定性;执行机构本身只支持这种接口 |
| 采集设备 → 云端 | 周期上传一组状态量,字段会随需求增加 | 点对点,无自建线路 | 现场没有有线通道;字段要能前后兼容地增删 |
这张表里最值得记住的是最后一列。每一种总线的存在都应该有它唯一要解决的那个约束; 一旦发现某种总线“只是为了统一而存在”,那它多半是多余的,而且会长期贡献维护成本。 反过来,如果有人问“为什么不全部用以太网”,正确的回答不是“RS485 更便宜”, 而是“现场那些执行机构只有 RS485 接口,而这恰好也是那一段最合适的拓扑”。 关于这两套系统的具体形态,我在 多板卡以太网控制实验记录 与 电池管理单元实验记录 里写过更多细节。
二、五维决策表:带宽 / 主机数 / 距离 / 成本 / 实时性
选总线的时候,我习惯按五个维度逐个过一遍。这张表不是“标准答案”, 而是把“什么样的需求该往哪条路走”写清楚,免得每次都凭感觉:
| 维度 | 什么需求该走以太网 | 什么需求该留 RS485 | 最容易搞错的地方 |
|---|---|---|---|
| 带宽 | 单次交互要传几十到上千字节(整包状态、日志、固件),或者要传文件 | 每次只读写十几个到几十个寄存器 | 按平均值算带宽。应该按最坏一轮算:节点数 × 单次字节数 ÷ 轮询周期 |
| 主机数 / 拓扑 | 多主机、点对点双向并发,节点之间还要互相通信 | 单主机轮询多从站,从站之间不通信 | RS485 是半双工共享介质,物理上不支持多主机;“两个主站”只能靠软件严格分时 |
| 距离 | 机柜内、机房内,几米到几十米,可以加交换设备 | 跨设备、跨桥架,几十米到上千米 | 只算“线有多长”。真正的约束是共模干扰有多强、两端能不能共地 |
| 成本 | 节点需要较高带宽时才划算 | 每加一个节点只要一个收发器,一根双绞线挂一串 | 只算物料成本,不算配置与维护成本(地址规划、交换端口、网管、IP 台账) |
| 实时性 | 平均延迟低,适合“快但可以偶尔慢一点”的交互 | 单次慢,但一问一答、时序可算,确定性好 | 把“低延迟”和“确定性”当成一回事。控制类系统要的往往是后者 |
这五维里,“实时性”是最容易被说错的一维。以太网的平均延迟通常远低于 RS485 (两者的速率差着数量级),但它的最坏延迟受交换、协议栈、上层流量共同影响, 很难给出一个可证明的上界;RS485 单次交互慢,可只要轮询表定下来, 最坏周期是可以手算出来的。所以“哪个更快”这个问题本身没有意义, 真正要问的是“最坏情况下晚多久,你能不能接受”。 这也是我在 《Modbus 主机的轮询调度与离线降级》 里专门用符号把最坏周期算一遍的原因。
还有一条经验:这五个维度不是平权的。实际项目里通常有一到两个维度是硬约束—— 比如“现场设备只支持 RS485”或者“这段距离必须走差分”,剩下的维度只是在硬约束划定的范围里做微调。 先找到硬约束,再谈优化,比一开始就把五个维度摆在一起权衡要快得多。
三、板间用 UDP 还是 TCP
这一节我想说一件看起来自相矛盾的事:我在两个项目里做出了相反的选择。
一个项目里,板间通信用的是 UDP 承载的小包(把 Modbus 的帧结构套在 UDP 上, 两端都是我自己写的),同时“整机状态上报给上位机”用的是 TCP 长连接。 另一个项目里,我把 UDP 整个关掉了——在协议栈配置里直接不编译 UDP,只保留 TCP, 上位机通过 TCP 读写变量表。
这不是前后矛盾,而是在衡量不同的东西:可靠优先,还是延迟优先。
UDP 那一侧的理由:板间那一层是“高频小包 + 状态量”,丢一帧的后果只是这一轮的值没更新, 很短的时间之后下一轮就带着新值来了。UDP 没有连接、没有重传, 也就不会出现“一次丢包导致后面的数据排队等重传”的队头阻塞。 反过来,如果这里用 TCP,一旦网络抖动触发重传,所有新数据都要排在旧数据后面, 而这一层要的语义恰恰是“我只要最新值,旧值没到也无所谓”。
TCP 那一侧的理由:那个系统里这一层的交互是“上位机读写变量表”, 每一次读写的语义都是“我要一个确定的答案”,不能丢、不能乱序; 而且网络环境不完全可控(要过网关、可能跨网段), UDP 在这种环境下的可达性问题会变成一个很难复现的现场问题。 TCP 的连接状态还有一个额外作用:它本身就是一个“对端还在不在”的信号。
| 问自己 | 倾向 UDP | 倾向 TCP |
|---|---|---|
| 丢一帧能不能接受? | 能,下一帧就带新值来了 | 不能,缺一个就错 |
| 能不能接受队头阻塞? | 不能,我要的永远是最新值 | 能,顺序与完整性更重要 |
| 需要“连接”本身当在线信号吗? | 不需要,我自己有心跳机制 | 需要,连接断了就是离线 |
| 网络环境可控吗? | 可控:直连或同一交换网,两端都是自己写的 | 不完全可控:过网关、跨网段、现场接线情况未知 |
| 节点数量与拓扑? | 节点较多,甚至需要一对多发送 | 少数点对点连接 |
/* 两种传输的调用形状对比:地址与端口一律来自配置参数,不写死在代码里 */
/* --- 无连接:绑定本地地址,每次收发都要带上对端 --- */
int ufd = socket(AF_INET, SOCK_DGRAM, 0);
bind(ufd, 本地地址);
recvfrom(ufd, buf, sizeof buf, 0, (struct sockaddr *)&peer, &peerlen);
sendto(ufd, buf, len, 0, (struct sockaddr *)&peer, peerlen);
/* --- 有连接:建立之后就像读写文件一样收发 --- */
int tfd = socket(AF_INET, SOCK_STREAM, 0);
setsockopt(tfd, SOL_SOCKET, SO_REUSEADDR, ...); /* 便于异常退出后立刻重绑 */
bind(tfd, 本地地址);
connect(tfd, 对端地址);
n = recv(tfd, buf, sizeof buf, 0); /* 返回 0 表示对端已正常关闭 */顺带说一句“把 UDP 整个关掉”这件事有多彻底:它不是“不发 UDP 报文”, 而是在协议栈的配置里把这个传输层整个排除掉。
/* 只保留 TCP:在协议栈的配置头文件里关掉不需要的传输层(LwIP 公开配置项名) */
#define LWIP_UDP 0
#define LWIP_TCP 1LwIP 是第三方开源协议栈(BSD 许可),上面两行只是它公开的配置项名称, 用来说明“连传输层都可以按需裁掉”这件事。这么做的直接好处是省下了 UDP 那部分代码空间与内存, 间接好处是把“这一层只用 TCP”变成了一个编译期事实—— 不会有人后来顺手加一个 UDP 接口上去,让系统悄悄多出一种需要维护的通信路径。
TCP 那一侧也不是没有代价,有两个坑我踩过:
- 对端异常断开时连接可能不会立刻被察觉。拔网线、对端掉电, 都可能留下一个“本地以为还在连、其实对端已经没了”的半开连接,连接资源不释放, 现象是“重新接入连不上”。两种处理方式:给接收设一个超时,超时就主动把连接踢掉重新等待接入; 或者在主动关闭时用强制复位的方式关闭,避免连接长时间停在半关闭状态。
- 重连必须有间隔。一断开就立刻重连,会在对端还没恢复的时候 把连接请求打成一片,反而更难恢复。我在几个项目里用的都是秒级的固定间隔—— 工业设备里“宁可慢一点,也别把链路打满”是个常见的取舍。
这两个坑和“在线 / 离线”状态管理是同一件事的两面。我在 《LwIP 上的 UDP 通信笔记》 里写过板间小包交互的实现要点,在那篇里也提过:无连接不等于不用管状态, 只是状态由应用层自己维护而已。
四、现场总线为什么还是 RS485
经常有人问:既然以太网更快更通用,为什么下面的执行机构还在用 RS485? 我的理由按重要性排是这五条:
- 一根线挂一串。现场每台设备一个地址,主机按地址轮询。 增加一台设备就是“多接一段线、多分配一个地址”,不需要交换端口、不需要网线、不需要地址规划台账。 这在现场施工上的差别非常大——尤其是设备分散安装、线槽已经封好的时候。
- 距离与抗干扰。差分传输对共模干扰的抑制能力,配合屏蔽双绞线, 可以覆盖跨设备、跨桥架的距离;用同样的成本让以太网覆盖这段距离,施工量和成本都要上去。
- 成本。每个节点只需要一个收发器加一对双绞线,从站侧通常连协议栈都不需要 (很多执行机构就是“寄存器 + 串口”的极简实现)。
- 生态。Modbus RTU 几乎是所有执行机构的“最小公约数”。 我这几个项目里的驱动板、断路器、光源、采集单元,来自不同厂家,但接口是同一种。 这一点决定了“能不能买到”和“坏了能不能换”,是选型里最实际的一条。
- 确定性。一主多从、一问一答,时序可以算。 对控制类系统来说,这一点比“平均更快”重要得多。
但代价也必须写清楚。这些代价恰好解释了为什么“以太网做上行、RS485 做下行” 会成为最常见的那种分层:
| RS485 的代价 | 表现 | 缓解办法 |
|---|---|---|
| 半双工,需要方向控制 | 收发切换的时序不对,会削掉第一个字节的起始位,表现为“偶发乱码、CRC 错很多” | 方向切换加必要的延时,并把这段时序当成硬件时序来验收,而不是当成“保险起见” |
| 节点越多,轮询周期越长 | 周期大致等于节点数乘以单次耗时;设备增加后整机刷新变慢 | 算清最坏周期再决定节点数;必要时拆第二条总线或把非实时设备挪走 |
| 地址与波特率必须全总线一致 | 新设备接上去忘了改地址,会撞地址或不应答,而且看起来像“设备坏了” | 把地址与波特率做成可读写的参数,上电时校验并上报 |
| 没有即插即用与拓扑发现 | 线接错了只能一段一段断开排除,费时且依赖现场经验 | 做诊断模式或诊断板,能读到每台的在线状态与超时计数 |
| 一段总线上的单点故障会影响整段 | 某一段短路或某个节点故障,可能把整条链路拖垮 | 分支尽量短、加隔离、终端电阻按规范接,避免长分支 |
关于“一帧到哪里算结束”这类现场总线上必须处理的问题(空闲判帧还是超时判帧、 半双工方向切换的时序),我在 《RS485 帧同步与超时判帧》 里单独写过;主站侧怎么把这些设备排进一个轮询状态机,则在 《Modbus 主机的轮询调度与离线降级》 里。
五、上云为什么用无线 + 文本协议
现场到云端没有自己铺设的线路,用蜂窝模块是最省事的一条路。这类模块对外通常只提供一个串口 加一套 AT 指令,所以主控侧的形态基本就被定死了:一个 AT 状态机 + 一个文本协议。
文本协议(JSON 这类)在嵌入式里的好处很实在:
- 可读。用串口助手就能看懂上下行内容,不需要先写一个解析工具才能调试。
- 字段可增删。云端要加一个字段,旧固件忽略它就行;要下线一个字段, 把它保留一段时间也不影响。前后兼容比二进制协议容易得多。
- 云端解析成本低。几乎每种云平台都直接支持 JSON,不需要在云侧再写一套二进制解析。
代价同样实在:
- 体积大。同样一组数据,文本形式的字节数要比紧凑二进制大好几倍, 在按流量计费的链路上是要算钱的。
- 吃内存。解析要在小容量 MCU 上跑,必须先把堆的峰值算清楚。 我见过在只有几 KB RAM 的芯片上用 JSON 库,光解析缓冲就占掉了相当比例, 再叠加通信缓冲就很紧张。
- 类型与精度要两端约定。浮点、整数、字符串之间的转换容易出现“看起来对、 对账时不对”的问题,尤其是小数位与时间戳。
还有一件事几乎一定会跟着上云一起来:对时。上云之后通常要把云端或模块 提供的时间同步到设备上,于是要在“文本时间字符串”和“设备内部的二进制时间”之间来回转换—— 时区、BCD 与 BIN 两种表示、闰年、以及“这个时间到底是哪一层给的”。 我在这上面犯过的错是把模块返回的时间当成 UTC 直接用了,结果是所有历史数据的时刻整体偏移, 而设备本地完全看不出来,只有在云端对账时才暴露。
所以上云和板间控制通常用完全不同的协议形态:控制用紧凑的二进制帧(要快、要可算), 上云用文本(要好读、要好加字段)。这不是技术栈不统一, 是两层的优化目标本来就不同。顺便说一句,如果采集设备还要靠低功耗休眠来省电, 那么上云这条链路的周期与唤醒策略会直接决定休眠能不能成立——这部分我在 《低功耗休眠、唤醒与喂狗的三件套》 里写过。
六、协议选型的代价:三种协议意味着三套调试手段
选型的时候很容易只看“能不能跑通”,不看“以后谁来维护”。我这套系统有三种协议, 意味着三套排查方法、三份文档,以及三种“看起来都像网络问题”的故障:
| 层次 | 协议形态 | 需要的调试手段 | 现场至少要有的工具 |
|---|---|---|---|
| 板间 / 上位机 | 以太网 + TCP / UDP | 抓包、连接状态、socket 收发与错误计数 | 一台能接进同一交换网络的笔记本 |
| 现场总线 | RS485 + Modbus RTU | 串口监听、报文解析、超时与错误计数、方向控制时序 | USB 转 485 转换器、终端电阻、一段可断开的测试线 |
| 上云 | 蜂窝模块 + AT 指令 + 文本协议 | 模块日志、AT 交互回显、文本字段校验 | 一个独立的调试串口,能看到模块的原始回显 |
这三套里最麻烦的是故障现象会互相伪装。一个“数据不更新了”的现象,可能是:
- 485 侧某台从站掉线,主机一直在它身上白等超时;
- 板间 UDP 丢包,某一轮的值没到;
- TCP 连接半开,对端已经没了但本地还以为在连;
- 蜂窝模块掉网,上云那条路断了,而本地一切正常。
如果不做可观测性,这四种情况在现象上几乎一样。所以我的做法是: 每一层都维护自己的收发计数、错误计数、超时计数和最后错误码, 并把这些统计汇总到同一个能被远程读到的位置——对 Modbus 从站来说, 就是变量表里的几个统计寄存器。这样“数据不更新”的第一个排查动作就变成了 “读一次统计,看是哪一层的计数不对”,而不是从头开始猜。
所以我建议在做选型时把两笔成本一起算:实现成本 + 以后每一次故障排查的成本。 如果维护现场只有一块万用表和一台笔记本,“多一种协议”省下来的那点物料钱, 很快就会被多出来的出差与排查时间吃掉。反过来说,如果确实需要三种协议, 那么“每层都有统计、都能远程读”就不是可选项,而是这套架构能不能维护下去的前提。
七、几条我反复用到的经验
- 先问“最坏情况要多久”,再问“用哪个协议”。平均最快不等于最坏可接受, 控制类系统尤其如此。
- 能一根线解决的,不要引入第二套协议栈。每多一套协议栈, 就多一份内存占用、多一套初始化顺序、多一类现场故障。
- 分层的原则是“每层只解决一个约束”。板间要低延迟、上行要可靠、 现场要多从站与长距离、上云要好读好扩展——四种约束,四套形态。
- UDP 与 TCP 不是先进与落后,是延迟优先与可靠优先。 选之前先把那张判据表填一遍,比凭印象决定靠谱。
- 私有用法必须写明前提。在 UDP 上跑 Modbus 这件事我之所以敢用, 是因为两端都是我自己写的、网络完全可控;这个前提要写进文档,而不是留在脑子里。
- 参数一律外提。地址、速率、超时、间隔都要能远程读写, 并且要有默认值与合法性校验——否则每换一个现场就要重出一版固件。
- 可观测性和功能一起做。每层都有收发、错误、超时计数, 故障定位就变成“看哪个计数不对”。
- 选型时把“以后谁来维护”算进成本。三种协议意味着三套调试手段, 这个代价是长期的,而它往往在选型会上被完全忽略。
这三篇笔记其实是同一条链路上的三个视角:这一篇决定“用哪种链路”, 主机轮询调度那一篇决定“链路怎么用”, 低功耗那一篇决定“空闲时怎么安静下来”。 三者凑在一起,才是一套能长期在现场活下去的通信方案。 更多同类笔记可以回到 学习笔记列表 查看。
参考资料与说明
- Modbus 应用协议规范(Modbus Application Protocol Specification)与 Modbus over Serial Line Specification and Implementation Guide 中关于 功能码、异常码、RTU 帧间隔与主从时序的部分,属于公开标准。
- Modbus TCP 相关公开规范中关于“Modbus TCP 基于 TCP 承载”的说明, 用于解释本文提到的“在 UDP 上承载 Modbus 属于私有用法”。
- LwIP 官方文档与其公开配置项说明(LwIP 为第三方开源协议栈,BSD 许可; 本文只引用公开配置项名称,不包含其源码)。
- FreeRTOS 官方文档(第三方开源项目,本文只引用其公开文档中的概念)。
- CAN 与 RS485 相关公开标准(ISO 11898 系列与 TIA/EIA-485 系列)中关于 差分传输、总线拓扑与终端匹配的部分,属于公开标准。
- 公开的串口 / 以太网 / 蜂窝模块 AT 指令文档中关于物理层参数与模块交互方式的说明。
- 文中涉及的芯片与模块型号仅用于说明技术方案,与相关厂商无隶属或授权关系; 文中的地址、速率、端口与节点数量一律不给出具体值,只讲选取方法。
- 示例代码为按个人理解重写的最小片段,不含任何产品的实际参数, 不代表任何产品或交付代码;第三方组件的名称与许可归其各自所有者。
- 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。
相关阅读:LwIP 上的 UDP 通信 · RS485 帧同步与超时判帧 · Modbus 主机的轮询调度与离线降级 · 低功耗休眠、唤醒与喂狗的三件套 · 多板卡以太网控制实验 · 电池管理单元实验记录 · 返回学习笔记列表