C#通过FINS协议读取欧姆龙PLC内存区:UDP报文构建与解析实战

📅 发布时间:2026/10/17 8:32:28
C#通过FINS协议读取欧姆龙PLC内存区:UDP报文构建与解析实战
简介面向需要借助C#与欧姆龙可编程控制器联调的上位机程序员和工业自动化工程师以FINS协议通信为切入点从TCP连接建立讲起完整演示了如何利用System.Net.Sockets类库中的TcpClient和NetworkStream连接可编程控制器并构造FINS请求帧、解析响应数据、读写寄存器等常见操作配套教学课件、Windows窗体示例源码和通讯手册可直接对照修改使用。包内共45个文件包含9个C#源文件、4个exe可执行程序、通讯手册PDF、Word直播素材、网络模型图与协议格式图等既能看到实现代码也有可运行的Demo和辅助调试的NetAssist测试工具整体仅4.38MB查阅成本低。已有1117人学习下载。内容覆盖连接建立、FINS命令格式、错误处理与多寄存器读写等细节配合目录中清晰的源码工程、代码文本和课件图片能帮助读者快速绕过协议文档的晦涩之处掌握一套可直接落地的C#与欧姆龙PLC通信方案。1. C#通过FINS读取欧姆龙PLC协议例子先从一个采集需求说起某产线的上位机项目需要在C#端通过FINS协议读取欧姆龙PLC的DM区数据用来实时显示温度、压力和运行状态。设备那边没有采购现成的通信组件项目进度又不允许引入新的依赖所以只能在基础Socket上把FINS报文直接拼出来。你会发现这个需求很典型不复杂但帧头、节点号、内存区地址、大端字节序这些细节里全是坑。真正动手时最容易被绊倒的不是C#语法而是报文里那一堆字节到底该填什么。这套方案适合正在做上位机与欧姆龙PLC通信课设或毕设的同学也适合需要快速交付采集功能的工程师参考。下面从协议结构、C#实现、常见排查、批量轮询到验收答辩完整展开目标是让你照着一份代码就能把FINS通信跑起来并且知道出了问题该往哪里看。2. FINS协议基础与报文结构帧头10字节、命令码和内存区代码怎么定2.1 FINS/UDP通信的选型为什么先从UDP端口9600入手欧姆龙PLC的以太网通信支持FINS/TCP和FINS/UDP两种承载方式。多数PLC默认开放的端口都是9600但UDP实现更轻不需要建立连接每次调用就是一个完整的请求-响应过程。在C#里UdpClient发送一条请求后阻塞等待接收响应单条请求的语义非常清楚。我一般在项目初期先用UDP打通链路原因有两个。第一UDP调试简单一条Send和一条Receive就能完成一次通信出问题时可以快速用抓包工具确认PLC有没有应答。第二上位机采集场景基本都是周期性轮询每次请求独立UDP天然契合这种模式TCP反而要处理连接断开、粘包、心跳在PLC主动断电或网线松动时会增加很多边界逻辑。需要说明的是UDP模式下PLC不会对每条报文做传输层确认所以应用层的超时重试必须自己负责。也就是说Send之后不一定能等到Receive可能丢包也可能PLC根本就没收到。这个问题在第四章会展开讲。如果你最终要面对的是多台上位机同时读取一台PLCFINS协议本身没有复杂的会话管理UDP也够用。只要保证每台上位机的源节点号SA1不同PLC就能区分请求来源。我曾经在一条产线上用三台工控机同时采集同一台PLC全部走UDP没有冲突。这里还要区分一个概念FINS/UDP的报文在以太网上不需要额外校验字节因为IP包自带校验。做TCP方案时反而要在FINS报文前面加4字节长度前缀多一个粘包拆包的步骤。所以从“快速跑通”的角度看UDP是成本最低的切入点。2.2 报文结构ICF、DA1、SA1这些字节到底填什么FINS报文头和命令体的关系可以用一张字节表讲清楚。发送一条内存区读命令时前10个字节是固定格式的报文头后面紧跟命令码和参数。字节位置字段含义一般取值0ICF信息控制标志0x80表示需要响应1RSV保留0x002GCT网关数0x023DNA目标网络号0x00本网络4DA1目标节点号PLC的FINS节点号5DA2目标单元号0x00CPU单元6SNA源网络号0x007SA1源节点号本机FINS节点号8SA2源单元号0x009SID服务标识每次请求自增或随机这里最容易被忽略的是DA1和SA1。FINS网络中的“节点号”不是IP地址而是PLC通信设置里的FINS节点编号。默认情况下欧姆龙PLC会自动把节点号设置为IP地址的最后一段例如192.168.250.1对应节点号1。如果PLC手动改过节点号报文里的DA1也必须跟着改否则PLC会直接丢弃请求。ICF填0x80表示“本机请求需要响应”。如果填0x00PLC可能不回包。SID的作用是匹配请求和响应在异步场景下如果同时发多条命令靠SID区分是哪一条的应答。即使在同步轮询场景里SID每次加一也能避免旧响应干扰新请求。有个细节值得注意FINS节点号范围是1到2540和255有特殊含义。如果你把DA1填成255在某些PLC上会被当作广播可能导致多个单元同时响应。为避免这种混乱单机通信时DA1就老老实实地填PLC的实际节点号。2.3 内存区读取命令0101DM区、CIO区、WR区的代码对照命令码0101是“内存区读”它的参数由四个部分组成内存区代码、起始地址2字节、起始位地址、读取数量2字节。起始位地址在读字数据时填0只有读位数据时才需要指定位号。常用内存区代码对照如下内存区代码十六进制地址说明CIO区0xB0按字地址直接填WR区0xB1W0对应地址0HR区0xB2H0对应地址0DM区0x82D0对应地址0D100对应地址100有一个常见的困惑PLC软件里显示的D100FINS命令里的起始地址到底填99还是100答案是不加偏移的100也就是0x0064。欧姆龙的DM区地址在FINS协议里就是从0开始按字编号的D100就是地址100不需要做减一操作。如果你看到别人代码里减一那可能是他读取的是某个具体通道映射不是通用的FINS编址。这里再补一个计算示例读D100到D104连续5个字那么内存区代码0x82起始地址高字节0x00、低字节0x64位地址0x00读取数量高字节0x00、低字节0x05。整个参数段是82 00 64 00 00 05前面加命令码01 01再加10字节帧头就是一条完整的请求。如果读CIO区地址按通道号直接填。例如CIO100地址就是100转成十六进制0x0064。WR区里的W0对应地址0W10对应地址10。HR区同理。这些区分在跨PLC类型移植时特别重要不能因为都是“一个字地址”就混用代码。FINS命令还有一个“内存区读”的约束单次请求读取的字数上限通常是999个字位上限是1999个位。如果你的采集点位很多后面第五章会讲怎么分批。总之协议层的结构就这么多接下来看C#怎么把这些字节组织起来。3. C#实现FINS读取示例从构建帧到解析响应3.1 类结构与连接初始化UdpClient和超时设计一个OmronFinsClient类把所有协议细节收在类内部。外部调用方只需要传入IP、端口和节点号不需要知道报文怎么拼。构造函数里创建UdpClient并连接目标设置ReceiveTimeout这样Receive超时会抛异常方便统一处理重试。using System; using System.Net; using System.Net.Sockets; public class OmronFinsClient { private readonly UdpClient _udp; private readonly byte _sa1; private readonly byte _da1; private byte _sid; public OmronFinsClient(string ip, int port, byte da1, byte sa1) { _udp new UdpClient(); _udp.Client.ReceiveTimeout 2000; _udp.Connect(ip, port); _da1 da1; _sa1 sa1; _sid 0; } public void Close() { _udp?.Close(); } }参数说明ip是PLC的IP地址port默认9600和PLC以太网单元设置一致da1是PLC的FINS节点号通常等于IP最后一段sa1是本机节点号需要和PLC侧的“允许通信节点”设置保持一致。ReceiveTimeout单位是毫秒这里给了2000实际项目中建议按PLC响应能力调整到1000至3000毫秒之间。初始化之后不要立刻关闭Socket同一个UdpClient可以重复收发。UdpClient.Connect的意义在于后面Send和Receive不用每次传远程地址这在单目标PLC的场景下代码更简洁。如果以后要支持多台PLC建议一个PLC对应一个客户端实例避免地址混淆。这里有一个容易忽视的问题ReceiveTimeout只对同步Receive方法生效而且一旦超时会抛SocketException。如果你在异步方法里用ReceiveAsync这个超时设置就不生效了。所以在采集线程里我倾向于用同步Receive加外层循环代码清晰且好控制重试。3.2 构建读取DM区的请求帧字节填充与端序读取DM区指定起始地址和数量的方法核心是构建18字节的请求帧。代码里我把帧头、命令码和参数逐字节写出注释标明每个字段的位置。public byte[] BuildReadDmFrame(ushort startAddress, ushort count) { byte[] frame new byte[18]; frame[0] 0x80; // ICF请求需要响应 frame[1] 0x00; // RSV 保留 frame[2] 0x02; // GCT 网关默认 frame[3] 0x00; // DNA 目标网络号 frame[4] _da1; // DA1 目标节点号 frame[5] 0x00; // DA2 目标单元号 frame[6] 0x00; // SNA 源网络号 frame[7] _sa1; // SA1 源节点号 frame[8] 0x00; // SA2 源单元号 frame[9] _sid; // SID 服务标识 frame[10] 0x01; // 命令码 0101内存区读 frame[11] 0x01; frame[12] 0x82; // 内存区代码DM区 frame[13] (byte)(startAddress 8); // 起始地址高字节 frame[14] (byte)(startAddress 0xFF); // 起始地址低字节 frame[15] 0x00; // 位地址字读取填0 frame[16] (byte)(count 8); // 读取数量高字节 frame[17] (byte)(count 0xFF); // 读取数量低字节 return frame; }帧总长固定18字节原因前面说过10字节头加8字节命令参数。注意起始地址和数量都用大端序也就是高字节在前。C#里直接强转低字节会得到相反顺序所以务必用移位和与运算拆开高位和低位。这段代码只负责构建帧实际发送由调用方完成。我习惯把“构建帧”和“发送接收”分开这样以后要加日志、加多内存区支持时不需要改动帧拼装逻辑。如果你把帧构建和收发写在一个方法里将来加协议扩展会越改越乱。如果要读取CIO区或WR区只需要把frame[12]换成对应的内存区代码地址仍按字编号填。为了方便复用可以把这个方法改成接收一个内存区代码参数而不是硬编码0x82。下面这个重载就是通用版本public byte[] BuildReadFrame(byte areaCode, ushort startAddress, ushort count) { byte[] frame new byte[18]; frame[0] 0x80; frame[1] 0x00; frame[2] 0x02; frame[3] 0x00; frame[4] _da1; frame[5] 0x00; frame[6] 0x00; frame[7] _sa1; frame[8] 0x00; frame[9] _sid; frame[10] 0x01; frame[11] 0x01; frame[12] areaCode; frame[13] (byte)(startAddress 8); frame[14] (byte)(startAddress 0xFF); frame[15] 0x00; frame[16] (byte)(count 8); frame[17] (byte)(count 0xFF); return frame; }这样ReadDm就变成了BuildReadFrame(0x82, startAddress, count)的一个特例。以后项目里要读W区、H区不需要新写方法传参就能解决。这也是方案里可复用的设计点。3.3 响应解析完成码、数据区、Int16转换发送请求后同步等待响应。响应帧的前10字节头部与请求一致第10、11字节是命令码第12、13字节是完成码第14字节开始才是真正的数据。public short[] ReadDmValues(ushort startAddress, ushort count) { byte[] request BuildReadDmFrame(startAddress, count); _udp.Send(request, request.Length); IPEndPoint remote new IPEndPoint(IPAddress.Any, 0); byte[] response _udp.Receive(ref remote); if (response[12] ! 0x00 || response[13] ! 0x00) { int errCode response[12] * 256 response[13]; throw new Exception($FINS响应错误完成码0x{errCode:X4}); } int dataOffset 14; short[] values new short[count]; for (int i 0; i count; i) { int high response[dataOffset i * 2]; int low response[dataOffset i * 2 1]; values[i] (short)(high * 256 low); } return values; }这里有一个容易踩坑的点响应中的数据是大端序一个字是两个字节高字节在前。直接用BitConverter.ToInt16会得到小端结果必须自己组合。如果你读取的是无符号数可以用ushort接收但欧姆龙PLC的DM区也可以存放负数所以用short更通用由用户按实际数据类型解释。响应帧的长度不是固定的它会随读取数量变化。读取5个字时响应总长度是10字节头2字节命令码2字节完成码10字节数据一共24字节。解析时不能假设固定长度而是根据count计算数据部分长度。完成码的常见情况不止0x0000一种。比如0x0105表示地址越界0x1101表示内存区代码非法0x0101表示目标节点不存在。排查时把完成码转成十六进制打印出来对照手册里的错误码表比逐字节猜要快得多。还有一个细节UDP层的Receive返回的字节数组长度并不一定等于响应帧长度。如果网络环境比较差可能会收到被分片重组的包但IP层会保证重组所以应用层看到的通常是一整个UDP数据报。不需要自己做粘包处理这是UDP相对TCP省事的地方。3.4 写一个Demo连续读取多组数据并显示用一个控制台示例把整个过程串起来连接PLC、读取D100开始的10个字、打印到屏幕。using System; class Program { static void Main() { OmronFinsClient client new OmronFinsClient( 192.168.250.1, 9600, da1: 1, sa1: 2); try { short[] values client.ReadDmValues(100, 10); for (int i 0; i values.Length; i) { Console.WriteLine($D{100 i} {values[i]}); } } catch (Exception ex) { Console.WriteLine($读取失败{ex.Message}); } finally { client.Close(); } } }实际项目里不会只在Main里调用一次而是启动一个后台采集线程按固定周期读取。这时要注意UdpClient的同步Receive会阻塞线程超时是按ReceiveTimeout内部计时不会因为外层循环而失效。每轮读取之间加一个Sleep或者用Timer控制节奏避免把PLC请求频率拉太高。这个Demo可以作为最小可运行例子放到课设的“核心功能实现”章节里配合抓包截图展示请求和响应。它虽然简单但已经把“请求帧构建-发送-接收-解析-展示”的完整链路打通了。后面的问题排查和批量扩展都建立在它之上。如果你还要写“内存区写入”功能命令码改成0102参数换成写入数据和数量就行结构很接近。很多课设会要求既能读也能写建议把读写两个方法一起封装这样演示时可以先用写入功能给DM区置数再从界面读出来验证。4. 常见坑与排查连不上、读错数、数据全零的几种翻车现场4.1 现象UDP请求发出后总超时PLC没有任何响应这是最常见的问题。PLC IP能ping通但FINS请求石沉大海。原因多半是DA1节点号或ICF设置不对。我在一个项目里遇到过PLC的FINS节点号被手动改成了10而报文里仍然填IP最后一位1结果PLC根本不认这个帧。解决先用PLC编程软件读出通信设置确认FINS节点号到底是多少。报文头里DA1必须填PLC设置里的实际节点号。还有一个排查技巧用抓包工具过滤PLC的IP和9600端口看请求有没有到达以及PLC有没有回包。如果只看得到请求看不到响应基本就是节点号或协议参数问题。4.2 现象能收到响应但完成码是0x1101或0x01050x1101表示内存区代码错误0x0105表示起始地址或数量越界。出现这两个错误先检查参数拼装顺序。比如内存区代码位置是frame[12]如果你把地址高位放到frame[12]PLC会认为内存区代码不对。解决写一个打印帧内容的调试工具把请求帧逐字节以十六进制打印出来对照手册逐字节检查。另外读取数量不能超过FINS协议限制字数据一次最多999个字超过会报地址越界。数量字段是两个字节03E7就是999别填到0x04D2这种超限值。4.3 现象完成码为0但读回来的数据全是0或明显错位这通常是地址换算问题。如果你把PLC里的D100对应成FINS地址99读回来的就是D99之前的数据很可能是0或者其他通道的值。我一般会用PLC软件往D100写入一个已知值比如12345然后上位机去读看是否对得上。解决明确DM区地址直接用D编号D100就是100不要减一。如果是其他区比如W区直接按字编号填。CIO区如果读取位还要注意位地址那一字节的编码按手册来。数据错位还有一个原因响应数据偏移算错。记住数据从第14字节开始不是第15字节这个最容易看走眼。4.4 现象偶尔超时重试后又能成功UDP本身不可靠加上现场网络有交换机广播风暴或带宽占用会导致丢包。不能用一次性超时判断通信故障。PLC扫描周期太忙时FINS响应也可能变慢甚至超过你设的2秒超时。解决封装一个Retry方法发送失败或超时后重试两三次重试间隔建议300到500毫秒。重试时SID要重新生成避免PLC把重试报文当成重复帧。同时在代码里统计超时次数连续失败超过比如5次再报通信故障触发上位机告警而不是第一次失败就弹窗。4.5 现象同一个UdpClient被多个线程同时调用响应错乱我见过把读取函数丢到后台线程又在UI按钮点击时直接调两个线程同时发请求导致接收到的响应可能不是当前请求的。FINS协议本身没有请求关联机制全靠SID匹配但同步接收模式下先发出的请求先等待后发出的请求可能先收到响应。解决把读取操作锁住或者用一个独立的采集线程串行发送请求禁止多线程直接读写同一个UdpClient。如果一定要并发就为每个线程创建独立连接或者改用异步发送并用SID做字典匹配响应。简单方案永远是串行吞吐量不够时再加并发。5. 扩展设计从单个读取到轮询采集与多区批量读取5.1 轮询调度多台PLC或多种内存区如何排队真正交付的采集程序不是只读一次D100而是要持续读多个区域、多台PLC。我一般会给每个PLC实例建一个请求队列后台线程从队列里取请求发送后等待响应超时重试完成后再取下一条。using System.Threading.Channels; public class PollingWorker { private readonly ChannelFuncbyte[] _queue; private readonly CancellationTokenSource _cts new(); public PollingWorker(int capacity 1000) { _queue Channel.CreateBoundedFuncbyte[](new BoundedChannelOptions(capacity) { FullMode BoundedChannelFullMode.Wait }); } public async Task StartAsync(FuncFuncbyte[], Task handler, CancellationToken token) { await foreach (var task in _queue.Reader.ReadAllAsync(token)) { await handler(task); } } public void Enqueue(Funcbyte[] task) { _queue.Writer.TryWrite(task); } }用C#的Channel 比直接用Queue加锁更干净。消费端从Channel读出一个任务执行请求再把结果交给结果处理器。这样生产者只需要关心“要读什么”不需要知道底层Socket什么时候能响应。FullMode设为Wait队列满时生产方会等待避免内存无限增长。消费端串行处理的优点是天然保证同一个UdpClient不会被并发调用彻底避免第四章说的响应错乱。如果只有一台PLC一个消费线程就足够如果有多台PLC可以每台PLC启动一个PollingWorker各自持有一个UdpClient实例。5.2 批量读取一次读多个字还是逐字读的效率对比逐字读简单但每读一个字都要走一次UDP请求-响应假设一次往返20毫秒读100个字要2秒。而一次读取100个字总耗时依然是一次往返加解析。所以批量读是必须的。欧姆龙FINS内存区读命令对字数据单次最多可以读999个字位数据最多可读1999个位。但建议每包不要超过512个字避免PLC扫描周期受影响。如果目标数据是连续的DM区段就在一个请求里读完如果不连续考虑读一个大区间然后本地切片前提是区间内没有不希望访问的特殊地址。批量读取对帧构造没有额外要求只要把count参数改大。解析时仍然按每2字节一个字去切。实测中一次读200个字和读10个字的时间几乎一样按这个特性设计采集周期可以把CPU和网络开销降得很低。在实际项目里我会把点位表按内存区合并成读取批次。比如DM区从D100到D500是一段连续工艺参数那就合并成一个批次数量401个字但D1000到D1010是一段报警区单独一个批次。合并规则通常是“连续地址且同一内存区”减少请求数量但也要避免把一个跨越特殊地址的大区间整体读回来造成不必要的网络流量。5.3 与上位机展示层对接并发安全与数据刷新读取结果不能直接丢给UI线程。桌面程序里后台线程修改控件会抛跨线程异常Web服务里直接把数据对象放到静态字典也会产生并发问题。我通常在采集线程和展示层之间放一个ConcurrentDictionary以“区名地址”为键保存最新值。var latestValues new ConcurrentDictionarystring, DataPoint(); // 采集线程写入 latestValues[DM100] new DataPoint { Value 12345, Timestamp DateTime.Now }; // UI线程读取 if (latestValues.TryGetValue(DM100, out var point)) { textBox.Text point.Value.ToString(); }展示层定时器每隔几百毫秒去字典里取最新值刷新界面不关心采集线程什么时候更新。这样即使PLC暂时无响应界面也不会闪断最多显示上一次的值。如果业务上要求“数据新鲜度”可以在值对象里加时间戳展示层超过阈值标记灰色。这种设计与FINS协议本身解耦无论读DM区还是CIO区只需要保证写入字典时使用统一的键格式。以后增加报警判断、历史存储也都从这份字典取数不需要改动通信层。5.4 点位表配置把地址写进配置文件而不是写死在代码里课设里把点位写死在代码里没问题但交付给现场的项目点位变更很频繁。我一般会把点位表放到一个JSON文件里每一条包含内存区、起始地址、长度、数据类型和描述。[ { Name: 温度, Area: DM, StartAddress: 100, Count: 1, DataType: Int16 }, { Name: 压力批次, Area: DM, StartAddress: 101, Count: 20, DataType: Int16 } ]程序启动时加载这个配置根据Area映射内存区代码根据StartAddress和Count计算读取批次。这样做的好处是现场增减点位不需要改代码重新编译只需要编辑JSON并重启采集服务。答辩时可以强调这个设计提升了系统的可维护性也更容易让评审理解你的工程化意识。6. 验收与答辩怎么证明这套FINS通信是可靠的验收不能只看“能读到数”。我会把验收分成三层功能正确性、稳定性、异常恢复。功能正确性测试用例可以用一张表列出来测试项操作预期结果DM区读取PLC写入已知值0x1234上位机读取D100返回4660连续读取循环读取100次每次间隔200ms全部成功无错包地址越界请求读取超出DM区范围地址返回0x0105错误码断网恢复拔掉网线5秒后恢复重试成功数据恢复更新稳定性方面我要求至少连续运行8小时统计丢包率和请求成功率。用代码计数器记录发送次数和超时次数正常情况下网线不插拔成功率应该在99.9%以上。如果高于万分之一的丢包就要排查网络设备或PLC负载。这个数据可以直接写进课设报告比“测试正常”四个字有说服力。异常恢复最容易被忽略。在验收脚本里模拟PLC重启读取过程中切断PLC电源等待PLC重新上线后采集程序要在没有人工干预的情况下自动恢复。这里的关键是重试逻辑不能因为连续失败就退出线程而要在失败时保持连接对象成功后自动恢复。我在实际项目中就吃过这个亏第一次验收时PLC一断电重启采集进程直接崩了后来才加重试和异常捕获。答辩时有一个很加分的技巧准备一张Wireshark抓包截图展示请求帧和响应帧的十六进制对照。你可以指着帧里的DA1和SA1说“这里目标节点号是1源节点号是2响应帧的完成码是0数据区10个字和PLC里一致”这比口头说“我用了FINS协议”有说服力得多。另一个答辩切入点是“为什么自己拼协议而不是用现成库”。可以从无外部依赖、可控性好、数据可追溯三个角度讲同时承认UDP丢包需要重试体现你清楚它的边界。导师通常喜欢听你分析协议字段和异常场景而不是听你描述写了多少行代码。从那以后我每次做PLC通信都会先在PLC里写一组已知数值再开始写上位机解析代码。先证明帧格式对再谈业务功能能少走很多弯路。希望帮到你。本文还有配套的精品资源点击获取