简介:这是一份面向工业自动化开发者的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,它只认 FINS(Factory 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 要求单连接内维持会话 ID(Session ID),ID 在首次
Connect后由 PLC 返回,后续所有MemoryAreaRead/MemoryAreaWrite必须携带该 ID,且 ID 在连接断开后失效; - 地址编码规则:欧姆龙地址非标准十六进制,如
D100对应0x0064(十进制 100),W10.0对应0x000A0000(字地址 10 + 位偏移 0),而CIO200.3需转为0x00C80003(CIO 区基址 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 (IC=0 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 的 502);response[8..10]是 Session ID 存储位置,PLC 文档明确指定;ReadTimeout设为 5 秒因工业现场网络延迟波动大,避免await挂起过久。
2.3 内存区读取:地址编码与帧组装的双重校验
读取DM100到DM103(4 个字)需构造 FINS 读命令帧。关键在于地址转换与长度计算:
| 地址格式 | 区域码 | 起始地址(十进制) | FINS 地址字(16进制) | 说明 |
|---|---|---|---|---|
DM100 | 0x82 | 100 | 0x0064 | DM 区区域码为 0x82,地址转小端序 |
W10 | 0x10 | 10 | 0x000A | W 区区域码为 0x10,无位偏移 |
CIO200.3 | 0x00 | 200 | 0x00C8 | CIO 区区域码为 0x00,位偏移 3 →0x00C80003 |
public async Task<short[]> ReadDMAsync(ushort startAddress, ushort length) { // 校验地址范围:DM 区 0~65535,length ≤ 1000(FINS 单次最大读取数) 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 字节(IC+Reserved+Command+Status+DataLength+SessionID);wordCount从响应帧第 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.0; - FINS 设置:在 CX-Programmer 中打开“PLC 设置” → “内置以太网” → “FINS 设置”,启用“允许 FINS/TCP 连接”,禁用“FINS UDP”(本项目仅支持 TCP);
- 内存区分配:温度传感器模拟量输入映射至
AI0(地址CIO200),控制字输出映射至D100(16 位整数,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: 循环读取 CIO200(AI0 模拟量输入,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 refused | PLC 未开启 FINS/TCP 或 IP 不通 | ping 192.168.1.10;telnet 192.168.1.10 9600 | 检查 PLC 网络设置,确认 FINS 功能启用 |
FINS read failed with status 0x0020 | 地址区域码错误(如用 0x82 读 CIO 区) | 查看ReadCIOAsync源码中区域码是否为0x00 | 修改区域码,参考欧姆龙手册《FINS Protocol Reference Manual》表 3-1 |
System.IO.IOException: Unable to read data from the transport connection | TCP 连接被 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 Task<byte[]> 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)确保只触发一次,避免重复定时器堆积。
本文还有配套的精品资源,点击获取