从零写一个工控多协议通讯库(二):Modbus TCP,与一次“把协议赶出框架”的架构微调
上一篇把 talkplc 的分层讲清楚了,结尾立了个 flag:“加 Modbus TCP,协议帧逻辑一行不改,只写个新传输后端”。这一篇来兑现它。
但真动手时,这句话只说对了一半——传输确实一行没改,加 TCP 的过程却反过来把框架里藏着的“Modbus 味”照了出来,逼我做了一次不小的架构微调。正好应上一篇那句:架构是会被新需求反复拷问的。
项目代号 talkplc,纯净室开发,只依据公开协议规范,个人时间与设备。
先兑现承诺:Modbus TCP 到底改了什么
Modbus RTU 和 TCP,应用层的读写请求(PDU)是一模一样的——都是“功能码 + 起始地址 + 数量”。区别只在外面那层壳:
1 | RTU(串口): ┌──────┬───────────┬─────────┐ |
RTU 靠 CRC 校验、靠帧间静默定界;TCP 换成一个 7 字节的 MBAP 头,用长度字段定界、用事务号把请求和响应对上。
因为上一篇把传输抽象成了一个“字节管道”,协议层根本不知道自己底下是串口还是 socket,所以这次加 TCP:
- 读写那套 PDU 逻辑:一行没改,RTU / TCP 共用;
- 新增的只有两样:一个 TCP 传输后端(连 socket、收发字节),和一层 MBAP 封帧 / 拆帧;
- 用哪种,就在建主站时选一个封帧函数——
[从站][PDU][CRC]还是[MBAP][单元][PDU]。
这部分完全在预期内。真正有意思的是下面。
但架构被自己“打脸”了
上一篇的框架里,界面和框架之间传的是“点位”,点位长这样:{ 区域, 地址, 数据类型, 值 }。
加 TCP 时倒没事,可我一往下想 S7、三菱 MC 就卡壳了:“区域”“寄存器地址”这些字段,是 Modbus 专属的。西门子是 DB1.DBD0,三菱是 D100,欧姆龙又是另一套——它们根本套不进“区域 + 寄存器号”这个模子。
也就是说:框架接口里还残留着 Modbus 的影子。 它表面上分了层,其实偷偷假设了“底下是 Modbus”。这种抽象,加第二个协议还能忍,加到第三个必然崩。
于是趁只有 Modbus 一家、包袱最小的时候,做了四处微调,目标只有一个:把所有“协议味”从框架里彻底赶出去。
微调一:框架和驱动之间,只认“索引”
新的边界接口叫 tp_pdriver,它不带任何协议概念——没有区域、没有寄存器、没有地址,只有下标:
1 | /* 框架 ↔ 协议驱动之间,唯一的边界。全程只有索引,零协议概念。 */ |
分工一下子就干净了:
1 | 界面 ──配点位──▶ 框架 ──"采第 0、2、5 个点"──▶ 协议驱动 |
- 框架:只管“把点位表交下去、按索引调度采集、把值收上来”,它永远不知道
40001是什么意思; - 协议驱动:藏在这个接口后面,自己解释地址、自己把连续地址合并成批量请求、自己组帧解帧收发。
加一个新协议 = 写一个实现这套接口的模块。框架和界面,一行都不用动。
微调二:调度可插拔,点位能分优先级
既然采集由框架调度,那“这一拍采哪些点”就该是一个可换的策略,而不是写死的循环。于是抽了个 tp_sched 接口:
- 默认策略:每一拍,全采;
- 优先级策略:给点位分高 / 中 / 低,高频点每拍都采,中频每 4 拍、低频每 16 拍——慢变量(温度、累计量)不再拖累快变量(报警、状态),也少占总线。
界面上就多了一列“优先级”,实测里高频点每拍在动、低频点隔很久才刷新一次,一眼能看出错峰效果。
微调三:站号这类“驱动私事”,框架也别管
站号(unit id)、协议的一些小脾气……这些只有驱动关心的属性,以前塞在框架的配置结构里。现在把它们打包成一段不透明的字节(drv_cfg):框架只负责原样透传给驱动,自己从不去解读。对框架来说,站号和“协议是什么”一样——无意义。
微调四:注册表 + 配置文件,界面连协议都不认识
前三步之后,框架已经不懂协议了。那界面呢?上一篇里界面还在 if (是TCP) 用TCP驱动 else 用RTU驱动 地写死。再往前推一步:
加一层注册表——把“协议名字”翻译成“驱动工厂”("modbus_tcp" → 对应的构造函数)。于是界面只要报一个字符串名字,剩下的交给注册表。这样一来:
- 界面代码里,从此不出现任何协议名;
- 一份 JSON 配置就能描述一整条连接:协议、链路、点位表,全在里面;
- 连“左边能选哪些协议”这个列表,都从一份 manifest 读出来,而不是写死在界面里——注册表里加一个协议,界面自动多一项。
界面于是退化成两件事:加载配置、按索引显示值。某个值是哪个点?点位表里按同一个下标一查便知。
1 | 一份 JSON ──▶ 注册表: "modbus_tcp" → 驱动工厂 |
这一步的直接效果:做一个“通用界面”,一行代码不改,换个配置文件就能监视任何协议、任何设备。 甚至可以完全没有界面——一个命令行程序读同一份配置,照样把值按索引打印出来。
那份配置文件长什么样
这是上面那个 Modbus TCP 会话的完整配置,界面里点“加载配置”选它就能跑:
1 | { |
注意:protocol 是个字符串——换成 "modbus_rtu" 再把 link/serial 填上,同一套界面立刻变成串口监视;area/address 这些是给 Modbus 驱动看的,框架和界面都不碰。点位数组的下标,就是界面显示时用的索引。
配套:Modbus TCP 监视界面
跑起来是这样的——这张是真的连了一个 Modbus TCP 从站(不是内存模拟),协议树选 TCP,配好主机 / 端口就连上了:

底部“收发帧”那栏能看到真正的 MBAP 帧:
1 | TX> 00 02 00 00 00 06 01 03 00 00 00 04 请求:事务号=0002,读保持寄存器 0 起 4 个 |
留意那个事务号在一帧帧递增(0001 → 0002 → 0003…)——这正是 Modbus TCP 用来把请求和响应配对的机制,和 RTU 用 CRC 定界是两种不同思路。下面这段动图能看得更清楚:值在跳、事务号在滚,而这一切界面代码都不知道底下是 Modbus:

这么折腾,到底换来什么
一次架构微调值不值,看它换来的具体好处:
- 加协议零改框架 / 界面:新协议只写一个实现索引接口的驱动模块,框架和界面一行不动——第三、第四个协议的成本,和第二个一样低。
- 界面彻底可换:界面只依赖“加载配置 + 按索引取值”。Qt 桌面版是其一,嵌入式 HMI 可以换 LVGL,甚至做成无界面的命令行守护进程——共用同一套纯 C 内核。
- 配置即一切:协议、链路、点位表、甚至“能选哪些协议”,全从配置文件来。改设备不改代码,运维直接下发一份 JSON。
- 优先级调度省总线:快慢变量错峰采集,慢点位不拖累报警刷新,也降低了总线占用。
- 站号等细节对框架透明:驱动的私有属性打包透传,框架不掺和,边界更干净。
- 没有硬件也能测:内置内存从站,RTU / TCP 两种帧都能不接设备跑通全流程——这次的 CI 里就是这么验的。
- 纯 C,能下沉到设备:核心不背运行时,网关、HMI、上位机同一套代码。
一句话:上一篇分了“层”,这一篇才真正做到了“层与层之间互不认识”。
接下来
框架这下真空了——它不认识任何协议了。那就该拿它去接第二种协议,验证一下这套抽象到底站不站得住:
- 下一篇,大概率是 Modbus 从站(把这个内存模拟从站升级成一个能被别人连的真从站),或者直接上西门子 S7——正好拿它的
DB1.DBD0地址,狠狠拷问一下这套“零协议概念”的接口。
如果新协议又逼我改了框架,那这次微调就还没到位;如果没改动,那这套抽象就算立住了。下一篇见分晓。