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命令帧结构是:
| 字段 | 长度 | 说明 |
|---|---|---|
| ICF | 1字节 | 信息控制字段,标识命令/响应、是否需要响应 |
| RSV | 1字节 | 保留,固定为0 |
| GCT | 1字节 | 网关计数,经过网关时递减 |
| DNA | 1字节 | 目标网络号 |
| DA1 | 1字节 | 目标节点号 |
| DA2 | 1字节 | 目标单元号 |
| SNA | 1字节 | 源网络号 |
| SA1 | 1字节 | 源节点号 |
| SA2 | 1字节 | 源单元号 |
| SID | 1字节 | 服务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”,而是用内存区代码+偏移量的方式编码。不同内存区的代码和地址范围如下:
| 内存区 | 代码 | 地址范围 | 单位 |
|---|---|---|---|
| CIO | B0 | 0~6143 | 字 |
| WR | B1 | 0~511 | 字 |
| DM | 82 | 0~32767 | 字 |
| EM0 | 90 | 0~32767 | 字 |
| EM1 | 91 | 0~32767 | 字 |
| HR | B2 | 0~511 | 字 |
| AR | B3 | 0~959 | 字 |
| 定时器 | 09 | 0~4095 | 位/字 |
| 计数器 | 09 | 0~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,然后让上位机发起连接。正常的流程应该是:
- TCP三次握手(SYN、SYN-ACK、ACK)
- 上位机发送节点地址自动分配请求(命令码
00 00 00 00) - PLC返回分配响应,包含分配的节点号
- 上位机发送FINS命令(如
01 01读DM区) - 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协议本身并不难,难的是把它放在真实的工业现场里跑稳。希望这篇内容能帮你少走一些我当年走过的弯路。