ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#封装松下MEWTOCOL PLC通信库:帧解析与采集服务

C#封装松下MEWTOCOL PLC通信库:帧解析与采集服务

简介:这是一份面向工业自动化上位机开发者、C#/.NET程序员与松下PLC现场调试人员的通信组件源码包,用于在自有软件中快速接入MEWTOCOL协议,完成对松下PLC的触点与数据区读写。包内实现XYR触点读取与写入,支持RCS/RCP/RCC及WCS/WCP/WCC方式并反馈ON/OFF状态;同时覆盖DT区RD/WD、PLC状态RT与远程控制RM,便于搭建监控、参数配置和故障诊断类应用。压缩包共57个文件,约310KB,以9个C#源码、7个DLL组件、解决方案与项目配置(sln、csproj、json、props、targets)为主,并含示例窗体、调试输出及跨平台运行库文件,结构紧凑、依赖清单完整。已有297人学习下载,适合希望参考成熟封装、理解命令帧组织与通信异常处理思路的中高级开发者。

1. 产线那台松下 PLC,串口助手调了半小时才读回一个 DT0

第一次接松下 FP 系列 PLC 的时候,我盯着通讯手册里几十页的帧格式发懵:一个%开头、$分隔、\r结尾的 MEWTOCOL-COM 字符串,配合软元件代码和两位响应码,串口助手里敲了半小时才把 DT0 的值读回来。这份C# 封装的松下 Panasonic MEWTOCOL 通信库,要做的就是把这些工作全打包进一个类库里:拼帧、收发、超时重连、响应码校验、位/字软元件解析、32 位整数和 REAL 的字序转换。业务代码最后只剩一句plc.ReadWord("D", 0)。它适合做产线数据采集、上位机监控、MES 边端通讯的开发者,尤其是手上已经有一堆 WinForm/WPF 客户端要接c# MEWTOCOL 松下PLC,又不想为几条读写上商业组态软件的场景。

2. 库的整体架构:从 TcpClient 到软元件地址的一层层剥开

拿到这种通信库,我第一件事不是看 API,而是先看它怎么分层。分错了层,后面加个机型、换个数采周期就得动全身。这个库的层次感比较清楚,往下拆是四层,往上写业务只碰最上面一层。

2.1 目录结构与各层职责

一个典型的封装目录大概长这样,名字可能不同,但职责边界基本是这几块:

Mewtocol/ ├── Transport/ │ ├── ITcpTransport.cs 传输抽象,后期换串口/网关只实现这个接口 │ └── TcpTransport.cs TcpClient 封装,负责连接、收发、超时 ├── Protocol/ │ ├── MewFrame.cs 帧拼装与响应帧解析 │ ├── MewCommand.cs 命令码、软元件代码常量 │ └── MewProtocolException.cs 带响应码的异常类型 ├── Client/ │ ├── PlcClient.cs 对外 API:ReadWord / WriteWord / ReadWords... │ └── PlcConnection.cs 连接状态与重连策略 └── Converters/ ├── WordConverter.cs short / int / float 与字数组互转 └── BitConverterEx.cs 位软元件打包解包

传输层只认字节流,不认 PLC;协议层只认字符串帧,不认业务;Client 层才是开发者天天见的ReadWord、WriteWords。这个切法有个实际好处:现场从以太网换成 RS485 转以太网模块时,只要新写一个ITcpTransport实现,协议层一行不动。很多刚入行的写法是把TcpClient直接塞进ReadWord里,第一次改通信介质就得把整个文件重写一遍,这是踩过的血泪经验。

2.2 一个读取请求的完整生命周期

把一次ReadWord("D", 100)拆开看,它经过了这些站点:

  1. PlcClient检查连接状态,不健康就调EnsureOpen()重建;
  2. 进 IO 锁(MEWTOCOL-COM 是严格的一问一答,同一连接上不能并发发帧);
  3. MewCommand把软元件代码D和地址 100 拼成帧体;
  4. MewFrame.BuildCommand加上%、两位站号、#、四位命令码和结尾 CR;
  5. 传输层写出,然后按 CR 收帧,不是 Read 一次就当收全(半包是必然会遇到的);
  6. 响应帧解析:校验帧头、分隔符、命令码回显、两位响应码;
  7. WordConverter把 4 个十六进制字符转成ushort返回。

第 5 步和第 6 步是最容易被简化掉的地方。很多简化版直接stream.Read(buf, 0, 1024)然后用Trim()处理,本地测试跑得好好的,一上产线遇到网络抖动就偶发解析失败——因为Read返回的是"当前有多少给多少",不是"一帧"。这个黑匣子不看清楚,后面全是玄学。

2.3 帧格式与命令码表

MEWTOCOL-COM 的帧结构是纯 ASCII,一目了然:命令帧是%+ 站号(2 位) +#+ 命令码(2 位) + 数据段 + CR;响应帧把#换成$,并在命令码后面多出两位响应码,00表示正常,非00就得查手册的错误码表——21是最常见的,基本都是软元件地址写错或者超出范围。

命令作用典型用途
RD / WD单字读 / 单字写读一个 DT 寄存器、写一个设定值
RSD / WSD多字读 / 多字写一次拉一批工艺参数
RCS / WCS单点读 / 单点写读一个 X 输入点、置一个 Y 输出
RCP / WCP多点读 / 多点写批量读 32 个内部继电器状态
RT / WT定时器/计数器读写采集节拍、产量计数
AB中止异常时中断长帧传输

软元件代码是另一张表,常用的就那几个:D数据寄存器、L链接寄存器、F文件寄存器、X外部输入、Y外部输出、R内部继电器、T定时器、C计数器、S特殊内部继电器。这里有个必须提前说清的点:不同机型、不同软元件的地址位数并不统一,多数 FP 系列按 5 位十进制补零,个别机型的位软元件要 6 位。别照抄任何人的常数,包括我下面代码里的,先拿一个 DT0 做单点验证,通了再批量。

3. 核心模块拆解:连接、拼帧、收帧与单点读写

这一章是真正要抄作业的部分。通信库翻车的概率,八成落在"连接管理"和"收帧"两件事上,协议本身反而简单。

3.1 连接与重连:把 Socket 的脾气关进笼子里

PLC 的以太网口不像 HTTP 服务那样随叫随到,长时间空闲后连接被中间设备悄悄回收是常态,所以连接对象必须是"可重建"的,而不是构造一次用到底。

public sealed class PlcConnection : IDisposable { private readonly string _host; private readonly int _port; private readonly int _stationNo; // PLC 站号,单机默认 1 private readonly int _timeoutMs; // 单次收发超时,产线上给 800~1500ms private readonly object _ioLock = new object(); private TcpClient _client; private NetworkStream _stream; public PlcConnection(string host, int port = 1025, int stationNo = 1, int timeoutMs = 1000) { _host = host; _port = port; _stationNo = stationNo; _timeoutMs = timeoutMs; } public bool IsConnected => _client != null && _client.Connected; public void EnsureOpen() { lock (_ioLock) { if (IsConnected) return; Close(); // 先把残骸清干净,别复用半死连接 _client = new TcpClient { NoDelay = true }; // 小包别等 Nagle 合并,延迟敏感 _client.SendTimeout = _timeoutMs; _client.ReceiveTimeout = _timeoutMs; _client.Connect(_host, _port); _stream = _client.GetStream(); } } // Close / Dispose 省略:关流、关客户端,两个都要 try-catch 包住 }

几个参数值得解释。port在以太网机型上是可以在 PLC 侧配置的,现场经常不是默认值,接不上先确认这一项。NoDelay = true是因为 MEWTOCOL 帧都很短(十几个字节),Nagle 算法会把它们攒着发,表现出来就是"明明网络不慢,但每次读写要多等几十毫秒",这种延迟在 100ms 级的采集周期里非常致命。_ioLock是给后面收发用的,MEWTOCOL-COM 在同一连接上不支持交叉请求,两个线程同时发帧,回来的数据一定会串。

3.2 拼帧与解帧:响应码校验是最容易被跳过的一步

协议层我一般写成静态类,无状态、好测。拼帧简单,解帧要把每一处不合规都当成异常抛出来,而不是吞掉返回默认值——PLC 通信最怕的就是"静默错误",页面上数字不动,你还以为是工艺没变化。

internal static class MewFrame { // 命令帧:% + 站号(2) + '#' + 命令码(2) + 数据段 + CR public static byte[] BuildCommand(int station, string command, string body) => Encoding.ASCII.GetBytes($"%{station:D2}#{command}{body}\r"); // 响应帧:% + 站号(2) + '$' + 命令码(2) + 响应码(2) + 数据段 + CR public static string ParseResponse(byte[] raw, int offset, int count, string expectCommand) { string frame = Encoding.ASCII.GetString(raw, offset, count).TrimEnd('\r', '\n'); if (frame.Length < 7 || frame[0] != '%') throw new MewProtocolException($"帧头非法: {frame}"); if (frame[3] != '$') throw new MewProtocolException($"分隔符非法,疑似 PLC 报错帧: {frame}"); string cmd = frame.Substring(4, 2); if (cmd != expectCommand) throw new MewProtocolException($"命令码不匹配,期望 {expectCommand} 实际 {cmd}"); string code = frame.Substring(6, 2); if (code != "00") throw new MewProtocolException($"PLC 返回响应码 {code}", code); return frame.Substring(8); // 数据段,没有数据时是空串 } }

frame[3]这个位置判断很关键。PLC 在遇到非法请求时,回的可能不是标准响应帧而是错误帧,$就变成了别的位置或者干脆没有,这时候直接跑Substring(6, 2)会抛ArgumentOutOfRangeException,堆栈指向协议解析,把人往错误方向带。把"结构不对"和"业务报错"分开抛,现场排障能省一半时间。code单独放进异常的Code属性,上层就能区分处理:21是地址问题,改代码;超时是链路问题,重连。

3.3 单点读写:收发必须按 CR 收帧

单点读写是整库的地基。这里最容易被写错的是接收——NetworkStream.Read是流式的,一帧可能分两次到,也可能和下一帧粘在一起(粘包在串行请求下概率低,但半包一定会遇到)。

public ushort ReadWord(string area, int address) { EnsureOpen(); lock (_ioLock) { string body = $"{area}{address:D5}"; byte[] req = MewFrame.BuildCommand(_stationNo, "RD", body); _stream.Write(req, 0, req.Length); byte[] buf = new byte[256]; int n = ReadUntilCr(buf); string data = MewFrame.ParseResponse(buf, 0, n, "RD"); return Convert.ToUInt16(data, 16); // RD 回 4 个十六进制字符,如 "0123" 即 0x0123 } } private int ReadUntilCr(byte[] buf) { int total = 0; var deadline = DateTime.UtcNow.AddMilliseconds(_timeoutMs); while (total < buf.Length) { if (!_stream.DataAvailable) { if (DateTime.UtcNow > deadline) throw new TimeoutException("等待 PLC 响应超时"); Thread.Sleep(2); // 别用 Read 阻塞等,超时不好控 continue; } int n = _stream.Read(buf, total, buf.Length - total); if (n <= 0) throw new IOException("PLC 主动断开连接"); total += n; if (buf[total - 1] == 0x0D) return total; // CR 到了才算一帧结束 } throw new MewProtocolException("响应帧超长,接收缓冲区不够"); }

ReadUntilCr用DataAvailable+ 短睡眠轮询,而不是靠ReceiveTimeout让Read自己抛异常。原因是ReceiveTimeout作用在整个Read调用上,一旦已经收到半帧、后面卡住,异常信息和实际状态对不上,排查时很难判断是"一个字节都没来"还是"来了半截"。自己算 deadline,超时点清楚,日志里能写明白。

写单点对称,只是数据段多一个值:

public void WriteWord(string area, int address, ushort value) { EnsureOpen(); lock (_ioLock) { string body = $"{area}{address:D5}{value:X4}"; // 写入值用 4 位十六进制 byte[] req = MewFrame.BuildCommand(_stationNo, "WD", body); _stream.Write(req, 0, req.Length); byte[] buf = new byte[256]; int n = ReadUntilCr(buf); MewFrame.ParseResponse(buf, 0, n, "WD"); // 写命令数据段为空,只看响应码 } }

注意写入值用X4而不是D5。MEWTOCOL 的写入数据段是十六进制的字值,写成十进制字符串会得到一个语法上合法、语义上完全错误的帧,PLC 不会报错,只是把值写成了别的数——这种错误最难查,因为整个链路"看起来是通的"。写完立刻回读一次做校验,是我给自己定的规矩。

4. 批量读写与类型转换:把 16 位字用成 C# 的 int、float 和 bool

单点读写打通以后,业务马上会提新需求:一次读 50 个寄存器、读一组输入点、把两个寄存器拼成一个温度浮点数。这一层做不好,业务代码里会满地都是Convert.ToUInt16和移位运算。

4.1 批量读的地址展开与分块

批量读用RSD,帧体是"软元件代码 + 起始地址 + 点数",返回一串连续的十六进制字符。看着简单,但有两点必须处理:一是单帧点数不能贪多,手册标称的上限拉到满,个别机型会直接不响应或者超时;二是一帧失败要能定位到具体是哪一段,而不是整个方法抛异常什么信息都没有。

private const int MaxWordsPerFrame = 32; // 保守值,实测稳定比贴着手册上限走更省心 public ushort[] ReadWords(string area, int start, int count) { var result = new ushort[count]; int done = 0; while (done < count) { int chunk = Math.Min(MaxWordsPerFrame, count - done); string body = $"{area}{start + done:D5}{chunk:D4}"; string data = Execute("RSD", body); // 返回 chunk * 4 个十六进制字符 for (int i = 0; i < chunk; i++) result[done + i] = Convert.ToUInt16(data.Substring(i * 4, 4), 16); done += chunk; } return result; } private string Execute(string command, string body) { byte[] req = MewFrame.BuildCommand(_stationNo, command, body); _stream.Write(req, 0, req.Length); byte[] buf = new byte[2048]; int n = ReadUntilCr(buf); return MewFrame.ParseResponse(buf, 0, n, command); }

MaxWordsPerFrame取 32 是个经验值:一帧 32 个字,返回帧大约 140 字节,绝大多数机型和转换模块都吃得下;再往上拉,遇到 RS485 转以太网的网关就容易半包、丢帧。分块还有一个隐性好处——某一帧失败时,异常里带上start + done就能直接定位到具体地址段,不用猜。缓冲区开 2048 是留够余量,ReadUntilCr里有越界保护,不会静默截断。

4.2 位软元件批量读:回来的是一串字符,不是位图

第一次读 X 输入点的时候我愣住了:返回的不是压缩位图,而是一串'0'和'1'的 ASCII 字符,每个字符对应一个点。所以解析逻辑比想象的简单,但也意味着点数一多,帧长会线性膨胀——读 32 个点返回 32 个字符,读 256 个点就 256 个字符,这时必须分块,否则直接超缓冲区。

public bool[] ReadContacts(string area, int start, int count) { var bits = new bool[count]; int done = 0; while (done < count) { int chunk = Math.Min(64, count - done); string data = Execute("RCP", $"{area}{start + done:D5}{chunk:D4}"); for (int i = 0; i < data.Length && i < chunk; i++) bits[done + i] = data[i] == '1'; done += chunk; } return bits; }

这里有个小坑:别用data.Length当循环边界就直接done += data.Length。万一 PLC 返回的字符数比请求的少(某些机型的位软元件地址被跳过了),后面的下标就会错位,整个数组全部偏一格,页面上表现为"点号整体位移",看着像接线接错了,其实是解析错位。用chunk作为步进,用Math.Min兜住越界,是最稳的写法。

4.3 32 位整数与 REAL:字序是最容易翻车的地方

PLC 的一个寄存器是 16 位,业务要的int、float都是 32 位,得占两个寄存器。麻烦在于高低字的顺序。多数 FP 系列在 DT 里存 32 位数据是低位字在前,而 C# 的BitConverter按小端字节序处理,两者拼起来时要先组合成 32 位整数,再转成目标类型,不能直接把两个字的内存连起来。

public static class WordConverter { // 低位字在前,组合成 32 位有符号整数 public static int ToInt32(ushort low, ushort high) => (int)(((uint)high << 16) | low); // 高位字在前时用这个:先交换再走同一套逻辑 public static int ToInt32Swapped(ushort low, ushort high) => ToInt32(high, low); // REAL(IEEE754 单精度),同样是低位字在前 public static float ToReal(ushort low, ushort high) => BitConverter.ToSingle(BitConverter.GetBytes(ToInt32(low, high)), 0); // 有符号 16 位寄存器,直接强转,别用 ToUInt16 再判断 public static short ToInt16(ushort raw) => unchecked((short)raw); }

ToInt16这个函数值得单说,因为负数寄存器是补码表示,用Convert.ToUInt16拿到 0~65535 直接塞给界面,温度显示 65486 度的事故真发生过。用unchecked((short)raw)一行解决,注意必须unchecked,否则编译器的溢出检查在某些项目配置下会抛异常。浮点数如果算出来是几十亿这种离谱值,先把高低字对调试一次——这是最常见的字序问题,但也可能是 PLC 那边压根没按 REAL 存,先用编程软件在线监控对照一遍再改代码。

5. 避坑与排查:现场最常翻车的五个点

这几个问题都是我在不同现场真遇到过的,写下来按"现象 → 原因 → 解决"的顺序排,遇到类似的直接对号入座。

5.1 连接层:能 ping 通却连不上,连上了又假死

现象:笔记本 ping PLC 通,程序连上去也成功,跑十几分钟以后所有读操作超时,重启程序又好了。

原因:连接被中间交换机或者 PLC 自身的空闲回收机制断掉了,而TcpClient.Connected这个属性是上一次 IO 的状态快照,它并不主动探测,所以IsConnected一直返回 true,代码以为连接还活着,就往一个死 socket 里写数据。

解决:不要依赖Connected做健康判断,改成"失败即重建"。捕获IOException、SocketException、TimeoutException之后,把连接整体Close()掉,下一次EnsureOpen()重新建立;同时加一个保活心跳,空闲超过 30 秒就主动读一个已知寄存器,把链路捂热。

5.2 数据层:读回来的数乱得离谱

现象:DT100 在 PLC 编程软件里显示 25,程序读出来是 6400 或者 65486。

原因:三种可能。写帧时值用了十进制字符串(应该用十六进制);有符号数当无符号处理;32 位数据的字序搞反了。这三种都不会让 PLC 报错,响应码照样是00。

解决:先做一次写读校验——写一个已知值再读回来,能立刻定位是写方向还是读方向的问题。有符号一律走unchecked((short)raw),32 位数据准备好正反两种转换函数,现场切换验证。

5.3 协议层:偶发解析异常,堆栈指向 Substring 越界

现象:程序跑几天偶尔抛ArgumentOutOfRangeException,位置在协议解析里,重试一次又好了。

原因:半包。NetworkStream.Read收不全一帧就返回了,解析时字符串长度不够。

解决:接收必须按 CR 收帧,见第 3 章的ReadUntilCr;同时所有Substring前先判长度,把"帧不完整"和"帧内容错"分成两种异常抛,日志里把原始字节用十六进制打出来,一眼就能看出是不是只有半截。

5.4 并发层:多线程同时读,数据张冠李戴

现象:采集线程和界面线程同时读同一台 PLC,偶尔某个寄存器的值和别的寄存器搞混,或者一个线程拿到另一个线程的响应。

原因:MEWTOCOL 是严格的一问一答,共用一条连接时没有请求 ID,两个线程交叉发帧,回来的响应谁先抢到算谁的。

解决:所有收发加同一把锁(代码里那个_ioLock),或者干脆做成单连接串行队列。如果业务上确实需要高并发,就开多条连接,一条连接一个消费线程,别在同一 socket 上玩并行。

5.5 部署层:产线重启后第一次读必定失败

现象:PLC 断电重启或者产线换班重启设备,程序第一次读一定超时,第二次就好。

原因:PLC 侧还没完成初始化,或者上一次的旧连接在 PLC 侧还没释放,新连接被拒绝;也有的是 Ethernet 模块需要几秒启动时间。

解决:EnsureOpen()外面套重试,3 次、间隔 500ms,别一次失败就抛给界面。另外程序退出时一定要Dispose关连接,不然 PLC 侧连接数会累积,几天后新连接建不起来,症状是"以前好好的,突然就连不上了"。

6. 二次开发:把它改成一个能长期跑的采集服务

库本身只解决"能通",真上线还得往上叠一层。我一般不加在库内部,而是新建一个PlcPoller,做出"轮询 + 变化检测 + 事件抛出去"的结构,库保持纯粹,业务侧随便换。

public sealed class PlcPoller { private readonly PlcClient _plc; private readonly int _start, _count; private readonly ushort[] _last; public event Action<int, ushort, ushort> OnChanged; // 偏移、旧值、新值 public PlcPoller(PlcClient plc, int start, int count) { _plc = plc; _start = start; _count = count; _last = new ushort[count]; } public void PollOnce() { var cur = _plc.ReadWords("D", _start, _count); for (int i = 0; i < cur.Length; i++) { if (cur[i] == _last[i]) continue; OnChanged?.Invoke(i, _last[i], cur[i]); _last[i] = cur[i]; } } }

这个结构的好处是把"读"和"用"解耦了。上层订阅OnChanged往数据库写、往 MES 推,采集节奏和业务处理节奏互不影响;采集中间抛异常,只要在外层try-catch里打日志、让下一轮继续跑,服务就不会因为一次网络抖动整个挂掉。

改造时我会按这几个方向下手:周期别设太短,200ms 起步,PLC 的扫描周期通常比这慢,读太快只是徒增带宽;地址表做成配置(JSON 或数据库都行),别硬编码在代码里,加一个采集点就重新编译发布的日子很难受;断线要能自动恢复并且把恢复事件记下来,工控现场最怕的是"悄悄不采了";写入单独走一条路径,做好权限和回读校验,一个误写的寄存器可能让产线停半天。

验证方法我固定用两套:一是拿 PLC 编程软件的在线监控做对照,同一时刻看同一地址,数值必须一致,包括负数和浮点数;二是写一个自测方法,往一片空闲的 D 区依次写 0、-1、32767、-32768、12345、浮点数,再读回来比对,跑通这套,字序和符号问题基本能一次排干净。这套自测我放在项目的单元测试里,改完转换代码先跑它,比上产线试快一百倍。

从那以后我每次接新机型,都强制自己先只读一个 DT0 打通链路,再往上堆批量、堆类型转换、堆采集服务——顺序反了,出问题时你根本不知道是协议错、地址错还是业务错,只能一点点往回剥,那才是真的耗时。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进