欧姆龙FINS协议详解:从报文结构到Python实现PLC通信 搞自动化的兄弟应该都有过这样一个瞬间项目做到一半突然被通知“那台欧姆龙PLC的数据你想办法读上来”。打开手册翻到通信章节一大片关于FINS协议的英文文档直接砸在你脸上。我第一次搞这东西的时候对着“节点号”“内存区代码”“FINS/TCP”这一串词纠结了一整个晚上才把一条最普通的读取报文跑通。这篇我就按自己从零啃下来的经验把欧姆龙FINS协议从原理到代码一层层剥开目标只有一个你看完不光懂还能自己在电脑上写出一个能用的通信程序。无论是给上位机系统对接PLC数据还是做数据采集网关这篇文章都适用。1. FINS协议到底解决了什么问题先说清楚FINS是什么。FINS全称Factory Interface Network Service是欧姆龙在自家PLC、温度控制器、伺服驱动器等设备之间定义的一套统一通信协议。它诞生的目的是让你不需要关心底层是串口、以太网还是Controller Link总线都能用同一套“语言”跟设备对话。对做上位机开发的人来说你真正要关心的就是我怎么构造一条指令让PLC执行“读D区”“写保持继电器”“强制某个输出”这些操作。1.1 三种常见网络载体别一上来就搞混FINS不是一个物理接口它跑在三种常见载体上这决定了你连接时用什么工具、什么端口FINS/UDP以太网传输端口9600。不需要握手发数据报就行适合追求低延迟、自己控制重传的场景。FINS/TCP以太网传输端口9600。需要先建立TCP连接再完成一次节点握手之后就是可靠的双向字节流。绝大多数上位机对接场景我建议直接用这个。FINS over EtherNet/IPFINS报文被封装在CIP协议里跑结构复杂一些一般只有需要和罗克韦尔生态互通时才会用到。新手最容易懵的就是FINS/UDP和FINS/TCP的关系。很多人以为UDP更快就一定更好其实在工业以太网这种相对可控的网络里TCP带来的好处远大于那点性能差异。不愿处理分包、粘包、重传这些破事的话直接选TCP就行。1.2 和Modbus这类协议最大的区别很多PLC都支持Modbus TCP那为什么不干脆用Modbus答案是能读但读不全。Modbus在欧姆龙PLC上能访问的存储区通常有限而且寄存器地址映射非常绕——比如欧姆龙的D区在Modbus里对应的地址是多少不同型号、不同软件版本可能都不一样。而FINS是亲儿子直接以PLC内部的地址体系为目标CIO、WR、HR、DM、定时器、计数器全都用一套方式访问甚至还能读写时钟、读取CPU状态、远程启停程序。凡是欧姆龙手册里能看到的操作FINS基本都能做。所以我的结论是如果你只是偶尔读几个温度值用Modbus也不是不行但如果你要做正经的数据采集或控制系统FINS才是正确选择。2. 一条FINS报文从头到尾是怎么组织的要理解FINS核心就是看报文结构。整个FINS报文由两部分组成10字节的固定头和命令区。命令区又细分为命令码、参数和数据。听着复杂拆开看其实就是一个非常规矩的“信封”。2.1 固定头10字节一字节都不能错固定头按顺序拆开是这样的字段长度含义典型值ICF1字节信息控制字段命令0x80响应0xC0RSV1字节保留0x00GCT1字节网关允许数0x02DNA1字节目标网络号0x00表示本地网络DA11字节目标节点号PLC的FINS节点号DA21字节目标单元号0x00表示CPU单元SNA1字节源网络号0x00SA11字节源节点号上位机自己的FINS节点号SA21字节源单元号0x00SID1字节服务标识自己定每次递增最好前三个字段基本是死的ICF固定0x80RSV固定0x00GCT固定0x02。真正需要每次动的只有四个地址相关字段DNA、DA1、DA2和SA1。DNA表示目标网络号绝大多数单PLC项目都是0x00。如果以后要通过路由跨网络访问别的PLC这个字段才会起作用到时候需要配合路由表一起配。DA1是PLC侧的网络节点号。这个节点号不是IP而是FINS层的一个逻辑编号在PLC的通信设置里独立配置。一个常见的坑是很多人以为“PLC的IP最后一个字段就是节点号”其实两者可以不一样。SA1是源节点号也就是上位机自己的FINS节点号。这个节点号要避开和PLC冲突而且必须在同一网络的节点号规则范围内。我习惯把上位机设成10以上的数值不容易和默认节点号重合。SID是服务标识。它就像快递面单上的流水号PLC会在响应里原样带回来你靠它来匹配哪条响应对应哪条请求。单线程写程序时随便固定一个数都行但如果将来做多线程并发就必须自己管理SID递增。2.2 命令码和内存区代码读写DM区怎么构造固定头之后紧跟两个字节的命令码。最常用的是0101Memory Area Read读内存区。0102Memory Area Write写内存区。0103Memory Area Fill把一片连续区域填成同一个值。0104Multiple Memory Area Read一次读多个不连续区域。以0101为例命令区的参数排列是内存区代码1字节 起始字地址2字节 起始位地址1字节 读取字数2字节内存区代码对应关系区域字访问代码说明CIO区0x30最常用的I/O通道继电器区WR工作区0x31PLC内部工作继电器HR保持区0x32断电保持继电器DM数据区0x02数据存储区模拟量、参数一般在这里注意DM区用0x02这种写法对应的是二进制访问。手册里还有一个0x82对应BCD模式访问。读的时候两个代码读出来的原始值基本一样但写的时候如果你用了0x82PLC会把你写入的数值当BCD解释数值对不上可别怪协议。所以我写程序时统一用0x02只用二进制方式读写。举个实际例子。我想读DM区地址0到49一共50个字。报文就是80 00 02 00 DA1 00 00 SA1 00 SID 01 01 ← 读取命令 02 ← DM区代码 00 00 ← 起始地址0 00 ← 位地址0 00 32 ← 读50个字PLC返回的报文结构也很固定固定头 命令码回显2字节 端代码2字节 数据区。端代码0000就表示成功数据区每个字按高位在前、低位在后的顺序返回。2.3 响应里的端代码成功失败一眼看出响应里最该关心的就是固定头后面的那2字节端代码。端代码不是随便看看的它是排查问题最重要的入口。端代码含义常见原因0000正常完成没有一切正常0101命令码错误命令码写错或该指令PLC不支持0201区域设置错误内存区代码不存在0202起始地址设置错误访问到不存在的DM区/CIO区地址0401数据过长一次读取/写入的字数超上限1101目标节点不存在PLC节点号配错或PLC离线1103目标节点无响应报文发过去了但PLC没回应我在项目里见过最多的就是0202基本都是地址越界。比如某型号PLC的DM区只有2000个字你非要读地址3000开始的数据PLC直接回0202。这种问题先看型号手册里存储区容量再对一遍地址很快就能定位。3. 先把链路打通FINS/TCP连接与握手流程报文结构看懂了接下来就是怎么把它送进PLC。这里最推荐的路由是FINS/TCP原因上面说过省心。下面我把完整流程拆开讲。3.1 为什么我优先推荐TCP而不是UDP有人会觉得FINS/UDP既然不需要连接那不就是先发一个报文试试嘛对但它有两个隐性成本第一UDP没有可靠传输。工业现场偶尔一次丢包你的上位机程序如果没有超时重发机制那一个数据点就永久卡在那里了。第二FINS/UDP报文在以太网载体上其实还额外带着一个4字节端口信息头等于你自己要维护一套“是否回复”“怎么重传”的逻辑。TCP把这些问题全部解决掉了底层会自动分包、重传、保证顺序。所以我的结论很直接除非你的应用场景对延迟极其敏感或者在同一个二层网络里已经有一整套UDP通信框架否则一律用FINS/TCP少给自己找麻烦。3.2 握手帧与节点号约定FINS/TCP不是TCP连上就能直接发命令的中间还有一个专门的“打招呼”阶段。这个阶段要交换的是节点地址数据说白了就是让PLC知道“我是谁我的FINS节点号是多少”。客户端连接PLC的9600端口后立刻发送一个16字节的握手帧前4字节ASCII字符 FINS就是0x46 0x49 0x4E 0x53。第5字节命令码0x00表示节点地址数据发送。第6字节保留0x00。第7字节上位机自己的FINS节点号。第8字节上位机单元号一般填0x00。后面8字节保留全部填0x00。PLC收到后会回一个16字节的响应帧前4字节同样是FINS第5字节回显0x00第6字节是状态第7字节带出来的是PLC自己的节点号。这里有个很容易卡住的地方我见过有些版本的PLC返回的状态字节既可能是0x00也可能是0x01都很正常。代码里别写死校验这个字节否则换个设备就崩。正确的判断标准是前4字节是FINS、第5字节是0x00就算通过。握手完成后TCP连接就进入正常通信状态。之后每次发命令直接发送固定头命令区的裸FINS报文即可不用再有任何封装。4. 从零写一个Python客户端读两个数出来讲原理不如来点能跑的。下面这个Python类可以完成FINS/TCP连接、读DM区、写DM区的操作。代码我尽量精简但保留了正确的组帧和收包逻辑。建议自己动手敲一遍这样记忆会深很多。4.1 最小可用代码直接抄import socket import struct class OmronFinsClient: def __init__(self, host, plc_node1, local_node10, port9600, timeout3.0): self.host host self.port port self.plc_node plc_node # PLC的FINS节点号 self.local_node local_node # 上位机自己的FINS节点号 self.timeout timeout self.sock None self.sid 0 def connect(self): self.sock socket.create_connection((self.host, self.port), timeoutself.timeout) # 构造16字节握手帧 handshake bytearray(16) handshake[0:4] bFINS handshake[4] 0x00 handshake[5] 0x00 handshake[6] self.local_node handshake[7] 0x00 self.sock.sendall(bytes(handshake)) resp self._recv_exact(16) if resp[0:4] ! bFINS or resp[5] ! 0x00: raise ConnectionError(FINS/TCP handshake failed) # 如果响应里带出了有效节点号就采用PLC自己的节点号 if resp[6] ! 0x00: self.plc_node resp[6] def _recv_exact(self, n): data bytearray() while len(data) n: chunk self.sock.recv(n - len(data)) if not chunk: raise ConnectionError(connection closed unexpectedly) data.extend(chunk) return bytes(data) def _header(self): sid self.sid 0xFF self.sid 1 return bytes([ 0x80, 0x00, 0x02, 0x00, self.plc_node, 0x00, 0x00, self.local_node, 0x00, sid ]) def read_dm(self, start_addr, word_count): body bytes([0x01, 0x01]) # 0101 读命令 body bytes([0x02]) # DM区 二进制访问 body struct.pack(H, start_addr) # 起始字地址 body bytes([0x00]) # 位地址 body struct.pack(H, word_count) # 读取字数 frame self._header() body self.sock.sendall(frame) # 响应长度 固定头10 命令码2 端代码2 数据2*N resp self._recv_exact(14 word_count * 2) if resp[12:14] ! b\x00\x00: code int.from_bytes(resp[12:14], big) raise RuntimeError(fFINS error: 0x{code:04X}) data resp[14:] return [int.from_bytes(data[i*2:i*22], big) for i in range(word_count)] def write_dm(self, start_addr, values): body bytes([0x01, 0x02]) # 0102 写命令 body bytes([0x02]) # DM区 body struct.pack(H, start_addr) body bytes([0x00]) body struct.pack(H, len(values)) for v in values: body struct.pack(H, v 0xFFFF) frame self._header() body self.sock.sendall(frame) resp self._recv_exact(14) if resp[12:14] ! b\x00\x00: code int.from_bytes(resp[12:14], big) raise RuntimeError(fFINS error: 0x{code:04X}) def close(self): if self.sock: self.sock.close() self.sock None用法plc OmronFinsClient(192.168.1.20, plc_node1, local_node10) plc.connect() print(plc.read_dm(0, 10)) plc.write_dm(20, [42, 100, 200]) plc.close()4.2 连不上、读不全时先打印这3个值新手跑这段代码失败时最容易瞎改。我的习惯是把三个值打印出来握手响应的前8字节看是否真的是FINS开头。响应帧的端代码看是0202还是1101。打印你实际发送的完整帧用十六进制看一眼和自己手写对照。大部分问题就藏在“我以为发送的”和“实际发送的”之间的差异。比如内存区代码写成了0x82起始地址字节序搞反了或者SID重复了但没影响。打印出十六进制帧比盯着屏幕猜有效得多。另外注意TCP是字节流一次recv不一定能收完整个响应所以代码里_recv_exact按期望长度循环收这是必须的不能省。5. 实战中躲不开的坑回复码、节点号和那些“诡异超时”代码能跑通只是第一步。真正到了现场你会发现通信问题千奇百怪有一半其实都集中在几个共性上面。下面这些坑都是我实际踩过的。5.1 节点号冲突是头号问题FINS节点号必须全网唯一。它和IP地址是两个体系你光看IP很难判断问题。某次调试上位机连PLC怎么都握手失败抓包一看PLC回了个0x02状态表示失败。后来排查发现上位机自己的节点号设成了1和PLC默认节点号撞了。解决办法很简单上位机节点号设置成一个固定的大号比如10、20、30。习惯上很多厂商默认节点号是1你只要避开常见默认值冲突概率就很低。5.2 端口通但握手失败先怀疑节点号现场最常见的情况是ping能通telnet IP 9600也能连上但程序就是握手失败。这时候两步走用抓包工具确认TCP是否真的建立了三次握手。看握手响应帧第7字节返回的PLC节点号是不是和你配置文件里写的一致。有一次我在某车间调试PLC里实际配置的节点号是2但上位机配置文件里写的是1。数据一直读不到每次都是1103超时。抓包发现TCP连接正常握手也正常但命令帧里的DA1是1自然没人理。5.3 诡异的超时多半是连接没释放还有一类问题最让人抓狂程序刚启动的时候好好的跑几个小时突然全部超时。这种大概率是PLC的以太网单元TCP连接数被占满了。有些上位机程序每采集一次就connect一次读完就close。但TCP断开是四次挥手PLC侧连接不会立刻消失加上有的程序异常退出没释放过一段时间连接表就满了。PLC默认允许同时连接的TCP会话数很有限超了之后新的握手直接被拒。解决思路就一条用长连接别频繁重连。程序启动时连接一次持续复用真要重连也必须加正常的close流程。除了连接数还要注意PLC的“保持连接超时”。如果长时间不发任何命令PLC可能主动断开空闲连接。所以长连接最好配合心跳机制每1到2秒发一条轻量命令比如读一个字的DM区让连接一直保持活跃。5.4 回复码表要贴在自己电脑旁边把上文的端代码表打印出来或者存成文件放身边。排查时优先看它。我调试时经常就是左手抓包软件右手端代码表绝大多数问题半小时内能定位。别看不起这份小表格它比任何高级调试技巧都实用。6. 从“能通”到“好用”长连接、批量读和运维建议协议跑通是及格线真正做产品化还有很多细节要做。这一节说几个能直接提升可靠性和开发效率的点。6.1 批量读取别一条条发FINS支持一次读多个字比如一次读100个字的DM区也就返回200字节数据耗时和读1个字基本一样。所以凡是连续的地址尽量用一次大范围读取替代多次小范围读取。一次通信量的上限要参考PLC手册通常一次读取的字数有上限比如几百个字别超过就行。如果数据不连续比如分布在DM区、HR区和CIO区就用0104多区读取命令一次报文里带多个区域描述。它的构造比0101复杂一点但原理一样就是每组地址后面再带一组。6.2 多线程场景要给SID加锁如果你用多线程同时向同一个socket发命令SID就必须要管理好。两个线程同时发命令SID一样回来的时候你就分不清哪个响应对应哪条请求。要么全局只用一条socket加锁顺序发送要么每个线程独立一条连接。我建议优先选后者为每个采集线程维护独立连接逻辑简单得多。6.3 线上运维建议记录这四样日志做数据采集这行最怕的就是“数据偶尔断一下又自己恢复”。这种间歇性问题只能靠日志还原现场。我的日志最少包含四个字段时间戳精确到毫秒。请求内容命令码、目标地址、读取长度。响应状态端代码或者超时标记。耗时从发报到收到响应的时间。有了这些下次再有人跟你反馈“数据卡了五分钟”你就能拉出日志看那五分钟里是超时还是重试是某一台PLC慢还是整体慢。别嫌记录日志麻烦这种问题排查一次的成本够你写一年的日志模块了。如果你是在Windows上做快速验证也可以直接抓包软件过滤tcp.port 9600把一整套握手和读写流程抓出来。看一遍真实抓到的十六进制数据对照本文的报文结构你会发现自己对这个协议的理解瞬间又深了一层。我在实际项目里就是这么干的先看一遍抓包再写代码成功率比闭眼写高太多了。