1. 项目背景与整体思路拆解
1.1 为什么是“C# + 串口 + 松下PLC”这个组合
工业上位机开发里,C#和PLC通信几乎是绕不开的活。我最早做这块的时候,接的就是一台松下FP系列PLC,通信方式要求走串口,上位机用C#写。那会儿最大的困惑是:协议资料有,但网上讲得碎片化严重,报文格式、地址换算、校验算法这些细节,翻半天才能拼出个大概。等自己把第一帧“读DT寄存器”的报文完整调通、能从PLC里读到真实数值的那一刻,整条链路才算真正通了。
这个组合之所以常见,是因为C#开发上位机界面效率高,串口在短距离、小数据量的设备通信里依然非常可靠;而松下PLC在中小型自动化设备里的存量很大,很多旧设备、定型设备就只留了串口通信口。学会用C#通过串口和松下PLC通信,本质上是在解决“PC上位机怎么跟现场设备对话”这个通用问题,掌握了协议封装、串口收发、数据处理这些核心能力,换任何品牌PLC都只是换一套命令格式的问题。
这篇内容适合谁看?刚接手工业通信项目的上位机开发人员,被松下PLC的MEWTOCOL协议弄得一头雾水的人,以及想把“读PLC数据”“写PLC数据”这类功能做成通用方法的人。我尽量按照实际项目的推进顺序来写:先讲协议原理,再给可直接用的C#代码,最后把调试中容易踩的坑一次性说透。
1.2 整体通信方案的设计逻辑
做这个项目时,我先明确了一个原则:在能满足需求的前提下,通信方案越简单越不容易出错。C#通过串口与松下PLC通信,核心链路是:
- 上位机(C#程序)通过串口发送请求报文
- PLC收到报文后执行读/写操作
- PLC返回响应报文,上位机解析出数据
很多人问为什么不直接上Modbus等更通用的协议。答案很简单:松下PLC的串口默认运行在MEWTOCOL协议,你只要把MEWTOCOL的报文格式搞明白,所有字寄存器、触点的读写都能覆盖;如果额外加一层Modbus协议转换,反而多了设备和配置环节。当然,如果现场有多品牌PLC混用,统一Modbus是有价值的,但单品牌、单机通信场景下,直接用原生协议最直接。
串口参数的选择也要在早期定下来。我用的参数是:
- 波特率:9600(如果数据量大,可以改成19200,但PLC侧和上位机侧必须一致)
- 数据位:8
- 停止位:1
- 校验位:None
这里有个易错点:不同型号、不同端口的出厂默认参数可能不一致,比如有些通信口默认偶校验、停止位是2。实际调试时以PLC系统寄存器里的设置或官方编程软件的通信设置界面为准,先把两边参数对齐,再谈后面的协议。
2. 先搞懂松下PLC的MEWTOCOL协议再动手
2.1 协议帧结构拆解
MEWTOCOL协议是松下FP系列PLC串口通信的基础,它是一种“请求-响应”式协议:上位机发一帧请求,PLC回一帧响应,一问一答,非常简洁。帧结构可以用一张表说清楚:
| 字段 | 说明 | 示例 |
|---|---|---|
| 帧头 | %表示请求帧,#表示响应帧 | %或# |
| 站号 | 2个ASCII十六进制字符,默认01 | 01 |
| 命令码 | 2到3个ASCII字符,代表操作类型 | RDS、WDS |
| 数据段 | 变长,由具体命令决定 | 地址、个数、数值等 |
| BCC校验 | 2个ASCII十六进制字符 | 37 |
| 终止符 | \r(回车) | \r |
站号是PLC的通信地址。单机直连时,如果PLC站号是1,报文里就写01;如果有多个PLC挂在同一条串口总线上,站号就是区分目标设备的唯一标识。命令码决定了这条报文要做什么,比如RDS代表读取数据寄存器,WDS代表写入数据寄存器。校验和终止符则是保证一帧数据“完整可靠”的关键。
这里我强调一个理解关键点:协议里所有数值,除了BCC本身是二进制数转十六进制文本,其余地址、数量、数据都是以ASCII十六进制字符的形式传输的。比如数值1234在报文里就是字符1、2、3、4,而不是二进制字节。很多新手在这上面栽了跟头,直接把数字当成二进制发出去,PLC当然不认。
2.2 命令码与寄存器地址映射
掌握了帧骨架,接下来就是命令码和地址的对应关系。我实际项目中用得最多的有4个命令:
RDS:读取数据寄存器DT的值WDS:写入数据寄存器DT的值RCS:读取触点状态(对应X、Y、R这类位地址)WCS:写入触点状态
数据寄存器DT是字寄存器,每个DT占16位,取值范围0~65535,是最常用的数据交换区域。比如PLC程序里把当前温度、压力、产量写到DT100,上位机就能用RDS把它读回来。
地址映射是整个协议里最容易让人发懵的部分。我的经验是:报文中的地址 = PLC编程软件里显示的寄存器编号,直接转成4位十六进制ASCII文本。举个例子:
- DT100 → 100 = 0x0064 → 报文地址填
0064 - DT300 → 300 = 0x012C → 报文地址填
012C
有些资料里会提“字地址要乘以2再转十六进制”,这说的是协议内部的物理地址偏移处理。但在实际和FP系列PLC通信时,我用“寄存器编号直接转十六进制”的方式是可以正常读到数据的。如果你手头的型号读不出正确数据,再观察一下PLC侧是否有地址偏移,或者用官方编程软件里的地址表核对一遍,这也是排查方向之一。
读取触点的地址规则就要注意分组了。触点是位地址,但报文里是以“字”(16个触点为一组)为最小单位传输的。比如X0这个输入触点,对应到协议里是按位放在某一组数据的bit0上。实际项目中我建议:能不碰触点就先用DT寄存器,等寄存器通信稳定了,再根据官方地址表去换算触点的组号,可以少踩很多坑。
2.3 BCC校验的手工计算与常见误区
BCC校验是MEWTOCOL协议的可靠性保障,计算方法很简单:从站号开始,到数据段结束,所有字符的ASCII码按字节逐位异或,得到一个字节,再把这个字节转成2位大写的十六进制文本。
我拿一个真实例子手算一遍:读取DT100开始的10个字。
报文数据段为:
01RDS0064000A逐字符取ASCII码异或(十六进制):
- 0x30 ^ 0x31 = 0x01
- 0x01 ^ 0x52 = 0x53
- 0x53 ^ 0x44 = 0x17
- 0x17 ^ 0x53 = 0x44
- 0x44 ^ 0x30 = 0x74
- 0x74 ^ 0x30 = 0x44
- 0x44 ^ 0x36 = 0x72
- 0x72 ^ 0x34 = 0x46
- 0x46 ^ 0x30 = 0x76
- 0x76 ^ 0x30 = 0x46
- 0x46 ^ 0x30 = 0x76
- 0x76 ^ 0x41 = 0x37
最后BCC结果是37,所以完整请求帧是:
%01RDS0064000A37\r这里有一个非常关键的边界:BCC只从站号算到数据段结束,只算01RDS0064000A这一段,不要包含前导的%,也不要包含后面的终止符\r。我用C#的SerialPort调试时,曾因为把%也带进去算校验,导致PLC始终报错,折腾了很久才发现是这个问题。
还需要注意:校验结果转十六进制时要大写,9A写成9a在某些固件版本里会判错。代码里统一用ToString("X2")就能保证大小写正确,下面给完整代码时也会体现。
3. C#串口通信环境搭建与核心代码实现
3.1 串口参数配置与初始化
C#操作串口有现成封装,核心就是System.IO.Ports.SerialPort这个类。项目里新建一个控制台应用或WinForms应用都可以,关键是通信核心代码要独立成一个类,方便复用。下面是我常用的初始化方式:
using System; using System.IO.Ports; using System.Text; public class PanasonicPlcClient { private SerialPort _port; public void Connect(string portName, int baudRate = 9600) { _port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _port.ReadTimeout = 1000; _port.WriteTimeout = 1000; _port.DataReceived += OnDataReceived; _port.Open(); } public void Disconnect() { if (_port != null && _port.IsOpen) { _port.Close(); } } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { // 接收事件,后面详细展开 } }这里几个细节值得说道:
- 打开串口前一定要确认串口号存在且没被其他程序占用,否则会抛
UnauthorizedAccessException。 - 读写超时时间必须设置,工业生产环境串口偶尔会卡住,不设超时可能导致界面线程永久等待。
DataReceived事件是异步触发的,线程池线程,不是UI线程。后面要更新界面,必须用Invoke或BeginInvoke回到UI线程,否则会报线程间非法操作。- 打开串口后,最好先清空接收缓存:
_port.DiscardInBuffer(),把历史残留数据丢掉,避免干扰第一帧响应判断。
3.2 BCC校验与报文封装核心代码
BCC计算和报文封装是整个通信的核心,我直接给出可以复用的代码。注意这里所有拼装都用StringBuilder做字符串拼接,避免频繁创建中间对象。
public static string CalcBcc(string content) { byte bcc = 0x00; foreach (byte b in Encoding.ASCII.GetBytes(content)) { bcc ^= b; } return bcc.ToString("X2"); } public static string BuildReadDt(int startAddr, int count) { string body = $"01RDS{startAddr:X4}{count:X2}"; return $"%{body}{CalcBcc(body)}\r"; } public static string BuildWriteDt(int startAddr, string[] values) { int count = values.Length; StringBuilder sb = new StringBuilder(); sb.Append($"01WDS{startAddr:X4}{count:X2}"); foreach (string v in values) { sb.Append(v); } string body = sb.ToString(); return $"%{body}{CalcBcc(body)}\r"; }用的时候:
string cmd = BuildReadDt(100, 10); _port.Write(cmd);这条命令读的就是DT100到DT109共10个字的数值。注意startAddr:X4会自动把100转成0064,count:X2把10转成0A。如果地址是200,就自动转成00C8,代码里完全不需要手工换算十六进制,这就是把逻辑封装进函数的好处。
3.3 帧接收与解析的完整处理
收到PLC响应后,不能直接假设一帧数据一次全到齐。串口通信和TCP一样会有粘包、半包的问题,尤其波特率较高时会特别明显。我的处理方式是:用一个临时字节列表缓存,按协议里的终止符\r来切帧。
读DT寄存器的响应帧结构是固定的:
# 01 RDS 数据段(4字符×字数) BCC(2字符) \r总共长度 = 1 + 2 + 3 + 4n + 2 + 1 = 9 + 4n 字节(n为读取字数)。知道了长度,就可以准确判断累计收到的字节数是否足够完整取出一帧。
private List<byte> _recvBuffer = new List<byte>(); private readonly object _lock = new object(); private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { int bytesToRead = _port.BytesToRead; byte[] buffer = new byte[bytesToRead]; _port.Read(buffer, 0, bytesToRead); lock (_lock) { _recvBuffer.AddRange(buffer); } // 每收一次数据,尝试解析一次 TryParseFrame(); } private void TryParseFrame() { lock (_lock) { // 找终止符 \r (0x0D) int endIndex = _recvBuffer.IndexOf(0x0D); if (endIndex < 0) return; // 数据还不完整,继续等 byte[] frame = _recvBuffer.Take(endIndex + 1).ToArray(); _recvBuffer.RemoveRange(0, endIndex + 1); string frameStr = Encoding.ASCII.GetString(frame); // 交给业务解析 ParseResponse(frameStr); } }核心逻辑是:收到数据先追加到缓冲区,然后找\r,一找到就切出包含\r在内的完整数据。这样就算一帧数据被拆成3段到达,也能正确拼起来。
切出响应帧后,还要确认命令码是否带!错误标记。MEWTOCOL协议里,如果命令执行出错,PLC会返回!开头的错误码,比如:
!01RDS E2 // 命令格式错误这种响应数据里不会有正常的寄存器数值,必须根据错误码判断异常,然后给出对应的提示信息,而不能傻傻地去解析后面的数据。
4. 完整实操:读写松下PLC的DT数据寄存器
4.1 读DT寄存器:报文拼装到数据解析
现在把上面几段代码串起来,走一遍完整流程。目标是读取PLC中DT100开始的10个字寄存器。
第一步,用BuildReadDt(100, 10)拼出报文:
%01RDS0064000A37\r第二步,把这个字符串写入串口:
string cmd = BuildReadDt(100, 10); _port.Write(cmd);第三步,PLC返回的响应帧形如:
#01RDS000100020003000400050006000700080009000A37\r这里数据段格式是:每个寄存器数值用4个十六进制字符表示,连续排列。解析时就按每4个字符切一组:
private void ParseReadDtResponse(string cmd, string dataPart, int count) { List<int> values = new List<int>(); for (int i = 0; i < count; i++) { string hex = dataPart.Substring(i * 4, 4); values.Add(Convert.ToInt32(hex, 16)); } // 此时 values[0] 对应DT100的值,values[1]对应DT101,以此类推 }这里要注意区分有符号和无符号。松下DT寄存器本身是无符号16位整数,范围0~65535。如果你的PLC程序里存的是负数,实际是以补码形式存储,上位机读取时需要自己判断,比如大于32767就做减法。这个细节在处理温度、位移等带符号工程值时很关键。
如果PLC程序里改过寄存器的值,上位机读到的应该立即变化。我在调试时习惯先在PLC侧用一个固定的计数器或定时器把DT100的值持续刷新,上位机读回来后做个简单的趋势变化,就能确认通信链路没问题。
4.2 写DT寄存器:数据格式与注意事项
写入操作比读取多一个环节,要把数值拼到报文里。比如要把DT200写成1234,DT201写成5678,报文就是:
string cmd = BuildWriteDt(200, new string[] { "1234", "5678" });拼出来是:
%01WDS00C800021234567830\rPLC如果接收正确,会返回一个不带数据的简短响应:
#01WDS30\r写操作有几个容易踩的坑:
- 数值必须是4位十六进制ASCII,
12要写成0012,不能偷懒写成12。 - 一次写入的数量要和控制字对应。比如我要写6个字,
count:X2就是06,后面的数据段也必须有24个十六进制字符,数量不对PLC会回错误码。 - 写入地址不要越界。不同型号的FP系列PLC,DT保持区域范围可能到DT59999,但普通DT区一般只到DT9999。写入超出范围通信层没问题,PLC执行时会报错,最好在代码里做个范围校验。
写完还可以马上用读命令把数值读回来验证,等于形成一个闭环自检。我在项目里就封装了一个WriteThenVerify的方法:先写,再读,比对不一致就重试一次,这样能及时发现通信链路不稳定或寄存器地址不连续的问题。
4.3 实操验证流程:不接PLC也能先测协议
如果PLC不在手边,或者不想在PLC程序里临时加通信测试代码,可以用虚拟串口的方式先把协议调通。我说的虚拟串口就是成对创建的串口,比如COM3和COM4是一对,数据从COM3发出,COM4能收到。
实际操作流程是:
- C#程序连接
COM3。 - 用串口调试工具打开
COM4。 - C#程序发送读DT指令,在调试工具里看收到的原始报文,确认格式是
%01RDS0064000A37\r。 - 手动在调试工具里输入PLC应有的响应帧,比如
#01RDS000100020003000400050006000700080009000A37\r,发给C#程序,看解析结果是否变成1、2、3……10。
这套验证方式能把协议封装和PLC错误区分开:协议层有问题,虚拟串口阶段就能暴露;接线或PLC设置有问题,也只需要在联机后单独排查,不用同时面对两堆问题。我强烈建议所有上位机通信开发都先用这种方法做一遍冒烟测试。
5. 常见问题与排查技巧实录
5.1 超时无响应的几个排查方向
通信没反应,是新手最头疼的问题。根据我的经验,按以下顺序排查基本能覆盖大部分情况:
首查串口参数。波特率、数据位、停止位、校验位要和PLC侧完全一致,一个对不上就没有响应。PLC的通信口默认参数不一定都是9600 8 N 1,有的型号扩展通信口默认是19200、偶校验。
次查站号和接线。站号写错,PLC会把报文当作别人的消息直接丢弃。接线要确认设备是RS232还是RS485,RS232是3线(TXD、RXD、GND),RS485是2线(A、B),收发串口线交叉接对。
再查PLC通信协议模式。松下PLC的通信口需要工作在MEWTOCOL协议模式下,有的PLC程序里可以改通信设置,如果把协议改成了别的模式(比如专用协议或计算机链路),上位机发MEWTOCOL报文就完全没反应。我遇到过一台设备被人改成其他模式后一直没响应,最后对PLC系统寄存器重新初始化才好。
5.2 BCC校验错误的根源
校验错误通常表现为PLC返回!开头的错误帧,或者在调试工具里看到PLC完全没反应。
最常见的原因是校验的范围算错了。记住一条原则:BCC只算报文中间那段,从站号开始、到数据段结束,不包含前导%、不包含终止符\r。如果你用整个请求帧去异或,结果一定和PLC算出来不一致。
第二常见的问题是十六进制大小写。校验结果转成字符串时一定要大写,比如9F不能写成9f。C#里ToString("X2")天然就是大写,但如果你手动拼字符串,就要留意。
第三,确认异或的对象是ASCII码的十六进制值,而不是直接对十六进制字符串做异或。很多人把A当成10去异或,得到的结果完全错误。正确的做法是用ASCII码0x41参与计算,而不是数值10。
5.3 粘包、脏数据与残帧处理
串口通信是流式的,没有天然的“消息边界”。我总结了一套处理策略:
- 接收端用缓冲区累积数据,不要每收到几个字节就立刻去解析识别内容。
- 帧以
\r为终止。收到\r表示一帧完整消息结束,可以切出来交给上层处理。 - 切完一帧后,如果缓冲区里还有多余数据,继续循坏找下一个
\r,这就是粘包的正常处理。 - 程序刚打开串口时,如果缓冲区有历史残留半包数据,会造成第一帧处理异常。解决方案是在
Open后调用一次DiscardInBuffer,或者把缓冲区在连接时清空一次。
还有一点经验值得分享:工业环境的电磁干扰,会导致个别字节被干扰、变成乱字符。数据解析时最好加一个“类型检查”,比如读DT响应里数据段应当全是十六进制字符(0-9A-F),如果出现其他字符,直接判定本帧异常并丢弃,同时进行重发。不要强行走完解析流程,否则可能得到莫名其妙的大数,影响业务判断。
5.4 通信速度与指令间隔的平衡
最后说一个很实际的问题:指令发多快合适。PLC串口通信不是无限快的,CPU处理每一帧都需要时间,而且时序要求严格,通常是“发一帧等响应,收到后再发下一帧”。我习惯在每一次请求之间做最多3次重试,每次等待时间200~500ms。如果持续超时,就要考虑PLC是否处于停止模式或通信口被占用,而不只是盲目加速重发。
指令间隔太短会导致响应队列堆积、丢帧,严重时还会让PLC通信口进入异常状态。稳妥的节奏是:上位机发起请求 → 等待响应 → 超时则重试 → 连续失败则抛出通信异常并提示用户检查设备。这种做法比一味提高循环频率可靠得多。
6. 最后再分享几点实操体会
我在几次项目里用这套方案读写过多个型号的松下PLC,最大的感受是:协议本身不复杂,复杂的是各种细节边界。现在回头看,真正让我省时间的不是某个高深的技巧,而是把“BCC计算、地址转换、帧拼接、响应解析”这四个步骤全部封装成独立、可单测的公共方法。一旦哪个环节出了问题,我可以直接单独测这个函数,不用每次拿到整条链路上去猜。
另外,通信调试中一定要先抓原始报文,再看解析结果。不要一上来就盯着解析出来的数字对不对——数字不对往往意味着报文已经错了,而报文的完整内容才是第一现场。用调试工具把收发帧完整的看一遍,85%的问题都能定位。
如果你想把这个内容继续扩展,下一步可以考虑做批量读写的自动化测试、增加重试机制和看门狗逻辑,甚至把松下通信层抽成独立的动态库供多个上位机项目共用。只要底层的协议封装稳定,往上加任何业务逻辑都会很顺手。