FINS协议与框架实战:欧姆龙PLC数据采集与避坑指南 简介FINS-Framework.zip 是一套面向工业自动化开发者的欧姆龙PLC通信协议实现资源基于C语言构建适用于需要远程监控、数据采集及程序上传下载的工控项目。FINS协议支持TCP/IP与RS-485串行通信框架涵盖控制报文、命令报文与响应报文的构建解析并提供fins_open、fins_read、fins_write、fins_close等API接口及错误处理机制帮助开发者快速集成设备通信功能。压缩包共610个文件约17.32MB以135个c源文件与91个h头文件为核心辅以makefile、cfg配置、mk构建脚本、txt说明及示例代码目录结构完整便于跨平台移植与二次开发。目前已有312人学习下载适合具备一定C语言与工控基础的开发者参考通过示例代码与文档可掌握PLC寄存器读写、I/O操作及系统诊断等关键技能。1. FINS 协议框架到底解决什么问题从一台欧姆龙 PLC 的调试现场说起车间里一台欧姆龙 CJ2M 挂在产线上上位机要读它的 DM 区、CIO 区还要往定时器里写设定值。很多人第一反应是装厂商驱动、用现成组态软件拖点可一旦要自己写采集程序、做边缘网关、把数据塞进 MES就绕不开一个词FINS 协议。FINSFactory Interface Network Service是欧姆龙为其 PLC 与上位设备之间定义的一套命令/响应式通信协议跑在以太网、串行链路、Controller Link 等多种承载之上核心价值是「用一条命令读写指定内存区」。而 FINS-Framework 这类框架本质是把 FINS 报文的拼装、发送、超时重试、地址解析封装成可调用的 API让你不用手撸字节序和命令码。它适合做设备数据采集、产线联网、上位机自研的工程师不适合只想点几下鼠标出报表的人。下面按「协议怎么立住 → 框架怎么跑通 → 坑在哪 → 怎么进阶」推一遍。2. FINS 报文结构与内存区寻址先搞懂命令码和地址编码2.1 FINS 以太网帧的三段结构FINS over Ethernet 的报文不是裸的 FINS 命令而是套了两层壳。最外层是 TCP 或 UDP 承载中间是 FINS/TCP 或 FINS/UDP 头最里面才是 FINS 命令帧。以 FINS/TCP 为例一个完整请求由三部分组成FINS/TCP 头固定 16 字节含长度、命令码、错误码、FINS 头含 ICF、RSV、GCT、DNA、DA1、DA2、SNA、SA1、SA2、SID 共 10 字节、FINS 命令数据区主命令码 MRC、子命令码 SRC、以及参数。这里最容易翻车的是 FINS 头里的 DA1目标网络号、DA2目标节点号、DNA目标单元地址。很多新手直接填 0在单机直连时能通一旦跨了以太网单元或做了路由就全部超时。常见做法是同一网段直连 PLC 内置以太网口时DNA0、DA10、DA2PLC 的 IP 末段节点号如果经过 ETN 单元或跨网DA1 要填目标网络号DA2 填目标节点号DNA 填单元地址CPU 单元通常为 0。字段长度含义直连典型值ICF1B信息控制字段0x80RSV1B保留0x00GCT1B网关允许数0x02DNA1B目标网络号0x00DA11B目标节点号PLC IP 末段DA21B目标单元地址0x00SNA1B源网络号0x00SA11B源节点号上位机 IP 末段SA21B源单元地址0x00SID1B服务 ID自增即可2.2 内存区地址怎么编码成命令参数FINS 读写命令的核心是「区域代码 起始地址 位数」。读 DM 区用主命令码 0x01、子命令码 0x01MEMORY AREA READ写 DM 区用 0x01/0x02MEMORY AREA WRITE。区域代码里DM 区是 0x82CIO 区是 0xB0WR 区是 0xB1定时器/计数器是 0x09。起始地址用 2 字节小端表示位数用 2 字节小端表示按位还是按字取决于区域。举个具体例子读 DM100 开始的 10 个字。区域代码 0x82起始地址 100 0x0064小端写成 64 00位数 10 0x000A小端写成 0A 00。拼出来的命令数据区就是01 01 82 00 64 00 00 0A。写的时候多一段数据体比如往 DM100 写 0x1234就是01 02 82 00 64 00 00 01 34 12。注意数据体也是小端写多字时按字顺序排列。import struct def build_read_dm(start_addr, count): # 主命令码 0x01子命令码 0x01区域代码 0x82DM mrc, src, area 0x01, 0x01, 0x82 # 起始地址和位数均按小端 2 字节打包 addr_bytes struct.pack(H, start_addr) count_bytes struct.pack(H, count) return bytes([mrc, src, area]) addr_bytes count_bytes def build_write_dm(start_addr, values): mrc, src, area 0x01, 0x02, 0x82 addr_bytes struct.pack(H, start_addr) count_bytes struct.pack(H, len(values)) # 每个字按小端打包后拼接 data b.join(struct.pack(H, v) for v in values) return bytes([mrc, src, area]) addr_bytes count_bytes data print(build_read_dm(100, 10).hex( )) # 输出: 01 01 82 64 00 0a 00上面这段代码只拼了 FINS 命令数据区实际发送时还要在前面补 FINS 头和 FINS/TCP 头。参数说明start_addr是十进制地址函数内部转小端count是字数不是字节数values里每个元素是一个 16 位字。很多人在这里把「字数」当成「字节数」填结果读回来的数据少一半这是血泪经验里排前三的坑。2.3 响应帧怎么判断成功与解析数据响应帧的结构和请求对称FINS/TCP 头 FINS 头 响应数据区。响应数据区的前两个字节是「主响应码 子响应码」正常结束时都是 0x00 0x00。只要主响应码非 0后面就没有数据必须按错误码查表。比如 0x40 0x01 表示「不支持的命令」0x21 0x02 表示「区域代码错误」0x22 0x01 表示「地址越界」。解析数据时从响应数据区第 3 个字节开始按小端逐字还原。def parse_read_response(resp_data): # resp_data 是去掉 FINS/TCP 头和 FINS 头之后的响应数据区 mres, sres resp_data[0], resp_data[1] if mres ! 0x00 or sres ! 0x00: raise RuntimeError(fFINS error: {mres:#04x} {sres:#04x}) payload resp_data[2:] # 按小端每 2 字节还原一个字 return [struct.unpack(H, payload[i:i2])[0] for i in range(0, len(payload), 2)]逻辑说明先校验响应码再切片。参数上要注意resp_data的起点必须精确——如果你把 FINS 头也算进去第一个字节就变成 ICF 而不是响应码解析必然错位。判断起点的方法是FINS/TCP 头固定 16 字节FINS 头固定 10 字节所以响应数据区从第 26 字节开始TCP 场景。3. 用 FINS-Framework 跑通第一条读写连接、组包、发命令3.1 环境准备与连接参数确认在动手之前先把几个参数确认清楚否则后面全是玄学超时。第一PLC 的 IP 和端口FINS/TCP 默认端口是 9600FINS/UDP 默认也是 9600但有些现场改过要用编程软件或网络配置工具查。第二节点号PLC 内置以太网口的节点号通常等于 IP 末段比如 192.168.1.10 就是节点 10。第三目标网络号和单元地址单网段直连填 0 和 0。第四确认 PLC 的以太网单元是否允许 FINS 通信有些安全设置会关掉。# 先确认网络可达再确认端口开放 ping 192.168.1.10 # Linux 下看 9600 端口是否响应需要 nc nc -zv 192.168.1.10 9600如果 ping 通但端口不通先查 PLC 侧以太网设置不要急着改代码。这一步能省掉大量「以为是协议问题其实是网络问题」的时间。3.2 用框架建立 TCP 连接并发送读命令FINS-Framework 这类框架通常提供连接管理、命令构造、响应解析三层 API。下面用 Python 演示一个最小可复现的读 DM 流程不依赖具体框架版本逻辑与主流实现一致。import socket import struct PLC_IP 192.168.1.10 PLC_PORT 9600 NODE_PLC 10 # PLC 节点号通常为 IP 末段 NODE_PC 1 # 上位机节点号按实际网卡设置 def build_fins_tcp_header(fins_frame_len): # FINS/TCP 头长度(4B) 命令码(4B) 错误码(4B) 保留(4B) # 命令码 0x00000002 表示 FINS 帧发送 return struct.pack(I I I I, fins_frame_len 8, 0x00000002, 0x00000000, 0x00000000) def build_fins_header(dest_node, src_node): # ICF RSV GCT DNA DA1 DA2 SNA SA1 SA2 SID return bytes([0x80, 0x00, 0x02, 0x00, dest_node, 0x00, 0x00, src_node, 0x00, 0x01]) def read_dm(sock, start_addr, count): cmd build_read_dm(start_addr, count) fins_header build_fins_header(NODE_PLC, NODE_PC) fins_frame fins_header cmd tcp_header build_fins_tcp_header(len(fins_frame)) sock.sendall(tcp_header fins_frame) # 先收 16 字节 TCP 头再按长度收剩余 resp_tcp sock.recv(16) total_len struct.unpack(I, resp_tcp[0:4])[0] resp_body b while len(resp_body) total_len - 8: resp_body sock.recv(total_len - 8 - len(resp_body)) # 跳过 10 字节 FINS 头取响应数据区 return parse_read_response(resp_body[10:]) sock socket.create_connection((PLC_IP, PLC_PORT), timeout3) print(read_dm(sock, 100, 10)) sock.close()逻辑说明先构造 FINS 命令数据区再套 FINS 头再套 FINS/TCP 头。发送后先收固定 16 字节 TCP 头从里面读出总长度再收剩余部分。参数上NODE_PC必须和上位机网卡的节点号一致否则 PLC 可能不回包timeout建议设 3 秒太短会误判太长会拖慢采集循环。这段代码跑通说明链路、节点号、命令码、地址编码全对后面就是批量化和异常处理的事。3.3 写命令与批量读的节奏控制写命令和读命令的区别只在子命令码和数据体。批量读的时候不要一次读几千个字FINS 单帧有长度上限以太网场景一般单次读 990 个字以内比较稳。我一般按 100 到 200 个字一批既减少往返次数又不至于触发长度错误。写命令要特别注意写之前确认目标区域是否被 PLC 程序占用往正在被扫描的 DM 区写值可能被程序下一周期覆盖看起来像「写了没生效」。def write_dm(sock, start_addr, values): cmd build_write_dm(start_addr, values) fins_header build_fins_header(NODE_PLC, NODE_PC) fins_frame fins_header cmd tcp_header build_fins_tcp_header(len(fins_frame)) sock.sendall(tcp_header fins_frame) resp_tcp sock.recv(16) total_len struct.unpack(I, resp_tcp[0:4])[0] resp_body b while len(resp_body) total_len - 8: resp_body sock.recv(total_len - 8 - len(resp_body)) mres, sres resp_body[10], resp_body[11] if mres ! 0 or sres ! 0: raise RuntimeError(fwrite failed: {mres:#04x} {sres:#04x})参数说明values的长度决定写入字数必须和实际数据一致响应校验只看两个响应码写命令正常时没有数据体。批量写的时候如果一批里有一个地址越界整批都会失败所以要么分批写要么先做地址范围校验。4. FINS 采集避坑超时、错位、节点号与并发4.1 现象连接成功但读命令一直超时原因通常有三类。第一FINS 头里的 DA1 节点号填错PLC 收到帧但发现目标节点不是自己直接丢弃。第二FINS/TCP 头的长度字段算错PLC 认为帧不完整等后续字节直到超时。第三PLC 侧以太网单元没开启 FINS/TCP 服务只开了 UDP。解决顺序先用 UDP 试一发如果 UDP 通说明是 TCP 服务没开再核对节点号用 IP 末段试最后用抓包工具看请求帧长度字段是否等于「FINS 帧长 8」。4.2 现象读回来的数据整体错位一个字原因多半是响应解析起点算错。FINS/TCP 响应里TCP 头 16 字节FINS 头 10 字节响应数据区从第 26 字节开始。如果你从第 24 或第 28 字节开始取就会整体偏移。另一个可能是把响应码当成了数据。解决打印原始响应的十六进制人工数一遍字节位置确认第 26、27 字节是 0x00 0x00。4.3 现象多线程采集时数据串包原因是一个 socket 连接上并发发多条命令响应按顺序回来但你的代码按线程各自 recv导致 A 线程收到 B 线程的响应。FINS 本身是请求-响应模型一条连接同一时刻只应有一个未完成请求。解决要么每条连接配一把锁要么每个线程独立连接。我一般用连接池每个采集任务独占连接简单可靠。4.4 现象写值后读回来没变原因是被 PLC 程序覆盖或者写到了错误的区域代码。比如想写 CIO 区却用了 DM 的区域代码 0x82命令成功但写到了 DM 区。解决确认区域代码确认该地址是否在程序里被赋值。可以在 PLC 编程软件里监控该地址看是否每个扫描周期都被刷新。4.5 现象大批量读时偶发长度错误原因是单帧请求字数超过 PLC 允许上限。不同型号上限不同常见做法是控制在 500 字以内保守一点 200 字。解决把大批量拆成小批循环读每批之间留几毫秒间隔避免 PLC 通信单元缓冲溢出。5. 进阶把 FINS 采集做成可复用的采集器5.1 用配置表驱动地址而不是硬编码真正上产线时地址会变、点位会增。把「区域代码 起始地址 字数 数据类型」做成配置表采集器读表组包比在代码里写死read_dm(100, 10)强得多。下面是一个配置结构示例。点位名区域起始地址字数数据类型采集周期温度DM1001int161000ms产量DM2002int325000ms运行状态CIO01bit500ms区域代码映射DM0x82CIO0xB0WR0xB1。数据类型决定解析时怎么拼字节int32 要两个字按小端合并。采集周期决定调度频率不要把高频点位和低频点位塞进同一批。5.2 断线重连与超时退避产线网络会抖采集器必须能自愈。我的习惯是每次读写捕获 socket 异常标记连接失效关闭旧连接按 1s、2s、4s 退避重连连续失败超过阈值就告警。重连后不要立刻发大批量命令先发一条单字读命令探活通了再恢复全量采集。import time def safe_read(conn, start_addr, count, retries3): for i in range(retries): try: return read_dm(conn.sock, start_addr, count) except (socket.timeout, ConnectionError) as e: conn.reconnect() time.sleep(2 ** i) # 指数退避 raise RuntimeError(read failed after retries)参数说明retries不宜过大3 次足够退避基数 1 秒避免网络刚恢复就被打满。重连逻辑里要重新建 socket不要复用旧对象。5.3 验证采集正确性的三个手段第一用 PLC 编程软件同时监控同一地址和采集器读回的值对比连续观察几分钟。第二写一个已知值到测试地址读回来确认一致再改回原值。第三用抓包工具看请求响应配对确认没有串包和错位。这三步做完基本可以放心上产线。我自己踩过最深的坑是「读回来值对但偶尔跳变」最后发现是采集周期和 PLC 扫描周期不同步读到中间状态后来把关键点位改成连续读两次取一致值才稳。希望帮到你。本文还有配套的精品资源点击获取