C# 直连欧姆龙 PLC:FINS 协议通讯实战与避坑指南 简介这份资源面向具备一定C#基础的工控开发者与自动化工程师聚焦用C#通过FINS协议与欧姆龙PLC建立通信这一实际问题提供可参考的示例代码与配套讲解。压缩包共45个文件、约4.38MB以cs源码、png协议示意图、exe测试工具、config配置及pdf通讯手册为主另含sln解决方案与docx直播素材兼顾代码实践与协议查阅。内容围绕TCP/IP连接、FINS帧结构构造、请求发送与响应解析展开并附网络模型与协议格式图示便于对照理解命令代码、地址与数据长度的组织方式同时给出借助现成库简化开发的思路。已有1120人学习下载适合希望快速跑通读写流程、排查通信异常并封装常用PLC操作的读者参考借鉴。1. 从一条产线调试日志说起C# 直连欧姆龙 PLC 的 FINS 协议到底怎么落地去年帮一个做包装设备的客户排查通讯故障现场日志里反复出现0x0000正常响应和0x1101超时交替刷屏上位机是 C# 写的走的是 FINS over TCP。问题不在协议本身而在几个地址换算和握手时序的细节上。这件事让我意识到很多做上位机集成的同行手里都缺一份能直接跑起来的 FINS 读写示例——不是协议手册那种满是帧格式定义的文档而是一个打开就能连、改改地址就能读写的 C# 工程。这份资源就是围绕这个场景整理的用 C# 实现欧姆龙 PLC 的 FINS 协议通讯覆盖 TCP/UDP 两种承载方式、DM 区与 CIO 区的字读写、位读写以及握手帧的构造。适合做设备数据采集、产线监控上位机、MES 对接的开发者尤其是手上有一台欧姆龙 PLC 却卡在通讯层的新手。下面按「协议怎么理解 → 代码怎么跑 → 参数怎么调 → 坑在哪」的顺序拆开讲。2. FINS 帧结构与 C# 实现从握手到读写命令的完整链路2.1 先搞清楚 FINS 的两种承载方式FINS 是欧姆龙 PLC 的指令级通讯协议本身只定义命令帧的数据结构不绑定传输层。实际工程里常见两种承载FINS/TCP 和 FINS/UDP。TCP 方式在建立连接后需要先发一条「FINS/TCP 握手帧」做节点地址协商PLC 返回自身节点号后才允许发 FINS 命令帧UDP 方式没有握手环节直接往 PLC 的 9600 端口发 FINS 帧即可但需要自己保证源节点地址不冲突。选型上我一般这样判断产线上单台上位机对单台 PLC、网络稳定、要求可靠传输走 TCP多上位机并发采集、对实时性要求高但能容忍偶发丢包走 UDP。TCP 的握手帧是新手最容易漏掉的一步漏了就会看到 PLC 对后续命令帧一律不响应日志里只有超时没有任何错误码——这个现象后面避坑章节会展开。FINS 命令帧的核心结构是固定的10 字节 FINS 头ICF、RSV、GCT、DNA、DA1、DA2、SNA、SA1、SA2、SID 命令码2 字节如 0101 读、0102 写 数据区。读 DM 区的命令码是0101写是0102读 CIO 区同样是0101靠命令帧里的区域代码区分。区域代码DM 区0x82CIO 区0xB0工作区0xB1保持区0xB2。这些代码在拼帧时写错一个字节PLC 就会返回0x1103之类的参数错误。2.2 用 C# 拼一条读 DM 区的命令帧下面这段代码是读 DM 区连续 N 个字的完整帧构造与发送逻辑基于System.Net.Sockets.TcpClient不依赖第三方库using System; using System.Net.Sockets; public class FinsTcpClient { private TcpClient _tcp; private NetworkStream _stream; private byte _pcNode 0x01; // 上位机节点号需与 PLC 路由表一致 private byte _plcNode 0x00; // PLC 节点号握手后由 PLC 返回 public bool Connect(string ip, int port 9600) { _tcp new TcpClient(); _tcp.Connect(ip, port); _stream _tcp.GetStream(); return Handshake(); } // FINS/TCP 握手帧固定 20 字节命令码 0x0000 private bool Handshake() { byte[] frame new byte[20]; frame[0] 0x46; frame[1] 0x49; frame[2] 0x4E; frame[3] 0x53; // FINS frame[4] 0x00; frame[5] 0x00; frame[6] 0x00; frame[7] 0x0C; // 长度 12 frame[8] 0x00; frame[9] 0x00; frame[10] 0x00; frame[11] 0x00; // 命令 0x0000 frame[12] 0x00; frame[13] 0x00; frame[14] 0x00; frame[15] 0x00; // 错误码 frame[16] _pcNode; // 客户端节点 frame[17] 0x00; frame[18] 0x00; // 保留 frame[19] 0x00; _stream.Write(frame, 0, frame.Length); byte[] resp new byte[24]; int n _stream.Read(resp, 0, resp.Length); if (n 24) return false; _plcNode resp[19]; // PLC 返回的节点号 return resp[15] 0x00; // 错误码为 0 表示握手成功 } // 读 DM 区startAddr 起始字地址count 读取字数 public ushort[] ReadDM(ushort startAddr, ushort count) { byte[] cmd new byte[8 8]; // FINS 头 10 命令 2 数据 4按实际补齐 int idx 0; cmd[idx] 0x80; // ICF命令帧 cmd[idx] 0x00; // RSV cmd[idx] 0x02; // GCT cmd[idx] 0x00; // DNA目标网络 cmd[idx] _plcNode; // DA1目标节点 cmd[idx] 0x00; // DA2目标单元 cmd[idx] 0x00; // SNA源网络 cmd[idx] _pcNode; // SA1源节点 cmd[idx] 0x00; // SA2源单元 cmd[idx] 0x00; // SID cmd[idx] 0x01; cmd[idx] 0x01; // 命令码 0101 读 cmd[idx] 0x82; // 区域代码 DM cmd[idx] (byte)(startAddr 8); cmd[idx] (byte)(startAddr 0xFF); // 起始地址 cmd[idx] 0x00; // 位偏移 cmd[idx] (byte)(count 8); cmd[idx] (byte)(count 0xFF); // 读取字数 _stream.Write(cmd, 0, idx); byte[] resp new byte[10 2 count * 2 2]; int n _stream.Read(resp, 0, resp.Length); if (n 14) return null; ushort[] result new ushort[count]; for (int i 0; i count; i) { int p 14 i * 2; // 跳过 FINS 头 10 命令 2 结束码 2 result[i] (ushort)((resp[p] 8) | resp[p 1]); } return result; } }逻辑说明握手帧的frame[16]是客户端节点号这个值不是随便填的必须和 PLC 路由表里登记的客户端节点一致否则握手返回错误码0x00000001。读命令帧里cmd[4]是目标节点PLC 节点cmd[7]是源节点上位机节点两个值在握手后都已经确定。响应帧的偏移 14 开始才是数据区前面 10 字节 FINS 头 2 字节命令码 2 字节结束码这个偏移算错就会读出乱码。count参数单次建议不超过 500 字超过后部分 PLC 型号会返回长度错误需要分包读。2.3 写 DM 区与位操作的差异写 DM 区的命令码是0102帧结构和读类似区别在于数据区要带上待写入的字数据且响应帧只有结束码没有数据回传。位操作如读写 CIO 区的某一位则要用命令码0101/0102配合位偏移字段区域代码后跟 2 字节字地址 1 字节位偏移0~15读取长度字段表示「位个数」而非「字数」。很多示例代码把位读写和字读写混用同一套偏移计算结果读 CIO 区第 100 通道第 3 位时读出来的是第 100 通道整个字——这是位偏移字段没填对。写操作还有一个容易忽略的点FINS 写命令没有「确认回读」机制PLC 返回结束码0x0000只代表命令被接受不代表数据已落盘。对可靠性要求高的场景我一般写完后再发一条读命令回读校验尤其是写保持区0xB2做配方参数时回读能挡住不少现场干扰导致的写失败。3. 把示例跑起来连接参数、地址映射与调试步骤3.1 连接前的 PLC 侧准备示例工程能跑通的前提是 PLC 侧网络参数配好。常见做法是在 PLC 的以太网设置里确认三件事IP 地址与上位机同网段、FINS/TCP 端口默认 9600未被占用、路由表里登记了上位机的节点号。节点号在 FINS 网络里相当于设备身份证同一网络内不能重复。如果现场有多台上位机采集同一台 PLC每台的节点号要错开否则会出现「一台能连、另一台握手失败」的玄学现象。地址映射是第二个坎。欧姆龙 PLC 的 DM 区地址从D0开始CIO 区从CIO0开始但 FINS 帧里用的是十六进制字地址不是梯形图里的符号地址。比如梯形图里写D100FINS 帧里起始地址字段填0x0064。CIO 区更绕CIO100对应字地址0x0064但 CIO 区前 20 个字CIO0~CIO19在部分型号上是系统保留区读写会返回错误码实际可用的用户区从CIO20往后。这个边界在协议手册里写得很隐晦踩过一次就记住了。3.2 分步调试从握手到读到第一个字调试顺序建议严格按下面走跳步会让问题定位变得很难第一步用 TCP 工具连 PLC 的 9600 端口确认端口通。连不上先查网线和 PLC 以太网单元指示灯别急着怀疑代码。第二步发握手帧看返回的 24 字节里第 15 字节错误码是否为 0。不为 0 就对照错误码表0x01是节点号冲突0x02是路由未登记0x03是协议不支持。第三步握手成功后发一条读D0单字的命令用抓包工具看响应帧。正常响应应该是 10 字节头 01010000结束码 2 字节数据。如果结束码是0x1101说明命令帧长度字段和实际不符0x1103是区域代码或地址越界。第四步单字读通后再扩到多字读最后测写。每步之间用抓包对比帧结构比在代码里打断点高效得多。// 调试入口读 D100 起 10 个字并打印 var plc new FinsTcpClient(); if (!plc.Connect(192.168.1.10)) { Console.WriteLine(握手失败检查节点号与路由表); return; } ushort[] data plc.ReadDM(100, 10); // D100 对应字地址 100 for (int i 0; i data.Length; i) Console.WriteLine($D{100 i} {data[i]});这段入口代码里ReadDM(100, 10)的 100 是十进制字地址内部拼帧时会转成两字节大端。如果现场梯形图里用的是D100符号直接传 100 即可如果是从别的系统导出的十六进制地址记得先转十进制再传否则会读到偏移错位的区域。3.3 参数怎么改端口、超时与分包大小示例里几个可调参数直接决定现场稳定性。端口默认 9600部分客户现场改过连不上时先确认这个值。超时时间建议设在 500ms~2s 之间太短会在网络抖动时误判断线太长会让上位机在 PLC 掉电时卡死。分包大小前面提过单次读不超过 500 字写不超过 250 字这是多数欧姆龙型号的安全边界。还有一个参数是重连策略。FINS/TCP 连接被 PLC 侧主动断开后TcpClient不会自动重连需要在上层加心跳每隔几秒读一个固定地址比如D0连续三次失败就重建连接并重新握手。心跳地址要选一个业务上不用的保留字避免和采集逻辑抢同一块区域。4. 避坑与排查五条现场踩出来的血泪经验4.1 握手成功但读命令全部超时现象握手帧返回错误码 0但后续读命令发出去后Read一直阻塞到超时PLC 侧无任何响应。原因通常是命令帧的 FINS 头里ICF字段填错——读命令的ICF应为0x80命令帧、需要响应填成0xC0或0x00时 PLC 会静默丢弃。解决抓包对比握手帧和命令帧的ICF字节确认是0x80同时检查GCT字段单网络环境填0x02填0x00在部分固件上也会被丢弃。4.2 读出来的数据高低字节颠倒现象PLC 里D100的值是1234十六进制0x04D2上位机读出来是0xD204。原因是 FINS 数据区是大端序而 C# 的BitConverter在小端机器上默认按小端解析。解决不要用BitConverter.ToUInt16直接转手动按(resp[p] 8) | resp[p1]拼或者转完后做一次字节交换。这个坑在 x86 上位机上必现ARM 工控机上反而可能正常换硬件平台时容易复发。4.3 多字读取返回长度错误现象单字读正常一次读 600 字时 PLC 返回结束码0x1104长度错误。原因是部分欧姆龙型号的 FINS 单帧数据区上限是 1000 字节扣除头尾后实际可读字数约 496。解决把读取请求按 400 字一包拆分循环读并拼接结果写操作同理按 200 字一包拆。分包时注意每包都要重新构造完整 FINS 帧不能只改长度字段。4.4 位读写读到整字数据现象想读CIO100.03这一位返回的却是CIO100整个字的值。原因是位操作时区域代码后的位偏移字段填了 0或者读取长度字段填的是字数而非位数。解决位读写的帧结构是「区域代码 2 字节字地址 1 字节位偏移 2 字节位个数」位偏移填 3、位个数填 1返回数据区第一个字节的最低位就是目标位。位写还要注意写入值放在数据区首字节的 bit0其余位补 0。4.5 长时间运行后连接假死现象上位机跑几小时后采集数据不再更新但 TCP 连接状态显示已建立代码不报错。原因是 PLC 侧对空闲连接有超时回收回收后 TCP 层没有及时感知NetworkStream.Read卡在半开连接上。解决加应用层心跳定时读固定地址并给TcpClient.ReceiveTimeout设一个有限值读超时后主动Close并重建连接。心跳间隔建议 3~5 秒比 PLC 的空闲回收时间短一个数量级。5. 进阶把 FINS 读写封装成可复用的采集层示例工程跑通单点读写后真正上产线还要解决批量采集和异常隔离。我的习惯是在FinsTcpClient之上再包一层采集器把「地址表 轮询周期 失败重试」做成配置驱动而不是在业务代码里散落ReadDM调用。地址表用简单的结构体描述区域类型、起始地址、长度、数据类型字/位、业务标签。轮询时按区域分组同一区域的连续地址合并成一次读请求减少帧数量——现场经验是合并后帧数能降一个数量级PLC 的通讯负载明显下降。验证采集层是否可靠我一般做两个测试一是拔网线 30 秒再插回看采集器能否在心跳失败后自动重连并恢复数据二是在 PLC 侧强制改一个 DM 值看上位机在一个轮询周期内是否同步到。这两个测试能挡住大部分现场问题。另外写操作一定要和读操作分通道或分优先级避免写命令被大量读请求堵在队列里——产线上急停信号写不进去的后果比读延迟严重得多。从那以后我每次交付 FINS 采集模块都强制走一遍「握手抓包 → 单字读 → 多字分包读 → 写后回读 → 拔网线重连」这五步验证少一步都不敢签字。希望这份示例和上面的排查清单能帮你在下次产线调试时少熬一个通宵。本文还有配套的精品资源点击获取