简介:这份源码资源面向工业自动化与上位机开发方向的C#程序员,聚焦通过TCP/IP协议与OMRON(欧姆龙)PLC建立通信并读取寄存器数据这一典型场景。内容涵盖Socket客户端连接、OMRON通信协议指令构造、返回数据解析与类型转换、异步通信与异常处理、连接状态管理等关键环节,适合具备一定C#基础、希望切入工控通信领域的开发者参考。压缩包共49个文件,约218KB,以cs源代码、csproj与sln工程文件、resx资源、config配置及exe、dll运行库为主,另含少量缓存与日志文件,可直接在Visual Studio中打开调试。目前已有682人学习下载。通过研读源码,读者可掌握TCP客户端与PLC的数据交互流程、请求命令的封装方式以及二进制数据的解析思路,并借鉴其异常处理与状态跟踪写法,为远程监控与控制类项目提供可复用的实现参考。
1. TcpEthernet 读 OMRON PLC:为什么 C# 上位机都绕不开这条链路
车间里一台 OMRON NX1P2 控制着包装线,MES 要每 200ms 取一次产量和报警字,你打开 Visual Studio 新建 WinForm,引用谁?FinsTcp?还是自己拿 Socket 拼 FINS 帧?这个选择直接决定后面三天是顺畅联调还是通宵抓包。TcpEthernet 指的就是走以太网口的 FINS/TCP 或 FINS/UDP 通道,用 C# 通过 Socket 与 OMRON PLC 的 DM 区、CIO 区、WR 区做读写。它解决的是上位机与产线控制器之间的实时数据交换,适合做 C# 上位机、数据采集、设备联网的工程师。标题里的「实例源码」不是让你复制粘贴就完事,而是理解帧结构后能自己改地址、改长度、改超时。下面按「协议怎么立住 → 代码怎么跑通 → 坑在哪 → 怎么验证」推一遍。
2. FINS/TCP 帧结构与 C# Socket 连接:从握手到读写的最小闭环
2.1 为什么选 FINS/TCP 而不是 Modbus TCP 或 Host Link
OMRON 的以太网模块(如 CJ2H-EIP、NX 系列内置口)原生支持 FINS 协议,这是 OMRON 自己的命令集,能直接访问 CIO、WR、DM、HR、AR 等所有数据区,不需要在 PLC 侧写梯形图做映射。Modbus TCP 虽然通用,但 OMRON 的 Modbus 映射表需要额外配置,且位地址和字地址混用时容易错位。Host Link 走串口,速率和距离都受限。FINS/TCP 的优势是:一条 TCP 长连接可以反复发命令,帧头固定,解析逻辑清晰,适合 C# 上位机做高频轮询。
FINS/TCP 的帧分两层:外层是 FINS/TCP 头(8 字节),内层是 FINS 命令帧。外层头结构如下:
| 偏移 | 长度 | 含义 | 典型值 |
|---|---|---|---|
| 0 | 4 | 固定标识 | 0x46494E53("FINS") |
| 4 | 4 | 长度 | 后面所有字节数 |
| 8 | 4 | 命令 | 0x00000000 握手 / 0x00000002 数据 |
| 12 | 4 | 错误码 | 0 表示正常 |
| 16 | 4 | 客户端节点号 | 握手时由 PLC 返回 |
握手阶段:客户端发命令 0x00000000,PLC 返回自己的节点号。之后所有数据帧的命令字段为 0x00000002。FINS 命令帧里包含 ICF、RSV、GCT、DNA、DA1、DA2、SNA、SA1、SA2、SID,然后是命令码(如 0x0101 读 CIO、0x0102 写 CIO、0x0201 读 DM)。读 DM 区时,命令码 0x0101 后面跟起始地址(2 字节)和数量(2 字节)。
2.2 用 C# 建立连接并读 DM 区的完整代码
下面这段代码可以直接放进 WinForm 的按钮事件里,用 Socket 同步方式实现,方便调试。注意:实际项目建议用异步或后台线程,避免卡 UI。
using System; using System.Net.Sockets; using System.Text; public class OmronFinsTcp { private Socket _socket; private byte _clientNode = 0x01; // 客户端节点号,握手后更新 private byte _plcNode = 0x00; // PLC 节点号,握手后更新 // 连接 PLC,ip 为 PLC 地址,port 默认 9600 public bool Connect(string ip, int port = 9600) { try { _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _socket.Connect(ip, port); return Handshake(); } catch (Exception ex) { Console.WriteLine("连接失败: " + ex.Message); return false; } } // FINS/TCP 握手:发送命令 0x00000000,解析返回的节点号 private bool Handshake() { byte[] handshake = new byte[20]; // 固定头 "FINS" handshake[0] = 0x46; handshake[1] = 0x49; handshake[2] = 0x4E; handshake[3] = 0x53; // 长度:后面 12 字节 handshake[4] = 0x00; handshake[5] = 0x00; handshake[6] = 0x00; handshake[7] = 0x0C; // 命令:0x00000000 握手 handshake[8] = 0x00; handshake[9] = 0x00; handshake[10] = 0x00; handshake[11] = 0x00; // 错误码:0 handshake[12] = 0x00; handshake[13] = 0x00; handshake[14] = 0x00; handshake[15] = 0x00; // 客户端节点号:0x01 handshake[16] = 0x00; handshake[17] = 0x00; handshake[18] = 0x00; handshake[19] = 0x01; _socket.Send(handshake); byte[] resp = new byte[24]; int len = _socket.Receive(resp); if (len < 24) return false; // 返回帧中偏移 20 处为 PLC 节点号 _plcNode = resp[20]; _clientNode = resp[23]; return true; } // 读 DM 区:startAddr 为起始地址(如 100 表示 D100),count 为字数 public short[] ReadDM(ushort startAddr, ushort count) { // FINS 命令帧:读 DM 区命令码 0x0101 byte[] fins = new byte[18 + count * 2]; // FINS 头 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 // 命令码:0x0101 读 CIO/DM 区 fins[10] = 0x01; fins[11] = 0x01; // 起始地址 fins[12] = (byte)(startAddr >> 8); fins[13] = (byte)(startAddr & 0xFF); // 数量 fins[14] = (byte)(count >> 8); fins[15] = (byte)(count & 0xFF); // 后面填充 0,实际发送时长度会调整 // 外层 FINS/TCP 头 byte[] frame = new byte[16 + fins.Length]; frame[0] = 0x46; frame[1] = 0x49; frame[2] = 0x4E; frame[3] = 0x53; int totalLen = 12 + fins.Length; frame[4] = (byte)(totalLen >> 24); frame[5] = (byte)(totalLen >> 16); frame[6] = (byte)(totalLen >> 8); frame[7] = (byte)(totalLen & 0xFF); frame[8] = 0x00; frame[9] = 0x00; frame[10] = 0x00; frame[11] = 0x02; // 数据命令 frame[12] = 0x00; frame[13] = 0x00; frame[14] = 0x00; frame[15] = 0x00; // 错误码 Array.Copy(fins, 0, frame, 16, fins.Length); _socket.Send(frame); byte[] resp = new byte[1024]; int recvLen = _socket.Receive(resp); // 解析:偏移 16 开始是 FINS 响应,偏移 26 开始是数据 short[] result = new short[count]; for (int i = 0; i < count; i++) { int idx = 26 + i * 2; result[i] = (short)((resp[idx] << 8) | resp[idx + 1]); } return result; } }逻辑说明:握手帧固定 20 字节,PLC 返回 24 字节,其中偏移 20 是 PLC 节点号,偏移 23 是客户端节点号(部分型号可能不同,以实际抓包为准)。读 DM 时,FINS 命令帧的 ICF 设为 0x80 表示需要响应,DA1 填 PLC 节点号,SA1 填客户端节点号。命令码 0x0101 是读 CIO 和 DM 的通用命令,起始地址和数量各占 2 字节。响应帧中,偏移 16 开始是 FINS 头,偏移 26 开始是数据区,每个字 2 字节,高位在前。
参数说明:startAddr是 DM 区地址,D100 就填 100;count是一次读取的字数,建议不超过 100,否则部分 PLC 会返回错误。端口默认 9600,如果 PLC 侧改了 FINS/TCP 端口,需要同步修改。超时时间没有在代码里设置,实际项目建议_socket.ReceiveTimeout = 2000,避免网络抖动时线程卡死。
2.3 写 DM 区和位操作的差异
写 DM 区命令码是 0x0102,帧结构和读类似,只是在起始地址和数量之后要跟上要写入的数据。位操作(如读 CIO 的某一位)命令码是 0x0101,但地址格式不同:位地址需要指定字地址和位偏移,例如 CIO 100.03 对应字地址 100,位 3。读位时数量字段填 1,返回的字节中每一位对应一个位。写位命令码 0x0102,数据区每个字节表示 8 个位。实际项目中,如果只是读几个报警位,建议直接读整个字然后做位运算,减少通信次数。
3. 避坑:C# 读 OMRON PLC 时最容易翻车的 5 个地方
3.1 握手返回的节点号不对,导致后续命令全部超时
现象:Connect 返回 true,但 ReadDM 一直收不到数据或收到错误码。原因:不同型号 PLC 的握手响应帧长度可能不同,有的返回 24 字节,有的返回 20 字节,节点号偏移位置不一样。解决:用 Wireshark 抓一次握手包,确认返回帧的实际长度和节点号位置。如果不想抓包,可以先把_plcNode固定为 0x00,_clientNode固定为 0x01,大多数 OMRON PLC 能正常响应。
3.2 一次读太多字,PLC 返回 0x00000003 错误
现象:读 200 个字时,响应帧错误码非零,数据区为空。原因:OMRON PLC 的 FINS 单次读写有上限,通常 CIO 区最多 128 字,DM 区最多 256 字,但实际受 PLC 型号和网络负载影响。解决:把大块读取拆成多次,每次不超过 100 字。如果必须读连续大块,用循环分批读,中间加 10ms 延时。
3.3 字节序搞反,读到的数值全是乱码
现象:DM 区里明明是 1000,读出来是 59392 或负数。原因:FINS 协议中数据是高字节在前(大端),而 C# 的BitConverter默认是小端。解决:手动拼接(short)((resp[idx] << 8) | resp[idx + 1]),不要用BitConverter.ToInt16。如果是 32 位数据,需要把两个字按顺序拼成 int,同样注意高低字顺序。
3.4 长连接被 PLC 主动断开,没有重连机制
现象:运行几小时后,Socket 报错「远程主机强迫关闭了一个现有的连接」。原因:PLC 的 FINS/TCP 连接有超时机制,如果长时间没有数据交互,会主动断开。解决:在后台线程里每 30 秒发一次读命令作为心跳,检测到断开后自动重连。重连时重新握手,不要复用旧的 Socket。
3.5 多线程同时读写同一个 Socket 导致数据错乱
现象:两个线程同时调用 ReadDM,返回的数据串位。原因:Socket 的 Send 和 Receive 不是线程安全的,多个线程交叉发送会导致响应帧匹配错误。解决:给通信加锁,或者用单个后台线程 + 队列的方式,所有读写请求排队执行。如果追求吞吐量,可以建立多个 Socket 连接,每个线程独立使用一个连接。
4. 从实例源码到产线可用:参数调优与异常恢复
4.1 超时、重试、心跳的参数怎么设
在车间网络环境下,交换机负载、PLC 扫描周期都会影响响应时间。我一般会这样设:ReceiveTimeout = 2000,SendTimeout = 1000,重试次数 3 次,每次重试间隔 100ms。心跳间隔 30 秒,心跳命令就是读一个固定的 DM 地址(比如 D0),读到了就认为连接正常。如果连续 3 次心跳失败,主动关闭 Socket 并重连。注意:重连不要立即进行,等 1 秒再试,避免 PLC 侧连接资源未释放。
4.2 用状态机管理连接生命周期
直接在一个按钮事件里 Connect、Read、Close 是 demo 写法,产线环境需要状态机。常见做法是定义Disconnected、Connecting、Connected、Reconnecting四个状态,用一个后台线程驱动。收到异常时切换到Reconnecting,成功握手后回到Connected。这样 UI 线程只负责显示状态,不阻塞通信。
private enum ConnState { Disconnected, Connecting, Connected, Reconnecting } private ConnState _state = ConnState.Disconnected; private void CommLoop() { while (!_stop) { switch (_state) { case ConnState.Disconnected: if (Connect(_ip, _port)) _state = ConnState.Connected; else Thread.Sleep(1000); break; case ConnState.Connected: try { var data = ReadDM(100, 10); // 更新界面或写入数据库 } catch { _state = ConnState.Reconnecting; } Thread.Sleep(200); break; case ConnState.Reconnecting: Close(); Thread.Sleep(1000); _state = ConnState.Disconnected; break; } } }这段代码的关键是:所有通信都在后台线程,UI 更新用Invoke。Thread.Sleep(200)是轮询间隔,根据实际需求调整,不要低于 50ms,否则 PLC 可能来不及响应。
4.3 数据解析:从 short 数组到工程值
读到的 short 数组只是原始寄存器值,需要根据 PLC 程序里的缩放关系转换成工程值。比如 DM100 存的是温度乘以 10,那么实际温度 = raw / 10.0。如果是 32 位浮点数,需要把两个 short 拼成 int,再用BitConverter.ToSingle转换,注意字节序。常见做法是在 C# 里定义一个结构体或类,把地址、类型、缩放因子、单位都配置化,用 JSON 或 XML 存,这样换一条产线只需要改配置文件,不用重新编译。
5. 验证与进阶:用抓包和模拟器确认你的 C# 代码到底对不对
5.1 没有 PLC 怎么调试?用 FINS 模拟器
手头没有 OMRON PLC 时,可以用软件模拟器。常见做法是找一台旧电脑装 CX-One 里的 FINS 模拟功能,或者用第三方 FINS Slave 模拟器(注意不要用来源不明的工具)。模拟器监听 9600 端口,响应 FINS 命令。把你的 C# 代码连到模拟器 IP,如果能正常握手和读写,说明帧结构没问题。模拟器还能故意返回错误码,测试你的异常处理逻辑。
5.2 用 Wireshark 抓 FINS/TCP 包做对比
Wireshark 过滤tcp.port == 9600,可以看到完整的 FINS/TCP 帧。重点看三个地方:握手帧的节点号字段、读命令的起始地址和数量、响应帧的数据区。如果你自己拼的帧和 Wireshark 里 PLC 返回的帧结构一致,基本就稳了。注意:Wireshark 里显示的是十六进制,和代码里的字节数组一一对应,不要被大小端显示迷惑。
5.3 一个具体技巧:批量读优化
如果上位机需要读 500 个 DM 字,不要循环 5 次每次读 100 字,而是先读一次 100 字,然后根据实际变化决定是否继续。更高效的做法是:把需要读的地址整理成不连续的块,每个块单独发命令,块内连续读取。比如需要 D100-D109、D200-D209、D300-D309,就发 3 条命令,而不是 30 条。这样能把通信次数从 30 次降到 3 次,刷新周期从 300ms 降到 50ms 以内。
我自己的习惯是:每次改完代码,先用模拟器跑 1000 次读写,确认没有内存泄漏和 Socket 异常,再上真机。真机调试时,先读一个已知值的 DM 地址(比如 D0 里放个 1234),确认字节序和地址都对,再扩展其他地址。这套流程帮我省了很多返工时间。希望帮到你。
本文还有配套的精品资源,点击获取