news 2026/9/29 4:18:27

C#与欧姆龙PLC通信实战:FINS/TCP协议帧解析与读写封装

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#与欧姆龙PLC通信实战:FINS/TCP协议帧解析与读写封装

简介:这是一份面向工业自动化与上位机开发者的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 更原生、更直接。

FINS(Factory 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.1,FINS/TCP 端口 9600。CS/CJ 系列内置 EtherNet/IP 口也支持 FINS/TCP,但 NJ/NX 系列走的是 EtherNet/IP 和 CIP,FINS 命令不直接兼容,需要走 CIP 封装。所以这套代码主要覆盖 CS/CJ/CP 系列,NJ 用户得换思路。

2.2 FINS/TCP 帧的头部与命令段

FINS/TCP 帧分两段:前面是 FINS/TCP 头(8 字节),后面是 FINS 命令帧。FINS/TCP 头结构如下:

偏移长度含义典型值
04魔术字 "FINS"0x46 0x49 0x4E 0x53
44长度(后面 FINS 帧的字节数)大端序
84命令码0x00000000 握手,0x00000002 FINS 帧
124错误码正常为 0
164客户端节点号握手后由 PLC 分配

握手阶段,客户端先发一个 12 字节的请求:魔术字 + 长度 0x0000000C + 命令 0x00000000 + 错误码 0。PLC 返回 24 字节,其中最后 4 字节是分配的客户端节点号,后面拼 FINS 帧时要用这个号。

FINS 命令帧本身结构:ICF(1 字节,信息控制字段,通常 0x80)、RSV(1 字节,保留 0x00)、GCT(1 字节,网关计数 0x02)、DNA(1 字节,目标网络号 0x00)、DA1(1 字节,目标节点号,PLC 的 FINS 节点号)、DA2(1 字节,目标单元号 0x00)、SNA(1 字节,源网络号 0x00)、SA1(1 字节,源节点号,客户端节点号)、SA2(1 字节,源单元号 0x00)、SID(1 字节,服务 ID,随便填 0x00)。这 10 字节之后才是命令码和参数。

读 DM 区的命令码是0x01 0x01,后面跟起始地址(2 字节大端)和读取字数(2 字节大端)。写 DM 区是0x01 0x02,后面跟起始地址、写入字数、然后是要写的数据(每个字 2 字节大端)。响应帧里,命令码后面跟结束码(2 字节),正常是0x00 0x00,然后才是数据。

2.3 用 C# 拼一个读 DM 区的请求帧

下面这段代码是拼一个读 DM100 开始 10 个字的 FINS/TCP 请求帧。假设已经握手拿到客户端节点号clientNode,PLC 节点号plcNode一般设为 0(广播到目标 IP 对应的 PLC)。

byte[] BuildReadDMRequest(byte clientNode, byte plcNode, ushort startAddr, ushort count) { // FINS 命令帧部分 var fins = new List<byte>(); 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 List<byte>(); 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 头里的客户端节点号在请求帧里填 0,PLC 响应里会带回分配的节点号。参数上,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 结束码(偏移 16+10+2 = 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 区是0x82,CIO 区是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 Task<byte> 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 区0x82,CIO 区0xB0,WR 区0xB1,HR 区0xB2。读命令码0x01 0x01,写命令码0x01 0x02。

async Task<ushort[]> 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 0x02,body 里多出要写的数据。

3.3 批量读优化:合并请求与分页策略

单次读 10 个字和读 500 个字,网络往返时间差不多,所以批量读能大幅提升吞吐。但一次读太多会超过 PLC 响应缓冲,CP1H 实测一次读 500 字没问题,读 1000 字偶尔超时。我一般按 400 字分页,循环读。如果地址不连续,比如要读 DM100 和 DM2000,就分两次请求,不要试图拼成一个帧。

另一个优化是合并写:如果多个地址连续,拼成一个写请求。不连续就分开写。写请求的帧长 = 16 + 10 + 2 + 2 + 2 + count*2,count 是字数,CP1H 一次写 400 字也稳。批量读的时候,把ushort[]结果按地址偏移映射到业务变量,建议用一个Dictionary<ushort, string>维护地址到变量名的映射,方便调试。

提示:批量读的地址范围不要跨区,比如 DM 区和 CIO 区不能在一个请求里读,必须分开。

4. 避坑与排查:FINS 通信里那些让人抓狂的翻车现场

4.1 握手成功但读写超时

现象:TcpClient连上了,握手也返回了节点号,但一发读命令就卡住,最后超时。原因:FINS 帧里的目标节点号DA1填错了。很多人以为填 PLC 的 IP 最后一段,其实应该填 PLC 的 FINS 节点号,CP1H 默认是 0,表示广播到目标 IP 对应的 PLC。如果填了非 0 值,PLC 会忽略。解决:DA1填 0,DNA填 0,DA2填 0,让 PLC 按 IP 路由。

4.2 读回来的数据字节序反了

现象:读 DM100 的值,PLC 里显示 1234,C# 读回来是 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 维护独立的连接对象和节点号,不要共用。用ConcurrentDictionary<string, PlcConnection>按 IP 管理。

5. 进阶技巧:用 FINS 写配方、读定时器与验证通信质量

5.1 写配方到 DM 区并回读校验

配方下发是产线常见需求,比如把 20 个参数写到 DM1000 开始的 20 个字。写完之后立刻回读,比对一致才算成功。下面这段代码演示写 20 个字并回读:

async Task<bool> 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。如果配方里有浮点数,需要先转成两个ushort(IEEE754 大端),写入后再回读解析。

5.2 读定时器当前值和计数器

定时器当前值在 CIO 区,地址计算方式:定时器编号 T0 对应 CIO 0 的 bit 0?不对,定时器当前值在 T 区,FINS 里用 CIO 区地址加偏移。具体:T0 的当前值在 CIO 0,T1 在 CIO 1,以此类推。读的时候区域代码用0xB0(CIO 区),地址就是定时器编号。计数器 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,都先抓包确认握手和一条读命令的字节,再写业务代码。这个习惯帮我省了至少两天排查时间。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 4:15:00

同事.skill 爆火背后:用 SKILL.md 把同事经验炼化成 Agent Skills

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 4:14:43

ListView中取数据:TaoToken 统一 Key 接入 AI 工具配置实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华