简介:本资源是一份面向工业自动化开发者的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包,而要手撕二进制帧
FINS(Factory 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–1 | FINS Header | 2 | 固定值0x4649('FI' ASCII) | 写成0x4946(字节序反) |
| 2–3 | Reserved | 2 | 必须为0x0000 | 留空或填随机数 |
| 4–5 | Command Code | 2 | 读DM区为0x0020,读IR区为0x0021,写为0x0022/0x0023 | 混淆读写码或区域码 |
| 6–7 | Error Code | 2 | 请求时必须为0x0000 | 误填非零值导致PLC拒收 |
| 8–9 | Data Length | 2 | 后续Data部总字节数(不含头部),如读1个DM字(2字节)则填0x0002 | 忘记加地址参数长度(通常4字节) |
提示:
Data Length是整个Data部长度,包括地址参数(4字节)+ 实际读取数据长度。例如读DM100起始的2个字(4字节),Data部 =[0x00,0x00,0x00,0x64] + [0x00,0x02]→ 共6字节 →Data Length = 0x0006
2.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 Task<bool> 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 Task<bool> 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区十进制地址(如100),wordCount是要读取的字(Word)数量,非字节。若需读取32位浮点数(占用2个字),则wordCount填2的倍数。
3.2 发送请求并接收响应:带超时与错误码检查的健壮流程
有了请求帧,下一步是通过TCP发送并等待响应。关键点在于:PLC响应帧同样有10字节头部,且Error Code字段(字节6-7)为非零时代表失败。常见错误码:0x0030(连接数超限)、0x0040(地址非法)、0x0050(未启用FINS)。以下代码演示完整通信循环:
public async Task<FinsResponse> 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发BE,C#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 List<TagConfig> _tags; private readonly FinsTcpClient _client; public PlcTagReader(string configPath, FinsTcpClient client) { _client = client; var config = JsonConvert.DeserializeObject<PlcConfig>(File.ReadAllText(configPath)); _tags = config.Tags; } public async Task<Dictionary<string, object>> ReadAllTagsAsync() { var results = new Dictionary<string, 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 Task<short> ReadDmInt16(int address) => (await _finsService.ReadDmWordsAsync(address, 1)).Values[0]; private async Task<float> 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 List<DataPoint> _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%的“通信失败”伪故障。希望帮到你。
本文还有配套的精品资源,点击获取