FEATURED · 精选文章

C#欧姆龙PLC通讯实战:FINS/TCP帧协议与上位机轮询优化

发布时间 / 2026/9/16 2:42:55
来源 / 创域科博编辑部
栏目 / 资讯中心
C#欧姆龙PLC通讯实战:FINS/TCP帧协议与上位机轮询优化 简介这套C#与欧姆龙PLC通讯程序教程资源面向工业自动化开发者和C#初学者主要解决如何通过串口或以太网实现上位机与欧姆龙PLC的数据交互问题。资源涵盖FINS协议解析、串口通信参数配置、地址映射、数据转换及异常处理等关键知识点并提供完整可运行的示例项目。压缩包共26个文件大小仅84KB以C#源码.cs、Visual Studio解决方案.sln/.csproj、可执行文件.exe及动态链接库.dll为主另含界面图片和资源文件结构清晰便于对照学习。目前已有799人学习下载适合希望快速上手欧姆龙PLC通讯开发的读者。通过源码和工程文件可以直观理解FINS帧结构、读写命令和异步编程实现为实际项目中的PLC集成打下基础。1. C#写欧姆龙PLC通讯程序卡住的往往不是Socket而是FINS帧很多人以为用C#写欧姆龙PLC通讯程序就是TcpClient连上9600端口发几个十六进制字节过去数据就能回来。真去连的时候才发现握手那一步就卡住了Socket连接是成功的但PLC要么不回包要么回一个0x1101这类看不懂的结束码。问题多半不在网络而在你还没理解欧姆龙自成一体的FINS帧协议连接前要发打开命令地址要按字和位换算响应里命令码和结束码要对得上少一个字节都不行。这篇C#教程面向正在写上位机、MES对接、产线数据采集的开发者目标是让你用C#写出一套能连上、能读写、轮询不卡界面、断线能自动重连的欧姆龙PLC通讯程序。下面从协议选型讲起每段代码都能直接落进WinForms或WPF项目里跑。2. 别急着写Socket先分清欧姆龙PLC的HostLink和FINS/TCP2.1 为什么C#上位机默认选FINS/TCP而不是串口HostLink欧姆龙PLC对外通讯有两条主流路线老一代串口用的HostLink协议以及现代以太网口默认支持的FINS/TCP协议。C#上位机项目绝大多数对接的是CP1H、CJ2M、CS1、NJ/NX这些带内置以太网口的型号所以首选FINS/TCP而不是拼ASCII字符串的HostLink。选错协议的表现很典型代码里在拼RD0001000002这类字符串但对面PLC的以太网口根本不识别。两类协议的核心差别决定了实现方式完全不同对比项HostLink串口FINS/TCP以太网物理链路RS-232/485传输距离和速率受限以太网可跨交换机支持多客户端数据格式ASCII字符串加帧校验码二进制帧16位字为单位通道能力一问一答半双工长连接全双工支持多路请求适用型号C200H、CQM1H等老PLC或经济型配置CP1H、CJ2M、CS1、NJ/NX等带网口型号C#实现成本字符串拼接加FCS校验计算帧构造加字节解析逻辑更固定还有一个现实因素HostLink通过串口做二进制数据交互时每次读写都要处理ASCII和十六进制互转数据量大一点代码就变得很啰嗦。FINS/TCP虽然帧构造看起来复杂但内存区、地址、数据长度都是固定字节序封装一次之后换PLC型号基本不用改协议层。提示如果PLC侧工程师已经把某个欧姆龙机型配成了Modbus-TCP ServerC#里直接引NModbus4这类现成库也能跑。但那属于PLC侧特殊配置大多数欧姆龙机型的默认工业协议仍然是FINS所以这篇按原生FINS帧来写。2.2 FINS/TCP帧结构18字节协议头加命令区FINS/TCP不是裸TCP发命令它外面包了一层固定协议头。整个请求帧从第0字节开始到第17字节是固定头第18字节之后才是FINS命令区。我一般这样记这18个字节// FINS/TCP 固定协议头每个数组元素占 1 字节 byte[] head new byte[18]; head[0] 0x46; head[1] 0x49; head[2] 0x4E; head[3] 0x53; // FINS // head[4..7] 是后续所有字节的总长度按大端填充 head[8] 0x80; // ICF二进制指令 head[9] 0x00; // RSV保留字段 head[10] 0x02; // GCT网关转发数通常为 2 head[11] 0x00; // DNA目标网络号 head[12] 0x00; // DA1目标节点号PLC 节点号直连场景可先试 0 head[13] 0x00; // DA2目标单元号 head[14] 0x00; // SNA源网络号 head[15] 0x3C; // SA1源节点号非关键可自定义 head[16] 0x00; // SA2源单元号 head[17] 0x00; // SID服务 ID多请求时需要自增这段代码里最容易出错的是head[4..7]的长度字段它统计的是“第8字节到帧尾”的长度也就是固定头后半段10字节加命令区字节数。很多调试抓包发现PLC不回包就是这里长度算错PLC直接丢弃整帧。DA1节点号在欧姆龙PLC里对应CPU单元节点设置如果你在欧姆龙PLC编程软件里把节点号设成了10这里最好填10而不是0省得遇到个别型号严格校验节点时报目标节点错误。2.3 内存区地址换算从D区、CIO区到字地址FINS命令里没有D100这种符号它只认“内存区代码加字地址”。不同区域的代码不一样地址也要换算成16位字地址才能放进命令。常用区域对照如下内存区代码十六进制地址示例说明DM区0x82 0x00D100对应字地址100数据存储器最常读写CIO区0xB0 0x00CIO 0对应字地址0输入输出及内部继电器WR区0xB1 0x00W0对应字地址0工作区HR区0xB2 0x00H0对应字地址0断电保持区初学者最容易踩的坑是把D区地址当成十进制转两个字节就直接填。实际上FINS帧里字地址用大端序放在命令参数中紧接着是位号字节读整字数据时位号填0。比如读D100一个字典型徒弟写出的帧可能是地址0x64 0x00正确写法是0x00 0x64高字节在前后面再跟一个0x00表示位号。这一处颠倒PLC会回0x1103地址越界。3. C#最小实现连接欧姆龙PLC并按FINS帧读取DM区3.1 先做FINS连接打开否则PLC不认后续指令FINS/TCP和HTTP类似正式读写之前有一个“连接打开”动作。命令码是0x0000参数只有1个字节通常填0x00。不少C#上位机开发第一次写欧姆龙通讯时TCP连上就往PLC发读写命令PLC大概率没有任何响应。连接打开这步在欧姆龙手册里叫“FINS/TCP Connection Open”省略它等于没完成握手。// 连接打开命令命令码 0x0000参数 1 字节 0x00 await SendAsync(new byte[] { 0x00, 0x00, 0x00 });这段发送逻辑会复用下面SendAsync里的组帧方法也就是说连接打开命令同样要带18字节固定头。PLC收到正常后会回一个结束码为0x0000的应答帧接下来读写命令才会被正常执行。3.2 完整的读DM区客户端类下面这个类是我在实际项目里精简后的版本包含TCP连接、FINS组帧、半包读取和DM区批量读取可以直接加进WinForms或WPF工程中使用。using System.Net.Sockets; using System.Text; public class OmronFinsClient : IDisposable { private TcpClient _tcp; private NetworkStream _stream; private byte _sid; // 服务ID每次请求自增 public async Task ConnectAsync(string ip, int port 9600) { _tcp new TcpClient(); await _tcp.ConnectAsync(ip, port); _stream _tcp.GetStream(); // FINS/TCP 连接打开命令码 0x0000 参数 0x00 await SendAsync(new byte[] { 0x00, 0x00, 0x00 }); } public async Taskushort[] ReadDmAsync(ushort startWord, ushort words) { byte[] cmd { 0x01, 0x01, // 读内存区命令 0x82, 0x00, // DM区按字读取 (byte)(startWord 8), (byte)startWord, // 起始字地址大端 0x00, // 位号0表示读整字 (byte)(words 8), (byte)words // 读取字数 }; byte[] response await SendAsync(cmd); if (response[18] ! 0x01 || response[19] ! 0x01) throw new InvalidOperationException(响应命令码不匹配); ushort endCode (ushort)((response[20] 8) | response[21]); if (endCode ! 0x0000) throw new IOException($PLC 返回结束码 0x{endCode:X4}); ushort[] values new ushort[words]; for (int i 0; i words; i) { values[i] (ushort)((response[22 i * 2] 8) | response[23 i * 2]); } return values; } private async Taskbyte[] SendAsync(byte[] cmd) { byte[] frame BuildFrame(cmd); await _stream.WriteAsync(frame, 0, frame.Length); byte[] head await ReadExactAsync(18); // 先读固定头 int bodyLen (head[4] 24) | (head[5] 16) | (head[6] 8) | head[7]; byte[] body await ReadExactAsync(bodyLen); return head.Concat(body).ToArray(); } private byte[] BuildFrame(byte[] cmd) { byte[] head new byte[18]; Buffer.BlockCopy(Encoding.ASCII.GetBytes(FINS), 0, head, 0, 4); int payloadLen cmd.Length 10; head[4] (byte)(payloadLen 24); head[5] (byte)(payloadLen 16); head[6] (byte)(payloadLen 8); head[7] (byte)payloadLen; head[8] 0x80; head[10] 0x02; head[15] 0x3C; head[17] _sid; return head.Concat(cmd).ToArray(); } // 半包和粘包处理读完指定字节数才返回 private async Taskbyte[] ReadExactAsync(int count) { byte[] buffer new byte[count]; int read 0; while (read count) { int n await _stream.ReadAsync(buffer, read, count - read); if (n 0) throw new IOException(连接被对方关闭); read n; } return buffer; } public void Dispose() { _stream?.Dispose(); _tcp?.Dispose(); } }ReadDmAsync里startWord传的是字地址比如要读D100就传100words是要连续读取的字数。响应帧的索引布局是18字节固定头、2字节响应命令码、2字节结束码第22字节开始才是数据区每2字节表示一个16位字高低字节顺序和请求帧一样是大端。ReadExactAsync这段是网络通讯里最容易漏的逻辑TCP是字节流一次Read可能只有几个字节也可能一次返回多帧必须按期望长度循环读取。提示_sid每次发请求自增响应帧里会带回相同的SID值。单线程一问一答时看不出用处后面多线程并发轮询时SID是匹配请求和响应的关键。3.3 写数据只需换命令码0102写入与结束码表读DM区命令码是0x0101写单个字数据改成0x0102参数部分在“读取字数”后面追加要写入的数据字节。比如往D200写一个字0x1234ushort addr 200; byte highByte 0x12; byte lowByte 0x34; byte[] cmd { 0x01, 0x02, // 写内存区命令 0x82, 0x00, // DM区 (byte)(addr 8), (byte)addr, 0x00, // 位号 0x00, 0x01, // 写入1个字 highByte, lowByte // 数据大端 }; byte[] response await SendAsync(cmd); ushort endCode (ushort)((response[20] 8) | response[21]); if (endCode ! 0x0000) throw new IOException($PLC 返回结束码 0x{endCode:X4});写命令的响应帧没有数据区结束码在索引20和21两个字节。实际调试中我发现高频结束码就那么几个按排查方向列出来比翻手册快结束码含义常见排查点0x0101本地节点错误SA1源节点号与PLC侧设置不一致0x0103目标节点不存在DA1节点号与PLC实际节点对不上0x1101内存区代码错误内存区代码写成其他区或位格式字段错误0x1103地址越界起始地址加读取字数超出该区范围0x2002数据错误写入数据长度和声明字数不匹配遇到结束码非零时我习惯先在抓包里比对请求帧的Length字段和响应帧的结束码。如果响应帧能正常回来说明通讯链路是通的问题基本锁定在命令参数上按上面表格逐项检查即可。4. C#循环数据采集和UI刷新卡顿上位机轮询欧姆龙PLC的经典坑4.1 UI线程直接轮询卡的是消息循环而不是网络C#上位机做欧姆龙PLC数据采集最典型的错误是把while轮询直接写在按钮点击事件里循环里用Thread.Sleep延时再顺手textBox.Text value刷新界面。这段代码跑起来后界面转圈、按钮点不动、窗体拖动像PPT原因不是PLC通讯多慢而是UI线程被轮询循环占住了。WinForms和WPF的界面刷新依赖消息循环Thread.Sleep阻塞UI线程后优先级再高的刷新消息也只能排队。另一个常见问题是每轮采集都调Invoke刷新控件采集周期100ms控件刷新却要30ms消息队列里积压的委托越来越多CPU居高不下。正确思路是网络轮询交给后台任务UI定时器只做快照绑定。4.2 用Task、CancellationToken和Stopwatch做后台轮询循环我一般用Task.Run启动一个长期运行的后台轮询配合CancellationTokenSource做关闭控制用Stopwatch计算单轮实际耗时动态调整延时避免固定Thread.Sleep导致的采集周期误差累积。private readonly CancellationTokenSource _cts new(); private readonly OmronFinsClient _client new(); private ushort[] _latestRaw Array.Emptyushort(); private int _failCount; private int _intervalMs 300; // 采集周期单位毫秒 public void StartPolling() { Task.Run(() PollLoopAsync(_cts.Token)); } private async Task PollLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { Stopwatch sw Stopwatch.StartNew(); try { ushort[] raw await _client.ReadDmAsync(0, 128); // 批量读128字 _latestRaw raw; _failCount 0; } catch (Exception ex) { _failCount; if (_failCount 3) { await ReconnectWithBackoffAsync(token); _failCount 0; } Console.WriteLine(ex.Message); } int wait Math.Max(10, _intervalMs - (int)sw.ElapsedMilliseconds); try { await Task.Delay(wait, token); } catch (TaskCanceledException) { break; } } }这个循环里ReadDmAsync一次读128个字比128次各读1个字少了几十次网络往返这是上位机采集效率最直接的提升手段。_failCount的作用是防止PLC偶发超时导致频繁重连连续3次失败才真正触发重连逻辑。wait用Math.Max兜底保证通讯耗时超过采集周期时至少给CPU让出10毫秒避免空转。注意await Task.Delay(wait, token)在取消时会抛TaskCanceledException循环里先catch再break窗体关闭时才能干净退出后台任务。4.3 批量读取与定时刷新的参数平衡后台采集和UI刷新解耦后还需要一个定时器把_latestRaw同步到界面。我通常用System.Windows.Forms.TimerWinForms或DispatcherTimerWPF间隔设200到300毫秒。定时器Tick事件里只做一件事把最新快照里的值赋给控件属性不做网络请求。private void Timer_Tick(object sender, EventArgs e) { if (_latestRaw.Length 2) return; label1.Text _latestRaw[0].ToString(); label2.Text _latestRaw[1].ToString(); }刷新周期和采集周期不建议设成一样。采集300ms、刷新200ms界面看起来是流畅变化的如果两个周期一样恰好相位错开时界面会出现明显的跳变感。定时刷新的另一个好处是即使某轮采集失败界面仍然显示上一轮快照不会白屏抖动。数据场景建议采集周期建议批量字数刷新周期产线状态显示300-500ms64-256字200-300ms高速监控10Hz以上100ms连续段批量读100ms配方或参数下发手动触发全量一次读完成后一次刷新这里有一个通用原则PLC通讯的瓶颈通常在网络往返和PLC扫描周期不在C#代码本身。只要把“网络请求次数”降下来轮询再快UI也能扛住。反之如果只优化C#侧而每个点位单独读一次CPU再快也掩盖不了延迟。5. 进阶技巧SID响应配对、连接保活与指数退避重连当轮询任务和多个业务线程同时向PLC发命令时TCP连接本身是顺序字节流但多个async请求交错发出后响应回来的先后顺序不一定和发送顺序一致。这时如果代码还在简单地“发一帧等一帧”就会拿错响应。FINS固定头里的SID字段就是解决这个问题的。给每个请求分配自增SID用Dictionarybyte, TaskCompletionSourcebyte[]暂存等待中的任务响应帧头取出SID找到对应的TaskCompletionSource并回填数据。这样同一个TCP连接上可以安全地并发多个读写请求单连接吞吐能明显提升。连接保活也是老生常谈。PLC侧如果长期没有通讯某些型号会关闭空闲的FINS/TCP连接上位机要定时发一帧轻量请求维持会话。常见做法是每30到60秒读一次D0作为心跳同时在C#侧打开Socket KeepAlive_tcp.Client.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);断线重连不要一失败就立刻ConnectAsync。PLC侧可能正在重新上电这时候高频重连只会占用连接资源。用指数退避把重连间隔从500毫秒开始翻倍封顶5秒int delay 500; while (!token.IsCancellationRequested) { try { await _client.ConnectAsync(ip); break; } catch { await Task.Delay(delay, token); delay Math.Min(delay * 2, 5000); } }最后分享一个调试技巧把构造好的请求帧用BitConverter.ToString(frame)打印出来和Wireshark抓包里的十六进制对比。重点看两处——head[4..7]长度字段是否等于“10加命令区字节数”以及响应帧结束码是否为0x0000。大多数通讯异常通过比对这两处就能定位到是协议头问题还是命令参数问题比自己改代码瞎猜快得多。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻