ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

欧姆龙FINS协议详解:报文结构、命令帧与TCP/UDP/串口调试实战

欧姆龙FINS协议详解:报文结构、命令帧与TCP/UDP/串口调试实战

1. 从一条产线调试的深夜求助说起

凌晨两点,群里弹出一条消息:“PLC的地址表都对得上,读D100就是没数据,换了个端口号还是不行,到底哪里出问题了?”配图是一段抓包截图,TCP流里能看到一串十六进制字节,开头是46 49 4E 53,后面跟着00 00 00 1A之类的字段。懂行的人一眼就能认出来,这是欧姆龙FINS协议的标准报文头。

FINS,全称Factory Interface Network Service,是欧姆龙为其PLC、运动控制器、视觉设备等工业自动化产品设计的一套应用层通信协议。它最特别的地方在于:同一套命令帧格式,可以跑在串口、以太网、甚至控制器背板总线上。也就是说,你学会了FINS的命令结构,换一种物理介质,命令本身几乎不用改。这个设计在工业现场非常实用,因为产线上经常出现“老设备只有串口、新设备只有网口”的混搭局面。

这篇内容适合三类人看:一是刚接触欧姆龙PLC、被地址格式和握手流程绕晕的自动化工程师;二是需要做上位机数据采集、但被FINS报文结构卡住的软件开发;三是做设备联网改造、需要把老旧欧姆龙设备接入新系统的集成人员。我会从报文结构讲起,把命令帧、响应帧、地址编码、常见命令码、TCP/UDP/串口三种承载方式逐一拆开,再补上实际调试中最容易踩的坑。全文基于公开协议规范和常见工程实践整理,不涉及任何具体企业内部资料。

2. FINS协议的报文骨架:为什么它能在三种介质上通用

2.1 FINS帧头的固定结构与字段含义

FINS命令帧的头部是固定长度的,这是它能够跨介质通用的根本原因。一个完整的FINS/TCP帧,在TCP载荷里长这样:

46 49 4E 53 00 00 00 1A 00 00 00 02 00 00 00 00 |--FINS--| |--长度--| |--命令--| |--错误码--|

前四个字节46 49 4E 53是ASCII的“FINS”,这是FINS/TCP特有的封装头,用来在TCP流中标识FINS报文的起始。紧接着的4个字节是后续数据的长度(大端序),再往后是4个字节的命令码,最后4个字节是错误码。这16个字节是FINS/TCP的“信封”,真正的FINS命令帧被装在这个信封里面。

拆开信封之后,里面的FINS命令帧结构是:

字段长度说明
ICF1字节信息控制字段,标识命令/响应、是否需要响应
RSV1字节保留,固定为0
GCT1字节网关计数,经过网关时递减
DNA1字节目标网络号
DA11字节目标节点号
DA21字节目标单元号
SNA1字节源网络号
SA11字节源节点号
SA21字节源单元号
SID1字节服务ID,用于匹配请求和响应
命令码2字节具体操作,如0101读、0102写
数据区变长命令参数和返回数据

这个结构里,ICF、DNA、DA1、DA2、SNA、SA1、SA2这几个字段是FINS寻址的核心。在以太网环境下,DNA和SNA通常填0(本地网络),DA1填目标PLC的节点号,DA2填0(CPU单元)。串口环境下,这些字段的填法又不一样。这就是FINS“一套命令、多种承载”的实现方式:命令码和数据区不变,只改寻址字段和底层传输方式。

2.2 命令码的编码逻辑与常用命令速查

FINS的命令码是2个字节,高字节表示功能大类,低字节表示具体操作。比如:

  • 01 01:读内存区(Memory Area Read)
  • 01 02:写内存区(Memory Area Write)
  • 01 03:读多个内存区
  • 01 04:写多个内存区
  • 02 01:读参数区
  • 02 02:写参数区
  • 05 01:读控制器状态
  • 06 01:读时钟
  • 07 01:读循环时间

这个编码逻辑很直观:高字节01代表“内存操作”,02代表“参数操作”,05代表“状态读取”。记住这个规律,看到命令码就能大致判断它在干什么。

实际工程中最常用的是01 01和01 02,也就是读写DM区、CIO区、WR区等。以读DM区为例,命令帧的数据区需要指定:

  • 内存区代码(1字节):DM区是82,CIO区是B0,WR区是B1
  • 起始地址(2字节,大端序)
  • 读取长度(2字节,大端序,单位是“字”)

比如要读D100开始的10个字,数据区就是82 00 64 00 0A。响应帧的数据区则直接返回20个字节的原始数据(10个字×2字节)。

2.3 响应帧与错误码:怎么判断命令到底成没成

响应帧的结构和命令帧几乎一样,区别在于ICF字段的最低位会从0变成1(表示这是响应),以及数据区的内容变成了返回数据或错误信息。判断命令是否成功,不能只看TCP有没有收到数据,必须看FINS层的错误码。

FINS错误码在响应帧的固定位置,常见的有:

错误码含义典型原因
00 00正常命令执行成功
01 01本地节点未加入网络网络号配置错误
01 02目标节点未加入网络目标PLC节点号不对
01 03目标节点未响应PLC断电或网线断开
01 04目标节点忙PLC正在执行其他高优先级任务
01 05目标节点无此单元单元号填错
02 01命令码不支持PLC型号不支持该命令
03 01地址范围超限读写的地址超出PLC内存范围
03 02数据长度超限一次读写的数据量太大
03 03地址格式错误内存区代码或地址编码不对
03 04数据区格式错误数据区字节数不对
04 01命令执行失败写保护或权限不足

我见过太多人抓包看到TCP层有响应就以为成功了,结果解析出来全是03 01。错误码必须逐字节解析,不能跳过。尤其是03 01和03 03,前者是地址越界,后者是地址格式错误,两者的排查方向完全不同。

3. 三种承载方式:TCP、UDP和串口的FINS有什么不一样

3.1 FINS/TCP:最常用但也最容易配错的方式

FINS/TCP是目前上位机采集最常用的方式,因为TCP可靠、编程方便。但它有一个容易被忽略的细节:FINS/TCP在建立连接后,需要先发一个“节点地址自动分配”的握手帧,才能发真正的FINS命令。

这个握手帧的格式是:

46 49 4E 53 00 00 00 0C 00 00 00 00 00 00 00 00 |--FINS--| |--长度--| |--命令--| |--错误码--|

命令码00 00 00 00表示“节点地址自动分配请求”。PLC收到后会返回一个响应,里面包含分配给客户端的节点号。如果你跳过这一步直接发01 01读命令,PLC会直接拒绝或返回错误。这是新手最容易踩的坑之一。

另外,FINS/TCP的默认端口是9600,但很多现场会改成其他端口。端口号本身不影响FINS命令结构,但连接建立后的握手流程是一样的。

3.2 FINS/UDP:轻量但需要自己处理丢包

FINS/UDP不需要握手,直接发命令帧就行。它的封装格式比TCP简单:没有那16字节的FINS/TCP信封,UDP载荷直接就是FINS命令帧。目标端口默认也是9600。

UDP的好处是开销小、延迟低,适合高频采集。但坏处也很明显:UDP不保证送达,也不保证顺序。如果PLC忙或者网络抖动,命令可能丢失。所以用UDP做采集时,必须在应用层做超时重试和序号匹配。

我个人的经验是:如果采集周期在100ms以上,用TCP更省心;如果周期在10ms级别且网络质量好,UDP更合适。但无论用哪种,SID字段都要认真填,因为它是匹配请求和响应的唯一标识。用UDP时如果SID重复,响应就可能对不上号。

3.3 串口FINS:老设备的唯一选择

串口FINS的帧结构和以太网FINS完全一样,只是没有TCP/UDP的封装头。串口参数通常是:波特率9600或19200,数据位7或8,停止位1或2,校验偶校验。具体参数必须和PLC的串口设置完全一致,否则连第一步都走不通。

串口FINS的寻址字段填法和以太网不同:DNA和SNA通常填0,DA1填目标PLC的单元号(不是节点号),DA2填0。串口是点对点或总线式,没有IP地址的概念,所以节点号在串口环境下意义不大。

串口调试最大的问题是没有抓包工具。以太网可以用Wireshark看原始字节,串口只能靠串口监视器。我的做法是:先用串口监视器确认PLC有没有返回数据,再把返回的十六进制字节复制出来,对照FINS响应帧结构逐字段解析。这个过程很笨,但很有效。

4. 地址编码:为什么D100和CIO100的写法完全不同

4.1 内存区代码与地址的换算关系

FINS命令里的地址不是直接写“D100”,而是用内存区代码+偏移量的方式编码。不同内存区的代码和地址范围如下:

内存区代码地址范围单位
CIOB00~6143字
WRB10~511字
DM820~32767字
EM0900~32767字
EM1910~32767字
HRB20~511字
ARB30~959字
定时器090~4095位/字
计数器090~4095位/字

以DM区为例,D100的编码是:内存区代码82,起始地址00 64(100的十六进制),读取长度00 0A(10个字)。所以数据区就是82 00 64 00 0A。

这里有一个容易混淆的点:地址是“字地址”还是“位地址”。读DM区时,地址是字地址,单位是字。但读CIO区的位时,地址编码方式不同:需要把位地址换算成“字地址+位偏移”。比如CIO 0.05,字地址是0,位偏移是5,编码时先写字地址00 00,再写位偏移05。这个换算规则在FINS规范里有明确定义,但不同PLC型号可能有细微差异,最稳妥的办法是查对应型号的通信手册。

4.2 位操作与字操作的命令差异

FINS命令里,位操作和字操作是分开的。读位用01 01,但数据区里要指定“位”而不是“字”。写位用01 02,数据区里每个位用1个字节表示(00或01)。

实际工程中,批量读位不如批量读字再在应用层拆位。因为FINS的位读取命令一次最多读256个位,而字读取一次可以读512个字(1024字节)。如果产线上有几百个传感器信号要采集,逐位读的效率远低于读一整块DM区再拆位。我做过对比:读1000个位,逐位读需要几十条命令,读字再拆位只需要几条命令,速度差了一个数量级。

4.3 跨内存区批量读取的实现思路

FINS支持01 03命令一次读取多个不连续的内存区。这个命令的数据区格式是:先写要读的区数(1字节),然后每个区写内存区代码、起始地址、读取长度。响应帧则按顺序返回每个区的数据。

这个命令在采集分散地址时非常有用。比如要读D100的10个字、CIO200的5个字、WR50的3个字,用01 03一条命令就能搞定,不需要发三条01 01。但要注意:一次读取的总数据量不能超过PLC的响应缓冲区大小。不同型号的PLC缓冲区大小不同,常见的是1024字节或2048字节。超过这个限制会返回03 02错误。

5. 调试实战:从抓包到跑通的完整排查链路

5.1 用Wireshark确认FINS/TCP握手是否完成

调试FINS/TCP的第一步,不是写代码,而是用Wireshark抓包确认握手流程。打开Wireshark,过滤tcp.port == 9600,然后让上位机发起连接。正常的流程应该是:

  1. TCP三次握手(SYN、SYN-ACK、ACK)
  2. 上位机发送节点地址自动分配请求(命令码00 00 00 00)
  3. PLC返回分配响应,包含分配的节点号
  4. 上位机发送FINS命令(如01 01读DM区)
  5. PLC返回FINS响应

如果第2步没有出现,或者第3步的响应里错误码不是0,后面的命令一定失败。我遇到过一种情况:上位机代码里直接发了01 01,跳过了握手,PLC返回01 01错误码(本地节点未加入网络)。排查了半天才发现是握手缺失。

5.2 解析响应帧时最容易忽略的字节序问题

FINS协议的所有多字节字段都是大端序(高字节在前)。但很多编程语言的默认字节序是小端序,解析时如果不做转换,读出来的地址和长度全是错的。

比如响应帧里返回的读取长度是00 0A,大端序解析是10,小端序解析是2560。这个错误很隐蔽,因为TCP层看起来一切正常,只是数据对不上。我的习惯是:所有FINS字段解析都显式指定大端序,不依赖语言默认行为。在Python里用struct.unpack('>H', data),在C#里用BinaryPrimitives.ReadUInt16BigEndian,在Java里用ByteBuffer.order(ByteOrder.BIG_ENDIAN)。

5.3 超时与重试策略:什么时候该重发,什么时候该放弃

FINS/TCP的超时设置很关键。如果超时太短,PLC稍微忙一点就超时;如果太长,采集效率上不去。我的经验值是:

  • 单条命令超时:500ms~1000ms
  • 重试次数:2~3次
  • 重试间隔:100ms~200ms

但不是所有错误都值得重试。错误码01 04(目标节点忙)可以重试,因为PLC可能只是暂时忙。错误码03 01(地址越界)重试一万次也没用,必须改地址。错误码01 03(目标节点未响应)要区分情况:如果是网络抖动,重试可能成功;如果是PLC断电,重试只是浪费时间。

我的做法是:在应用层维护一个错误码分类表,可重试的错误才进入重试队列,不可重试的错误直接上报。这样既不会因为网络抖动丢数据,也不会在配置错误时无限重试。

6. 工程化落地:把FINS采集做成稳定可维护的模块

6.1 连接池与心跳机制的设计要点

如果上位机需要同时采集多台PLC,不要每台PLC都开一个长连接然后不管。PLC的TCP连接数有限,而且长时间空闲的连接可能被PLC主动断开。我的做法是:

  • 维护一个连接池,每个PLC对应一个连接
  • 每隔30秒发一条05 01(读控制器状态)作为心跳
  • 心跳失败时标记连接为“可疑”,下次采集时优先重连
  • 连接空闲超过5分钟自动关闭,释放资源

心跳命令的选择也有讲究。05 01只读状态,不涉及内存区,对PLC的负担最小。不要用心跳去读DM区,那样会增加PLC的扫描周期负担。

6.2 数据缓存与变化上报的取舍

很多采集场景不需要“每次读到的值都上报”,而是“值变化时才上报”。这时候可以在应用层做缓存:每次读到数据后和上一次比较,只有变化超过阈值才推送到上层。

但缓存会引入延迟。如果上层需要实时曲线,缓存就不合适。我的经验是:状态量(开关、报警)用变化上报,模拟量(温度、压力)用定时上报。变化上报的阈值要根据物理量的噪声水平来定,太小会频繁触发,太大会漏掉真实变化。

6.3 多PLC并发采集时的资源竞争问题

当上位机同时采集几十台PLC时,线程池的大小和命令队列的管理就成了瓶颈。如果每个PLC一个线程,线程切换的开销会很大。如果用一个线程轮询所有PLC,单台PLC的响应慢会拖累其他PLC。

我的方案是:用固定大小的线程池(比如8~16个线程),每个线程从队列里取任务,任务粒度是“一台PLC的一批命令”。这样既不会创建过多线程,也不会因为单台PLC慢而阻塞全局。队列里要设置优先级,报警相关的命令优先于常规采集。

另外,FINS的SID字段在并发场景下要特别小心。如果多个线程同时向同一台PLC发命令,SID必须唯一,否则响应会串。我的做法是:每个PLC连接维护一个SID计数器,每次发命令前递增,响应回来时按SID匹配。

7. 几个让我印象深刻的现场问题

第一个是地址偏移的“1”问题。某次调试,读D100读不到,读D101却能读到。查了半天发现是PLC的DM区起始地址被设置成了D1而不是D0。FINS命令里的地址是绝对地址,但PLC内部可能有一个偏移量。遇到地址对不上时,先确认PLC的地址设置,再怀疑命令编码。

第二个是响应数据长度和请求长度不一致。请求读10个字,响应却返回了20个字。后来发现是PLC的“数据格式”设置成了“双字”模式,每个地址占4个字节。FINS命令本身不感知这个设置,它只按字节返回。所以解析响应时,不能假设“请求10个字就返回20个字节”,要以实际返回的长度为准。

第三个是UDP采集时的乱序问题。用UDP高频采集时,偶尔会出现响应顺序和请求顺序不一致。如果应用层按顺序解析,数据就会错位。解决办法是在每条命令里填不同的SID,响应回来时按SID匹配,而不是按到达顺序匹配。

这些问题的共同点是:FINS协议本身不复杂,复杂的是PLC的配置和现场的网络环境。协议规范只定义了“命令怎么发、响应怎么回”,但PLC怎么配、网络怎么走,规范里不会写。这些只能靠现场经验和抓包分析来补。

8. 写给准备上手FINS的人

如果你刚开始接触FINS,我的建议是:先不要写代码,先用现成的调试工具把一条命令跑通。很多PLC厂商提供FINS调试工具,或者可以用通用的TCP调试助手手动构造命令帧。手动发一条01 01读DM区,看到正确的响应之后,再把这个过程用代码实现。这样你能清楚地知道每一步在干什么,而不是被代码里的封装层遮住细节。

另外,抓包工具是FINS调试的命根子。Wireshark能看TCP层的原始字节,串口监视器能看串口层的原始字节。遇到问题时,先把原始字节抓出来,对照FINS帧结构逐字段解析。大部分问题都能通过这种方式定位。

最后,不要迷信“一次配置永久有效”。产线的网络环境、PLC的负载、上位机的并发量都会变化。今天跑通的配置,明天可能因为网络抖动或PLC固件升级而失效。所以采集模块要有足够的日志和错误上报,出问题时能快速定位是网络问题、配置问题还是PLC问题。

FINS协议本身并不难,难的是把它放在真实的工业现场里跑稳。希望这篇内容能帮你少走一些我当年走过的弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进