一、主机比从站难在哪
从站是被动的:上位机来问,我照着寄存器表答。整个从站的复杂度集中在两件事上—— 怎么把一帧解析对、怎么把寄存器表排好,而时间是上位机给的,我只要别答得太慢。 主机完全是另一回事:什么时候发、发给谁、发完等多久、等不到怎么办、下一轮什么时候开始, 这些全得自己定。从站的一次错误只影响自己那一条应答,主机的一次错误会外溢到整条总线上的所有设备。
我做从站的时候,最坏的情况是“这一帧没答上来,上位机重试”。做了主机之后才发现, 真正的麻烦是时间:一条 RS485 上挂着多台从站,主机一轮只能问一台, 任何一台的延迟都要算进整个轮询周期里。更麻烦的是主机通常要同时处理几类不同的动作—— 常规采集要周期做,参数下发要按需做,还有一些独占性的动作(一次测试、一次分组广播) 一旦开始就不能被打断。这些动作全都抢同一条总线。
所以主机代码里最值钱的部分不是“怎么组一帧”,而是“怎么安排这一帧什么时候发”。 这也是我后来把主机的发送逻辑整个改成状态机的原因。关于从站侧的实现(寄存器表怎么划、 功能码与异常码怎么定),我在 《Modbus RTU 从站实现笔记》 里写过,这里不再重复。
二、多类动作要串行化:一个调度状态机
我最初的写法是“哪个任务需要发就调发送函数”。这种写法很快就出了问题: 两条链路几乎同时调用发送时,总线上会出现两帧叠在一起,从站收到的是垃圾; 更隐蔽的一种是两个请求交替发出,A 的应答被 B 当成自己的应答收走了。 RS485 是半双工共享介质,同一时刻只能有一个说话的人, 这一点必须在软件结构上保证,而不是靠“应该不会同时发生”这种约定。
串行化的做法是:所有要占用总线的动作都交给一个调度状态机,其他模块只能“提请求”, 不能自己发帧。状态机每个周期只做一件事,做完回到决策状态重新排队。下面是骨架, 只保留控制流形状,去掉了全部业务常量、地址与超时值:
/* 轮询调度骨架:只保留控制流形状,不含业务常量、地址与超时值 */
for (;;) {
switch (sched_state) {
case ST_DECIDE: /* 全机唯一的决策点 */
if (有独占动作待办) sched_state = ST_EXCLUSIVE;
else if (周期动作到点) sched_state = ST_PERIODIC;
else sched_state = ST_ROUTINE;
break;
case ST_EXCLUSIVE: /* 整段做完才回决策点 */
step_exclusive();
break;
case ST_PERIODIC: /* 没到点就直接跳过这一轮 */
step_periodic();
break;
case ST_ROUTINE: /* 逐台推进,每台一个超时窗口 */
step_routine();
break;
}
/* 任何一步失败都回决策点重新排队,不在分支内部循环重试 */
}这段形状里有三个刻意的选择,都是踩过之后才定下来的:
- 只有一个决策点。所有优先级判断都集中在
ST_DECIDE一处, 不散落在各个分支里。后来现场联调要调优先级顺序,我只改了这一个switch, 其余代码一行没动。 - 失败不在分支里重试。重试是“下一轮的事”,本轮失败只更新设备状态与计数器。 否则一个分支内部的循环重试会把整个状态机的响应性吃掉——这是我最开始写的版本最大的问题。
- 状态本身就是进度。
sched_state加上“当前轮到第几台”的下标, 就足以表达“这一轮走到哪了”。不需要额外的同步变量,掉线重连、参数变更也不会把进度弄乱。
三、优先级怎么定
我用的判据不是“哪个更重要”,而是“错过它会发生什么”。按这个判据, 主机上的动作大致能分成三类:
| 动作类别 | 能不能被打断 | 错过的代价 | 给的优先级 |
|---|---|---|---|
| 独占型(一次测试、一次分组广播) | 不能,开始就要整段完成 | 半途被打断会留下不一致的中间状态,往往需要重来一次 | 最高 |
| 截止型(按固定间隔必须发一次,例如均衡指令) | 可以,但不能晚太多 | 错过这一轮要等下一个间隔,而间隔是分钟量级的 | 中 |
| 常规型(周期采集) | 可以,随时让路 | 只是数据旧一轮,下一轮就补上 | 最低 |
第二列和第三列是这个表的重点。常规采集听起来最重要——整机的保护判断、画面显示、 数据上云都靠它,但它的错过代价是最低的,因为下一轮马上还会再来一次。 而一个独占型动作被中途打断,可能要整段重来,代价高得多。所以优先级应该跟着“代价”走, 而不是跟着“感觉上的重要性”走。
反过来还有一条:如果一个动作既不能被打断、错过的代价又高, 那它不该靠“提高优先级”来解决,而应该先问它为什么不能被打断、能不能拆成可中断的小步。 我遇到过最长的一次独占动作是“一次分组广播 + 全组从站的处理窗口”。它之所以不能被打断, 是因为广播发出去之后,总线上会出现多台从站的反应,此时插入任何单播都会撞车。 这种情况只能给它最高优先级,并且把它的时长计入最坏刷新间隔(见第六节)。 换句话说:不可打断是设计的结果,不是借口。能拆的应该拆掉, 剩下的再用优先级保护。
四、离线降级:为什么“离线后还重试三次”是错的
这一条是我在这个项目里改得最值的一处,也是我认为主机代码里最容易被忽略的一处。
我一开始的重试逻辑是统一的:无论设备在线还是离线,一次请求超时就重试, 连续失败到上限就判定本次操作失败。看起来没什么问题,但实际上有一个很坏的后果: 一台已经掉线的设备,每一轮仍然要消耗掉“重试次数 × 超时窗口”的时间。 而这段时间里总线是空的,其他在线设备正在排队等着被问。
算一笔账就清楚了。设一条总线上有 N 台从站,其中 Noff 台离线, 单次超时窗口是 Tto,在线设备的重试上限是 R。统一重试的做法下, 一轮里光是白等的时间就是 Noff × R × Tto。 更要紧的是它的连锁反应:轮询周期被拉长之后, 所有在线设备的数据更新率一起下降。现场看到的现象不是“某台设备掉线了”, 而是“所有数据都变慢了、画面卡”。这个现象很容易被误判成总线干扰或者主机跑飞, 排查方向一开始就是错的——我就在这上面浪费过两天。
正确做法是把“在线 / 离线”做成设备的一个显式状态,并且让两条路径的代价完全不一样:
- 设备在线:超时后重试,连续失败到上限就转离线, 并立即结束这台设备的本轮处理,进入下一台。
- 设备离线:只发一次、只等一个超时窗口。收到合法应答就立刻转回在线并清零失败计数; 收不到就直接跳过,不重试、不多占一个超时。
/* 在线重试有限次、离线只试一次:骨架,次数与超时值均以符号表示 */
if (!dev->online) {
发一次请求();
if (收到合法应答()) { dev->online = 1; dev->fail = 0; }
/* 失败就直接返回:不重试,不额外占用超时窗口 */
return;
}
while (dev->fail < RETRY_MAX) {
if (发一次请求() && 收到合法应答()) { dev->fail = 0; return; }
dev->fail++; /* 失败只计数,不在这里空等 */
}
dev->online = 0; /* 连续失败到上限:转离线 */
/* 本轮到此结束,不再等下一个超时 */这段形状里有一个非常容易写错的地方:“转离线”必须同时结束本轮。 如果只是把在线标志置 0 然后继续循环,这一轮还是要等满 R 次超时,降级等于没做。 我第一次改的时候就是漏了这一条,测出来的周期几乎没有变化,还以为是改错了地方。
还有一个配套的细节:离线设备也要按轮询周期被“探一下”,不能完全不发。 因为现场的设备可能是断电重启,也可能只是接线端子松了一下, 它自己不会告诉你它回来了。所以离线不是“不问”,而是“少花时间地问”。
“三次”这个经验常数是怎么来的
我用的一直是三次,理由很朴素:一次超时说明“这一次没收到”,可能是干扰、可能是刚好撞上从站在处理别的事; 两次还失败,概率已经小很多但还不敢下结论;连续三次失败,基本可以认为这台设备真的不在了。 代价也很明确:每多一次重试,最坏情况就多一个完整的超时窗口, 所以在节点多、超时窗口长的链路上,重试次数是拿周期换来的。
什么时候该改这个常数?我的判断是看失败的原因分布:
- 如果单次超时窗口本来就短、总线负载很轻,可以多给一次重试,用很小的周期代价换更少的误判离线。
- 如果超时窗口本身很长(例如对端要执行一个物理动作才回话),三次重试会让最坏周期难以接受, 这时应该减少重试次数,把“是不是真的离线”更多地交给下一轮去确认。
- 如果现场干扰很重,把重试次数堆高并不能解决问题——应该先处理屏蔽、接地、终端电阻与走线, 重试只是把问题从“偶尔错”变成“偶尔慢”。
我后来把重试上限做成一个符号常量并写在注释里说明它的单位与代价, 这样下次有人(包括几个月后的我自己)想改它的时候,能先看到“改这个值会直接影响最坏轮询周期”。
五、时序参数外提:从写死到可配置
主机侧有一批参数天生就是“现场相关”的:对端从站的响应速度不同、线缆长度与波特率不同、 挂的设备数量不同、干扰水平不同。这些差异最后都落在同一批时间参数上。 我最早的版本把它们写死在头文件里,结果是每换一个现场就要重出一版固件—— 出一版固件意味着重新编译、重新走一遍烧写流程、重新确认版本号, 在现场那种“设备已经装好、线已经接完”的条件下非常不划算。
后来我把这些参数全部外提成可读写的配置寄存器,落到参数区里持久化。大致是这几类:
| 参数类别 | 作用 | 写死会怎样 |
|---|---|---|
| 单播帧间隔 | 两帧单播之间必须留出的最小静默时间,含方向切换与从站准备时间 | 不同收发器与不同从站的准备时间不同,写死了要么不够(偶发丢帧)要么浪费(周期变长) |
| 单播接收超时 | 从“发送完成”到“判定这次没有应答”的窗口 | 对端换了型号、换了固件版本就可能变慢,写死了会把正常设备判成离线 |
| 发送超时 | 一帧数据在规定时间内没有全部移出,就要放弃并复位发送通道 | 写死了遇到异常收发器会一直卡在发送等待里,整条轮询停住 |
| 广播轮次间隔 | 广播只发不收,但要给所有从站留出完整的处理窗口,之后才能开始单播 | 窗口不够会让广播后的第一帧单播撞上从站的反应,表现为“广播之后必错一帧” |
| 整轮节拍 | 常规采集走完一轮之后到下一轮开始之间的间隔 | 写死了就没法在“数据要新”和“总线别太忙”之间按现场调整 |
| 独占动作的允许间隔 | 多久允许做一次耗时的独占动作 | 写死了要么做得太频繁影响采集,要么太稀疏错过测试窗口 |
顺便说一下超时窗口不能凭感觉给。它至少要覆盖“从站的处理时间 + 一帧应答的回传时间”。 一帧的传输时间是可以算出来的:帧字节数乘以每字节位数再除以波特率; 在常见的串口帧格式下,一个字节按十个位算(起始位、八位数据、停止位,无校验)。波特率越低、 帧越长,窗口就要越大。把这一步先算出来,再往上加从站的处理余量,比“先给个大数,出问题再调”靠谱得多。
外提当然也有代价,我遇到过两个:
- 参数错了会伪装成通信故障。现场一个错误的间隔值,现象是“偶发丢帧”, 排查时很难第一时间想到是参数问题。所以每一个时间参数都要有合法范围检查与默认值兜底, 并且上电时能从寄存器读回来核对。
- 参数本身要持久化,还要能被恢复。参数区要带校验,校验不过就回到默认值, 同时要有一个恢复默认值的命令;否则一旦写入一个越界值,设备可能连通信都进不去, 只能拆机重新烧写。
关于参数区的通用做法(双备份、校验、恢复默认),我在 《Flash 参数区的双备份与校验》 与 《参数表设计:把现场差异从代码里赶出去》 两篇笔记里写过更多细节。
六、轮询周期的账:最坏情况要算出来
主机侧的周期不能靠“跑起来看看”。跑起来看到的是平均值,而工程的验收标准通常是 “最坏情况下也不能超过某个刷新时间”。所以这笔账要在写代码之前先算,算完之后再回头决定节点数、 超时窗口和重试次数怎么定。用符号写出来是这样的:
/* 最坏轮询周期:全部以符号表示,不代入任何具体数值 */
T_frame = n_bytes * bits_per_byte / baud; /* 一帧的传输耗时 */
T_cycle = N * (T_req + T_resp + T_gap); /* 正常一轮 */
T_bad = N * (T_req + T_gap + R * T_to); /* 全部在线,但每台都要重试到底 */
T_worst = N_on * (T_req + T_gap + R * T_to) /* 在线设备:可能重试 */
+ N_off * (T_req + T_gap + T_to); /* 离线设备:只等一个超时 */
/* 对账:最坏周期加上独占动作的占用,必须小于上层要求的刷新间隔 */
要求:T_worst + T_excl < 1 / f_rate;符号的含义:N 是节点数,Non / Noff 是在线与离线节点数, Treq 是一帧请求的发送耗时,Tresp 是一帧应答的接收耗时, Tgap 是两帧之间的必要间隔,Tto 是单次超时窗口,R 是在线设备的重试上限, Texcl 是独占动作的最长占用时间,frate 是上层要求的最低刷新率。 把这三个式子放在一起,可以得到三条我做主机之后一直在用的结论:
- 节点数是乘法项。节点数翻倍,周期大致翻倍。这是 RS485 单主机轮询的固有代价, 不是代码写得不好。所以“节点数 × 单次耗时”这个乘积必须先算出来, 再决定要不要拆第二条总线——而不是等到现场发现画面卡了才回头拆。
- 超时窗口是唯一一个“给错了也不会立刻报错”的参数。给短了会把正常设备判成离线, 给长了最坏周期成倍增长。它的下限由“从站处理时间 + 一帧回传时间”决定, 上限由“最坏周期与刷新率要求”反推。上下限一夹,可选的区间往往比想象中窄。
- 重试次数与超时窗口是可以互换的。总等待时间大约是 R × Tto, 在总预算不变的前提下,是“少重试 + 长超时”还是“多重试 + 短超时”,取决于失败的原因: 如果失败主要来自“设备慢”,就该长超时、少重试;如果主要来自偶发干扰,就该短超时、多重试, 让偶发错误尽快被一次重试覆盖掉。
这笔账还有一个经常被漏掉的加项:独占动作的占用时间要单独加,不能平摊进轮询周期里。 因为它不来则已,一来就会把那一轮的刷新间隔整个推后。我最初算周期时只算了轮询, 结果在最坏情况下(独占动作正好插在要读关键数据之前)刷新间隔超出了要求, 这是后来加 Texcl 这一项的原因。
最后,把统计做出来。我在这条链路上留了轮询耗时、最小耗时、最大耗时、 超时次数、离线次数这几个量,全部放进可以远程读的寄存器。它们的作用不是“看着好看”, 而是让上面这三条结论能在现场被验证:算出来的最坏周期和实际测到的最大耗时对不对得上, 哪台设备贡献了最多的超时——这些都能直接读出来,比猜快得多。
七、几条我反复用到的经验
- 主机代码的核心是调度,不是组帧。组帧错了只错一帧,调度错了整条总线都受影响。
- 一条共享总线上只允许一个发送者。串行化要在软件结构上保证,不能靠约定和“应该不会同时”。
- 优先级按“错过的代价”排,不按“谁重要”排。常规采集通常最重要,但它的错过代价最低。
- “离线只试一次”必须显式写出来,并且要在同一处结束本轮。 统一重试会让一台掉线设备拖慢所有在线设备的数据更新率。
- 经验常数要写成符号并注明代价。“三次”不是定律,它是“三次误判概率可以接受、 周期还能接受”的一个折中。
- 所有时间参数外提成可配置寄存器,带合法范围检查、默认值兜底和恢复默认的命令。 现场差异太大,每换一个现场重出固件不现实。
- 周期要算,不要估。把节点数、单次耗时、超时、重试次数代进最坏情况, 再和刷新率要求对账;算不出来的周期,就是还没设计完的周期。
- 给统计留位置。轮询耗时、最大耗时、超时与离线计数放进可远程读的寄存器, 让“算出来的最坏值”在现场能被验证。
这些经验放在别的链路上也一样成立。如果你在做的是“请求少但要求极低功耗”的采集设备, 那主机侧要考虑的东西会反过来——我在 《低功耗休眠、唤醒与喂狗的三件套》 里写了那种形态下变成主要矛盾的几件事。
参考资料与说明
- Modbus Application Protocol Specification(Modbus 应用协议规范)中关于功能码、 异常码与广播行为的部分,属于公开标准。
- Modbus over Serial Line Specification and Implementation Guide 中关于 RTU 帧间隔、 静默时间与主从时序的部分,属于公开标准。
- FreeRTOS 官方文档中关于任务优先级与中断优先级管理的说明(FreeRTOS 为第三方开源项目, 本文只引用其公开文档中的概念,不包含其源码)。
- LwIP 官方文档中关于协议栈配置项的说明(LwIP 为第三方开源协议栈,本文只引用公开配置项名称)。
- 文中涉及的芯片型号与总线形式仅用于说明技术方案,与相关厂商无隶属或授权关系; 文中的参数类别与状态机形状均为按个人理解重写的通用形式,不含任何产品的实际数值。
- 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码; 第三方组件的名称与许可归其各自所有者。
- 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。
相关阅读:Modbus RTU 从站实现笔记 · RS485 帧同步与超时判帧 · LwIP 上的 UDP 通信 · 低功耗休眠、唤醒与喂狗 · 多板卡系统的通信分层 · 多板卡以太网控制实验 · 电池管理单元实验记录 · 返回学习笔记列表