简介:面向工业自动化方向开发者与工控上位机编程初学者,这份资源围绕C#通过FINS协议与欧姆龙PLC通信展开,重点解决TCP连接建立、FINS数据帧构造与响应解析等实际编程问题,适合已具备C#基础、希望入门PLC网络通信的读者参考实践。压缩包共45个文件、约4.38MB,以cs源代码、png协议与网络模型示意图、pdf通讯手册、docx直播素材及exe测试工具为主,另含sln解决方案与csproj工程文件,可直接打开调试。已有1120人学习下载。资源将协议格式讲解、网络模型图示与可运行示例代码结合,配套通讯手册便于对照查阅命令代码与地址格式,测试工具可用于验证收发数据,帮助读者理解连接、读写寄存器及错误处理等环节,减少自行摸索协议细节的时间成本。
1. 从一条产线调试说起:这个 C# 读欧姆龙 PLC 的例子能省你两天
去年帮一个做装配线的朋友排查数据采集问题,现场是欧姆龙 CP 系列 PLC,上位机用 C# 写的 MES 客户端,通讯走 FINS 协议。他卡了整整两天,问题不在协议本身,而在字节序和地址换算上——读回来的 DM 区数据全是乱的,明明 PLC 里是 1234,上位机显示成 53764。这类问题在工控上位机开发里太常见了,而手头这个「C# 通过 FINS 读取欧姆龙 PLC 协议例子」,恰好就是把这些坑提前踩过一遍的工程代码。它面向的是需要做设备数据采集、产线监控、上位机对接的 C# 开发者,尤其是刚接触 FINS 协议、不想从协议手册第一页啃起的人。整个例子围绕 FINS/TCP 和 FINS/UDP 两种链路,把命令帧拼装、响应解析、内存区读写封装成了可直接调用的类,拿来改改地址和 IP 就能接自己的设备。
2. 协议与工程结构:FINS 帧长什么样,代码怎么分层
2.1 FINS 协议的核心概念与帧结构
FINS(Factory Interface Network Service)是欧姆龙自家的一套通讯协议,跑在 TCP 或 UDP 之上,也可以走串口或 Controller Link,但这个例子聚焦的是以太网侧。它的帧结构分两段:FINS/TCP 头 + FINS 命令帧。TCP 头固定 16 字节,包含魔数0x46494E53(ASCII 的 "FINS")、长度、命令码(握手/透传)、错误码;命令帧则是 10 字节的 FINS 头加可变长的命令数据。
FINS 头里几个字段必须记牢:ICF(信息控制域)、RSV(保留)、GCT(网关计数)、DNA/DA1/DA2(目标网络/节点/单元地址)、SNA/SA1/SA2(源网络/节点/单元地址)、SID(服务 ID)。很多新手第一次拼帧失败,就是 ICF 和 SID 没填对。ICF 常见取值0x80表示命令、需要响应;SID 随便给个非零值,响应会原样带回,用来做请求响应配对。
命令码是两字节,读内存区是0x0101,写内存区是0x0102。命令数据里带内存区代码(DM 区是0x82,CIO 区是0xB0,WR 区是0xB1)、起始地址(两字节,高位在前)、读取长度(两字节,按字或位)。响应帧在命令码后多一个两字节的结束码,0x0000表示成功,非零就是错误码,后面才是数据区。
这个例子的价值在于,它没有让你对着手册一字节一字节拼,而是把这些字段做成了结构体或常量,拼帧逻辑集中在一个方法里,改地址只改参数。
2.2 工程目录与模块划分
虽然输入里没有给出完整目录树,但按这类 FINS 通讯例子的常见组织方式,代码一般分成这么几层:
FinsPlcDemo/ ├── Fins/ │ ├── FinsTcpClient.cs # TCP 链路:连接、握手、收发 │ ├── FinsUdpClient.cs # UDP 链路:无连接收发 │ ├── FinsFrameBuilder.cs # 命令帧拼装 │ ├── FinsFrameParser.cs # 响应帧解析 │ └── FinsAddress.cs # 地址换算(DM/CIO/WR + 偏移) ├── Models/ │ └── PlcMemoryArea.cs # 内存区枚举与元数据 ├── Services/ │ └── PlcDataService.cs # 业务层:批量读、定时轮询 └── Program.cs # 控制台入口,演示读写这种分层的意图很清楚:链路层只管字节进出,帧层只管拼和拆,地址层只管把「D100」这种人类写法翻译成协议要的区代码加偏移,业务层再往上做批量采集。二次开发时你大概率只动PlcDataService和Program,底层帧逻辑不用碰。
2.3 地址换算:D100 到底对应哪两个字节
这是整个项目里最容易被低估的一块。欧姆龙的内存区地址在协议里不是直接写 100,而是要拆成区代码 + 偏移。DM 区 D100 对应区代码0x82、偏移0x0064(100 的十六进制)。CIO 区、WR 区、HR 区各有自己的区代码,位地址还要额外处理位偏移。
常见做法是封装一个FinsAddress结构:
public struct FinsAddress { public byte AreaCode; // 0x82=DM, 0xB0=CIO, 0xB1=WR, 0xB2=HR public ushort Offset; // 字偏移,如 D100 -> 100 public byte BitOffset; // 位地址时使用,字地址填 0 // 把 "D100" 这类字符串解析成协议地址 public static FinsAddress Parse(string address) { char area = address[0]; ushort offset = ushort.Parse(address.Substring(1)); byte code = area switch { 'D' => 0x82, 'C' => 0xB0, 'W' => 0xB1, 'H' => 0xB2, _ => throw new ArgumentException($"不支持的区域: {area}") }; return new FinsAddress { AreaCode = code, Offset = offset, BitOffset = 0 }; } }AreaCode决定读哪个内存区,Offset是字偏移,BitOffset只在读位时用。注意偏移是ushort,最大 65535,DM 区实际容量看 PLC 型号,超了会返回结束码错误。解析时别用int.Parse后强转,地址越界在协议层不会给你友好提示,只会回一个非零结束码,排查起来很费劲。
3. 跑起来:从握手到读出第一个 DM 值
3.1 环境准备与连接参数
跑这个例子需要 .NET 环境,控制台项目用dotnet new console建,目标框架 .NET 6 及以上都行,代码本身没用到什么高版本特性。PLC 侧要确认几件事:以太网单元已配置 IP,FINS/TCP 端口默认 9600,节点号(DA1/SA1)在同一个网络里不能冲突。上位机 PC 和 PLC 要在同一网段,或者路由可达。
连接参数一般集中在一个配置类里:
public class PlcConnectionOptions { public string Ip { get; set; } = "192.168.1.10"; public int Port { get; set; } = 9600; public byte DestNode { get; set; } = 1; // PLC 节点号 public byte SrcNode { get; set; } = 10; // 上位机节点号 public byte DestNetwork { get; set; } = 0; public byte SrcNetwork { get; set; } = 0; public int TimeoutMs { get; set; } = 3000; }DestNode是 PLC 的 FINS 节点号,通常等于 IP 最后一段,但要看 PLC 的实际配置,别想当然。SrcNode是上位机自己声明的节点号,同一网络内不能和别的设备重复。TimeoutMs一定要设,工控现场网络抖动是常态,不设超时的同步读会把 UI 线程卡死。
3.2 TCP 握手与命令帧发送
FINS/TCP 不是连上就能发命令帧,要先做一次客户端节点握手。握手帧的 TCP 头命令码是0x0000,数据区带客户端节点号;PLC 回一个命令码0x0001的响应,带回服务端节点号。握手成功后,后续每帧的 TCP 头命令码是0x0002,表示透传 FINS 帧。
private async Task HandshakeAsync(NetworkStream stream) { // 握手请求:魔数 + 长度 + 命令码0 + 错误码0 + 客户端节点号 var req = new byte[20]; WriteMagic(req, 0); WriteInt32BE(req, 8, 12); // 长度:12字节数据 WriteInt32BE(req, 12, 0); // 命令码 0 = 节点地址请求 WriteInt32BE(req, 16, 0); // 错误码 // 客户端节点号放在数据区,这里省略具体偏移写法 await stream.WriteAsync(req); var resp = new byte[24]; await ReadExactAsync(stream, resp, 24); int errCode = ReadInt32BE(resp, 16); if (errCode != 0) throw new IOException($"FINS 握手失败,错误码 {errCode}"); }这段的关键点:TCP 头里所有多字节整数都是大端序,WriteInt32BE就是手动按高位在前写。握手响应的错误码非零就直接抛,别继续往下发命令帧,否则后面每个请求都会失败,错误信息还看不出根因。ReadExactAsync要保证读满指定字节数,NetworkStream.ReadAsync单次不保证读满,这是新手常翻车的地方。
3.3 读 DM 区的完整调用链
握手完成后,读 DM 区的调用链是:业务层给地址和长度 → 帧构造器拼 FINS 命令帧 → TCP 客户端加 TCP 头发出 → 读响应 → 解析器校验结束码并取数据。
public async Task<short[]> ReadDmAsync(ushort start, ushort count) { // 1. 拼 FINS 命令帧:读内存区 0x0101 var fins = FinsFrameBuilder.BuildReadCommand( areaCode: 0x82, start: start, count: count, destNode: _opt.DestNode, srcNode: _opt.SrcNode); // 2. 加 TCP 头(命令码 0x0002 透传)并发送 var frame = FinsTcpClient.WrapTcpHeader(fins); await _stream.WriteAsync(frame); // 3. 读响应:先读 16 字节 TCP 头拿长度,再读剩余 var tcpHeader = await ReadExactAsync(_stream, 16); int bodyLen = ReadInt32BE(tcpHeader, 4); var body = await ReadExactAsync(_stream, bodyLen); // 4. 解析:跳过 10 字节 FINS 头 + 2 字节命令码 + 2 字节结束码 int endCode = ReadUInt16BE(body, 12); if (endCode != 0) throw new FinsException(endCode); var data = new short[count]; for (int i = 0; i < count; i++) data[i] = ReadInt16BE(body, 14 + i * 2); return data; }参数说明:start是字偏移,count是读取字数,一次别读太多,FINS 单帧有长度上限,常见做法是每批不超过 100 字,超了拆多次。结束码在响应体的第 12、13 字节(从 0 计),非零就抛异常,把结束码带出去,方便对照手册查原因。数据区从第 14 字节开始,每个字两字节大端序,ReadInt16BE负责转换。这里用short是因为 DM 区是 16 位有符号字,如果你的业务是无符号,换成ushort即可。
4. 避坑与排查:现场最容易翻车的五个点
4.1 读回来的数值翻了几万倍
现象:PLC 里 DM 区是 100,上位机读出来 25600。原因:字节序搞反了。FINS 协议全程大端序,而 x86 机器是小端,如果你用BitConverter.ToInt16直接转,就会得到高低字节互换的结果。解决:统一用自己写的ReadInt16BE,或者读出来后(short)((b[0] << 8) | b[1]),别偷懒用BitConverter。
4.2 握手成功但每个读命令都返回错误码
现象:握手响应错误码是 0,但读 DM 返回结束码0x1101或0x1002。原因:多半是目标节点号或网络号填错,或者 PLC 侧没允许该节点访问。解决:先确认DestNode和 PLC 实际节点号一致,再检查 PLC 的 FINS 允许节点列表;0x1101通常指向地址越界,核对起始地址加长度有没有超出该区容量。
4.3 偶发超时,重试就好
现象:大部分请求正常,偶尔超时,重发一次又成功。原因:UDP 链路丢包,或者 TCP 链路被现场交换机广播风暴影响。解决:TCP 链路设合理超时并做一次重试;UDP 链路必须自己做超时重传和 SID 配对,别指望协议层保证。轮询间隔别设太短,常见做法是 200ms 以上,给 PLC 扫描周期留余量。
4.4 批量读大长度时响应被截断
现象:一次读 500 个字,解析时数组越界或数据后半段全零。原因:FINS 单帧数据长度有上限,超长响应会被 PLC 拒绝或分片,而代码按单帧解析。解决:把批量读拆成每批 100 字以内,循环拼接;拆批时注意起始地址递增,别把字偏移和字节偏移搞混。
4.5 多线程同时读写导致响应错位
现象:两个线程同时发请求,A 线程收到了 B 线程的响应。原因:FINS/TCP 是请求响应模式,一条连接上并发发多个请求,响应顺序不保证,SID 没做配对就会错位。解决:一条连接加锁串行化,或者每个请求带唯一 SID 并在接收侧按 SID 分发。我一般直接给收发加SemaphoreSlim(1,1),工控采集这点并发量,串行完全够用。
5. 二次开发:把例子改成你自己的采集服务
5.1 从控制台 Demo 到常驻采集服务
这个例子是控制台入口,实际项目里你要把它包成 Windows 服务或后台 Worker。改造点集中在三处:连接生命周期、异常恢复、数据出口。连接不要每次读都新建,启动时握手一次,保持长连接,断线时用指数退避重连;异常恢复要把FinsException和IOException分开处理,前者是协议层错误(地址错、权限错),重试没用,后者是链路错误,可以重连;数据出口建议抽象成接口,方便从写数据库换成推 MQ。
public interface IPlcDataSink { Task WriteAsync(string tag, short[] values); } // 采集循环骨架 while (!stoppingToken.IsCancellationRequested) { try { var values = await plc.ReadDmAsync(100, 20); await sink.WriteAsync("line1.dm100", values); } catch (FinsException ex) { _logger.LogError(ex, "协议错误,结束码 {Code}", ex.EndCode); // 协议错误不重试,记录后跳过本轮 } catch (IOException) { await plc.ReconnectAsync(); // 链路错误,重连 } await Task.Delay(500, stoppingToken); }这段骨架的价值在于把「哪些错该重试、哪些不该」写死了。现场最常见的误用是无差别 catch 后重试,结果地址配错时日志刷屏还找不到根因。
5.2 扩展写操作与位读写
读通了之后,写操作是顺手的事,命令码换成0x0102,数据区在地址后跟要写的字。位读写要注意命令数据里的位偏移字段,读 CIO 区的某一位,长度按位算,解析时按位取值。常见做法是把读写统一到一个ExecuteCommand方法,命令码和数据处理用委托传入,避免复制粘贴出两套几乎一样的收发逻辑。
5.3 验证方法:用 PLC 侧监视对照
改完代码怎么确认对?最直接的办法是在 PLC 编程软件里开监视,同时在上位机读同一个地址,两边数值对照。再做一个边界测试:读 DM 区最后一个字、读长度为 1、读长度为上限,看结束码是否符合预期。我一般还会故意填一个越界地址,确认异常路径能正确抛出结束码,而不是静默返回零数组——后者在现场是最难查的。
从那以后我每次接新的 PLC 型号,都先把地址换算和字节序这两件事单独写个小测试跑通,再往上搭业务逻辑,省下的排查时间远比写测试多。希望帮到你。
本文还有配套的精品资源,点击获取