第二篇把协议赶出了框架,结尾立了个 flag:接第二种协议时才见真章——如果新协议逼我改了框架,那套“零协议、按索引”的抽象就没立住。

这一篇来还债。选的是西门子 S7:它的地址是 DB1.DBW0M0.0IW4,和 Modbus 的“区域 + 寄存器号”是两个世界,最适合拷问抽象。结论先说:框架和界面一行没改,加的只是一个新协议模块 + 一个驱动。

项目代号 talkplc。S7 部分从公开的 S7comm 协议从零写——只依据公开资料与抓包,个人时间与设备,不涉任何厂商代码。

S7 不是“一层”,是三层套娃

Modbus 一帧很扁:从站 + 功能码 + 数据 + CRC。S7 classic(S7-300/400/1200/1500 的非优化访问)要啰嗦得多——它是三层套在一起

1
2
3
4
5
6
7
8
┌─ TPKT (RFC1006)  03 00 [长度16] ──────────────────────────────┐
│ ┌─ COTP 02 F0 80 ───────────────────────────────────┐ │
│ │ ┌─ S7comm PDU 32 01 [冗余id] [ref] [参数长] [数据长] ───┐ │ │
│ │ │ 参数区: 04 01 <S7ANY 寻址项…> ← 这是一次 Read Var │ │ │
│ │ └────────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
整套跑在 tp_transport 字节管道上(tp_tcp → PLC 的 102 端口)
  • TPKT(RFC1006):4 字节小头,就干一件事——用一个长度字段告诉你这一包多长(TCP 是字节流,得自己定界);
  • COTP(ISO 传输层):数据帧固定 02 F0 80
  • S7comm PDU:真正的应用层,功能码 Read Var / Write Var / Setup Communication……

好在传输早就被抽象成字节管道了(第一篇就定的),所以这三层的组帧解帧,和 Modbus TCP 一样,全都架在同一个 tp_transport 上——协议层根本不知道底下是 socket。

连上之前,得先握两次手

Modbus 连上就能读。S7 不行,得先走两步握手:

1
2
3
4
5
6
TCP 连到 <PLC>:102
├─ 发 COTP 连接请求(CR) —— TSAP 里编码了机架/槽号(rack/slot)
│ 收 COTP 连接确认(CC)
└─ 发 S7 "Setup Communication" —— 和 PLC 协商最大 PDU 长度
收 Ack(比如协商成 960 字节)
之后才能 Read Var / Write Var

rack/slot 是 S7 特有的概念(S7-300 常是 0/2,S7-1200/1500 常是 0/1),它被塞进 COTP 的 TSAP 字段里。对外我的接口就一个 tp_s7_connect() 把这两步都包了:

1
2
3
4
tp_s7_t *s7 = tp_s7_new(transport, /*rack*/0, /*slot*/1);
tp_s7_connect(s7); /* COTP CR/CC + Setup Communication */
uint8_t buf[4];
tp_s7_read_area(s7, TP_S7_AREA_DB, /*db*/1, /*start*/0, /*size*/4, buf); /* 读 DB1.DBD0 */

和 Modbus 主站是一样的味道:建在 transport 上、set_timeout/set_trace、读写区域。

地址的世界观:DB1.DBW0

这才是拷问抽象的地方。Modbus 说“保持寄存器第 0 号”;S7 说的是 DB1.DBW0——1 号数据块、字节偏移 0、按字(word)读。还有 M0.0(标志位)、IW4(输入字)、Q0.1(输出位)……

一次 DB1.DBW0 的读,在 S7comm 里被编码成一个 12 字节的 S7ANY 寻址项

1
2
3
4
5
6
7
8
9
10
12 0A 10 02 00 02 00 01 84 00 00 00
│ │ │ │ └─┬─┘ └─┬─┘ │ └──┬───┘
│ │ │ │ │ │ │ └ 起始地址(按“位”计) = 字节0×8 = 0
│ │ │ │ │ │ └────── 区域码 0x84 = DB(M=0x83 / I=0x81 / Q=0x82)
│ │ │ │ │ └────────── DB 号 = 1
│ │ │ │ └──────────────── 元素个数 = 2 字节
│ │ │ └───────────────────── 传输尺寸 = BYTE
│ │ └──────────────────────── 语法 id = S7ANY
│ └─────────────────────────── 后续长度 = 10
└────────────────────────────── 寻址项标志 = 0x12

关键点:这套地址结构,Modbus 的“区域 + 16 位地址”模型根本装不下。 如果我的框架接口里还残留着 Modbus 的地址概念,这里就得动框架。

但框架真没动——只加了一个“地址串”

第二篇里,框架↔驱动的接口已经是按索引、零协议的了:框架只管“把点位表交下去、按索引采集、把值收上来”,从不解读地址。这次唯一的动作,是给点位加一个通用地址串字段(框架依旧不看它,只透传):

1
2
3
4
5
6
/* 点位:框架把它当不透明数据;地址串只有对应协议的驱动才解析 */
typedef struct tp_tag {
char name[32];
/* … 类型 / 字序 / 值 / 优先级 … */
char addr[24]; /* "DB1.DBW0" / "M0.0" —— S7 驱动自己解析 */
} tp_tag_t;

于是 S7 的接入,完全复刻 Modbus 的套路:

  • 写一个 S7 驱动:实现那套索引接口,内部把 addr 解析成 (区域, DB号, 偏移, 位)、按大端解码、连续点位用一帧 ReadMultiVars 批量读;
  • 注册表里加一行 "s7" → S7 驱动工厂
  • 在协议清单(protocols.json)里加一行。

框架、调度、点表、看板、配置加载——一行没改。 界面里协议树自动多出“Siemens S7”,点表填上 DB1.DBW0,值就按索引显示出来:

talkplc 监视西门子 S7:左侧协议树选中 Siemens S7,点位表里是 DB1.DBW0 / M20.0 这类 S7 地址,右侧实时值

没有 PLC 也能测

和 Modbus 一样,我写了个内存 S7 从站(应答 COTP CR/CC、Setup、Read/Write Var),插在同一个 tp_transport 上——不接设备就能把 TPKT/COTP/S7 整条帧路跑通。于是一个无界面的小程序,加载一份 S7 配置就能按索引把值打印出来:

1
2
3
4
5
$ config_monitor  config/example_s7_sim.json
--- cycle 0 [已连接] ---
[0] temp = 20 (ok) ← DB1.DBD0 float32
[1] count = 17 (ok) ← DB1.DBW10 uint16
[2] run = true (ok) ← M20.0 bool

temp 每拍在变、count 每 4 拍才变——因为优先级调度(高频/中频错开)也是框架的事,S7 驱动同样白捡。单元测试里,这条“客户端 ↔ 内存从站”的往返(连接协商、DB 读写回、M 位、float32)自然也纳入了 CI。

顺带:一帧读多个点

S7 的 Read Var 一帧里能放多个 S7ANY 项,所以驱动轮询时把“这一拍要采的点”打包成一次 ReadMultiVars(最多 ~20 项),而不是一个点发一帧——少很多往返。解析响应时要留意 S7 那个“项之间的填充字节”,属于协议细节里的小坑。

抽象立住了

回到开篇的那个赌注:接第二种协议,会不会逼我改框架?

  • 传输层:没改(S7 复用 tp_transport/tp_tcp);
  • 框架层:没改(还是按索引调度 + 中继);
  • 界面层:没改(协议树、点表、看板、配置全靠数据驱动);
  • 新增的,只有 talkplc_s7(协议)+ 一个 S7 驱动 + 注册表里一行。

这正是前三篇一步步“把协议赶出去”想换来的东西:第 N 个协议的接入成本,和第二个一样低。 地址是 40001 还是 DB1.DBW0,框架一视同仁——因为它压根不看。

接下来

S7 只做了 classic(非优化 DB)。S7-1200/1500 的优化访问 / S7-Plus 是另一套带加密的私有协议,开源实现里也没有,暂不碰。后面大概率往这几个方向走:

  • 三菱 MC、欧姆龙 FINS——再各拷问抽象一次;
  • 或回到界面:把点位表做成协议无关的地址串编辑(现在 S7 点位靠配置文件下发,手动编辑还带着 Modbus 的“区域”列);
  • 或做 LVGL 嵌入式前端,让这套纯 C 内核直接跑到 HMI 上。

每加一层都回来对照一次:改动越小,说明当初的抽象越对。到目前为止,它还立着。