做上位机开发第二个月,我差点被欧姆龙PLC通信协议折腾到怀疑人生。手里的CP1H明明Ping得通,程序里的Socket也显示连接成功,可发过去的FINS报文就是没有任何响应。后来把欧姆龙PLC通信协议的帧格式、地址换算、握手流程一条条捋清楚,才发现问题全藏在细节里。这篇就把我踩过的坑和最后搞明白的东西整理出来,给正准备和欧姆龙PLC做串口或以太网通信的朋友一个参考,特别是第一次接触HostLink、FINS/TCP这些概念、又不想在文档里泡三天的人。
1. 欧姆龙PLC通信协议选型:HostLink、FINS/UDP与FINS/TCP怎么选
1.1 三条通信路径的真实适用场景
很多人一上来就翻协议文档,其实第一步应该先想清楚走哪条路。欧姆龙PLC的通信协议主要有三条路:HostLink、FINS/UDP和FINS/TCP。它们之间的差别不是简单的“串口vs网口”,而是定位完全不同。
HostLink是欧姆龙老牌的串口通信协议,跑在RS-232或RS-422上,数据是ASCII文本。读DM区一个字的命令大概长这样:@00RD00640001FCS*\r\n。其中@是起始符,00是单元号,RD表示读,0064表示地址偏移,0001表示读取字数,FCS是前面所有字符异或后转成的两位十六进制ASCII,最后以*\r\n收尾。这种协议简单直接,用串口调试助手就能看出报文内容,但速率上限就是串口本身的115200bps,而且RS-232抗干扰能力一般,车间里走远了容易出问题。它适合近距离、点位不多、对实时性要求不高的老设备场景。
FINS/UDP则完全是另一个思路。FINS是欧姆龙自有的工控网络协议,报文是二进制的,跑在UDP上,默认端口9600。相比HostLink,FINS/UDP免去了ASCII和十六进制之间的来回转换,直接发二进制帧,效率高很多,而且没有握手过程,连上就发,延迟低。但UDP是不可靠传输,报文丢了不会自动补,偶尔一次超时还能忍,如果交换机上有广播风暴或者跨了网段,丢包率会直接让人崩溃。
FINS/TCP是在FINS外面包了一层TCP连接,先建立可靠连接,再收发FINS命令帧。它既有二进制的效率,又有TCP的可靠性,还能跨网段通信,是目前我做欧姆龙以太网通信的首选方案。三个方案放一起对比,能看得更清楚:
| 通信方式 | 物理层 | 报文形式 | 可靠性 | 典型应用 |
|---|---|---|---|---|
| HostLink | RS-232/422 | ASCII文本 | 中,依赖应用层校验 | 短距离、老设备、串口触摸屏 |
| FINS/UDP | 以太网 | 二进制FINS | 低,UDP丢包需重试 | 同网段、高实时采集 |
| FINS/TCP | 以太网 | 二进制FINS+TCP头 | 高,TCP可靠流 | 跨网段、长时间稳定采集 |
1.2 我为什么最后锁定了FINS/TCP
我的项目场景是中控室定时采集三个站点的PLC数据,交换机之间隔了两层,PLC和上位机不在同一网段。最开始我用FINS/UDP,程序刚跑起来看着挺顺,一个晚上过去发现数据缺了十几个点,全是UDP丢包造成的。后来想改成HostLink,结果车间布线已经结束,再拉串口线不现实。
最后换到FINS/TCP,一个晚上跑下来数据一个不少,才意识到选型这一步省不得。FINS/TCP只在握手阶段有一次额外的来回,后续每条命令都是长连接上的普通收发,效率损失可以忽略,但可靠性提升是质的区别。而且PLC侧只需在以太网设置里开启FINS/TCP服务,固定端口9600,防火墙规则也很好配,生产环境落地非常省心。
还有一点,欧姆龙配套的软件和文档对FINS/TCP的支持明显更全,CX Programmer里的网络设置、CX-Protocol的示例、官方手册里的报文示例,很多都是围绕FINS展开的。与其在HostLink的ASCII文本格式里手推校验码,不如直接吃透一套二进制协议,后面换机型、加点位都会轻松很多。
2. FINS/TCP协议帧结构逐字节拆解与地址换算避坑
2.1 FINS/TCP报文结构:从16字节头到10字节FINS帧
选定FINS/TCP之后,真正的坑才开始。第一次联调时我以为Socket连上就能收发数据,结果连PLC的响应都等不到。后来才搞明白,FINS/TCP的报文分两层:外层是FINS/TCP头,内层才是FINS命令帧。
外层头固定16字节,结构是四个32位无符号整数:
- 命令码:连接请求时为
0x00000000,后续发送FINS命令时为0x00000002 - 错误码:服务器响应时返回,0表示正常
- 保留字段:固定填0
- 数据长度:后面内层FINS帧的字节数
内层FINS帧前10字节是固定头,每个字节都有明确含义:
| 字节 | 含义 | 常见值 |
|---|---|---|
| ICF | 信息控制字段 | 0x80(表示需要响应) |
| RSV | 保留 | 0x00 |
| GCT | 网关计数 | 0x02 |
| DNA | 目标网络号 | 0x00(本地网络) |
| DA1 | 目标节点号 | PLC的节点地址 |
| DA2 | 目标单元号 | 0x00(CPU内置端口) |
| SNA | 源网络号 | 0x00 |
| SA1 | 源节点号 | 上位机节点地址 |
| SA2 | 源单元号 | 0x00 |
| SID | 服务标识 | 每次命令建议递增 |
这10个字节后面紧跟命令码和参数。比如读取内存区的命令码是0101,写内存区是0102。我最早犯的错就是把DNA填成了1,因为看文档觉得“目标网络”应该给个非零值,结果PLC直接不认。实际上在单层网络里,DNA就是0。
2.2 DM区与CIO区地址换算:最容易翻车的一关
地址换算这关,我印象太深了。PLC程序里写的是D100,文档里说FINS协议地址是十六进制偏移量,我直接在报文里填了82 100 00,然后看着PLC的响应错误码发愣。
先看结论,D100的正确FINS起始地址字段是82 00 64,其中:
82是DM区内存区代码,表示读写DM字数据00 64是地址偏移的十六进制表示,100(十进制)= 0x0064,高字节在前、低字节在后
我最初犯的错误就是把面板上的“100”当成十六进制填进去,导致PLC去读偏移0x100=256的那个字,当然读回来的数据完全不对劲,严重的话直接返回地址越界错误。正确换算方法其实很简单:PLC面板的D编号就是十进制偏移,转成2字节十六进制,高位在前、低位在后。
CIO区也是一样的套路。CIO100对应的FINS起始地址是30 00 64,30是CIO字区的内存区代码。这里有个大坑:欧姆龙有些资料里把CIO位区代码写成B0,有些触摸屏组态软件的地址写法又是CIO100不带区号,导致我一度以为要用B0去读整个字,结果读出来的数据完全对不上。记住区分:读“字”用30,读“位”才用B0。
常见的几个区可以做成速查表:
| PLC地址 | 十进制偏移 | 十六进制偏移 | 内存区代码 | FINS起始地址字段 |
|---|---|---|---|---|
| D100 | 100 | 0064 | 82(DM字) | 82 00 64 |
| D200 | 200 | 00C8 | 82(DM字) | 82 00 C8 |
| CIO100 | 100 | 0064 | 30(CIO字) | 30 00 64 |
| W100 | 100 | 0064 | 31(WR字) | 31 00 64 |
| H100 | 100 | 0064 | 02(HR字) | 02 00 64 |
不同系列的PLC内存区代码会有细微差异,CJ/NJ/CP系列略有不同,务必以对应型号的通信手册为准。但换算思路是一致的:面板地址转十进制偏移,再转2字节十六进制,内存区代码按手册查表。
2.3 握手、心跳与超时:稳定运行的三块基石
FINS/TCP连接建立后不是立即就能发数据,必须先走一次握手。握手包的命令码是0x00000000,数据长度4字节,内容是4字节的本地节点号。PLC收到后返回同样的结构,错误码字段为0表示握手成功。
这个握手流程我一开始完全不知道,直接发0101读命令,结果PLC那边Socket不认,等一下就把连接断开了。后来按顺序补上握手,数据往来才恢复正常。所以连接流程必须严格保持三步:TCP三次握手、FINS/TCP握手、FINS命令交互。
心跳机制也值得单独说。FINS/TCP连接建立后,如果长时间没有数据传输,有些PLC的通信模块会自动释放连接。我在现场就出现过半夜数据不跑了、第二天早上重新连又恢复的情况。后来在代码里加了一个30秒的定时器,每隔30秒发一条读取时钟区的命令(命令码0701),既保活了连接,又顺便校对上位机和PLC的时间,一举两得。
超时设置方面,我建议命令响应超时设为2到3秒,重试2次。太短容易误判为故障,太长会拖慢故障发现速度。跨网段场景第一次建立连接可以放长到5秒,因为TCP握手加FINS握手需要两次往返。不要天真地以为局域网内都是毫秒级响应,交换机拥塞、PLC扫描周期抖动都会让响应时间翻倍。
3. 欧姆龙PLC通信实操:Python先验证、C#上位机落地
3.1 先用Python脚本验证FINS/TCP链路
正式写上位机之前,我强烈建议先用Python脚本把链路验证通。原因很简单:脚本改得快、看得见报文、能快速定位问题,等协议全通了再搬到正式工程里。
下面这个脚本实现了FINS/TCP连接、读DM区一个字,注释里写清了每一段的作用:
import socket import struct import time class OmronFinsTcp: def __init__(self, ip, port=9600, plc_node=1, local_node=0x0F): self.ip = ip self.port = port self.plc_node = plc_node # PLC节点地址,DA1 self.local_node = local_node # 上位机节点地址,SA1 self.sid = 0 self.sock = None def connect(self): self.sock = socket.create_connection((self.ip, self.port), timeout=5) # FINS/TCP握手包: 命令=0, 错误码=0, 保留=0, 长度=4 # 数据: 4字节本地节点号 packet = struct.pack('>IIII', 0x00000000, 0x00000000, 0x00000000, 0x00000004) packet += struct.pack('>I', self.local_node) self.sock.sendall(packet) resp = self.sock.recv(1024) if len(resp) < 16: raise RuntimeError('握手响应长度异常') err = struct.unpack('>I', resp[4:8])[0] if err != 0: raise RuntimeError(f'握手失败, 错误码: 0x{err:08X}') def _build_fins_command(self, command_code: bytes, data: bytes) -> bytes: # 10字节FINS固定头 fins_body = bytes([ 0x80, # ICF 需要响应 0x00, # RSV 0x02, # GCT 0x00, # DNA self.plc_node, 0x00, # DA2 0x00, # SNA self.local_node, 0x00, # SA2 self.sid ]) + command_code + data self.sid = (self.sid + 1) & 0xFF # FINS/TCP头: 命令=2, 错误码=0, 保留=0, 长度=len(fins_body) packet = struct.pack('>IIII', 0x00000002, 0x00000000, 0x00000000, len(fins_body)) return packet + fins_body def read_dm(self, address: int, count: int = 1) -> list: # 0101读命令, DM区代码0x82, 地址2字节, 读取字数2字节 cmd = bytes([0x01, 0x01]) addr = bytes([0x82, (address >> 8) & 0xFF, address & 0xFF]) param = addr + struct.pack('>H', count) self.sock.sendall(self._build_fins_command(cmd, param)) # 读响应头: 16字节FINS/TCP头 + 响应体 header = self._recv_exact(16) length = struct.unpack('>I', header[12:16])[0] body = self._recv_exact(length) # 响应体: 10字节FINS头 + 命令码 + 结束码 + 数据 err_code = int.from_bytes(body[12:14], 'big') if err_code != 0: raise RuntimeError(f'FINS错误码: 0x{err_code:04X}') data = body[14:] return [int.from_bytes(data[i:i+2], 'big') for i in range(0, len(data), 2)] def _recv_exact(self, n: int) -> bytes: buf = b'' while len(buf) < n: chunk = self.sock.recv(n - len(buf)) if not chunk: raise ConnectionError('连接已断开') buf += chunk return buf调用方式很简单:
if __name__ == '__main__': plc = OmronFinsTcp('192.168.1.10', plc_node=1) plc.connect() for _ in range(3): values = plc.read_dm(100, 1) print(f'D100 = 0x{values[0]:04X} ({values[0]})') time.sleep(1)第一次跑通时,我在PLC程序里给D100写死了一个#1234,也就是十六进制0x1234,上位机打印出来的值完全一致,那种“终于对上了”的感觉,是前面所有抓狂最好的解药。先用已知值验证,再测真实数据,这条原则能帮你过滤掉80%的通信问题。
3.2 C#上位机落地:从验证脚本到工程化
Python验证通过之后,C#落地就简单多了,但有几个点必须处理好,不然照样翻车。
第一是连接生命周期管理。FINS/TCP连接不能反复断开重建,那时候握手开销反而比命令本身还大。我一般维护一个常驻连接对象,用锁保护发送和接收,避免多个线程同时写Socket导致报文交叉错乱。欧姆龙PLC的通信模块并发能力有限,一条连接串行发命令是最稳妥的方式。
第二是SID的管理。FINS协议里每条命令都带SID,响应帧会回显相同的SID。这个字段的作用是匹配请求和响应,防止读到上一条命令的旧响应。C#工程里我把它做成了每次发送自动递增的计数器,越界就回绕,很简单但很有用。没有SID管理,偶尔会读到莫名其妙的历史数据。
第三是断线重连逻辑。生产环境里交换机重启、网线松动都可能让TCP连接断开。我在程序里加了心跳超时检测,连续3次没有响应就触发重连流程,重连时先关闭旧Socket再重新执行完整握手。不能只重建Socket而不重走FINS握手,否则PLC侧状态对不上,照样收不到数据。
C#命令组包的核心代码和Python思路一致,只是换成手动拼byte数组,这里不再赘述。工具选型上,如果只是给PLC写个简单调试工具,轻量方案就够了;如果要做多设备管理系统,建议用欧姆龙官方提供的FINS库封装,或者直接购买成熟的通信中间件,别在底层协议上重复造轮子。
4. 欧姆龙PLC通信常见故障排查与避坑经验汇总
4.1 故障现象速查表
联调这一个月,我把自己踩过的和帮同事排掉的坑整理成了一张速查表,按现象快速定位原因,能省很多时间:
| 现象 | 可能原因 | 排查办法 |
|---|---|---|
| Ping不通PLC | IP冲突、网段不一致、网线松动 | 先固定IP,用ping验证物理链路 |
| Ping通但连接被拒 | PLC未开启FINS/TCP服务、防火墙拦截9600 | 检查PLC以太网设置里的FINS/TCP使能选项 |
| 连接正常但命令无响应 | FINS/TCP握手未完成、数据长度字段算错 | 用抓包对比报文,确认长度字段与实际字节数一致 |
| 响应错误码0201 | 目标节点号DA1填错、PLC节点地址不一致 | 核对PLC节点号设置,确认请求帧里的DA1 |
| 响应错误码1103 | 地址越界或内存区代码不对 | 检查地址偏移是否超范围,对照手册查内存区代码 |
| 偶发超时 | UDP丢包、交换机拥塞 | 改用FINS/TCP,加超时重试机制 |
| 读回数据全对不上 | 字节序搞反、地址没换算成十六进制 | 先在PLC写已知值验证,再测真实数据 |
每次排查,先确认物理层,再确认协议层,最后才是应用层。很多问题其实出在PLC侧某个配置开关没打开,而不是程序代码的问题。
4.2 排查思路与心得:先抓包,别瞎猜
如果排查表还没解决你的问题,那就用抓包工具。Wireshark对FINS协议有解析支持,能直接把FINS/TCP帧里的命令码、地址、长度拆开给你看。我第一次看到Wireshark里详细解析的报文时,才发现自己把数据长度字段算错了整整4个字节,程序里看起来没啥问题,但PLC每次都在等剩余的字节,自然没有响应。
抓包时注意过滤条件,tcp.port == 9600就能把FINS流量过滤出来。对照报文逐字节检查顺序:TCP三次握手、FINS/TCP握手、FINS命令帧。哪一步断了,问题就在哪一步。
串口场景则用串口调试助手,把HostLink报文直接发出去看返回。HostLink报文本身就是ASCII,肉眼可读,排查比二进制FINS还方便。重点检查单元号是不是2位十六进制、FCS校验值是不是对上了。
最后一个心得:一次只改一个变量。联调时最容易犯的错是同时改PLC配置、改上位机代码、改地址表,出了问题根本不知道是哪一项导致的。我现在的做法是先用固定的IP、固定的节点号、PLC里写死一个已知值,把最简单的“读1个字”跑通,然后再逐步加点位、改地址。每一步都验证,看起来慢,实际上是最快的路径。
这套折腾下来,我最深的体会是:通信协议本身并不难,难的是你对“发送出去的每一个字节”有没有敬畏之心。欧姆龙PLC通信协议里每一段帧头、每一个地址偏移、每一位校验码,都是文档里白纸黑字写好的,出错的原因基本都是没看完、没看懂、没验证。建议大家联调时多准备一个Wireshark和串口助手,把第一帧报文逐字节对照文档过一遍,后面的工作会顺畅很多。另外,地址换算和内存区代码这种细节,不同系列的PLC有差异,遇到问题先翻对应型号的通信手册,别凭经验硬套。最后再分享一个小技巧:正式项目里把SID、响应时间、重试次数都打成日志,哪怕现场出了问题,也能在日志里精确还原每一帧的通信过程。