1. 为什么值得花时间啃S7协议这块硬骨头
如果你做过西门子PLC相关的上位机开发、数据采集网关或者产线MES对接,大概率绕不开一个名字——S7通信协议。它不像Modbus那样简单直白,也不像OPC UA那样自带信息模型,但偏偏西门子S7-200 Smart、S7-1200、S7-1500这些设备在国内工厂里的保有量极大,你只要碰西门子的PLC,早晚得跟它打交道。
很多人第一次接触S7协议,是拿了一个现成的库,比如Snap7或者某个C#的S7.Net,调几个函数就把数据读出来了,感觉挺简单。但一旦遇到连不上、读回来数据错位、批量读取超时、TSAP填错导致握手失败这些问题,就抓瞎了。因为你不知道底层发生了什么,只能靠猜。而S7协议恰恰是一个分层封装的协议栈,从TCP之上叠了TPKT、COTP,再到S7 Communication本身,每一层都有自己的握手逻辑和字段含义。你不把这四层的关系理清楚,排错基本靠运气。
这篇文章的目标很明确:把S7通信协议从建立连接到读写数据的完整链路拆开讲透。我会从最底层的TCP连接说起,逐层往上分析TPKT的头部结构、COTP的连接请求与确认、S7层的通信建立和读写报文格式。每一层我都会给出实际的报文结构、字段含义,以及在实际调试中怎么用抓包工具去验证。适合有一定网络基础和PLC使用经验、但想深入理解协议细节的工程师,也适合正在做数据采集网关、需要自己实现S7协议栈的开发者。
需要提前说明的是,S7协议本身是西门子的私有协议,官方并没有完全公开的协议规范文档。目前业界对它的理解主要来自逆向工程和社区积累,Snap7这个开源项目就是其中最有代表性的成果。我下面讲的内容,也是基于这些公开的社区知识加上我自己在实际项目中的验证。不同型号的PLC在细节上可能有差异,比如S7-200 Smart和S7-1500在连接参数上就不完全一样,具体以实测为准。
另外提一句,如果你只是想快速读写数据,直接用Snap7或者基于它封装的库就行,没必要自己从零实现。但如果你想搞清楚为什么某个参数要那样填、为什么有时候连不上、为什么读取效率上不去,那理解协议本身是绕不过去的。下面我们正式进入协议的分层拆解。
2. 从TCP到TPKT:数据是怎么被一层层包起来的
2.1 四层协议栈的整体视角
S7通信的协议栈可以简单理解为四层:最底下是TCP,上面是TPKT,再上面是COTP,最上面是S7 Communication。这个分层结构跟OSI模型不是严格对应的,但思路类似——每一层负责自己的事情,下层为上层提供服务。
TCP层负责的是可靠的字节流传输,默认端口是102。这个端口是S7通信的标准端口,ISO-on-TCP协议就是跑在这个端口上的。你在代码里创建一个TCP客户端连接到PLC的102端口,这一步跟普通的TCP连接没有任何区别。
TPKT层的作用是解决TCP的“粘包”问题。TCP是字节流,没有消息边界,你发两次数据,对方可能一次收到,也可能分三次收到。TPKT在每段数据前面加一个4字节的头部,标明这段数据有多长,接收方就能根据长度字段把完整的消息切出来。TPKT的全称是ISO Transport Service on top of TCP,顾名思义,就是在TCP之上提供传输服务。
COTP层是ISO 8073协议的一个简化实现,全称是Connection-Oriented Transport Protocol。它负责建立和维护连接,包括连接请求、连接确认、数据传输和断开连接。在S7通信中,COTP主要负责两件事:一是建立连接时的参数协商,二是为上层数据提供传输通道。
S7 Communication层就是西门子自己的协议了,负责具体的通信功能,比如建立S7通信、读取数据、写入数据、获取PLC状态等。这一层才是我们真正关心的业务逻辑层。
理解这个分层结构的好处是,当通信出问题时,你可以按层排查。TCP连不上,那是网络问题;TCP连上了但COTP握手失败,那是连接参数问题;COTP握手成功但S7层报错,那可能是PLC的访问权限或者地址配置问题。分层排查比盲目试错效率高得多。
2.2 TPKT头部结构详解
TPKT头部固定4个字节,结构如下:
| 字节偏移 | 字段名 | 长度 | 说明 |
|---|---|---|---|
| 0 | Version | 1字节 | 版本号,固定为0x03 |
| 1 | Reserved | 1字节 | 保留字段,固定为0x00 |
| 2-3 | Length | 2字节 | 整个TPKT包的总长度,包括头部本身,大端序 |
这个结构非常简单,但有一个细节容易踩坑:Length字段是包含TPKT头部在内的总长度,不是数据部分的长度。比如你发一个COTP连接请求,COTP部分有22字节,加上TPKT的4字节头部,Length字段就是26(0x001A)。
在实际抓包中,你会看到每个S7报文都以03 00开头,这就是TPKT的Version和Reserved字段。后面两个字节就是长度。如果你看到的数据不是以03 00开头,那要么不是S7协议的数据,要么报文被截断了。
注意:TPKT的Length字段是大端序,也就是高字节在前。比如长度26,写成十六进制是0x001A,在报文中就是00 1A。如果你按小端序解析,会得到0x1A00,也就是6656,那就完全错了。
2.3 为什么需要TPKT这一层
有人可能会问,TCP已经是可靠传输了,为什么还要加一层TPKT?直接自己处理粘包不行吗?
当然可以,但TPKT提供了一个标准化的方案。ISO-on-TCP协议在很多工业协议中都有应用,不只是S7,比如一些老式的PLC和DCS系统也用这个。TPKT作为ISO-on-TCP的传输层,提供了一个通用的消息边界机制,上层的COTP和S7就不用各自处理粘包问题了。
从实现角度看,TPKT的解析非常简单,就是读4个字节,取后两个字节作为长度,然后从缓冲区里切出这个长度的数据交给上层。发送的时候,先计算好上层数据的长度,加上4,填入Length字段,再拼上上层数据一起发出去。这个逻辑用任何语言实现都不超过20行代码。
但就是这么一个简单的东西,在实际调试中经常出问题。我遇到过好几次,抓包看到TCP连接建立了,数据也发了,但PLC就是不响应。后来发现是TPKT的Length字段算错了,把数据长度当成了总长度,少加了4个字节。PLC收到之后发现长度对不上,直接丢弃了。这种问题在日志里看不出来,只能抓包对比。
2.4 实际抓包中的TPKT示例
下面是一个典型的COTP连接请求报文的十六进制数据:
03 00 00 16 11 E0 00 00 00 01 00 C1 02 01 00 C2 02 01 02 C0 01 0A逐字段解析:
03 00:TPKT的Version和Reserved00 16:Length = 22,表示整个报文22字节11:COTP的Length字段,表示COTP头部剩余部分长度E0:COTP的PDU类型,0xE0表示连接请求(CR)00 00:目标引用(Destination Reference)00 01:源引用(Source Reference)00:Class和OptionsC1 02 01 00:参数代码0xC1,长度2,内容01 00,表示TSAPC2 02 01 02:参数代码0xC2,长度2,内容01 02,表示TSAPC0 01 0A:参数代码0xC0,长度1,内容0A,表示TPDU大小
这个报文就是上位机向PLC发起连接请求时发送的第一帧数据。PLC收到之后,如果参数没问题,会返回一个连接确认报文。如果参数有问题,比如TSAP不对,PLC可能直接不响应,或者返回一个拒绝报文。
理解这个报文的结构,对于排查连接问题非常关键。比如你发现PLC不响应连接请求,可以抓包看看你发的COTP报文里TSAP填的是什么,跟PLC实际期望的是否一致。S7-1200和S7-1500的TSAP通常是01 00和03 02这样的组合,而S7-200 Smart可能不同。具体值需要查对应型号的文档或者用抓包工具对比正常通信的报文。
3. COTP握手:连接建立的关键一步
3.1 COTP连接请求报文的字段含义
COTP连接请求(Connection Request,CR)是上位机发给PLC的第一帧COTP数据。它的结构比TPKT复杂一些,但字段并不多。上面已经给出了一个完整的示例,这里再详细解释一下每个字段的作用。
COTP的Length字段(1字节)表示COTP头部剩余部分的长度,不包括Length字段本身。在连接请求中,这个值通常是0x11,也就是17字节。这17字节包括PDU类型、目标引用、源引用、Class/Options以及各种参数。
PDU类型字段(1字节)标识COTP报文的类型。常见的类型有:
| PDU类型值 | 名称 | 说明 |
|---|---|---|
| 0xE0 | CR | 连接请求 |
| 0xD0 | CC | 连接确认 |
| 0xF0 | DT | 数据传输 |
| 0x80 | DR | 断开请求 |
| 0xC0 | DC | 断开确认 |
目标引用(2字节)和源引用(2字节)用于标识连接的两端。在连接请求中,目标引用通常填0x0000,源引用填一个由上位机生成的随机值或固定值。PLC在连接确认中会返回它自己的引用值。
Class和Options字段(1字节)在S7通信中通常填0x00,表示Class 0,也就是最基本的传输服务。
参数部分是COTP连接请求的核心,每个参数由参数代码(1字节)、参数长度(1字节)和参数内容组成。S7通信中常见的参数有:
- 0xC1:源TSAP(Transport Service Access Point),标识上位机侧的连接端点
- 0xC2:目标TSAP,标识PLC侧的连接端点
- 0xC0:TPDU大小,表示最大传输单元
TSAP是COTP层最重要的参数,它决定了你连接的是PLC的哪个通信资源。不同的TSAP对应不同的通信服务,比如S7-1200的TSAP03 02通常对应的是PG(编程器)通信,03 01对应的是OP(操作面板)通信,01 00对应的是S7基本通信。如果你填错了TSAP,PLC可能会拒绝连接,或者连接上了但无法进行S7通信。
3.2 TSAP的构造规则与常见取值
TSAP在S7通信中是一个容易让人困惑的点,因为它不像IP地址和端口那样直观。TSAP的全称是Transport Service Access Point,翻译过来就是传输服务访问点。你可以把它理解成COTP层的一个“门牌号”,PLC通过这个门牌号来区分不同的通信连接。
S7通信中,TSAP通常由两部分组成:连接类型和连接ID。对于S7-1200和S7-1500,常见的TSAP取值如下:
| 连接类型 | 上位机TSAP | PLC TSAP | 说明 |
|---|---|---|---|
| PG通信 | 01 00 | 03 02 | 编程器连接,用于下载程序和调试 |
| OP通信 | 01 00 | 03 01 | 操作面板连接,用于HMI通信 |
| S7基本通信 | 01 00 | 01 00 | 用于数据读写 |
对于S7-200 Smart,TSAP的规则又不一样。S7-200 Smart的TSAP通常是03 00和03 01这样的组合,具体取决于连接的是哪个通信资源。而且S7-200 Smart对TSAP的校验比较严格,填错了直接拒绝连接。
在实际项目中,我通常的做法是先用抓包工具抓一次正常通信的报文,看看TSAP填的是什么,然后照抄。如果没有正常通信的报文可抓,那就查对应型号的手册,或者用Snap7的示例代码试。Snap7的源码里对不同型号的PLC有不同的TSAP配置,可以参考。
提示:S7-1200和S7-1500在默认配置下,PG通信的TSAP是
03 02,OP通信是03 01。但如果你在PLC的硬件配置里修改了连接资源,TSAP可能会变。另外,如果PLC启用了“允许来自远程对象的PUT/GET通信访问”,S7基本通信的TSAP01 00才能正常工作。
3.3 连接确认报文与参数协商
PLC收到COTP连接请求后,如果参数没问题,会返回一个连接确认(Connection Confirm,CC)报文。这个报文的结构跟连接请求类似,但PDU类型是0xD0,目标引用和源引用的值会互换。
连接确认报文中,PLC会返回它自己的TSAP和TPDU大小。上位机收到之后,需要检查这些参数是否跟预期一致。如果PLC返回的TSAP跟你请求的不一样,那说明PLC可能对你的请求做了调整,或者你请求的TSAP不对,PLC用了默认值。
连接确认报文中的TPDU大小字段表示PLC能接受的最大传输单元。这个值决定了你后续发送数据时,单个COTP数据包的最大长度。如果超过这个值,PLC可能会拒绝或者分片。在S7通信中,TPDU大小通常是0x0A,也就是1024字节。但实际可用的数据长度要减去TPKT、COTP和S7头部的开销,所以单次读写的数据量不能超过这个限制。
如果PLC拒绝了连接请求,它会返回一个断开请求(DR)报文,里面会包含拒绝原因。常见的拒绝原因包括TSAP不支持、资源不足、参数不合法等。抓包看到DR报文时,可以解析里面的原因字段来定位问题。
3.4 握手失败的常见原因与排查方法
COTP握手失败是S7通信中最常见的问题之一。根据我的经验,失败原因大致可以归为以下几类:
第一类是TSAP填错。这是最常见的原因。不同型号的PLC、不同的连接类型,TSAP都不一样。填错了PLC直接拒绝,或者根本不响应。排查方法是抓包对比,或者查手册确认。
第二类是PLC的通信资源被占满。S7-1200和S7-1500对同时连接的客户端数量有限制,默认情况下PG通信资源可能只有1个,OP通信资源可能有3个。如果你已经用博途或者HMI占用了这些资源,再发起新的连接就会被拒绝。解决办法是断开其他连接,或者在PLC配置里增加连接资源。
第三类是PLC的访问权限设置。S7-1200和S7-1500默认可能不允许PUT/GET通信,需要在硬件配置里勾选“允许来自远程对象的PUT/GET通信访问”。如果没有勾选,S7基本通信的TSAP01 00就连不上。
第四类是网络问题。虽然TCP连接建立了,但中间有防火墙或者NAT设备修改了报文,导致COTP握手失败。这种情况比较少见,但如果你是通过路由器或者网关连接的,就需要检查中间设备是否对102端口做了特殊处理。
排查的时候,我通常按这个顺序来:先确认TCP能连通(用telnet或者nc测试102端口),然后抓包看COTP请求和响应,再检查TSAP和PLC配置。大部分问题在前两步就能定位。
4. S7 Communication层:读写报文的完整拆解
4.1 S7通信的建立过程
COTP握手完成之后,TCP连接就建立好了,但这还不算完。S7 Communication层还需要进行一次“通信建立”的过程,也就是发送一个S7通信的初始化报文。这个报文的作用是协商S7层的参数,比如最大PDU长度、通信双方的引用值等。
S7通信建立的过程通常包括以下几个步骤:
- 上位机发送一个Job报文,里面包含一个“通信建立”的请求(Function Code 0xF0)。
- PLC返回一个Ack报文,确认通信建立。
- 上位机发送一个“通信参数协商”的报文,里面包含最大PDU长度等参数。
- PLC返回确认。
这个过程在Snap7的源码里叫做“Negotiate PDU Length”,目的是确定双方都能接受的最大数据长度。S7-1200和S7-1500默认的PDU长度可能是240字节或者480字节,具体取决于PLC的型号和配置。协商之后,双方会取一个较小的值作为后续通信的最大PDU长度。
这个协商过程很重要,因为它决定了你单次读写能传输多少数据。如果你要读取的数据量超过了协商的PDU长度,就需要分多次读取。比如你要读取1000字节的数据,而协商的PDU长度是240字节,那就需要分5次读取,每次读200字节左右。
4.2 S7报文头部结构
S7层的报文头部比TPKT和COTP都要复杂,因为它承载了更多的业务信息。一个典型的S7 Job报文头部结构如下:
| 字节偏移 | 字段名 | 长度 | 说明 |
|---|---|---|---|
| 0 | Protocol ID | 1字节 | 固定为0x32 |
| 1 | ROSCTR | 1字节 | 报文类型,Job为0x01,Ack为0x02,Ack-Data为0x03 |
| 2-3 | Redundancy ID | 2字节 | 冗余标识,通常为0x0000 |
| 4-5 | Protocol Data Unit Ref | 2字节 | PDU引用,用于匹配请求和响应 |
| 6-7 | Parameter Length | 2字节 | 参数部分的长度 |
| 8-9 | Data Length | 2字节 | 数据部分的长度 |
| 10 | Error Class | 1字节 | 错误类别,仅Ack报文中有效 |
| 11 | Error Code | 1字节 | 错误代码,仅Ack报文中有效 |
Protocol ID固定为0x32,这是S7协议的标识。ROSCTR字段标识报文的类型,Job是上位机发给PLC的请求,Ack是PLC的确认(不带数据),Ack-Data是PLC的响应(带数据)。
PDU引用是一个递增的序号,用于匹配请求和响应。你发一个Job,PDU引用是1,PLC返回的Ack-Data里PDU引用也是1,这样你就知道这个响应对应的是哪个请求。在批量读取的场景下,这个字段特别重要,因为你可以同时发多个请求,然后根据PDU引用来匹配响应。
Parameter Length和Data Length分别表示参数部分和数据部分的长度。参数部分包含了具体的功能码和地址信息,数据部分包含了实际要写入或读取的数据。在读取请求中,Data Length通常为0,因为不需要携带数据。在写入请求中,Data Length就是你要写入的数据长度。
4.3 读取请求的报文构造
读取请求是S7通信中最常用的功能。它的参数部分包含了一个Function Code(0x04表示读取)和一个Item列表,每个Item描述了一个要读取的地址区域。
一个读取请求的参数部分结构如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| Function Code | 1字节 | 0x04表示读取 |
| Item Count | 1字节 | 要读取的Item数量 |
| Item 1 | 12字节 | 第一个Item的描述 |
| Item 2 | 12字节 | 第二个Item的描述 |
| ... | ... | ... |
每个Item的结构如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| Variable Specification | 1字节 | 固定为0x12 |
| Length of Following Address | 1字节 | 后续地址部分的长度,通常为0x0A |
| Syntax ID | 1字节 | 地址类型,0x10表示S7ANY |
| Transport Size | 1字节 | 数据类型,0x01表示BIT,0x02表示BYTE,0x04表示WORD等 |
| Length | 2字节 | 要读取的数据长度 |
| DB Number | 2字节 | DB块号,如果是非DB区域则为0 |
| Area | 1字节 | 区域标识,0x81表示输入,0x82表示输出,0x83表示标志位,0x84表示DB |
| Address | 3字节 | 起始地址,按位计算 |
这个结构看起来复杂,但其实逻辑很清晰。Syntax ID固定为0x10,表示S7ANY寻址方式。Transport Size和Length共同决定了要读取的数据类型和数量。Area和DB Number决定了从哪个区域读取。Address是起始地址,注意它是按位计算的,比如DB1.DBX0.0的地址是0,DB1.DBX1.0的地址是8。
举个例子,如果要读取DB1.DBB0开始的10个字节,Item的构造如下:
- Variable Specification: 0x12
- Length of Following Address: 0x0A
- Syntax ID: 0x10
- Transport Size: 0x02 (BYTE)
- Length: 0x000A (10字节)
- DB Number: 0x0001
- Area: 0x84 (DB区域)
- Address: 0x000000 (起始地址0)
这个Item一共12字节,加上Function Code和Item Count,参数部分一共14字节。然后加上S7头部的10字节,再加上TPKT和COTP的头部,就是完整的报文。
4.4 读取响应的解析与数据提取
PLC收到读取请求后,会返回一个Ack-Data报文。这个报文的参数部分包含了每个Item的返回码和数据类型,数据部分包含了实际读取到的数据。
Ack-Data报文的参数部分结构如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| Function Code | 1字节 | 0x04表示读取 |
| Item Count | 1字节 | Item数量 |
| Item 1 Return Code | 1字节 | 返回码,0xFF表示成功 |
| Item 1 Transport Size | 1字节 | 数据类型 |
| Item 1 Length | 2字节 | 数据长度(按位或按字节) |
| ... | ... | ... |
数据部分紧跟在参数部分之后,每个Item的数据按顺序排列。需要注意的是,如果Transport Size是0x04(WORD)或者0x05(DWORD),Length字段的值是按位计算的,实际字节数要除以8。比如Length是0x0010,表示16位,也就是2字节。
解析响应的时候,首先要检查每个Item的返回码。如果返回码不是0xFF,说明读取失败,需要根据返回码排查原因。常见的返回码有:
| 返回码 | 说明 |
|---|---|
| 0xFF | 成功 |
| 0x0A | 对象不存在 |
| 0x05 | 地址越界 |
| 0x06 | 数据类型不支持 |
| 0x07 | 数据类型不一致 |
如果返回码是0x05,说明你请求的地址超出了PLC的实际范围。比如你请求读取DB1.DBB0开始的100字节,但DB1只有50字节,就会返回0x05。如果返回码是0x0A,说明你请求的DB块不存在,可能是DB号填错了。
数据提取的时候,要注意字节序的问题。S7协议中,多字节数据通常是大端序,但具体取决于数据类型。比如WORD类型是大端序,DWORD也是大端序,但REAL类型(浮点数)的字节序需要特别注意,因为IEEE 754浮点数在S7中也是大端序存储的。如果你用C#或者Python解析,需要做字节序转换。
4.5 写入请求与响应确认
写入请求的Function Code是0x05,参数部分的结构跟读取请求类似,但多了一个Data部分。每个Item的描述中,Transport Size和Length字段表示要写入的数据类型和长度,Data部分则包含了实际要写入的数据。
写入请求的参数部分结构如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| Function Code | 1字节 | 0x05表示写入 |
| Item Count | 1字节 | Item数量 |
| Item 1 | 12字节 | 第一个Item的描述 |
| ... | ... | ... |
| Data | 变长 | 要写入的数据 |
写入请求的Item描述跟读取请求基本一样,但有一个细节需要注意:在写入请求中,Transport Size字段的最高位(bit 7)表示数据的格式。如果最高位是0,表示数据是按位计算的;如果最高位是1,表示数据是按字节计算的。这个细节在构造写入报文时容易忽略,导致PLC解析错误。
PLC收到写入请求后,会返回一个Ack报文,里面包含每个Item的返回码。如果返回码是0xFF,表示写入成功。如果返回码不是0xFF,需要根据返回码排查原因。写入失败的常见原因包括地址越界、数据类型不匹配、PLC处于停止模式等。
在实际项目中,写入操作比读取操作更容易出问题,因为写入涉及到PLC的内部状态。比如你往一个正在被程序修改的DB块里写数据,可能会被PLC的程序覆盖掉。或者你写入的数据类型跟DB块里定义的不一致,PLC可能会拒绝。所以写入之前,最好先确认DB块的结构和PLC的运行状态。
4.6 批量读取与PDU长度限制
在实际的数据采集场景中,我们通常需要读取多个地址区域的数据。如果每个地址都发一个单独的请求,效率会很低,因为每次请求都有TPKT、COTP和S7头部的开销。S7协议支持在一个请求中包含多个Item,也就是批量读取。
批量读取的关键是控制好Item的数量和总数据量,不要超过协商的PDU长度。前面提到,S7-1200和S7-1500的PDU长度通常是240字节或480字节。减去S7头部的10字节、参数部分的长度(每个Item 12字节加上Function Code和Item Count的2字节),剩下的才是数据部分可用的长度。
假设PDU长度是240字节,你要读取3个Item,每个Item读取10字节,那么参数部分长度是2 + 312 = 38字节,数据部分长度是310 = 30字节,总共68字节,远小于240字节,没问题。但如果你要读取20个Item,每个Item读取50字节,参数部分就是2 + 20*12 = 242字节,已经超过240字节了,PLC会拒绝或者分片。
所以批量读取的时候,需要根据PDU长度和Item数量来动态调整。我的做法是先把所有要读取的地址列出来,然后按PDU长度分组,每组的总长度不超过PDU长度减去头部开销。每组发一个批量读取请求,收到响应后再解析。这样可以最大化利用PDU长度,减少请求次数。
提示:S7-200 Smart的PDU长度可能更小,通常是240字节。S7-1200和S7-1500可以通过协商获得更大的PDU长度,但具体值取决于PLC的配置。在Snap7中,可以通过
Cli_SetParam设置PDU长度,但最终以协商结果为准。
5. 实战调试:抓包分析与常见故障定位
5.1 抓包工具的选择与配置
调试S7协议,抓包是必不可少的。没有抓包,你只能看到“连接失败”或者“读取超时”这样的表面现象,看不到底层到底发生了什么。常用的抓包工具是Wireshark,它内置了S7协议的解析器,可以自动解析TPKT、COTP和S7层的字段。
用Wireshark抓S7报文,需要注意几点。首先,过滤器要设置好,只抓102端口的数据,否则会被其他流量淹没。过滤器表达式是tcp.port == 102。其次,如果PLC和上位机不在同一台机器上,需要在中间设备上做端口镜像,或者在上位机上抓包。如果上位机是Windows,可以用Wireshark的Npcap驱动抓本地回环流量。
抓包的时候,建议同时抓正常通信和异常通信的报文,对比着看。正常通信的报文可以作为参考,异常通信的报文可以帮你定位问题。比如你发现连接失败,可以对比正常通信的COTP连接请求,看看TSAP、TPDU大小这些参数是否一致。
Wireshark的S7解析器会把每个字段都展开,你可以直接看到Function Code、Item Count、Area、Address这些值。如果Wireshark没有自动解析,可能是端口不是102,或者报文不完整。你可以手动Decode As,选择S7COMM协议。
5.2 连接失败的排查链路
连接失败是S7通信中最常见的问题,排查链路可以按以下步骤进行:
第一步:确认TCP层是否连通。用telnet <PLC_IP> 102或者nc -zv <PLC_IP> 102测试端口是否开放。如果TCP都连不上,那后面的都不用看了,先检查网络配置、防火墙、PLC的IP地址是否正确。
第二步:抓包看COTP连接请求和响应。如果TCP连上了,但COTP握手失败,抓包会看到你发了CR报文,但PLC没有返回CC,或者返回了DR。如果PLC没有响应,可能是TSAP不对,或者PLC的通信资源被占满。如果PLC返回DR,解析DR里的原因字段,通常能直接定位问题。
第三步:检查TSAP和PLC配置。如果COTP握手失败,重点检查TSAP。S7-1200和S7-1500的PG通信TSAP是03 02,OP通信是03 01,S7基本通信是01 00。如果PLC配置里禁用了PUT/GET通信,01 00就连不上。另外,检查PLC的“连接资源”配置,确认没有超过最大连接数。
第四步:检查S7通信建立。如果COTP握手成功,但S7通信建立失败,抓包看S7层的Job报文和Ack报文。如果PLC返回了错误码,根据错误码排查。常见的错误码有“资源不足”、“功能不支持”等。
第五步:检查PDU协商。如果S7通信建立成功,但读写数据时失败,可能是PDU长度协商有问题。抓包看协商后的PDU长度,确认你的请求没有超过这个长度。
这个排查链路是我在实际项目中总结出来的,大部分连接问题都能在前三步定位。关键是要有抓包数据,没有抓包数据就只能猜。
5.3 数据读取错误的典型场景
数据读取错误比连接失败更隐蔽,因为连接是正常的,但读回来的数据不对。常见的场景有以下几种:
第一种是地址计算错误。S7的地址是按位计算的,DB1.DBX0.0的地址是0,DB1.DBX1.0的地址是8,DB1.DBW0的地址是0,DB1.DBW2的地址是16。如果你按字节计算地址,就会读错。比如你想读DB1.DBB10,地址应该是80,而不是10。这个坑我踩过好几次,后来养成了习惯,每次构造地址之前都先确认单位。
第二种是数据类型不匹配。你请求的Transport Size是BYTE,但实际数据是WORD,读回来的字节数就不对。或者你请求的是REAL类型,但按整数解析,得到的就是一个乱七八糟的值。解决方法是先确认DB块里变量的数据类型,然后选择对应的Transport Size。
第三种是字节序问题。S7协议中,多字节数据是大端序,但有些库或者框架默认按小端序解析,导致读回来的值跟预期不符。比如DB1.DBW0的值是0x1234,大端序解析是4660,小端序解析是13330。解决方法是确认库的字节序设置,或者手动做转换。
第四种是DB块优化访问的问题。S7-1200和S7-1500的DB块默认是“优化访问”的,这种DB块没有固定的偏移地址,不能按绝对地址读取。如果你要按绝对地址读取,需要在DB块的属性里取消“优化访问”,或者使用符号寻址。这个坑在从S7-300/400迁移到S7-1200/1500的时候特别常见。
第五种是PLC程序覆盖。你往一个DB块里写数据,但PLC的程序也在往这个DB块里写,结果你的数据被覆盖了。这种情况在写入操作中比较常见。解决方法是确认DB块的用途,避免跟PLC程序冲突。
5.4 性能优化的几个实用技巧
S7通信的性能优化,核心是减少请求次数和充分利用PDU长度。以下是我在实际项目中总结的几个技巧:
技巧一:批量读取。把多个地址合并到一个请求里,减少TPKT、COTP和S7头部的开销。比如你要读取10个DB块的各10个字节,如果每个DB块发一个请求,就是10个请求;如果合并成一个请求,就是1个请求。批量读取的Item数量上限取决于PDU长度,一般可以做到10-20个Item。
技巧二:合理设置PDU长度。S7-1200和S7-1500支持更大的PDU长度,但需要在S7通信建立时协商。Snap7默认的PDU长度可能是240字节,你可以通过Cli_SetParam设置更大的值,比如480字节或960字节。但要注意,PDU长度越大,单次请求的数据量越大,PLC的响应时间也可能越长。需要根据实际情况权衡。
技巧三:异步读取。如果需要读取的数据量很大,可以考虑用异步方式,同时发多个请求,然后根据PDU引用匹配响应。这样可以充分利用网络带宽,减少等待时间。但要注意PLC的最大并发连接数,不要超过限制。
技巧四:缓存不变的数据。有些数据在PLC运行过程中不会变化,比如设备型号、版本号等。这些数据可以只读一次,缓存起来,不用每次都读。这样可以减少请求次数,提高效率。
技巧五:避免频繁写入。写入操作比读取操作开销大,因为PLC需要处理写入请求并更新内部状态。如果不需要实时写入,可以合并写入请求,或者降低写入频率。比如把多个写入操作合并成一个批量写入请求。
注意:性能优化要在稳定性的前提下进行。不要为了追求速度而忽略错误处理,否则一旦通信异常,可能会导致数据丢失或者PLC状态异常。建议在优化之前先做好错误处理和重试机制。
6. 自己实现S7协议栈时容易忽略的细节
6.1 字节序与数据对齐
自己实现S7协议栈,字节序是第一个要处理的问题。S7协议中,TPKT的Length字段、COTP的引用字段、S7的PDU引用和Length字段,都是大端序。但你在代码里用的语言可能是小端序的,比如C#和Python的默认字节序就是小端序。所以每次读写多字节字段时,都需要做转换。
C#里可以用BinaryPrimitives.ReadUInt16BigEndian和WriteUInt16BigEndian,Python里可以用struct.pack('>H', value)和struct.unpack('>H', data)。这些方法比手动移位更安全,也不容易出错。
数据对齐是另一个容易忽略的问题。S7的地址是按位计算的,但实际数据是按字节存储的。比如DB1.DBX0.0到DB1.DBX0.7是第一个字节,DB1.DBX1.0到DB1.DBX1.7是第二个字节。如果你要读取DB1.DBX0.3开始的5个位,地址是3,长度是5,但实际读取的时候,PLC会返回一个字节,你需要自己从字节里提取这5个位。这个细节在读取位变量时特别重要。
6.2 超时与重试机制
S7通信是基于TCP的,TCP本身有超时和重试机制,但S7层也需要自己的超时和重试。因为PLC的响应时间可能不稳定,特别是在PLC负载较高或者网络状况不好的时候。如果没有超时机制,你的程序可能会一直等待,导致线程阻塞。
我的做法是给每个请求设置一个超时时间,比如3秒。如果超过3秒没有收到响应,就认为请求失败,然后根据情况决定是否重试。重试次数一般设为2-3次,重试间隔可以递增,比如第一次等1秒,第二次等2秒。如果重试多次仍然失败,就上报错误,让上层处理。
重试的时候要注意,不要重复发送同一个PDU引用的请求,否则PLC可能会返回两个响应,导致你匹配错误。每次重试都应该用新的PDU引用,或者先确认上一个请求已经超时,再发新的请求。
6.3 连接保持与断线重连
S7连接建立之后,如果长时间没有数据交互,PLC可能会主动断开连接。不同的PLC型号,空闲超时时间可能不同,一般是几十秒到几分钟。如果你的应用需要长时间保持连接,就需要定期发送心跳报文,比如每隔10秒发一个S7通信的保持报文,或者读一个固定的地址。
断线重连是另一个必须处理的问题。网络抖动、PLC重启、交换机故障都可能导致连接断开。你的程序需要能够检测到连接断开,然后自动重连。检测连接断开的方法有两种:一是发送请求时如果超时或者收到TCP的RST,就认为连接断开;二是定期发送心跳,如果心跳失败,就认为连接断开。
重连的时候,需要重新走一遍COTP握手和S7通信建立的过程。重连成功后,之前缓存的PDU引用和连接参数可能需要重置。我的做法是把连接管理封装成一个独立的模块,对外提供Connect、Disconnect、Read、Write这些方法,内部处理重连逻辑。这样上层业务代码不用关心底层的连接状态。
6.4 错误码的完整处理
S7协议的错误码分布在COTP层和S7层。COTP层的错误码主要在DR报文中,S7层的错误码在Ack和Ack-Data报文的Error Class和Error Code字段中。自己实现协议栈时,需要把这些错误码都解析出来,并映射成有意义的错误信息。
COTP层的DR报文里,拒绝原因通常在参数部分,比如0x01表示“不支持的TSAP”,0x02表示“资源不足”。S7层的Error Class和Error Code组合起来表示具体的错误,比如Error Class 0x81表示“应用关系错误”,Error Code 0x04表示“地址越界”。
处理错误码的时候,不要只记录错误码,还要记录错误发生时的上下文,比如请求的地址、PDU引用、时间戳等。这样排查问题的时候才有足够的信息。我通常会把错误码和上下文一起写进日志,方便后续分析。
6.5 线程安全与并发控制
如果你的应用是多线程的,多个线程同时读写S7连接,就需要考虑线程安全。S7连接是一个有状态的对象,PDU引用、连接状态、缓冲区都是共享的。如果多个线程同时操作,可能会导致PDU引用冲突、数据错乱、连接状态不一致等问题。
最简单的做法是给连接加锁,每次读写之前先获取锁,操作完成后再释放。但这样会降低并发性能,因为同一时间只能有一个线程操作连接。如果并发量不大,这种方式足够了。如果并发量较大,可以考虑用连接池,每个线程或者每个请求用一个独立的连接。但要注意PLC的最大连接数限制,不要超过。
另一种做法是用异步队列,把所有的读写请求放到一个队列里,由一个专门的线程或者任务来处理。这样既保证了线程安全,又不会阻塞调用方。调用方提交请求后,等待异步结果。这种方式在C#里可以用Task和Channel实现,在Python里可以用asyncio和Queue实现。
提示:不管用哪种方式,都要确保PDU引用的分配是线程安全的。可以用
Interlocked.Increment(C#)或者threading.Lock(Python)来保证PDU引用的唯一性。PDU引用是2字节的,范围是0-65535,用完之后会回绕。回绕的时候要注意不要跟未完成的请求冲突。
7. 从协议理解到工程落地的几点体会
搞懂S7协议的分层结构和报文格式之后,再回头看那些现成的库,你会发现很多东西都变得清晰了。比如Snap7里为什么要设置TSAP、为什么要协商PDU长度、为什么读取大块数据要分片,这些在协议层面都有明确的解释。这种“知其所以然”的感觉,是单纯调库得不到的。
在实际项目中,我一般不会自己从零实现S7协议栈,因为Snap7已经足够成熟,而且跨平台支持也好。但我会把协议知识用在排错和优化上。比如遇到连接问题,我知道该抓包看哪一层;遇到性能瓶颈,我知道该调整哪个参数;遇到数据错误,我知道该检查地址计算还是字节序。这些经验比会调几个API值钱得多。
还有一个体会是,不同型号的PLC在S7协议上的实现差异比想象中大。S7-200 Smart、S7-1200、S7-1500、S7-300/400,它们在TSAP、PDU长度、连接资源、优化访问这些方面都有各自的特点。你不能拿一个型号的经验直接套到另一个型号上。每次换型号,最好先抓包对比一下,确认参数是否一致。
最后说一个实际调试中的小技巧:如果你不确定某个参数该怎么填,就抓一次正常通信的报文,把参数抄下来。比如TSAP、PDU长度、连接类型这些,正常通信的报文里都有。这比查手册快,也比试错靠谱。当然,前提是你有一个能正常通信的环境作为参考。如果没有,那就只能查手册加试错了,但至少你知道该试哪些参数,而不是盲目乱试。