ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C#原生实现欧姆龙FINS协议通信

C#原生实现欧姆龙FINS协议通信 简介这是一份面向工业自动化领域C#开发者的欧姆龙PLC上位机通信开源解决方案专为需要快速集成FINS协议读写功能的工程师设计解决传统方式依赖第三方组件、线程阻塞、开发周期长等痛点。资源包共30个文件含9个核心C#源码文件如Form1.cs、Program.cs、2个可执行程序exe、1个Visual Studio解决方案sln与1个项目文件csproj辅以配置文件config、资源文件resx、调试符号pdb及使用说明文档txt结构完整开箱即用压缩包仅42KB轻量高效。已有5097人学习下载验证了其在多个实际产线监控与数据采集项目中的稳定性与实用性。开发者可直接复用全部源码无需额外安装驱动或商业库后台线程安全读写机制避免UI卡顿完整支持TCP/IP网络层FINS指令封装涵盖连接管理、内存区读写、错误处理等关键逻辑是C#工业通信入门与快速落地的高价值参考实现。1. C# 直连欧姆龙PLC 的真实路径绕过商业SDK用原生 FINS 协议实现稳定读写很多刚接触工业自动化开发的 C# 工程师会误以为“上位机买套控件拖几个按钮”结果在产线调试时卡在 PLC 连不上、寄存器读错、密码校验失败、TCP 连接闪断这些细节里。实际上欧姆龙主流 CP/CJ/NJ 系列 PLC 对外暴露的是标准 FINSFactory Interface Network Service协议它不依赖任何 Windows 服务或专用驱动纯 TCP/IP 层即可通信——这意味着你完全可以用System.Net.Sockets原生实现无需安装 Omron CX-Protocol、无需购买 NS-Designer 许可、更不必集成体积庞大且版本绑定的 commercial SDK。本项目聚焦「最小可行通路」从零构造 FINS 命令帧、处理响应校验、解析数据区、支持连续地址批量读写并兼容 CJ2M、NJ501、CP1E 等常见机型。适合需要快速验证通信逻辑、嵌入自有 HMI 框架、或对通信时序有硬性要求如 20ms 周期轮询的开发者。不是教你怎么用现成控件而是带你亲手把0x80 0x00 0x02 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00这串字节变成可读的 D100 值。2. FINS 协议核心结构解析与 C# 帧构造实战FINS 是欧姆龙定义的二进制应用层协议运行于 TCP端口 9600或 UDP端口 9600之上。其本质是「请求-响应」模型每个完整交互包含三部分FINS 头部10 字节、命令体可变长、响应数据含状态码。理解头部字段是避免“连得上但读不出”的关键。2.1 FINS 头部字段含义与 C# 结构体映射FINS 头部固定 10 字节顺序不可错。C# 中必须用StructLayout(LayoutKind.Sequential, Pack 1)强制内存对齐否则字节序错位将导致 PLC 拒绝响应[StructLayout(LayoutKind.Sequential, Pack 1)] public struct FinsHeader { public byte ICF; // 0x80: 请求标志0x00: 响应标志 public byte RSV; // 保留字节恒为 0x00 public byte GCT; // 网关跳数单网段设为 0x00 public byte DNA; // 目标网络地址PLC 所在网络通常为 0x00 public byte DA1; // 目标节点地址PLC 站号默认 0x01 public byte DA2; // 目标单元地址PLC 单元号默认 0x00 public byte SNA; // 源网络地址通常为 0x00 public byte SA1; // 源节点地址PC 站号设为 0x01需与 PLC 设置一致 public byte SA2; // 源单元地址PC 单元号设为 0x00 public byte SID; // 请求序列号每次递增用于匹配响应取值 0x00~0xFF }提示DA1/DA2对应 PLC 的「节点号」和「单元号」在 CX-Programmer 或 PLC 面板中可查。若 PLC 设置为节点 1 单元 0则DA10x01, DA20x00若设置为节点 2 单元 1则DA10x02, DA20x01。错误配置会导致0x00 0x00无响应或0x01 0x03目标节点不存在错误。2.2 构造 D 区域读取命令从地址到字节流的完整转换以读取 D100 开始的 4 个字D100-D103为例FINS 命令体结构如下字段长度值十六进制说明命令码2 字节0x01 0x01内存读取命令Memory Area Read内存区域码1 字节0x82D 区域详见下表起始地址高位2 字节0x00 0x64D100 → 100 十进制 0x64高位在前起始地址低位2 字节0x00 0x00D 区域地址为 32 位低 16 位恒为 0读取点数2 字节0x00 0x04读取 4 个字Word对应内存区域码表关键常用项0x82: D 区数据存储区0x20: CIO 区输入输出映像区CIO0-CIO9990x00: WR 区工作继电器WR0-WR2550x01: HR 区保持继电器HR0-HR999C# 中将上述逻辑封装为可复用方法public static byte[] BuildReadDCommand(ushort startAddress, ushort wordCount) { var header new FinsHeader { ICF 0x80, RSV 0x00, GCT 0x00, DNA 0x00, DA1 0x01, // PLC 节点号 DA2 0x00, // PLC 单元号 SNA 0x00, SA1 0x01, // PC 节点号需与 PLC 设置匹配 SA2 0x00, SID (byte)Interlocked.Increment(ref _sidCounter) // 线程安全自增 }; // 命令体2字节命令码 1字节区域码 4字节起始地址 2字节点数 var body new byte[9]; body[0] 0x01; // 命令码高字节 body[1] 0x01; // 命令码低字节 body[2] 0x82; // D区 BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)startAddress)).CopyTo(body, 3); // 地址高位注意大端序 BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)0)).CopyTo(body, 5); // 地址低位恒为0 BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)wordCount)).CopyTo(body, 7); // 点数 // 合并头体 var packet new byte[10 body.Length]; Marshal.Copy(IntPtr.Size 4 ? (IntPtr)(header) : throw new NotSupportedException(), packet, 0, 10); Buffer.BlockCopy(body, 0, packet, 10, body.Length); return packet; }注意BitConverter.GetBytes()默认返回小端序而 FINS 要求网络字节序大端因此必须用IPAddress.HostToNetworkOrder()转换。若直接使用BitConverter.GetBytes(startAddress)D100 将被误传为0x64 0x00小端PLC 解析为地址 25600必然超界报错0x01 0x04地址范围错误。2.3 发送与同步接收超时控制与响应校验TCP 连接建立后发送命令帧并等待响应。关键点在于FINS 响应无长度前缀必须按固定头部 动态体长解析。响应头部同样 10 字节其中第 9-10 字节为响应状态码0x00 0x00表示成功public async Task(bool success, ushort[] data, string error) ReadDWordsAsync( TcpClient client, ushort startAddress, ushort wordCount, CancellationToken ct default) { var command BuildReadDCommand(startAddress, wordCount); var stream client.GetStream(); // 发送 await stream.WriteAsync(command, ct).ConfigureAwait(false); // 接收头部10字节 var headerBuffer new byte[10]; var read await stream.ReadAsync(headerBuffer, ct).ConfigureAwait(false); if (read ! 10) throw new IOException(FINS header incomplete); // 校验响应标志位 if (headerBuffer[0] ! 0x00) return (false, null, Invalid response ICF, expected 0x00); // 解析状态码第9-10字节 var status BitConverter.ToInt16(new[] { headerBuffer[9], headerBuffer[8] }, 0); // 大端转小端 if (status ! 0) { var statusDesc status switch { 0x0103 Target node not found, 0x0104 Address range error, 0x0105 Access denied (password required), _ $Unknown error 0x{status:X4} }; return (false, null, statusDesc); } // 计算数据体长度响应体 命令体长度 数据长度2字节/Word var dataLength wordCount * 2; var dataBuffer new byte[dataLength]; read await stream.ReadAsync(dataBuffer, ct).ConfigureAwait(false); if (read ! dataLength) return (false, null, Incomplete data received); // 解析为 ushort 数组每个 Word 2 字节大端序 var result new ushort[wordCount]; for (int i 0; i wordCount; i) { result[i] (ushort)((dataBuffer[i * 2] 8) | dataBuffer[i * 2 1]); } return (true, result, null); }提示TcpClient必须设置client.ReceiveTimeout 3000毫秒否则网络抖动时ReadAsync可能无限挂起。状态码0x0105明确指向「PLC 密码未通过」此时需在连接后先发送0x01 0x05内存访问密码设置命令而非在读写命令中携带密码字段。3. 支持多机型与复杂数据类型的读写封装实际项目中PLC 型号混用如 CJ2M 与 CP1E、数据类型多样Bit、Word、DWord、Float是常态。硬编码0x82和固定地址格式无法支撑长期维护。本节提供可扩展的抽象层设计。3.1 内存区域与地址空间的统一建模定义PlcMemoryArea枚举与地址解析器将D100、CIO100.01、HR50等字符串地址转化为内部结构public enum PlcMemoryArea { D 0x82, CIO 0x20, HR 0x01, WR 0x00, AR 0x02, // 辅助继电器 LR 0x03, // 链接继电器 } public struct PlcAddress { public PlcMemoryArea Area; public uint Address; // 统一为 32 位地址Bit 地址用小数位表示如 CIO100.01 → Address100.01m public int BitIndex; // -1 表示非 Bit 访问 } public static class PlcAddressParser { public static PlcAddress Parse(string addressStr) { var match Regex.Match(addressStr.ToUpper(), ([A-Z])(\d)(?:\.(\d))?); if (!match.Success) throw new ArgumentException($Invalid address format: {addressStr}); var areaStr match.Groups[1].Value; var area areaStr switch { D PlcMemoryArea.D, CIO PlcMemoryArea.CIO, HR PlcMemoryArea.HR, WR PlcMemoryArea.WR, AR PlcMemoryArea.AR, LR PlcMemoryArea.LR, _ throw new ArgumentException($Unknown area: {areaStr}) }; var baseAddr uint.Parse(match.Groups[2].Value); var bitPart match.Groups[3].Success ? int.Parse(match.Groups[3].Value) : -1; return new PlcAddress { Area area, Address baseAddr, BitIndex bitPart }; } }3.2 泛型读写方法支持 Bit/Word/DWord/Float 四种类型基于地址解析结果动态构造不同命令体。以写入 D100 为float值3.14159f为例public async Taskbool WriteAsyncT(TcpClient client, string address, T value, CancellationToken ct default) where T : unmanaged { var addr PlcAddressParser.Parse(address); if (typeof(T) typeof(bool) addr.BitIndex 0) { // Bit 写入使用 0x01 0x02 命令Memory Area Write Bit return await WriteBitAsync(client, addr, (bool)(object)value, ct).ConfigureAwait(false); } else if (typeof(T) typeof(ushort)) { // Word 写入0x01 0x02 命令Memory Area Write Word return await WriteWordAsync(client, addr, (ushort)(object)value, ct).ConfigureAwait(false); } else if (typeof(T) typeof(uint)) { // DWord 写入0x01 0x02 命令地址2字节数据 return await WriteDWordAsync(client, addr, (uint)(object)value, ct).ConfigureAwait(false); } else if (typeof(T) typeof(float)) { // Float 写入先转为 uint 再按 DWord 写 var bits BitConverter.SingleToUInt32Bits((float)(object)value); return await WriteDWordAsync(client, addr, bits, ct).ConfigureAwait(false); } throw new NotSupportedException($Type {typeof(T)} not supported for write); } private async Taskbool WriteDWordAsync(TcpClient client, PlcAddress addr, uint value, CancellationToken ct) { var header BuildHeader(); // 复用 2.1 节的头部构造 var body new byte[13]; // 2(cmd)1(area)4(addr)2(len)4(data) body[0] 0x01; body[1] 0x02; // Write Word body[2] (byte)addr.Area; // 地址32位大端序 var addrBytes BitConverter.GetBytes(IPAddress.HostToNetworkOrder((int)addr.Address)); Array.Copy(addrBytes, 0, body, 3, 4); // 长度1个DWord 2个Word 0x0002 BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)2)).CopyTo(body, 7); // 数据uint 转 4 字节大端 var dataBytes BitConverter.GetBytes(IPAddress.HostToNetworkOrder((int)value)); Array.Copy(dataBytes, 0, body, 9, 4); var packet BuildPacket(header, body); await client.GetStream().WriteAsync(packet, ct).ConfigureAwait(false); return await CheckResponseAsync(client, ct).ConfigureAwait(false); }注意CheckResponseAsync方法需校验响应状态码是否为0x0000并丢弃响应数据体写操作无返回数据。若 PLC 启用了写保护将返回0x0105错误此时需调用SetPasswordAsync方法先解锁。3.3 密码设置与访问控制绕过 Omron CPT 的硬编码限制当 PLC 设置了「内存访问密码」在 CX-Programmer 的「PLC 设置→安全→内存访问密码」中配置所有读写命令前必须发送密码设置命令0x01 0x05。该命令体结构为字段长度值说明命令码2 字节0x01 0x05密码设置密码4 字节0x31 0x32 0x33 0x34ASCII 字符串 1234 的字节C# 实现public async Taskbool SetPasswordAsync(TcpClient client, string password, CancellationToken ct default) { if (password.Length 4) throw new ArgumentException(Password max length is 4 chars); var header BuildHeader(); var body new byte[6]; body[0] 0x01; body[1] 0x05; // Set Password // 填充密码不足4位补0x00 var pwdBytes Encoding.ASCII.GetBytes(password.PadRight(4, \0)); Array.Copy(pwdBytes, 0, body, 2, 4); var packet BuildPacket(header, body); await client.GetStream().WriteAsync(packet, ct).ConfigureAwait(false); return await CheckResponseAsync(client, ct).ConfigureAwait(false); }提示密码设置命令只需执行一次后续所有读写操作均生效。若密码错误响应状态码为0x0105访问拒绝而非0x0106密码错误这是欧姆龙协议的特殊约定。4. 生产环境必备连接池、心跳保活与异常恢复策略在 24/7 运行的产线系统中单纯TcpClient无法应对网络中断、PLC 重启、防火墙超时等现实问题。必须构建具备自动重连、连接复用、状态监控的通信管理层。4.1 基于 ConcurrentDictionary 的轻量级连接池避免频繁创建销毁TcpClient消耗 socket 句柄与 TIME_WAIT 状态。连接池按 PLC IP:Port 为 Key 存储活跃连接public class PlcConnectionPool { private readonly ConcurrentDictionarystring, LazyTcpClient _pool new ConcurrentDictionarystring, LazyTcpClient(); public TcpClient GetOrConnect(string host, int port, int timeoutMs 3000) { var key ${host}:{port}; return _pool.GetOrAdd(key, _ new LazyTcpClient(() { var client new TcpClient(); client.Connect(host, port); client.ReceiveTimeout timeoutMs; client.SendTimeout timeoutMs; return client; })).Value; } public void Release(string host, int port) { var key ${host}:{port}; if (_pool.TryRemove(key, out var lazyClient)) { lazyClient.Value?.Close(); } } }4.2 FINS 心跳机制用 0x01 0x01读取 CPU 单元状态维持长连接PLC 侧 TCP 连接空闲超时通常为 60~300 秒。每 45 秒发送一次轻量心跳仅读取 CPU 状态命令体仅 2 字节避免干扰主业务private async Task StartHeartbeatAsync(TcpClient client, CancellationToken ct) { while (!ct.IsCancellationRequested) { try { // 构造 CPU 状态读取命令0x01 0x01区域码 0x00地址 0x0000长度 0x0001 var heartbeatCmd BuildCpuStatusCommand(); await client.GetStream().WriteAsync(heartbeatCmd, ct).ConfigureAwait(false); // 读取 10 字节头部 2 字节状态数据 var header new byte[10]; await client.GetStream().ReadAsync(header, ct).ConfigureAwait(false); var data new byte[2]; await client.GetStream().ReadAsync(data, ct).ConfigureAwait(false); // 成功则休眠失败则触发重连 await Task.Delay(TimeSpan.FromSeconds(45), ct).ConfigureAwait(false); } catch (Exception ex) when (ex is IOException || ex is OperationCanceledException) { // 连接已断退出心跳循环由上层触发重连 break; } } } private byte[] BuildCpuStatusCommand() { var header BuildHeader(); var body new byte[9]; body[0] 0x01; body[1] 0x01; // Read body[2] 0x00; // CPU 状态区域 // 地址 0x00000000, 长度 0x0001 BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)0)).CopyTo(body, 3); BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)0)).CopyTo(body, 5); BitConverter.GetBytes(IPAddress.HostToNetworkOrder((short)1)).CopyTo(body, 7); return BuildPacket(header, body); }4.3 断线自动恢复状态机驱动的重连流程定义PlcConnectionState枚举结合Task.Run启动后台恢复任务public enum PlcConnectionState { Disconnected, Connecting, Connected, Failed } public class PlcCommunicator { private PlcConnectionState _state PlcConnectionState.Disconnected; private readonly PlcConnectionPool _pool; private readonly string _host; private readonly int _port; public PlcCommunicator(string host, int port) { _host host; _port port; _pool new PlcConnectionPool(); } public async Taskbool EnsureConnectedAsync(CancellationToken ct default) { if (Interlocked.CompareExchange(ref _state, PlcConnectionState.Connecting, PlcConnectionState.Disconnected) PlcConnectionState.Disconnected) { try { var client _pool.GetOrConnect(_host, _port); // 发送密码如有 if (!string.IsNullOrEmpty(_password)) await SetPasswordAsync(client, _password, ct).ConfigureAwait(false); // 启动心跳 _ Task.Run(() StartHeartbeatAsync(client, ct), ct); Interlocked.Exchange(ref _state, PlcConnectionState.Connected); return true; } catch { Interlocked.Exchange(ref _state, PlcConnectionState.Failed); // 延迟 5 秒后重试 await Task.Delay(5000, ct).ConfigureAwait(false); return await EnsureConnectedAsync(ct).ConfigureAwait(false); } } return _state PlcConnectionState.Connected; } }提示EnsureConnectedAsync是幂等方法可被业务线程安全调用。当ReadDWordsAsync抛出IOException时应先调用此方法再重试而非直接新建TcpClient。5. 性能调优与调试技巧定位通信瓶颈与协议级排错在高频率50ms轮询场景下原始TcpClient可能因 GC 压力或缓冲区拷贝成为瓶颈。本节提供可立即落地的优化手段与诊断方法。5.1 零拷贝发送使用 SocketAsyncEventArgs 避免内存复制TcpClient内部仍基于Socket但每次WriteAsync都会触发数组拷贝。改用SocketAsyncEventArgs可复用缓冲区public class OptimizedPlcWriter { private readonly Socket _socket; private readonly SocketAsyncEventArgs _sendArgs; private readonly byte[] _sendBuffer; public OptimizedPlcWriter(Socket socket) { _socket socket; _sendBuffer new byte[1024]; _sendArgs new SocketAsyncEventArgs(); _sendArgs.SetBuffer(_sendBuffer, 0, _sendBuffer.Length); _sendArgs.Completed OnSendCompleted; } public void SendCommand(byte[] command) { // 复制命令到预分配缓冲区 Array.Copy(command, 0, _sendBuffer, 0, command.Length); _sendArgs.SetBuffer(0, command.Length); bool willRaiseEvent; try { willRaiseEvent _socket.SendAsync(_sendArgs); } catch (Exception ex) { // 处理发送异常 throw; } if (!willRaiseEvent) HandleSendCompletion(_sendArgs); } private void OnSendCompleted(object sender, SocketAsyncEventArgs e) { HandleSendCompletion(e); } private void HandleSendCompletion(SocketAsyncEventArgs e) { if (e.SocketError ! SocketError.Success) { // 记录错误 } // 缓冲区已发送完毕可复用 } }5.2 协议级抓包分析Wireshark 过滤与 FINS 解码当通信异常时跳过 C# 代码直接看网络层最有效。Wireshark 过滤表达式tcp.port 9600 tcp.len 0FINS 帧识别要点请求帧第 1 字节为0x80响应帧第 1 字节为0x00状态码位置响应帧第 9-10 字节Wireshark 中显示为FINS Response Status: 0x0000提示在 Wireshark 中右键 FINS 帧 →Decode As...→ 选择FINS可自动解析命令码与区域码。若未识别说明帧结构错误如头部长度不对、字节序反了。5.3 关键参数速查表不同机型与网络环境下的典型配置场景参数推荐值说明PLC 型号DA1/DA2CJ2M:0x01/0x00, CP1E:0x01/0x00, NJ501:0x01/0x00多数情况下相同但 NJ 系列若启用 EtherCAT 主站DA2 可能为0x01TCP 超时ReceiveTimeout2000ms低于 1000ms 易受网络抖动影响高于 5000ms 导致故障响应慢心跳间隔StartHeartbeatAsync45秒必须小于 PLC TCP 空闲超时CX-Programmer 中默认 60 秒并发读写MaxDegreeOfParallelism1FINS 协议本身不支持流水线同一连接必须串行化请求密码长度SetPasswordAsync4字符超过 4 位会被截断不足 4 位自动补\0最后一个可立即验证的完整调用链var communicator new PlcCommunicator(192.168.1.10, 9600); await communicator.EnsureConnectedAsync(); // 读取 D100-D103 var (success, data, err) await communicator.ReadDWordsAsync(100, 4); if (success) Console.WriteLine($D100{data[0]}, D101{data[1]}, D102{data[2]}, D103{data[3]}); else Console.WriteLine($Read failed: {err});将这段代码放入ConsoleApp替换 IP 地址编译运行——你看到的不再是黑屏或异常而是真实的 PLC 数据流。这才是 C# 开发欧姆龙上位机的起点不依赖第三方控件不迷信商业 SDK用协议本身的力量把字节流变成产线可信赖的信号。本文还有配套的精品资源点击获取
返回列表