☰
欧姆龙FINS协议详解:从报文结构到Python实现PLC通信
2026/10/13 5:08:55 网站建设 项目流程

搞自动化的兄弟应该都有过这样一个瞬间:项目做到一半,突然被通知“那台欧姆龙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/IP:FINS报文被封装在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,响应0xC0
RSV1字节保留0x00
GCT1字节网关允许数0x02
DNA1字节目标网络号0x00表示本地网络
DA11字节目标节点号PLC的FINS节点号
DA21字节目标单元号0x00表示CPU单元
SNA1字节源网络号0x00
SA11字节源节点号上位机自己的FINS节点号
SA21字节源单元号0x00
SID1字节服务标识自己定,每次递增最好

前三个字段基本是死的:ICF固定0x80,RSV固定0x00,GCT固定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区怎么构造

固定头之后,紧跟两个字节的命令码。最常用的是:

  • 0101:Memory Area Read,读内存区。
  • 0102:Memory Area Write,写内存区。
  • 0103:Memory Area Fill,把一片连续区域填成同一个值。
  • 0104:Multiple Memory Area Read,一次读多个不连续区域。

以0101为例,命令区的参数排列是:

内存区代码(1字节) + 起始字地址(2字节)+ 起始位地址(1字节)+ 读取字数(2字节)

内存区代码对应关系:

区域字访问代码说明
CIO区0x30最常用的I/O通道继电器区
WR工作区0x31PLC内部工作继电器
HR保持区0x32断电保持继电器
DM数据区0x02数据存储区,模拟量、参数一般在这里

注意DM区用0x02这种写法,对应的是二进制访问。手册里还有一个0x82,对应BCD模式访问。读的时候两个代码读出来的原始值基本一样,但写的时候如果你用了0x82,PLC会把你写入的数值当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_node=1, local_node=10, port=9600, timeout=3.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), timeout=self.timeout) # 构造16字节握手帧 handshake = bytearray(16) handshake[0:4] = b'FINS' 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] != b'FINS' 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(f'FINS error: 0x{code:04X}') data = resp[14:] return [int.from_bytes(data[i*2:i*2+2], '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(f'FINS error: 0x{code:04X}') def close(self): if self.sock: self.sock.close() self.sock = None

用法:

plc = OmronFinsClient('192.168.1.20', plc_node=1, local_node=10) plc.connect() print(plc.read_dm(0, 10)) plc.write_dm(20, [42, 100, 200]) plc.close()

4.2 连不上、读不全时先打印这3个值

新手跑这段代码失败时,最容易瞎改。我的习惯是把三个值打印出来:

  1. 握手响应的前8字节,看是否真的是FINS开头。
  2. 响应帧的端代码,看是0202还是1101。
  3. 打印你实际发送的完整帧,用十六进制看一眼,和自己手写对照。

大部分问题就藏在“我以为发送的”和“实际发送的”之间的差异。比如内存区代码写成了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也能连上,但程序就是握手失败。

这时候两步走:

  1. 用抓包工具确认TCP是否真的建立了三次握手。
  2. 看握手响应帧第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,把一整套握手和读写流程抓出来。看一遍真实抓到的十六进制数据,对照本文的报文结构,你会发现自己对这个协议的理解瞬间又深了一层。我在实际项目里就是这么干的:先看一遍抓包,再写代码,成功率比闭眼写高太多了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询