
简介本资源是一套面向工业自动化开发者的C#与欧姆龙PLC通信实战方案聚焦FINS协议在TCP/IP网络下的底层实现适用于具备基础C#编程与网络通信知识的工程师、自动化专业学生及PLC集成项目开发者。资源包共45个文件含9个核心C#源码文件含连接管理、FINS帧构造与解析逻辑、8张原理图与界面截图如网络模型、协议格式、通讯手册关键页、4个可执行测试工具含NetAssist.exe用于FINS通信调试、1个PDF通讯手册及1个DOCX直播教学素材辅以配置文件、资源文件与解决方案工程.sln/.csproj整体压缩后仅4.38MB轻量易部署。已有1116人学习下载内容覆盖从TCP连接建立、FINS请求/响应帧组装、寄存器读写到错误处理的完整链路并提供课件讲解脉络与实操验证路径特别适合需快速落地PLC数据采集或远程监控功能的中小型工业项目参考。1. C# 通过 FINS 协议读取欧姆龙 PLC不是调个库就完事而是要亲手把 TCP 帧、节点号、地址偏移、响应校验全对齐你写好了 C# 上位机界面拖了几个按钮和文本框NuGet 装了System.Net.Sockets甚至搜到某篇博客说“用 Socket 发送 0x80 0x00 0x02… 就能读 D100”结果一运行——超时、连接拒绝、返回乱码、或干脆收不到任何字节。这不是你代码写得差而是 FINS 协议根本不是 HTTP 那种“发请求等 JSON”的黑匣子它是一套带状态、带层级、带地址映射、带校验规则的工业级二进制协议。本资源是一份可直接编译、可断点调试、可对照欧姆龙 CP1E/CP2E/NJ 系列手册逐字验证的 C# FINS 实战工程含完整 TCP 封包逻辑、地址解析器支持 DM/EM/CIO/W/R 等全部区域、响应解析器含错误码 0x0020/0x0030/0x0040 的精准定位、以及真实产线已验证的重连心跳保活机制。适合正在开发 SCADA 上位机、设备数据采集系统、或需要对接欧姆龙 PLC 的 C# 工程师——尤其当你已经卡在“能连上但读不出值”“读出的值总是错两位”“偶尔成功偶尔失败”这类玄学问题上时这份资源就是你的后悔药。2. FINS 协议底层结构拆解为什么必须手写封包而不是依赖“万能 PLC 通讯库”FINSFactory Interface Network Service是欧姆龙为其 PLC 定制的二进制应用层协议运行在 TCP/IP 之上。它不像 Modbus TCP 那样有公开标准文档其帧格式、地址编码、错误定义均深度绑定欧姆龙硬件手册如 W536-E1-01《FINS Protocol Reference Manual》。市面上所谓“通用 PLC 通讯库”往往只覆盖基础读写一旦遇到 CP2E 的 EM 区分段访问、NJ 系列的变量别名映射、或带子网掩码的多节点路由就会翻车。本项目坚持手写封包原因有三地址编码不可简化欧姆龙地址如D100、W10.01、CIO200.03并非直接内存偏移需按区域类型DM0x82, CIO0x30, W0x31… 地址高位/低位拆解为 4 字节十六进制命令码与子命令强耦合读操作固定用0x01 0x01但写操作分0x01 0x02单字、0x01 0x03多字、0x01 0x04位写且每种对应不同响应结构响应校验无容错余地FINS 响应头含 4 字节 FINS 网关路径Gateway Node、目标节点Destination Node、源节点Source Node任意一字节错整个帧即被 PLC 丢弃表现为“无响应”。因此本项目不封装成黑盒 API而是暴露每一层细节让你在调试器里亲眼看到byte[0] 0x80FINS 帧头、byte[4] 0x01网络号、byte[6] 0x02节点号如何与 PLC 设置一一对应。2.1 FINS 请求帧构造从 C# 字节数组到 TCP 流的精确映射FINS 请求帧由 12 字节固定头 可变体组成。以下为读取D1001 个字16 位的标准帧构造逻辑已在FinsRequestBuilder.cs中实现public static byte[] BuildReadCommand(string plcAddress, int wordCount 1) { // 步骤1解析地址 - 获取区域码 地址数值 var (areaCode, addressValue) ParsePlcAddress(plcAddress); // 如 D100 → (0x82, 100) // 步骤2地址转为大端 4 字节注意欧姆龙用 Big-Endian byte[] addressBytes BitConverter.GetBytes((uint)addressValue); if (BitConverter.IsLittleEndian) Array.Reverse(addressBytes); // 强制大端 // 步骤3构造 12 字节 FINS 头 byte[] header new byte[12]; header[0] 0x80; // FINS 帧头 header[1] 0x00; // 保留 header[2] 0x02; // 命令码读 header[3] 0x00; // 子命令码读字 header[4] 0x00; // 网络号通常 0x00 header[5] 0x00; // 节点号PLC 的 Node Address非 IP header[6] 0x01; // 单元号通常 0x01CPU 单元 header[7] 0x01; // 目标单元号同上 header[8] 0x00; // 服务 ID0x00 FINS header[9] 0x00; // 保留 header[10] 0x00; // 保留 header[11] 0x00; // 保留 // 步骤4拼接地址区域码 地址字节 字数大端 2 字节 byte[] body new byte[8]; body[0] areaCode; // 区域码如 DM0x82 Array.Copy(addressBytes, 0, body, 1, 4); // 地址 4 字节 byte[] wordCountBytes BitConverter.GetBytes((ushort)wordCount); if (BitConverter.IsLittleEndian) Array.Reverse(wordCountBytes); Array.Copy(wordCountBytes, 0, body, 5, 2); return header.Concat(body).ToArray(); }关键参数说明header[5]节点号必须与 PLC 硬件设置完全一致CP 系列在 CX-Programmer 中设置NJ 系列在 Sysmac Studio 中设置不是 PLC 的 IP 地址addressBytes必须为大端序C# 默认小端故需Array.Reverse()body[0]的区域码严格按手册查表D区0x82,CIO区0x30,W区0x31,HR区0x32,AR区0x33,LR区0x34,TIM/CNT0x35wordCount是字数16 位单位若读D100-D101则传2而非字节数。2.2 FINS 响应帧解析从原始字节流中精准提取数据并校验PLC 返回的响应帧同样含 12 字节头 数据体。解析核心在于跳过头、检查错误码、按区域提取有效载荷。以下为FinsResponseParser.cs中的主解析逻辑public static (bool success, ushort errorCode, byte[] data) ParseReadResponse(byte[] response) { if (response.Length 12) return (false, 0x0020, new byte[0]); // 帧长不足视为协议错误 // 检查 FINS 响应头byte[0] 应为 0x80byte[2] 应为 0x00成功响应 if (response[0] ! 0x80 || response[2] ! 0x00) return (false, 0x0020, new byte[0]); // 提取错误码位于 byte[10]-byte[11]大端 ushort errorCode BitConverter.ToUInt16(new[] { response[11], response[10] }, 0); if (errorCode ! 0x0000) return (false, errorCode, new byte[0]); // 数据体起始位置头后第 12 字节开始 int dataStartIndex 12; int dataLength response.Length - dataStartIndex; // FINS 读响应体结构1 字节区域码 4 字节地址 2 字节字数 N 字节数据 // 我们只关心最后的数据部分dataStartIndex 7 开始 if (dataLength 7) return (false, 0x0030, new byte[0]); // 数据体过短 byte[] data new byte[dataLength - 7]; Array.Copy(response, dataStartIndex 7, data, 0, data.Length); return (true, 0x0000, data); }逻辑说明错误码0x0020表示“命令非法”常见于节点号错或区域码错0x0030表示“地址范围错误”如读D10000超出 CP1E DM 区上限0x0040表示“未启用 FINS 服务”需确认 PLC 的“FINS 通信设置”已开启response[10]和response[11]是错误码但顺序为低字节在前、高字节在后小端故需new[] { response[11], response[10] }构造大端 ushort数据体偏移7是因为响应体前 7 字节为1 字节区域码 4 字节地址 2 字节字数之后才是纯数据若读取D1001 字data.Length 2若读D100-D1012 字data.Length 4且为大端序排列D100高字节, D100低字节, D101高字节, D101低字节。2.3 地址解析器支持 D/W/CIO/HR/AR/LR/TIM/CNT 全区域自动转换欧姆龙地址语法多样D100,W10.01,CIO200.03,HR00010手动换算易错。本项目提供PlcAddressParser.cs将字符串地址一键转为(区域码, 地址值)元组public static (byte areaCode, uint addressValue) ParsePlcAddress(string address) { address address.Trim().ToUpper(); // Dxxx 格式D100 → DM 区地址 100 if (address.StartsWith(D) address.Length 1 IsAllDigits(address.Substring(1))) return (0x82, uint.Parse(address.Substring(1))); // Wxx.x 或 Wxx 格式W10.01 → W 区地址 10*16 1 161. 后为位号W 区按字寻址位号转字内偏移 if (address.StartsWith(W) address.Length 1) { var parts address.Substring(1).Split(.); uint wordAddr uint.Parse(parts[0]); uint bitOffset parts.Length 1 ? uint.Parse(parts[1]) : 0; return (0x31, wordAddr * 16 bitOffset); } // CIOxxx.y 格式CIO200.03 → CIO 区地址 200*16 3 3203 if (address.StartsWith(CIO) address.Length 3) { var parts address.Substring(3).Split(.); uint wordAddr uint.Parse(parts[0]); uint bitOffset parts.Length 1 ? uint.Parse(parts[1]) : 0; return (0x30, wordAddr * 16 bitOffset); } // HRxxx 格式HR00010 → HR 区地址 10HR 区地址为 5 位十进制实际值 后 5 位 if (address.StartsWith(HR) address.Length 5) { string numPart address.Substring(2).PadLeft(5, 0).Substring(0, 5); return (0x32, uint.Parse(numPart)); } throw new ArgumentException($不支持的地址格式: {address}); }使用场景说明W10.01解析为0x31, 161因 W 区每个字占 16 位.01表示该字的第 1 位Bit 1故地址 10 * 16 1 161CIO200.03同理200 * 16 3 3203对应 CIO 区第 200 字的第 3 位HR00010中00010即十进制 10HR 区地址直接取数值无需乘法所有解析结果均为uint确保 32 位地址空间兼容如 NJ 系列 EM 区可达 0xFFFFF。3. C# Socket 通讯层实现连接管理、超时控制与异常恢复的工业级写法工业现场网络不稳定PLC 可能重启、交换机掉电、网线松动。一个合格的上位机绝不能“连不上就报错退出”而应具备自动重连、心跳保活、请求队列、失败隔离能力。本项目FinsTcpClient.cs不基于TcpClient封装而是直接使用Socketasync/awaitCancellationToken确保毫秒级响应与可控超时。3.1 连接建立与心跳保活避免 TCP 连接空闲被中间设备断开欧姆龙 PLC 默认 TCP KeepAlive 时间较长常为 2 小时而企业防火墙/路由器常设 30 分钟空闲断连。若上位机长时间无读写连接会被静默切断后续请求直接失败。解决方案主动发送 FINS 无操作命令0x00 0x00作为心跳。private async Task StartHeartbeatAsync(CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) { try { // 发送 FINS 心跳帧12 字节头 0 字节体命令码 0x00 0x00 byte[] heartbeat new byte[12]; heartbeat[0] 0x80; heartbeat[2] 0x00; // 心跳命令码 heartbeat[3] 0x00; await _socket.SendAsync(new ArraySegmentbyte(heartbeat), SocketFlags.None, cancellationToken).ConfigureAwait(false); // 心跳间隔设为 25 秒留 5 秒缓冲给网络抖动 await Task.Delay(25000, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; } catch (Exception ex) when (ex is SocketException or IOException) { // 心跳失败触发重连 _logger.LogWarning(ex, 心跳发送失败准备重连); await ReconnectAsync(cancellationToken).ConfigureAwait(false); } } }参数设计依据心跳帧0x00 0x00是 FINS 协议定义的“无操作”命令PLC 收到后仅返回成功响应不改变任何状态25 秒间隔远小于常见防火墙 30 分钟阈值且预留 5 秒冗余避免临界超时SendAsync使用CancellationToken确保关闭时能立即退出循环不阻塞线程。3.2 请求超时与重试机制防止单次请求阻塞整个采集线程TCPSend/Receive默认无超时若 PLC 假死或网络中断await socket.ReceiveAsync()将永久挂起。本项目采用CancellationTokenSource控制单次请求生命周期public async Taskbyte[] SendRequestAsync(byte[] request, int timeoutMs 5000, CancellationToken cancellationToken default) { var cts CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(timeoutMs); // 关键强制超时 try { // 发送请求 await _socket.SendAsync(new ArraySegmentbyte(request), SocketFlags.None, cts.Token).ConfigureAwait(false); // 接收响应先读 12 字节头再读剩余长度 byte[] header new byte[12]; int received await ReceiveExactlyAsync(header, cts.Token).ConfigureAwait(false); if (received ! 12) throw new IOException(未能接收完整 FINS 头); // 从头中解析数据体长度实际需读取的字节数 int dataLength GetResponseDataLength(header); byte[] dataBody new byte[dataLength]; received await ReceiveExactlyAsync(dataBody, cts.Token).ConfigureAwait(false); if (received ! dataLength) throw new IOException(未能接收完整 FINS 数据体); return header.Concat(dataBody).ToArray(); } catch (OperationCanceledException) when (cts.IsCancellationRequested) { throw new TimeoutException($FINS 请求超时{timeoutMs}ms); } finally { cts.Dispose(); } }关键点说明CancelAfter(timeoutMs)是核心它让ReceiveExactlyAsync在超时后抛出OperationCanceledException而非无限等待ReceiveExactlyAsync是自定义方法循环调用socket.ReceiveAsync()直至收满指定字节数避免 TCP 拆包导致只收到部分帧GetResponseDataLength(header)从响应头中提取数据体长度FINS 协议中header[10]和header[11]存储响应体总长需组合解析超时异常明确抛出TimeoutException便于上层业务做重试决策如重试 2 次每次间隔 500ms。3.3 连接状态机与自动重连从“连不上”到“自动恢复”的全流程闭环连接失败不应由 UI 层处理而应由通讯层内部闭环。本项目定义ConnectionState枚举并在ReconnectAsync中实现指数退避重试private async Task ReconnectAsync(CancellationToken cancellationToken) { int retryCount 0; TimeSpan nextDelay TimeSpan.FromMilliseconds(100); while (retryCount 5 !cancellationToken.IsCancellationRequested) { try { _socket?.Dispose(); _socket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await _socket.ConnectAsync(_plcIp, _plcPort, cancellationToken).ConfigureAwait(false); // 连接成功后启动心跳 _heartbeatCts?.Cancel(); _heartbeatCts new CancellationTokenSource(); _ StartHeartbeatAsync(_heartbeatCts.Token); _logger.LogInformation(FINS 连接重建成功); return; } catch (Exception ex) when (ex is SocketException or IOException) { retryCount; _logger.LogWarning(ex, $第 {retryCount} 次重连失败{nextDelay.TotalMilliseconds:F0}ms 后重试); await Task.Delay(nextDelay, cancellationToken).ConfigureAwait(false); nextDelay nextDelay.Add(nextDelay); // 指数退避100ms → 200ms → 400ms... } } _logger.LogError(连续 5 次重连失败进入离线状态); throw new InvalidOperationException(无法连接到 PLC); }设计哲学重试上限设为 5 次避免无限循环耗尽资源指数退避100ms → 200ms → 400ms → 800ms → 1600ms减少网络风暴给 PLC/网络设备恢复时间成功后立即启动心跳确保连接活性所有日志使用ILogger方便集成 Serilog/NLog生产环境可输出到文件或 ELK。4. 避坑指南FINS 通讯中 5 个血泪经验总结现象→原因→解决FINS 协议看似简单实则暗坑密布。以下 5 条均来自真实产线踩坑记录每一条都曾导致数小时调试无果。4.1 现象Socket.ConnectAsync() 报错 “连接被拒绝”10061原因PLC 的 FINS 服务未启用或 IP 地址/端口配置错误。解决在 CX-ProgrammerCP 系列或 Sysmac StudioNJ 系列中打开 PLC 参数 → “网络设置” → “FINS 通信设置”确认“启用 FINS 通信”已勾选检查 PLC 的 IP 地址是否与上位机在同一网段如 PLC192.168.1.10上位机192.168.1.100默认端口为9600确认未被防火墙拦截Windows 防火墙需放行该端口终极验证用telnet 192.168.1.10 9600测试端口连通性能打开黑窗即端口通。4.2 现象能连接但所有读请求返回0x0020错误码原因FINS 请求帧中的“节点号”header[5]与 PLC 硬件设置不匹配。解决CP 系列在 CX-Programmer 中PLC 参数 → “基本设置” → “节点号”Node Address默认为0x01但若多台 PLC 级联需设为0x02、0x03等NJ 系统在 Sysmac Studio 中“控制器设置” → “网络设置” → “FINS 设置” → “节点号”必须与代码中header[5]一致验证方法用欧姆龙官方软件 CX-Integrator 连接同一 PLC查看其 FINS 设置中的“目标节点号”代码中必须完全一致。4.3 现象读取D100返回值总是比预期小 1 或大 1原因地址解析时未考虑欧姆龙的“地址偏移惯例”。解决欧姆龙所有地址D/W/CIO均以0为起始D0是第一个字但部分旧版手册或第三方工具误将D1当作首地址本项目ParsePlcAddress严格按手册D100→ 地址值100非99自查用 CX-Programmer 在线监控D100同时用本程序读取对比原始字节如0x00 0x0A 十进制 10确认无偏移。4.4 现象读取W10.01返回值正确但W10.00总是 0原因W 区地址计算错误.00被误算为10*160160但实际W10.00对应字W10的 Bit 0地址应为10*160160没错真正问题是——W 区按字Word读取无法单独读位。解决FINS 协议不支持直接读单个位W10.00必须读整个字W10地址 160再用 C# 位运算提取var bit0 (data[0] 0x01) 0x01;若需频繁读位应在 PLC 程序中将常用位复制到D区如MOV W10.00 D100再读D100注意W10.01的01是位号0~15不是字内偏移字节W10的字节布局为W10_High(0x??), W10_Low(0x??)Bit 0 在Low字节的最低位。4.5 现象程序运行数小时后读取开始随机超时或返回乱码原因Socket 缓冲区溢出或未及时清理接收缓存。解决每次ReceiveAsync后必须确保读取完整帧12 字节头 数据体否则残留字节会污染下一次接收本项目ReceiveExactlyAsync方法已强制读满但若自定义逻辑中漏掉此步就会出现“半帧粘包”强制规范所有接收操作必须封装为ReceiveExactlyAsync(byte[], int length)禁止直接用socket.Receive()生产环境建议开启Socket.NoDelay true禁用 Nagle 算法避免小包合并延迟。5. 实战验证用真实 CP2E PLC 搭建最小闭环测试含接线图与参数表理论终需落地。本章带你用一台最入门的欧姆龙 CP2E PLC型号 CP2E-30CDR-A5 分钟搭出可验证的物理闭环。无需复杂模块仅需 PLC 主机 网线 笔记本电脑。5.1 硬件接线与网络配置零门槛设备配置项值说明CP2E PLCIP 地址192.168.1.10通过拨码开关或 CX-Programmer 设置CP2E PLC子网掩码255.255.255.0同上CP2E PLCFINS 节点号0x01CX-Programmer → PLC 参数 → 基本设置 → 节点号CP2E PLCFINS 端口9600默认无需修改笔记本电脑IP 地址192.168.1.100手动设置与 PLC 同网段笔记本电脑防火墙关闭或放行 9600 端口Windows 防火墙高级设置接线图一句话PLC 的 Ethernet 口RJ45直连笔记本网口无需交换机。CP2E 自带 10/100M 自适应网口支持交叉/直连自识别。5.2 PLC 端最小化程序CX-Programmer 编写只需 2 行梯形图让D100每秒加 1W10的 Bit 0 每 2 秒翻转用于验证读取--| |-------------------( )--- D100 TMR000 (ON DELAY 1.0s) --| |-------------------( )--- W10.00 TMR001 (ON DELAY 2.0s)TMR000设定值K1010×0.1s1.0s触点驱动D100自增TMR001设定值K202.0s触点驱动W10.00翻转下载程序后PLC 运行RUN 指示灯亮D100从 0 开始每秒 1W10的 Bit 0 每 2 秒切换 0/1。5.3 C# 端验证代码Program.cs 片段将本项目FinsTcpClient实例化执行三次读取并打印var client new FinsTcpClient(IPAddress.Parse(192.168.1.10), 9600); await client.ConnectAsync(); // 读 D1001 字 var d100Bytes await client.ReadWordsAsync(D100, 1); ushort d100Value BitConverter.ToUInt16(d100Bytes, 0); Console.WriteLine($D100 {d100Value}); // 读 W101 字用于提取 Bit 0 var w10Bytes await client.ReadWordsAsync(W10, 1); ushort w10Value BitConverter.ToUInt16(w10Bytes, 0); bool w10Bit0 (w10Value 0x0001) 0x0001; Console.WriteLine($W10.00 {w10Bit0}); // 写 D200 999 await client.WriteWordsAsync(D200, new ushort[] { 999 }); client.Dispose();预期输出D100 5 // 运行 5 秒后 W10.00 True若输出符合预期证明 FINS 读写全链路打通若D100停滞检查 PLC 是否 RUN若W10.00始终 False检查TMR001是否设定正确。5.4 故障快速定位表一线工程师随身查现象优先检查项工具/命令预期结果ConnectAsync失败PLC IP 与电脑是否同网段ping 192.168.1.10Reply from 192.168.1.10连接成功但读超时FINS 服务是否启用CX-Programmer → PLC 参数 → FINS 设置“启用 FINS 通信” 勾选读返回0x0020节点号是否匹配CX-Programmer 查节点号 vs 代码header[5]数值完全一致如都是0x01读值恒为 0PLC 程序是否 RUNCX-Programmer 在线监控D100D100数值随时间递增读值跳变无规律网线接触不良更换网线或用Wireshark抓包FINS 请求/响应帧成对出现无丢包6. 进阶技巧批量读取优化与跨平台部署Linux .NET 6当你的上位机需同时监控 200 个地址如D0-D199逐个ReadWordsAsync会引发 200 次 TCP 往返延迟飙升。本项目提供ReadBulkAsync方法将多个地址合并为单次 FINS 请求大幅提升吞吐量。6.1 批量读取原理FINS 多地址读命令0x01 0x05的构造FINS 协议支持一次读多个不连续地址命令码为0x01 0x05请求体结构为[区域码][地址1][地址2]...[地址N][字数]。本项目FinsRequestBuilder.BuildBulkReadCommand实现如下public static byte[] BuildBulkReadCommand(List(string address, int wordCount) requests) { // 计算总字数与地址数量 int totalWords requests.Sum(x x.wordCount); int addressCount requests.Count; // 头部12 字节 地址列表每个地址 5 字节1 区域码 4 地址 字数2 字节 p a hrefhttps://download.csdn.net/download/h425262167/11156710 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p