C#通过FINS协议读取欧姆龙PLC实战:字节序与地址换算避坑指南
简介面向工业自动化方向开发者与工控上位机编程初学者这份资源围绕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 协议的核心概念与帧结构FINSFactory Interface Network Service是欧姆龙自家的一套通讯协议跑在 TCP 或 UDP 之上也可以走串口或 Controller Link但这个例子聚焦的是以太网侧。它的帧结构分两段FINS/TCP 头 FINS 命令帧。TCP 头固定 16 字节包含魔数0x46494E53ASCII 的 FINS、长度、命令码握手/透传、错误码命令帧则是 10 字节的 FINS 头加可变长的命令数据。FINS 头里几个字段必须记牢ICF信息控制域、RSV保留、GCT网关计数、DNA/DA1/DA2目标网络/节点/单元地址、SNA/SA1/SA2源网络/节点/单元地址、SID服务 ID。很多新手第一次拼帧失败就是 ICF 和 SID 没填对。ICF 常见取值0x80表示命令、需要响应SID 随便给个非零值响应会原样带回用来做请求响应配对。命令码是两字节读内存区是0x0101写内存区是0x0102。命令数据里带内存区代码DM 区是0x82CIO 区是0xB0WR 区是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、偏移0x0064100 的十六进制。CIO 区、WR 区、HR 区各有自己的区代码位地址还要额外处理位偏移。常见做法是封装一个FinsAddress结构public struct FinsAddress { public byte AreaCode; // 0x82DM, 0xB0CIO, 0xB1WR, 0xB2HR 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最大 65535DM 区实际容量看 PLC 型号超了会返回结束码错误。解析时别用int.Parse后强转地址越界在协议层不会给你友好提示只会回一个非零结束码排查起来很费劲。3. 跑起来从握手到读出第一个 DM 值3.1 环境准备与连接参数跑这个例子需要 .NET 环境控制台项目用dotnet new console建目标框架 .NET 6 及以上都行代码本身没用到什么高版本特性。PLC 侧要确认几件事以太网单元已配置 IPFINS/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 Taskshort[] 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 型号都先把地址换算和字节序这两件事单独写个小测试跑通再往上搭业务逻辑省下的排查时间远比写测试多。希望帮到你。本文还有配套的精品资源点击获取