简介:面向工业自动化开发者的C#与欧姆龙PLC通信示例,通过FINS协议实现上位机读取PLC数据,资源定位清晰,适合工控软件工程师、自动化专业学生以及需要快速开发上位机程序的技术人员。压缩包提供完整Visual Studio工程与配套学习资料,包含C#源码、通讯手册、网络模型图、协议格式示意图以及网络调试工具,可帮助使用者理解FINS帧的构成、TCP连接如何建立、寄存器读写流程等关键知识点。资源文件共四十五个,以cs源码、pdf说明、png示意图、exe工具为主,同时包含配置文件、文本备注、工程框架等辅助内容,整体大小约四点三八兆字节,体积小巧,结构清晰。目前已有1117人学习使用,在欧姆龙PLC通信入门、项目开发与故障排查中应用广泛;通过源码与手册的对照学习,读者可以掌握创建TcpClient连接、构造FINS请求、解析响应数据、处理异常等关键步骤,并进一步扩展批量读写、状态监控等功能。通讯手册与协议示意图尤其适合初次接触FINS的开发者,能大幅缩短理解命令格式和地址编码的时间,整体是一份兼顾教学价值与工程实用性的行业示例。
1. 用 C# 通过 FINS 读取 PLC:先搞清楚协议再写 Socket
FINS 是某日系品牌 PLC 以太网通信里最常用的一套二进制协议,走 UDP 或 TCP 的 9600 端口,C# 可以直接用 Socket 实现,不需要额外装组件。很多人一上来就找第三方库,其实手写一个最小的读取器并不难,难的是把 FINS 帧头那 10 个字节、内存区代码和数据地址换算弄清楚。这篇文章从一个可运行的 C# 示例出发,拆解 FINS 帧结构、读取命令、响应解析,以及我实际踩过的几个典型坑:UDP 收不到响应、节点号对不上、响应码 0x0103、批量读取超长。适合做上位机、MES 采集、设备数据接入的人,新手可以照着搭,熟手可以当排错清单用。
2. FINS 帧结构:内存区代码与手写组帧的完整过程
2.1 FINS 报文的三段式结构
FINS 协议不像 Modbus 那样只有一个简单数据区,它的请求帧天然分成三段:FINS 帧头(10 字节)、命令码(2 字节)、命令参数(可变长度)。响应帧则是帧头、命令码、端点码加返回数据。这个结构在所有 FINS 命令里是通用的,所以写一次组帧逻辑,后面读什么地址都只是换参数。
UDP 和 TCP 两种传输方式有差别:UDP 模式下,FINS 消息直接作为 UDP 负载发出;TCP 模式下,每个 FINS 消息前面还多了 4 字节的长度字段,表示后面整个 FINS 消息的字节数。对数据采集场景,我更推荐用 UDP,少了 TCP 的粘包、半包处理,组帧简单,丢包靠应用层超时重试解决。
FINS 帧头这 10 个字节是所有人第一次写代码时最容易看懵的地方,字段含义如下:
| 字节 | 字段 | 说明 |
|---|---|---|
| 0 | ICF | 指令控制。常见请求示例固定为 0x80 |
| 1 | RSV | 保留字节,填 0x00 |
| 2 | GCT | 网关计数,常见请求示例填 0x02 |
| 3 | DNA | 目标网络号,局域网内填 0x00 |
| 4 | DA1 | 目标节点号,也就是 PLC 的 FINS 节点号 |
| 5 | DA2 | 目标单元号,CPU 单元通常填 0x00 |
| 6 | SNA | 源网络号,填 0x00 |
| 7 | SA1 | 源节点号,也就是上位机自己的 FINS 节点号 |
| 8 | SA2 | 源单元号,上位机填 0x00 |
| 9 | SID | 服务标识符,每次请求递增,用于配对响应 |
我一般只改 DA1、SA1、SID 这三个值,其余固定。SID 每次请求必须变化,因为响应会带上同一个 SID,如果异步并发多,光靠 SID 就能判断这是哪次请求的响应。
2.2 内存区代码与地址换算
FINS 命令码是两字节,读取内存区用的命令码固定是 0x0101(Memory Area Read)。紧跟命令码后面的是内存区代码,不同的内存区有不同的代码,而且字访问和位访问还分成两个编码。
常用内存区代码:
| 内存区 | 字访问代码 | 位访问代码 | 说明 |
|---|---|---|---|
| CIO | 0x30 | 0x31 | 共享输入输出区,地址形如 CIO 100.05 |
| WR | 0xB0 | 0xB1 | 工作区,地址形如 W0.00 |
| HR | 0x20 | 0x21 | 保持区,断电不掉 |
| DM | 0x82 | 0x83 | 数据存储区,字访问最常用 |
FINS 的地址不是我们平时看到的“DM100”这种字符串,而是拆成两个字:字地址(2 字节)加位地址(1 字节)。例如读 DM0,字地址是 0x0000,位地址是 0x00;读 DM100,字地址就是 0x0064,位地址还是 0x00。CIO 区也一样,CIO 100.05 对应的字地址是 0x0064,位地址是 0x05。
这个换算很容易出错,尤其是把位地址和字地址搞混。我的经验是先把你要读的地址写成一个标记,比如DM0就是(0x0000, 0x00),CIO 100.05就是(0x0064, 0x05),再照着填命令参数,不要现场口算。
2.3 手写一个最简 C# 组帧器
有了上面的结构,先用一个最小类把组帧逻辑写出来。下面的类负责建立 UDP 通道、生成读取命令、发送并接收响应。
using System.Net; using System.Net.Sockets; public class FinsClient { private UdpClient _udp; private readonly IPEndPoint _remote; private readonly int _localNode; private readonly int _remoteNode; private byte _sid = 0; public FinsClient(string ip, int port, int localNode, int remoteNode) { _udp = new UdpClient(); _remote = new IPEndPoint(IPAddress.Parse(ip), port); _localNode = localNode; _remoteNode = remoteNode; } public byte[] BuildReadCommand( byte memoryCode, ushort wordAddress, byte bitAddress, ushort length) { var frame = new List<byte> { 0x80, 0x00, 0x02, // ICF, RSV, GCT 0x00, (byte)_remoteNode, 0x00, // DNA, DA1, DA2 0x00, (byte)_localNode, 0x00, // SNA, SA1, SA2 _sid++ // SID,每次递增 }; frame.Add(0x01); // 命令码高位:内存读取 frame.Add(0x01); // 命令码低位:内存读取 frame.Add(memoryCode); // 内存区代码,如 0x82 表示 DM 字区 frame.Add((byte)(wordAddress >> 8)); // 字地址高字节 frame.Add((byte)(wordAddress & 0xFF)); // 字地址低字节 frame.Add(bitAddress); // 位地址,字访问时填 0 frame.Add((byte)(length >> 8)); // 读取长度高字节 frame.Add((byte)(length & 0xFF)); // 读取长度低字节 return frame.ToArray(); } public byte[] ReadWords(byte memoryCode, ushort wordAddress, ushort count) { byte[] req = BuildReadCommand(memoryCode, wordAddress, 0, count); _udp.Send(req, req.Length, _remote); return _udp.Receive(ref _remote); } }这段代码的核心是BuildReadCommand,它把 FINS 帧头、命令码和参数按顺序拼到一个byte[]里。注意wordAddress和length都是按大端序拆开的,也就是高字节在前、低字节在后,这恰好和 x86 电脑内存里的小端序相反。这就是为什么我手动(byte)(wordAddress >> 8)而不是用BitConverter.GetBytes,后者在 Windows 上低位在前,直接填进帧里会读错地址。
ReadWords是一个最简单的同步读取封装,Send之后用同一个 UDP socket 等 PLC 回包。实际使用中,这一行Receive会一直阻塞,所以后面必须加超时和异步封装,第五章会展开。
3. 读取实现:从单条读到批量读,再到响应解析
3.1 读取 DM0 的完整实现
有了FinsClient,读取 DM 区一个地址就很直接了。这里读 DM0 开始连续 10 个字,命令参数分别是内存区代码0x82、字地址0x0000、位地址0x00、长度10。
static void Main(string[] args) { var client = new FinsClient( ip: "192.168.1.202", port: 9600, localNode: 66, remoteNode: 202); byte[] resp = client.ReadWords(0x82, 0, 10); if (resp.Length < 14) throw new Exception("响应帧不完整,长度不足 14 字节"); // FINS 响应布局: // [0..9] FINS 帧头 // [10..11] 命令码,应为 0x01 0x01 // [12..13] 响应码,0x0000 表示成功 // [14..] 实际数据 if (resp[12] != 0x00 || resp[13] != 0x00) { throw new Exception( $"读取失败,响应码 0x{resp[12]:X2}{resp[13]:X2}"); } for (int i = 0; i < 10; i++) { ushort value = (ushort)((resp[14 + i * 2] << 8) | resp[15 + i * 2]); Console.WriteLine($"DM{i} = 0x{value:X4} ({value})"); } }响应解析的索引是很多人第一次写错的地方。FINS 帧头占 10 字节,命令码占第 10、11 字节,所以响应码要从第 12、13 字节开始判断。只有响应码是0x0000时,第 14 字节之后才是真正的数据。如果你把resp[11]当响应码去判,几乎永远报错。
数值还原的写法(resp[14 + i * 2] << 8) | resp[15 + i * 2]就是把两个大端字节拼成一个ushort。如果读的是 16 位有符号数,要转成short;如果读的是模拟量通道或计数器,一般ushort就够了。
3.2 批量读取:连续地址一次拿回
现场采集一般不是只读 10 个字,而是把一长段 D 区数据周期性地拿回来。如果一口气把长度填成 1000,PLC 大概率直接回错误码,因为 FINS 单帧长度有限。
常见的做法是按 64 个字分块,每块一次请求,然后拼起来。
public List<ushort> ReadDMBatch(ushort start, ushort count) { const int chunkSize = 64; var result = new List<ushort>(); int offset = 0; while (offset < count) { int len = Math.Min(chunkSize, count - offset); byte[] resp = ReadWords(0x82, (ushort)(start + offset), (ushort)len); if (resp[12] != 0x00 || resp[13] != 0x00) throw new Exception($"批量读取失败,偏移 {offset},响应码 0x{resp[12]:X2}{resp[13]:X2}"); for (int i = 0; i < len; i++) { ushort value = (ushort)((resp[14 + i * 2] << 8) | resp[15 + i * 2]); result.Add(value); } offset += len; } return result; }分块大小我一般压到 64 个字以内。虽然部分 PLC 型号允许单次读更多,但现场编程扫描周期、网关转发、总线负载都会影响表现,64 是在稳定性和效率之间比较稳的一个折中值。如果项目对实时性要求高,可以把块大小调到 128 试试,但一旦出现0x1104(长度超限)响应码,就先降回来。
批量读取的另一个好处是减少握手次数。每次 UDP 请求至少有一个网络来回,分块越多延迟越大。把 100 个字拆成 2 块,比拆成 10 块快得多,所以块大小应该尽量取最大值,而不是每块填 10 个。
3.3 响应码与数值还原
FINS 响应码是 2 字节,0x0000正常。实际运行中常见的非零响应码没有想象中多,记几个就够排查用了:
| 响应码 | 含义 | 常见原因 |
|---|---|---|
| 0x0101 | 本地节点错误 | 源节点号设置冲突或不可用 |
| 0x0103 | 目标节点不存在 | DA1 写的节点号和 PLC 实际节点号不一致 |
| 0x1101 | 内存区代码错误 | 如把 0x82 写成了 0x81 |
| 0x1103 | 地址超界 | 字地址越过了该内存区末尾 |
| 0x1104 | 长度超限 | 单次读取长度超过上限 |
数值还原不只是ushort。现场最常见的是读两个连续字拼 32 位数据,比如浮点或长整型。这时要把按字地址顺序排列的 4 个字节,重新组合成 C# 里的int或float。
byte[] raw = new byte[4]; raw[0] = resp[14]; raw[1] = resp[15]; raw[2] = resp[16]; raw[3] = resp[17]; int intValue = (raw[0] << 24) | (raw[1] << 16) | (raw[2] << 8) | raw[3]; float floatValue = BitConverter.ToSingle(raw, 0);注意顺序:FINS 响应数据本身是大端序,所以第一个字的高字节是整个 32 位数据的最高字节。如果你发现浮点数值翻车,先检查是否是字序问题。我遇到过一次现场,PLC 侧用 L 区和 D 区交叉存的浮点,业务程序里按低字在前拼,结果延迟和温度全反了,这就是字序搞反的典型后果。
4. 避坑排查:UDP 端口、节点号、响应码四个高频坑
4.1 现象:UDP 能发出去,但 Receive 一直超时,抓包只有请求没有响应
这个问题占了我接触到的 FINS 接入问题的一半。程序里Send不报错,说明 UDP 防火墙至少没拦发送方向,但 PLC 那边要么没收到,要么回了包但没到 PC。
原因通常是三个:PC 和 PLC 不在同一网段,路由不通;PLC 侧 FINS UDP 服务没启用;端口不是 9600。很多人只看 PLC 的 IP 能 ping 通,就忽略了 FINS 服务本身是软件层的东西。
解决步骤:先用ping 192.168.1.202确认网络通;再从 PLC 设置里确认 FINS UDP 端口是否是 9600;最后看 PLC 是否已经处于运行模式。还有一个很容易忽略的是 PC 防火墙,窗口弹出拦截提示时要允许 UDP 9600 入站。我一般把这三件事写成自检清单,换新 PLC 时先过一遍再连,能省很多时间。
4.2 现象:能收到响应包,但响应码是 0x0103,目标节点不存在
这是帧头 DA1 写错造成的。DA1 填的是 PLC 的 FINS 节点号,不是 IP 地址。很多 PLC 的节点号在出厂时和 IP 最后一位有关,但又不绝对,尤其在同一台 PLC 里同时挂多个 CPU 单元时,节点号可以手动设置。
有次我在现场配一个新增设备,IP 配置没问题,响应码一直 0x0103。折腾半天才发现在 PLC 参数里 FINS 节点写成 0,而代码里 DA1 填的是 IP 最后一位 202。把 DA1 改成 0 之后立刻通了。
解决方法是先查 PLC 参数里当前 FINS 节点号,再写死到 DA1。如果暂时查不到,可以拿 0、1、IP 最后一位三个值分别试一遍,这个区间很小,十分钟内能定位。
4.3 现象:单条读取正常,批量读 100 个字就报 0x1104
0x1104 是长度超限。这个坑主要是因为 FINS 的长度字段单位是“字”,不是“字节”。读 100 个字,长度字段填100,不是200。如果填成 200,一部分 PLC 会直接回 0x1104。
另一个原因是分块不够。有些 PLC 型号单次读取上限不是很大,尤其是老型号,标称最大 128 个字,实际用下来超过 64 就偶发报错。注意这不是网络问题,是协议层的参数限制。
解决方式就是第三章写的ReadDMBatch分块函数,块大小从 64 开始调。如果遇到 0x1104,先检查长度字段有没有按字算,再检查当前块长度是否小于最大允许值。
4.4 现象:读 CIO 或 WR 区成功,但数据全是 0,监控画面明明有值
这是地址换算问题。FINS 命令参数里,字地址和位地址是分开的三个字节,很多人把带小数点的字符串地址直接放进代码,或者把位地址填成字地址。
比如 CIO 100.05,正确写法是字地址0x0064(十进制的 100),位地址0x05。如果你只填了字地址100,位地址却按0填,读到的是 CIO 100.00 这个通道位,可能是完全独立的一个点,自然可能全 0。
解决方法是先读固定已知地址验证,比如 DM0 里塞一个已知值,读回来对了再动 CIO 区。我自己的习惯是做一个 C# 地址转换函数,专门解析"DM100"、"CIO100.05"这种字符串,输出三个字节,从那以后再也不手填了。
5. 进阶封装:异步读取、超时重试与心跳保活
5.1 用 Task 把读取动作改成异步方法
基础同步版在控制台测试没问题,但到了 WinForms 或 WPF,同步Receive会把 UI 线程堵死。改成async方法,界面才能保持响应。
public async Task<byte[]> ReadWordsAsync( byte memoryCode, ushort wordAddress, ushort count, int timeoutMs = 1000) { byte[] req = BuildReadCommand(memoryCode, wordAddress, 0, count); await _udp.SendAsync(req, req.Length, _remote).ConfigureAwait(false); Task<UdpReceiveResult> recvTask = _udp.ReceiveAsync(); Task timeoutTask = Task.Delay(timeoutMs); Task completed = await Task.WhenAny(recvTask, timeoutTask).ConfigureAwait(false); if (completed == timeoutTask) { throw new TimeoutException($"读取超时,地址 0x{wordAddress:X4}"); } return recvTask.Result.Buffer; }SendAsync对应 .NET 里 UdpClient 的异步发送,ReceiveAsync返回Task<UdpReceiveResult>,所以直接用recvTask.Result.Buffer拿响应字节。Task.WhenAny的作用是等两个任务中先完成的那一个,谁先完成说明是收到了响应还是超时到期。
这里有个细节:超时后recvTask没有被取消,它只是被丢弃了,晚到的响应不会影响当前方法。这种写法在低频采集下够用,但如果采集频率很高,要注意超时任务会积累,建议把Task.Delay(timeoutMs)换成带CancellationToken的版本。
5.2 UDP 没有真连接:心跳与重连策略
UDP 是无连接的,技术上加个UdpClient不算“连上”。很多时候程序刚开始能收到数据,运行几小时后突然超时,然后一直恢复不过来。原因可能是网络抖动导致 socket 状态异常,也可能是 PLC 侧临时重启。
我的做法是加一个健康检查,定时读一个固定地址,连续失败就重建 UdpClient。
public bool CheckHealth(out string message) { try { ReadWordsAsync(0x82, 0, 1, 500).GetAwaiter().GetResult(); message = "OK"; return true; } catch (Exception ex) { message = ex.Message; Reconnect(); return false; } } private void Reconnect() { _udp.Dispose(); _udp = new UdpClient(); }CheckHealth读 DM0 一个字,这个地址一定要选最稳定、不会频繁变动的数据。500ms 超时足够判断网络是否正常。如果失败,Reconnect把旧 socket 丢掉重开一个,这一步看似多余,实际上能清掉很多半死状态的 socket 问题。
心跳不能读太快,我一般放在一个 2 秒的定时器里。读得太频繁会给 PLC 增加不必要的通信负载,读得太慢又发现不了断线。2 到 5 秒是常见区间。
5.3 参数表:超时、重试、缓冲区
把几个关键参数统一放到配置里,比在代码里散落一堆魔法数字好维护得多:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 超时时间 | 1000 ms | 首次发送后等待响应的最长时间 |
| 重试次数 | 3 次 | 超时后重新发送请求的次数 |
| 重试间隔 | 200 ms | 避免连续请求冲垮 PLC 通信 |
| 批量块大小 | 64 字 | FINS 单帧读取长度的保守值 |
| 心跳间隔 | 2000 ms | 健康检查周期,视采集频率调整 |
| UDP 端口 | 9600 | FINS 默认端口,与 PLC 设置一致 |
重试次数建议参考前面异步方法加一个循环。最后一次超时直接抛出,由上层决定是否切换备用通道或报警。三连失败基本可以判定链路有问题,继续重试意义不大。
6. 调试技巧:先抓包跑通最小例子,再往上封装
调试 FINS 最怕的是一上来就把组帧、解析、批量读取全部写完,然后对着一个黑匣子找问题。我会先用一个最小控制台,把固定请求打出去,只打印响应的十六进制,看一眼能不能拿到自己想要的地址数据。
static void Main(string[] args) { var client = new FinsClient("192.168.1.202", 9600, 66, 202); // 读 DM0,一个字的固定请求 byte[] req = client.BuildReadCommand(0x82, 0, 0, 1); Console.WriteLine($"发送: {BitConverter.ToString(req)}"); byte[] resp = client.ReadWords(0x82, 0, 1); Console.WriteLine($"响应: {BitConverter.ToString(resp)}"); }如果响应帧最后两字节里有非零响应码,先对照第四章的响应码表。如果全是00,再把第 14 字节开始的两个字节按高低位拼成一个 16 位整数,和 PLC 上看到的监控值比对。
抓包工具在这时候比日志好用得多。用 Wireshark 过滤udp.port == 9600,能看到完整的 FINS 请求和响应帧,甚至能直接用请求示例和 PLC 手册里的报文对比,哪里字节不对一目了然。我自己遇到过 SID 一直不递增导致响应配对错乱的问题,就是抓包时发现两次请求的帧头一模一样才定位到的。
从那以后,我每次接到新的 PLC 接入需求,都强制先跑一遍这个最小例子,确认组帧和响应解析没问题,再往上加批量读取和心跳。这套流程看着老土,但真的能避开后面一大批业务层排查。希望帮到你。
本文还有配套的精品资源,点击获取