简介:面向工控上位机开发的C#与三菱Q系列PLC通信示例包,定位中等技能层级开发者,解决通过MC协议读写PLC寄存器数据的常见需求。压缩包内共31个文件,包含6个C#源代码文件、3个可执行程序以及配套PDB调试符号、resources资源、resx窗体布局等,整体仅64KB,属于轻量级参考工程。源码以WinForms窗体程序为主,涵盖Form1.cs、Program.cs及resx资源文件,并带有工程解决方案文件,便于直接加载调试并观察通信效果。项目主要演示MC协议下对Q系列PLC寄存器进行读取和写入的操作,可帮助理解报文封装、地址计算与通信流程,工程结构清晰,适合快速验证上位机与PLC的联调逻辑。目前已有6105人学习下载,适合需要快速上手三菱PLC以太网/串口通信的上位机工程师,也可作为课程设计或项目原型的起点。
1. 车间上位机最常见的通信需求:C#与三菱Q系列PLC通过MC协议通信
一台三菱Q系列PLC放在产线上,MES、SCADA或者上位机软件要拿走它的D寄存器数据、M继电器状态,还要往下写配方、写启停命令。很多工程师第一反应是走MODBUS,但Q系列原生支持、响应最稳的其实是三菱自家MC协议。用C#通过MC协议和三菱Q系列PLC通信,本质就是把请求报文按协议格式拼好,从以太网口发出去,再把响应报文拆开,转成能用的ushort、bool和float。这篇文章不讲花架子,从协议选型、报文结构、C#代码到真机调试的坑,一路讲到能落地。
2. MC协议与Q系列通信模型:为什么先选3E帧二进制格式
2.1 Q系列PLC的通信入口:从软元件到以太网模块
Q系列PLC本身不带以太网口的情况很常见,现场一般通过以太网模块或者CPU内置以太网口对上位机。无论哪种硬件,上位机访问内部软元件的渠道都是MC协议。MC协议的全称是三菱通信协议,Q系列、L系列、FX系列都支持,但不同系列能访问的软元件种类和设备代码不完全一样。写程序前先查清楚这台PLC的软元件范围,尤其是Q系列与L系列之间的差异,完全可以当成一个独立排查项。
通信模型上,Q系列以太网支持多种帧类型:1E帧、2E帧、3E帧、4E帧。1E帧需要先发送“打开通信线路”命令,建立逻辑连接后再读写,通信步骤多,现在的C#上位机基本不碰它。3E帧是无状态协议,不需要预先打开线路,TCP连接建立后直接发请求、收响应,适合程序化访问。4E帧带更多头部信息,主要用在多PLC转发或跨网络场景。做单台上位机直连PLC的数据采集,QnA兼容3E帧的可变长度类型是默认首选,这也是很多现成通信库的底层实现。
以太网模块侧需要确认的参数有三个:IP地址、端口号、通信协议使能状态。端口号不是固定的,取决于GX Works2或GX Developer里以太网模块的“打开设置”,常见配置有TCP 5000、TCP 2000等,以实际组态为准。很多第一次联调的人卡在连接超时,最后发现是模块参数里根本没有勾选MC协议通信,或者帧类型设成了ASCII而代码里发的是二进制。
2.2 3E帧二进制报文结构:从帧头到数据区逐字节拆开
3E帧二进制格式的请求报文分为固定头部和请求数据两部分。固定头部共10个字节,顺序是:帧头D0 00,网络号1字节,PC号1字节,IO编号2字节,站号1字节,请求数据长度2字节。请求数据部分从监视定时器开始,后面依次是命令、子命令、设备代码、软元件号、点数,以及写入时的数据区。
一个读D100开始的2个字(D100、D101)的请求报文,完整字节流是这样的:
D0 00 | 00 | FF | FF 03 | 00 | 0F 00 | 10 27 | 01 14 | 00 00 | A8 00 | 64 00 00 | 02 00逐段说明:D0 00是3E帧二进制帧头;00是网络号,默认0;FF是PC号,填FF表示不指定;FF 03是IO编号,小端表示0x03FF;00是站号;0F 00是请求数据长度,十进制13,指从监视定时器到报文末尾共13字节;10 27是监视定时器,十六进制0x2710就是10000毫秒,每个模块有上限范围;01 14是批量读取命令,十六进制0x1401的小端表示;00 00是子命令,0代表字单位;A8 00是设备代码,对应D寄存器,十六进制0x00A8以小端输出正好是A8 00;64 00 00是软元件号,十进制100的三字节小端表示;02 00是要读取的点数,2个字。
注意,整个3E帧二进制报文里,所有多字节字段都是低字节在前。这一点极其容易踩坑,尤其是设备代码。设备代码D的协议值是0x00A8,文档上写A8h,但放进报文必须是A8 00,反了就读不到数据。地址段同理,D0是00 00 00,D255是FF 00 00,D256是00 01 00。
2.3 ASCII格式与二进制格式怎么选:调试用ASCII,上线用二进制
3E帧还分ASCII格式和二进制格式。ASCII格式把人能读懂的十六进制字符直接拼成字符串发送,比如读D100一字的请求不带固定头部也要几十个字符,可读性好,串口工具里一眼能看到报文内容,但解析字符串和长度计算开销大。二进制格式紧凑,CPU开销小,适合高频轮询,代价是一串不可读的字节,排查问题要靠抓包工具。
我的习惯是:先不管格式,用三菱官方调试工具把通信调通,确认PLC侧软元件地址和数据类型没问题,再写C#代码。如果现场只有串口工具,就先用ASCII帧报文手动发一遍,验证PLC响应码。代码里则统一用二进制格式,性能是一方面,更重要的是避免字符串编码出错,比如ASCII格式里地址补零和大小写问题会让响应码变成C0。
监视定时器这个参数值得单独说。它告诉PLC“我的请求最多等多久”,超时未完成则返回错误。默认常见值是4096毫秒,也就是0x1000。如果上位机批量读几百个字,或者PLC扫描周期刚好被某个长任务拖住,定时器设太小会偶发超时。我一般直接设10000毫秒,也就是0x2710,线上字节就是10 27。响应报文的定时器字段是PLC回填的剩余时间,一般不用管。
3. 用C#写最小MC协议客户端:TcpClient到首次读写
3.1 工程准备:目标框架与连接对象
C#端选什么框架不重要,.NET Framework 4.7.2和.NET 8都能跑通,用.NET 6以上可以省很多异步模板代码。新建控制台项目或者类库都行,核心就依赖System.Net.Sockets,不需要第三方NuGet包。如果公司规范不让用现成通信库,自己封装一套MC协议客户端完全可行,工作量集中在报文构造和响应解析上。
TCP连接有三点要提前定下来:PLC IP地址、端口号、连接超时。不要用TcpClient默认的超时机制,ConnectAsync要配合Task.WhenAny或CancellationToken做超时控制。一次连接建立后可以长期复用,不要每次读写都重新Connect,PLC侧以太网模块能承载的连接数和握手频率都有限。断线后的处理放到后面避坑章节细说,先写一个最简单的连接管理:
using System.Net.Sockets; public class McTcpClient { private readonly string _host; private readonly int _port; private TcpClient? _client; private NetworkStream? _stream; private readonly SemaphoreSlim _gate = new(1, 1); public McTcpClient(string host, int port) { _host = host; _port = port; } public async Task ConnectAsync(int timeoutMs = 3000) { if (_client?.Connected == true) return; using var cts = new CancellationTokenSource(timeoutMs); var tcp = new TcpClient(); try { await tcp.ConnectAsync(_host, _port, cts.Token); _client = tcp; _stream = tcp.GetStream(); } catch (OperationCanceledException) { tcp.Dispose(); throw new TimeoutException($"连接PLC {_host}:{_port} 超时"); } } }ConnectAsync先检查现有连接是否可用,避免重复建立连接。超时用CancellationTokenSource实现,超过timeoutMs直接抛TimeoutException,这个异常在上位机界面上要明确提示“检查PLC IP和端口”。SemaphoreSlim是后面的并发防线,MC协议TCP是请求响应串行模型,多线程同时发请求会把响应搞乱,所有读写都要过这个闸门。
3.2 构造批量读请求:设备代码与三字节地址拼接
批量读命令0x1401用来读多个连续软元件。请求数据区的格式是:监视定时器2字节、命令2字节、子命令2字节、设备代码2字节、软元件号3字节、点数2字节。设备代码和软元件号不是直接塞int,必须按协议宽度和字节序拆开。
public static byte[] BuildReadFrame( ushort deviceCode, int startAddress, ushort points, ushort timerMs = 10000) { using var ms = new MemoryStream(32); // 固定头部 ms.WriteByte(0xD0); ms.WriteByte(0x00); ms.WriteByte(0x00); // 网络号 ms.WriteByte(0xFF); // PC号 ms.WriteByte(0xFF); // IO编号低位 ms.WriteByte(0x03); // IO编号高位 ms.WriteByte(0x00); // 站号 // 请求数据 var body = new List<byte>(16); body.AddRange(BitConverter.GetBytes(timerMs)); // 监视定时器 body.Add(0x01); body.Add(0x14); // 批量读 0x1401 body.Add(0x00); body.Add(0x00); // 子命令 字单位 body.AddRange(BitConverter.GetBytes(deviceCode)); // 设备代码 body.Add((byte)(startAddress & 0xFF)); // 软元件号低字节 body.Add((byte)((startAddress >> 8) & 0xFF)); body.Add((byte)((startAddress >> 16) & 0xFF)); body.AddRange(BitConverter.GetBytes(points)); // 点数 // 请求数据长度,放在头部末尾 ms.Write(BitConverter.GetBytes((ushort)body.Count)); ms.Write(body.ToArray()); return ms.ToArray(); }代码里最容易被忽略的是软元件号的三字节位运算。起始地址虽然逻辑上是个int,但协议只给3字节,意味着最大地址空间不能超过0xFFFFFF。Q系列常用软元件地址离这个上限很远,但写代码时还是要习惯性做一次范围检查,地址为负或超过0xFFFFFF就直接抛异常,别默默截断。设备代码用BitConverter.GetBytes输出小端,所以D寄存器0x00A8得到A8 00,和协议要求一致。
请求数据长度是body.Count,这个长度只包含监视定时器到点数这段,不含固定头部10字节。计算错误最常见的是多算了头部的长度,导致PLC认为报文不完整,响应会表现为超时或收到响应帧头错乱。构造完报文后,建议先打印hex对比手册里的报文示例再上真机。
3.3 接收响应与校验:固定头部跳过,响应码必须判断
PLC的响应报文结构是:固定头部10字节、响应数据长度2字节、响应代码2字节、数据区。响应代码为0x0000表示正常,非0对应表格式错误码。上位机最容易犯的错是读完固定头部就直接解析数据,把响应代码当成数据的一部分。
public async Task<byte[]> SendAsync(byte[] frame, CancellationToken ct = default) { await _gate.WaitAsync(ct); try { if (_client?.Connected != true || _stream == null) throw new InvalidOperationException("连接已断开,请先调用ConnectAsync"); await _stream.WriteAsync(frame, ct); // 读响应固定头部 var header = new byte[10]; await ReadFullAsync(header, ct); if (header[0] != 0xD0 || header[1] != 0x00) throw new InvalidDataException($"响应帧头错误: {header[0]:X2} {header[1]:X2}"); // 响应数据长度,小端 int bodyLength = header[8] | (header[9] << 8); if (bodyLength < 2) throw new InvalidDataException("响应长度异常"); var body = new byte[bodyLength]; await ReadFullAsync(body, ct); // 响应代码低字节在前,0x0000 表示成功 ushort resultCode = (ushort)(body[0] | (body[1] << 8)); if (resultCode != 0) throw new McProtocolException(resultCode); // 返回数据区(不含响应代码) return body[2..]; } finally { _gate.Release(); } } private async Task ReadFullAsync(byte[] buffer, CancellationToken ct) { int offset = 0; while (offset < buffer.Length) { int read = await _stream!.ReadAsync(buffer.AsMemory(offset), ct); if (read == 0) throw new IOException("PLC连接已关闭"); offset += read; } }响应体长度是bodyLength,而不是10加bodyLength。很多人读响应时把数据长度字段算重复,导致后续读多字节时偏移错位。ReadFullAsync这个循环很重要,TCP是流协议,一次Read不一定收满整个报文,必须循环读直到填满缓冲区。MC协议没有帧尾标记,只能靠长度字段确认报文边界。
响应代码如下:body[0]和body[1]的组合就是协议响应代码。抛异常前把响应代码记录到日志,因为错误码本身就够排查半天。McProtocolException这个自定义异常可以带上错误码和描述,后面对应表直接输出可读信息,别让现场调试的人看到一个数字去翻手册。
3.4 解析读取结果:每个字是两字节小端
读D寄存器的点数单位是“字”,一个字占两个字节。响应数据区的长度是点数乘以2。解析时最稳的是按ushort解析,因为Q系列D寄存器默认16位无符号整数的场景最多。如果PLC里存的是负数,用short.Parse转换一下,别直接用int强转,符号扩展会出错。
public static ushort[] ParseWords(byte[] data, int count) { if (data.Length < count * 2) throw new InvalidDataException($"响应数据长度不足: 期望{count * 2},实际{data.Length}"); var result = new ushort[count]; for (int i = 0; i < count; i++) { result[i] = (ushort)(data[i * 2] | (data[i * 2 + 1] << 8)); } return result; }解析逻辑很简单,但注意不要用BitConverter.ToInt16去读大端数组,BitConverter依赖本机字节序,Windows上恰好是小端,遇到将来换ARM平台或PLC端奇偶校验场景就会出问题。手动拼ushort的代码更可移植,也不容易被编译器优化出意外结果。
至此最小客户端已经能读D寄存器了。M继电器、写入操作、字符串读取都是在同一套帧结构上换设备代码、子命令和数据区布局,下一章逐个展开。
4. 读写Q系列软元件数据:D寄存器、M继电器与字符串
4.1 D寄存器与M继电器的设备代码和子命令差异
D寄存器是字软元件,M继电器是位软元件。设备代码分别对应0x00A8和0x0090,但仅改设备代码还不够,子命令也要跟着变。批量读M继电器时,如果子命令仍用0x0000字单位,PLC会按字返回,每点的状态用一个字表达,数据量翻倍,读256个M点会返回512字节,数据区长度容易把人绕晕。
我一般读M继电器时把子命令设为0x0001位单位,响应数据区每个M点返回1字节,0x00或0x01。这样解析更直观,数据量也小。位单位响应的解析如下:
public static bool[] ParseBits(byte[] data, int count) { if (data.Length < count) throw new InvalidDataException($"位数据长度不足: 期望{count},实际{data.Length}"); var result = new bool[count]; for (int i = 0; i < count; i++) { result[i] = data[i] != 0; } return result; }另一个容易错的地方是M地址的进制。D寄存器十进制递增,M继电器的编号本身也是十进制,但X/Y这类输入输出软元件是八进制编号。三菱手册里X0到X7之后就是X10,没有X8和X9。通过MC协议访问X/Y时,软元件号字段要填八进制数值对应的十进制数字,比如X20对应的软元件号实际是十六进制0x0010。写程序前理清目标设备的进制,出了错很难看出来,因为读回来的数据是正常的,只是地址错位。
4.2 批量写入:命令0x1402与数据区顺序
写入命令是0x1402,请求数据区在点数之后追加数据段。读字软元件时数据区是每字2字节小端,写也一样。写入D100写入0x1234,报文数据区就是34 12。写入M继电器时,子命令用位单位0x0001,每点1字节,0x00或0x01。
public static byte[] BuildWriteWordFrame( ushort deviceCode, int startAddress, ushort[] values, ushort timerMs = 10000) { using var ms = new MemoryStream(64); ms.WriteByte(0xD0); ms.WriteByte(0x00); ms.WriteByte(0x00); // 网络号 ms.WriteByte(0xFF); // PC号 ms.WriteByte(0xFF); ms.WriteByte(0x03); ms.WriteByte(0x00); // 站号 var body = new List<byte>(32); body.AddRange(BitConverter.GetBytes(timerMs)); body.Add(0x01); body.Add(0x14); // 依旧可以,下面写命令在注释里说明 body.Add(0x00); body.Add(0x00); // 子命令字单位 body.AddRange(BitConverter.GetBytes(deviceCode)); body.Add((byte)(startAddress & 0xFF)); body.Add((byte)((startAddress >> 8) & 0xFF)); body.Add((byte)((startAddress >> 16) & 0xFF)); body.AddRange(BitConverter.GetBytes((ushort)values.Length)); foreach (var value in values) { body.AddRange(BitConverter.GetBytes(value)); // 小端写入 } ms.Write(BitConverter.GetBytes((ushort)body.Count)); ms.Write(body.ToArray()); return ms.ToArray(); }这段代码有个很现实的问题:命令字段写的是0x0114,也就是批量读,不仔细看会直接把读命令的帧改成写入,导致PLC每次返回C0错误。写入命令应该是0x0115,批量写入,子命令同样要对应设备类型。把这个命令字节提取成常量是值得的:
public static readonly byte[] CmdBatchRead = { 0x01, 0x14 }; public static readonly byte[] CmdBatchWrite = { 0x01, 0x15 };写入响应只有响应代码,没有数据区。判断成功与否就是检查响应代码0x0000。还有一点:写M继电器时values数组里的值只需要0或1,直接写成0x00和0x01,不要传ASCII字符“0”“1”,PLC不会帮你转。
4.3 浮点数与字符串:D寄存器的复合数据类型读写
D寄存器最常见的复合数据是32位浮点数和字符串。三菱Q系列PLC里浮点数占用两个字,单精度32位。至于哪个字是高16位,不同批次、不同设置会有差异,而且这部分特别容易记反。最可靠的办法不是背文档,而是做一次往返测试:PLC里放一个已知的1.0或者3.14,读回来用C#的BitConverter.ToSingle验证,能对上就认定这套字节序组合可用。
public static float[] ParseFloatWords(byte[] data, int startIndex) { float result = BitConverter.ToSingle(data, startIndex); return new[] { result }; }如果读出来的值是个天文数字,比如1.0变成1.401298e-45这种接近0的极小值,基本可以确定是32位内部两个字的顺序反了。处理方式是把两个字先按小端还原成4字节数组,再交换前后两个16位块重新解释。现场实践中,Q系列FLOAT存储顺序和我们按D寄存器地址递增顺序拼接高字低字的方式,必须在项目启动时就固定下来,否则后期换人维护容易互相“修正”出相反结果。
字符串读取则简单得多。每个字符占一个字,两个字节能表示一个ASCII字符。读取长度为N的字符串,点数就填N,响应数据区每两字节还原一个char。编码上常见的是ASCII或Shift-JIS,C#端用Encoding.ASCII或对应代码页解析。不要天真地认为所有PLC字符串都是UTF8,三菱老项目里ASCII占多数,按UTF8读会把中文读成一堆乱码。
public static string ParseAscii(byte[] data, int count) { var chars = new byte[count * 2]; Array.Copy(data, chars, Math.Min(chars.Length, data.Length)); var sb = new StringBuilder(count); for (int i = 0; i + 1 < chars.Length; i += 2) { sb.Append((char)chars[i]); // 低字节是ASCII码 } return sb.ToString().TrimEnd('\0'); }这里只取低字节是常见套路,因为ASCII字符的高字节通常是0。但空字符可能夹杂在字符串中间,TrimEnd只去掉结尾的\0,中间的空字符需要根据业务自行判断。写入字符串时反过来,把字符串的每个字符放进字的低字节,高字节补0,然后按写字软元件的方式发送。
5. MC协议通信避坑指南:超时、错位与错误码
5.1 现象:第一次读写成功,第二次就超时
上位机程序启动后第一次读D寄存器正常,第二次连接同一台PLC就卡住直到超时。这种问题在刚上手MC协议时出现频率极高。
原因通常是TcpClient没有正确复用,每次读写都重新new一个TcpClient,旧的连接没释放,PLC侧以太网模块的连接资源被占满。还有一种情况是写完数据后没有读取响应就发起第二次请求,TCP缓冲区里残留着上一个响应,后续报文全部错位。
解决方法是把TcpClient和NetworkStream提升为类字段,连接成功后持续复用。每次读写操作进行串行化,用SemaphoreSlim保证同一时间只有一个请求在途。如果使用了HttpClient那套“用完即弃”的习惯,一定要改过来,PLC不是Web服务器,连接池对它不友好。这个坑之所以玄学,是因为有时连着读几十次都正常,一旦某个请求异常就再也缓不过来了。
5.2 现象:读上来的数值翻倍或者全部变成了0或65535
读D100返回的数据和PLC监控窗口里的值对不上,比如监控里显示100,C#读出来是25600,又或者读一串数据时第0个值正确,后面对应关系整体错位一个字节。
原因基本集中在两个点:一是数据解析用了大端字节序,把D寄存器的两字节反过来拼了;二是点数计算错误,PLC返回数据时每个字两字节,C#按字节数而不是字数去切分数据段,导致后续数据全部错位。至于读出来65535,通常是读取了未初始化的地址区域,或者PLC型号不支持这个地址段。
解决方法是先固定一个已知值做往返验证。比如在PLC里给D100写入十六进制0x1234,C#读出来如果变成0x3412,说明解析端字节序反了,需要交换高低字节。写一个16进制打印函数把响应数据完整打出来,用肉眼和报文示例比对,比盲目改代码更快。另外批量读取时,请求点数填的是“字”数不是字节数,响应数据长度用点数乘以2来校验。
5.3 现象:PLC返回错误码C0或D0,报文看起来没问题
报文从构造到打印都没毛病,但PLC响应错误码不是0x0000。C0和D0在MC协议错误码表里是高频出现项。
C0通常是存取范围异常,也就是访问了不存在的软元件地址或超过该软元件的最大地址范围。D0通常是数据异常,常见于请求数据长度与实际数据区长度不一致,或者设备代码不被该PLC或模块支持。还有一种隐蔽情况是命令代码里混入ASCII帧的命令写法,比如用ASCII帧的命令0x0401去请求二进制帧格式,PLC直接判定命令错误。
解决方法是把错误码对应表打印在日志里,并附带上完整的请求hex和响应hex。C0先检查起始地址和点数是否越过了该软元件的允许范围;D0先检查body长度和实际发送字节数是否一致。以上都确认没问题,再检查帧类型和命令是否匹配。我这里吃过亏:代码里构造报文时命令字节写反,低字节高字节颠倒,报文打印出来却因为人工看时下意识按“0141”脑补成“0114”,愣是排查了半小时。
5.4 现象:模拟器或调试工具一切正常,连真机就随机失败
用PLC模拟器或者官方调试软件测试没有任何问题,换到现场真机后通信时好时坏。这个现象最容易被归结为“玄学”,实际上原因往往很具体。
现场网络环境里存在交换机、线缆长度、电磁干扰,TCP报文碰撞重传的概率比实验室高得多。但更深层的原因通常是PLC扫描周期和以太网模块缓冲区。如果上位机读取点数过大,比如一次性读1000个D寄存器,以太网模块需要在下一个扫描周期内准备数据,扫描周期一长就会触发监视定时器超时。另外,PLC程序本身如果写了频繁往同一批D寄存器写入的梯形图逻辑,上位机读到的数据有概率是写入前后的中间态,这不是通信错误,是数据一致性问题。
解决方法是控制批量点数,一次读256个字以内比较稳妥;轮询周期不要低于PLC扫描周期的3倍。真机上做24小时连续读写测试很有必要,我习惯先测短包5000次,再测长包5000次,通过后再接入生产逻辑。随机失败的案例里,有很大比例是网线质量导致的重传丢包,现场抓包能看到TCP重传频繁,换一根六类线就好了。
5.5 现象:多线程读写时数据错乱,响应和请求对不上
上位机界面用多个线程分别读温度、读报警、写配方,结果偶尔读到的是别组请求的返回数据。这个问题不只是MC协议独有,所有请求响应式TCP协议都会遇到。
原因就是多个线程同时往同一个NetworkStream写数据,PLC按顺序返回响应,但C#侧不知道该响应对应哪个请求。错位一旦发生,解析出来的数据全是错的,而且不会自己恢复。
解决方法是把通信层改成单连接串行队列,所有读写操作先进入SemaphoreSlim闸门,同一时间只允许一个请求在途。不要试图用请求ID来匹配响应,因为MC协议的响应报文里没有携带请求ID字段,做不到多路复用。我习惯把通信层封装成独立的PlcService类,对外只暴露异步方法,内部统一加锁,业务线程不用关心并发问题,只在调用端设置合适的超时重试即可。
6. 进阶落地:把读写封装成异步服务并加三个验证
到这一步,单次读写的代码已经能跑,但接入上位机项目还差一层服务封装。我习惯把一个PLC通信模块做成只暴露业务语义的异步服务,内部维持连接、加锁、超时重试和断线重连。核心设计是:所有读写方法都走同一个执行通道,连接一旦断开,下次调用自动触发重连,不把处理连接的逻辑散落在业务代码里。
public class PlcMcService { private readonly McTcpClient _client; private readonly int _maxRetry = 3; public PlcMcService(string ip, int port) { _client = new McTcpClient(ip, port); } public async Task<ushort[]> ReadDAsync(int start, int count) { for (int attempt = 0; ; attempt++) { try { await _client.ConnectAsync(3000); var frame = McFrameBuilder.BuildReadFrame(0x00A8, start, count); var data = await _client.SendAsync(frame); return McFrameParser.ParseWords(data, count); } catch (IOException) when (attempt < _maxRetry) { await Task.Delay(200); _client.ForceReconnect(); } catch (TimeoutException) when (attempt < _maxRetry) { await Task.Delay(100); } } } }重试只对网络异常和超时生效,响应错误码异常不要重试,因为请求数据本身有问题,重试只会重复犯错。断线重连时把旧TcpClient释放干净,避免半开连接状态,然后重新建立。加锁已经在SendAsync内部做了,业务层这个方法天然线程安全。
上真机验收时,我固定做三个验证点。第一,读一个已知固定值的区域,比如PLC里预设的一段测试D区,确认返回值与梯形图写入的一致。第二,连续读写同一批地址至少5000次,中途随机验证数据一致性。第三,拔掉网线模拟断线,恢复网线后确认自动重连且数据连续。前两道关过了,通信层基本就能扛住产线环境。
以上这套方案里,最让我长教训的是命令字节和字节序。命令0x1401和0x1402这两个数看起来相似,一旦写成0x0114和0x0115,PLC会一直返回错误码;D寄存器的字节序错了,所有数值都带上“放大”效果,且很难定位。现在每上一个新PLC型号,我都会先看设备代码表和该型号的批读点数上限,把这些整理成一个常量文件,后续维护成本低很多。希望帮到你。
本文还有配套的精品资源,点击获取