ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入S7协议:Wireshark抓包与Python自研客户端实现

深入S7协议:Wireshark抓包与Python自研客户端实现 简介面向工业自动化开发者的西门子PLC以太网S7协议客户端源码基于TCP/IP封装与SIMATIC PLC通信所需的核心逻辑覆盖从连接建立、PDU服务请求构造到DB块、M区、I区等变量读写以及错误处理、多线程调用等关键环节适合需要快速集成S7通信功能的上位机与监控系统项目。压缩包共52个文件整体187KB包含C#工程源码cs、可直接运行的exe与依赖dll、配置文件、XML资源以及配套的《西门子可编程控制器S7协议PC通讯组件使用说明》docx文档便于对照学习与二次开发。目前已有1500人学习下载。通过阅读和调试这份源码开发者不仅能得到可复用的TCP通信框架和读写PLC数据的示例还能理解工业以太网通信中的PDU结构、异常排查思路与优化手段对入门西门子PLC上位机开发或完善现有监控系统都有实际借鉴价值。1. 从 S7 协议到自研接线为什么你仍然需要读报文不少工控工程师第一次打开 Wireshark 抓 PLC 报文时会看到一长串十六进制里夹着03 00 00 16 ...再往后是一段看起来很像乱码的02 f0 80 32 ...。如果只看这个现象很容易把 S7 协议当成一个需要特殊驱动才能打开的“黑盒”。实际上 S7 协议并没有加密——它只是把功能码、参数区、数据区和传输控制信息叠在一起只要知道每一层从哪里开始、到哪里结束就能用任意语言解析它。对做上位机、边缘网关或嵌入式采集设备的人来说理解 S7 协议源码的核心价值不是去背报文格式而是能在没有西门子官方 SDK 的情况下自己拼出读 DB 块、写 M 区的请求并能准确判断数据区边界。这篇内容默认你手里有 PLC 或仿真器手里能抓到 TCP 102 端口的报文顺着协议分层把源码级的实现路径讲清楚。2. 抓包理解 S7 协议结构从 TPKT/COTP 到参数区和数据区2.1 先分清四层结构以太网帧、IP、TCP、COTP/PayloadS7 通信走的是 TCP 端口 102承载在标准以太网之上。一个完整的 S7 报文从网线上看依次是二层帧头、IP 头、TCP 头然后是 TPKT 头、COTP 头最后才是 S7 Communication 的载荷。用 Wireshark 打开抓包文件后过滤器写s7comm能直接定位到 S7 层但如果你想自己写解析器不能只看显示结果得知道每一层的字节偏移。以常见的读 DB 请求为例TCP Payload 的第一个字节是03代表 TPKT 版本号。第 2 个字节也是03表示保留位。第 3、4 字节是整个 TPKT 报文的总长度包含 TPKT 头自身。从第 5 字节开始是 COTP 层常见值为02 f0 80其中02表示 TPDU 是数据格式f0是说 TPDU 不包含确认和 EOT 标记80是 TPDU 序号。对于连接建立阶段COTP 头会更长会多出11 e0 00 00 00 01和一段 TSAP 参数。理解这个分层对后面抓包排错很重要很多初学者看到03 03 00 16就以为是 S7 请求其实那只是 COTP 连接请求。2.1.1 用 Wireshark 快速定位 S7 报文Wireshark 自带 s7comm 解析器可以直接展开看到 Function、Parameter、Data 字段。但自带的解析对 Job 类型支持较好对一些扩展的 Read/Write 变体不一定能完整显示。实际抓包时建议配合tcp.port 102过滤再添加自定义列显示s7comm.function和s7comm.param.response这样能在一大串会话中快速区分请求和响应。如果没有真实 PLC可以用仿真器或抓取软件模拟器发出的报文。S7-PLCSIM 在本地会监听 TCP 102但要注意它默认绑定的是回环地址Wireshark 抓回环接口需要安装 Npcap 并勾选 Loopback 抓包选项。2.2 理解 Job 和 Ack_DataS7 协议的两大语义单元S7 协议里绝大多数操作都是由客户端发起 JobPLC 回一个 Ack_Data。Job 和 Ack_Data 都有自己的头部长度不一。Job 头固定 10 字节Ack_Data 头固定 12 字节但两者的参数区结构不同。写协议解析器时不能靠固定偏移直接读参数区要先看 ROSCTR 字段第 11 字节判断类型0x01是 Job0x03是 Ack_Data0x07是 Userdata。Ack_Data 的响应码在第 16 字节如果值不是0x00 0xff表示有异常这个机制用于判断读写的成功与否。比如读一个不存在的 DB 块返回的参数区里会出现对象不存在错误而不是直接断 TCP 连接。2.2.1 一张表理清报文头字节含义字节偏移长度含义示例值0-34TPKT 头含总长度03 03 00 214-63COTP 头数据格式02 f0 8071ROSCTR报文类型01Job, 03Ack_Data8-92冗余标识一般固定00 0010-112协议 ID固定 0x3232 0112-132数据长度从下一字节到报文末尾00 1014-152功能码Read/Write/Request 等04 01 表示 Read Job这张表适合在写解析脚本时作为偏移基准。实际用 Python 解析时我会用struct.unpack_from一次性取字段避免多个切片拼接出错。2.3 参数区、数据区、Item 结构S7 协议的“超文本”S7 报文里最复杂的地方不是头是参数区里的 Item。一个读 DB 的 Job参数区先有功能码0x04然后是0x01表示读一个变量接着是 Item 计数以及每个 Item 的长度。变频器场景中通常要一次性读多个参数比如读 3 台变频器的运行频率很多人会把地址放在一个 Item 里但那是错的。S7 协议要求每个变量一个 Item比如同时读 4 个 M 地址或 4 个 DB 偏移每一个都是独立的 Item。这一点在解析源码时最容易写错也是自己拼报文时最容易漏字段的地方。数据区在参数区之后对 Read Job 来说发送时数据区长度可以是 0但 Ack_Data 返回时数据区会有实际数据其结构是 Item 头 数据。Item 头有 4 字节的返回码、2 字节的传输大小、2 字节的数据长度之后才是真正的值。不少人解析时会漏掉 Item 头直接从数据区第 1 字节当数值导致数值整体错位。3. 用 Python 从零拼一个 S7 读 DB 块的最小实现3.1 不依赖第三方库的 S7 报文构造连接握手与协商先建 TCP 连接发送 COTP 连接请求这是 S7 通信最容易被忽略的步骤。很多人在局域网里能 ping 通 PLC但程序连上端口后发数据没反应就是因为没有先做 COTP 握手。COTP 连接请求的固定报文如下03 00 00 16 11 e0 00 00 00 01 00 c0 01 0a c0 01 09 c1 02 00 01 c2 02 00 01这段报文的意思是TPKT 总长度 0x1622 字节COTP 连接请求源 TSAP 为 0x0100目的 TSAP 为 0x0100。PLC 收到后会返回一个连接确认确认报文以03 00 00 16 11 d0开头。如果收到的不是d0开头说明 TSAP 不匹配。S7-1200 默认的 TSAP 是 0x0100S7-300 的常见组合是 0x0100 与 0x0100也可以按需改成 0x0101。握手完成后还需要进行一次 S7 协商请求用来协商 PDU 长度。报文头固定参数区里的f0是协商 PDU 长度的功能码。PDU 长度通常设为 240 字节这是兼容性最好的值。如果你的采集任务涉及大数据块可以改成 480 或 960但前提是 PLC 侧也支持。S7-200 等老型号对 PDU 长度的支持上限较低盲设大 PDU 会导致通信直接断开。3.2 手工拼装 Read Job 并解析响应下面给出一个可直接运行的脚本它连接 PLC读取 DB1.DBD0 和 DB1.DBD4 两个 32 位浮点数然后打印结果。这是自己写 S7 客户端的最小骨架去掉注释后不到 80 行import socket import struct def cotp_connect(sock): cr bytes.fromhex(03 00 00 16 11 e0 00 00 00 01 00 c0 01 0a c0 01 09 c1 02 00 01 c2 02 00 01) sock.send(cr) resp sock.recv(1024) assert resp[5] 0xd0, COTP 连接失败检查 TSAP 配置 def s7_negotiate(sock): payload bytes.fromhex(03 00 00 21 02 f0 80 32 01 00 00 04 00 00 06 00 01 12 04 11 44 01 00 04 12 0a 10 02 00 00 00 00 a2 00 00 00) sock.send(payload) resp sock.recv(1024) # 响应中第 25-26 字节为协商后的 PDU 长度 pdu_len struct.unpack(H, resp[25:27])[0] print(f协商后的 PDU 长度: {pdu_len}) def build_read_db(db_number, start_offset, count): # count 表示连续读取的字节数建议不超过 200避免跨 PDU item_count 1 param struct.pack(B, 0x04) # 功能码 Read param struct.pack(B, item_count) # 变量个数 param struct.pack(B, 0x12) # 变量规范长度 param struct.pack(B, 0x0a) # 后续地址长度 param struct.pack(B, 0x10) # 变量类型DB param struct.pack(H, db_number) # DB 块号 param struct.pack(B, 0x04) # 区域类型4 表示 DB param struct.pack(H, start_offset) # 字节偏移 param struct.pack(B, 0x00) # 位偏移 param struct.pack(B, 0x00) # 访问模式 param struct.pack(H, count) # 读取长度 param struct.pack(B, 0x00) # 数据区暂空 return param def read_db(ip, db, offset, length): s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(5) s.connect((ip, 102)) cotp_connect(s) s7_negotiate(s) param build_read_db(db, offset, length) tpkt_len 7 10 len(param) header struct.pack(BBH3sBBBHH, 0x03, 0x03, tpkt_len, b\x02\xf0\x80, 0x01, 0x00, 0x00, 0x00, 0x00) job header param s.send(job) resp s.recv(4096) # 检查响应码第 16 字节应为 0xff 表示成功 if resp[15] ! 0xff: raise RuntimeError(fPLC 返回错误错误码 0x{resp[15]:02x}) # 数据从 Item 头之后开始Item 头为 4 字节返回码 2 字节传输大小 2 字节长度 data resp[25:] values [] for i in range(0, len(data), 4): values.append(struct.unpack(f, data[i:i4])[0]) return values if __name__ __main__: vals read_db(192.168.0.1, 1, 0, 8) print(vals)逻辑说明整个实现的核心理念是“无第三方库”因此必须自己组织 TPKT 长度和 COTP 头。第 46 行header的H字段是报文总长度它需要等于 7 字节 TPKT/COTP 头、10 字节 Job 头和参数区长度之和。如果这个长度算错PLC 会一直认为报文不完整表现为程序卡在 recv。参数说明db_number是实际的 DB 块编号不是从 1 开始的索引第 2 个参数start_offset是按字节算的偏移例如 DBD0 就是 0DBW2 就是 2。length是连续读取的字节数读两个实数就传 8。传输大小在 S7 协议里默认用 4 字节实数或 2 字节整数这个脚本里固定按浮点解析如果你读的是 DBD 整数需要把数据区解析方式改成i。你会发现这里没有处理跨 PDU 分包的情况原因是读取长度小于 200 字节时响应总能在一个 TCP 包里回来工程上建议把读取块控制在 200 字节以内既避免分包也减少 PLC CPU 的负载。3.3 写 M 区和 Q 区的报文变体读 M 区和写 M 区的区别只在参数区的区域类型和功能码上。区域类型0x03是 M 区0x02是 Q 区0x01是 I 区。写操作的参数区与读操作类似但数据区会变成实际要写入的值。注意写操作里也有 Item 头Item 头的传输大小字段必须和实际写入的数据类型一致比如写 16 位整数是0x02写 32 位浮点则是0x04。如果类型不匹配PLC 会返回地址错误或类型错误这类错误在 Wireshark 里能看到参数区中的错误码但不会断开连接。def build_write_m(offset, value, value_typeint16): # 适用于 M 区字写入 param struct.pack(B, 0x05) # 功能码 Write param struct.pack(B, 0x01) param struct.pack(B, 0x12) param struct.pack(B, 0x0a) param struct.pack(B, 0x03) # 区域类型 M param struct.pack(H, offset) param struct.pack(B, 0x00) param struct.pack(B, 0x00) if value_type int16: data struct.pack(h, value) else: data struct.pack(f, value) param struct.pack(H, len(data)) param b\x00 return param这一段代码没有做完整封装只展示写报文的参数区差异。实际发送时仍要加 Job 头和 TPKT 头并且 Job 头里的数据长度字段要包含参数区加数据区的总长度。一个典型的手误是把数据长度只算了参数区导致 PLC 认为数据区为空写入不生效。4. 深入 s7 协议细节PDU 长度、地址对齐与多变量读取策略4.1 PDU 长度对报文切分的影响S7 协商值决定了每次通信能携带的最大应用数据长度但这个值不是越大越好。常见的 S7-1200 默认协商值为 240 字节S7-300 往往只有 240 字节S7-400 部分固件支持 480 字节。如果你通过协商把 PDU 长度改到 960但程序一次读 800 字节响应会被拆成多个 TCP 段接收端必须按 TPKT 中的长度字段去拼包。很多自研驱动的 bug 就出现在这里只调了一次 recv就默认拿到了完整报文。解决方法是先读 TPKT 头里的总长度字段然后循环 recv 直到收满。伪代码如下def recv_tpkt(sock): header b while len(header) 4: header sock.recv(4 - len(header)) total_len struct.unpack(H, header[2:4])[0] body b while len(body) total_len - 4: body sock.recv(total_len - 4 - len(body)) return header body这在源码级调试里是必须有的基础函数没有它一切大块读取都是概率性成功。4.2 地址对齐规则与符号寻址的坑S7 的地址区偏移是“字节偏移 位偏移”的结构但你写地址时不能把位偏移随意填空。访问位变量时位偏移在 0 到 7 之间访问字或双字时位偏移必须为 0否则 PLC 会报地址错误。很多人用 Python 构造报文时读取 I0.0 这种位变量会忘记把位编号放进偏移字段结果读出来永远不对。对 DB 块来说如果需要读取的变量是字符串或自定义结构体最简单的方式是让 PLC 工程师把数据打包成字节数组上位机按数组解析。这在工程上是最稳定的做法能避开 PLC 数据对齐带来的坑。如果你必须直接读取混合结构建议按偏移逐个变量读取而不要一次性把整个结构体读出来再拆因为 DB 的内部填充字节在不同固件版本下不完全一致。4.3 一次读多个变量的 Item 布局一次性读取多个变量时Item 格式和单变量相同但要在参数区里连续排列。注意 Item 前面有一个字节的变量规范长度多个 Item 时该值为每个 Item 长度之和。比如读 M0.0 和 DB1.DBD0参数区的变量规范长度就不是 0x0a而是 0x12。最容易错的是漏掉变量规范长度占的那个字节导致整个报文偏移错乱PLC 回复错误参数错误。下表给出常见数据类型的 Item 参数分布项读 M0.0读 DB1.DBD0读 IW64变量规范0x02 0x0a0x02 0x0a0x02 0x0a区域类型0x030x100x01偏移区字节偏移位偏移DB 号字节偏移字节偏移数据类型长度1 位4 字节2 字节从这里能看到读位变量时长度字段填 1但传输大小的编码是0x03表示位读字节或字时需要根据具体的数据类型编码。自研代码时建议先从小块、单变量开始确认 Wireshark 里的解码和预期一致再扩展多变量。5. 抓包与源码级调试技巧先模拟、再真机、固定偏移验证最后一章不写总结直接放三个我在实际调试中一定会用的技巧。第一个技巧是用 S7-PLCSIM 做离线开发。仿真器能和真实 PLC 一样响应 COTP 握手和 S7 读写请求。开发时先连仿真器调通报文再换真机验证能省掉大量在设备旁边改脚本的时间。注意仿真器默认只监听本机回环地址且要求电脑网卡启用 S7 通信相关的服务。第二个技巧是固定一个解析脚本用 Wireshark 的导出对象做回归。把一段抓包文件存成 pcap用 tshark 导出所有 S7 请求和响应然后让脚本自动比对报文长度和关键参数。这一步能在代码改动后快速发现报文构造错误。tshark -r capture.pcapng -Y s7comm tcp.port 102 -T fields -e frame.number -e tcp.payload这条命令把每个 S7 报文的 TCP payload 输出为十六进制串复制一段到脚本里就可以离线验证报文解析逻辑不需要连着 PLC 调试。第三个技巧是关注 Ack_Data 中的错误码而不是只看超时。很多工程师在通信失败时直接看 TCP 状态但响应报文里的错误码能指出更具体的原因比如对象不存在、地址越界、类型不匹配。在解析响应的代码里单独提取错误码并打印含义调试效率会明显提升。正确性验证建议用 PLC 侧已知值作为基准。先在 TIA Portal 里给 DB1.DBD0 写一个固定浮点数再用脚本读取对比位模式是否一致。如果浮点数值完全吻合基本可以确定字节序和传输大小字段都正确。之后再扩展到数组和结构体。最后把这套读取逻辑封装成独立的库函数加上超时重连和 PDU 长度自适应就可以作为自研 S7 客户端的内核使用了。本文还有配套的精品资源点击获取
返回列表