C#上位机实现OMRON CJ2M PLC的FINS通信详解
发布时间:2026/9/16 13:51:45 锦皓数字建站

简介面向工业自动化上位机开发者这份源代码包围绕OMRON CJ2M PLC与C# .NET 4.0的FINS通信展开适合需要实现设备数据采集、远程监控或参数下发的工程师参考。资源共165个文件、15.74MB包含C#/C源码.cs/.cpp/.h、可执行demo、动态库、Visual Studio工程与配置文件源码与可运行程序搭配方便直接调试和二次开发。目前已有252人学习。代码模块划分清晰FINS协议类库负责构建报文与收发指令互操作接口衔接底层通信库输出模块用于结果解析与数据呈现演示程序则展示了完整调用流程。通过这套代码读者可以系统掌握上位机与CJ2M系列的交互原理并快速移植到实际项目中降低C#与OMRON PLC联调的入门门槛。1. 拿到 OMRON CJ2M.rar 之后这套源码到底解决了什么解压这个压缩包看到的不是安装包而是 FinsDLL、demo、interopInterface、output 这样一组 C# 工程目录。这类资源之所以值得拆是因为它直接回答了一个很现实的问题在没有 CX-Server、没有 OMRON 官方通信组件授权的情况下上位机怎么用纯 C# 把 CJ2M 的 D 区、CIO 区读出来、写进去。很多做产线设备的上位机开发者在第一步就被卡住网上资料不是讲 CX-Programmer 梯形图就是贴一堆看不懂的十六进制帧能跑通的例子反而难找。这套源码的价值在于它用 .NET 4.0 写了一个完整的 FINS 通信链路适合两类人一类是刚接手 PLC 上位机项目、想少走弯路的 C# 工程师另一类是已经在用其他协议如 Modbus想对比 FINS 实现思路的自动化从业者。下面从协议帧结构开始一步步把这条链路拆开。2. FINS 协议解析与 CJ2M 地址映射先从帧格式和内存布局说起2.1 FINS 帧的完整字段拆解FINSFactory Intelligent Network System是 OMRON 设备间通信的标准协议走 UDP 或 TCP 时默认端口都是 9600。它的帧结构分三层FINS 头部10 字节、命令码2 字节、参数区按命令变化。这 10 字节依次是 ICF、RSV、GCT、DNA、DA1、DA2、SA1、SA2、SID、保留位。其中 ICF 用于标识请求还是响应请求帧填 0x80响应帧填 0xC0DNA 固定为 0x00 表示目标网络DA1 是目标 PLC 的节点号DA2 是目标单元号CPU 单元一般填 0x00SA1 是上位机自己的节点号SA2 是源单元号通常 0x00SID 是服务标识用于匹配请求和响应调一次加一。实际抓包时经常看到有人在 ICF 或 SID 上填错。ICF 如果填了 0x00PLC 会把这条帧当成广播或非法请求直接丢弃SID 如果每次请求都用同一个值高并发时响应匹配会错乱。所以这 10 字节不是随便填的尤其是节点号DA1 和 SA1 的取值要和 PLC 侧设定的 FINS 节点地址一致否则报文到了 PLC 也被拒收。2.2 DM 区、CIO 区、WR 区的地址计算公式CJ2M 的软元件地址在 FINS 命令里不是直接写 D100 或 CIO100而是要转换成协议地址。不同区域对应不同的内存类型代码常见的几类用下面这张表来区分区域内存类型代码ASCII 对应的十六进制地址转换规则示例DM 区D0x02实际地址 字地址 × 2D100 → 0x00C8CIO 区CIO0x30实际地址 CIO 通道号CIO100 → 0x0064WR 区W0x31实际地址 WR 通道号W30 → 0x001E内部继电器IR0x30同 CIO 区规则IR20 → 0x0014保持继电器HR0x32实际地址 HR 通道号HR50 → 0x0032DM 区之所以地址要乘以 2是因为 FINS 协议里 DM 区按字寻址时地址字段的值是目标字地址的 2 倍也就是把位偏移和字偏移统一在一个线性空间里。这个规则很多人会踩坑写入 D100 结果实际写到了 D200或者读到的是 D50 的数据。CIO 区相对直观通道号本身就是地址。内存类型代码的低字节写在命令的参数区里高字节是 0。2.3 读命令和写命令的参数格式读命令的命令码是 0x0101写命令是 0x0102读多个字和写多个字都是同一条命令通过参数区的长度字段控制。读命令的参数排列是内存类型代码2 字节、起始地址2 字节、位位置1 字节字访问填 0x00、读取字数2 字节。写命令在此基础上多一段要写入的数据内存类型代码、起始地址、位位置、写入字数、数据体。以读取 D100 开始的 10 个字为例参数区就是02 00 00 C8 00 00 0A。这里的 02 是 DM 区的类型代码00 C8 是 D100 转换后的地址00 是位位置00 0A 是字数。响应帧里如果命令正常完成前两个字节是 0x00 0x00 的主错误码和副错误码后面跟着的是读取到的数据每个字两个字节高位在前。3. 基于 TcpClient 的 C# FINS 帧收发实现从连接握手到响应匹配3.1 建立连接与设置收发超时C# 里实现 FINS over TCP 不需要引入第三方库用 System.Net.Sockets.TcpClient 就能完成大部分工作。CJ2M 作为 TCP 服务端监听 9600 端口上位机主动连接后直接发 FINS 命令帧即可不需要像 Modbus TCP 那样先发握手报文。连接建立后要分别设置读超时和写超时否则 PLC 掉线时 Read 方法会一直阻塞。using System.Net.Sockets; public class FinsTcpClient { private TcpClient _tcp; private NetworkStream _stream; private byte _sid 0; public bool Connect(string ip, int port 9600, int timeoutMs 3000) { _tcp new TcpClient(); var ar _tcp.BeginConnect(ip, port, null, null); if (!ar.AsyncWaitHandle.WaitOne(timeoutMs)) { throw new TimeoutException(PLC 连接超时); } _tcp.EndConnect(ar); _tcp.NoDelay true; _stream _tcp.GetStream(); _stream.ReadTimeout timeoutMs; _stream.WriteTimeout timeoutMs; return _tcp.Connected; } }这里的 BeginConnect 不是异步调用的常规用法而是利用它的等待句柄实现一个可控的同步超时。连接超时时间一般取 1500 到 3000 毫秒太短在 PLC 忙时容易误判太长会影响上位机启动速度。NoDelay 置 true 是为了禁用 Nagle 算法因为 FINS 命令帧通常只有几十字节Nagle 会把小包合并导致响应延迟。3.2 构建 FINS 读命令帧构建帧的核心是把第 2 章讲的字段按顺序填充到一个 byte[] 里。下面的方法封装了读多个字的完整流程public byte[] BuildReadFrame(byte dna, byte da1, byte sa1, ushort memType, ushort startAddr, ushort wordCount) { var frame new byte[18]; // 10字节FINS头 2字节命令码 6字节参数 frame[0] 0x80; // ICF: 请求帧 frame[1] 0x00; // RSV: 保留 frame[2] 0x02; // GCT: 网关数 frame[3] dna; // DNA: 目标网络号 frame[4] da1; // DA1: 目标节点号 frame[5] 0x00; // DA2: 目标单元号 frame[6] sa1; // SA1: 源节点号 frame[7] 0x00; // SA2: 源单元号 frame[8] _sid; // SID: 服务标识 frame[9] 0x00; // 保留 frame[10] 0x01; // 命令码高字节 frame[11] 0x01; // 命令码低字节: 0x0101 读 frame[12] (byte)(memType 8); frame[13] (byte)(memType 0xFF); frame[14] (byte)(startAddr 8); frame[15] (byte)(startAddr 0xFF); frame[16] 0x00; // 位位置字访问填0 frame[17] (byte)wordCount; return frame; }这段代码的关键在 frame[14] 和 frame[15]起始地址是大端序高位在前低位在后。很多从 Modbus 转过来的开发者习惯小端序会把 0x00C8 填成 C8 00导致地址错乱。wordCount 参数在 FINS 协议里最多 2 字节实际 CJ2M 单次读的最大字数有限制普通 DM 区一次建议不要超过 100 个字超过后要分批读取。3.3 发送并解析响应帧发送和接收的流程比较直接但响应解析有一个容易忽略的点TCP 是流协议一次 Read 不一定能收全整条帧需要先读前 2 个字节确认长度再循环接收剩余部分。FINS 响应帧的长度可以通过命令码推算读命令的响应长度是 12 字节基础头加 2 倍字数写命令的响应固定 12 字节。public byte[] Transceive(byte[] sendFrame, int expectedDataBytes) { _stream.Write(sendFrame, 0, sendFrame.Length); _stream.Flush(); var respHeader new byte[12]; int readTotal 0; while (readTotal 12) { int r _stream.Read(respHeader, readTotal, 12 - readTotal); if (r 0) throw new IOException(PLC 未返回完整响应); readTotal r; } if ((respHeader[0] 0xC0) ! 0xC0) throw new InvalidDataException(ICF 字段不是响应标志); var data new byte[expectedDataBytes]; readTotal 0; while (readTotal expectedDataBytes) { int r _stream.Read(data, readTotal, expectedDataBytes - readTotal); if (r 0) throw new IOException(响应数据不完整); readTotal r; } return data; }这里用两层 while 循环分别接收定长头部和变长数据体。respHeader[0] 判断响应标志位如果是 0xC0 说明这是一条响应帧而不是 PLC 主动上报的帧。解析业务数据时要注意响应里前两个字节是主错误码和副错误码真正有效的数据从第 2 个字节开始也就是 respHeader[10] 和 [11] 之后的位置。错误码处理放到第 5 章详细说这里先把链路跑通。4. demo 工程从连接握手到数据落盘的完整链路4.1 demo 的工程结构与启动流程压缩包里 demo 项目是一个控制台或 WinForms 程序引用 FinsDLL 后依次执行连接、握手、读写、关闭四个步骤。它的运行流程大致是读取配置文件里的 PLC IP 和节点号调用 FinsTcpClient.Connect 建立连接然后发送握手命令确认链路可用再进行周期性的读写最后把数据写入 output 目录下的日志文件。这个结构就是把前面的类串起来没有多余的设计模式很适合拿来当模板改。启动时最容易出的问题不是代码逻辑而是防火墙拦截 9600 端口。Windows 默认会拒绝来自外部的 TCP 连接最好在开发机上先把入站规则打开或者用 UDP 方式绕过后面会讲两种方式的取舍。4.2 从界面到 PLC 的完整读写链路以读取 D1000 开始的 50 个字为例完整调用链是用户点击读取按钮程序解析起始地址和长度调用 ReadDmWords 方法方法内部将 D1000 转换为 FINS 地址 0x07D0构建读命令帧发送后接收响应再把每两个字节组合成一个 ushort显示在 DataGridView 或 ListView 里。下面是 ReadDmWords 的核心实现public ushort[] ReadDmWords(int dmStart, int count) { ushort memType 0x02; // DM区类型码 ushort addr (ushort)(dmStart * 2); // DM区地址规则 byte[] frame BuildReadFrame(0x00, 0x01, 0x10, memType, addr, (ushort)count); byte[] respData Transceive(frame, count * 2 4); // 4字节错误码为数据前缀 // 从响应数据的第4、5字节提取错误码 ushort mainCode (ushort)((respData[0] 8) | respData[1]); ushort subCode (ushort)((respData[2] 8) | respData[3]); if (mainCode ! 0) throw new Exception($FINS错误: 主码{mainCode:X4}, 副码{subCode:X4}); var result new ushort[count]; for (int i 0; i count; i) { result[i] (ushort)((respData[4 i * 2] 8) | respData[5 i * 2]); } return result; }注意这里 dmStart 是程序里用户输入的十进制地址比如 1000转换时先乘 2 得到 2000再转成十六进制 0x07D0。有些实现会把 dmStart 直接当成 FINS 地址用那是对协议理解不到位。错误码的校验放在数据解析之前如果主错误码非零说明这条命令被 PLC 拒绝了返回的数据都是无效的。4.3 写入操作与批量数据下发写操作和读操作的区别在命令码和报文长度。写单个字 D500 的流程构建写命令帧命令码换成 0x0102参数区加 2 字节的数据体发送后等待 12 字节的响应。响应里只有错误码没有数据体。批量写入时每包建议控制在 256 字节以内一是 PLC 扫描周期有限二是 TCP 分包重传的概率会随着包长增大而上升。public void WriteDmWord(int dmAddress, ushort value) { ushort addr (ushort)(dmAddress * 2); var frame new byte[22]; frame[0] 0x80; frame[1] 0x00; frame[2] 0x02; frame[3] 0x00; frame[4] 0x01; frame[5] 0x00; frame[6] 0x10; frame[7] 0x00; frame[8] _sid; frame[10] 0x01; frame[11] 0x02; // 0x0102 写命令 frame[12] 0x00; frame[13] 0x02; // DM类型码 frame[14] (byte)(addr 8); frame[15] (byte)(addr 0xFF); frame[16] 0x00; // 位位置 frame[17] 0x00; frame[18] 0x01; // 字数1 frame[19] (byte)(value 8); frame[20] (byte)(value 0xFF); byte[] resp Transceive(frame, 4); ushort mainCode (ushort)((resp[0] 8) | resp[1]); if (mainCode ! 0) throw new Exception(写命令被PLC拒绝); }写命令的帧长是 22 字节比读命令多 4 个字节的数据体。value 的字节序也是大端高字节在前。写完可以再读回来校验这是一个值得养成的习惯因为 CJ2M 某些特殊继电器比如保持型定时器的当前值对写入有附加条件直接写不一定成功。5. 排错与边界interopInterface、超时策略与 FINS 错误码表5.1 节点地址与代号不一致的典型故障CJ2M 在 CX-Programmer 里设置的 FINS 节点号默认和它的 IP 地址最后一字节相同。如果 PLC 的 IP 是 192.168.0.10节点号就是 10十六进制 0x0A。上位机在 BuildReadFrame 里填的 DA1 必须等于这个值填错的结果是帧发出去了PLC 也收到了但因为目标节点不匹配直接被丢弃上位机这边表现为 Read 超时。还有一种常见情况PLC 设置了多个 CPU 单元或者用了 CJ1W-EIP21 之类的以太网模块DA2单元号就不是 0x00 了要根据单元号在系统设置里的排列序号来填。排查思路是先用第三方工具抓包确认请求帧是否到达 PLC再看响应帧是超时还是带错误码返回。如果响应帧回来但错误码是 0x1101说明 DA1 填的节点号在网络上找不到如果是 0x1103说明地址域不对比如访问了不存在的软元件区域。5.2 超时重发与 FINS 错误码速查通信超时是工业现场最频发的问题。TCP 连接还在但 PLC 扫描周期过长、或者上位机发送频率太高导致 PLC 响应不过来都可能触发 Read 超时。重发策略我一般用固定次数加重试间隔比如连续失败 3 次每次间隔 200 毫秒如果还是失败就判定链路异常并断开重连。不要无限重发否则上位机线程会越积越多。下面是 FINS 响应中主错误码最常遇到的几个值副错误码只需作为辅助信息记录到日志里主错误码含义处理建议0x0000正常完成无0x0101本地节点错误检查 SA1 是否与其他设备冲突0x0103目标节点错误检查 DA1 对应的 PLC 是否存在0x0201目标节点不存在节点号超出网络范围0x1001命令过长拆分读取区间减少单包字节数0x1101地址区间长度错误检查起始地址加长度是否越界0x1103软元件不存在该区域在 CJ2M 上不存在5.3 interopInterface 存在的意义与使用边界解压包里出现 interopInterface 这个目录说明这套源码的早期版本可能用 DllImport 调用了某个原生通信库比如 OMRON 提供的 finsgate.dll 或者自研的 C FINS 库。interop 层的作用是声明这些非托管函数的签名把 C# 的 byte[] 和 int 类型转换成 C 对应的指针和长度参数。如果你不需要调用非托管库直接用纯 C# 的 TcpClient 实现就能满足需求interop 层可以忽略但如果原来的业务逻辑已经用了某个动态库那这个目录里的 DllImport 声明就不能删删了会导致运行时出现 EntryPointNotFoundException。常见做法是检查 interop 目录下有没有 .cs 文件声明了类似[DllImport(fins.dll)]的方法有就把对应的 dll 放到 exe 同目录或系统搜索路径里。这里有个容易犯的错把 32 位的 fins.dll 放进了 64 位进程里加载时会报 BadImageFormatException。如果 PLC 通信库只有 32 位版本就必须把整个上位机项目改成 x86 平台编译没有别的办法。5.4 TCP 与 UDP 两种传输方式的取舍FINS 协议同时支持 UDP 和 TCPCJ2M 的 FINS/UDP 端口也是 9600。工程实践里TCP 适合需要稳定长连接、数据量大的场景UDP 无连接每一条命令都要带完整的源和目标地址信息响应会有一定的延迟抖动。用 UDP 的收益是不需要维护连接状态PLC 断电重启后上位机不需要感知断线直接发命令即可对监控类应用很友好。改用 UDP 时代码上只需要把 TcpClient 换成 UdpClient发送时用 Send 方法接收用 Receive 方法。UDP 接收要绑定本机端口并且要把 PLC 的 IP 和端口作为发送目标。注意 UDP 模式下没有读超时异常Recv 会一直阻塞所以必须用异步或设置 Socket 的 ReceiveTimeout。6. 断线重连与心跳探测让上位机在 PLC 重启后自动恢复PLC 在产线上重启是常态不是异常。如果上位机用 TCP 连接PLC 断电重启后旧的连接会处于半开状态此时发送命令不会立刻报错而是反复超时直到 TCP 栈检测到异常。处理方案是加一个心跳探测加自动重连的机制用读 PLC 的时钟区或者固定的 DM 区作为探测命令连续 N 次失败后主动断开 TcpClient 并重新 Connect。public async Task RunWithReconnectAsync(FuncTask workLoop, int heartBeatMs 5000) { int failCount 0; const int maxFails 3; while (!_cancelled) { try { if (!IsConnected) { Connect(_plcIp, _plcPort, 3000); failCount 0; } await workLoop(); // 心跳探测读CPU单元型号或固定DM区 var probe ReadDmWords(0, 1); failCount 0; await Task.Delay(heartBeatMs); } catch (Exception ex) { failCount; Console.WriteLine($[{DateTime.Now:HH:mm:ss}] 通信异常: {ex.Message}); if (failCount maxFails) { _tcp?.Dispose(); _tcp null; failCount 0; } await Task.Delay(1000); } } }这里的关键是 failCount 的复位逻辑只要一次心跳成功失败次数就清零连续三次失败才真正断开重连避免因为单次网络抖动频繁重连。Connect 里的 3000 毫秒超时也要保留否则 PLC 处于正在启动状态时 Connect 会阻塞很久。实测 CJ2M 从断电到进入可通信状态大约需要 10 到 15 秒重连循环需要考虑这个时间建议第一次重连失败后间隔不低于 3 秒否则上位机一直在快速循环里空转CPU 占用率会异常偏高。把这段逻辑集成到 demo 的 workLoop 里再配合 output 目录下的日志记录这套 OMRON CJ2M 的 C# 通信源码就算真正接入了产线场景。最后再强调一个容易忽略的细节PLC 侧 FINS 节点号和上位机 SA1 不能相同否则请求帧会被当成回环帧报 0x0101 本地节点错误。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。