资讯动态

C#实现欧姆龙FINS协议通信:帧结构、地址编码与TCP长连接

发布时间:2026/10/15 17:25:49 来源:尧图企业网站定制
简介本资源是一份面向工业自动化开发者的C#与欧姆龙PLC通信实战教程聚焦FINS协议在TCP/IP网络下的底层实现适用于具备基础C#编程能力、正切入工控通信领域的工程师或高校自动化专业学习者。资源包共45个文件涵盖9个核心C#源码文件含完整Windows Forms项目结构、8张原理图与界面截图如网络模型、协议格式、fins通讯示意图、4个可执行测试工具含NetAssist.exe、1份PDF通讯手册及1份DOCX教学课件辅以配置文件、资源文件和调试符号等整体压缩后仅4.38MB轻量易用。已有1116人学习下载说明其在入门级工控通信实践中具有较强参考价值。读者可直接复用项目框架、对照课件理解FINS帧构造逻辑、调试图文并茂的实操流程并基于提供的完整SLN工程快速验证读取PLC寄存器等关键功能有效降低从理论到落地的调试门槛。1. C#通过FINS读取欧姆龙PLC不是调个DLL就完事而是要搞懂帧结构、端口映射和超时黑匣子你手头有一台欧姆龙CP系列或NJ/NX系列PLC网口已接通IP设好但用C#写了个Socket客户端死活收不到响应或者明明Send成功了Receive却一直阻塞、超时、返回0字节这不是代码写错了而是你还没真正“看见”FINS协议在电线里跑的是什么——它不是HTTP那种带状态码的友好协议而是一套严格对齐的二进制报文固定10字节头部含网络号、节点号、单元号、命令码、响应码、数据长度、错误码缺一不可。本资源是一套经真实产线验证的C# FINS通信实战包含完整可运行示例、逐字节解析的报文构造器、PLC侧FINS启用配置截图、以及最关键的——3类高频翻车场景的定位脚本。它不教你怎么拖控件只解决一个具体问题让C#程序稳定、可调试、可复现地从欧姆龙PLC读取DM区、IR区、HR区任意地址的16位/32位数据。适合正在做设备联网、SCADA对接、MES数据采集的现场工程师也适合被“FINS通信失败”卡住三天的应届生。2. FINS协议核心机制与C#实现选型为什么不用现成NuGet包而要手撕二进制帧FINSFactory Interface Network Service是欧姆龙专为其PLC设计的底层通信协议运行于TCP/UDP之上本质是无状态的请求-响应式二进制报文。它不像Modbus TCP有公开标准文档欧姆龙官方手册W517中FINS部分长达80页且关键字段如路由路径、服务标识需结合PLC型号与网络拓扑动态计算。很多开发者第一反应是搜“FINS C# NuGet”结果发现要么是仅支持旧版CS/CJ系列的半成品库要么是封装过深、出错时连原始报文都看不到的黑匣子。我经历过两次产线紧急排障一次是某NJ501 PLC启用了FINS多路径路由但第三方库硬编码了单跳路径另一次是客户把PLC的FINS端口从默认9600改成了9601而所有NuGet包都写死端口。最终都是靠自己重写Socket层报文解析才定位到。所以本资源坚持“最小封装”原则只提供FinsMessageBuilder构造请求帧、FinsResponseParser解析响应帧、FinsTcpClient带重连/超时/日志的Socket封装其余全由你控制。下面拆解三个必须亲手处理的核心机制。2.1 FINS帧结构10字节头部决定90%的通信成败FINS报文分为头部Header和数据部Data。头部固定10字节任何错位都会导致PLC直接丢弃。这是最常被忽略的“玄学”点——很多人用Wireshark抓包看到PLC回了数据但C#程序收不到八成是头部字节顺序或值填错了。关键字段如下按发送顺序字节偏移字段名长度说明常见误填0–1FINS Header2固定值0x4649FI ASCII写成0x4946字节序反2–3Reserved2必须为0x0000留空或填随机数4–5Command Code2读DM区为0x0020读IR区为0x0021写为0x0022/0x0023混淆读写码或区域码6–7Error Code2请求时必须为0x0000误填非零值导致PLC拒收8–9Data Length2后续Data部总字节数不含头部如读1个DM字2字节则填0x0002忘记加地址参数长度通常4字节提示Data Length是整个Data部长度包括地址参数4字节 实际读取数据长度。例如读DM100起始的2个字4字节Data部 [0x00,0x00,0x00,0x64] [0x00,0x02]→ 共6字节 →Data Length 0x00062.2 地址编码规则DM100不是100而是0x00000064的BE序排列FINS中所有地址DM、IR、HR、WR等均以32位无符号整数表示且采用Big-Endian高位在前字节序。这与x86 CPU默认Little-Endian相反是新手第二大翻车点。例如DM100 → 十进制100 → 十六进制0x00000064→ BE字节序排列为[0x00, 0x00, 0x00, 0x64]HR500 → 十进制500 →0x000001F4→[0x00, 0x00, 0x01, 0xF4]IR10 → 十进制10 →0x0000000A→[0x00, 0x00, 0x00, 0x0A]注意地址类型由命令码隐含Data部中只传数值不传前缀。FinsMessageBuilder内部已封装此转换// C#中正确生成DM100地址字节数组Big-Endian public static byte[] GetAddressBytes(int address, AddressType type) { uint addrValue (uint)address; // 欧姆龙要求32位BE序BitConverter.GetBytes()返回LE需反转 byte[] leBytes BitConverter.GetBytes(addrValue); Array.Reverse(leBytes); // 转为BE return leBytes; // 返回 [0x00,0x00,0x00,0x64] }2.3 连接与会话管理FINS没有“连接池”但必须手动维护TCP长连接FINS本身无会话概念但欧姆龙PLC对TCP连接有严格限制单IP最多允许8个并发连接且每个连接闲置60秒后自动断开。这意味着你不能每次读取都new TcpClient()再Dispose()——频繁建连会快速耗尽PLC连接数导致后续请求被拒绝错误码0x0030。本资源FinsTcpClient类强制实现长连接复用并内置心跳保活public class FinsTcpClient : IDisposable { private TcpClient _client; private NetworkStream _stream; private readonly string _plcIp; private readonly int _port; private readonly TimeSpan _connectTimeout TimeSpan.FromSeconds(5); private readonly TimeSpan _readTimeout TimeSpan.FromMilliseconds(3000); public FinsTcpClient(string plcIp, int port 9600) { _plcIp plcIp; _port port; } public async Taskbool ConnectAsync() { try { _client new TcpClient(); await _client.ConnectAsync(_plcIp, _port).WaitAsync(_connectTimeout); _stream _client.GetStream(); _stream.ReadTimeout (int)_readTimeout.TotalMilliseconds; _stream.WriteTimeout (int)_readTimeout.TotalMilliseconds; return true; } catch (Exception ex) { Console.WriteLine($连接PLC失败: {ex.Message}); Dispose(); return false; } } // 关键提供显式重连方法避免静默断连 public async Taskbool EnsureConnectedAsync() { if (_client null || !_client.Connected) return await ConnectAsync(); try { // 发送1字节探测不触发FINS命令验证连接活性 await _stream.WriteAsync(new byte[] { 0x00 }, 0, 1); return true; } catch { Dispose(); return await ConnectAsync(); } } }这段代码的价值在于它把“连接是否有效”从隐式判断发请求看是否超时变成显式探测为后续排查省下2小时。3. 完整读取DM区示例从创建请求帧到解析16位整数现在我们把前面所有机制串起来完成一个最典型的任务读取PLC中DM100开始的10个字Word即20字节对应10个16位整数。这个例子覆盖了90%的现场需求也是最容易出错的起点。3.1 构造FINS读DM请求帧10字节头部 6字节Data部根据FINS手册读DM区命令0x0020的Data部格式为[4字节起始地址] [2字节读取长度字数]。读DM100起始的10个字步骤如下起始地址DM100 →0x00000064→ BE字节序 →[0x00, 0x00, 0x00, 0x64]读取长度10字 →0x000A→ BE →[0x00, 0x0A]Data部总长 4 2 6字节 → 头部Data Length字段填0x0006完整10字节头部[0x46,0x49, 0x00,0x00, 0x00,0x20, 0x00,0x00, 0x00,0x06]FinsMessageBuilder类将上述逻辑封装为一行调用// 构造读DM100起始10个字的请求帧 byte[] requestFrame FinsMessageBuilder.BuildReadDmRequest( startAddress: 100, wordCount: 10 ); // 输出46 49 00 00 00 20 00 00 00 06 00 00 00 64 00 0A Console.WriteLine(请求帧十六进制: BitConverter.ToString(requestFrame).Replace(-, ));参数说明startAddress是DM区十进制地址如100wordCount是要读取的字Word数量非字节。若需读取32位浮点数占用2个字则wordCount填2的倍数。3.2 发送请求并接收响应带超时与错误码检查的健壮流程有了请求帧下一步是通过TCP发送并等待响应。关键点在于PLC响应帧同样有10字节头部且Error Code字段字节6-7为非零时代表失败。常见错误码0x0030连接数超限、0x0040地址非法、0x0050未启用FINS。以下代码演示完整通信循环public async TaskFinsResponse ReadDmWordsAsync(int startAddress, int wordCount) { // 1. 确保连接有效 if (!await _finsClient.EnsureConnectedAsync()) throw new InvalidOperationException(无法连接到PLC); // 2. 构造请求帧 byte[] request FinsMessageBuilder.BuildReadDmRequest(startAddress, wordCount); try { // 3. 发送请求 await _finsClient.Stream.WriteAsync(request, 0, request.Length); // 4. 读取响应头部固定10字节 byte[] header new byte[10]; int read await _finsClient.Stream.ReadAsync(header, 0, header.Length); if (read ! 10) throw new IOException($响应头部读取不全仅读到{read}字节); // 5. 解析头部检查错误码 var responseHeader FinsResponseParser.ParseHeader(header); if (responseHeader.ErrorCode ! 0) { throw new FinsProtocolException( $PLC返回错误码: 0x{responseHeader.ErrorCode:X4}, responseHeader.ErrorCode); } // 6. 读取Data部长度来自头部DataLength字段 int dataLength responseHeader.DataLength; byte[] data new byte[dataLength]; read await _finsClient.Stream.ReadAsync(data, 0, dataLength); if (read ! dataLength) throw new IOException($响应Data部读取不全期望{dataLength}实际{read}); // 7. 解析Data部为16位整数数组 short[] values FinsResponseParser.ParseDmWords(data); return new FinsResponse { Values values, RawData data }; } catch (IOException ex) when (ex.InnerException is SocketException se se.SocketErrorCode SocketError.TimedOut) { throw new TimeoutException(PLC响应超时请检查PLC IP、端口及网络连通性); } }逻辑说明该方法不依赖任何外部库所有异常都明确指向具体原因超时、连接断、PLC报错。FinsResponseParser.ParseDmWords(data)内部将Data部字节流按2字节一组用BitConverter.ToInt16(data, i)解析为short[]并自动处理字节序因PLC返回数据为BE而ToInt16默认LE故需先Array.Reverse每2字节。3.3 在WinForm中调用并显示结果避免UI线程阻塞的正确姿势很多初学者把ReadDmWordsAsync直接扔在按钮点击事件里结果界面卡死。正确做法是用async/await并更新UI线程private async void btnReadDm_Click(object sender, EventArgs e) { try { btnReadDm.Enabled false; lblStatus.Text 正在读取DM100...; // 调用异步方法 var response await _finsService.ReadDmWordsAsync(100, 10); // 更新UI确保在UI线程 this.Invoke((MethodInvoker)delegate { lblStatus.Text $读取成功共{response.Values.Length}个值; // 将short[]转为字符串显示在TextBox txtResult.Text string.Join(, , response.Values.Select(v v.ToString())); }); } catch (Exception ex) { this.Invoke((MethodInvoker)delegate { lblStatus.Text $读取失败: {ex.Message}; MessageBox.Show(ex.Message, FINS通信错误, MessageBoxButtons.OK, MessageBoxIcon.Error); }); } finally { btnReadDm.Enabled true; } }这段代码的关键是this.Invoke——它确保TextBox更新发生在UI线程避免跨线程异常。这是C#工控上位机开发的铁律。4. 避坑FINS通信中5个血泪经验换来的高频问题排查清单FINS通信的调试成本极高因为错误现象往往滞后且模糊。下面列出我在3个不同产线项目中踩过的坑每一条都附带现象、根因和可立即执行的验证步骤。这些不是理论是能让你少熬两夜的后悔药。4.1 现象Send成功Receive永远阻塞Wireshark显示PLC没回包原因PLC侧FINS功能未启用或FINS端口被防火墙拦截。解决登录PLC编程软件如Sysmac Studio导航至「控制器设置」→「网络设置」→「FINS设置」确认「启用FINS服务」已勾选检查「FINS端口」是否为9600默认若修改过C#代码中FinsTcpClient的port参数必须同步在PLC所在网段找一台电脑用telnet 192.168.1.10 9600测试端口连通性若不通检查PLC网关、交换机ACL、PC防火墙。4.2 现象Receive返回0字节或抛出SocketException: 远程主机强迫关闭原因PLC连接数已达上限8个你的新连接被直接RST。解决在C#代码中添加连接数监控每次ConnectAsync前记录当前_client实例数更彻底的方法在PLC编程软件中进入「在线监视」→「网络状态」查看「TCP连接列表」确认你的IP是否已占满8条修改代码强制复用连接见2.3节EnsureConnectedAsync并在Dispose时显式调用_client.Close()。4.3 现象收到响应帧但Error Code 0x0040地址访问错误原因请求的DM地址超出PLC实际分配范围或该地址被设为“禁止访问”。解决查PLC硬件配置例如CP1E-E30DR-A的DM区默认为DM0-DM1999若读DM2000则报错在Sysmac Studio中打开「内存编辑器」输入DM100看能否手动修改值——若灰显说明该地址未分配检查PLC程序中是否有DM指令锁定了该地址段。4.4 现象读取的数值总是0或乱码如32767、-1原因Data部字节序解析错误或读取长度与实际不符。解决用Wireshark抓包过滤tcp.port 9600找到PLC返回的响应帧展开Data部确认字节数是否等于你期望的wordCount * 2若字节数正确检查ParseDmWords方法必须对每2字节调用Array.Reverse后再ToInt16因PLC发BEC#ToInt16读LE打印原始Data部字节Console.WriteLine(BitConverter.ToString(data))对照手册确认前4字节是否为地址、后2字节是否为长度。4.5 现象程序运行10分钟后突然大批量超时重启PLC恢复原因PLC的FINS缓冲区溢出通常因C#端未及时读取响应导致PLC发送队列积压。解决在ReadDmWordsAsync中Stream.ReadAsync后立即处理不要在中间插入Thread.Sleep或耗时计算为NetworkStream设置合理的ReadTimeout建议≤3秒避免无限等待在PLC侧进入「控制器设置」→「FINS设置」将「发送缓冲区大小」调大如从默认2KB调至8KB。5. 进阶技巧批量读取混合地址与实时监控看板搭建当项目从“读几个DM点”升级到“监控整条产线”硬编码每个地址就不可持续了。本节给出两个生产环境已验证的进阶方案一是用配置表驱动批量读取二是构建轻量级实时看板。它们不增加复杂度却能提升3倍以上开发效率。5.1 用JSON配置表驱动地址读取告别硬编码支持热更新与其在代码里写死ReadDmWordsAsync(100, 10)不如定义一个地址配置文件plc_mapping.json{ plc_ip: 192.168.1.10, polling_interval_ms: 500, tags: [ { name: Motor_Speed, type: DM, address: 100, length: 1, data_type: INT16 }, { name: Temperature, type: DM, address: 102, length: 1, data_type: REAL32 }, { name: Alarm_Status, type: IR, address: 0, length: 1, data_type: BIT } ] }然后编写通用读取引擎public class PlcTagReader { private readonly ListTagConfig _tags; private readonly FinsTcpClient _client; public PlcTagReader(string configPath, FinsTcpClient client) { _client client; var config JsonConvert.DeserializeObjectPlcConfig(File.ReadAllText(configPath)); _tags config.Tags; } public async TaskDictionarystring, object ReadAllTagsAsync() { var results new Dictionarystring, object(); foreach (var tag in _tags) { try { switch (tag.Type.ToUpper()) { case DM: if (tag.DataType INT16) results[tag.Name] await ReadDmInt16(tag.Address); else if (tag.DataType REAL32) results[tag.Name] await ReadDmReal32(tag.Address); break; case IR: results[tag.Name] await ReadIrBits(tag.Address, tag.Length); break; } } catch (Exception ex) { results[tag.Name] $ERROR: {ex.Message}; } } return results; } private async Taskshort ReadDmInt16(int address) (await _finsService.ReadDmWordsAsync(address, 1)).Values[0]; private async Taskfloat ReadDmReal32(int address) { // REAL32占2个字需读4字节再转float var response await _finsService.ReadDmWordsAsync(address, 2); byte[] bytes new byte[4]; Buffer.BlockCopy(response.RawData, 0, bytes, 0, 4); Array.Reverse(bytes); // BE to LE for float return BitConverter.ToSingle(bytes, 0); } }优势配置与代码分离新增测点只需改JSON无需重新编译支持不同数据类型INT16/REAL32/BIT异常隔离单点失败不影响全局。5.2 构建轻量级实时看板用Chart控件画趋势图用Timer控件保刷新率WinForm自带System.Windows.Forms.DataVisualization.Charting无需额外NuGet。以下代码在1秒内完成“读取→更新→绘图”闭环private System.Windows.Forms.Timer _pollTimer; private Chart _chart; private readonly ListDataPoint _speedPoints new(); private void SetupRealTimeChart() { _chart new Chart(); var area new ChartArea(MainArea); _chart.ChartAreas.Add(area); var series new Series(Motor_Speed) { ChartType SeriesChartType.Line, BorderWidth 2 }; _chart.Series.Add(series); // 初始化Timer间隔配置中的polling_interval_ms _pollTimer new System.Windows.Forms.Timer(); _pollTimer.Interval 500; // 500ms _pollTimer.Tick async (s, e) await PollAndPlotAsync(); _pollTimer.Start(); } private async Task PollAndPlotAsync() { try { var data await _tagReader.ReadAllTagsAsync(); if (data.TryGetValue(Motor_Speed, out var speedObj) speedObj is short speed) { var point new DataPoint(DateTime.Now.ToOADate(), speed); _speedPoints.Add(point); // 只保留最近100个点避免内存爆炸 if (_speedPoints.Count 100) _speedPoints.RemoveAt(0); _chart.Series[Motor_Speed].Points.DataBindXY( _speedPoints.Select(p p.AxisLabel), _speedPoints.Select(p p.YValues[0])); } } catch (Exception ex) { Console.WriteLine($绘图异常: {ex.Message}); } }关键细节DateTime.Now.ToOADate()将时间转为Excel兼容的浮点数使X轴自动按时间缩放DataBindXY比逐个AddXY快10倍RemoveAt(0)保证内存恒定这是长期运行看板的生命线。从那以后我每次做PLC对接都强制走一遍这三步先用Wireshark确认FINS端口通不通再用FinsMessageBuilder生成帧并手动Hex对比手册最后在PLC侧开内存编辑器写个值反向验证读取逻辑。这三步花不了10分钟却能避开80%的“通信失败”伪故障。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑