上一篇talkplc 的分层讲清楚了,结尾立了个 flag:“加 Modbus TCP,协议帧逻辑一行不改,只写个新传输后端”。这一篇来兑现它。

但真动手时,这句话只说对了一半——传输确实一行没改,加 TCP 的过程却反过来把框架里藏着的“Modbus 味”照了出来,逼我做了一次不小的架构微调。正好应上一篇那句:架构是会被新需求反复拷问的。

项目代号 talkplc,纯净室开发,只依据公开协议规范,个人时间与设备。

先兑现承诺:Modbus TCP 到底改了什么

Modbus RTU 和 TCP,应用层的读写请求(PDU)是一模一样的——都是“功能码 + 起始地址 + 数量”。区别只在外面那层壳

1
2
3
4
5
6
7
8
RTU(串口):  ┌──────┬───────────┬─────────┐
│ 从站 │ PDU │ CRC16 │
└──────┴───────────┴─────────┘

TCP(以太网):┌───────────── MBAP 头 ─────────────┬──────┬───────────┐
│ 事务号 │ 协议号=0 │ 长度 │ 单元号 │ PDU (功能码+数据) │
└────────┴──────────┴──────┴────────┴───────────┘
(没有 CRC——TCP 自己保证完整性)

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
2
3
4
5
6
7
8
9
/* 框架 ↔ 协议驱动之间,唯一的边界。全程只有索引,零协议概念。 */
typedef struct tp_pdriver {
void *ctx;
int (*add_point)(void *ctx, const tp_point_t *p); /* 加一个点 → 返回索引 */
tp_status_t (*poll_points)(void *ctx, const int *idx, int n); /* 采这批索引 */
const tp_point_t*(*point)(void *ctx, int index); /* 按索引读回值 */
tp_status_t (*write)(void *ctx, int index, double value);
/* … set_timeout / set_trace / destroy … */
} tp_pdriver;

分工一下子就干净了:

1
2
3
4
界面 ──配点位──▶ 框架 ──"采第 0、2、5 个点"──▶ 协议驱动
(只管索引) (地址怎么解析、
界面 ◀──第i个值── 框架 ◀────第 i 个值────── 连续地址怎么合并成一帧、
怎么组帧解帧,全在这里)
  • 框架:只管“把点位表交下去、按索引调度采集、把值收上来”,它永远不知道 40001 是什么意思;
  • 协议驱动:藏在这个接口后面,自己解释地址、自己把连续地址合并成批量请求、自己组帧解帧收发。

加一个新协议 = 写一个实现这套接口的模块。框架和界面,一行都不用动。

微调二:调度可插拔,点位能分优先级

既然采集由框架调度,那“这一拍采哪些点”就该是一个可换的策略,而不是写死的循环。于是抽了个 tp_sched 接口:

  • 默认策略:每一拍,全采;
  • 优先级策略:给点位分高 / 中 / 低,高频点每拍都采,中频每 4 拍、低频每 16 拍——慢变量(温度、累计量)不再拖累快变量(报警、状态),也少占总线。

界面上就多了一列“优先级”,实测里高频点每拍在动、低频点隔很久才刷新一次,一眼能看出错峰效果。

微调三:站号这类“驱动私事”,框架也别管

站号(unit id)、协议的一些小脾气……这些只有驱动关心的属性,以前塞在框架的配置结构里。现在把它们打包成一段不透明的字节drv_cfg):框架只负责原样透传给驱动,自己从不去解读。对框架来说,站号和“协议是什么”一样——无意义。

微调四:注册表 + 配置文件,界面连协议都不认识

前三步之后,框架已经不懂协议了。那界面呢?上一篇里界面还在 if (是TCP) 用TCP驱动 else 用RTU驱动 地写死。再往前推一步:

加一层注册表——把“协议名字”翻译成“驱动工厂”("modbus_tcp" → 对应的构造函数)。于是界面只要报一个字符串名字,剩下的交给注册表。这样一来:

  • 界面代码里,从此不出现任何协议名
  • 一份 JSON 配置就能描述一整条连接:协议、链路、点位表,全在里面;
  • 连“左边能选哪些协议”这个列表,都从一份 manifest 读出来,而不是写死在界面里——注册表里加一个协议,界面自动多一项。

界面于是退化成两件事:加载配置、按索引显示值。某个值是哪个点?点位表里按同一个下标一查便知。

1
2
3
4
一份 JSON  ──▶  注册表: "modbus_tcp" → 驱动工厂
(协议名 链路参数、点位表
+链路 组装成一条会话,起线程
+点位表) ──▶ 界面: 只拿「第 i 个值」显示,全程不认识“Modbus”

这一步的直接效果:做一个“通用界面”,一行代码不改,换个配置文件就能监视任何协议、任何设备。 甚至可以完全没有界面——一个命令行程序读同一份配置,照样把值按索引打印出来。

那份配置文件长什么样

这是上面那个 Modbus TCP 会话的完整配置,界面里点“加载配置”选它就能跑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"link": "tcp",
"protocol": "modbus_tcp",
"sched": "priority",
"station": 1,
"interval_ms": 500,
"timeout_ms": 1000,

"tcp": { "host": "127.0.0.1", "port": 502 },

"points": [
{ "name": "level", "area": "holding", "address": 0, "type": "uint16", "priority": "high" },
{ "name": "offset", "area": "holding", "address": 1, "type": "int16", "priority": "normal" },
{ "name": "flow", "area": "holding", "address": 2, "type": "float32", "word_order": "hi_first", "priority": "low" }
]
}

注意:protocol 是个字符串——换成 "modbus_rtu" 再把 link/serial 填上,同一套界面立刻变成串口监视;area/address 这些是给 Modbus 驱动看的,框架和界面都不碰。点位数组的下标,就是界面显示时用的索引。

配套:Modbus TCP 监视界面

跑起来是这样的——这张是真的连了一个 Modbus TCP 从站(不是内存模拟),协议树选 TCP,配好主机 / 端口就连上了:

talkplc 监视 Modbus TCP:左侧协议树、右侧配主机端口 + 点位表(含优先级列与实时值),底部是收发帧监视

底部“收发帧”那栏能看到真正的 MBAP 帧

1
2
TX> 00 02 00 00 00 06 01 03 00 00 00 04     请求:事务号=0002,读保持寄存器 0 起 4 个
RX< 00 02 00 00 00 0B 01 03 08 00 02 ... 响应:事务号对上 0002,8 字节数据

留意那个事务号在一帧帧递增0001 → 0002 → 0003…)——这正是 Modbus TCP 用来把请求和响应配对的机制,和 RTU 用 CRC 定界是两种不同思路。下面这段动图能看得更清楚:值在跳、事务号在滚,而这一切界面代码都不知道底下是 Modbus:

talkplc 监视 Modbus TCP 实时轮询:点位值随从站变化,收发帧监视里 MBAP 事务号逐帧递增

这么折腾,到底换来什么

一次架构微调值不值,看它换来的具体好处

  • 加协议零改框架 / 界面:新协议只写一个实现索引接口的驱动模块,框架和界面一行不动——第三、第四个协议的成本,和第二个一样低。
  • 界面彻底可换:界面只依赖“加载配置 + 按索引取值”。Qt 桌面版是其一,嵌入式 HMI 可以换 LVGL,甚至做成无界面的命令行守护进程——共用同一套纯 C 内核
  • 配置即一切:协议、链路、点位表、甚至“能选哪些协议”,全从配置文件来。改设备不改代码,运维直接下发一份 JSON。
  • 优先级调度省总线:快慢变量错峰采集,慢点位不拖累报警刷新,也降低了总线占用。
  • 站号等细节对框架透明:驱动的私有属性打包透传,框架不掺和,边界更干净。
  • 没有硬件也能测:内置内存从站,RTU / TCP 两种帧都能不接设备跑通全流程——这次的 CI 里就是这么验的。
  • 纯 C,能下沉到设备:核心不背运行时,网关、HMI、上位机同一套代码。

一句话:上一篇分了“层”,这一篇才真正做到了“层与层之间互不认识”。

接下来

框架这下真空了——它不认识任何协议了。那就该拿它去接第二种协议,验证一下这套抽象到底站不站得住:

  • 下一篇,大概率是 Modbus 从站(把这个内存模拟从站升级成一个能被别人连的真从站),或者直接上西门子 S7——正好拿它的 DB1.DBD0 地址,狠狠拷问一下这套“零协议概念”的接口。

如果新协议又逼我改了框架,那这次微调就还没到位;如果没改动,那这套抽象就算立住了。下一篇见分晓。