简介:面向工业自动化开发者与PLC编程初学者的C#通信实例资源,解决通过FINS协议连接欧姆龙PLC并实现数据读写的常见需求。包内包含可运行的Windows窗体工程、9个C#源文件、通讯手册PDF与协议格式图,并附带NetAssist网络调试工具及演示截图,便于对照协议帧结构逐行理解。资源共45个文件,压缩包约4.38MB,配置文件和可执行程序齐全,解压后即可在Visual Studio中打开调试。已有1117人学习下载,适合希望快速上手FINS通信、需要现成代码骨架的工程师。除了基础TcpClient连接示例,资料中还涉及响应解析、多寄存器读取等扩展内容,并配有通讯手册、直播素材文档与课件,能够帮助读者理解帧头、命令码与地址格式,减少反复查阅文档的时间,将通信模块直接移植到自己的项目中。
1. FINS协议读欧姆龙PLC:一个不用改梯形图的C#采集方案
车间里一台老设备的PLC程序是设备厂封死的,梯形图不让动,可MES那边催着要产量和设备状态数据。加传感器不现实,改PLC更不可能,最后只能走它的原生FINS协议。这套“C#通过FINS读取欧姆龙PLC协议例子”要解决的就是这件事:不修改PLC一行逻辑,直接用C#从UDP 9600端口发送标准FINS指令帧,把DM区、CIO区、WR区、HR区的数据读回来,需要时还能往指定地址写值。整个工程按最小可运行闭环组织,不是零散片段,拉下来改一下IP、节点号和点位地址就能跑。适合做设备数据采集网关、MES对接、上位机监控的开发者参考,哪怕你只是偶尔想远程看一眼PLC内部的变量,这套代码改两个参数也能当临时工具用。
2. 技术选型与通信链路:从FINS帧格式到工程目录结构
2.1 FINS协议报文结构拆解
FINS是欧姆龙定义的工厂自动化网络协议,底层可以跑在UDP、TCP、HostLink串口上。做数据采集时绝大多数场景都是用UDP,因为不需要建立长连接,发一个请求帧等一个响应帧,成本最低。PLC侧默认开放UDP 9600端口,里面跑着一个FINS服务,你给它发标准帧它就会回。可以把PLC当成一个网络黑匣子,只要帧格式对,它就老老实实回数据。
每一条FINS请求帧由三部分组成:
- 帧头:10字节,固定结构
- 命令码:2字节,01 01是读内存区,01 02是写内存区
- 参数:内存区代码、地址、数量等,随命令变化
帧头的10个字节是最容易抄错的地方,依次是:
| 字节 | 字段名 | 典型值 | 作用 |
|---|---|---|---|
| 0 | ICF | 0x80 | 最高位置1表示需要响应 |
| 1 | RSV | 0x00 | 保留字节 |
| 2 | GCT | 0x02 | 网关计数,固定值 |
| 3 | DNA | 0x00 | 目标网络号,本地网络填0 |
| 4 | DA1 | 0x0A | 目标节点号,要与PLC配置一致 |
| 5 | DA2 | 0x00 | 目标单元号,CPU单元填0 |
| 6 | SNA | 0x00 | 源网络号 |
| 7 | SA1 | 0x3E | 源节点号,PC侧自己定义 |
| 8 | SA2 | 0x00 | 源单元号 |
| 9 | SID | 0x01 | 服务标识符,响应帧会原样带回 |
DA1和SA1这两个节点号特别重要。DA1填错会导致请求根本没到达指定PLC,SA1填错或者跟同一网段里其他FINS客户端撞了,PLC可能直接不回包。很多人一开始把DA1和SA1都填0,结果只有一台PLC时能通,多台设备一接就翻车,原因就在这。我一般把DA1和SA1都做成可配置项,PLC的FINS节点号在其网络配置里就能看到,PC侧SA1取一个不冲突的值,比如62。
帧头后面是命令区。读内存区命令的参数排列顺序是:内存区代码(2字节)、起始地址(3字节)、读取个数(2字节)。起始地址的三字节结构经常让人困惑:前两字节是字地址,高字节在前,第三字节是位号。读字数据时位号填0;读位数据时才把具体的位号填到第三字节里。内存区代码上,字访问和位访问是两套编码:
| 区域 | 字访问码 | 位访问码 | 说明 |
|---|---|---|---|
| CIO区 | 0xB0 | 0x30 | 继电器、输入输出区 |
| WR区 | 0xB1 | 0x31 | 工作继电器区 |
| HR区 | 0xB2 | 0x32 | 保持继电器区 |
| DM区 | 0x82 | 0x02 | 数据内存区 |
以读DM区D100开始的10个字为例,完整请求帧就是:帧头10字节 +01 01+00 82+00 64 00+00 0A。D100换算成十六进制是0x0064,高字节0x00、低字节0x64、位号0x00,连起来正好三个字节。发送后PLC返回的响应帧结构是:帧头10字节 + 命令码2字节 + 结束码2字节 + 数据若干字节。结束码为0x0000表示正常,非0就是错误,具体含义后面编码表里再讲。
2.2 工程目录与模块划分
工程结构按“通信客户端、点位配置、数据转换、入口示例”四个层次切分,每个文件职责单一:
FinsCollector/ ├── FinsClient.cs // UDP收发、帧头、读写指令 ├── FinsFrame.cs // 帧常量与SID自增管理 ├── PlcDataPoint.cs // 点位配置模型 ├── DataConverter.cs // 字节序转换、BCD解码 ├── Program.cs // 控制台示例:轮询读取 └── appsettings.json // PLC IP、节点号、点位清单FinsClient.cs是核心类,负责UDP通信、组帧、发送、接收、超时重试;FinsFrame.cs单独放帧常量,避免魔法数字散落各处,SID自增也收敛在这个文件里;PlcDataPoint.cs定义点位模型,把PLC地址、数据类型、比例换算这些信息从硬编码中解耦出来;DataConverter.cs处理字节序和BCD码转换,这是工业数据采集里绕不开的脏活;Program.cs给了一个控制台轮询示例;appsettings.json保存连接参数。
技术栈选型上,我用了.NET 6控制台应用加内置的System.Net.Sockets.UdpClient,没有引入第三方库。原因有二:一是UDP本身就是无连接协议,标准库足够;二是很多工厂环境里的上位机还跑着老Windows系统,如果目标机是.NET Framework 4.7.2,这段代码的改动量也只是命名空间级别,迁移成本很低。等会儿讲核心代码时,你会发现所有逻辑都在处理字节流,这恰恰是跨框架最稳的部分。
3. 核心代码实现:组帧、收发、解析的完整闭环
3.1 UDP通信基类实现
FinsClient类承担了整条链路的底层工作。构造时传入PLC的IP、目标节点号、本机节点号,内部创建UdpClient实例;每次发送时动态构建10字节帧头,SID自增保证每次请求的标识都不一样;发送后等待响应,用CancellationToken实现超时控制。
public class FinsClient : IDisposable { private readonly UdpClient _udp; private readonly IPEndPoint _plcEp; private readonly int _plcNode; private readonly int _selfNode; private byte _sid = 0; public FinsClient(string plcIp, int plcNode, int selfNode = 62, int port = 9600) { _plcNode = plcNode; _selfNode = selfNode; _plcEp = new IPEndPoint(IPAddress.Parse(plcIp), port); _udp = new UdpClient(); } public async Task<byte[]> SendCommandAsync(byte[] command, CancellationToken ct) { byte[] header = BuildFinsHeader(); byte[] packet = header.Concat(command).ToArray(); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(2000); try { await _udp.SendAsync(packet, packet.Length, _plcEp, timeoutCts.Token); UdpReceiveResult result = await _udp.ReceiveAsync(timeoutCts.Token); return result.Buffer; } catch (OperationCanceledException) { throw new TimeoutException("FINS响应超时,请检查网络和节点号"); } } private byte[] BuildFinsHeader() { _sid = (byte)((_sid + 1) % 256); return new byte[] { 0x80, 0x00, 0x02, 0x00, (byte)_plcNode, 0x00, 0x00, (byte)_selfNode, 0x00, _sid }; } public void Dispose() => _udp.Dispose(); }这段代码的核心逻辑集中在两个地方。BuildFinsHeader每次调用都会让SID自增,原因是PLC的响应帧会原样带回SID,客户端可以通过它判断当前响应对应哪一次请求;如果SID始终不变,多请求并发时就会分不清响应归属。超时控制用的是CreateLinkedTokenSource,把外部取消令牌和内部超时绑在一起,外部触发取消时内层也会跟着取消,避免任务悬挂。
两个参数需要注意:timeoutCts.CancelAfter(2000)里的2000毫秒是经验值,老型号PLC在重负载下响应可能超过2秒,如果现场频繁超时可以调到3000;构造函数的port默认9600是FINS UDP服务的标准端口,但实际项目中常遇到PLC工程师把端口改掉的情况,所以这里必须暴露成可配置项,不要写死。
3.2 内存区读写指令:DM区与CIO区的组帧逻辑
有了底层发送方法,上层读写指令就相对清晰了。读连续字的逻辑是:按协议拼出命令体,发送后校验结束码,最后裁出数据区返回。
/// <summary> /// 读取连续字数据 /// </summary> /// <param name="memoryArea">内存区字访问码,如DM区0x82</param> /// <param name="wordAddress">起始字地址,如100对应D100</param> /// <param name="count">读取个数,最多不要超过64</param> /// <returns>原始字节数组,每2字节为一个字,高字节在前</returns> public async Task<byte[]> ReadWordsAsync(ushort memoryArea, ushort wordAddress, ushort count) { var cmd = new List<byte> { 0x01, 0x01, (byte)(memoryArea >> 8), (byte)(memoryArea & 0xFF), (byte)(wordAddress >> 8), (byte)(wordAddress & 0xFF), 0x00, (byte)(count >> 8), (byte)(count & 0xFF) }; byte[] resp = await SendCommandAsync(cmd.ToArray(), CancellationToken.None); // 响应:10字节帧头 + 2字节命令码 + 2字节结束码 + 数据 int endCode = (resp[12] << 8) | resp[13]; if (endCode != 0) { throw new FinsException($"读取失败,结束码 0x{endCode:X4}"); } return resp.Skip(14).ToArray(); }组帧时注意所有多字节整数都是大端序,高字节在前。(byte)(memoryArea >> 8)拿到高8位,(byte)(memoryArea & 0xFF)拿到低8位,地址三字节同理。DM区D100的wordAddress传100,代码自动换算成0x00、0x64、0x00。count参数理论上允许更大值,但不同PLC型号对单次读取长度限制不同,我习惯上限设为64个字,够用且永远不会触发响应过长错误。
响应解析时,resp[12]和resp[13]是结束码的高低位。响应帧结构固定,偏移量不会变:0到9是帧头,10、11是回显的命令码,12、13是结束码,14开始才是真正的数据。很多初学者试图解析返回的整个UDP包当数据,结果把帧头当业务数据存进数据库,这里直接Skip(14)一步到位。
写指令的组帧逻辑类似,只是多了一段待写入的数据区:
public async Task WriteWordsAsync(ushort memoryArea, ushort wordAddress, ushort[] values) { var cmd = new List<byte> { 0x01, 0x02, (byte)(memoryArea >> 8), (byte)(memoryArea & 0xFF), (byte)(wordAddress >> 8), (byte)(wordAddress & 0xFF), 0x00, (byte)(values.Length >> 8), (byte)(values.Length & 0xFF) }; foreach (ushort v in values) { cmd.Add((byte)(v >> 8)); cmd.Add((byte)(v & 0xFF)); } byte[] resp = await SendCommandAsync(cmd.ToArray(), CancellationToken.None); int endCode = (resp[12] << 8) | resp[13]; if (endCode != 0) { throw new FinsException($"写入失败,结束码 0x{endCode:X4}"); } }写入时命令码换成01 02,数量用的是values.Length,然后把每个ushort按高字节在前依次追加。这个指令可以一次写多个连续字,比一条一条写效率高得多,比如批量改写PID参数或整组配方值时,一次发几十个字没有问题。写操作最怕地址越界,写入前最好在点位配置里做一次地址范围校验,防止把数据写到相邻的保持区造成事故。
3.3 响应校验、位读取与数据类型转换
读位数据和读字数据只在两个地方不同:内存区代码换成位访问码,地址第三字节填实际位号。读取CIO区W0.01位,内存区代码是0x30,字地址是0,位号是1。
public async Task<bool> ReadBitAsync(ushort bitAreaCode, ushort wordAddress, byte bitIndex) { var cmd = new List<byte> { 0x01, 0x01, (byte)(bitAreaCode >> 8), (byte)(bitAreaCode & 0xFF), (byte)(wordAddress >> 8), (byte)(wordAddress & 0xFF), bitIndex, 0x00, 0x01 }; byte[] resp = await SendCommandAsync(cmd.ToArray(), CancellationToken.None); int endCode = (resp[12] << 8) | resp[13]; if (endCode != 0) { throw new FinsException($"读位失败,结束码 0x{endCode:X4}"); } // 返回的1字节数据,0x00为OFF,0x01为ON return resp[14] != 0x00; }读单个位时,count固定为1,返回的数据区只有1字节。注意位号bitIndex只在0到15之间有效,因为PLC的字是16位,超过15会直接报地址错误。这个方法的调用频率如果很高,建议在应用层做缓存,不要每次都发UDP包去读同一个开关量,否则网络上会有大量无意义的小包。
数据转换这块,欧姆龙PLC内部多字节数据默认大端存储,但C#的BitConverter是小端。直接用BitConverter.ToInt16会得到高低字节颠倒的结果,所以我在DataConverter里封装了解析方法:
public static short ToInt16LE(byte[] data, int offset) { // PLC是大端,两个字节分别是高、低位 return (short)((data[offset] << 8) | data[offset + 1]); } public static float ToFloat(byte[] data, int offset) { // 4字节大端,需要先反转再走BitConverter byte[] temp = new byte[4]; Array.Copy(data, offset, temp, 0, 4); Array.Reverse(temp); return BitConverter.ToSingle(temp, 0); }这里有个容易踩的细节:data[offset] << 8如果写成data[offset++] << 8 | data[offset],逻辑看着对,但运算符优先级和自增副作用混在一起容易翻车,我后来统一用固定索引写法。浮点数的转换必须先Array.Reverse再交给BitConverter,因为PLC里的IEEE 754也是大端。BCD码转换更特殊,比如D区的温度值可能存的是BCD格式,直接按整数解析会得到0x1234而不是数字1234,需要逐位分离再乘权重,这块在工业场景里经常遇到,我会在点位模型里加一个DataType枚举,把转换策略跟点位绑定。
4. 避坑与常见问题排查:四个翻车现场的解决记录
4.1 通信超时:原因多半不在协议本身
现象:程序启动后第一次读正常,几秒后第二次读就抛TimeoutException,重试也无济于事。或者从开发机换到车间工控机后完全收不到响应。
原因:这不是协议解析的问题,九成出在网络或节点配置。第一,Windows防火墙默认拦截UDP入站包,工控机上的杀毒软件也可能顺手拦掉9600端口;第二,PLC的FINS节点号跟请求帧里的DA1不一致,比如PLC实际配置的是1,代码里写的是10;第三,UDP无连接,第一次能通是碰巧,中途PLC侧以太网单元重启后,本机ARP表里的映射过期,包发到了错误地址。
解决:先把ping通作为前提,再打开Windows防火墙的高级设置,添加入站规则放行UDP 9600。节点号以PLC网络配置页为准,不要靠猜。ARP表过期的问题建议在程序启动时先向PLC发一条空读指令做预握手,让ARP缓存刷新。别一上来就怀疑协议写错,先抓包看PLC有没有回包,回包了再查解析,大部分所谓的玄学超时都是这一层问题。
4.2 地址映射混乱:字地址、位号与高低字节序
现象:读D100返回的数据跟PLC编程软件里看到的不一致,数值明显是负数或者大小颠倒;读CIO位时返回状态跟实际继电器状态对不上。
原因:FINS地址三字节结构里,前两字节是字地址,高字节在前,第三字节是位号。很多人把“D100”直接拆成0x01、0x00、0x00传进去,等于请求读D256,数据当然全错。字节序问题更隐蔽,PLC返回的16位整型是大端,C#默认小端解析,低地址字节被当成低字节,数值就错位了。
解决:地址转换统一收敛到一个方法里,不要到处手写。比如(byte)(wordAddress >> 8)这种写法封装成BuildAddress方法后,所有调用方传十进制字地址即可,杜绝低级错误。字节序问题在DataConverter里强制用大端解析,宁可多写两行移位,也别用BitConverter直接啃原始字节。调试时把读回来的原始字节数组打出来,跟PLC软件里的值手工对照一遍,确认区域和地址都对了再上解析逻辑。
4.3 节点号冲突:多台PLC混采时串数据
现象:现场有三台PLC,分别配了节点号1、2、3,网关程序分别建了三个FinsClient实例,结果间歇性出现A台的数据被记到B台名下。
原因:FINS帧头里的SA1(源节点号)在三台PLC通信时用了同一个值。PLC的响应其实都回了,但上位机在并发收发时没有按SID去关联请求和响应,谁后到就当成谁的,数据就串了。另一个常见原因是三个客户端实例的SID都从0自增,同一时刻两台PLC回包里的SID恰好一样,响应归属判断失效。
解决:每个FinsClient实例在构造时通过selfNode参数错开源节点号,第一台用62、第二台用63、第三台用64。SID不要每个客户端单独存,而是在FinsFrame里做成进程级的AtomicInteger,保证全局唯一递增。响应回来时先校验SID是否匹配当前请求,不匹配就丢弃或挂起等待。如果并发量大,建议直接用Task带着请求ID去等待对应响应,别用同步收包再分发的模型。
4.4 一次读取数量过大触发0x110B
现象:程序跑得好好的,改成读128个字后,PLC返回结束码0x110B,解析直接抛异常。
原因:0x110B是“响应过长”错误。PLC的FINS响应帧长度有上限,不同型号限制不同,但常规经验是单次读字不要超过124个字,有些老型号甚至只支持64个字。我一开始图省事想一次把D区整段搬回来,结果撞上了这个天花板。
解决:把读取数量上限设为64,超过就分段多次读。分段读取要注意地址递增的计算,比如读D100开始128个字,第一次读D100取64个,第二次的起始地址就是D164。为了减小对PLC的冲击,分段之间加5到10毫秒间隔,别用for循环无脑连发。这一点也是我踩过最久的坑,后面我把所有读指令都套了一层自动分片封装,调用方只传起始地址和总长度,内部按64字切片,从此再没在这上面栽过。
5. 二次开发改造:从单点读写升级成稳定采集服务
5.1 点位配置驱动与轮询引擎
把点位与代码彻底解耦是第一步改造。现在PlcDataPoint承载区域、地址、长度、数据类型、比例换算和采集周期:
public class PlcDataPoint { public string Name { get; set; } public ushort Area { get; set; } // 字访问码,如0x82 public ushort Address { get; set; } // 起始字地址 public byte BitIndex { get; set; } // 位号,仅位类型使用 public ushort Length { get; set; } // 读取字数 public DataType Type { get; set; } // Short/UsInt/Float/Bcd public double Scale { get; set; } = 1.0; public int PollIntervalMs { get; set; } = 1000; }配合一个简单的轮询引擎,每个点位按自己的周期异步执行。采集结果先打进内存缓存,再由单独的推送线程发给MES或写入数据库,避免采集与上报互相阻塞。这样新增一个监控点只是往配置文件里加一行,不用改代码重新编译。
5.2 断线退避与末值缓存
UDP没有真正意义上的连接,所谓断线重连,实际是超时后的退避策略。我习惯在轮询循环里维护一个连续失败计数器:连续失败3次就暂停该点位采集,等待时间按1秒、2秒、5秒递增,最多拉大到30秒;一旦某次读取成功就立即清零计数器,恢复正常节奏。这套策略能保证PLC重启或网络抖动时,采集服务不会疯狂发包把工控机网络打满。
末值缓存是我的后悔药设计。MES拉数据时如果刚好赶上网络故障,宁可把最后一次成功采集的值给它,也不能让报表里出现大片空白。做法是采集成功后把解析值存入ConcurrentDictionary,外部查询接口优先读缓存。同时给缓存打时间戳,超过N分钟未被刷新就标记为过期,由业务方决定是否继续信任。
这个项目的坑我踩了不少,从那以后我每次接入新的PLC都会先在纸上手工画一遍帧头和地址换算,把报文从ICF到结束码推演一次再联调,这个习惯省下过无数个调试的夜晚。希望帮到你。
本文还有配套的精品资源,点击获取