☰
欧姆龙PLC通信核心:FINS协议报文结构、节点规划与上位机实战排查
2026/10/13 5:23:44 网站建设 项目流程

做上位机这几年,跟欧姆龙PLC打交道最多的就是FINS协议。无论是用C#写上位机读取DM区数据,还是用Python脚本做产线数据采集,只要连的是欧姆龙CP1H、CJ2M或者NJ系列,最后基本都会落到FINS这个协议上。FINS全称是Factory Interface Network Service,是欧姆龙自己定义的工厂接口网络服务协议,在以太网、Controller Link、SYSMAC LINK这些链路上都能跑。它最方便的一点是:不管底层走哪种物理链路,命令格式是统一的,也就是说你学会了以太网上的FINS/UDP报文格式,换到串行链路或者Controller Link上,报文结构照样通用,只是传输方式不一样而已。

这篇内容主要面向两类人:一是刚接触欧姆龙PLC通信、需要快速把上位机和PLC连起来的新手;二是已经会调用现成库、但遇到问题不知道怎么排查的开发者。我会从帧结构、节点规划、命令报文、实际部署这几个角度,把FINS协议从底层到应用拆开讲清楚,并且给出可以直接拿走的代码示例和排查思路。

1. 先搞懂FINS协议的定位和通信模型

1.1 为什么是FINS而不是别的协议

欧姆龙PLC除了FINS,其实还支持Modbus-TCP、以太网/IP(EtherNet/IP)这些通用协议。那为什么很多老项目仍然用FINS?原因大致有三个方面:

第一,FINS是欧姆龙的原生协议,对PLC内部资源的访问最完整。比如通过Modbus-TCP也能读写DM区,但像CPU总线单元的状态读取、远程程序传输、文件内存操作这类功能,严格来说还是走FINS更直接,因为FINS的命令码设计就是覆盖整个FA(工厂自动化)网络的。

第二,FINS本身就建立在TCP/UDP之上,实现成本低。它不需要像EtherNet/IP那样做复杂的CIP对象建模,抓包看报文非常直观,一对请求和响应搞明白,基本就能应对大部分数据采集和控制需求。

第三,欧姆龙的编程软件CX-Programmer、Sysmac Studio内置的网路配置工具,默认通信方式也支持FINS。很多现场已经在用这些工具做在线监控,你上位机沿用FINS,跟在PLC侧看到的数据是完全一致的,排查问题时候不用在多个协议之间来回转换。

1.2 FINS节点和网络结构

FINS协议里最基本的概念就是节点(Node)。它相当于给每台设备分配一个地址,在同一网络里必须唯一。FINS的地址由三个部分组成:

  • 网络号(Network Address):标识通信链路所在的网络,范围0到127。现场一般都用0,除非你做了多网段隔离。
  • 节点号(Node Address):网络内的设备编号,范围1到254。PLC侧的节点号在单元设置里指定,比如设为1;上位机侧需要给它分配一个和PLC不冲突的编号。
  • 单元号(Unit Address):标识CPU单元或者特定通信单元。CPU单元通常是0,如果是CJ1W-EIP21这类通信单元,则按单元号来写。

在实际报文里,这三个参数分为目标地址和源地址两组。你在发命令时,填的是目标设备的地址和你自己的源地址。PLC返回响应帧时,会把这两个地址互换,这样你就能通过响应帧的源地址判断响应来自哪台设备。

1.3 FINS/UDP和FINS/TCP怎么选

FINS在以太网上有两种承载方式:UDP和TCP。这两者的区别不只是协议层次上的,实际使用上有几个非常关键的点。

  • FINS/UDP:端口固定为9600,无连接,发请求后等响应。适用于快速读取和周期性数据采集,开销最小。
  • FINS/TCP:端口也是9600,但需要先建立TCP连接。连接建立后,发送的FINS帧前面会增加4个字节的长度字段(Length),用于TCP流式传输的断包重组。另外TCP模式下还有个Keep-Alive机制,通信双方可以互发保活帧检测链路状态。

从实操角度来说,如果你只是做简单的读写字数据,UDP模式够用且容易调试;如果你要长时间保持连接、大量连续读写,或者需要可靠传输,建议用TCP。我自己的经验是:新项目优先用TCP,因为它能复用连接,不会因为频繁建立UDP会话而出现端口层面的问题;老项目如果已经在用UDP跑得很稳定,也不急着迁移。

2. FINS报文结构逐字节拆解

2.1 命令帧格式总览

FINS UDP命令帧的整体结构由FINS Header(10字节)和FINS Command(命令码+参数+数据)组成。前面10个字节就是三组地址加一个服务标识符,后面跟着具体要执行的操作。

这里以读取PLC的DM0000开始的10个字为例,完整命令帧长这样:

80 00 02 00 00 01 00 00 01 00 01 01 82 00 00 00 00 0A

别急着背,我把每一段拆开讲。

2.2 FINS Header各字段含义

FINS Header总共10个字节,依次是:

字节偏移字段名示例值说明
0ICF0x80通信控制字段,0x80表示需要响应
1RSV0x00保留字段,固定为0
2GCT0x02允许网关转发
3DNA0x00目标网络号
4DA10x01目标节点号
5DA20x00目标单元号
6SNA0x00源网络号
7SA10x01源节点号
8SA20x00源单元号
9SID0x00服务标识符,用于请求响应匹配

这里最需要注意的是ICF字段。ICF的低三位表示数据类型,高位置1表示需要响应。如果ICF是0x80,说明这是一个需要应答的命令,PLC处理完后会回一个响应帧。如果你把ICF设为0x00,表示不需要响应,那就是"发了不管"模式,一般不建议在数据采集里用,因为没法确认PLC是否真的执行了。

GCT字段通常填0x02,意思是允许网关中继。如果你只有一台PLC直连,这个字段其实不参与路由,但按照FINS规范,固定填0x02最稳妥。DNA、DA1、DA2是目标设备的网络号、节点号和单元号,SNA、SA1、SA2则是源设备的对应信息。SID(服务标识符)你可以理解成请求的序列号,PLC返回响应时会原样带上这个SID,你的上位机靠它来区分当前响应到底对应哪一条请求。多线程并发发送请求时,SID非常重要——每个请求用不同的SID,响应回来就能一一对上。

2.3 命令码和参数区

在FINS Header之后,紧接着是2字节的命令码。常用命令码有:

  • 0101:内存区读出(Read)
  • 0102:内存区写入(Write)
  • 0103:内存区填充(Fill)
  • 0104:多内存区读出(Multiple Read)
  • 0105:内存区数据比较
  • 2001:运行/停止控制
  • 2301:读时钟数据
  • 2302:写时钟数据

以0201之外的用途如果暂时用不上,先记住0101和0102就够了,它们覆盖了你90%以上的数据读写需求。

对于0101命令,命令码后面的参数是:

内存区代码(1字节)+ 起始地址(2字节)+ 数据个数(2字节)

起始地址的2字节表示的是这个内存区内部的字地址。比如DM区,在FINS报文里就是DM区代码0x82加上一个字地址,例如DM0000对应的地址编码是0000。IK、CIO区的地址则要看PLC型号和操作数编号规则,后面会单独讲。

2.4 响应帧怎么解析

PLC处理完请求后,会返回一个响应帧。响应帧的ICF、SA1、DA1这些字段和命令帧是反过来的——源和目标的地址互相交换,SID保持一致。响应帧的格式是:

FINS Header(10字节) + 命令码(2字节) + 响应码(2字节) + 数据

其中响应码是关键。0000表示正常结束;非0000则表示出了异常。常见异常响应码包括:

响应码含义
0x0101本地节点错误(源节点号未被正确设置)
0x0102目标节点错误(节点号不存在或未连接)
0x0201内存区指定错误(地址越界或不符合规范)
0x0202访问被保护(PLC启用了写保护)
0x0203存储器为只读(比如读文件存储器但不能写)
0x1101参数异常(数据个数或地址范围不对)

看到非0000响应码先别慌,绝大多数情况下就是报文里的目的地址或者内存区代码写错了。对照响应码去查,比盲猜快得多。

3. 实操:用Python实现FINS/UDP报文读写

3.1 准备工作

写代码之前,先把PLC侧的通信设置检查一遍。这里以某型号欧姆龙PLC为例,USB连接编程软件后依次确认:

  1. PLC的IP地址,比如192.168.1.10,子网掩码255.255.255.0。
  2. PLC的节点号,默认是1,在单元设置里能看到。
  3. 端口号默认9600,FINS UDP服务默认是开启的。如果PLC开启了防火墙过滤规则,需要放行UDP 9600。

另外,给上位机分配一个和PLC不冲突的节点号。我习惯用127这个中间值,因为在上位机端手动指定的节点号不宜和PLC重复,也不宜用0和255,用127这个中间节点号能避开大多数默认配置。

3.2 直接构造UDP报文的Python脚本

以下代码不需要任何第三方库,直接基于Python内置的socket和struct,发送读取DM0000开始10个字的FINS命令,并解析响应:

import socket import struct PLC_IP = "192.168.1.10" PLC_PORT = 9600 PLC_NODE = 0x01 LOCAL_NODE = 0x7F # 上位机分配的节点号127 def fins_header(dst_node, src_node, sid=0x00, dst_net=0x00, src_net=0x00, dst_unit=0x00, src_unit=0x00): # ICF=0x80 需要响应, GCT=0x02 允许网关转发 header = bytes([0x80, 0x00, 0x02]) header += bytes([dst_net, dst_node, dst_unit]) header += bytes([src_net, src_node, src_unit]) header += bytes([sid]) return header def build_read_dm(start_addr, count): # 0101 内存区读出命令 cmd = bytes([0x01, 0x01]) # 内存区代码0x82表示DM区 mem = bytes([0x82]) # 起始地址2字节,这里用struct转为大端字节序 addr = struct.pack(">H", start_addr) cnt = struct.pack(">H", count) return fins_header(PLC_NODE, LOCAL_NODE) + cmd + mem + addr + cnt def parse_response(resp): # 前10字节是FINS Header,第11-12字节是命令码,第13-14字节是响应码 if len(resp) < 14: raise ValueError("响应帧长度不足") cmd = resp[10:12] end_code = resp[12:14] if end_code != b'\x00\x00': raise RuntimeError(f"PLC返回错误码: {end_code.hex()}") data = resp[14:] return data def read_dm(start_addr, count): req = build_read_dm(start_addr, count) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(3) try: sock.sendto(req, (PLC_IP, PLC_PORT)) resp, _ = sock.recvfrom(2048) data = parse_response(resp) # 每个字2字节,大端序格式 values = [] for i in range(count): value = struct.unpack(">H", data[i*2 : i*2+2])[0] values.append(value) return values finally: sock.close() if __name__ == "__main__": vals = read_dm(0, 10) print("DM0000-DM0009:", vals)

这段代码跑通后,你就能拿到PLC里从DM0000开始的10个字数据,返回值是无符号整数。如果DM区存的是带符号整数、浮点数或者ASCII字符串,需要再把原始字节按目标格式重新解释。比如带符号整数用struct.unpack(">h", ...),32位浮点要按两个字拼接后解包。

3.3 写操作报文怎么构造

写命令的报文结构和读命令非常相似,只不过是在命令参数后面多加了一截要写入的数据。把DM0000地址的1个字写成1234,报文的构造方式是:

def build_write_dm(start_addr, values): # 0102 内存区写入 cmd = bytes([0x01, 0x02]) mem = bytes([0x82]) addr = struct.pack(">H", start_addr) cnt = struct.pack(">H", len(values)) payload = b"" for v in values: payload += struct.pack(">H", v) return fins_header(PLC_NODE, LOCAL_NODE) + cmd + mem + addr + cnt + payload

写操作的响应帧比读操作更短,正常响应时只有命令码和响应码0000,后面不跟数据。如果你的程序收到响应帧长度恰好是14字节(Header 10字节 + 命令码2字节 + 响应码2字节),说明写入成功。

注意:欧姆龙PLC的DM区是掉电保持区,写入后数据会一直保留。如果你只是在测试,写完记得把PLC设置里的DM区初始化选项确认一下,避免把现场数据弄乱。

3.4 用现成库还是手搓报文

网上有不少现成的FINS通信库,比如某Python库和其他开源实现,封装了节点连接和内存区读写接口,能省去很多构造报文的麻烦。我的态度是:生产环境用现成库没问题,但开发阶段一定要手搓一遍报文,至少要用抓包工具看到自己发出去的帧结构。原因很简单——一旦通信出了问题,你必须能看懂报文才能定位是哪一段出了问题。库帮你隐藏了细节,排查时就少了一个抓手。

如果你打算基于现成库做二次开发,至少确认它支持以下能力:

  • 自定义源节点号,避免和PLC冲突。
  • 自定义响应超时时间和重试次数。
  • 支持UDP和TCP两种承载方式切换。
  • 能把原始响应帧暴露出来,方便抓包对比。

4. 节点规划、地址编码和常见坑

4.1 节点号规划:上位机和PLC怎么分配

FINS UDP模式下,PLC收到报文后是靠“目标节点号”判断要不要响应的。所以你要保证上位机发出去的DA1和PLC设置的节点号一致;同时,PLC在响应帧里会根据SNA、SA1回发,也就是把你源端的网络号和节点号作为响应目标。这里有个很经典的坑:在FINS UDP通信里,两边节点号的和不能等于0。如果你把PLC节点号设成了254,上位机又发一个254的源节点号,两者相加会发生地址冲突,响应帧可能发不回来。实际表现就是请求超时,抓包却能看到PLC已经把响应发出去了。

为了避免这类问题,我一般建议:

设备节点号建议说明
PLC1出厂默认值,便于记忆
上位机A127中间值,避免加和冲突
上位机B126多台上位机全靠不同节点号区分
触摸屏2如果触摸屏走FINS,也要占一个节点

多台上位机同时连一台PLC是可行的,因为它们源节点号不同,PLC能够区分响应目标。但这也意味着所有上位机的FINS节点号必须规划好,谁先占用了就不能再复用,否则会出现响应被别的上位机“截胡”的情况。

4.2 内存区代码和地址映射

FINS协议用1字节的内存区代码来标识不同类型的存储区。常用代码对照如下:

内存区代码存储区说明
0x82DM区数据内存,字单位
0x83AR区特殊辅助继电器区
0x84HR区保持继电器区
0xB0CIO区CIO区,包括输入输出
0xB1WR区工作继电器区
0xB2HR区(某些型号写法略有差异)保持继电器
0xB3定时器标志使用TC编号访问
0xB4计数器标志使用TC编号访问
0x98定时器PV当前值
0x99计数器PV当前值

在FINS报文里,DM区的字地址直接用16位整数表示,DM0000就是0000,DM65535就是FFFF。但CIO区、WR区、HR区的地址编码跟梯形图里的通道号不一定直接一致。最典型的是CIO区的输入输出,比如CIO 0.00这种按位寻址的点位,在FINS里要用字地址0再加位偏移来换算。查内存区代码表的时候,一定先确认PLC型号对应的存储区映射范围。

4.3 位读写怎么实现

有时候你不需要读整个字,只需要读某一位。比如要监控CIO区某个输出点的通断。FINS也支持按位读写,方法是把内存区代码换成位指定方式。以前面说的0101命令为例,内存区代码仍然是0x30(表示CIO区按位访问)这种扩展代码,起始地址的两字节则是“字地址和位号”打包的形式,低4位表示位号,高12位表示字地址。

另一种更通用的做法是:读取整个字,然后在上位机里做位运算。比如要读CIO 10.02,你就读10号字,然后取第3位(bit2)的值。这种方式虽然多传了几个字节,但程序逻辑简单,也不会因为协议细节不同而出错。对于我的项目,除非监控点特别多且要求实时性极高,否则我都是用整字读取再按位解析。

4.4 常见坑:地址连续和数据类型

上位机读取DM区时,IO数据如果跨越了连续地址,一切正常。但如果你读的是模拟量输入通道分配给CIO区,从CH1到CH3是连续的,CH4可能因为通道分配方式变成跳过的地址。编程时务必画出“PLC内部地址映射表”,把模拟量、通信数据、工装参数这些区域划分清楚,再按区域批量读取,比一条条读有效得多。

数据类型这块,欧姆龙的DM区可以存放各种类型的变量。如果PLC里写的是浮点数(32位浮点),那一个字放不下,需要连续两个字来存储。上位机读取时要按浮点格式解包,这时候要注意字节序。FINS报文里多字节数据默认是大端序(高字节在前),而PLC内部的浮点存储方式也遵从大端序布局,所以直接按>f格式解包一般没问题。如果出现解析结果很奇怪,比如数值差了好几个数量级,先检查是不是字节序弄反了。

5. 常见问题与排查技巧实录

5.1 请求超时,但PLC明明在线

这是我被问得最多的问题。你ping PLC能通,但FINS请求就是没响应。排查顺序建议如下:

  1. 检查端口是否通:用工具向PLC的9600端口发UDP包,确认有响应。UDP没有真正的“端口连通性测试”,但你可以抓包看有没有回包。
  2. 检查节点号加和是否为0。如果你的PLC节点号是254,上位机FINS源节点号也写了254,就会出现“请求发出去了也到了,但响应回不来”的情况。把源节点号改成127通常立竿见影。
  3. 查看PLC单元设置里的FINS UDP端口是不是被改过。某些型号默认不是9600,或者被防火墙规则拦截了。
  4. 检查SID是否重复。如果你连续发请求都用了同一个SID,在快速连续发送的场景里,响应匹配不上也可能导致程序判定超时。

5.2 响应码0201内存区指定错误

0201是地址越界或内存区不存在。排查重点是地址换算是否正确。举个例子,你要读CIO区,结果内存区代码填了0x30,但实际在报文里CIO区字访问的代码是0xB0,这就会直接报0201。另外,起始地址加上要读的数据个数超出了存储区大小范围,也会报这个错误。

遇到0201时,先手算一遍地址范围:起始地址 + 数据个数 <= 存储区上限。如果上限没错,再检查存储区代码。

5.3 抓包工具怎么配合调试

调试FINS通信,强烈建议装一个抓包工具。Wireshark对FINS协议有专门的解析器,能直接识别出FINS header里的各个字段,并对UDP 9600端口的报文标成FINS。

抓包调试的具体步骤:

  1. 在上位机侧抓包,过滤条件用udp.port == 9600。
  2. 发一条读DM0000的请求,看请求帧的DA1、SA1是否符合预期。
  3. 看PLC回包的目标IP和端口是否指向上位机本地。
  4. 对比请求帧和响应帧的SID是否一致。

如果请求帧发对了但响应帧没出现,问题大概率在网络层。如果响应帧出现了但上位机程序超时,问题大概率在节点号加和或者Winsock绑定上。

5.4 TCP模式下的断包和长度字段

切到FINS/TCP之后,报文里会多出4字节的Length字段,表示后面FINS帧的总长度。TCP是流协议,一次接收可能包含多条请求,也可能一条请求被拆成两个包。这时候接收缓冲处理就要正确:先读4字节算出长度,再按长度收取完整报文。

这里的典型错误是直接用recv(1024)去收,然后按固定长度解析。如果网络环境不好或者负载高,很容易出现粘包/拆包问题,导致解析出来的是错位的垃圾数据。

我建议写一个简单的拆包函数:

def recv_fins_tcp(sock): header = b"" while len(header) < 4: chunk = sock.recv(4 - len(header)) if not chunk: raise ConnectionError("连接断开") header += chunk length = struct.unpack(">I", header)[0] payload = b"" while len(payload) < length: chunk = sock.recv(length - len(payload)) if not chunk: raise ConnectionError("连接断开") payload += chunk return payload

5.5 多线程并发和SID分配

FINS UDP本身没有严格的多路复用机制。你要在同一个socket上并发发送请求,就必须保证每条请求的SID不重复,否则响应回来时程序无法判断是哪个请求的回应。两个线程同时发同一条命令、用同一个SID,一个线程收到响应后把数据取走,另一个线程就会一直等到超时。

几个建议:

  • 用原子整数递增的方式生成SID。
  • 维护一个“待响应队列”,发送请求时把SID和业务回调注册到队列。
  • 收到响应后,根据SID从队列里找到对应回调,做匹配处理。

如果业务场景比较简单,也可以退一步,直接用互斥锁把发送动作串行化,一次只发一条请求、等一条响应。虽然牺牲了一点吞吐,但实现可靠很多。

5.6 PLC侧需要做的配置

最后强调一遍PLC侧的两个关键配置:

  • PLC内网IP、子网掩码必须正确。很多现场是把PLC直连工控机,用了默认IP,工控机换了IP段之后就把PLC“丢”了。
  • 节点号必须和上位机报文里的DA1一致。有些工程师在PLC里改了节点号,但没有重新下载设置,或者下载后没重启,都会导致地址不生效。

在线修改节点号后,至少给PLC重新上电一次,确保网络单元按新设置初始化。这类"改完没生效"的问题,在欧姆龙PLC上还算常见,别当玄学处理。

6. 调试现场的一些感受

FINS协议真正跑起来之后,你会发现它其实非常规矩。相比很多工业协议喜欢搞复杂的状态机和对象模型,FINS的核心就是"发一条命令,等一条响应",帧结构固定,字段含义清晰,出错也有明确的错误码反馈。这也是为什么很多老工程师哪怕手里有现成的库,遇到现场问题第一反应还是抓包看报文——因为协议本身足够简单,报文一展开,问题基本就暴露了。

我个人在实际项目里的习惯是:第一次联调,一定先用最原始的UDP报文把读写通道打通,再往工程代码里集成。别一上来就套框架或者库,因为一旦通信链路有基础层面的问题(比如节点号冲突、端口被挡),在框架的层层封装下去排查,反而更痛苦。基础通道确认没问题后,再根据项目需要选择是继续裸报文,还是封装成函数复用。

如果后续要把这套通信做成通用服务,建议把通信层拆成独立的模块,内部封装好节点管理、SID分配、收发队列、超时重试这些机制,对外只暴露ReadDM(addr, count)、WriteDM(addr, values)这类业务接口,这样不管是做MES数据采集,还是做设备远程控制,都能快速复用。FINS协议本身不难,难的其实是把通信能力和业务边界规划清楚,这个思路比死记几个报文字段更重要。

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

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

立即咨询