
简介这是一份面向工业自动化开发者的C#上位机通信开源项目专为快速实现欧姆龙PLC数据读写而设计适用于具备基础C#编程能力的工程师与自动化集成人员。资源基于FINS协议通过以太网TCP/IP直连PLC无需依赖第三方组件或额外驱动支持后台线程非阻塞操作兼顾稳定性与高性能可直接用于产线监控、设备联调及HMI快速原型开发。压缩包共30个文件含9个核心C#源码如Form1.cs、Program.cs、2个可执行程序exe、1个Visual Studio解决方案sln与1个项目配置文件csproj另有配置文件config、资源文件resx、调试符号pdb及说明文档txt整体仅42KB轻量易集成。已有5097人学习下载提供完整可运行工程结构、清晰的UI界面逻辑与即用型通信封装开箱即用大幅降低欧姆龙PLC上位机开发门槛。1. C# 直连欧姆龙PLC 的 FINS 协议栈不是调库是拆协议、控字节、稳握手你手头有一台 CP1E 或 NJ/NX 系列欧姆龙 PLC网口开着IP 已配好但用通用 Modbus 工具读不到 DM 区——因为欧姆龙不认 Modbus TCP它只认 FINSFactory Interface Network Service。这不是一个“选个串口控件拖一拖”就能跑通的场景。FINS 协议本身无加密、无认证默认但要求严格遵循帧结构4 字节头部网络号/节点号/单元号/命令码、2 字节命令类型、2 字节状态码、变长数据体更关键的是必须先发 FINS 连接请求0x0000 命令获取会话 ID后续所有读写操作都需携带该 ID。很多 C# 上位机项目卡在“连接成功但读取超时”本质是跳过了会话建立阶段或误把 UDP 当 TCP 用FINS/TCP 实际走的是 TCP 长连接非 UDP 广播。本项目是一套全开源、零依赖、可调试的 C# FINS 协议实现覆盖 CP 系列CP1H/CP1E、CJ/CJ2、NJ/NX 全线 PLC支持 DM/EM/W/CIO/D 等全部内存区读写且所有帧构造逻辑裸露可断点——适合需要自主可控通信链路、要对接定制 HMI 或嵌入 SCADA 子系统的开发者而非仅需快速出 Demo 的初学者。2. FINS/TCP 协议栈核心实现从 Socket 封装到帧解析的逐层解构2.1 为什么不用现成 NuGet 包FINS 的三个硬约束必须手动控制主流 C# 通信库如 HslCommunication、EasyModbus对欧姆龙的支持常止步于“能读”但实际工业现场存在三类强约束会话生命周期管理FINS/TCP 要求单连接内维持会话 IDSession IDID 在首次Connect后由 PLC 返回后续所有MemoryAreaRead/MemoryAreaWrite必须携带该 ID且 ID 在连接断开后失效地址编码规则欧姆龙地址非标准十六进制如D100对应0x0064十进制 100W10.0对应0x000A0000字地址 10 位偏移 0而CIO200.3需转为0x00C80003CIO 区基址 0xC8 位偏移 3错误码直译能力PLC 返回的 FINS 状态码如0x0000成功0x0020内存区不存在0x0030地址越界必须映射为可捕获的 .NET 异常而非笼统抛IOException。提示本项目OmronFinsClient.cs中SendCommandAsync方法强制校验SessionId是否为 0若未调用ConnectAsync则直接抛InvalidOperationException(Session not established)杜绝静默失败。2.2 TCP 连接与会话建立四步握手不可省略FINS/TCP 连接并非简单TcpClient.Connect()后即可读写必须完成协议级握手。以下是ConnectAsync的关键步骤代码已精简注释public async Task ConnectAsync(string ipAddress, int port 9600) { _client new TcpClient(); await _client.ConnectAsync(ipAddress, port); _stream _client.GetStream(); // Step 1: 构造 FINS 连接请求帧Command: 0x0000 var connectRequest new byte[12]; BitConverter.GetBytes((ushort)0).CopyTo(connectRequest, 0); // IC (IC0 for connect) BitConverter.GetBytes((ushort)0).CopyTo(connectRequest, 2); // Reserved BitConverter.GetBytes((ushort)0).CopyTo(connectRequest, 4); // Command (0x0000) BitConverter.GetBytes((ushort)0).CopyTo(connectRequest, 6); // Status (0x0000) BitConverter.GetBytes((ushort)0).CopyTo(connectRequest, 8); // Data length (0) // Step 2: 发送并等待响应固定 12 字节响应 await _stream.WriteAsync(connectRequest, 0, connectRequest.Length); var response new byte[12]; await _stream.ReadAsync(response, 0, response.Length); // Step 3: 解析 Session ID位于响应帧第 8-9 字节 _sessionId BitConverter.ToUInt16(response, 8); if (_sessionId 0) throw new InvalidOperationException(PLC rejected connection: invalid session ID); // Step 4: 设置超时与缓冲区FINS 要求单次读写不超过 1024 字节 _stream.ReadTimeout 5000; _stream.WriteTimeout 5000; }参数说明port 9600是欧姆龙默认 FINS/TCP 端口非 Modbus 的 502response[8..10]是 Session ID 存储位置PLC 文档明确指定ReadTimeout设为 5 秒因工业现场网络延迟波动大避免await挂起过久。2.3 内存区读取地址编码与帧组装的双重校验读取DM100到DM1034 个字需构造 FINS 读命令帧。关键在于地址转换与长度计算地址格式区域码起始地址十进制FINS 地址字16进制说明DM1000x821000x0064DM 区区域码为 0x82地址转小端序W100x10100x000AW 区区域码为 0x10无位偏移CIO200.30x002000x00C8CIO 区区域码为 0x00位偏移 3 →0x00C80003public async Taskshort[] ReadDMAsync(ushort startAddress, ushort length) { // 校验地址范围DM 区 0~65535length ≤ 1000FINS 单次最大读取数 if (startAddress 65535 || length 1000 || length 0) throw new ArgumentOutOfRangeException(Address or length out of range); // Step 1: 构造 FINS 读命令帧Command: 0x0001 var frame new byte[20]; BitConverter.GetBytes((ushort)0).CopyTo(frame, 0); // IC BitConverter.GetBytes((ushort)0).CopyTo(frame, 2); // Reserved BitConverter.GetBytes((ushort)0x0001).CopyTo(frame, 4); // Command: Memory Area Read BitConverter.GetBytes((ushort)0).CopyTo(frame, 6); // Status (0 on request) // Step 2: 填充地址参数区域码 地址 长度 frame[8] 0x82; // DM area code BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)startAddress)).CopyTo(frame, 9); // Address (big-endian) BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)length)).CopyTo(frame, 11); // Length // Step 3: 添加 Session ID 和数据长度 BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)_sessionId)).CopyTo(frame, 13); BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)12)).CopyTo(frame, 15); // Data length 12 // Step 4: 发送并解析响应响应帧含 2 字节状态 2 字节数据长度 数据体 await _stream.WriteAsync(frame, 0, frame.Length); var response new byte[1024]; var readLen await _stream.ReadAsync(response, 0, response.Length); // Step 5: 校验状态码response[6..8] var status BitConverter.ToUInt16(response, 6); if (status ! 0) throw new FinsException($FINS read failed with status 0x{status:X4}, status); // Step 6: 提取数据response[12..] 开始为实际数据每字 2 字节 var dataStart 12; var wordCount BitConverter.ToUInt16(response, 10); var result new short[wordCount]; for (int i 0; i wordCount; i) { result[i] BitConverter.ToInt16(response, dataStart i * 2); } return result; }逻辑说明IPAddress.HostToNetworkOrder确保字节序为网络序大端因 FINS 协议规定所有多字节数值均按大端传输dataStart 12是因 FINS 响应帧固定头部为 12 字节ICReservedCommandStatusDataLengthSessionIDwordCount从响应帧第 10-11 字节读取而非依赖请求长度防止 PLC 返回异常数据。2.4 写入操作与批量处理避免“写一次断一次”的性能陷阱单次写入D100一个字效率极低。工业现场常见需求是批量写入 50 个字如配方参数若循环调用WriteDMAsync每次都会触发完整 FINS 帧含 Session ID、命令头等网络开销翻 50 倍。本项目提供WriteDMBatchAsync将多个地址合并为单帧public async Task WriteDMBatchAsync((ushort address, short value)[] items) { if (items.Length 0) return; // Step 1: 计算总数据长度每个字 2 字节 var totalBytes items.Length * 2; var frameSize 20 totalBytes; // 头部 20 字节 数据体 var frame new byte[frameSize]; // Step 2: 填充头部Command: 0x0002 BitConverter.GetBytes((ushort)0).CopyTo(frame, 0); BitConverter.GetBytes((ushort)0).CopyTo(frame, 2); BitConverter.GetBytes((ushort)0x0002).CopyTo(frame, 4); // Memory Area Write BitConverter.GetBytes((ushort)0).CopyTo(frame, 6); // Step 3: 填充第一个地址后续地址自动递增 frame[8] 0x82; // DM area BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)items[0].address)).CopyTo(frame, 9); BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)items.Length)).CopyTo(frame, 11); // Step 4: 填充 Session ID 和数据长度 BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)_sessionId)).CopyTo(frame, 13); BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)(12 totalBytes))).CopyTo(frame, 15); // Step 5: 填充数据体连续地址值按顺序写入 var dataOffset 20; foreach (var item in items) { BitConverter.GetBytes(IPAddress.HostToNetworkOrder(item.value)).CopyTo(frame, dataOffset); dataOffset 2; } await _stream.WriteAsync(frame, 0, frame.Length); var response new byte[12]; await _stream.ReadAsync(response, 0, response.Length); var status BitConverter.ToUInt16(response, 6); if (status ! 0) throw new FinsException($FINS write batch failed: 0x{status:X4}, status); }参数说明items是元组数组address为起始地址value为对应值函数假设地址连续如D100,D101,D102故只传首地址与数量totalBytes必须精确计算否则 PLC 会返回0x0040数据长度错误。3. 实战对接 CP1E-E30DR-A 读取温度传感器数据并写入控制字3.1 硬件准备与 PLC 端配置确认目标 PLC 型号CP1E-E30DR-A带以太网口固件版本 Ver. 1.12。需确认三项基础设置IP 地址PLC 网口 IP 设为192.168.1.10子网掩码255.255.255.0FINS 设置在 CX-Programmer 中打开“PLC 设置” → “内置以太网” → “FINS 设置”启用“允许 FINS/TCP 连接”禁用“FINS UDP”本项目仅支持 TCP内存区分配温度传感器模拟量输入映射至AI0地址CIO200控制字输出映射至D10016 位整数0停机1启动2急停。注意CP1E 默认 FINS 端口为 9600若修改过需同步更新ConnectAsync的port参数。3.2 C# 控制台程序实时读取并闭环控制创建 .NET 6 控制台项目引用本项目源码OmronFinsClient.cs编写主逻辑class Program { static async Task Main(string[] args) { var client new OmronFinsClient(); try { // Step 1: 连接 PLC await client.ConnectAsync(192.168.1.10); Console.WriteLine(Connected to PLC with Session ID: client.SessionId); // Step 2: 循环读取 CIO200AI0 模拟量输入16 位有符号整数 while (true) { // 读取 CIO200注意CIO 区区域码为 0x00地址 200 → 0x00C8 var cioData await client.ReadCIOAsync(200, 1); // 返回 short[] short rawValue cioData[0]; double temperature rawValue * 0.1; // 假设传感器量程 0-100℃分辨率 0.1℃ Console.WriteLine($Raw AI0: {rawValue}, Temperature: {temperature:F1}℃); // Step 3: 温度超限写入控制字D100 short controlWord temperature 80 ? (short)2 : // 80℃ 急停 temperature 70 ? (short)1 : // 70-80℃ 启动 (short)0; // ≤70℃ 停机 await client.WriteDMAsync(100, controlWord); Console.WriteLine($Wrote control word {controlWord} to D100); await Task.Delay(1000); // 1 秒采样周期 } } catch (Exception ex) { Console.WriteLine($Error: {ex.Message}); if (client.IsConnected) await client.DisconnectAsync(); } } }关键点解析ReadCIOAsync(200, 1)中200是 CIO 区十进制地址函数内部自动转为0x00C8并填入帧WriteDMAsync(100, controlWord)直接写单个字适用于控制字这类低频操作Task.Delay(1000)避免高频轮询导致 PLC 通信负载过高CP1E 最大 FINS 请求频率约 10Hz。3.3 故障排查三类高频报错的定位路径当程序运行报错时按以下顺序排查错误现象可能原因定位方法解决方案Connection refusedPLC 未开启 FINS/TCP 或 IP 不通ping 192.168.1.10telnet 192.168.1.10 9600检查 PLC 网络设置确认 FINS 功能启用FINS read failed with status 0x0020地址区域码错误如用 0x82 读 CIO 区查看ReadCIOAsync源码中区域码是否为0x00修改区域码参考欧姆龙手册《FINS Protocol Reference Manual》表 3-1System.IO.IOException: Unable to read data from the transport connectionTCP 连接被 PLC 主动断开抓包 Wireshark 过滤tcp.port 9600观察 FIN 包来源检查 PLC 是否设置了连接超时默认 60 秒增加KeepAlive心跳或缩短轮询间隔提示本项目FinsException类包含StatusCode属性可直接switch(statusCode)分支处理无需字符串匹配。4. 进阶技巧自定义地址解析器与异步批量监控4.1 构建地址解析器支持D100.0、W10.3等带位操作的语法工业现场常需读取单个位如D100.0表示 D100 的 bit0。原生 FINS 协议不支持位读需通过字读位运算实现。本项目扩展AddressParser类public static class AddressParser { public static (byte areaCode, ushort address, byte bitOffset) Parse(string addressStr) { var match Regex.Match(addressStr, ^([A-Z])(\d)\.(\d)$); if (match.Success) { string area match.Groups[1].Value.ToUpper(); ushort addr ushort.Parse(match.Groups[2].Value); byte bit byte.Parse(match.Groups[3].Value); if (bit 15) throw new ArgumentException(Bit offset must be 0-15); return area switch { D (0x82, addr, bit), W (0x10, addr, bit), CIO (0x00, addr, bit), _ throw new ArgumentException($Unsupported area: {area}) }; } // 纯字地址如 D100 match Regex.Match(addressStr, ^([A-Z])(\d)$); if (match.Success) { string area match.Groups[1].Value.ToUpper(); ushort addr ushort.Parse(match.Groups[2].Value); return area switch { D (0x82, addr, 0xFF), // 0xFF 表示全字操作 W (0x10, addr, 0xFF), CIO (0x00, addr, 0xFF), _ throw new ArgumentException($Unsupported area: {area}) }; } throw new ArgumentException($Invalid address format: {addressStr}); } } // 使用示例读取 D100.3 的值 var (area, addr, bit) AddressParser.Parse(D100.3); var word await client.ReadDMAsync(addr, 1); bool bitValue (word[0] (1 bit)) ! 0;设计逻辑bitOffset 0xFF作为“全字”标记避免额外字段正则预编译提升解析速度支持CIO200.3等复杂格式。4.2 异步监控队列避免阻塞主线程的多地址轮询当需同时监控 20 个地址如产线 10 台设备状态同步Read会串行阻塞。改用Task.WhenAll并发public async Task(string address, bool value)[] MonitorBitsAsync(string[] addresses) { var tasks addresses.Select(addr { var (area, a, b) AddressParser.Parse(addr); return Task.Run(async () { short word; switch (area) { case 0x82: word (await client.ReadDMAsync(a, 1))[0]; break; case 0x10: word (await client.ReadWAsync(a, 1))[0]; break; case 0x00: word (await client.ReadCIOAsync(a, 1))[0]; break; default: throw new NotSupportedException(); } return (addr, (word (1 b)) ! 0); }); }).ToArray(); return await Task.WhenAll(tasks); } // 调用 var results await MonitorBitsAsync(new[] { D100.0, D101.1, W10.2 }); foreach (var r in results) Console.WriteLine(${r.address} {r.value});性能对比20 个地址同步读耗时 ≈ 20 × 100ms 2s并发读耗时 ≈ 100ms 网络 RTT提升 10 倍以上。4.3 通信健壮性增强自动重连与会话续期工业环境网络抖动常见需在DisconnectAsync后自动重连。本项目OmronFinsClient添加AutoReconnect属性public class OmronFinsClient : IDisposable { private Timer _reconnectTimer; public bool AutoReconnect { get; set; } false; private string _lastIp; private int _lastPort; public async Task DisconnectAsync() { _reconnectTimer?.Dispose(); _reconnectTimer null; _stream?.Close(); _client?.Close(); } private async Task ReconnectAsync() { try { await ConnectAsync(_lastIp, _lastPort); Console.WriteLine(Reconnected successfully); } catch (Exception ex) { Console.WriteLine($Reconnect failed: {ex.Message}); _reconnectTimer new Timer(_ ReconnectAsync(), null, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(-1)); } } // 在 SendCommandAsync 中捕获 IOException 后触发 private async Taskbyte[] SendCommandAsync(byte[] command) { try { await _stream.WriteAsync(command, 0, command.Length); // ... 读取逻辑 } catch (IOException) { if (AutoReconnect _lastIp ! null) await ReconnectAsync(); throw; } } }使用方式client.AutoReconnect true; client._lastIp 192.168.1.10;即可启用Timer使用TimeSpan.FromMilliseconds(-1)确保只触发一次避免重复定时器堆积。本文还有配套的精品资源点击获取