搞工控的人,迟早会撞上“欧姆龙PLC怎么读数据”这道坎。我最早接手某型号PLC时,手里只有一份官方手册,翻到“FINS协议”这四个字,完全不知道从哪下手。后来在车间里对着抓包工具反复试,才把这条链路彻底跑通。FINS(Factory Interface Network Service)是欧姆龙自家的标准通信协议,跑在串口、UDP、TCP上都行,覆盖了内存读写、位操作、PLC启停、参数读取这些最常见需求。它的帧结构本身不复杂,但网上资料很零散,很多教程只甩一张命令码表,不讲帧头怎么拼、节点号怎么对、响应码怎么查,新手抄完代码还是拿不到数据。这篇我直接按实战顺序讲清楚:先拆报文的每个字节,再上手写一个能跑的Python例程,最后把我调过的节点号冲突、字节序反了、地址越界这些坑全部摆出来。适合刚接触欧姆龙PLC通信、或者写上位机时对着文档发愣的同学。
1. FINS是什么?先把它放在工控通信的正确位置
1.1 一个协议打天下:欧姆龙为什么需要FINS
欧姆龙早期不同系列的PLC,通信方式五花八门,有的走编程口,有的走Host Link,有的走专用协议。对于写上位机的人来说,这意味着每换一个PLC型号,驱动代码就要推翻重写,现场维护成本很高。FINS的出现,本质上就是把“内存访问”“设备控制”“状态查询”这些操作抽象成一套统一的帧格式,无论底层是串口、以太网UDP还是TCP,上层报文都长一个样。
你可以把FINS想象成一张“贴好地址的通用快递单”:不同PLC的内存区就是不同的仓库,快递单上写清楚发到哪个区、从哪个地址开始、拿多少数据,仓库管理员按单子领货发货就行。至于快递车是走公路还是走铁路,那是网络层的事。理解这个定位很重要——你后面写上位机时,不需要关心PLC内部数据结构,只需要按照FINS的规则组装报文,剩下的事由PLC自己消化。
这套协议在欧姆龙各系列中保持了极高的兼容性,从老型号到新系列,核心命令编码基本一致。这就意味着,你花了两天时间吃透FINS读写逻辑,以后碰到欧姆龙其他型号,大部分代码可以直接复用。这也是为什么很多设备厂商的采集程序里,欧姆龙驱动总是最先被稳定支持的那一批。
1.2 FINS能干什么:它不止能读D区
很多人一提FINS就想到读D区数据,其实它的应用范围要宽得多。我常用的功能至少包括这些:
- 内存区读写:DM区、CIO区、WR区、HR区、AR区,可以按字读,也可以按位读。
- 内存区写入:写参数、写配方、批量修改设定值,跑生产时需要把工艺参数下发到PLC。
- 位操作:单个开关量点、按钮、输出点的置位和复位,相当于远程按按钮。
- 运行控制:启动、停止PLC,读取运行状态。
- 时钟操作:读PLC当前时间,也能对时,很多项目用这个做设备日志的时间基准。
- 诊断信息:读取CPU错误日志、节点信息、型号版本号,排查问题时非常有用。
这些功能对应不同的命令码,但核心的帧格式是同一套。你只要把“帧头怎么拼、命令码怎么填、参数怎么排”学会了,其他命令无非是换一下命令码和数据段而已。这也是我强烈建议先把这篇文章涉及的原理看懂的原因——它不是教你背几条命令,而是教你一种“以后自己也能查手册扩展”的方法。
2. 报文拆解:一帧完整的FINS报文到底长什么样
2.1 十字节固定帧头:每个字段都是一份收件地址
FINS报文在UDP或TCP里传输时,最前面永远是10个字节的固定帧头。初学者最头疼的就是这10个字节,因为每个字节都有明确含义,填错了,PLC要么不搭理你,要么直接回错误码。
| 字段 | 长度 | 含义 | 常见值 |
|---|---|---|---|
| ICF | 1字节 | 信息控制字段,控制是否需要响应 | 请求帧用0x80,表示“必须给我回响应” |
| RSV | 1字节 | 保留字节 | 固定0x00 |
| GCT | 1字节 | 网关计数 | 固定0x02 |
| DNA | 1字节 | 目标网络号 | 单网络环境下填0x00 |
| DA1 | 1字节 | 目标节点号 | PLC侧设置的FINS节点号,比如0x01 |
| DA2 | 1字节 | 目标单元号 | CPU单元一般填0x00 |
| SNA | 1字节 | 源网络号 | 单网络环境下填0x00 |
| SA1 | 1字节 | 源节点号(上位机自己的节点号) | 自己指定,比如0x0A,不能与PLC重复 |
| SA2 | 1字节 | 源单元号 | 上位机填0x00即可 |
| SID | 1字节 | 服务标识符,用来配对请求和响应 | 可以递增,比如0x01、0x02、0x03 |
ICF字段值得多说一句。你发的请求帧ICF设成0x80,表示你要求目标设备回响应;PLC收到后,回复报文里的ICF会变成0xC0,协议用这个值标记“这是一条响应帧”。有些调试工具只看报文内容,不解释含义,新手看到ICF不一样容易懵,其实这很正常。
DA1和SA1是新手最容易填错的地方。DA1填的是PLC的FINS节点号,不是PLC的IP地址。很多PLC的IP地址和FINS节点号没有换算关系,需要单独在PLC参数里设置。你Ping得通PLC,不代表FINS节点号填对了。
SID字段我建议养成递增的习惯。当上位机同时发多条请求时,PLC响应帧会原样带回SID,你就能用SID判断这条响应对应的是哪条请求。如果程序里SID始终填同一个值,万一网络乱序或者超时重发,你就无法区分旧响应和新响应,程序会越跑越乱。
2.2 命令区与数据段:请求和响应是如何对上的
10字节帧头之后,就是真正的“业务内容”。FINS命令区占2个字节,第一个字节是主命令码,第二个字节是副命令码。比如最常用的内存区读是0x01 0x01,内存区写是0x01 0x02。
这里有个小规律:绝大多数请求命令的副码都是奇数,对应的响应帧里,同样的位置会变成响应状态码。比如读命令0x01 0x01发出后,PLC返回的响应帧中,第11、12字节(从0开始数是第10、11字节)不再是命令码,而是“主响应码”和“副响应码”。0x00 0x00表示正常完成,其他值就是错误代号。
实际调试时,我习惯先把响应帧的状态码打出来。只要状态码是0x00 0x00,后续数据段大概率是对的;如果状态码非0,先排错,再看数据,能省很多时间。
2.3 内存区编码与地址计算:能不能读对数据就看这一节
内存区代码是报文中指明“我要访问哪个区”的字段,不同区域、读和写用的是不同代码。我把最常用的几个整理成了表格:
| 内存区 | 读代码 | 写代码 | 可访问粒度 | 说明 |
|---|---|---|---|---|
| CIO区 | 0x80 | 0x81 | 字/位 | 包含物理I/O、内部继电器等 |
| DM区 | 0x82 | 0x83 | 字 | 数据存储区,最常用 |
| WR区 | 0xB0 | 0xB1 | 字/位 | 工作继电器区 |
| HR区 | 0x88 | 0x89 | 字/位 | 保持继电器区 |
| AR区 | 0xA0 | 0xA1 | 字/位 | 特殊辅助继电器区 |
紧接着内存区代码,是2字节的起始地址,再往后是2字节的读取长度。这里有一个几乎所有人都会踩的坑:地址字段的编码方式因内存区而异,不是简单地把地址编号转成十六进制。
对于DM区这种只能按字访问的区域,地址就是D编号直接转成2字节。比如D100,十六进制是0x0064,所以报文中起始地址就是0x00 0x64。要读10个字,长度字段就是0x00 0x0A。这是最简单的。
对于CIO区、WR区、HR区这类可位访问的区域,2字节地址的高字节表示“字编号”,低字节表示“位编号”。举个例子:W0.05这个位,地址是0x00 0x05;W1.00这个位,地址就是0x01 0x00。如果是按字访问W1这个字,地址依然填0x01 0x00。CIO区同理,CIO100这个字,地址是0x64 0x00,而不是0x00 0x64。
很多人把DM区的规则套到CIO区,地址自然就错了,读出来的数据要么报地址越界,要么读到完全不相干的位置。我的建议是:写代码之前,先在纸上把目标地址换算成十六进制,对照一下高字节和低字节是否符合该区域的规则,再拼报文。
3. 通信方式与节点号:UDP、TCP、FINS/TTCP的区别与选择
3.1 串口、UDP、TCP、FINS/TCP,我该怎么选
FINS的底层承载方式有几种,不同方式的适用场景完全不同。我自己做项目时是这么选的:
- 串口方式:适用于老设备或短距离现场,比如RS-232或者RS-422/485。稳定但速度慢,报文格式在帧头前后还会额外套串口专用校验,不建议新项目优先考虑。
- UDP方式:车间局域网内最常用。无连接、速度快、实现简单,PLC收到UDP报文后会原路回包。适合上位机周期性采集数据。
- TCP方式:FINS/TCP是带握手的可靠连接,适合跨路由、数据量大、丢包敏感的场合。代价是通信建立稍复杂,需要处理握手过程。
- 嵌入式网关方式:有些第三方网关把FINS转换成Modbus等协议,但本质上还是绕了一圈,能做但没必要,除非你确实不想写FINS报文。
车间里多数欧姆龙PLC采集项目,我都优先推荐UDP。理由很实际:PLC的UDP响应是基于请求的,上位机不发,PLC不主动塞数据,不会造成网络拥塞。而且UDP报文格式就是纯FINS帧,没有额外封装,用Python几分钟就能跑通。
3.2 FINS/TCP握手到底是怎么一回事
FINS/TCP使用固定端口9600,和UDP的端口一样,但通信前多了一步握手。简单说,客户端先向PLC发起TCP连接,然后发送一个“节点地址请求”的握手帧。这个帧里带着客户端自己的网络号、单元号,以及想使用的节点号;如果填0,相当于请求PLC分配一个可用节点号。PLC收到后,如果成功,就返回一个握手响应帧,并在其中确认或分配一个FINS节点号。此后客户端发所有FINS命令时,SA1字段都必须用这个节点号。
这个设计初看有点绕,实际是为了解决“上位机节点号冲突”的问题。TCP连接是点对点的,PLC可以通过握手给每个客户端分配唯一节点号,避免多台电脑同时采集时互相干扰。UDP模式下没有这个机制,所有上位机必须手工设置各自不重复的节点号,这也是UDP调试时最常见的坑之一。
3.3 网络号、节点号、单元号:三个号码一个都不能错
FINS寻址依赖三个关键编号,任何一个填错,请求都可能石沉大海。
- 网络号:单网络环境填0。如果你有子网或者使用路由,才需要区分网络号。
- 节点号:每台设备的FINS节点号,范围通常在1到254之间。PLC的节点号在PLC参数里设置,上位机的节点号自己指定,但绝对不能用0,也不能和PLC重复。
- 单元号:CPU单元一般填0。如果你访问的是通信模块、特殊模块,单元号才是模块对应的数字。
我见过太多案例,IP地址能Ping通,但上位机死活读不到数据,最后发现是PLC侧FINS节点号根本没设置,默认值不匹配,或者上位机节点号填了0。记住一句话:Ping通只代表网络通,FINS能不能通,取决于三个编号和PLC的FINS服务是否开放。
4. 实操:用Python从零跑通D区读写
4.1 准备工作:PLC侧和上位机侧都要设什么
动手写代码之前,先确认PLC侧参数。用编程软件连接PLC后,打开CPU参数里的以太网设置,主要看三处:IP地址是否和上位机在同一网段、FINS节点号是否已设置、FINS服务是否被使能。很多新型号PLC的FINS服务默认是打开的,但有些旧型号或特殊配置下会被关掉,这一点务必先确认。
上位机侧相对简单,只要保证能和PLC网络互通,并且防火墙允许UDP 9600端口通信。Windows系统调试时,我发现某些企业版系统默认会拦UDP广播,接收不到PLC回包,建议先把调试端口临时放行再测试。
4.2 读取D100开始的10个字:一条命令拆开看
假设PLC的FINS节点号是1,上位机节点号暂定10,目标是读取D100开始的10个字。这条UDP请求完整报文如下:
80 00 02 00 01 00 00 0A 00 01 01 01 82 00 64 00 0A对照帧头拆一下:
- 80:ICF,要求响应
- 00:RSV保留
- 02:GCT网关数
- 00:DNA目标网络号
- 01:DA1目标节点号,即PLC节点号
- 00:DA2目标单元号,CPU单元
- 00:SNA源网络号
- 0A:SA1源节点号,上位机自己
- 00:SA2源单元号
- 01:SID服务标识
- 01 01:主命令0x01,副命令0x01,即内存区读
- 82:内存区代码,DM区读
- 00 64:起始地址D100
- 00 0A:读取字数为10
用Python实现这个功能非常直接:
import socket import struct def fins_read_dm(ip, dm_addr, count, dst_node=0x01, src_node=0x0A, sid=0x01): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(2) header = bytes([ 0x80, # ICF:需要响应 0x00, # RSV 0x02, # GCT 0x00, # DNA dst_node, # DA1:PLC节点号 0x00, # DA2:CPU单元 0x00, # SNA src_node, # SA1:上位机节点号 0x00, # SA2 sid & 0xFF # SID ]) body = bytes([0x01, 0x01, 0x82]) # 内存区读、DM区 body += dm_addr.to_bytes(2, 'big') # 起始地址,如D100 -> 0x0064 body += count.to_bytes(2, 'big') # 读取字数 sock.sendto(header + body, (ip, 9600)) resp, _ = sock.recvfrom(4096) sock.close() if len(resp) < 12: raise RuntimeError('响应帧太短: %s' % resp.hex()) sid_resp = resp[9] mrc, src = resp[10], resp[11] if mrc != 0x00 or src != 0x00: raise RuntimeError('响应错误: 0x%02X 0x%02X' % (mrc, src)) data = resp[12:] if len(data) != count * 2: raise RuntimeError('数据长度异常: %d' % len(data)) return struct.unpack('>%dH' % count, data) if __name__ == '__main__': values = fins_read_dm('192.168.1.10', 100, 10) print(values)这段代码有几个细节我想单独说明。dm_addr.to_bytes(2, 'big')是高位字节在前,也就是大端序,FINS协议明确规定多字节数据高字节在前。响应解析时,我只取了第10和第11字节作为状态码,第12字节之后才读数据,因为FINS响应帧的10字节帧头后,紧接着是2字节状态码。还有,代码里用了struct.unpack('>%dH' % count, data),这个格式串表示按大端序解析count个无符号16位整数,比手工拼字节要清晰得多。
4.3 写D区数据:从D100开始写入三个字
写操作和读操作几乎是镜像关系,命令码变成0x01 0x02,区代码变成0x83,数据段末尾多出待写入的数据。写入三个字,比如100、200、300,完整报文为:
80 00 02 00 01 00 00 0A 00 02 01 02 83 00 64 00 03 00 64 00 C8 01 2C响应帧只有帧头和状态码,正常情况下状态码是0x00 0x00,没有数据段。对应Python代码:
def fins_write_dm(ip, dm_addr, values, dst_node=0x01, src_node=0x0A, sid=0x02): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(2) header = bytes([ 0x80, 0x00, 0x02, 0x00, dst_node, 0x00, 0x00, src_node, 0x00, sid & 0xFF ]) body = bytes([0x01, 0x02, 0x83]) # 内存区写、DM区 body += dm_addr.to_bytes(2, 'big') body += len(values).to_bytes(2, 'big') for v in values: body += v.to_bytes(2, 'big') # 注意:每个值都是大端序 sock.sendto(header + body, (ip, 9600)) resp, _ = sock.recvfrom(4096) sock.close() if len(resp) < 12: raise RuntimeError('响应帧太短: %s' % resp.hex()) mrc, src = resp[10], resp[11] if mrc != 0x00 or src != 0x00: raise RuntimeError('写入失败: 0x%02X 0x%02X' % (mrc, src)) return True这里我想强调一下字节序。写入的300,十六进制是0x012C,在报文中必须写成0x01 0x2C,而不是0x2C 0x01。如果这个顺序写反,PLC收到的值实际上是0x2C01,也就是11265,问题很难排查。遇到这种“写进去的值和监控软件看到的完全对不上”的故障,第一个怀疑对象就是字节序。
4.4 读响应里不只是整数:浮点数该怎么解析
D区里存的不一定都是整数,很多时候是浮点数。欧姆龙PLC存浮点数时,遵循IEEE 754标准,并且是大端序。用Python解析时,把相应的字节用struct.unpack('>f', data[i:i+4])处理即可。
举一个实际场景:D100和D101组成一个32位浮点数,那么读取4个字后,数据长度是8字节,前4字节解析为一个float,后4字节解析为另一个float。很多科学计算项目里,采集到的温度、压力、流量都以浮点形式存在D区,如果你还是按16位整数解析,就会得到一堆看起来毫无规律的数字。解析前先确认PLC侧的数据类型,是整数、浮点数还是BCD码,再决定用哪套解析方式。
5. 我踩过的坑:响应码与排查实战记录
5.1 响应码速查表:看到这些错误码先别慌
FINS协议的错误码分为主响应码和副响应码,常见的有这些:
| 主码 | 副码 | 含义 | 常见原因 |
|---|---|---|---|
| 0x00 | 0x00 | 正常完成 | 无 |
| 0x01 | 0x01 | 本地节点错误 | PLC的FINS节点号未设置 |
| 0x01 | 0x02 | 目标节点错误 | 目标节点号不存在或路由不可达 |
| 0x01 | 0x03 | 目标单元错误 | 单元号不对,比如CPU单元不是0 |
| 0x01 | 0x04 | 目标节点无响应 | UDP模式下设备离线或防火墙拦截 |
| 0x02 | 0x01 | 命令错误 | 命令码不存在或命令参数错误 |
| 0x02 | 0x02 | 地址超界 | 起始地址加上长度超出区域范围 |
| 0x02 | 0x03 | 数据无法读取 | 对位地址做字操作,或区域只写 |
| 0x02 | 0x05 | 数据无法写入 | 写保护或PLC处于不允许写状态 |
| 0x11 | 0x01 | 校验错误 | 串口线路干扰或帧损坏 |
遇到0x01 0x02和0x02 0x02的频率最高。0x01 0x02一般是节点号、网络号配置问题,0x02 0x02则是地址计算问题。每次看到0x02 0x02,我都先检查起始地址计算,特别是CIO区的字地址有没有按“高字节=字编号”的规则换算。
5.2 请求发出去了,但收不到任何响应?先查这三件事
第一种情况,报文根本没到PLC。先确认UDP端口是9600,再确认防火墙没有拦截。有一个简单方法:在PLC侧用通信监控功能看有没有收到上位机报文。如果PLC侧只收到请求但上位机收不到响应,通常是防火墙或上位机节点号问题。
第二种情况,FINS节点号不匹配。上位机节点号和PLC节点号是两套编号,很多人把SA1误填成PLC的IP地址最后一位,这是错的。SA1应该填上位机自定义的节点号,既不能是0,也不能和PLC重复。我调试时习惯把上位机节点号固定在10左右,避免和PLC常见节点1相撞。
第三种情况,PLC的FINS服务没开。有些型号的CPU参数里,“FINS/TCP服务有效”“UDP接收有效”这些选项需要手动勾选,出厂默认不一定打开。在编程软件里打开对应开关后重新上电,再测试,八成能通。
5.3 读到的数据和监控软件对不上?先检查这三处
第一处是字节序。FINS是大端序,但很多编程语言的默认字节序是小端序。你用C#或者Python读取时,如果没有显式指定高位在前,读出来的值就会错位。之前我写过一次C#程序,直接用了BitConverter.ToUInt16,结果所有数据和PLC监控值差了256倍,排查到半夜才发现是字节序问题。
第二处是数据类型。D区存的是32位浮点时,你按16位整数去读,出来的数当然不对。有些PLC把上位机写入的整数自动转成BCD码保存,这种情况在仪器仪表类项目里特别常见,解析方式又不一样。写程序前,先用PLC监控软件看几个已知地址的实际值和数据类型,再决定解析方式。
第三处是地址。不少人在CIO和DM区之间套用相同地址,比如读DM区用了0x0100这种地址,表面看没什么问题,实际早就跑偏了。DM区地址就是D编号本身,D256就是0x0100;但CIO区的0x0100对应的是CIO字1的地址,完全不是一码事。拿不准时,先在PLC里对某个地址写入已知值,再用报文读回来对比,是验证地址换算最直接的方法。
5.4 几个藏得更深的问题
有时候报文格式看着全对,响应码也是0,但数据刷新慢或者偶尔丢失。我遇到过一次多台上位机同时采集同一个PLC,大家都是默认节点号,结果PLC同时段的响应报文串了。UDP模式下没有连接状态,靠的就是节点号区分客户端,节点号重复会造成响应数据张冠李戴。解决办法很简单:每台上位机设置不同的SA1节点号。
还有一个问题来自SID。高频率采集时,如果SID始终固定不变,当请求超时重发后,新的响应和旧的响应无法区分,程序很容易把旧数据当成新数据。正确的做法是每次请求让SID递增,并用字典记录发出请求的SID,收到响应时根据SID匹配结果。
最后提醒一句,尽量不要循环单字读取100个连续地址。FINS支持一次读取多个字,你读10个字和读100个字,报文只差一个长度字段,但上位机循环发送100次请求,不仅拖慢速度,还容易触发PLC的处理瓶颈。批量读取,永远比单字循环稳妥。
我个人这些年调FINS协议,养成了一个习惯:每接手一个新项目,第一件事就是在PLC里放一段固定的测试数据块,比如前16个字分别是0到15,上位机连上后先读这个块。协议通不通、地址换算对不对、字节序是否正常,看这一块数据就一目了然。FINS本身并不神秘,把帧头、地址、节点号这三件事理顺,剩下的就是往报文里填数而已。遇到不会的命令,翻手册对照抓包,很快就能写对。