简介:面向工业自动化开发者与C#程序员,这是一份基于Winform的C#示例工程,演示如何通过FINS协议与欧姆龙PLC通信,并同时支持TCP与UDP两种传输方式,适用于设备数据采集、远程监控与产线控制等场景。工程覆盖设备连接、会话建立、寄存器数据读写、错误处理与界面展示等完整链路,核心辅助类封装了FINS报文构建、地址解析和数据类型转换逻辑,便于理解协议细节并快速迁移到实际项目;源码保留调试与运行痕迹,可对照通讯日志排查问题。压缩包共82个文件,以cs源码、xml配置和dll依赖为主,同时包含sln工程、界面截图、说明文档及缓存文件,整体约13.65MB,结构清晰。已有514人学习下载,适合需要掌握C#网络编程与工业控制协议结合的初中级开发者学习参考。
1. 这个压缩包解决的是上位机联调的硬骨头:FINS-TCP 与 UDP 双通道读写欧姆龙 PLC
第一次把 FINS 驱动接到欧姆龙 NJ 上时,困惑我的不是 C# 语法,而是同样的读 D100 命令,走 TCP 和走 UDP 的封装习惯居然不一样。这个标题对应的就是一套基础通信包:用 C# 做上位机,通过 FINS 协议以 TCP 或 UDP 的方式读写欧姆龙 PLC 的 DM、CIO、WR 等内存区。它能解决设备数据采集、MES 对接、按钮启停和配方下发这类落地需求,而不是停留在协议演示。适用人群很直接:写过几句 C#、正在跟欧姆龙 PLC 联调的上位机工程师,或者刚接手一条产线、需要把 PLC 数据搬到数据库的现场调试人员。
2. FINS 协议拆解:帧结构、内存区寻址和 TCP/UDP 差异
2.1 FINS 命令帧的固定 10 字节头
FINS 是欧姆龙工业以太网协议,所有命令帧都从 10 字节头开始,后续接 2 字节命令码和命令相关数据。C# 里最好把这段固定头写成一个数组,不要在业务代码里散着拼。
| 字节偏移 | 字段 | 说明 | 常见值 |
|---|---|---|---|
| 0 | ICF | 信息控制字段,命令帧置 0x80 | 0x80 |
| 1 | RSV | 保留 | 0x00 |
| 2 | GCT | 网关计数,固定 0x02 | 0x02 |
| 3 | DNA | 目标网络号,本地网络为 0 | 0x00 |
| 4 | DA1 | 目标节点号,即 PLC 的 FINS 节点号 | 按 PLC 配置 |
| 5 | DA2 | 目标单元号,CPU 单元为 0 | 0x00 |
| 6 | SNA | 源网络号,本地网络为 0 | 0x00 |
| 7 | SA1 | 源节点号,即上位机节点号 | 按上位机配置 |
| 8 | SA2 | 源单元号,上位机通常为 0 | 0x00 |
| 9 | SID | 服务标识,每次请求自增 | 0x01~0xFF |
DA1 和 SA1 是 FINS 路由的关键。单台上位机配一台 PLC 时,两个都填 0 也能通;但车间里挂了多台 PLC,就必须在 PLC 侧把 FINS 节点号分配开,否则报文会串。SID 尤其重要,UDP 模式下靠它区分响应属于哪次请求,我一般用一个字节变量每发一帧就加 1,溢出归零。
2.2 读内存区 0x0101 与写内存区 0x0102 的命令体
读 DM 区的命令体是:内存区代码 1 字节,起始地址 2 字节,读取字数 2 字节。写 DM 区则在后面再挂上实际数据,数据长度是字数的两倍。
| 命令 | 命令码 | 数据体 |
|---|---|---|
| 内存区读 | 0x0101 | 内存区代码 + 起始地址(2)+ 读取字数(2) |
| 内存区写 | 0x0102 | 内存区代码 + 起始地址(2)+ 写入字数(2)+ 数据(字数×2) |
这里起始地址和字数都是大端。比如读 D100 开始的 10 个字,起始地址就是 0x00 0x64,字数是 0x00 0x0A。很多第一次写的人栽在这里:把 100 当成 0x64 直接按 2 字节塞,没问题;但把 10 个字节数据当成 10 个字去写,数量就错了一倍。
2.3 内存区代码:DM、CIO、WR、HR 怎么对应到 PLC 点位
不同派生的代码容易记混。字操作的区域代码我常用这几个:
| 内存区 | 区域代码(字操作) | 示例 |
|---|---|---|
| DM | 0x82 | D100 |
| CIO | 0xB0 | CIO 0 |
| WR | 0xB1 | W0 |
| HR | 0xB2 | H0 |
CIO 区对应外部输入输出、内部继电器,比如 CIO 0.00 在读取字时直接用字编号 0。如果想操作单个位,比如置位 CIO 0.01,需要用位操作命令和另一组区域代码(常见是 0x30 开头),不要拿字操作的 0xB0 去改地址低位。这一条在避坑环节还会展开。
2.4 TCP 封装与 UDP 裸帧的差别
TCP 是流协议,没有报文边界,所以每条 FINS 命令外面要再包 4 字节长度前缀,长度值是大端,表示后续所有字节数。UDP 是数据报,本身就有边界,直接丢 FINS 帧。
帧层次对比:
TCP 线路上的实际数据:[4字节长度][10字节FINS头][2字节命令码][数据体]
UDP 报文的负载:[10字节FINS头][2字节命令码][数据体]
这个差异直接决定了 C# 实现:TcpClient 发送前要先把 FINS 帧整体算一遍长度再拼前缀,UdpClient 则不需要。后面两章分别写这两种实现。
3. 用 C# 实现 Fins-TCP 读写:从命令封装到响应解析
3.1 先写一个纯函数:构造读 DM 区命令帧
我习惯把命令帧构造和收发拆开,这样可以用单元测试覆盖帧内容,也方便以后移植到其他协议封装。读 DM 区的函数如下:
public static byte[] BuildReadDmCommand(ushort startDmAddress, ushort wordCount, byte sid) { byte[] frame = new byte[17]; // 10字节FINS头 + 2字节命令码 + 1字节区域码 + 2字节地址 + 2字节数量 frame[0] = 0x80; frame[1] = 0x00; frame[2] = 0x02; frame[3] = 0x00; frame[4] = 0x00; frame[5] = 0x00; frame[6] = 0x00; frame[7] = 0x00; frame[8] = 0x00; frame[9] = sid; frame[10] = 0x01; frame[11] = 0x01; // 内存区读 0x0101 frame[12] = 0x82; // DM 区 frame[13] = (byte)(startDmAddress >> 8); frame[14] = (byte)(startDmAddress & 0xFF); frame[15] = (byte)(wordCount >> 8); frame[16] = (byte)(wordCount & 0xFF); return frame; }这段代码把 FINS 头写死,重点看帧[13] 到帧[16]:地址和数量都用高位在前。sid 由外部传入,我建议每个调用点维护一个计数器,而不是每次都用同一个值,否则 UDP 响应匹配会乱套。这个函数返回的 17 字节是纯 FINS 帧,还没有加 TCP 的长度前缀。
3.2 TcpClient 收发:4 字节长度前缀与响应读取
TCP 通信里最容易翻车的是读响应。NetworkStream.Read 一次可能只返回几个字节,直接拿 read 的返回值当作响应长度是新手常犯的错误。正确做法是先读 4 字节长度前缀,再按这个长度循环读完整个响应体。
public static byte[] SendAndReceiveTcp(byte[] finsFrame, string ip, int port, int timeoutMs) { using var client = new TcpClient(); client.Connect(ip, port); using var stream = client.GetStream(); stream.ReadTimeout = timeoutMs; stream.WriteTimeout = timeoutMs; // 拼接 4 字节大端长度前缀 byte[] lengthPrefix = BitConverter.GetBytes(finsFrame.Length); if (BitConverter.IsLittleEndian) Array.Reverse(lengthPrefix); byte[] sendBuffer = new byte[finsFrame.Length + 4]; Buffer.BlockCopy(lengthPrefix, 0, sendBuffer, 0, 4); Buffer.BlockCopy(finsFrame, 0, sendBuffer, 4, finsFrame.Length); stream.Write(sendBuffer, 0, sendBuffer.Length); // 读长度前缀 byte[] lengthBuffer = new byte[4]; int offset = 0; while (offset < 4) { int read = stream.Read(lengthBuffer, offset, 4 - offset); if (read <= 0) throw new IOException("PLC 关闭了连接"); offset += read; } if (BitConverter.IsLittleEndian) Array.Reverse(lengthBuffer); int responseLength = BitConverter.ToInt32(lengthBuffer, 0); // 读响应体 byte[] responseBody = new byte[responseLength]; offset = 0; while (offset < responseLength) { int read = stream.Read(responseBody, offset, responseLength - offset); if (read <= 0) throw new IOException("响应不完整"); offset += read; } return responseBody; }这个方法的重点是两个循环。第一个循环凑满 4 字节,第二个循环凑满响应体。调用时 timeoutMs 通常给 1000-2000 毫秒,生产环境里如果 PLC 扫描周期较长,可以放宽到 3000 毫秒。
3.3 解析响应:检查结束码,数据从第 14 字节开始
PLC 返回的响应也是 FINS 头 + 命令码 + 结束码 + 数据。前 10 字节是 FINS 头回显,第 10-11 字节是命令码,第 12-13 字节是结束码,0x0000 表示成功。所以读数据的起始位置是第 14 字节。
public static bool TryParseReadResponse(byte[] response, out ushort endCode, out byte[] data) { if (response == null || response.Length < 14) { endCode = 0xFFFF; data = null; return false; } endCode = (ushort)((response[12] << 8) | response[13]); data = new byte[response.Length - 14]; Buffer.BlockCopy(response, 14, data, 0, data.Length); return endCode == 0x0000; }解析之前先判断长度小于 14 的直接失败,防止 PLC 异常响应导致数组越界。看到 endCode 非 0 时,把数值打印出来,欧姆龙手册里的错误码表能帮你定位是区域码问题还是地址越界问题。
3.4 写 DM 区:命令码 0x0102,数据长度是字数的两倍
写 DM 区比读少写不少代码,但要格外注意帧总长度是动态的。要写多少个字,后面就跟多少个 2 字节数据。
public static byte[] BuildWriteDmCommand(ushort startDmAddress, byte[] registerData, byte sid) { int wordCount = registerData.Length / 2; byte[] frame = new byte[17 + registerData.Length]; frame[0] = 0x80; frame[1] = 0x00; frame[2] = 0x02; frame[3] = 0x00; frame[4] = 0x00; frame[5] = 0x00; frame[6] = 0x00; frame[7] = 0x00; frame[8] = 0x00; frame[9] = sid; frame[10] = 0x01; frame[11] = 0x02; // 内存区写 0x0102 frame[12] = 0x82; frame[13] = (byte)(startDmAddress >> 8); frame[14] = (byte)(startDmAddress & 0xFF); frame[15] = (byte)(wordCount >> 8); frame[16] = (byte)(wordCount & 0xFF); Buffer.BlockCopy(registerData, 0, frame, 17, registerData.Length); return frame; }这里 registerData 必须是按 PLC 存储顺序排好的字节数组。C# 里 int 是小端,如果直接 BitConverter.GetBytes(100) 再塞进去,PLC 侧读出来的字就是颠倒的。所以写入前要做字节转换,常见做法是每个 16 位寄存器把两个字节交换,32 位数据再按高字在前排列。
3.5 int 和 float 的大端转换
读回来的 data 数组,每个寄存器是两个字节且高位在前。32 位浮点占用两个 DM 字,高字在前,低字在后。对应解析函数:
private static ushort ReadWord(byte[] data, int offset) { return (ushort)((data[offset] << 8) | data[offset + 1]); } public static int ReadInt32(byte[] data, int offset) { ushort high = ReadWord(data, offset); ushort low = ReadWord(data, offset + 2); return (high << 16) | low; } public static float ReadFloat32(byte[] data, int offset) { return BitConverter.Int32BitsToSingle(ReadInt32(data, offset)); }比如 D100 到 D101 存一个浮点,ReadFloat32(data, 0) 就是 D100 和 D101 组合出来的值。向 PLC 写浮点则反向操作:先把 float 转成 32 位整数,再拆成两个 16 位寄存器,每个寄存器内部高字节在前。
4. UDP 通道实战:无连接通信与 SID 匹配
4.1 UDP 发送的最小实现:没有长度前缀
UDP 方式可以直接复用第 3 章构造的 FINS 帧,不需要拼 4 字节长度前缀。用 UdpClient 发送是最简洁的写法:
public static byte[] SendAndReceiveUdp(byte[] finsFrame, IPEndPoint remote, int timeoutMs) { using var udp = new UdpClient(); udp.Client.ReceiveTimeout = timeoutMs; udp.Send(finsFrame, finsFrame.Length, remote); var from = new IPEndPoint(IPAddress.Any, 0); return udp.Receive(ref from); }这里 remote 的端口默认是 9600,也就是 PLC 的 FINS/UDP 服务端口。如果 PLC 的 FINS/TCP 端口被改过,UDP 端口也可能是同一个值,联调前先在 PLC 的以太网设置里确认。ReceiveTimeout 超时后会抛 SocketException,生产环境建议包一层 try-catch 转成业务超时异常。
4.2 按 SID 匹配响应:别把别人家的报文当自己的
UdpClient.Receive 返回的是第一个到达的数据报,不一定属于刚才那次请求。如果上位机同时开了两个线程轮询同一台 PLC,或者刚才有一次超时重试后旧响应才回来,就会收到 SID 不匹配的报文。解决思路是循环接收,直到 SID 对上。
public static byte[] ReceiveUntilSid(UdpClient udp, IPEndPoint remote, byte expectedSid, int timeoutMs) { var from = new IPEndPoint(IPAddress.Any, 0); var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); while (DateTime.UtcNow < deadline) { int remainMs = (int)(deadline - DateTime.UtcNow).TotalMilliseconds; if (remainMs <= 0) break; udp.Client.ReceiveTimeout = remainMs; byte[] data = udp.Receive(ref from); if (data.Length >= 14 && data[9] == expectedSid) { return data; } } throw new TimeoutException("等待 FINS 响应超时"); }关键点是 data[9] 等于请求时的 SID。收到不匹配的报文就丢掉,继续下一次 Receive。另外还可以检查 from 的 IP 是否等于 PLC 的 IP,防止局域网里其他设备的 UDP 包干扰。这个函数放在循环里调用时,每次请求的 SID 必须递增,不能一直用同一个值,否则同一个 SID 的旧响应会被误认为当前响应。
4.3 用 UDP 读 DM 的完整调用链
下面这段是把前面函数串起来的最小样例:
byte sid = 0x01; var request = BuildReadDmCommand(100, 10, sid); var remote = new IPEndPoint(IPAddress.Parse("192.168.1.10"), 9600); using var udp = new UdpClient(); udp.Send(request, request.Length, remote); byte[] response = ReceiveUntilSid(udp, remote, sid, 1000); if (TryParseReadResponse(response, out ushort endCode, out byte[] data)) { // data 长度为 20 字节 float firstValue = ReadFloat32(data, 0); }注意 ReceiveUntilSid 内部会用掉已有的 UdpClient,所以这个函数里不能再像简单版本一样用 using 包一层。我一般把 udp 对象作为参数传入,方便调用方复用同一端口。绑定固定本地端口还有一个好处:PLC 侧如果允许设置源 IP 过滤,你可以在上位机防火墙里只放行这个端口。
4.4 TCP 和 UDP 的工程选型:轮询场景选谁
TCP 稳定,适合上位机与 PLC 一对一长连接,数据读多写少且要求可靠。UDP 无连接,适合周期性巡检或者需要广播的场合,但丢包、乱序都需要应用层处理。我自己最常见的配合是:单台上位机盯一台 PLC 用 TCP,产线十几台 PLC 汇总数据用 UDP 轮询,因为 TCP 连接数量多了以后,断线重连逻辑会很麻烦。
另一个经验是 PLC 的 UDP 接收缓冲区通常不大,发送频率不要超过 20Hz,否则 PLC 可能直接丢弃数据报。如果有高频需求,优先换 TCP 或加大 PLC 侧的通信单元缓冲设置。这个玄学问题不是协议能解决的,跟具体型号的内存分配有关。
5. 避坑:FINS 通信最常见的 5 个问题(现象/原因/解决)
5.1 TCP 能连上但收不到 PLC 响应
现象:TcpClient.Connect 成功,业务命令发出后 Read 一直超时。
原因有两类:一是 4 字节长度前缀的字节序搞反了,PLC 按错误的长度解析直接丢弃;二是部分欧姆龙 PLC 的 FINS/TCP 服务要求客户端先发一个命令码 0x0000 的建立连接请求,处理完这个握手之后才会响应业务命令。
解决:先用 WireShark 抓包,对比你发出去的长度字段是不是大端。如果长度没问题,试着发一帧 10 字节 FINS 头加命令码 0x0000、不携带任何数据体的请求,观察是否收到结束码为 0x0000 的响应。
5.2 响应结束码 0x1101 / 0x1103:内存区代码或地址越界
现象:读 DM 正常,读 CIO 报错,或者 D 地址一超过某个值就失败。
原因:区域码用错,比如拿 0x30 这类位区域码去读字;或者起始地址+读取字数超出了 PLC 该内存区的实际范围。CP1H 等小型机的 DM 容量有限,NJ 系列的 CIO 大小取决于硬件配置。
解决:字操作统一用 0xB0 读 CIO,0x82 读 DM。报 0x1103 时把读取的范围缩小到一半再试,就能确认是不是地址越界。记得把结束码打出来,不要只显示"通信失败"。
5.3 UDP 收到旧响应或广播包,解析全乱
现象:偶尔读到的数据是上一次请求的值,或者 TryParseReadResponse 返回 false。
原因:UdpClient.Receive 返回第一个到达的数据报,并不管它是不是你正在等的那条。上位机有多个发送线程时,旧响应会串到新请求头上。
解决:严格按 SID 匹配响应,代码参考第 4.2 节。同时检查响应里 FINS 头第 4 字节(DA1)等于 PLC 节点号,第 7 字节(SA1)等于上位机节点号。不匹配的包直接丢弃,不要试图解析。
5.4 float 读出来和触摸屏不一致
现象:触摸屏上显示 1.25,上位机读出来是 3.75 或者一个莫名其妙的巨大数。
原因:PLC 内部浮点存储在两个字里的顺序跟上位机的直觉不一样。按字节小端读字,再按低字在前拼 32 位,自然会错。
解决:按第 3.5 节的 ReadInt32 实现,先保证每个 16 位寄存器内部是大端,再保证高字在前拼成 32 位。写浮点则反向操作,宁可多写两个十六进制打印,也别凭感觉直接塞字节。
5.5 多台 PLC 共用轮询时数据源错乱
现象:A 请求响应里包含 B 的数据,或者 B 请求经常超时。
原因:所有 PLC 的 FINS 节点号都保持默认 0,上位机也把 DA1 填 0,PLC 不校验目标节点时,本机收到的报文可能来自任意 PLC。
解决:在每台 PLC 的 FINS 节点设置里分配不同编号,上位机请求里 DA1 填对应的节点号。UDP 轮询时再从响应头中校验源 SA1 等于期望的 PLC。这样即使 IP 写错,也能从抓包里快速定位。
6. 进阶技巧:批量读取、抓包验证和现成库的取舍
调试阶段我有两个固定习惯。第一个是把每次发送和收到的十六进制串同时打日志,保留最近 200 条。这样一旦出现结束码非 0,直接翻日志就能定位是哪一次请求出了问题,不用在现场猜玄学。第二个习惯是先抓包后联调:在 WireShark 里过滤 udp.port == 9600 或者 TCP 端口,先确认 FINS 帧的 SID 和地址字节跟你代码里构造的一致,再上机测试。抓包能一眼看出长度前缀是不是大端,比盯着 C# 代码找 bug 快得多。
批量读取的写法也值得固化到代码里。一次读多个连续 DM 字,比循环一次次发请求节省很多时间。上限一般按 PLC 型号限制,我常用 256 个字一批:
int batchCount = 256; for (int baseAddr = 0; baseAddr < totalCount; baseAddr += batchCount) { int readWords = Math.Min(batchCount, totalCount - baseAddr); byte[] frame = BuildReadDmCommand((ushort)baseAddr, (ushort)readWords, sid++); byte[] response = SendAndReceiveTcp(frame, plcIp, 9600, 2000); if (TryParseReadResponse(response, out ushort ec, out byte[] data)) { // 按 ReadFloat32 等方式解析 data } }注意读取的字数不能超过 PLC 单个响应报文的最大长度,否则返回 0x1103。这个参数没有统一标准,不同系列差异很大,先测一个 256 再退回来即可。
关于库的取舍,我在厂房里遇到过两种极端:一种完全自己写帧,一封邮件都要跟硬件组对半天;另一种直接引了一个比较全的开源库,结果协议版本不匹配,库里的重试机制掩盖了现场问题。我的建议是:核心的 BuildReadDmCommand 和 TryParseReadResponse 这类纯函数自己留一份,收发包也自己封装,但在时间紧、PLC 型号杂、软件又急着上线的项目里,可以拿现成的 FINS 类库当参照物对照抓包。理解帧格式之后再引用库,排错时心里才有底,而不是黑匣子扔一边。
我自己的习惯是先从 UDP 跑通协议,再改造成 TCP 封装。UDP 不需要处理连接状态,构图和改地址都直观,等 SID 匹配和地址映射这两关过了,TCP 只是多一个 4 字节长度前缀和重新连接的问题。这套流程帮我在 C#、欧姆龙 PLC 之间来回换过好几个项目,希望帮到你。
本文还有配套的精品资源,点击获取