☰
C# TcpClient 直连 OMRON PLC:Fins/TCP 协议帧解析与避坑实战
2026/9/28 1:17:33 网站建设 项目流程

简介:这份资源是面向工业自动化开发者的C#与OMRON PLC通信实战源码,基于TCP/IP协议实现上位机对欧姆龙PLC的数据读取,适合具备一定C#基础、希望切入工控通信领域的程序员参考学习。压缩包共49个文件,约218KB,包含6个cs源代码文件、6个resources资源文件、3个resx与2个settings配置项,以及csproj、sln工程文件和exe、dll、pdb等编译产物,另有xml、txt说明与png图片,结构完整可直接用Visual Studio 2010打开运行。源码覆盖Socket客户端连接、OMRON通信协议交互、寄存器数据读取与解析、异常处理、异步编程及二进制到整型浮点的类型转换等关键环节,并保留连接状态管理与调试测试痕迹。目前已有682人学习下载,读者可借此理解C#网络编程与PLC集成的完整链路,掌握远程监控与控制设备的实现思路,是工控上位机开发入门与排错的实用参考。

1. TcpEthernet 读取 OMRON PLC:为什么我最后放弃了 Fins 指令封装库

车间里一台 CJ2M 已经跑了六年,上位机还是 XP 时代的 VB6 程序,屏幕花得看不清。产线班长要求三天内换一套能看实时数据的 C# 上位机,我第一反应是找现成的 Fins 封装库,结果翻了三四个开源项目,要么只支持串口 HostLink,要么把 Fins/TCP 的握手头写死成固定值,换一台 NJ 系列直接连不上。最后我回到最笨的路子:用 C# 的TcpClient直接怼 OMRON 的 Fins/TCP 协议,自己拼帧、自己解析。这套方案就是标题里说的 TcpEthernet 读取 OMRON PLC,核心不是某个库,而是把以太网帧结构吃透。它适合两类人:一是手头有 CJ/CS/CP 系列带以太网模块、需要快速搭 C# 上位机的工控开发者;二是被各种"PLC 通讯库"坑过、想搞清楚底层到底发了什么字节的较真派。下面我按自己踩过的顺序,把选型、拼帧、读写、避坑一次讲透。

2. Fins/TCP 协议拆解:从 TCP 三次握手到一条 DM 区读取指令

2.1 为什么是 Fins/TCP 而不是 HostLink 或 Modbus

OMRON 的以太网通讯有好几条路:Fins/TCP、Fins/UDP、HostLink over Ethernet,还有第三方网关转 Modbus TCP。选型时我列过一张对比表,实际落地只看三个维度——是否需要长连接、单次读写延迟、以及能不能直接访问 DM/CIO 区。

方式传输层典型延迟地址空间适用场景
Fins/TCPTCP 96005~15msDM/CIO/WR/HR 全支持上位机实时监控
Fins/UDPUDP 96003~8ms同上高频采集、可容忍丢包
HostLink串口/以太网20~50ms受限于命令格式老设备、无以太网模块
Modbus TCP 网关TCP 50210~30ms映射后地址多品牌混用

Fins/TCP 的优势在于它是 OMRON 原生协议,不需要额外网关,且支持一次请求读连续多个字。代价是帧结构比 Modbus 复杂,握手阶段有个"节点地址协商"容易被忽略。我一般会先确认 PLC 的以太网模块型号(比如 CJ2M-EIP21 或 CP1W-CIF41),因为不同模块对 Fins/TCP 的端口和最大帧长支持略有差异。

2.2 Fins/TCP 帧的三段结构

一条完整的 Fins/TCP 报文分三段:Fins/TCP 头(8 字节)、Fins 命令帧(10 字节固定头 + 数据区)、以及可选的响应尾。很多人翻车就翻在把 Fins/TCP 头当成 Fins 帧的一部分算错偏移。

Fins/TCP 头结构如下:

  • 字节 0-3:固定FINS的 ASCII 码,即0x46 0x49 0x4E 0x53
  • 字节 4-7:命令码,0x00000000表示客户端发起的节点地址协商,0x00000002表示 Fins 帧发送
  • 字节 8-11:数据长度(大端序),指后面 Fins 帧的字节数
  • 字节 12-15:命令码对应的附加数据,节点协商时是客户端节点地址,Fins 发送时固定0x00000000

Fins 命令帧本身以0x80 0x00 0x02开头(ICF、RSV、GCT),接着是目标节点、源节点、服务 ID,然后是 MRC/SRC 主次命令码。读 DM 区的 MRC 是0x01,SRC 是0x01。

2.3 节点地址协商:最容易被跳过的一步

Fins/TCP 在发第一条读写指令前,必须先做一次节点地址协商。客户端发一个命令码为 0 的 Fins/TCP 头,PLC 返回它分配的节点地址。如果跳过这步直接发读写帧,PLC 会直接断开连接,而且不返回任何错误码——这就是典型的"黑匣子"式翻车。

// 节点地址协商:发送 Fins/TCP 头,命令码 0 byte[] NegotiateFrame() { var frame = new byte[20]; // "FINS" ASCII frame[0] = 0x46; frame[1] = 0x49; frame[2] = 0x4E; frame[3] = 0x53; // 命令码 0x00000000,大端 frame[4] = 0x00; frame[5] = 0x00; frame[6] = 0x00; frame[7] = 0x00; // 数据长度 12 frame[8] = 0x00; frame[9] = 0x00; frame[10] = 0x00; frame[11] = 0x0C; // 错误码占位 frame[12] = 0x00; frame[13] = 0x00; frame[14] = 0x00; frame[15] = 0x00; // 客户端节点地址,0 表示自动分配 frame[16] = 0x00; frame[17] = 0x00; frame[18] = 0x00; frame[19] = 0x00; return frame; }

这段代码里,字节 8-11 的数据长度写0x0C是因为协商请求的附加数据固定 12 字节。字节 16-19 填 0 让 PLC 自动分配节点地址,实际项目中我建议固定一个不冲突的值(比如 0x01),方便抓包时辨认。协商成功后 PLC 返回的帧里,字节 16-19 就是它分配的节点地址,后续所有 Fins 帧的源节点字段都要用这个值。

3. C# 实现 TcpClient 长连接:连接、心跳与断线重连

3.1 建立连接与超时控制

用TcpClient连 PLC 有个坑:Connect方法在跨网段时可能阻塞十几秒。我一般用带超时的异步连接,避免 UI 线程卡死。

async Task<TcpClient> ConnectPlcAsync(string ip, int port, int timeoutMs = 3000) { var client = new TcpClient(); var connectTask = client.ConnectAsync(ip, port); var timeoutTask = Task.Delay(timeoutMs); var completed = await Task.WhenAny(connectTask, timeoutTask); if (completed == timeoutTask) { client.Close(); throw new TimeoutException($"连接 {ip}:{port} 超时"); } await connectTask; // 传播可能的异常 client.NoDelay = true; // 关闭 Nagle,降低小帧延迟 return client; }

NoDelay = true是关键参数。Fins 帧通常只有几十字节,Nagle 算法会攒包导致延迟从 5ms 涨到 40ms 以上。timeoutMs设 3000 是经验值,跨交换机时给到 5000 更稳。连接成功后不要立刻发读写帧,先做节点协商,否则 PLC 侧可能还没准备好。

3.2 心跳与断线检测

PLC 侧有 TCP 空闲超时,长时间不发数据会被动断开。我一般每 5 秒发一次节点协商帧当心跳,成本低且能验证链路。检测断线不能只靠Socket.Connected,那个属性在对方半关闭时仍返回 true。可靠做法是读操作超时或返回 0 字节时判定断线。

async Task<bool> HeartbeatAsync(TcpClient client) { try { var stream = client.GetStream(); var frame = NegotiateFrame(); await stream.WriteAsync(frame, 0, frame.Length); var buf = new byte[24]; using var cts = new CancellationTokenSource(2000); int n = await stream.ReadAsync(buf, 0, buf.Length, cts.Token); return n >= 24; // 协商响应固定 24 字节 } catch { return false; } }

心跳间隔设 5 秒是折中:太短增加 PLC 负担,太长断线发现慢。如果产线要求秒级恢复,可以缩到 2 秒,但要确认 PLC 的以太网模块能承受这个频率。断线后重连要加退避,我一般用 1s、2s、4s、8s 递增,避免网络抖动时疯狂重连把 PLC 连接数占满。

3.3 读写指令的帧拼装

读 DM 区的完整帧拼装如下。假设读 D100 开始的 10 个字,PLC 节点地址为 1,客户端节点地址为 1。

byte[] BuildReadDmFrame(byte plcNode, byte clientNode, ushort startAddr, ushort wordCount) { var fins = new byte[18 + 0]; // 读指令无数据区 // Fins/TCP 头 fins[0] = 0x46; fins[1] = 0x49; fins[2] = 0x4E; fins[3] = 0x53; fins[4] = 0x00; fins[5] = 0x00; fins[6] = 0x00; fins[7] = 0x02; // 命令码 2 fins[8] = 0x00; fins[9] = 0x00; fins[10] = 0x00; fins[11] = 0x0C; // Fins 帧长 12 fins[12] = 0x00; fins[13] = 0x00; fins[14] = 0x00; fins[15] = 0x00; // Fins 帧 fins[16] = 0x80; // ICF fins[17] = 0x00; // RSV fins[18] = 0x02; // GCT fins[19] = plcNode; // 目标节点 fins[20] = clientNode; // 源节点 fins[21] = 0x00; // 服务 ID,可自增 fins[22] = 0x01; fins[23] = 0x01; // MRC/SRC 读 fins[24] = 0x82; // DM 区 fins[25] = (byte)(startAddr >> 8); fins[26] = (byte)(startAddr & 0xFF); fins[27] = 0x00; // 位偏移 fins[28] = (byte)(wordCount >> 8); fins[29] = (byte)(wordCount & 0xFF); return fins; }

注意这里我把 Fins/TCP 头和 Fins 帧拼在同一个数组里,总长 30 字节。字节 8-11 的数据长度写0x0C是因为 Fins 帧固定头 10 字节加地址数据 2 字节,共 12 字节。0x82是 DM 区的区域代码,CIO 区是0xB0,WR 区是0xB1。服务 ID 每次请求递增,响应里会原样返回,用来匹配请求和响应,多线程环境下必须用这个字段做关联,不能靠响应顺序。

4. 避坑与排查:连不上、读回乱码、多线程抢连接的 5 个血泪记录

4.1 现象:Connect 成功但发帧后立刻被断开

原因:跳过了节点地址协商,或者协商帧的命令码写成了0x00000002。PLC 收到非协商帧时如果还没分配节点,会直接 RST。

解决:抓包确认第一条发出的帧命令码是 0,且数据长度字段是0x0C。协商响应必须完整读取 24 字节后再发下一条。

4.2 现象:读回的数据字节序颠倒,D100 的值 0x1234 变成 0x3412

原因:Fins 协议是大端序,而 C# 的BitConverter.ToUInt16默认按小端解析。直接拿响应字节数组转换就会翻。

解决:手动按大端拼装,或者用BinaryPrimitives.ReadUInt16BigEndian。我一般写个扩展方法统一处理,避免每处都记着翻转。

ushort ReadUInt16BigEndian(byte[] buf, int offset) => (ushort)((buf[offset] << 8) | buf[offset + 1]);

4.3 现象:多线程同时读写时响应错位,A 线程拿到 B 线程的数据

原因:Fins/TCP 是请求-响应模型,一个连接上不能并发发多条指令。多个线程共用一个NetworkStream时,响应会交叉。

解决:给连接加锁,或者用请求队列串行化。我一般用一个SemaphoreSlim(1,1)包住整个"发送-接收"过程,服务 ID 只作为二次校验。如果吞吐要求高,就开多条 TCP 连接,每条连接一个线程。

4.4 现象:读 CIO 区返回错误码 0x00004001

原因:区域代码写错。CIO 区是0xB0,不是0x82。另外 CIO 区的地址是位地址,读字时要确认起始地址对齐。

解决:对照 OMRON 的 Fins 命令手册确认区域代码。DM 区0x82、CIO 区0xB0、WR 区0xB1、HR 区0xB2。错误码0x00004001表示"区域指定错误",看到这个先查区域代码。

4.5 现象:运行几小时后连接还在但读不到数据

原因:PLC 侧以太网模块有最大连接数限制(通常 16 或 32),旧连接没释放导致新连接被拒。或者客户端没处理半关闭,socket 处于 CLOSE_WAIT 堆积。

解决:每次重连前先Close旧TcpClient,并设置LingerState让 RST 立即发出。监控CLOSE_WAIT数量,超过 5 个就强制重建连接池。我一般在上位机里加个定时器,每 10 分钟主动重建一次连接,比等它出问题再修省心。

5. 进阶:把读写封装成可复用的 PLC 客户端与批量优化技巧

走到这一步,裸帧拼装已经能跑通,但每个项目都重写一遍不现实。我的习惯是抽一个OmronFinsClient类,把连接管理、节点协商、读写、重连全包进去,对外只暴露ReadDM(ushort start, ushort count)和WriteDM(ushort start, ushort[] values)两个方法。这样换项目时只改 IP 和地址映射。

批量读取是性能优化的重点。Fins 单次最多读 990 个字(不同模块略有差异),但实际项目中我一般控制在 100 字以内,因为单帧越大,PLC 处理时间越长,且一旦丢包重传成本高。如果上位机要采集 500 个 DM 字,我会拆成 5 次读,每次 100 字,而不是一次读 500。实测下来,5 次 100 字的往返总耗时比 1 次 500 字少 20% 左右,因为 PLC 的响应缓冲区有限。

另一个技巧是写操作合并。如果连续写 D100 到 D110,不要发 11 条写指令,拼成一条写 11 个字的帧。写指令的 MRC/SRC 是0x01 0x02,数据区格式是起始地址 + 位偏移 + 字数 + 数据。注意写指令的数据长度字段要算上数据区,别沿用读指令的0x0C。

验证方案是否可靠,我一般用两个手段:一是用 Wireshark 抓包,过滤tcp.port == 9600,逐帧核对 Fins/TCP 头和 Fins 帧的偏移;二是在 PLC 侧用 CX-Programmer 监控 DM 区,手动改值看上位机是否实时刷新。两个手段交叉验证,基本能覆盖 95% 的协议问题。

最后说个我自己的教训:早期我图省事,把节点协商的响应直接丢掉不解析,结果换了一台 NJ 系列 PLC 后一直连不上,查了两天才发现 NJ 分配的节点地址不是 1,而我在后续帧里写死了 1。从那以后,协商响应里的节点地址我一定存到字段里,所有后续帧都从这个字段取。这个习惯帮我省了至少三次类似的排查。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询