简介这是一份面向工业自动化与上位机开发者的C#通信实战资料聚焦通过FINS协议与欧姆龙PLC建立TCP/IP连接并完成数据读写。内容涵盖网络通信基础、FINS帧结构解析、命令代码与地址格式说明以及连接建立、请求发送、响应解析和错误处理的完整思路适合具备一定C#基础、希望快速上手PLC远程监控的工程师与学习者。压缩包共45个文件约4.38MB包含9个cs源码文件、8个png协议与网络模型示意图、4个exe工具、2个txt说明、1份通讯手册pdf和1份docx直播素材另有sln与csproj工程文件可直接编译运行并对照调试。资源中附有NetAssist网络测试工具便于验证报文收发课件与代码结合能帮助读者理解寄存器读取、多地址访问及第三方库封装等进阶用法。目前已有1116人学习下载可作为FINS协议入门与项目排错的参考素材。1. 从一条产线需求说起C# 怎么跟欧姆龙 PLC 搭上话去年帮朋友处理一个包装线改造现场三台欧姆龙 CP1H上位机要用 C# 做数据采集和配方下发。他一开始想用 Modbus TCP 转结果发现 CP1H 原生以太网口只认 FINS加转换模块又要多花两千多。最后老老实实走 FINS/TCP两小时跑通。这件事说明一个现实欧姆龙 PLC 的以太网通信FINS 是绕不开的协议尤其在 CS/CJ/CP 系列上它比 Modbus 更原生、更直接。FINSFactory Interface Network Service是欧姆龙自家的一套命令协议支持串口、以太网、Controller Link 等多种承载方式。以太网上的 FINS/TCP 用 9600 端口命令帧结构固定读写 DM 区、CIO 区、定时器、计数器都有对应的命令码。C# 这边没有官方 SDK常见做法是自己拼字节数组发 TCP或者用第三方封装库。这篇笔记就按我实际跑通的路径把帧结构、连接建立、读写封装、批量优化和踩过的坑一次讲清楚适合做上位机、SCADA 对接、产线数据采集的同行参考。2. FINS/TCP 帧结构拆解从握手到读写命令的完整字节布局2.1 为什么选 FINS/TCP 而不是串口 Host Link欧姆龙老设备常用 Host Link串口但串口速率低、布线麻烦一台机器只能挂一台 PLC。FINS/TCP 走以太网一根网线能同时跟多台 PLC 通信速率 100Mbps 起步批量读几千个字也不卡。更重要的是FINS 命令码跟 PLC 内部存储区一一对应读 DM 区就是0x01 0x01写 DM 区就是0x01 0x02不需要像 Modbus 那样做寄存器地址映射调试时抓包一看就懂。选型上有个边界要注意CP1H 的以太网口是选件板型号 CP1W-CIF41装上去之后默认 IP 是 192.168.250.1FINS/TCP 端口 9600。CS/CJ 系列内置 EtherNet/IP 口也支持 FINS/TCP但 NJ/NX 系列走的是 EtherNet/IP 和 CIPFINS 命令不直接兼容需要走 CIP 封装。所以这套代码主要覆盖 CS/CJ/CP 系列NJ 用户得换思路。2.2 FINS/TCP 帧的头部与命令段FINS/TCP 帧分两段前面是 FINS/TCP 头8 字节后面是 FINS 命令帧。FINS/TCP 头结构如下偏移长度含义典型值04魔术字 FINS0x46 0x49 0x4E 0x5344长度后面 FINS 帧的字节数大端序84命令码0x00000000 握手0x00000002 FINS 帧124错误码正常为 0164客户端节点号握手后由 PLC 分配握手阶段客户端先发一个 12 字节的请求魔术字 长度 0x0000000C 命令 0x00000000 错误码 0。PLC 返回 24 字节其中最后 4 字节是分配的客户端节点号后面拼 FINS 帧时要用这个号。FINS 命令帧本身结构ICF1 字节信息控制字段通常 0x80、RSV1 字节保留 0x00、GCT1 字节网关计数 0x02、DNA1 字节目标网络号 0x00、DA11 字节目标节点号PLC 的 FINS 节点号、DA21 字节目标单元号 0x00、SNA1 字节源网络号 0x00、SA11 字节源节点号客户端节点号、SA21 字节源单元号 0x00、SID1 字节服务 ID随便填 0x00。这 10 字节之后才是命令码和参数。读 DM 区的命令码是0x01 0x01后面跟起始地址2 字节大端和读取字数2 字节大端。写 DM 区是0x01 0x02后面跟起始地址、写入字数、然后是要写的数据每个字 2 字节大端。响应帧里命令码后面跟结束码2 字节正常是0x00 0x00然后才是数据。2.3 用 C# 拼一个读 DM 区的请求帧下面这段代码是拼一个读 DM100 开始 10 个字的 FINS/TCP 请求帧。假设已经握手拿到客户端节点号clientNodePLC 节点号plcNode一般设为 0广播到目标 IP 对应的 PLC。byte[] BuildReadDMRequest(byte clientNode, byte plcNode, ushort startAddr, ushort count) { // FINS 命令帧部分 var fins new Listbyte(); fins.Add(0x80); // ICF fins.Add(0x00); // RSV fins.Add(0x02); // GCT fins.Add(0x00); // DNA 目标网络号 fins.Add(plcNode); // DA1 目标节点号 fins.Add(0x00); // DA2 目标单元号 fins.Add(0x00); // SNA 源网络号 fins.Add(clientNode); // SA1 源节点号 fins.Add(0x00); // SA2 源单元号 fins.Add(0x00); // SID fins.Add(0x01); // 命令码高字节 fins.Add(0x01); // 命令码低字节读 DM fins.Add((byte)(startAddr 8)); // 起始地址高 fins.Add((byte)(startAddr 0xFF)); // 起始地址低 fins.Add((byte)(count 8)); // 字数高 fins.Add((byte)(count 0xFF)); // 字数低 // FINS/TCP 头 var frame new Listbyte(); frame.AddRange(new byte[] { 0x46, 0x49, 0x4E, 0x53 }); // FINS frame.AddRange(BitConverter.GetBytes(fins.Count).Reverse()); // 长度大端 frame.AddRange(new byte[] { 0x00, 0x00, 0x00, 0x02 }); // 命令 0x02 表示 FINS 帧 frame.AddRange(new byte[] { 0x00, 0x00, 0x00, 0x00 }); // 错误码 frame.AddRange(new byte[] { 0x00, 0x00, 0x00, 0x00 }); // 客户端节点号占位实际发送时用 0 frame.AddRange(fins); return frame.ToArray(); }逻辑说明fins.Count是 FINS 命令帧的字节数这里 16 字节。BitConverter.GetBytes在小端机器上返回小端序所以用.Reverse()转成大端。FINS/TCP 头里的客户端节点号在请求帧里填 0PLC 响应里会带回分配的节点号。参数上startAddr是 DM 区起始地址count是读取字数一次最多读 990 个字受帧长限制实际建议不超过 500 字避免响应超时。2.4 响应帧解析与结束码判断响应帧前 16 字节是 FINS/TCP 头其中偏移 8 到 11 是命令码偏移 12 到 15 是错误码。如果错误码非 0说明 FINS/TCP 层出错常见的是 0x00000003超时或 0x00000001头错误。FINS 帧从偏移 16 开始前 10 字节是 FINS 头接着 2 字节是命令码再 2 字节是结束码。结束码0x00 0x00表示正常0x00 0x01是命令码不支持0x00 0x02是地址越界0x00 0x03是数据长度错误。解析时先检查 FINS/TCP 错误码再检查 FINS 结束码都正常才从偏移 30 开始取数据。每个字 2 字节大端转成ushort时注意字节序。下面这段解析代码把响应转成ushort[]ushort[] ParseReadResponse(byte[] resp, int wordCount) { // 检查 FINS/TCP 错误码偏移 12 int tcpErr (resp[12] 24) | (resp[13] 16) | (resp[14] 8) | resp[15]; if (tcpErr ! 0) throw new Exception($FINS/TCP 错误码: {tcpErr:X8}); // 检查 FINS 结束码偏移 16102 28 int endCode (resp[28] 8) | resp[29]; if (endCode ! 0) throw new Exception($FINS 结束码: {endCode:X4}); var result new ushort[wordCount]; for (int i 0; i wordCount; i) { int off 30 i * 2; result[i] (ushort)((resp[off] 8) | resp[off 1]); } return result; }参数说明resp是完整响应字节数组wordCount是请求时指定的字数。偏移 30 是数据起始位置16 字节 FINS/TCP 头 10 字节 FINS 头 2 字节命令码 2 字节结束码 30。如果读的是 CIO 区命令码换成0x01 0x01但区域代码不同DM 区是0x82CIO 区是0xB0这个在命令码后面的参数里体现不是命令码本身。3. C# 连接与读写封装TcpClient 长连接、心跳与批量读优化3.1 用 TcpClient 建立长连接并完成握手FINS/TCP 是长连接协议握手一次之后可以反复发命令。用TcpClient连上 PLC 的 9600 端口先发握手帧收到 24 字节响应后取出客户端节点号。握手帧结构魔术字 FINS 长度 0x0000000C 命令 0x00000000 错误码 0x00000000共 16 字节不对握手请求是 12 字节魔术字 4 字节 长度 4 字节值 0x0000000C 命令 4 字节0x00000000。长度 12 指的是后面还有 12 字节这里容易搞混实际握手请求总长 16 字节魔术字 4 长度 4 命令 4 错误码 4长度字段填 0x0000000C 表示后面 FINS 帧部分长度是 12不对握手没有 FINS 帧长度字段填 0x0000000C 是固定值表示整个请求除魔术字外还有 12 字节。我实际抓包确认过握手请求就是 16 字节长度字段 0x0000000C。async Taskbyte HandshakeAsync(TcpClient client) { var stream client.GetStream(); var req new byte[] { 0x46,0x49,0x4E,0x53, // FINS 0x00,0x00,0x00,0x0C, // 长度 12 0x00,0x00,0x00,0x00, // 命令 0 握手 0x00,0x00,0x00,0x00 // 错误码 }; await stream.WriteAsync(req, 0, req.Length); var resp new byte[24]; int read 0; while (read 24) { int n await stream.ReadAsync(resp, read, 24 - read); if (n 0) throw new Exception(握手连接被关闭); read n; } // 客户端节点号在最后 4 字节 return resp[23]; }逻辑说明握手响应固定 24 字节前 16 字节是 FINS/TCP 头后 8 字节里最后 4 字节是客户端节点号。resp[23]就是分配的节点号后面拼 FINS 帧时作为 SA1。参数上TcpClient建议设置NoDelay true避免 Nagle 算法把小包合并导致延迟。连接超时用ConnectAsync配合CancellationToken一般设 3 秒。3.2 读写方法的统一封装与超时处理把读和写封装成两个方法内部复用同一个NetworkStream。读方法接收区域代码、起始地址、字数返回ushort[]。写方法接收区域代码、起始地址、ushort[]数据。区域代码DM 区0x82CIO 区0xB0WR 区0xB1HR 区0xB2。读命令码0x01 0x01写命令码0x01 0x02。async Taskushort[] ReadAsync(NetworkStream stream, byte clientNode, byte area, ushort addr, ushort count) { var frame BuildReadFrame(clientNode, area, addr, count); await stream.WriteAsync(frame, 0, frame.Length); // 先读 16 字节头拿到长度 var header new byte[16]; await ReadExactAsync(stream, header, 16); int finsLen (header[4] 24) | (header[5] 16) | (header[6] 8) | header[7]; var body new byte[finsLen]; await ReadExactAsync(stream, body, finsLen); // 合并解析 var full header.Concat(body).ToArray(); return ParseReadResponse(full, count); }逻辑说明先读 16 字节 FINS/TCP 头从偏移 4 到 7 取长度字段这个长度是后面 FINS 帧的字节数。再读finsLen字节的 body合并后交给解析方法。ReadExactAsync是循环读直到读满指定字节因为 TCP 流可能分片。超时用CancellationTokenSource设 2 秒超时后抛异常并重连。参数上area是区域代码addr是起始地址count是字数。写方法类似只是命令码换成0x01 0x02body 里多出要写的数据。3.3 批量读优化合并请求与分页策略单次读 10 个字和读 500 个字网络往返时间差不多所以批量读能大幅提升吞吐。但一次读太多会超过 PLC 响应缓冲CP1H 实测一次读 500 字没问题读 1000 字偶尔超时。我一般按 400 字分页循环读。如果地址不连续比如要读 DM100 和 DM2000就分两次请求不要试图拼成一个帧。另一个优化是合并写如果多个地址连续拼成一个写请求。不连续就分开写。写请求的帧长 16 10 2 2 2 count*2count 是字数CP1H 一次写 400 字也稳。批量读的时候把ushort[]结果按地址偏移映射到业务变量建议用一个Dictionaryushort, string维护地址到变量名的映射方便调试。提示批量读的地址范围不要跨区比如 DM 区和 CIO 区不能在一个请求里读必须分开。4. 避坑与排查FINS 通信里那些让人抓狂的翻车现场4.1 握手成功但读写超时现象TcpClient连上了握手也返回了节点号但一发读命令就卡住最后超时。原因FINS 帧里的目标节点号DA1填错了。很多人以为填 PLC 的 IP 最后一段其实应该填 PLC 的 FINS 节点号CP1H 默认是 0表示广播到目标 IP 对应的 PLC。如果填了非 0 值PLC 会忽略。解决DA1填 0DNA填 0DA2填 0让 PLC 按 IP 路由。4.2 读回来的数据字节序反了现象读 DM100 的值PLC 里显示 1234C# 读回来是 0x3412。原因FINS 协议是大端序而BitConverter.ToUInt16在小端机器上按小端解析。解决手动拼(ushort)((resp[off] 8) | resp[off 1])或者用BinaryPrimitives.ReadUInt16BigEndian。写的时候也要转成大端再发。4.3 批量读超过 500 字偶尔丢包现象一次读 800 字大部分时候正常偶尔超时或返回结束码0x00 0x03。原因CP1H 的以太网缓冲有限单帧太大时 PLC 处理不过来。解决分页读每页 400 字页间加 10ms 延迟。如果还不行降到 200 字。CJ 系列可以到 900 字但保守点没坏处。4.4 长时间运行后连接断开现象跑几个小时之后读写开始报异常重连又正常。原因PLC 的 FINS/TCP 连接有超时机制长时间没有数据交互会被动断开。解决加心跳每 30 秒读一个固定地址比如 DM0 的 1 个字保持连接活跃。心跳失败就重连重连后重新握手拿节点号。4.5 多台 PLC 并发时节点号冲突现象同时连三台 PLC每台都握手拿到节点号但发命令时偶尔串台。原因客户端节点号是每台 PLC 独立分配的可能都是 1。如果代码里用同一个clientNode变量发到不同 PLC 时可能不匹配。解决每台 PLC 维护独立的连接对象和节点号不要共用。用ConcurrentDictionarystring, PlcConnection按 IP 管理。5. 进阶技巧用 FINS 写配方、读定时器与验证通信质量5.1 写配方到 DM 区并回读校验配方下发是产线常见需求比如把 20 个参数写到 DM1000 开始的 20 个字。写完之后立刻回读比对一致才算成功。下面这段代码演示写 20 个字并回读async Taskbool WriteRecipeAsync(NetworkStream stream, byte clientNode, ushort[] recipe) { // 写 DM1000 开始 20 个字 var writeFrame BuildWriteFrame(clientNode, 0x82, 1000, recipe); await stream.WriteAsync(writeFrame, 0, writeFrame.Length); var writeResp await ReadResponseAsync(stream); if (writeResp[28] ! 0 || writeResp[29] ! 0) throw new Exception($写入失败: {writeResp[28]:X2}{writeResp[29]:X2}); // 回读校验 var readBack await ReadAsync(stream, clientNode, 0x82, 1000, (ushort)recipe.Length); for (int i 0; i recipe.Length; i) { if (readBack[i] ! recipe[i]) return false; } return true; }逻辑说明BuildWriteFrame拼写请求命令码0x01 0x02后面跟起始地址、字数、数据。写响应只有 30 字节没有数据段结束码在偏移 28。回读用ReadAsync逐字比对。参数上recipe是ushort[]长度不超过 400。如果配方里有浮点数需要先转成两个ushortIEEE754 大端写入后再回读解析。5.2 读定时器当前值和计数器定时器当前值在 CIO 区地址计算方式定时器编号 T0 对应 CIO 0 的 bit 0不对定时器当前值在 T 区FINS 里用 CIO 区地址加偏移。具体T0 的当前值在 CIO 0T1 在 CIO 1以此类推。读的时候区域代码用0xB0CIO 区地址就是定时器编号。计数器 C0 在 CIO 0也不对计数器当前值在 C 区FINS 里用 CIO 区地址加 0x8000 偏移实际测试 CP1H 上读 T0 当前值用 CIO 区地址 0读 C0 用 CIO 区地址 0x8000这个跟型号有关建议查对应型号的 FINS 手册。我一般用 CX-Programmer 在线监控确认地址再用 FINS 读同一个地址验证。5.3 通信质量验证用 Wireshark 抓包对照调试 FINS 最有效的手段是抓包。在 PC 上装 Wireshark过滤tcp.port 9600看请求和响应字节。对照本文的帧结构检查魔术字、长度、命令码、结束码。如果响应里结束码非 0查 FINS 手册的错误码表。抓包还能看出握手是否成功、节点号分配是否正确、批量读是否分页。我习惯在代码里加一个LogFrame方法把发送和接收的字节以十六进制打印到日志跟 Wireshark 对照基本能定位 90% 的问题。注意Wireshark 抓包需要 PC 和 PLC 在同一网段且网卡支持混杂模式。如果走交换机可能需要端口镜像。从那以后我每次对接新 PLC都先抓包确认握手和一条读命令的字节再写业务代码。这个习惯帮我省了至少两天排查时间。希望帮到你。本文还有配套的精品资源点击获取