☰
C#通过FINS协议实现PLC数据读写:UDP客户端与字节序实战解析
2026/10/21 2:43:41 网站建设 项目流程

简介:这是一套面向工业自动化上位机开发者的C# FINS通信示例资源,专门解决C#通过FINS协议读取欧姆龙PLC数据时的连接建立、帧格式构造、命令发送与响应解析等实际问题。压缩包共45个文件,大小约4.38MB,以9个C#源程序、4个可执行文件、8张PNG协议示意图、PDF通讯手册和DOCX课件为主体,并包含config配置文件、调试日志和辅助工具,兼顾代码阅读、运行调试与手册查阅。目前已有1117人学习下载。资源内含Windows窗体示例工程,演示TcpClient连接PLC、拼接FINS请求帧、读写寄存器等关键操作;协议格式截图与官方通讯手册可帮助理解帧头、命令代码和地址字段;借助NetAssist网络调试工具能直观验证收发数据,配合Omron.Fins.Client库还可减少底层实现成本。适合需要快速掌握欧姆龙PLC网络通信、或正在做上位机集成项目的开发者参考。

1. C#通过FINS读取PLC数据:一次高字节翻车后换来的稳定打法

拿到某日系PLC品牌的老机型,以太网口不开放Modbus TCP,只能走它私有的FINS协议。这个坑我踩过两次:最开始图省事抄网上的半成品例子,能连上也能收发,但读回来的DM区数据总对不上,查了三天发现是字节序、节点号和路由表三个问题叠在一起。这篇笔记把FINS报文结构、C# UDP客户端的完整实现、读写参数调优和排查经验全部展开,适合刚接手PLC通信的桌面开发,也适合被“数据读不对”折磨过、想找一份完整参照的熟手。资源里是可直接编译的C#解决方案,先跑通一次,再改造成自己的通信层,能省掉大量调试时间。

2. FINS协议层拆解:报文头、命令码与内存区编码规则

2.1 为什么选FINS而不是HostLink或Modbus TCP

老批次PLC常见的三种上位机通信方式,我放在同一张表里对比过:

特性HostLinkFINS UDPModbus TCP
物理层RS-232/422以太网以太网
帧格式ASCII/二进制二进制二进制
默认端口串口9600502
多客户端支持弱好好
读写覆盖范围有限DM/CIO/WR/HR/EM全量视型号而定
PLC侧配置需要指定串口参数默认开启部分型号需组态映射

带以太网口的PLC,我一般直接选FINS UDP。原因不是“速度快”这么空泛,而是两个实际好处:第一,PLC侧的FINS UDP服务默认开启,少一个组态步骤;第二,FINS读写内存区覆盖最全,连扩展数据内存EM都能碰,Modbus TCP在部分型号上要额外做地址映射,反而多一层出错机会。

FINS协议的层级关系也值得先讲清楚:FINS报文直接塞进UDP数据报的数据区,前面不需要额外的协议头,后面也没有校验和字段。理解这一点,后面拆包时就不会被多余的字节干扰。

2.2 FINS报文结构:从ICF到SID的每一字节

FINS请求帧的固定头部是10字节,每一字节都有明确含义,填错了PLC要么不响应,要么返回错误码。先给头部字段表:

偏移字段长度含义常用值
0ICF1B信息控制字段0x80表示需要响应
1RSV1B保留字段0x00
2GCT1B网关计数0x02
3DNA1B目标网络号0x00
4DA11B目标节点号填PLC节点号
5DA21B目标单元号CPU单元填0x00
6SNA1B源网络号0x00
7SA11B源节点号填上位机节点号
8SA21B源单元号0x00
9SID1B服务识别号每次事务递增,用来配对请求和响应

头部之后是2字节的命令码,读内存区是0x0101,写内存区是0x0102。命令码后面跟的是参数区,读命令的参数区长度固定为6字节:内存区代码(1B)+起始地址(2B)+位地址(1B)+读取字数(2B)。

响应帧的结构是:头部10字节 + 命令码2字节 + 完成码2字节 + 数据区。完成码为0x0000时表示正常,其他值就是排查手册里最常见的错误码来源。

2.3 内存区编码与寻址计算

内存区编码是另一个高频翻车点。不同区域访问字和访问位,用的是不同编码:

内存区字访问编码位访问编码
DM区0x820x02
CIO区0x300xB0
WR区0x310xB1
HR区0x320xB2
EM区(当前Bank)0x500xA0

访问规则是:字访问时,位地址填0x00;位访问时,位地址填0x00到0x0F。

起始地址的计算也有讲究。比如要读DM100,十进制100换算成十六进制是0x0064,在帧里就得按高字节在前填0x00、0x64。很多第一次写的人会直接把100填进去,PLC收到的是0x0100,读出来的数据自然对不上。CIO区、WR区的地址换算逻辑一样,先把十进制地址转成十六进制,再拆高字节和低字节。

2.4 响应帧的数据排布:大端字节序

响应帧里,每个字的数据占据2字节,且高位字节在前,也就是网络字节序。例如PLC里DM100的值是0x1234,响应帧数据区里就是0x12、0x34,而不是0x34、0x12。

这一点看起来简单,但恰恰是“读回来数据不对”最常见的隐性原因。C#里x86机器默认小端序,如果直接把字节数组转成ushort而不做高低字节交换,读出来的值会变成0x3412。数据解析代码里必须显式做移位拼接,这也正是第3章代码里为什么写了那么多(data[i] << 8)的原因。

3. C#实现FINS UDP客户端:从帧封装到数据解析

3.1 UDP还是TCP:四种工况下的选型对比

FINS支持UDP和TCP两种承载方式,我在模拟项目X里先试过TCP,后来整体换成了UDP。选型依据可以按工况归类:

  • 单机直连PLC,上位机和PLC在同一个交换机下:UDP完全够用,丢了包就重发,实现还简单。
  • 上位机和PLC跨车间、跨楼层,中间经过多台交换机:UDP可能出现偶发丢包,但重试3次通常能扛住。
  • 多台上位机同时访问:FINS TCP需要PLC侧维护多条连接并组态,UDP天然支持多客户端并发。
  • 对实时性要求高的点位采集:UDP单条命令的往返延迟比TCP少一次握手,高频场景下优势更明显。

FINS UDP的PLC侧服务默认开启,端口固定是9600,只需要商量好双方节点号就能通信。TCP反而要确认PLC侧是否开放了TCP端口、是否做了绑定,多一步排查。

3.2 构建读帧:逐字节写一个18字节的报文

以下代码根据FINS协议标准构建一条内存区读请求,共18字节:

/// <summary> /// 构建FINS内存区读命令帧 /// </summary> /// <param name="plcNode">PLC节点号,十六进制,如0x64</param> /// <param name="memCode">内存区编码,如0x82表示DM区字访问</param> /// <param name="startAddr">起始字地址,十进制,函数内部转十六进制</param> /// <param name="count">读取字数,单帧最多128</param> /// <returns>18字节的FINS报文</returns> public static byte[] BuildReadFrame(byte plcNode, byte memCode, ushort startAddr, ushort count) { if (count > 128) throw new ArgumentOutOfRangeException(nameof(count), "FINS单帧最多读取128个字"); var frame = new byte[18]; frame[0] = 0x80; // ICF:请求响应 frame[1] = 0x00; // RSV:保留 frame[2] = 0x02; // GCT:网关数为2 frame[3] = 0x00; // DNA:目标网络0 frame[4] = plcNode; // DA1:目标节点号 frame[5] = 0x00; // DA2:目标单元号 frame[6] = 0x00; // SNA:源网络0 frame[7] = 0x01; // SA1:源节点号 frame[8] = 0x00; // SA2:源单元号 frame[9] = 0x01; // SID:服务ID,每次事务可递增 frame[10] = 0x01; // 命令码0x0101 frame[11] = 0x01; // 内存区读 frame[12] = memCode; // 内存区编码 frame[13] = (byte)(startAddr >> 8); // 起始地址高字节 frame[14] = (byte)(startAddr & 0xFF); // 起始地址低字节 frame[15] = 0x00; // 字访问,位地址为0 frame[16] = (byte)(count >> 8); // 读取字数高字节 frame[17] = (byte)(count & 0xFF); // 读取字数低字节 return frame; }

这段代码里最重要的两个参数是startAddr和count。startAddr传入的是十进制字地址,函数内部通过移位拆成两个字节,高位在前——这个顺序如果写反了,PLC收到的起始地址就完全不对。count限制在128以内,这是FINS协议的参数上限,不能贪多。

3.3 构建写帧:在头部之后追加数据区

写命令和读命令的头部结构一致,区别在于命令码换成0x0102,尾部追加要写入的字数据:

/// <summary> /// 构建FINS内存区写命令帧 /// </summary> /// <param name="plcNode">PLC节点号</param> /// <param name="memCode">内存区编码</param> /// <param name="startAddr">起始字地址</param> /// <param name="values">要写入的字数组</param> public static byte[] BuildWriteFrame(byte plcNode, byte memCode, ushort startAddr, ushort[] values) { if (values == null || values.Length == 0 || values.Length > 128) throw new ArgumentOutOfRangeException(nameof(values), "写入数据不能为空且最多128个字"); var frame = new byte[18 + values.Length * 2]; frame[0] = 0x80; // ICF frame[1] = 0x00; // RSV frame[2] = 0x02; // GCT frame[3] = 0x00; // DNA frame[4] = plcNode; // DA1 frame[5] = 0x00; // DA2 frame[6] = 0x00; // SNA frame[7] = 0x01; // SA1 frame[8] = 0x00; // SA2 frame[9] = 0x01; // SID frame[10] = 0x01; // 命令码0x0102 frame[11] = 0x02; // 内存区写 frame[12] = memCode; // 内存区编码 frame[13] = (byte)(startAddr >> 8); frame[14] = (byte)(startAddr & 0xFF); frame[15] = 0x00; frame[16] = (byte)(values.Length >> 8); frame[17] = (byte)(values.Length & 0xFF); int offset = 18; foreach (ushort v in values) { frame[offset++] = (byte)(v >> 8); // 高字节在前 frame[offset++] = (byte)(v & 0xFF); // 低字节在后 } return frame; }

写入数据的字节序和读响应一样,都是大端序。如果你从别处拿到一组已经打包成小端序的byte数组,直接拼进这个帧里,PLC收到后每个字都会变成高低位反转,看起来像“写进去了但值不对”。

3.4 UDP收发与超时处理

有了帧构建函数,收发层就简单了。用UdpClient是最直接的方式,关键点是设置接收超时、匹配SID、处理半包:

public class FinsUdpClient : IDisposable { private readonly UdpClient _udp; private readonly IPEndPoint _plcEndPoint; private byte _sid = 0x01; /// <summary> /// 初始化FINS UDP客户端 /// </summary> /// <param name="plcIp">PLC的IP地址</param> /// <param name="port">FINS UDP端口,默认9600</param> public FinsUdpClient(IPAddress plcIp, int port = 9600) { _plcEndPoint = new IPEndPoint(plcIp, port); _udp = new UdpClient(); _udp.Client.ReceiveTimeout = 3000; // 3秒超时 } public byte[] Transceive(byte[] request) { // SID递增,用于响应匹配 request[9] = _sid++; // 发送请求帧 _udp.Send(request, request.Length, _plcEndPoint); // 等待响应 var remote = new IPEndPoint(IPAddress.Any, 0); byte[] response = _udp.Receive(ref remote); // 检查SID是否匹配,防止读到上一条命令的迟到响应 if (response.Length < 10 || response[9] != request[9]) throw new InvalidDataException("SID不匹配或响应帧过短"); return response; } public void Dispose() => _udp.Dispose(); }

接收超时设为3000毫秒,是权衡了PLC扫描周期和网络抖动后的值。如果PLC的CPU扫描周期较长,比如程序复杂时可能超过100ms,3秒超时已经留了充足冗余。SID递增的意义在于:UDP没有连接概念,迟到响应和当前请求混在一起是常有的事,靠SID过滤能避免读到过期数据。

3.5 解析响应帧:把字节还原成PLC变量

读响应的数据区从偏移14开始,每2字节是一个字,解析时注意手工拼接:

/// <summary> /// 解析FINS读响应帧 /// </summary> /// <param name="response">UDP收到的原始响应字节</param> /// <returns>读取到的ushort数组</returns> public static ushort[] ParseReadResponse(byte[] response) { if (response.Length < 14) throw new InvalidDataException($"响应帧太短:{response.Length}字节"); // 偏移12-13是完成码 ushort errorCode = (ushort)((response[12] << 8) | response[13]); if (errorCode != 0x0000) throw new FinsException($"FINS完成码异常:0x{errorCode:X4}"); int dataLength = response.Length - 14; if (dataLength <= 0) return Array.Empty<ushort>(); int wordCount = dataLength / 2; var result = new ushort[wordCount]; for (int i = 0; i < wordCount; i++) { // 高字节在前,手工拼接 result[i] = (ushort)((response[14 + i * 2] << 8) | response[15 + i * 2]); } return result; }

这里最容易忽略的边界条件是响应帧长度为奇数:如果PLC返回了奇数长度的数据,wordCount会把最后的半字丢掉。实际使用中按协议不会出现奇数长度,但我在代码里加了dataLength <= 0的提前返回,避免产生长度为0的数组让上层死循环。

4. 读写DM区的实战:握手排查、重试与多帧读取

4.1 通信前先过三个检查

不用急着写代码,先花五分钟确认三件事,能避免大半的无头排查:

第一步,Ping PLC的IP地址,确认物理链路通。第二步,用PLC编程软件连上PLC,查看网络设置里的FINS节点号,这个节点号要和代码里plcNode参数一致,默认通常是十六进制的0x64。第三步,确认上位机节点号(代码里SA1)没和PLC节点号冲突。如果PLC的节点号是0x64、上位机节点号也设成0x64,PLC会把请求当作发给自己的消息而不回包,这种问题只看代码永远查不出来。

Ping通不代表FINS服务可用,端口没开或服务没启用时,UDP包发出去会石沉大海。所以第四步我一般会额外检查一下9600端口是否在监听,用TcpClient连一下试试——虽然FINS UDP本身不依赖TCP握手,但这个动作能快速判断PLC侧通信相关服务是否在线。

4.2 完整的DM区读写流程

以下代码把帧构建、UDP收发、响应解析串成一条完整链路:

public class PlcService { private readonly FinsUdpClient _client; public byte PlcNode { get; set; } = 0x64; public PlcService(IPAddress plcIp) { _client = new FinsUdpClient(plcIp); } /// <summary> /// 读取DM区连续字 /// </summary> public ushort[] ReadDm(int startAddr, int count) { byte[] frame = FinsFrameBuilder.BuildReadFrame(PlcNode, 0x82, (ushort)startAddr, (ushort)count); byte[] response = _client.Transceive(frame); return FinsFrameBuilder.ParseReadResponse(response); } /// <summary> /// 写入DM区连续字 /// </summary> public void WriteDm(int startAddr, ushort[] values) { byte[] frame = FinsFrameBuilder.BuildWriteFrame(PlcNode, 0x82, (ushort)startAddr, values); byte[] response = _client.Transceive(frame); // 写响应没有数据区,但有完成码需要解析 FinsFrameBuilder.CheckResponseCode(response); } }

调用方式很直观:ReadDm(100, 1)读DM100这一个字,WriteDm(200, new ushort[]{1, 2, 3})从DM200开始连写三个字。注意startAddr是十进制字地址,函数内部转成十六进制字节序,如果PLC手册里标注的是十六进制地址,换算时别踩坑。

4.3 批量读取:按128字切分而不是逐字循环

FINS单帧最多读128字,但实际项目里常常要读取几百上千个字。逐字循环是最差的做法,一次往返一个UDP包,1000字的采集任务要发1000次请求,延迟全耗在网络往返上。正确做法是分段批量读取:

/// <summary> /// 批量读取DM区,自动按128字切分 /// </summary> public List<ushort> ReadDmArea(int startAddr, int totalCount) { var result = new List<ushort>(totalCount); int remaining = totalCount; int currentAddr = startAddr; while (remaining > 0) { int chunk = Math.Min(128, remaining); ushort[] data = ReadDm(currentAddr, chunk); result.AddRange(data); currentAddr += chunk; remaining -= chunk; } return result; }

这里的chunk每次取剩余量和128的较小值,保证不超协议上限。边界情况是PLC实际返回的字数少于请求数,比如请求了128字但地址越界,PLC可能返回部分数据或错误码。我习惯在ReadDm里加一层校验,判断返回值长度是否等于请求长度,不一致就抛出异常,宁可报错也不让上层拿到不完整的数据。

4.4 写入重试:UDP丢包不是玄学

车间里交换机和PLC之间偶发丢包很常见,写入操作比读取更怕丢包,因为写数据是改状态的,丢一次不仅操作失败,还会让上位机的状态判断跟着出错。我在模拟项目X里见过的典型场景是:PLC每500ms执行一次动作,上位机在动作触发前写控制字,某一次UDP包恰好被交换机丢弃,动作就没执行,导致整条产线停了一拍。

重试策略不能盲目循环,我建议固定试3次,每次间隔逐次拉长:

public void WriteDmWithRetry(int startAddr, ushort[] values, int maxRetries = 3) { int delay = 200; for (int attempt = 1; attempt <= maxRetries; attempt++) { try { WriteDm(startAddr, values); return; // 成功后直接返回 } catch (FinsException ex) when (attempt < maxRetries) { // 记录失败日志 Console.WriteLine($"第{attempt}次写入失败:0x{ex.ErrorCode:X4},{delay}ms后重试"); Thread.Sleep(delay); delay *= 2; // 逐次拉长间隔 } } }

重试的退出条件有两个:一是写成功后直接返回,二是达到最大重试次数后抛出异常。这里故意把延迟写在catch块里而不是finally里,是为了让首次失败就立即重试,避免无谓等待。每次重试时Transceive里的SID会递增,这保证了重试请求不会被当作重复帧处理。

5. FINS通信避坑与排查手册:字节序、节点号、路由表

5.1 现象:返回0x1101“目标节点不存在”

有一次调试,读写命令都返回0x1101,查看FINS协议文档这个完成码表示目标节点不存在。代码里plcNode填的0x64,PLC编程软件里看到的节点号是0x63,差一个数,整个通信全废。原因是这台PLC曾经被别人改过网络参数,节点号不是出厂默认值。

解决方式很直接:在PLC编程软件的网络设置页面里查看实际的FINS节点号,把代码里的plcNode改成一致的值,立刻恢复。这个错误码是排查手册里出现频率最高的,遇到它不要怀疑代码逻辑,先核对节点号。

5.2 现象:上位机与PLC跨网段,FINS命令毫无响应

把程序部署到现场后,上位机在办公网,PLC在车间设备网,Ping能通但FINS请求全部超时。UDP包到了PLC,PLC也知道有请求进来,但目标网络号填的是0x00、目标节点号是PLC自己的节点号,PLC在本地找不到匹配项,就直接丢弃了。

原因在FINS路由机制:PLC收到请求后先查路由表,看看目标网络号和节点号是否在本机或可转发范围内,查不到就丢弃。解决方法是登录PLC软件,在FINS路由表里添加一条规则:目标网络0、目标节点0x64对应到本机CPU单元。路由表配好之后,跨网段读写就正常了。

5.3 现象:读DM区数值全对不上,且偏移规律很奇怪

之前遇到一次读回来的数据整体错位:DM100读回来的是DM102的值,DM101读回来的是DM103的值。检查了字节序、节点号、路由表都没问题,最后发现是PLC程序里定义的数据结构用了双字(32位),也就是说DM100和DM101合起来存了一个DINT,我在上位机里按单字去读,自然就错位了。

这种问题没有代码层面的解法,只有一条排查建议:先用PLC软件打开在线监视,确认目标地址的数据类型是16位还是32位,然后按类型去解析。FINS协议本身对数据类型是无感知的,它只管搬运字节,拆分和解析逻辑全部由上位机负责。

5.4 现象:偶发性写入失败,重试一次就成功

某控制字写入失败时好时坏,失败后立即重试就成功,间隔久了再写又可能失败。抓包看日志后发现,失败请求的UDP包在交换机上被丢弃了。上位机的UDP接收超时设为3秒,但PLC处理命令很快、响应通常在几十毫秒内回来,一旦发生丢包,第一个请求直接超时,第二个请求才成功。

解决方法就是第4章的重试逻辑。但要注意重试间隔不能太短,否则PLC还在处理上一条命令,新的请求可能被PLC的接收队列挤掉。我一般间隔从200ms起步,每次翻倍,最多3次,既能覆盖偶发丢包,也不会给PLC填太多重复请求。

5.5 现象:读取CIO位地址时,响应帧长度和预期不符

读取CIO区的某个位点位,按位编码0x02去读,结果响应帧长度比预期长了一截,解析出来的数据长度对不上。原因是CIO区按位访问时,起始地址的单位是位而不是字,协议里的寻址方式和按字访问完全不同。比如要读CIO 0.05这个位,起始地址填的是0,位地址填的是5,而不是把地址按位偏移折算。

解决方法是严格区分字访问和位访问:字访问填字地址、位地址置0;位访问填的是位所在字地址、位地址写0到15。最容易犯的错是把“0.05”这种小数点位表示直接当作地址,导致起始地址多算或少算。

6. 把FINS封装成通用通信库:超时、并发与日志的三点实践

6.1 超时参数与SID冲突排查

通信库上线3个月后出了一次怪问题:数据偶尔延迟,抓包发现上位机同时发了两条相同SID的请求。原因是多线程并发时,请求帧构建和SID递增不是原子操作,两个线程拿到同一个SID,PLC只回了一条,另一个请求超时。

解决方式就是在上位机加一个事务锁:同一时刻只允许一个FINS请求在途,请求完成后再发下一条。这个设计让FINS通信变成了串行的请求-响应模式,彻底消除了SID冲突。

6.2 用信号量控制并发读写

刚接手项目时,我遇到过一个并发读写的问题:上位机有3个线程在跑,一个读温度、一个读转速、一个写控制字,三个线程同时调用Transceive,UDP socket的接收缓冲区很快被多个响应填满。响应顺序和请求顺序对不上,读取结果就串了。

改用信号量后,整个通信通道同一时刻只允许一个事务:

private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1); public byte[] SafeTransceive(byte[] request) { _gate.Wait(); try { return _udpTransceive(request); } finally { _gate.Release(); } }

信号量初始计数和最大计数都设为1,效果就是强制串行化。虽然牺牲了并发发请求的能力,但PLC侧本身处理命令就是串行的,比起并行带来的响应错乱,串行反而是更稳妥的选择。

6.3 抓包验证:Wireshark过滤FINS报文

调FINS协议时抓包是个好习惯,每次怀疑协议格式不对,直接抓包看比对着代码猜快得多。过滤语法是udp.port == 9600,抓到请求帧后逐字节对照第2章的表检查:ICF是不是0x80、DA1是不是0x64、命令码是不是0x0101、起始地址的高低位有没有填反。响应帧看完成码和数据区的字节序。

从那以后,我每次接新的PLC通信项目,都强制自己先抓包确认一次字节序和节点号,再开始写业务代码。这个习惯帮我省掉了不少深夜排障的时间,也让我对协议的理解越来越具体。整套C#源码和排查笔记都在下载包里,改成自己的通信层时可以直接复用,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询