简介:面向C#工控上位机开发者与电气自动化学习者的松下PLC串口通讯DEMO,采用Mewtocol-COM协议并经过实测验证,重点解决上位机通过COM口读写PLC触点与数据寄存器的基础通讯问题。工程完整包含单个触点RCS读取、WCS写入,单个数据寄存器RD读取、WD写入,以及字单位触点RCC读取(如Y0—YF、R0—RF)和多寄存器连续读写等功能,适合用作协议帧拼装、串口收发、地址解析与联机调试的参考样例。压缩包共36个文件,约119KB,以C#源码为主,配有解决方案与项目工程文件、应用配置、资源文件及可执行程序,结构轻量,便于直接打开运行和二次改造。已有1089人学习下载,读者可据此对照Mewtocol命令格式理解请求与响应报文,掌握触点与寄存器地址映射、读写校验和异常排查思路,快速搭建自己的PLC串口通讯模块。
1. 串口助手能通、代码不通,问题从来不在那根线
现场最常见的画面:笔记本插着 USB 转 232,串口助手敲进%01#RDD00000001,松下 PLC 回了东西,看着挺美;换成 C# 上位机,同一根线同一个口,发出去石沉大海。问题不在线,在帧。Mewtocol-COM 是松下 PLC 原生支持的串口协议,帧头、站号、指令、数据区、地址、点数、BCC 校验一个都不能少,串口助手替你补的那些东西,代码不会替你补。这份 DEMO 干的就是把这件事拆开摆平:在 C# 里拼 Mewtocol-COM 帧、算 BCC、一发一收解析响应,把 DT 数据寄存器和继电器状态读上来、写下去。适合做产线采集、配方下发、老设备改造的上位机开发者;设备已经走 OPC 或现成驱动的,这套东西用不上。下面按帧结构、串口层、跑通路线、踩坑顺序拆。
2. 拆 Mewtocol-COM 帧:从%到 CR 的每一个字节都有归属
2.1 请求帧与响应帧的字段对照
Mewtocol-COM 是 ASCII 文本协议,一帧从头到尾都是可读字符,这一点对现场调试极其友好:不用凑二进制,串口助手直接敲就能试。请求帧以%(0x25)开头,以 CR(0x0D)结尾,中间的字段全部定长、十进制补零,少一位 PLC 就直接甩一个错误码回来,不会给你任何容错空间。
| 段 | 长度 | 示例 | 说明 |
|---|---|---|---|
| 帧头 | 1 | % | 固定,ASCII 0x25 |
| 站号 | 2 | 01 | PLC 侧设定的站号,单机默认 01 |
| 分隔符 | 1 | # | 表示这是一个请求 |
| 指令 | 2 | RD/WD/RCS/WCS | 读数据 / 写数据 / 读触点 / 写触点 |
| 数据区 | 1~2 | D/X/Y/R/L/T/C | DT 数据寄存器对应的是D |
| 地址 | 5 | 00100 | 十进制、左补零,地址语义是「第几个字」不是「第几个字节」 |
| 点数 | 4 | 0001 | 本次读写的字数或触点数 |
| BCC | 2 | 64 | 前面所有字符异或后的低 8 位,两位十六进制大写 |
| 结束 | 1 | CR | 0x0D,不能用 LF 代替 |
响应帧的骨架比请求帧更短:%+ 站号 +$或!+ 指令回显 + 数据 + BCC + CR。$表示正常执行,!表示异常,!后面紧跟两位错误码,BCC 在最后两位,CR 收尾。举个例子,站号 01 读 DT100 一个点成功,返回的帧体形如%01$RD0064,其中0064就是 DT100 里的值,十六进制表示,占 4 位。
读触点的响应格式跟读数据不一样,这点 DEMO 里必须分开处理:RCS的响应数据段是一串连续的0和1,第 n 个字符对应从起始地址起的第 n 个触点,不是 4 位一组。如果拿读 DT 的解析逻辑去套读 X/Y,解析出来的值会张冠李戴,而且不会报错,属于典型的静默错。
提示:地址字段的位数跟机型有关。FP0、FP-X、FP-XH 这一代用 5 位地址配 4 位点数;更老的 FP1 系列部分机型是 4 位地址。接机型前先确认手册里那一页的帧示例长度,别拿着 5 位格式去怼 4 位机型,BCC 会一直报错,你还以为是自己算错了。
2.2 BCC 的计算范围为什么必须从%开始
BCC 是这一整套协议里最容易翻车的地方,因为它不像 CRC 那样有标准库现成可调,只能手算。规则本身不复杂:把帧体里除 BCC 两位和 CR 之外的每一个字符取 ASCII 码,逐字节异或,结果取低 8 位,转成两位大写十六进制。
// body 是从 '%' 开始、到点数最后一位结束的帧体,不含 BCC 和 CR private static byte CalcBcc(string body) { byte bcc = 0; foreach (char c in body) { // 取字符的 ASCII 码参与异或,不是 char 本身做算术 bcc ^= (byte)c; } return bcc; }参数说明:body必须以%开头,这一点是硬要求,把%漏掉是最常见的错误来源;末尾字符是点数的最后一位,不能再往后带 CR;返回值用ToString("X2")转成大写两位十六进制拼到帧尾。DEMO 里如果封装的是一个「传帧体、返回完整帧」的方法,那 CR 的拼装和 BCC 的计算必须在同一个方法里完成,分开写迟早会有人传错。
手算一遍有助于建立直觉。帧体%01#RDD000000001(%+01+#+RD+D+ 地址00000+ 点数0001),逐字符异或:0x25 ^ 0x30 = 0x15,^ 0x31 = 0x24,^ 0x23 = 0x07,^ 0x52 = 0x55,^ 0x44 = 0x11,^ 0x44 = 0x55,接下来 8 个0(0x30)是偶数个,异或结果不变,仍然是0x55,最后^ 0x31 = 0x64。所以 BCC 是64,整帧就是%01#RDD00000000164加一个 CR。找张纸把这段算一遍,比看十遍代码管用。
为什么 BCC 不用 CRC:这套协议诞生年代早,跑在 9600 的线上,校验能力够用即可;异或的实现成本低到单片机都能随手算出来,对 PLC 侧的实时性影响最小。反过来说,异或校验对两位同时翻转的错误是查不出来的,所以现场不要指望它兜住线路干扰,屏蔽双绞、单点接地这些基本功该做还得做。
2.3 响应里的$和!是两种命运
$之后的处理是机械的:按指令类型切数据段,每 4 位一组转十六进制。!之后的处理才是真正需要经验的部分,错误码直接告诉你 PLC 为什么拒绝你,读懂能省掉半天排查。
| 错误码 | 含义 | 现场最常见的触发原因 |
|---|---|---|
| 21 | 数据区代码或地址异常 | 区代码写错(把 DT 当成了 DM),或地址超过该机型实际点数 |
| 22 | 点数异常 | 一次请求的点数超过协议上限,或写了 0000 |
| 23 | 数据范围异常 | 写入值超过 16 位无符号范围,或 BCD 模式下写了非法数字 |
| 24 | 指令无法执行 | PLC 处于 PROG 模式、被编程口占用,或该区只读 |
| 26 | 指令不存在 | 指令拼错,比如把RD敲成DR |
| 30 | BCC 校验错误 | 异或范围错,漏了%或把 CR 算进去了 |
| 50 / 51 | 发送 / 接收超时 | 帧不完整、结束符不是 CR、波特率对不上 |
不同机型的手册里错误码表会有细微差异,手上的那本手册才是最终解释权。DEMO 里我把错误码封装成一个自定义异常,抛出时带上原始响应帧,这样日志里能看到现场那一刻到底收到了什么,不用靠猜。
选型上还有个问题值得说清楚:为什么不用 Modbus。松下 FP 系列原生就跑 Mewtocol,不需要额外通讯模块,也不需要买授权;Modbus 要么加扩展卡,要么依赖某些机型的内置支持。为什么不去 NuGet 上找现成库:能搜到的多数是面向 FP7 的以太网 Mewtocol,走 232 文本帧的很少,而且大多几年没更新。协议本身只有两三百行的工作量,手写一遍换来完全可控的排错能力,这笔账我认为划算。
3. C# 串口层落地:参数、拼帧、收帧与事务封装
3.1 上位机串口参数和 PLC 侧系统寄存器必须对齐
这一步对不上,后面所有代码都是白写。松下 FP 系列的 COM1 口参数由 PLC 侧的系统寄存器决定,改完之后一般需要断电重启或者 RUN→PROG→RUN 切一次才生效,很多人改完参数直接在软件里试,试不通就以为是代码问题。
| 参数 | PLC 侧(COM1) | C# 侧 SerialPort |
|---|---|---|
| 波特率 | 9600(出厂常见) | BaudRate = 9600 |
| 数据位 | 8 | DataBits = 8 |
| 校验 | 奇校验 Odd | Parity = Parity.Odd |
| 停止位 | 1 | StopBits = StopBits.One |
| 流控 | 无 | Handshake = Handshake.None |
| 站号 | 1(默认) | 帧内%01 |
奇校验这一项坑过太多人:串口助手新建连接时默认是无校验,你在助手里发的帧 PLC 能收到,回的帧你这边全是乱码,于是得出「PLC 没回」的错误结论。实际上回了,只是校验位不匹配导致的字节错位。新建任何串口连接,先把 Odd 点上。
硬件侧还有两件事值得在代码之前确认:USB 转 232 的芯片型号(CH340、FT232、PL2303 驱动各不相同),装好驱动后在设备管理器里确认端口号,不要每次靠猜;以及 232 是交叉接线,2-3、3-2、5-5,直连线的现象是「发得出去、收不回来」,跟 BCC 错的症状一模一样,很容易误判。
3.2 拼帧:定长格式化和 BCC 必须在同一个方法里
拼帧的核心是「所有数字字段定长补零」,用字符串插值里的格式化占位符是最省事的写法,比PadLeft一串调用可读性好得多。
public static class MewtocolFrame { // 读数据区:area 传 "D" 读 DT,"R" 读内部继电器字 public static string BuildRead(byte station, string area, int address, int count) { // 站号 2 位、地址 5 位、点数 4 位,全部补零到位 string body = $"%{station:D2}#RD{area}{address:D5}{count:D4}"; byte bcc = CalcBcc(body); return body + bcc.ToString("X2") + "\r"; } // 写数据区:values 每个元素占 4 位十六进制 public static string BuildWrite(byte station, string area, int address, params ushort[] values) { var sb = new StringBuilder(); sb.Append($"%{station:D2}#WD{area}{address:D5}{values.Length:D4}"); foreach (ushort v in values) sb.Append(v.ToString("X4")); // 一个字固定 4 位,不能省前导零 string body = sb.ToString(); byte bcc = CalcBcc(body); return body + bcc.ToString("X2") + "\r"; } private static byte CalcBcc(string body) { byte bcc = 0; foreach (char c in body) bcc ^= (byte)c; return bcc; } }逐个参数说明:station是 PLC 侧设定的站号,单机默认 1,多站 485 组网时每台不同;area是区代码,读 DT 传"D",读内部继电器传"R",读输入输出传"X"和"Y",传错不会有编译错误,只会在运行时收到错误码 21;address是起始地址,注意它是字地址,DT100 就传 100,不要乘 2;count是点数,DEMO 里我限制在 1~64,理由在 3.3 节讲。
写帧里v.ToString("X4")这一句不能偷懒写成ToString("X")。省掉前导零后,值0x0005会变成"5",帧长度少三位,PLC 收到的是一个点数对不上长度的畸形帧,返回错误码 50 或者干脆不回。这类问题在代码 review 时极难发现,因为写 0x1234 的时候一切正常,只有小数值才暴露。
3.3 收帧:ReadTo("\r")好用,但有三个前提
SerialPort 的读取方式有好几种,ReadExisting会把缓冲区里现有的一切都吐给你,可能是半帧,可能是两帧粘在一起,自己拼状态机成本不低。ReadTo("\r")是文本协议下的正确选择,它以 CR 为界返回一整个帧体,但它成立有三个前提,缺一个就会翻车。
第一个前提是NewLine和协议结束符一致。ReadTo的分隔符是显式传参,跟NewLine无关,但如果代码里混用了ReadLine,就必须把NewLine设成"\r",默认是"\r\n",会一直等到超时。
第二个前提是超时时间要合理。9600 波特率下一个字符大约 1ms,一帧 20 来个字符,加上 PLC 的扫描周期,500ms 是宽松值。设成 50ms 会在 PLC 忙的时候频繁超时,设成 5000ms 会让界面卡到用户以为死机。
第三个前提是超时之后必须清缓冲。ReadTo抛TimeoutException时,缓冲区里往往残留着半帧,下一帧到来后会跟这半帧拼在一起,产生一个 BCC 永远对不上的怪帧。所以每次事务开始前DiscardInBuffer(),超时后也要清,这是用血泪换来的习惯。
注意:一次请求一次响应,中间不要插别的读写。串口是独占资源,两个线程同时往同一个 SerialPort 写,帧会交错,PLC 收到的是两帧揉在一起的乱码。多线程场景下必须用锁或者信号量把整个事务串起来。
3.4 把收发封装成一次事务
把拼帧、发送、收帧、BCC 校验、异常识别收进一个Transact方法,上层调用者只需要关心「读哪个地址、读几个」,这是 DEMO 结构上最值得保留的部分。
public sealed class MewtocolClient : IDisposable { private readonly SerialPort _port; private readonly object _gate = new object(); // 事务级互斥 private readonly byte _station; public MewtocolClient(string portName, byte station = 1, int baudRate = 9600) { _station = station; _port = new SerialPort(portName, baudRate, Parity.Odd, 8, StopBits.One) { ReadTimeout = 600, WriteTimeout = 600, NewLine = "\r", Encoding = Encoding.ASCII }; } public bool IsOpen => _port.IsOpen; public void Open() { if (!_port.IsOpen) _port.Open(); } private string Transact(string body) { lock (_gate) { byte bcc = 0; foreach (char c in body) bcc ^= (byte)c; string frame = body + bcc.ToString("X2") + "\r"; _port.DiscardInBuffer(); // 清掉上一轮残留,防半帧拼接 _port.Write(frame); string resp = _port.ReadTo("\r"); // 不含 CR,含末尾两位 BCC if (resp.Length < 5) throw new IOException("响应过短:" + resp); string payload = resp.Substring(0, resp.Length - 2); string recvBcc = resp.Substring(resp.Length - 2); byte calc = 0; foreach (char c in payload) calc ^= (byte)c; if (!string.Equals(calc.ToString("X2"), recvBcc, StringComparison.OrdinalIgnoreCase)) throw new IOException("BCC 不匹配,原始响应:" + resp); if (resp[3] == '!') throw new MewtocolException(resp.Substring(4, 2), resp); return resp; } } public ushort[] ReadDT(int address, int count) { if (count < 1 || count > 64) throw new ArgumentOutOfRangeException(nameof(count)); string resp = Transact($"%{_station:D2}#RDD{address:D5}{count:D4}"); string head = $"%{_station:D2}$RD"; if (!resp.StartsWith(head, StringComparison.Ordinal)) throw new IOException("非预期响应:" + resp); string data = resp.Substring(head.Length, resp.Length - head.Length - 2); var values = new ushort[count]; for (int i = 0; i < count; i++) values[i] = Convert.ToUInt16(data.Substring(i * 4, 4), 16); return values; } public void Dispose() { if (_port.IsOpen) _port.Close(); _port.Dispose(); } }几个设计点值得解释。lock (_gate)锁的是整个收发过程而不是只锁写,因为请求和响应是一对一的,只锁写会让两个线程的响应互相串台。resp[3] == '!'这个判断依赖帧头固定为%+ 两位站号,索引 3 一定是状态位,站号超过两位的协议变体不适用。BCC 校验放在错误码判断之前,因为一个 BCC 错的帧里读出来的错误码本身也不可信。
ReadDT里把点数限制在 64,不是因为协议不支持更多,而是 9600 波特率下响应帧过长时,PLC 侧的发送缓冲和上位机的接收超时都容易出问题,实测 32 点左右最稳。要读 100 个 DT,就分四次读,代码里多一个循环,现场少一堆偶发超时。
4. DEMO 跑通路线:从上电到读到 DT 值的四步验证
4.1 先别接 PLC,用回环确认线路和驱动
拿到任何一套串口通讯 DEMO,我的习惯都是先不接设备,把 USB 转 232 的 2、3 脚短接做回环。发什么收什么,说明驱动、端口号、串口参数这一整条链路是通的。这一步花两分钟,能排除后面一半的怀疑对象。
具体做法:设备管理器里记下端口号,比如 COM3;串口助手打开 COM3,9600、8、Odd、1(对,回环也用 Odd,保持和后面一致);发送区输入ABC,点发送,接收区应该立刻显示ABC。收不到就是驱动或者线的问题,跟 PLC 一点关系都没有。
回环通了之后接 PLC,仍然先用串口助手发一帧,不要急着写代码。发送框里敲%01#RDD00000000164,记得勾选「发送新行」并且确认新行是 CR 而不是 CRLF,然后观察返回。收到%01$RD开头的帧,说明接线、参数、站号、帧格式、BCC 全对,可以开始写代码了;收到%01!30就是 BCC 算错,收到%01!21是区代码或地址不对。
4.2 单点读 DT:一帧请求一帧响应
单点读写是最小可验证单元,先把 DT100 读上来,再去谈批量。
using (var plc = new MewtocolClient("COM3", station: 1, baudRate: 9600)) { plc.Open(); ushort[] v = plc.ReadDT(100, 1); // 读 DT100 开始 1 个字 Console.WriteLine($"DT100 = {v[0]} (0x{v[0]:X4})"); }执行逻辑:构造帧体%01#RDD001000001,算 BCC 拼上 CR 发出,等待响应,校验 BCC,确认响应头是%01$RD,从第 7 个字符起每 4 位切一组转十六进制。如果 DT100 里存的是十进制 100,你看到的会是DT100 = 100 (0x0064)。这一步跑通,说明协议层和串口层都没问题,剩下的都是工程量。
写入的验证方式要注意:写完立刻回读。写 DT100 值为 1234,然后读回来确认是 1234,而不是看 PLC 编程软件的监控画面——监控画面有刷新延迟,你看到的可能是写入前的值,白高兴一场。
plc.WriteDT(100, 1234); // 写 DT100 ushort back = plc.ReadDT(100, 1)[0]; // 立刻回读校验 if (back != 1234) throw new IOException($"回读不一致:{back}");4.3 批量读写与地址映射表
DEMO 里最容易被忽略的是地址映射。上位机界面上写的是「1 号工位温度」,实际对应 DT200,这个对应关系必须有一张表,而且这张表要跟 PLC 程序里的注释对得上。我一般会把它做成配置而不是硬编码,改地址不用重编译。
| 用途 | 区代码 | 起始地址示例 | 点数 | 数据类型 |
|---|---|---|---|---|
| 工艺参数 | D | 100 | 8 | 16 位无符号 |
| 实时数据 | D | 200 | 32 | 16 位无符号 |
| 内部继电器状态 | R | 0 | 16 | 位 |
| 电机运行反馈 | X | 0 | 8 | 位,只读 |
| 输出控制 | Y | 0 | 8 | 位 |
批量读的写法和单点一样,只是把点数改大,解析时循环切 4 位。需要注意的是跨区读写不要放在同一帧里,协议不支持,一个区代码一帧。
4.4 WinForms 界面和后台轮询的边界
DEMO 如果带界面,最容易踩的坑就是把串口 IO 放在 UI 线程。ReadTo在超时期间是阻塞的,600ms 的超时意味着界面卡 600ms,用户拖动窗口会拖出残影,连续几次超时就变成「程序未响应」。
正确做法是后台线程或Task里跑轮询,拿到数据后用Invoke或BeginInvoke回 UI 线程更新控件。Invoke是同步的,会阻塞后台线程等 UI 处理完,轮询频率高的时候会拖慢整个循环;BeginInvoke是异步投递,更适合这种「取到数据就扔给界面」的场景。
private async Task PollAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { ushort[] data = _plc.ReadDT(200, 32); // 后台线程读,不碰控件 BeginInvoke(new Action(() => UpdateGrid(data))); // 回 UI 更新 } catch (TimeoutException) { BeginInvoke(new Action(() => SetStatus("通讯超时"))); } await Task.Delay(200, ct); // 轮询间隔别小于 PLC 扫描周期 } }轮询间隔设 200ms 是个经验值,比这更密在 9600 波特率下意义不大:一帧 20 字符来回加上 PLC 处理,本来就要几十毫秒,再快只是把请求堆在缓冲区里。
5. 避坑与排查:五个现场血泪记录
5.1 发出去收不到,串口助手却一切正常
现象:代码里Write不抛异常,ReadTo每次都在 600ms 后抛TimeoutException,同一台机器用串口助手发一模一样的字符串,PLC 回得好好的。
原因:九成是串口参数不一致,尤其是校验位。串口助手默认新建连接是 None,你自己手动改成 Odd 了,代码里却写成了Parity.None;或者是数据位写成了 7。参数不一致时 PLC 能收(接收对校验不敏感的部分机型),但它回帧的校验位对你来说是错位的,接收缓冲里全是垃圾字节,ReadTo等不到 CR。
解决:把上位机侧的波特率、数据位、校验、停止位、流控五项和 PLC 系统寄存器里的设置逐项对一遍,特别注意改完 PLC 参数要断电重启或者 RUN→PROG→RUN。另外把Handshake显式设成None,默认值在某些 USB 转串口驱动上表现不一致。
5.2 BCC 一直报 30,手算却是对的
现象:代码拼出来的帧发给 PLC,稳定返回%01!30,拿计算器按一遍 BCC 又跟代码算的一样。
原因:多半是 CR 被算进去了,或者用了\r\n。ReadTo("\r")场景下,如果拼帧时写成body + bcc + "\r\n",PLC 收到 CR 之后还会多看到一个 LF,某些固件会把它算进下一帧的起始,导致 BCC 校验失败或者帧错位。另一个可能是大小写:协议要求 BCC 是两位大写十六进制,某些机型对a和A的容忍度不一样。
解决:拼帧用"\r"不用"\r\n";BCC 输出固定ToString("X2");调试时把最终发出的完整帧用Encoding.ASCII.GetBytes打出来看字节,0D 后面不该有 0A。这个小技巧我一般会写进日志,出问题时一眼就能看出多没多字节。
5.3 读 DT 读到的是隔壁地址的值
现象:读 DT100,返回的值跟 PLC 编程软件监控里 DT100 的值对不上,但跟 DT101 或者 DT102 能对上。
原因:地址位数或者地址语义搞错了。有的开发者按字节地址思维,把 DT100 传成 200;有的是 4 位地址机型和 5 位地址机型混用,{address:D5}把 100 补成00100,PLC 期望的是0100,多出来的一位把后面的点数位挤掉,PLC 解析出的是另一个地址。
解决:先确认机型对应的地址字段长度,用串口助手发一帧看响应是否正常;再确认地址是字地址不是字节地址。DEMO 里把地址格式化的位数做成可配置的常量,换机型只改一处,别散落在各个方法里。
5.4 跑十几分钟后必现超时,重启软件就好
现象:刚启动时通讯正常,跑十几分钟开始偶发超时,越来越频繁,最后完全不通,关掉软件重开又好了。
原因:异常路径没有清缓冲。每次超时后缓冲区里残留半帧,下一帧的响应接在这半帧后面,ReadTo读到的第一个 CR 是那个残帧的,解析出来 BCC 对不上,抛异常,再残留,雪球越滚越大。另一个可能是SerialPort没关就重新Open,端口句柄泄漏。
解决:事务开始前和超时后都调DiscardInBuffer();把SerialPort的Close/Dispose放进using或finally;界面上提供一个「重连」按钮,内部实现是Close后延时 500ms 再Open,别在原地反复Open。
5.5 两个窗体同时开,报端口被占用
现象:主界面已经在轮询,点开一个参数设置窗体,设置窗体也去new SerialPort("COM3"),抛UnauthorizedAccessException。
原因:串口是独占资源,同一进程里两个SerialPort实例指向同一个物理口也会冲突。
解决:整个应用只维护一个MewtocolClient实例,通过构造函数或者静态属性传给各个窗体;如果窗体之间是松耦合的,就套一层单例的服务类,所有读写请求排队进同一个事务锁。这属于架构问题,不是串口问题,但表现出来的现象特别像串口问题,值得单独记一笔。
6. 把 DEMO 改成能上产的通讯层:轮询表、重连和日志
DEMO 能跑通和能上产线之间,差的不是协议知识,是三样东西:一张配置化的轮询表、一套断线自愈的重连逻辑、一份能复盘的黑匣子日志。这三样我在每个项目里都会补,缺一样后面都要还债。
轮询表用配置驱动,把「读哪个区、起始地址、点数、周期、超时、失败重试次数」写
本文还有配套的精品资源,点击获取