做工业物联网这一行,最绕不开的就是和西门子PLC打交道。我最近半年都在折腾安卓设备直接走以太网通讯采集PLC数据的项目,期间加了几个同行群,发现一个特别共性的需求:大家都在找能兼容西门子全系PLC的安卓通讯方案——S7-300、S7-400、S7-1200、S7-1500,甚至老旧的S7-200都想在一个APP里搞定。
市面上不是没有商业控件,但收费是真贵,而且很多还是底层封装的黑盒,出了问题只能找售后。我自己踩了不少坑之后,手头正好沉淀了一套自研的安卓APP源码,底层通讯基于S7协议族实现,可以直接编译运行。今天不写虚的,把协议拆解、架构设计、实操踩坑全部分享出来,同行可以直接拿去参考。
1. 项目背景与需求拆解
1.1 从现场HMI到安卓屏,为什么需要一套自研通讯方案
很多工厂现场的HMI(人机界面)要么是西门子原装屏,要么是第三方组态屏。原装屏稳定性没问题,但价格贵,而且远程监控、数据上云这些需求一出来,原装屏的扩展性就捉襟见肘了。第三方组态屏呢,虽然支持不少PLC,但安卓生态下的定制化需求——比如把设备状态直接推送到手机、大屏看板、边缘计算等——传统组态屏基本做不了。
安卓设备最大的优势是生态和算力。一台几百块的安卓工控屏,性能比传统HMI强出好几个档次,还能跑MQTT、Modbus网关、Web服务等其它服务。但问题在于,安卓本身没有原生的PLC通讯库,Java和Kotlin生态里能找到的开源方案屈指可数,很多还只支持三菱、Modbus,西门子的支持往往不完整。
我做这套方案的核心思路就一个:用原生Java Socket实现S7协议的完整通讯链路,把对PLC的读写封装成一个独立的通讯引擎,APP上层只关心点位映射和业务逻辑,不关心协议细节。
这里要强调一个背景问题:很多同行一上来就找商业控件,其实如果只做数据采集和点位读写,S7协议本身并不复杂,自己实现完全可行。难的是把各种型号的差异、TSAP参数、PDU协商这些细节处理好。这部分如果没经验,确实要走不少弯路。
1.2 通讯方案的选型路线:S7协议族和它的变体
西门子PLC的以太网通讯,核心是S7协议。但所谓“全系支持”,并不意味着所有型号都走同一条路,必须先把协议族梳理清楚:
S7-300和S7-400是经典的模块化PLC,走ISO-on-TCP,抓包能看到TPKT头加COTP头,然后在上面承载S7协议。这里有个关键参数叫TSAP,全称是Transport Service Access Point,相当于传输层的“门牌号”,不同的机架号和插槽号会对应不同的TSAP值。
S7-1200和S7-1500是新一代PLC,同样走S7协议,但是TSAP的默认值和300/400不一样,握手流程里对PDU长度的要求也更高。S7-1500对通讯并发量、报文规范都更严格,稍不规范就会报错。
最特殊的是S7-200。它原生走的是PPI协议,串口居多,以太网模块出现之后才有了用PPI协议跑在以太网帧里的方案。到了S7-200 SMart,又改成了类似S7-300的以太网通讯方式。所以要兼容S7-200,必须单独处理PPI协议,或者通过PLC侧的Modbus模式来曲线救国。
我的方案设计是这样的:主体实现标准S7协议,覆盖S7-300/400/1200/1500;对S7-200和S7-200 SMART,给出单独的分支处理,其中S7-200 SMART走标准S7,老款S7-200走PPI协议适配。这个分类思路决定了后续整个代码结构。
1.3 方案目标与技术边界
做了这么长时间,我给自己定的目标是:
- 支持绝对寻址方式下的位读写和字节/字/双字读写,覆盖M区、DB区、I区、Q区。
- 支持单个点位读取和批量读取,批量读取可以大幅提高采集效率。
- 支持通讯超时、重连机制,满足现场长时间运行的要求。
- 代码结构清晰,通讯层和业务层完全分离,方便移植到其他平台。
技术边界要讲清楚:这套方案是数据采集和点位控制级别的通讯,不是西门子编程软件级别的全功能覆盖。不要指望用它能下载程序、在线监控梯形图,那些要用专业工程软件。它能做的是把PLC里的数据读出来、写进去,这是绝大多数工业物联网项目的核心诉求。
2. S7通讯协议核心拆解
2.1 报文结构:TPKT、COTP和S7三层
很多人拿到S7协议的报文第一反应是“这什么玩意,看不懂”。其实把层次拆开就清楚了。S7协议走TCP时是标准的四层结构,应用层从底往上分别是TPKT、COTP、S7。
TPKT是4个字节的传输层封装,首个字节固定0x03,第二个字节固定0x00,后两个字节表示整个TPKT报文的总长度。COTP是ISO传输层协议,在S7通讯里主要有两类:一类是连接建立时的CR和CC报文,用于携带TSAP信息;另一类是数据传输时的DT报文,PFG格式,前两个字节固定0x02 0xF0,用于承载S7报文。S7层才是真正的西门子应用协议,头10个字节是固定的S7 Header,里面有协议ID(固定0x32)、ROSCTR类型、PDU引用号、参数长度和数据长度等字段。
我整理了一个S7报文关键字段表,方便对照抓包分析:
| 字段 | 长度 | 说明 |
|---|---|---|
| 协议ID | 1字节 | 固定0x32,标识S7协议 |
| ROSCTR | 1字节 | 消息类型,0x01为Job,0x02为ACK,0x03为ACK-Data,0x07为用户数据 |
| 冗余 | 2字节 | 固定0x0000 |
| PDU引用 | 2字节 | 请求响应匹配用,相当于序列号 |
| 参数长度 | 2字节 | 参数区的字节数 |
| 数据长度 | 2字节 | 数据区的字节数,注意是不包含参数区的 |
ROSCTR是最容易看错的地方。读请求和写请求都是Job消息,PLC返回的结果如果是确认响应,可能是ACK或ACK-Data。ACK-Data在PLC有数据返回时出现,比如读操作的结果。判断是否通讯成功,除了看TCP层是否收到响应,还要看S7层的返回值字段,0x00表示成功,0x01表示硬件错误,0x02表示对象不存在,0x03表示数据长度错误,0x05表示地址错误,0x06表示写保护。
2.2 握手环节:TSAP参数与PDU协商
连接PLC不是TCP一通就完事,还要过“三道关”:TPKT封装、COTP连接请求、S7协商通讯。前两步合起来就是握手,关键在TSAP。
TPKT加COTP连接请求(CR报文)会携带本地的TSAP和远程的TSAP。我用一个实际例子来说明:S7-300,机架0插槽2的情况下,本地TSAP常设为0x0100(对应本地端口),远程TSAP设为0x0102(机架号乘0x100加插槽号再加0x01)。S7-1200和S7-1500更简单,默认本地TSAP是0x0101,远程TSAP是0x0102或0x0101,但要注意不同固件版本有差异。S7-400的默认布局又不一样,插槽号往往在3或4。
TSAP填错的最典型现象是:TCP连接能建立,但COTP握手失败,PLC直接断开连接。排查的时候不要只盯着IP地址和端口号,TSAP才是真正的幕后黑手。
PDU协商是握手阶段另一个关键动作。S7协议里PDU(Protocol Data Unit)是单次通讯能传输的最大数据长度。PLC型号不同,最大PDU长度也不同,S7-300很多只有240字节,S7-1200一般能到960字节,S7-1500能到960甚至更高。客户端在协商请求里会带上自己期望的PDU长度,PLC会返回它允许的长度,实际使用的PDU长度取两者中的较小值。
如果忽略PDU协商,直接按960字节长度去请求S7-300的数据,PLC会直接拒收响应或者返回错误。我的经验是:协商阶段统一请求最大长度,然后以PLC响应中的长度为准,后续所有读写的PDU长度都不能超过这个值。这个值还决定了单次批量读取最多能读多少个字节,算一下很有帮助。
2.3 读写指令:功能码与参数区、数据区
S7协议的读指令功能码是0x04,写指令是0x05。以读为例,Job消息的参数区结构是:功能码(0x04)、Item计数(通常为1)、然后是Item内容。
一个Item包含规格号(0x12)、长度(0x0A)、寻址模式(0x10表示绝对寻址)、传输大小、DB号(仅DB区使用)和数据地址。传输大小代码有讲究:位是0x01,字节是0x02,字是0x04,双字是0x06。地址部分一共4个字节,包含面积区标识和偏移,面积区0x81是M区、0x84是DB区、0x80是I区、0x82是Q区。
这里有一个容易出错的细节:地址解析时,位地址如M10.0,需要把字节偏移和位偏移分别处理,实际报文里有一个专门的位序号字段。很多初写者用整型直接算地址,结果读出来数据总是不对,就是这个细节没处理好。
写指令的结构和读指令有差异。写请求的Item里不是完整的目标地址描述,而是分两部分:前面是寻址参数,后面跟的是要写的数据本身。写数据部分前两个字节表示数据长度,然后是数据内容,如果要写位或字节,数据部分还需要按字对齐,不足一个字的高位补0。这个补位逻辑如果漏掉,PLC会报数据长度错误。
2.4 S7-200的特殊处理:PPI的帧格式差异
为什么单独把S7-200拿出来说?因为它的通讯协议和S7-300/400/1200/1500完全不同。S7-200没有以太网口时用串口走PPI,数据帧格式是类似Modbus的RTU帧,里边承载的是PPI报文。S7-200 SMART虽然升级了以太网口,但通讯协议已经接近S7-300。
我做的方案里,S7-200的兼容分成两条路:
老款S7-200带以太网模块,模块本身做了PPI-over-IP的映射,这种情况下客户端可以模拟PPI协议,直接通过TCP发送PPI帧。PPI帧的格式比较简单:起始符0x68、长度、重复长度、目标地址、源地址、功能码、数据、校验和。这里的校验不是CRC,而是简单的累加和校验,按字节求和取低八位。
S7-200通过Modbus RTU从站方式接入:如果PLC侧程序启用了Modbus从站库,安卓端按Modbus TCP协议直接读写,这是绕开PPI最省事的方式。很多老工程师一听到S7-200就头疼,其实走Modbus往往是更稳定的路线。
3. 自研APP的架构设计与模块划分
3.1 通讯引擎层:连接池与读写线程模型
安卓APP里对接PLC,最忌讳的就是在UI线程里直接做Socket读写。我在设计通讯引擎时,把所有网络操作封装在独立的线程池里,UI层通过回调接口拿结果。
通讯引擎的核心是一个S7Client类,它负责:TCP连接、COTP握手、PDU协商、读请求、写请求、断开连接。连接失败或通讯超时时,内部自动触发重连逻辑,重连间隔可以配置,一般建议3到5秒。这个类本身是无状态的,可以被多个任务复用,但我实际测试下来,同一时间只跑一个连接实例是最稳的,多个实例同时连同一个PLC,PDU引用号匹配容易乱。
线程模型上,我用了一个专有读写线程加一个定时任务调度器。定时任务调度器按照PLC类型和轮询周期,决定当前周期需要发送哪些点位读取请求。读到的数据通过消息队列分发给UI层,而不是直接在回调里更新UI,避免频繁的界面刷新造成卡顿。
3.2 点位管理层:地址映射与配置结构
点位管理是这套APP最实用的部分。我在数据库里放了一张点位映射表,字段包括点位逻辑名、PLC型号、面积区类型(M、DB、I、Q)、DB块号、字节偏移、位偏移、数据类型(位、字节、字、双字、实数)。UI层绑定的是逻辑名,比如“一号泵温度”,底层根据映射关系换成S7协议的报文地址。
这种设计带来一个很大的好处:现场PLC地址变了,不用改APP代码,直接在配置界面里改点位映射表就行。比如原来温度点在DB1.DBW0,后来改到DB1.DBW2,界面上改一下地址,保存后生效。
点位配置还支持分组和启用开关。分组可以按工艺流程分,比如“预热段”“反应段”“冷却段”,每组独立设置轮询周期。启停开关用于临时摘除故障点位,不影响整体通讯链路。
3.3 数据展示层:绑定机制与实时刷新策略
数据展示层我用的是数据绑定机制,观察者模式,点位值变化时自动通知绑定的控件更新。实时刷新策略需要讲究一下:不是所有数据都要高频刷新。
我的做法是按变量类型分轮询周期:高速量(设备启停状态、报警信号)300到500毫秒刷一次;中速量(温度压力流量)1到2秒刷一次;低速量(累积量、统计数据)5到10秒刷一次。轮询周期分开后,通讯负载大幅下降,PLC侧的CPU占用也降下来了。实际测试里,一台S7-1200挂了120个点位,快慢周期混跑,CPU占用不到5%。
UI展示上还加了一个数据质量标记,每次轮询超时或失败时,点位状态置为“异常”,控件直接置灰或变成警示色,而不是显示上一次的旧值。这个细节很多商用组态都做得不够好,但对现场维护来说太重要了。
4. 实操过程与核心环节实现
4.1 环境准备与开发工具选择
开发环境无非是Android Studio,SDK版本建议API 26以上,用Java还是Kotlin都行。我自己技术栈偏Java,所以通讯层用Java写,上层的业务界面用Kotlin写,两者混编在安卓里没有问题。
调试工具强烈推荐准备两个:一个是网络抓包工具,用于查看TCP报文,确认识别TSAP、PDU协商这些参数;另一个是西门子官方或第三方的仿真PLC工具,用于在没有实体PLC时先跑通通讯逻辑。我前期用模拟器调试,后期到现场用真机验证,效率高很多。
测试阶段建议先用真实的S7-1200或S7-1500实体PLC,这两个型号最常用,而且协议层面的约束更多,能暴露的问题也最全面。S7-300和S7-400的老型号协议的容错空间稍大,能跑通1200/1500,基本跑这些也没问题。
4.2 连接PLC的完整流程与关键代码
整个连接流程分四步:TCP连接、COTP握手、S7协商、进入读写循环。我用Java代码把这四步串起来。
第一步建立TCP连接,设置超时时间,我一般设5000毫秒。超过这个时间PLC还没响应,直接判定连接失败,进入重连流程。
Socket socket = new Socket(); socket.connect(new InetSocketAddress(plcIp, 102), 5000); socket.setSoTimeout(5000);第二步发送COTP连接请求,这里要构建TPKT和COTP CR报文。TPKT长度按实际报文长度算,COTP CR里携带本地TSAP和远程TSAP。不同型号的TSAP值我在前面章节已经讲了,这里给一个S7-300/400的通用计算方式:
// 本地TSAP:0x0100 + 机架号 * 0x0100 + 插槽号 int localTsap = 0x0100 + rack * 0x0100 + slot; // 远程TSAP:0x0100 + 机架号 * 0x0100 + 插槽号 int remoteTsap = 0x0100 + rack * 0x0100 + slot;注意S7-1200和S7-1500不需要按机架插槽计算,固定用默认TSAP就可以。这一步踩坑最多,我在5.1节详细说。
第三步S7协商通讯,发送SetupCommunication请求,请求PDU长度为960字节,然后从响应中解析PLC返回的最大PDU长度,后续所有读写报文长度都不得超过这个值。
第四步就是读写了。我封装了一个读字节区域的方法,核心是构造0x04功能码的读取报文,代码片段如下:
private byte[] buildReadRequest(Area area, int dbNumber, int byteOffset, int bitOffset, int size, int pduLength) { ByteBuffer buf = ByteBuffer.allocate(31).order(ByteOrder.BIG_ENDIAN); // TPKT头 buf.put((byte)0x03); buf.put((byte)0x00); buf.putShort((short)31); // COTP头 buf.put((byte)0x02); buf.put((byte)0xF0); buf.putShort((short)0x8000); // S7头 buf.put((byte)0x32); buf.put((byte)0x01); buf.put((byte)0x00); buf.put((byte)0x00); buf.putShort((short)0x0001); buf.putShort((short)0x0000); buf.put((byte)0x00); buf.put((byte)0x08); buf.putShort((short)0x0000); // 参数区开始 buf.put((byte)0x04); // 功能码READ buf.put((byte)0x01); // Item数 buf.put((byte)0x12); // 规格号 buf.put((byte)0x0A); // 长度 buf.put((byte)0x10); // 寻址模式: 绝对寻址 buf.put((byte)size); // 传输大小 buf.putShort((short)task.dbNumber); // DB号 buf.put((byte)area.code); // 面积区 buf.put((byte)byteOffset); buf.put((byte)0x00); buf.put((byte)task.bitOffset); return buf.array(); }这段代码里最需要注意的是字节偏移和位偏移的组装方式。很多开源的实现是直接用一个整型数表示地址,然后字节位都靠位运算,容易错。我这样分开传参,上层点位配置的时候清清楚楚,不会产生歧义。
4.3 点位读写与轮询调优
点位读取的核心是批量读取,一次请求读取多个连续地址区域,效率提升非常明显。比如轮询一组温度传感器,地址是连续的,我可以一次把所有字节读回来,解析后拆成多个点位值。这比一个一个点位请求快上十几倍。
轮询调度器我用的是优先级队列加时间轮思想。每个点位组记录自己的下次执行时间,调度器每次取时间最近的请求发送。请求放到通讯引擎的发送队列里,响应回来后解析并分发。这里的经验值是:轮询周期不要太激进,一般300毫秒是下限,再快的话PLC的通讯模块压力会变大,实测S7-1500能扛住,但S7-300老型号会变成处理不过来的状态。
写操作相对读要谨慎很多。我的设计里,写操作不参与轮询,只由用户主动触发,而且每次写之前会做数据类型校验和范围校验,防止把非法值写进PLC。对于关键控制类的写入,我还做了一层二次确认,需要用户30秒内二次点击,这个看起来烦,但能有效防止误操作。
5. 常见问题与排查技巧实录
5.1 连接建立阶段的典型故障
这一阶段列一个速查表,都是我现场遇到过的真实问题:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| TCP连接超时 | 网络不通、PLC未开启以太网通讯 | 先ping PLC的IP,再用PC端抓包软件确认102端口是否响应 |
| TCP能连但立即断开 | TSAP参数错误 | 按PLC型号核对本地和远程TSAP,S7-300/400重点检查机架号和插槽号,S7-1200/1500确认默认值 |
| COTTP握手成功但S7协商失败 | PLC通讯资源被占用 | 检查PLC在线连接数,有些型号允许的并发连接数有限,关掉无关连接再试 |
| 连接成功但重连后失败 | 上一次连接未正常释放 | 客户端断开时发送关闭报文,确保PLC端释放连接资源 |
TSAP问题是最隐蔽的,我遇到过一台S7-300设备,在车间里一台能连一台不能连,最后发现两台PLC的硬件组态里模块插槽位置不同,导致TSAP不同。所以做项目时,一定要搞清楚每台设备的实际组态,不能拿着一个默认值通吃所有设备。
5.2 读写异常与数据错误排查
读写数据出错是最烦人的,因为TCP连接明明好好的,读回来的数据却不对。这类问题大多出在报文组装和解析上。
读取返回值为空但连接正常,大概率是目标地址不存在。确认点位地址是否真的存在于PLC里,DB块是否存在,偏移是否超出数据块长度。如果PLC返回错误代码,0x05是地址错误,0x07是写保护,对照表格处理就好。
位和字节的解析错误也常见。S7协议里位地址在报文中有专门的位置,我在3.2节讲过。如果读回来的位值总是反的或者错位,去检查报文中的位偏移字段是否和组态里一致。字节偏移超过128时,要留意PDU长度限制,超过后就得分帧读取。
还有一个经典的坑是实数还原。PLC里的REAL类型是32位浮点,很多安卓通讯库里解析的时候大小端搞反了,导致读回来的温度变成天文数字。我的方案里统一用大端解析,收到字节后用ByteBuffer的order方法指定BIG_ENDIAN,然后再转float,确保和PLC侧保存方式一致。
5.3 通讯稳定性问题的工程处理
现场通讯最大的敌人是电磁干扰和网络抖动。我做过一套设备,在工控柜旁边放了变频器,变频器启动的瞬间,无线网络直接卡断,PLC通讯也出现间歇性超时。
安卓设备走有线网口的时候,问题相对少;走WiFi时,稳定性考验很大。我的建议是现场优先用工业级路由器的有线端口,WiFi只用于调试。如果只能用WiFi,关闭安卓设备的WiFi省电模式,固定IP,不要用DHCP自动获取,减少网络重连的概率。
通讯层还要做断线重连和心跳检测。心跳机制我用的是周期性的读取某个固定点位的值,连续三次超时才判定连接断开,然后触发重连。不要用TCP上的ping或者Socket自带的keepalive,那些在工业环境下并不可靠,S7层面的心跳才是最实在的。
每次重连之后,还需要重新执行COTP握手和PDU协商,这个状态重置逻辑一定要做好。我见过不少开源方案,第一次连接没问题,PLC断电重启后客户端连不回来,就是状态机没有正确回到初始状态导致的。
6. 源码获取与后续扩展方向
6.1 源码结构一览与引入建议
这个APP源码我整理成了标准Android Studio工程,核心模块分了三层:core层放S7Client和协议构建解析;data层放点位配置管理和数据库;ui层放数据展示和配置界面。
可以直接在Android Studio里打开工程,改一下PLC的IP地址和点位配置就能跑起来。建议先连一台S7-1200或S7-1500,把基础的读取流程跑通,再逐步扩展其它功能。源码里我预留了日志开关,打开后可以打印完整的收发报文,方便排查问题。
引入自己的项目时,只需要把core层的S7Client拷过去就行,不依赖其它第三方库。这个设计是为了保证通讯层足够独立,方便移植到其它Java平台。
6.2 从S7通讯引擎到边缘网关的扩展思路
有了这套安卓端S7通讯引擎,能做的事其实很多。我自己的规划是往边缘网关方向扩展:在通讯引擎之上叠加MQTT模块,把采集到的点位数据按固定周期推送到云端平台;叠加本地存储模块,断网时数据先落SQLite,恢复后补传;叠加规则引擎,在本地做报警判断,阈值触发后直接推送消息。
更进阶的玩法是做协议转换:安卓设备充当一个协议网关,S7协议收到的数据,转成Modbus TCP协议暴露给其它系统。这样的话,老的S7-200也能融入新的物联网架构中。
另外想提醒一下,做这类工业项目,最好把采集的数据打上时间戳和质量戳,这个是和MES、SCADA对接时的硬性要求。我的源码里已经包含数据质量戳的实现,直接用就行。
最后说点实在的:我做这套方案的过程中,最大的体会是协议细节一定要吃透,尤其是PDU协商和TSAP这两个点。很多商业控件的稳定秘诀,说白了就是在这些容易错的地方做足了防御。如果你在集成过程中遇到问题,先抓包看报文,逐字段核对,八成问题都能定位。安卓采集西门子PLC这条路,只要把协议层打磨扎实,后面扩展什么功能都顺。