☰
C#上位机松下PLC Mewtocol-COM串口通讯与BCC校验实战
2026/10/26 13:42:14 网站建设 项目流程

简介:面向C#上位机开发者的松下PLC串口通讯实战DEMO,基于Mewtocol-COM协议,解决工控软件与松下PLC之间读写交互的落地问题。工程可直接编译运行,覆盖RCS读单触点、WCS写单触点、RD读单个数据寄存器、WD写单个数据寄存器,以及RCC按字读取Y0—YF、R0—RF这一类触点状态,并支持多寄存器批量读写,基本囊括产线调试中最常用的指令组合。压缩包共36个文件、约119KB,以8个C#源码文件(含Panasonic-COM.cs通讯封装与Form1窗体逻辑)为主,配合sln与csproj工程文件、App.config配置、resx资源及bin、obj下的编译产物与缓存,解压后可直接用Visual Studio打开对照学习。已有1089人学习下载。读者可从中获得可复用的通讯类封装思路、串口收发与报文拼接写法、各指令的调用示例,以及从窗体交互到通讯层的分层组织方式,适合入门工控上位机或需要快速复用串口通讯模块的开发者参考。

1. 老设备只有一个串口,活最后还得自己写

车间里一台用了七八年的设备,控制核心是松下 FP 系列 PLC,柜门上只剩一个 DB9 串口,甲方要求上位机每 200 ms 采一轮 DT 区数据,还要能把配方参数写回去。这种活绕不开自己写通讯层——上组态软件太重,买现成驱动不划算。真正卡人的不是 C# 的SerialPort,而是 Mewtocol-COM 这套帧:一帧从%开头、CR结尾,中间夹着站号、指令、参数和两位 BCC,错一个字符 PLC 就一声不吭,收到的全是乱码。这份 C# 上位机 DEMO 就是围绕松下 PLC 的 Mewtocol-COM 串口通讯做的收发层,串口参数对齐、帧拼装、BCC 校验、超时重发、寄存器地址映射都在里面,做完之后读写 DT、WR、X/Y 就是几行调用。适合手上有松下 PLC 串口、要自己做采集和下发的工控开发者,也适合刚接手老设备、被一串十六进制劝退的人。

2. 拆开 Mewtocol-COM 的一帧:%01RD00000里每个字符都有位置

Mewtocol-COM 这套协议最大的好处是纯 ASCII、肉眼可读,最大的坏处也是这个——看着像文本,实际上每一位都有严格定义,缺一位或者多一位,PLC 那边直接当噪声丢掉。所以第一步不是打开 Visual Studio,而是拿一张纸把帧结构画出来。

2.1 六段结构:头、站号、指令、参数、BCC、帧尾

一帧固定由六段拼成,顺序不能换:

段长度内容示例
起始符1 字符%(0x25)%
站号2 字符ASCII 十进制,01~9901
指令2 字符读/写/触点操作等RD
参数变长地址、点数、写入值00000
BCC2 字符前面所有字符的异或,十六进制大写02
帧尾1 字符CR(0x0D)\r

以读 DT0 的单个字为例,去掉 BCC 和 CR 的主体是%01RD00000:%是起始,01是 1 号站,RD表示读数据寄存器单点,后面 5 位00000是 DT 编号。拼成完整帧就是%01RD000002\r。这串东西直接丢进串口助手、按 ASCII 发、勾上「发送新行」以外的十六进制尾,PLC 正常会回一帧%01RD+ 4 位十六进制数据 + BCC + CR。

提示:串口助手调试时一定要用「ASCII 发送 + 手动追加 0x0D」,别用「发送新行」,因为不同工具追加的可能是\r\n两个字节,多了个\nPLC 就判帧尾错。

参数段的位宽是第一个容易出错的地方。DT 编号常见是 5 位十进制,点数常见是 3 位或 4 位,不同系列(FP0R、FP-X、FP-XH、FP7 这一串)在个别指令上有出入。我一般的做法是把位宽抽成常量,代码里改一行就能对上随机手册,而不是把"D5"硬编码到十个地方。

2.2 串口参数必须和 PLC 侧系统寄存器逐项对齐

帧拼对了但串口参数不一致,现象是「完全没反应」或者「收到一堆问号」,这两种失败长得非常像,容易误判成接线问题。松下 PLC 的通讯格式由系统寄存器设定,不同系列编号不同,常见在 410~413 这个区段里,其中一项管波特率、数据长、校验方式、停止位的组合值。用编程软件连上去看一眼这一项,比在代码里瞎试快得多。

出厂默认最常见的是 9600、8 数据位、奇校验、1 停止位、无流控。注意这个「奇校验」——很多 USB 转串口工具和示例代码默认是Parity.None,一上来就对不上,改了这一项往往立刻通。

参数常见取值说明
波特率9600 / 19200 / 38400 / 57600 / 115200距离远、线材差时优先降速
数据位8基本不用改
校验Odd(奇校验)出厂常见,必须与系统寄存器一致
停止位1少数配置为 2
流控NoneRS485 两线时尤其要关掉
站号011:1 连接固定 01,1:n 才需要区分

走 RS485 两线做 1:n 的时候,物理层上多个 PLC 挂在同一对差分线上,靠站号区分。这时候绝对不能并发发帧——一条总线上同一时刻只能有一个说话的人,所有读写必须串行进一个队列。很多现场「偶尔读错一个值」的玄学问题,根子就在这里。

2.3 BCC 手算一遍,比看十页文档管用

BCC 的算法简单到容易被轻视:从起始符%开始,一直异或到参数段的最后一个字符,结果转成两位大写十六进制。注意范围是「含%、不含 BCC 自己和 CR」。

拿%01RD00000手算一遍:

0x25 ^ 0x30 = 0x15 // % ^ '0' 0x15 ^ 0x31 = 0x24 // ^ '1' 0x24 ^ 0x52 = 0x76 // ^ 'R' 0x76 ^ 0x44 = 0x32 // ^ 'D' 0x32 ^ 0x30 = 0x02 // ^ 第一个 '0' 0x02 ^ 0x30 = 0x32 0x32 ^ 0x30 = 0x02 0x02 ^ 0x30 = 0x32 0x32 ^ 0x30 = 0x02 // 五个 '0' 异或完,结果 0x02

所以 BCC 是02,完整帧%01RD000002+CR。手算这一遍的价值在于:以后收到 BCC 报错,你能立刻判断是「发送侧拼错」还是「接收侧截断」,而不是干瞪眼。

2.4 正常响应和异常响应长得不一样

正常响应是「原样回显站号和指令,后面跟数据」,比如读 DT0 得到 1234H,回%01RD1234+ BCC + CR。异常响应的指令位变成一个叹号:%01!+ 两位错误码 + BCC + CR。

这个叹号是整个排错流程的分水岭。收到!说明帧本身被 PLC 认出来了,问题在参数层面——地址越界、点数超限、当前运行模式下该区不允许写、写入值超出范围,这几类占绝大多数,对着手册的错误码表查一下就能定位。收不到任何回应才是链路层的问题:接线、波特率、校验位、站号、帧尾。这两类故障的排查路径完全不同,先分清能省一半时间。

3. C# 串口层怎么写:把收发做成一个能反复用的黑匣子

上位机代码里最容易失控的部分就是串口。有人把SerialPort直接拖到窗体上,在按钮事件里Write完就地ReadExisting,本地测试跑得欢,一到现场连续跑两小时就卡死。做法上我倾向于把它关进一个类里:外部只看到ReadDT、WriteDT这种业务方法,串口对象、锁、重发全在内部。

3.1 端口初始化与帧拼装

初始化的关键是「一次配全、开完就清缓冲」。DiscardInBuffer这一步别省,上一次调试残留的半截帧会让第一次读直接失败,新手很容易在这里怀疑人生。

public sealed class MewtocolCom : IDisposable { private readonly SerialPort _port = new SerialPort(); private readonly object _gate = new object(); public byte Station { get; set; } = 1; // 站号,1:1 连接固定 01 public int TimeoutMs { get; set; } = 800; // 单帧等响应的上限 public int RetryTimes { get; set; } = 2; // 超时后的重发次数 public void Open(string portName, int baudRate = 9600) { _port.PortName = portName; _port.BaudRate = baudRate; _port.DataBits = 8; _port.Parity = Parity.Odd; // 松下出厂常见奇校验,必须与 PLC 侧一致 _port.StopBits = StopBits.One; _port.Handshake = Handshake.None; _port.ReadTimeout = TimeoutMs; _port.WriteTimeout = 500; _port.Open(); _port.DiscardInBuffer(); // 清掉上次残留的半截帧 _port.DiscardOutBuffer(); } // body 形如 "RD00000"、"WD00000" + 4 位数据 private byte[] BuildFrame(string body) { string head = "%" + Station.ToString("D2") + body; byte bcc = 0; foreach (char c in head) bcc ^= (byte)c; // 从 '%' 异或到参数最后一位 string frame = head + bcc.ToString("X2") + "\r"; return Encoding.ASCII.GetBytes(frame); } }

Station用"D2"格式化而不是直接拼数字,是为了保证 1 号站发出去是01而不是1——少一个字符,整帧错位。

3.2 发一帧、收一帧:按 CR 断帧

接收端只有一条规则:读到0x0D就算一帧结束。不能用ReadExisting一次性读,因为它返回的是「当前缓冲区里恰好有多少」,串口是流式的,一帧可能分两次到达,也可能两帧黏在一次读里。逐字节读、逐字节判 CR,是最笨但最不容易出错的做法。

public string Execute(string body) { byte[] tx = BuildFrame(body); lock (_gate) // 同一端口同一时刻只允许一帧在飞 { for (int attempt = 0; attempt <= RetryTimes; attempt++) { try { _port.DiscardInBuffer(); _port.Write(tx, 0, tx.Length); var buf = new List<byte>(32); int deadline = Environment.TickCount + TimeoutMs; while (true) { if (Environment.TickCount > deadline) throw new TimeoutException($"{body} 等待响应超时"); int b = _port.ReadByte(); // 受 ReadTimeout 约束 if (b < 0) continue; buf.Add((byte)b); if (b == 0x0D) break; // CR 就是帧尾 if (buf.Count > 260) throw new InvalidDataException("响应长度异常"); } return Check(buf.ToArray()); } catch (TimeoutException) when (attempt < RetryTimes) { Thread.Sleep(30); // 给 PLC 一点恢复时间再重发 } } } throw new TimeoutException($"{body} 重发 {RetryTimes} 次仍无响应"); }

lock (_gate)那一行承担的是「总线仲裁」的职责。上位机里经常有多个线程要读数据:采集线程、界面刷新、报警检测。不加锁时两帧会交叉发出去,PLC 那边收到的就是垃圾,表现是「偶尔报 BCC 错」。血泪经验:这个锁要加在最外层,不能加在Write和Read各自身上。

重发设计上有个细节值得说一说。超时重发不能无脑连发,Thread.Sleep(30)是给 PLC 侧留出处理上一帧的时间。松下 PLC 的串口是半双工的,上一帧还没处理完就来了下一帧,它会直接丢弃,连错误码都不给你。

3.3 校验、拆包与类型转换

收到字节流之后是三件事:验帧头、验 BCC、拆数据。这三步的顺序不能乱——帧头不对说明物理层就没通,验 BCC 没意义;BCC 不对说明这一帧在传输中被污染,拆出来的数据不可信。

private string Check(byte[] raw) { string resp = Encoding.ASCII.GetString(raw); // 末尾带 CR if (resp.Length < 5 || resp[0] != '%') throw new InvalidDataException("帧头不是 '%',先查波特率和校验位"); string body = resp.Substring(1, resp.Length - 4); // 去 '%'、两位 BCC、CR byte recvBcc = Convert.ToByte(resp.Substring(resp.Length - 3, 2), 16); byte calc = 0; foreach (char c in "%" + body) calc ^= (byte)c; // 注意要把 '%' 补回去 if (calc != recvBcc) throw new InvalidDataException($"BCC 不符:收到 {recvBcc:X2},算得 {calc:X2}"); if (body[2] == '!') // 异常响应:! + 错误码 throw new MewtocolException(body.Substring(3, 2)); return body.Substring(4); // 正常响应:后面全是数据 }

Substring(1, resp.Length - 4)这个-4是%(1)+ BCC(2)+ CR(1)。这里最容易写错成-3,然后 BCC 永远对不上,还以为是协议理解错了。校验的时候必须把%补回参与异或,因为发送侧是从%开始算的,接收侧自然也包含它。

3.4 超时时间怎么定,别拍脑袋

TimeoutMs设多少,取决于三件事:波特率、帧长、PLC 的扫描周期。9600 bps 下一个字符约 1 ms,20 字符的帧光传输就 20 ms;PLC 侧要等一个扫描周期才处理,慢的机器一个周期几十毫秒。所以 200 ms 起步是合理的,800 ms 是留给现场干扰的余量,再往上就说明链路本身有问题,靠加超时掩盖只会让界面越来越卡。

3.5 掉线后要能自己爬起来

现场最怕的是「跑三天,第四天早上串口没反应了,重启软件就好」。这类问题多半是 USB 转串口在电磁干扰下掉线,SerialPort对象内部状态坏了。做法是在Execute外面套一层:连续 N 次超时(比如 5 次)就Close+Dispose重建对象,再Open。重建比DiscardInBuffer有效得多,因为后者救不回已经挂掉的句柄。这个自愈逻辑我一般放在业务层,不放在通讯层里,方便单独打日志。

4. 寄存器读写落地:DT、WR、X/Y 的地址怎么对

帧通了之后,第二道坎是地址。协议只认编号,不认「X12」这种人类写法,而且不同区的编号规则还不一样——DT 是十进制,X/Y 是十六进制。这一点不注意,读回来的值永远是错的,而且错得很有规律,容易误判成数据类型问题。

4.1 常用区的性格差异

区域常见写法编号进制读写典型用途
数据寄存器DT0、DT100十进制读/写配方参数、产量计数
内部继电器(字)WR0十进制读/写中间变量
内部继电器(位)R0、R1F十六进制读/写逻辑标志、启停信号
输入继电器X0、X1F十六进制只读按钮、限位、传感器
输出继电器Y0、Y2A十六进制读/写电磁阀、指示灯
定时器/计数器T0、C0十进制读(现值)时间、计数

X 和 Y 只能读到 PLC 映像区的当前状态,写 X 一般无效——那是外部输入强行决定的。有些型号允许写 Y,但写之前得确认没有被程序里的输出指令覆盖,否则你写进去 1,下一个扫描周期就被改回来了,现场表现是「继电器抖一下就没了」,很容易被当成硬件故障查半天。

4.2 单点读写:RD / WD

单点读用RD+ 5 位地址,单点写用WD+ 5 位地址 + 4 位数据。写入值必须是 4 位十六进制,负数要先转补码。

public short ReadDT(int address) { string data = Execute("RD" + address.ToString("D5")); return unchecked((short)Convert.ToUInt16(data.Substring(0, 4), 16)); } public void WriteDT(int address, short value) { // 负数按补码转 4 位十六进制,用 ushort 中转避免格式异常 string hex = ((ushort)value).ToString("X4"); Execute("WD" + address.ToString("D5") + hex); }

这里有个反直觉的点:Convert.ToInt16("FFFF", 16)在某些 .NET 版本上会抛溢出异常,因为它把FFFF当成 65535 来解析。用Convert.ToUInt16拿无符号值再unchecked转short,语义明确,也不会突然炸。

4.3 批量读写:点数上限和位宽

采 100 个字,如果循环调 100 次ReadDT,9600 bps 下光传输就要两三秒,采集周期直接崩。多点指令RDS/WDS一次就能拿回一串,但有两个约束:一是一次读的点数有上限(跟型号有关,常见几十到上百),二是参数里点数的位宽可能是 3 位也可能是 4 位。

private const int PointWidth = 4; // 点数位宽,按手册改 3 或 4 public short[] ReadDTBlock(int start, int count) { // RDS + 5 位起始地址 + N 位点数 string body = "RDS" + start.ToString("D5") + count.ToString("D" + PointWidth); string data = Execute(body); var result = new short[count]; for (int i = 0; i < count; i++) result[i] = unchecked((short)Convert.ToUInt16(data.Substring(i * 4, 4), 16)); return result; }

调用前先按上限切块:for (int i = 0; i < total; i += chunk),一块一块读。我一般把chunk设成 32,既不会超上限,单帧长度也还在两百字符以内,出了问题日志好看。

4.4 32 位数据和字序

配方里常有 32 位整数(比如累计流量),松下是把低 16 位放在前一个寄存器、高 16 位放在后一个。我这边的机型都是这个顺序,换系列时先在编程软件的数据监视里对一遍,别信文档里一句话就照搬。

public int ReadDT32(int start) { short[] w = ReadDTBlock(start, 2); return (w[1] << 16) | (ushort)w[0]; // 低字在前,DTn 是低 16 位 }

浮点数(REAL)如果 PLC 支持,那就得先拿两个寄存器拼出 32 位 int,再BitConverter.Int32BitsToSingle转成float。这一步写错方向,读出来的数是一个完全没有物理意义的巨大值或者极小的值,看到这种数先怀疑字序,别怀疑传感器。

5. 避坑与排查:五条踩过的坑

5.1 发什么都没回应,串口灯也不闪

现象:Write不报错,ReadByte一直超时,用示波器看或者换个串口助手发同样的帧也没反应。

原因:九成是串口参数不一致,尤其是校验位。代码里Parity.None、PLC 侧是奇校验,两边都「工作正常」,就是互相听不懂。剩下的一成是接线:RS232 的 2、3、5 脚,很多现场是自己压的 DB9 头,TX/RX 对调的情况非常普遍。

解决:先用厂家自带的调试工具或者串口助手发一条最简的%01RD000002+ CR,确认 PLC 本身会回话。这一步能回,问题就在代码;不能回,就先查线和系统寄存器。顺序不能反,否则会在代码里改一天。

5.2 BCC 偶尔校验失败,重发就好

现象:大部分帧正常,隔几十帧错一次,重发必成功,日志里BCC 不符:收到 XX,算得 YY。

原因:三种可能。一是接收时没等到完整帧就断了——比如用ReadExisting或者按字节数读;二是发送侧被其他线程插了一脚,两帧粘连;三是现场干扰,长距离非屏蔽线最容易中招。

解决:先看错误帧的长度对不对,长度明显偏短就是断帧问题,改逐字节读到 CR;长度正常但内容乱,往线程和接地上查。RS485 长距离建议双绞屏蔽线、屏蔽层单端接地,波特率从 115200 降到 19200 往往比换线更快见效。

5.3 地址写对了,读回来的值永远慢一拍或者差一位

现象:读 DT100 想拿配方第一个参数,结果拿到的是第二个参数的值;或者写 X 区完全没反应。

原因:多半是编号进制搞混。DT 是十进制,X/Y/R 是十六进制。界面上输入X1F,代码里int.Parse("1F")直接抛异常,Convert.ToInt32("1F", 16)才是对的。另一个常见错误是把「偏移」当成「绝对地址」,PLC 手册上的地址是从 0 开始还是从 1 开始,不同资料写法不一样。

解决:地址解析单独写个方法,按区域决定进制,解析完再拼帧。上位机界面上让操作员填地址时,把区域做成下拉框,别让人手输字符串。

5.4 长时间运行后串口卡死,只能重启软件

现象:跑几小时到几天,突然所有读写全超时,串口对象还在,IsOpen还是true。

原因:USB 转串口在干扰下掉线,或者驱动层缓冲区溢出。SerialPort的IsOpen只反映托管侧状态,底层句柄挂了它不知道。

解决:连续 5 次超时后强制Close+Dispose重建对象,重建失败就换一个 COM 口再试。同时把ReadBufferSize调小(比如 256),缓冲区越大,出问题时残留的脏数据越多。工控机上尽量用带光电隔离的转换器,别用便宜的口红式小板子,这是我在三个现场验证过的结论。

5.5 界面卡成幻灯片

现象:点击「开始采集」之后界面几秒不响应,鼠标转圈。

原因:通讯跑在 UI 线程上。ReadByte是阻塞调用,PLC 不回话就要等到超时,800 ms 的超时乘以几十帧,界面必然假死。

解决:通讯全部挪到后台线程,UI 只通过Invoke拿结果,而且刷新频率压到 200~500 ms 一次。采集频率 100 ms、界面刷新 500 ms 是常见搭配,数据不丢,界面也不卡。

6. 进阶:把轮询做成队列,上线前跑三项验证

单点读写跑通之后,下一步是让它能撑住整条产线的节奏。我一般不上定时器硬打——System.Timers.Timer每 100 ms 触发一次,如果上一轮还没跑完,就会堆线程,越跑越乱。做法是单线程轮询队列:

private readonly BlockingCollection<Func<Task>> _queue = new(); private async Task WorkerAsync(CancellationToken ct) { foreach (var job in _queue.GetConsumingEnumerable(ct)) { try { await job(); } catch (MewtocolException ex) { Log.Warn($"PLC 拒绝指令,错误码 {ex.Code}"); // 参数问题,不重试 } catch (TimeoutException) { Log.Warn("链路超时,本轮跳过"); // 链路问题,下轮自然重来 } await Task.Delay(5, ct); // 帧间留 5 ms,别贴着发 } }

队列消费是串行的,天然满足「一条总线同时只有一个说话的人」。业务侧只管往里丢任务,采集、写配方、读报警各丢各的,谁也不用管锁。Task.Delay(5)那个间隔看着不起眼,去掉之后在 RS485 多站场景下错误率会明显上升。

上线前的三项验证,我每次都走一遍:第一,拔掉串口线,看软件是否在 5 次超时后自动重连而不是死等;第二,把波特率故意改错一位,确认错误日志里出现的是「帧头不是 '%'」而不是一堆解析异常;第三,连续跑 8 小时,统计 BCC 错误率,正常应该接近 0,超过千分之一就得回去查屏蔽和接地。

注意:验证阶段别拿真实产线试,用

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询