ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C# TcpEthernet 直连读写 OMRON PLC:FINS/TCP 协议实战与避坑

C# TcpEthernet 直连读写 OMRON PLC:FINS/TCP 协议实战与避坑

简介:这份源码面向工业自动化方向的C#开发者与工控上位机学习者,聚焦通过TCP/IP协议与OMRON(欧姆龙)PLC建立通信并读取寄存器数据这一典型场景。资源以C# 2010项目形式组织,包含TCP客户端连接、通信指令收发、返回数据解析、异常与超时处理、异步调用以及二进制到整型浮点的类型转换等关键环节,可帮助读者理解上位机与PLC之间的完整数据交换链路。压缩包共49个文件,约218KB,涵盖cs源代码、csproj与sln工程文件、resx资源、config配置、exe与dll运行库以及pdb调试符号等,结构完整,便于直接打开工程对照调试。目前已有682人学习下载,适合希望快速上手OMRON PLC通信、积累工控网络编程经验的开发者参考借鉴。

1. TcpEthernet 读取 OMRON PLC:为什么我最后放弃了 FinsGateway

三年前接手一条包装线的数据采集,PLC 是 OMRON CJ2M,上位机用 C# 写。第一版方案我用了官方 FinsGateway 加 DLL 封装,装驱动、配路由表、注册组件,折腾两天才让 WinForm 界面上跳出一个 DM 区的值。后来产线要加三台设备、换一台工控机,同样的步骤又得重来一遍,而且客户现场不允许随便装驱动。那次之后我彻底转向了 TcpEthernet 直连——不装任何 OMRON 组件,纯 C# 用 Socket 发 FINS 帧,一个 exe 拷过去就能跑。

这篇笔记讲的就是这条路:C# 通过以太网口直接读写 OMRON PLC,覆盖 FINS/TCP 协议帧怎么拼、Socket 怎么收、地址怎么映射、批量读怎么写,以及我踩过的那些坑。适合正在做 C# 上位机、需要和 OMRON PLC 对接的工程师,新手能照着把最小实例跑通,熟手可以看参数边界和性能取舍。前提是你手上有一台支持以太网口的 OMRON PLC(CJ/CS/CP 系列带 ETN 模块,或 NX/NJ 内置口),并且知道它的 IP。

2. FINS/TCP 协议拆解:从握手到一帧完整报文

2.1 为什么选 FINS/TCP 而不是 Host Link 或 Modbus

OMRON PLC 的以太网通信有几条路:FINS/TCP、FINS/UDP、Host Link、以及部分机型支持的 Modbus TCP。我选 FINS/TCP 的理由很直接——它是 OMRON 原生的以太网协议,能直接访问 DM、CIO、WR、HR 等所有数据区,不需要在 PLC 侧写梯形图做地址映射,也不像 Host Link 那样走串口转以太网的网关。Modbus TCP 虽然通用,但 OMRON 的 Modbus 地址表和实际数据区之间隔了一层映射,调试时经常对不上号。

FINS/TCP 的本质是:在 TCP 之上跑一层 FINS 命令帧。TCP 负责可靠传输,FINS 负责告诉 PLC「我要读哪个区、从哪个地址开始、读多少个字」。整个交互分两个阶段——先做 TCP 连接和 FINS 节点握手,然后才能发读写命令。

2.2 FINS/TCP 帧头结构:握手帧和命令帧的区别

FINS/TCP 的报文分两类,帧头格式不同,这是最容易搞混的地方。

握手帧(客户端发给 PLC 的第一帧)结构如下:

偏移长度内容说明
0446 49 4E 53固定 ASCII "FINS"
4400 00 00 0C后续长度,固定 12
8400 00 00 00命令码,握手固定 0
12400 00 00 00错误码,请求时填 0
16400 00 00 xx客户端节点号,可填 0 让 PLC 自动分配

PLC 返回的握手响应里,偏移 16 处会给出分配好的节点号,偏移 20 处是 PLC 自己的节点号。这两个值后面发命令帧时要用到。

命令帧(握手完成后发读写请求)结构:

偏移长度内容说明
0446 49 4E 53固定 "FINS"
4400 00 00 xx后续长度 = 8 + FINS 帧长度
8400 00 00 02命令码,读写固定 2
12400 00 00 00错误码
16...FINS 帧真正的 FINS 命令

注意命令帧的偏移 8 是00 00 00 02,不是握手帧的 0。我第一次写的时候把这两个搞混了,PLC 一直不回数据,抓包才发现命令码填错。

2.3 FINS 命令帧内部:ICF、DNA、DA1 这些字段到底填什么

FINS 帧本身是 OMRON 定义的一套命令格式,读 DM 区的 FINS 帧长这样:

ICF RSV GCT DNA DA1 DA2 SNA SA1 SA2 SID MRC SRC ... 80 00 02 00 xx 00 00 xx 00 xx 01 01 ...

关键字段解释:

  • ICF(信息控制字段):填0x80,表示需要响应。
  • DNA(目标网络号):本地网络填0x00。
  • DA1(目标节点号):PLC 的 FINS 节点号,握手时拿到。
  • DA2(目标单元号):CPU 单元填0x00,如果是特殊单元另算。
  • SNA(源网络号):0x00。
  • SA1(源节点号):客户端节点号,握手时 PLC 分配的。
  • SID(服务 ID):随便填,响应里会原样返回,用来匹配请求和响应。
  • MRC/SRC(主/子命令码):读操作是01 01,写操作是01 02。

后面跟的是地址和长度。读 DM 区时,MRC/SRC 后依次是:区域代码(DM 是0x82)、起始地址(2 字节,大端)、位偏移(1 字节,字访问填 0)、读取字数(2 字节,大端)。

2.4 用 C# 拼一帧读 DM 区的完整代码

下面是我实际用的最小可跑代码,读 DM100 开始的 10 个字:

using System; using System.Net.Sockets; using System.Text; public class OmronFinsClient { private TcpClient _tcp; private NetworkStream _stream; private byte _clientNode; private byte _plcNode; // 连接并完成 FINS 握手 public bool Connect(string ip, int port = 9600) { _tcp = new TcpClient(); _tcp.Connect(ip, port); _stream = _tcp.GetStream(); // 握手帧:FINS + 长度0C + 命令0 + 错误0 + 客户端节点0 byte[] handshake = new byte[20]; Encoding.ASCII.GetBytes("FINS").CopyTo(handshake, 0); handshake[7] = 0x0C; // 偏移8-11命令码0,12-15错误码0,16-19客户端节点0 _stream.Write(handshake, 0, handshake.Length); byte[] resp = new byte[24]; int n = _stream.Read(resp, 0, resp.Length); if (n < 24) return false; // 响应偏移16是分配的客户端节点,偏移20是PLC节点 _clientNode = resp[19]; _plcNode = resp[23]; return true; } // 读 DM 区,startAddr 如 100,count 为字数 public ushort[] ReadDM(ushort startAddr, ushort count) { // FINS 帧部分 byte[] fins = new byte[12 + 2]; // 命令+地址+长度 fins[0] = 0x80; // ICF fins[1] = 0x00; // RSV fins[2] = 0x02; // GCT fins[3] = 0x00; // DNA fins[4] = _plcNode; // DA1 fins[5] = 0x00; // DA2 fins[6] = 0x00; // SNA fins[7] = _clientNode; // SA1 fins[8] = 0x00; // SA2 fins[9] = 0x01; // SID fins[10] = 0x01; // MRC 读 fins[11] = 0x01; // SRC fins[12] = 0x82; // DM 区代码 fins[13] = (byte)(startAddr >> 8); // 地址高字节 fins[14] = (byte)(startAddr & 0xFF); // 地址低字节 fins[15] = 0x00; // 位偏移 fins[16] = (byte)(count >> 8); // 字数高 fins[17] = (byte)(count & 0xFF); // 字数低 // FINS/TCP 命令帧头 byte[] frame = new byte[16 + fins.Length]; Encoding.ASCII.GetBytes("FINS").CopyTo(frame, 0); int bodyLen = 8 + fins.Length; frame[4] = (byte)(bodyLen >> 24); frame[5] = (byte)(bodyLen >> 16); frame[6] = (byte)(bodyLen >> 8); frame[7] = (byte)(bodyLen & 0xFF); frame[11] = 0x02; // 命令码 2 // 偏移12-15错误码0 fins.CopyTo(frame, 16); _stream.Write(frame, 0, frame.Length); // 响应:16字节头 + 14字节FINS头 + 数据 byte[] resp = new byte[16 + 14 + count * 2]; int total = 0; while (total < resp.Length) { int r = _stream.Read(resp, total, resp.Length - total); if (r <= 0) break; total += r; } // 检查 FINS 响应码,偏移28-29是 MRC/SRC 后的响应码 if (resp[28] != 0x00 || resp[29] != 0x00) throw new Exception($"FINS 错误码 {resp[28]:X2}{resp[29]:X2}"); ushort[] result = new ushort[count]; for (int i = 0; i < count; i++) { // 数据从偏移30开始,大端 result[i] = (ushort)((resp[30 + i * 2] << 8) | resp[31 + i * 2]); } return result; } }

逻辑说明:Connect先发 20 字节握手帧,PLC 回 24 字节,从中提取节点号。ReadDM拼 FINS 帧时,ICF 填0x80表示要响应,DA1 填 PLC 节点号,SA1 填客户端节点号,这两个值必须和握手时一致,否则 PLC 会丢弃。地址和字数都是大端序,C# 默认小端,所以手动移位。

参数说明:startAddr是 DM 区地址,比如 DM100 就传 100;count是读取字数,一次最多读 960 个字(FINS 帧长度限制),实际建议不超过 500 字,避免单帧过大导致超时。端口默认 9600,如果 PLC 侧改过要对应调整。

3. 把实例跑起来:连接、读写、断线重连的完整流程

3.1 环境准备和 PLC 侧必须确认的三件事

在写代码之前,先在 PLC 侧确认三件事,否则代码再对也连不上。

第一,PLC 的 IP 地址和子网掩码。用 CX-Programmer 或 Sysmac Studio 连上 PLC,在「内置以太网端口设置」或 ETN 模块的「TCP/IP 设置」里看 IP。常见坑是 PLC 默认 IP 是192.168.250.1,和工控机不在同一网段,需要改。

第二,FINS/TCP 端口号。OMRON 默认是 9600,但有些现场会改成别的。在「FINS/TCP 设置」里确认,同时确认「FINS/TCP 服务器」是启用的。

第三,节点号分配方式。FINS/TCP 设置里有一项「自动分配节点号」和「固定节点号」。如果选自动,握手时客户端填 0,PLC 分配;如果选固定,客户端必须填 PLC 侧配置的节点号。我一般用自动,省事。

工控机侧只需要装 .NET Framework 4.0 以上或 .NET 6/8,不需要装任何 OMRON 驱动。用 Visual Studio 建一个控制台或 WinForm 项目,把上面的OmronFinsClient类拷进去就能用。

3.2 最小可跑实例:控制台读 DM100 并打印

把上面的类放进项目后,Main 方法这样写:

static void Main(string[] args) { var plc = new OmronFinsClient(); if (!plc.Connect("192.168.250.1")) { Console.WriteLine("握手失败"); return; } Console.WriteLine("握手成功"); try { ushort[] values = plc.ReadDM(100, 10); for (int i = 0; i < values.Length; i++) { Console.WriteLine($"DM{100 + i} = {values[i]}"); } } catch (Exception ex) { Console.WriteLine($"读取失败: {ex.Message}"); } }

跑之前把 IP 换成你 PLC 的实际地址。如果握手成功但读取报 FINS 错误码,先看错误码是什么:00 00是正常,01 01是区域代码不对,02 01是地址越界,03 01是命令不支持。这些错误码在 OMRON 的 FINS 手册里有完整列表,但实际调试时最常遇到的就是区域代码和地址越界。

3.3 写 DM 区:和读的差异在哪

写 DM 区的 FINS 帧和读几乎一样,区别在 MRC/SRC 变成01 02,并且要在地址和长度后面跟上要写的数据。代码改动如下:

public void WriteDM(ushort startAddr, ushort[] values) { int count = values.Length; byte[] fins = new byte[12 + 5 + count * 2]; fins[0] = 0x80; fins[1] = 0x00; fins[2] = 0x02; fins[3] = 0x00; fins[4] = _plcNode; fins[5] = 0x00; fins[6] = 0x00; fins[7] = _clientNode; fins[8] = 0x00; fins[9] = 0x01; fins[10] = 0x01; // MRC fins[11] = 0x02; // SRC 写 fins[12] = 0x82; // DM fins[13] = (byte)(startAddr >> 8); fins[14] = (byte)(startAddr & 0xFF); fins[15] = 0x00; fins[16] = (byte)(count >> 8); fins[17] = (byte)(count & 0xFF); for (int i = 0; i < count; i++) { fins[18 + i * 2] = (byte)(values[i] >> 8); fins[19 + i * 2] = (byte)(values[i] & 0xFF); } byte[] frame = new byte[16 + fins.Length]; Encoding.ASCII.GetBytes("FINS").CopyTo(frame, 0); int bodyLen = 8 + fins.Length; frame[4] = (byte)(bodyLen >> 24); frame[5] = (byte)(bodyLen >> 16); frame[6] = (byte)(bodyLen >> 8); frame[7] = (byte)(bodyLen & 0xFF); frame[11] = 0x02; fins.CopyTo(frame, 16); _stream.Write(frame, 0, frame.Length); byte[] resp = new byte[30]; int total = 0; while (total < resp.Length) { int r = _stream.Read(resp, total, resp.Length - total); if (r <= 0) break; total += r; } if (resp[28] != 0x00 || resp[29] != 0x00) throw new Exception($"写失败 {resp[28]:X2}{resp[29]:X2}"); }

写操作的响应比读短,只有 30 字节(16 字节 TCP 头 + 14 字节 FINS 头),没有数据区。注意写操作对 PLC 有实际影响,调试时先拿一个不用的 DM 地址试,别直接写生产数据。

3.4 断线重连和超时设置:别让上位机卡死

工业现场网络抖动是常态,TcpClient默认没有超时,Read会一直阻塞。我一般做三件事:

第一,设置ReceiveTimeout和SendTimeout:

_tcp.ReceiveTimeout = 2000; _tcp.SendTimeout = 2000;

第二,把读写包在 try-catch 里,捕获IOException和SocketException,触发重连。重连逻辑是:关闭旧连接,重新Connect和握手,然后重试当前操作。重试次数我一般设 3 次,超过就报警。

第三,如果 PLC 侧主动断开(比如断电重启),Read会返回 0 或抛异常,这时候必须重建整个连接,不能只重发命令帧,因为节点号可能变了。

4. 避坑与排查:那些让我加班到凌晨的 FINS 问题

4.1 握手成功但读数据超时

现象:Connect返回 true,但ReadDM一直阻塞到超时。

原因:命令帧的偏移 8 命令码填错了。握手帧命令码是 0,命令帧是 2。我见过有人把命令帧也填 0,PLC 收到后当成握手帧处理,不回数据。

解决:确认命令帧偏移 8-11 是00 00 00 02。抓包对比握手帧和命令帧的前 16 字节,一眼就能看出来。

4.2 FINS 错误码 01 01:区域代码不对

现象:读取返回 FINS 错误码01 01。

原因:区域代码填错了。DM 区是0x82,CIO 区是0xB0,WR 区是0xB1,HR 区是0xB2。我一开始把 CIO 当成 DM,填了0x82,PLC 直接报区域错误。

解决:查 OMRON FINS 手册的「区域代码表」,确认你要读的区对应哪个代码。DM 和 CIO 是最常用的两个,CIO 区地址从 0 开始,DM 区也从 0 开始,但两者是独立的数据区。

4.3 读到的数据字节序反了

现象:PLC 里 DM100 的值是 1234(十进制),读上来变成 53764。

原因:FINS 协议用大端序,C# 的BitConverter默认小端。如果直接用BitConverter.ToUInt16处理响应数据,字节序会反。

解决:手动移位,像上面代码里那样(resp[30 + i * 2] << 8) | resp[31 + i * 2]。或者用BitConverter.ToUInt16后做IPAddress.HostToNetworkOrder转换,但手动移位更直观。

4.4 批量读超过 500 字就失败

现象:一次读 600 个字,PLC 不回数据或返回错误。

原因:FINS/TCP 单帧有长度限制,虽然理论上能到 960 字,但实际受 PLC 缓冲区大小和网络 MTU 影响。我实测 CJ2M 一次读 500 字稳定,超过就容易超时。

解决:分批读,每批 200-500 字,循环拼接。如果对实时性要求高,可以开多个 TCP 连接并行读不同区域,但注意 PLC 的并发连接数有限制,一般不超过 4 个。

4.5 多线程同时读写导致响应错乱

现象:两个线程同时调ReadDM,偶尔读到对方的数据或报错。

原因:NetworkStream不是线程安全的,两个线程同时写请求,响应会交叉。

解决:加锁,把读写操作串行化。如果性能不够,就开多个OmronFinsClient实例,每个实例独立 TCP 连接,但要注意 PLC 的并发连接上限。

5. 进阶:批量读优化和地址映射表的设计

5.1 用地址映射表把 DM 地址和业务字段解耦

实际项目里,PLC 的 DM 地址是固定的,但业务字段会变。我一般做一个映射表,用 JSON 或 XML 配置,代码里不写死地址。比如:

public class PlcTag { public string Name { get; set; } public ushort Address { get; set; } public ushort Length { get; set; } public string Type { get; set; } // "ushort", "int", "float" }

然后按地址排序,把连续的地址合并成一次批量读。比如 DM100-DM110 和 DM200-DM210 是两个块,分别读一次,而不是每个字段读一次。这样能把 20 次请求压缩到 2 次,性能提升明显。

5.2 批量读的合并策略和性能对比

我做过一个测试,读 100 个分散的 DM 地址:

策略请求次数耗时(ms)
逐点读1001200
按连续块合并8150
全量读 500 字再切片140

全量读最快,但会读很多无用数据。实际项目里我一般用「连续块合并」,块大小不超过 200 字,兼顾性能和灵活性。如果 PLC 负载低、网络好,可以适当放大块大小。

5.3 用异步 Socket 替代同步 Read 避免界面卡死

WinForm 上位机如果用同步Read,界面会卡。我一般改成ReadAsync或BeginRead,配合async/await。核心改动是把_stream.Read换成await _stream.ReadAsync,方法签名加async Task。注意NetworkStream的ReadAsync在 .NET Framework 4.5 以上才支持,老项目可以用Task.Run包一层。

5.4 一个我用了五年的调试习惯

每次新项目对接 OMRON PLC,我第一件事不是写代码,而是用 Wireshark 抓一次官方工具(比如 CX-Programmer 或 Sysmac Studio)的通信包,把握手帧和读命令帧的十六进制抄下来,和自己的代码逐字节对比。这个习惯帮我省了至少几十个小时的排查时间。FINS 协议本身不复杂,但字段多、容易填错,抓包对比是最快的验证方式。

希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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