
简介C#编写的欧姆龙PLC通讯程序源码直接定位于工业自动化领域中需要与欧姆龙PLC进行数据交互的上位机开发者也适合作为PLC通讯协议学习的实战范例。程序基于HOST LINK协议以纯C#代码实现串口通讯无需安装第三方控件即可完成通讯测试、PLC工作模式设定、DM数据区字读写、IR区位的置位与复位以及位状态读取等操作覆盖了常见PLC调试和维护场景。资源包为RAR压缩包共27个文件主要包含C#窗体与逻辑源码、可编译执行的程序、依赖库、界面图片与资源文件等并附有Visual Studio解决方案和工程文件整体体积仅73KB轻量精简便于直接查看和改动。截至目前已有2317人学习下载。源码结构清晰主窗体、程序入口与项目配置分离具备完整的协议交互处理与操作界面适合欧姆龙PLC通讯入门学习、协议解析参考以及直接作为上位机项目二次开发的基础。 搞C#上位机开发的人迟早会遇到跟欧姆龙PLC通讯的需求。我之前在产线上做数据采集一台CP1H要把几十个工艺参数实时上抛MES还要支持配方下发。当时手头有现成组态软件但驱动授权贵得要命自定义逻辑也憋手最后干脆自己用C#基于FINS协议写了一套通讯程序源码。用下来反而比组态软件更可控后来从CP1H换到NJ系列只要改改地址映射就能复用。这篇就把FINS UDP通讯从协议原理、源码实现到踩坑经历一次说透给准备做欧姆龙上位机开发的同行做个参考。1. 通讯方案选型为什么我选了FINS UDP而不是TCP或OPC1.1 先看清欧姆龙通讯的四条路做欧姆龙PLC通讯方案其实不少但每条路的水深不一样。我先把常见方案对比列出来再讲我为什么最后锁定了FINS UDP方案实现成本实时性典型场景注意事项HostLink很低中RS232/422串口连接老设备帧结构简单但串口速率有限制FINS UDP低高单台上位机直连PLC无连接一问一答响应极快FINS TCP中高跨交换机、多客户端访问要处理拆包粘包、断线重连OPC/Sysmac Gateway高中多客户端同时访问、跨平台集成DCOM配置麻烦授权成本高HostLink如果走串口调试确实方便帧是ASCII字符抓到报文一眼能看懂。但波特率撑死115200数据量一大就吃力。OPC虽然标准化程度高现场部署DCOM权限问题能让人折腾一整天。FINS TCP要维护连接状态写断线重连逻辑费不少功夫。相比之下FINS UDP是真的省心。1.2 FINS UDP方案的两大核心优势第一个优势是响应快、无连接开销。UDP协议栈本身不维护状态发一个UDP包就是一次sendtoPLC收到后处理完直接回一个包。我的实测数据读32个DM字从发出到收到响应通常在2ms到5ms之间这个速度对看板刷新、数据采集完全够用。第二个优势是实现简单、排查容易。FINS UDP报文是二进制帧结构固定没有TCP那种粘包拆包的问题。每次请求的回应靠帧里的SID字段做匹配即可。代码写出来非常干净出问题也容易定位。1.3 什么情况下别用FINS UDPFINS UDP也不是万能。如果现场是跨三层交换机的大网络或者上位机要同时连多台PLC且对可靠性要求极高UDP丢包重试逻辑会变复杂。这种情况下FINS TCP更合适它有连接保活和重传机制。但对绝大多数“一台上位机管一台或几台PLC”的工控场景FINS UDP就是最优解不需要犹豫。如果你接触的PLC是老型号比如C200H系列CPU单元不带以太网口那只能用HostLink或者加一个以太网模块这时候HostLink反而是最简单直接的选择。2. FINS协议拆解帧头、命令码和地址换算2.1 十字节帧头每条报文的身份证FINS协议全称Factory Interface Network Service是欧姆龙自己的工厂接口网络服务协议。一条完整FINS UDP报文由两部分组成10字节固定帧头 N字节命令正文。帧头每个字节都有明确含义字节名称含义常用值0ICF信息控制字段0x80表示命令帧1RSV保留0x002GCT网关计数0x023DNA目标网络地址0x00本网络4DA1目标节点号PLC的FINS节点号5DA2目标单元号0x00CPU单元6SNA源网络地址0x007SA1源节点号上位机节点号8SA2源单元号0x009SID服务ID自增计数用于匹配响应注意一个关键点DA1填的是PLC的FINS节点号不是IP地址的最后一位。很多人第一次写FINS程序直接把IP末位填进去碰巧PLC节点号也是这个数才通否则必然收不到响应。我第一次调CP1H时就踩过这个坑PLC节点号在以太网设置里改成了3IP是192.168.1.10结果报文里DA1填0x0A整整排查了一下午。SID字段用来匹配请求和响应我用一个byte计数器每发一条命令自增一次。响应帧里的SID会原样回显上位机收到响应后按SID找对应的等待任务即可。2.2 命令码、内存区代码和地址换算FINS命令正文的第一部分是命令码两个字节。最常用的就两个功能命令码读内存区0x01 0x01写内存区0x01 0x02命令码后面跟操作数。以读DM区为例格式是内存区代码(1字节) 起始地址(2字节) 读取数量(2字节)内存区代码我整理了一下区域代码说明地址范围示例CP1HCIO区0x80输入输出及内部继电器CIO0 ~ CIO6143DM区0x82数据存储区掉电保持D0 ~ D32767WR区0xB1工作继电器W0 ~ W511HR区0xB2保持继电器H0 ~ H511TIM/CNT当前值0x89定时器/计数器当前值读取时另有类型区分地址是2字节直接填内存区的字地址。注意FINS的地址单位是“字”不是“字节”。比如D100十进制100换算成十六进制是0x0064高字节在前所以帧里填00 64。如果读D100开始10个字命令正文就是01 01 82 00 64 00 0A82是DM区代码00 64是D10000 0A是10个字。这个地址换算是新手最容易写错的地方D1000是0x03E8别写成0x1000。2.3 响应帧怎么解析响应帧的前10字节帧头和请求帧一样SID回显请求值。第10、11字节回显命令码第12、13字节是MRES即主错误码和子错误码。00 00表示正常完成非零值就是报错。响应示例读10个字成功80 00 02 00 01 00 00 63 00 05 01 01 00 00 20字节数据第14字节开始才是真正要读的数据。注意如果主错误码非零比如11 01表示命令长度不对11 03表示命令格式错误数据段就没有了。错误码是调试时的第一线索我后面会专门列一个速查表。3. C#源码实现从零搭一个能用的FINS客户端3.1 源码结构规划写通讯源码最重要的是层次清楚别一股脑塞一个类里。我的项目结构是这样FinsClient/ ├── FinsClient.cs // 核心通讯类连发报文、收响应、超时控制 ├── FinsCommand.cs // 命令构造读/写命令组包 ├── FinsResponse.cs // 响应模型Success、错误码、Data ├── FinsDataUtil.cs // 字节序转换工具 └── Demo/ └── MainForm.cs // WinForms示例读D区、写D区、读CIOFinsClient只负责一件事把命令发出去把响应收回来。组包逻辑放FinsCommand解析逻辑在FinsResponse里做。这样以后换PLC型号或加协议不会牵一发动全身。3.2 核心代码组包、发送、解析三段关键实现先看组包。读取10个字的命令用C#写出来是这样public byte[] BuildReadCommand(byte memoryCode, int address, int count) { byte[] cmd new byte[17]; // 帧头10字节 cmd[0] 0x80; cmd[1] 0x00; cmd[2] 0x02; cmd[3] 0x00; cmd[4] (byte)_plcNode; cmd[5] 0x00; cmd[6] 0x00; cmd[7] (byte)_localNode; cmd[8] 0x00; cmd[9] _sid; // 命令正文读命令 cmd[10] 0x01; cmd[11] 0x01; cmd[12] memoryCode; cmd[13] (byte)(address 8); cmd[14] (byte)(address 0xFF); cmd[15] (byte)(count 8); cmd[16] (byte)(count 0xFF); return cmd; }这里_sid在组包时就完成了自增SID从0开始到255后溢出归零循环用没问题。_plcNode和_localNode在构造函数里传入PLC节点号默认1上位机节点号我习惯设为990x63只要和PLC不同即可。写命令类似就是命令码变成0x01 0x02后面多跟一段数据public byte[] BuildWriteCommand(byte memoryCode, int address, int count, byte[] data) { byte[] cmd new byte[17 data.Length]; // 帧头10字节同上 cmd[10] 0x01; cmd[11] 0x02; cmd[12] memoryCode; cmd[13] (byte)(address 8); cmd[14] (byte)(address 0xFF); cmd[15] (byte)(count 8); cmd[16] (byte)(count 0xFF); Array.Copy(data, 0, cmd, 17, data.Length); return cmd; }数据长度必须等于count * 2。比如写D100开始2个字count2data必须是4字节。3.3 线程模型为什么必须用后台接收线程这是整个源码最关键的设计点。很多人写UDP通讯会这样做发一条命令然后直接Receive等响应。在单次请求下没问题但一旦上位机要轮询、要并发操作这种写法就直接卡死或者响应串包。我的做法是启动一个常驻后台接收线程所有响应统一进字典按SID匹配请求private ConcurrentDictionarybyte, TaskCompletionSourcebyte[] _pending new(); private async Task ReceiveLoopAsync() { while (!_cts.IsCancellationRequested) { UdpReceiveResult result await _udp.ReceiveAsync(); byte sid result.Buffer[9]; if (_pending.TryRemove(sid, out var tcs)) tcs.TrySetResult(result.Buffer); } }发送请求时创建一个TaskCompletionSource注册到_pending然后等待private async TaskFinsResponse SendAsync(byte[] command, int timeout 1000) { byte sid command[9]; var tcs new TaskCompletionSourcebyte[](TaskCreationOptions.RunContinuationsAsynchronously); _pending[sid] tcs; await _udp.SendAsync(command, command.Length); var completed await Task.WhenAny(tcs.Task, Task.Delay(timeout)); if (completed ! tcs.Task) { _pending.TryRemove(sid, out _); throw new TimeoutException($PLC响应超时SID0x{sid:X2}); } return ParseResponse(await tcs.Task); }这个模式的好处是无论多少个地方同时发请求SID唯一匹配响应自动回到对应请求方。UI线程只发请求并await完全不阻塞界面。UI卡死是WinForms上位机最常被吐槽的问题这个模型从根上解决了。3.4 字节序转换FINS是纯大端FINS协议是大端字节序高位在前。Windows的BitConverter默认用小端序直接转Int16会得到反过来的结果这是不少人读数据读成乱码的根本原因。我写了一个单独的工具类处理public static short ToInt16(byte[] data, int offset) { return (short)((data[offset] 8) | data[offset 1]); } public static int ToInt32(byte[] data, int offset) { return (data[offset] 24) | (data[offset 1] 16) | (data[offset 2] 8) | data[offset 3]; } public static byte[] FromInt16(short value) { return new byte[] { (byte)(value 8), (byte)(value 0xFF) }; }如果需要读浮点数欧姆龙存储格式是IEEE 7542个字4字节大端顺序。转换时先按大端拼成4字节再转成float即可。我用BitConverter.ToSingle之后做了字节反转或者干脆自己把4字节按大端移位组合实测都没问题。4. 实操中的坑和排查技巧实录4.1 PLC侧配置三个地方必须设对源码写得再对PLC侧配置不对一样白搭。我每次换项目第一件事就是检查PLC的这三个设置第一是IP地址。CP1H如果用的是内置以太网口在CX-Programmer的“设置-内置以太网端口”里配IP。CP2E则是在“内置以太网端口设置”里配。IP必须和上位机在同一网段子网掩码要一致。第二是FINS节点号。这个字段在同一个设置页面里默认是1。上位机报文里的DA1必须和这个值一致。注意节点号和IP末位没有必然关系不要想当然。第三是UDP端口。FINS UDP的标准端口是9600这个一般不用改。上位机防火墙要放行UDP 9600入站很多工控电脑装了安全软件默认就把这个端口挡了表现为“上位机能ping通PLC但报文就是没响应”。以CP1H为例设置好之后还要把设置传送到PLC并且断电重启才会生效。这个不生效的问题我见过好几个人卡了一下午。4.2 常见问题速查表我把实际调试过程中遇到的高频问题整理成一张表按这张表排查大部分问题能在一小时内定位现象可能原因排查方法收不到任何响应IP不通、节点号不一致、端口被防火墙拦截ping测试核对PLC节点号放行UDP 9600返回错误码 0x1101报文长度不对数一遍报文总字节数确认和命令类型匹配返回错误码 0x1103地址或数量超范围确认内存区代码、地址范围、读取数量是否合理返回错误码 0x1102数据格式错误写命令时检查数据长度是否等于count*2数据读回来了但全是乱码字节序没处理用大端方式解析不要直接用BitConverter偶尔超时重试后恢复轮询频率过高PLC扫描周期被影响降低轮询频率把高频读改成批量读错误码里0x1101是我早期写程序常碰到的。有一次写命令数据长度算错了一位FINS帧总长度多了一个字节PLC直接回0x1101。这个错误码的含义就是“你这帧长度不对我按协议解析不了”。事后我把BuildWriteCommand改成用17 data.Length动态计算长度再没犯过这个错。4.3 性能经验轮询频率和批量读的取舍上位机轮询PLC最忌讳一个循环里把所有地址都读一遍。我第一版程序就是每500ms读一次D区、一次CIO、一次HR结果PLC整个扫描周期都被通讯任务占掉运动控制偶尔出现不稳定的情况。后来改成分组轮询高频数据产量、报警每300ms读一次低频数据配方参数每2秒读一次配置类数据只在启动时读一次。同时把相邻地址合并成批量读比如D100到D150不用发50次请求一次读50个字性能提升非常明显。修改之后PLC的CPU占用率明显下降运动控制再没出过问题。通讯频率不是越快越好够用就好这个原则在工控现场永远有效。5. 源码的复用与扩展方向5.1 从读DM扩展到CIO、HR、WR这套FinsClient的底层命令构造不区分内存区只要换内存区代码就能读所有区。读CIO区内存区代码填0x80读HR区填0xB2。地址换算逻辑完全一样。比如读CIO100的8个字var resp await client.ReadWordsAsync(0x80, 100, 8);有了这个基础后面写“强制置位输出点”也不难往CIO区指定地址写数据即可。但要注意CIO区里包含物理I/O点强制写要小心别把现场设备搞出安全事故。我在测试机上玩过真机上从不敢乱写。5.2 上位机常用扩展MES对接和报警记录通讯这层稳定之后上层业务就自由了。我这套FinsClient后来接了好几套上位机应用数据采集看板把PLC的DM区数据映射到数据库用WinForms绑定Chart控件画曲线现场看趋势非常直观。配方下发界面录入配方参数点保存后批量写入PLC的HR区PLC重启数据也不丢。报警管理PLC里维护一个报警字数组上位机轮询到非零值就解析出报警信息和时间写进日志表。这些功能都是在FinsClient之上叠业务逻辑通讯层本身几乎没有改动。这也验证了当初分层设计的好处——协议处理和业务逻辑解耦复用时才省力。5.3 跨型号迁移从CP1H到NJ的适配经验后来有个项目用了欧姆龙NJ501我一度担心通讯代码要重写。实际发现NJ的EtherNet/IP端口也支持FINS协议只是要确认PLC侧“FINS服务”是否启用。启用后IP、节点号、端口配置好上位机代码几乎原封不动跑通。差异主要体现在地址映射上。NJ没有DM区的概念取而代之的是全局变量区FINS访问时需要用变量对应的内存地址。这块需要结合Sysmac Studio里的变量地址表来配置。所以我在代码里把内存区代码和地址映射做成了配置项换PLC型号时只改配置不碰通讯核心。如果你准备把C#上位机技能深入下去我强烈建议先把FINS协议吃透。欧姆龙全系PLC的以太网通讯基本都是这个套路一套代码吃遍好几个系列这个投入非常值。最后再分享一个实操小技巧。刚开始调FINS通讯不用急着接真机可以用欧姆龙官方模拟器或者一个简单的UDP回显工具先验证报文的组包逻辑。等报文格式确认无误再接PLC联调一次成功率非常高。我后来每换一个PLC型号都是先用模拟器把报文验证一遍再上产线省掉了大量现场调试时间。本文还有配套的精品资源点击获取