简介:针对松下PLC标准计算机链通讯协议的C#实现源码包,面向自动化设备开发与维护人员,重点解决通过RS232串口读写松下PLC内部寄存器的通信问题。资源采用Visual Studio WinForms工程结构,包含窗体界面、通信协议封装及示例工程,代码注释清晰,适合新手快速上手,也便于有经验者直接复用协议层逻辑。
压缩包共48个文件,整体大小仅191KB,主要包含6个C#源文件、8个DLL库文件、4个EXE可执行程序,以及若干配置、资源、工程管理文件(如sln/csproj)和调试所需的PDB。其中EXE可直接运行验证效果,C#工程源码便于二次开发,DLL为程序引用的依赖库,结构精简清晰。
目前已有822人浏览学习。这套源码为串口通信开发提供了完整可运行的参考案例,涵盖界面交互、指令封装、串口收发等环节,配合案例代码中的详细说明,可帮助读者理解松下标准协议帧格式与PLC操作流程。
1. 松下PLC标准通讯协议:这份C#源码到底解决了什么
当上位机要通过RS232直接操作松下PLC时,最大的门槛不是SerialPort,而是协议帧本身。这套C#源码把松下标准计算机链通讯协议(MEWTOCOL)的帧组织、BCC校验、收发应答都封装在comForm.cs和Program.cs里,解压后打开complc.sln即可编译运行,也可以把报文构建函数移植到自己的C#上位机项目中。它适合两类人:一类是刚接触串口通信流程、想用完整代码理解松下PLC标准通讯协议的新手;另一类是有labview与松下PLC通讯经验、想换到C#快速实现的开发者。源码把“协议细节不用再翻手册慢慢抠”这件事直接落地了。
2. MEWTOCOL帧格式与BCC校验:先从协议底层说起
2.1 报文结构:为什么响应都以%开头
松下PLC的标准计算机链通讯协议一般工作在ASCII模式下,命令和响应的每个字节都是可打印字符,这给调试带来很大方便,用串口监视器就能直接看到一帧。请求帧固定以百分号%起始,后面依次是两位站号、一个#、两位命令码、变长参数文本、两位BCC校验码和一个回车符\r。正常响应的$出现在站号之后,错误响应则把$换成了!,错误码紧接着!,所以看帧的第4个字符就能区分本次请求是否成功。
| 构成 | 示例 | 说明 |
|---|---|---|
| 起始符 | % | 一帧开始,不参与BCC |
| 站号 | 01 | RS232点对点固定01,RS485多站时为各站编号 |
| 功能符 | #请求、$正常、!错误 | 是解析响应的最主要锚点 |
| 命令码 | RCP、RCS等 | 对应读取字、读取触点、写入字、写入触点 |
| 参数文本 | 00020004 | 地址和点数/数据,由命令码决定长度 |
| BCC | A7 | 从站号到参数文本末尾的逐字节异或 |
| 结束符 | \r | ReadLine可以按此切帧 |
值得强调的是BCC异或范围不包含开头的%,也不包含BCC自身和末尾CR。实际抓包时经常看到这种情况:帧内容肉眼看着一模一样,PLC却完全不回复,问题往往就出在异或时把%也算进去了。这个边界在源码里通常体现为先拼station + "#" + command + text,再对这一个中间字符串逐字节异或。选择ASCII模式而不是二进制模式,也是这套源码明显考虑到调试成本,传输效率在9600波特率下并不用太计较。
2.2 C#构建请求帧:从看得懂到发得出
源码里最值得复用的一个函数就是把站号、命令和参数拼成一帧。自己写C#上位机时,可以直接抄下面这段结构:
private static byte[] BuildMewtocolFrame(string station, string command, string text) { if (station.Length != 2 || command.Length != 2) throw new ArgumentException("站号和命令码必须为两位"); // station在%之后参与BCC校验,所以不能把%包含进来 string body = station + "#" + command + text; byte bcc = 0; foreach (char c in body) { bcc ^= (byte)c; } string frame = "%" + body + bcc.ToString("X2") + "\r"; return Encoding.ASCII.GetBytes(frame); }这段代码先把station、#、command、text拼成body,然后对每个字符取ASCII码做异或。bcc.ToString("X2")强制转成两位大写十六进制,例如异或结果是0x0D时会输出0D而不只是D。如果使用小写x或漏掉X2,帧长度就会少一位或大小写不对,松下PLC通常不会给出任何提示,只会静默丢弃。中间的#也不能省略,它是请求帧和响应帧的定位符,丢了之后即使BCC正确,PLC仍不知道这是一条新命令。末尾的\r是ReadLine分帧的依据,也是标准协议规定的一帧结束标志。
针对常用的读取字操作,命令码是RCP,参数文本由4位十六进制首地址和4位十六进制读取点数组成。源码里的按钮事件会传进去类似下面的字符串:
byte[] request = BuildMewtocolFrame("01", "RCP", "00020004"); serialPort.Write(request, 0, request.Length);01表示站号,RCP告诉PLC要读取字软元件,0002是起始软元件地址,0004表示连续读取4个字。地址编码在FP系列里一般可以直接对应对应PLC编程软件中的DT编号,触点类地址则带固定偏移。如果你是从Modbus设备转过来,别急着把Modbus寄存器地址表套到松下协议上,两者地址映射完全不通用。如果读回来的数据完全不对,优先检查PLC编程软件里当前程序使用的软元件编号,而不是先怀疑通信参数。
3. complc工程结构与RS232串口初始化
3.1 源码文件怎么分工
complc.sln是Visual Studio解决方案文件,双击后可以在VS2019或VS2022中直接打开。Program.cs只负责启动WinForms窗体,真正的主逻辑在comForm.cs和comForm.Designer.cs里。comForm.Designer.cs是设计器生成的界面代码,控件拉好之后基本不需要手工改;comForm.cs里则是按钮事件、串口事件和几个核心通信方法。obj和bin目录是编译输出目录,源码包里保留它们通常只是为了证明这套代码可以直接编译,不属于阅读重点。Properties目录下保存程序集信息,做二次分发时可以顺手改产品名。
对于有一定经验的C#开发,这套源码最大的价值不是UI布局,而是把串口通信和协议拼装放在同一个窗体事件下,能完整看清一次“打开串口-发送请求-读取响应-更新界面”的路径。我一般会先把comForm.cs另存出来,裁掉UI部分,包成一个MewtocolClient类,后续写新的上位机时直接引用。但第一次读源码时,建议还是从原样的窗体代码入手,因为单步调试时可以看到按钮点击后每一行做了什么,很多隐藏的协议细节都是在这些平铺代码里暴露出来的。
3.2 串口参数与打开流程
源码的app.config里保存通信默认值。打开串口前,需要把波特率、数据位、校验位、停止位和站号读出来。下面是典型的初始化代码:
private void OpenPort() { if (serialPort.IsOpen) return; serialPort.PortName = ConfigurationManager.AppSettings["PortName"]; serialPort.BaudRate = int.Parse(ConfigurationManager.AppSettings["BaudRate"]); serialPort.DataBits = 8; serialPort.Parity = Parity.None; serialPort.StopBits = StopBits.One; serialPort.NewLine = "\r"; serialPort.Open(); serialPort.DataReceived += SerialPort_DataReceived; }这里NewLine = "\r"非常关键。默认ReadLine按\n切分一行,而松下协议一帧的结尾只是\r,不改成回车会导致ReadLine等不到换行符,一直阻塞到超时。如果松下PLC通信格式被设置成带奇偶校验,那么Parity和DataBits必须和PLC侧的通信设置一致,常见的是无校验8位,也有少量现场配置为7位偶校验,切到7位后DataBits也要同步改成7。
关闭串口时也不能直接调Close,要先取消事件订阅,不然关闭瞬间正好有一帧进来,后台线程会访问已经释放的资源。收尾代码在工程里可以这样写:
private void ClosePort() { serialPort.DataReceived -= SerialPort_DataReceived; if (serialPort.IsOpen) { serialPort.Close(); } }3.3 接收事件与同步请求封装
DataReceived事件在后台线程触发,不能直接操作窗体的TextBox或Label,否则会抛跨线程异常。comForm.cs里的做法通常是先ReadLine取到整帧,再用BeginInvoke把字符串抛回UI线程。同步请求-响应模式则更适合做成一个公共方法:
private string Request(string station, string command, string text) { if (!serialPort.IsOpen) return ""; byte[] frame = BuildMewtocolFrame(station, command, text); serialPort.DiscardInBuffer(); serialPort.Write(frame, 0, frame.Length); serialPort.ReadTimeout = 500; try { return serialPort.ReadLine(); } catch (TimeoutException) { return "ERROR:TIMEOUT"; } }DiscardInBuffer()在发送前清空缓冲区,防止上一帧残留数据被当成响应。ReadTimeout=500是给PLC响应留出的最大等待时间;RS232半双工模式下,常见PLC响应时间在几十毫秒到几百毫秒之间,设置太短会出现偶发超时,设置太长则UI会明显卡住。这个方法返回的字符串要么是%01$开头的正常响应,要么是%01!开头的错误响应,超时则返回固定错误文本,后续解析就统一围绕这三种情况展开。
4. 用标准协议读写松下PLC:指令封装与返回码处理
4.1 常用命令码与参数文本
源码注释里列出了松下标准协议最常用的几条命令。实际项目里最常打交道的四类是:RCS读触点、RCP读字、WCS写触点、WCP写字。参数文本格式由命令码决定,推荐在封装层建成表格驱动,而不是在业务代码里手工拼字符串,减少命令码和地址写错的机会。
| 命令码 | 功能 | 参数文本构成 | 典型文本 |
|---|---|---|---|
| RCS | 读触点 | 触点地址4位+读取点数4位 | 01000010 |
| RCP | 读字 | 字地址4位+读取点数4位 | 00020004 |
| WCS | 写触点 | 触点地址4位+触点值1位 | 01000001 |
| WCP | 写字 | 字地址4位+字数据4位 | 0002000A |
表格里的地址编码只是示例,具体到FP系列不同型号会有偏移规则。真正要落地上位机时,建议先把PLC编程软件里的软元件表导出来,和源码案例里的地址段做一次对照,再开始改代码。如果连一条RCS都读不通,多半不是指令格式不对,而是地址范围超出了该型号PLC的软元件区段。
4.2 响应解析与校验
收到响应后,先把$和!区分开。$后面是正常数据,!后面是错误码。下面的解析函数适合从comForm.cs里提取出来复用:
private string ExtractData(string raw) { if (string.IsNullOrEmpty(raw)) return ""; if (raw.Length < 5) return raw; if (raw[3] == '!') { return "ERR:" + raw.Substring(4, 2); } int dollar = raw.IndexOf('$'); if (dollar < 0) return ""; return raw.Substring(dollar + 1, raw.Length - dollar - 3); } private bool VerifyBcc(string raw) { if (raw.Length < 4) return false; string payload = raw.Substring(1, raw.Length - 3); byte bcc = 0; foreach (char c in payload) bcc ^= (byte)c; return bcc.ToString("X2") == raw.Substring(raw.Length - 2); }raw[3]对应%01!中的!;错误码只取两位十六进制,对应松下协议中的异常编号。正常响应从第一个$后开始截取,减掉末尾两位BCC,剩下的就是有效数据。VerifyBcc在解析前做一道防线,能直接滤掉串口线路干扰产生的脏帧。如果你以前用过NModbus4,这个思路和Modbus RTU的LRC/CRC校验非常相似,只是松下协议用异或而不是多项式校验。
调用顺序一般是这样:
string raw = Request("01", "RCP", "00020004"); if (!VerifyBcc(raw)) { AppendLog("BCC错误"); return; } string value = ExtractData(raw); if (value.StartsWith("ERR:")) { AppendLog("PLC异常: " + value); return; } // value按4位一组读取,得到的每4个十六进制字符就是一个字 for (int i = 0; i < value.Length; i += 4) { string word = value.Substring(i, Math.Min(4, value.Length - i)); // 这里再转int或short }0002在资料里对应PLC中的DT2,0004表示连续4个字。读取响应里的数据用value.Substring切分,4个字符一组解析成字,高地位顺序要看PLC型号。如果数据看起来是反转的,把每组的前后两个字节换一下再转int,这也是松下协议里最容易踩的字节序坑。
4.3 日志与串口复用保护
建议在调试阶段保留一份十六进制日志。拼接发送帧时看十六进制比看转义后的ASCII字符串直观得多:
private void AppendLog(string message) { string hex = string.Join(" ", Encoding.ASCII.GetBytes(message).Select(b => b.ToString("X2"))); txtLog.AppendText($"[{DateTime.Now:HH:mm:ss.fff}] {message} | HEX: {hex}\r\n"); }串口本身是独占资源,多个按钮、多个定时器同时调用Request会产生写重叠。用SemaphoreSlim把请求包一层是最直接的改造:
private readonly SemaphoreSlim _comLock = new SemaphoreSlim(1, 1); private async Task<string> RequestAsync(string station, string command, string text) { await _comLock.WaitAsync(); try { return await Task.Run(() => Request(station, command, text)); } finally { _comLock.Release(); } }这样多个采集任务同时触发时,实际发到串口的数据仍然是串行执行的。松下RS232协议没有抢占机制,如果两条命令在协议层面交错,PLC的响应帧会彻底错乱,且很难从抓包里复原。加了信号量后,即使UI线程连续点击“读取”和“写入”按钮,也不会出现底层串口写重叠。
5. 循环数据采集与UI刷新卡顿的实战处理
5.1 为什么定时器直接采集会卡死界面
最常见的采集写法是Timer每50ms调用一次Request,然后在SerialPort.ReadLine()等待数据。由于ReadTimeout=500,一个未插线或未上电的串口就会让UI线程阻塞半秒,界面表现为点击按钮后整个窗体无响应。换到后台线程后,再用BeginInvoke更新UI,问题立刻缓解。源码案例里已经给出了最简单的同步更新方式,但实际工控界面往往还要同时更新多个文本框、DataGridView和曲线,这时候需要把刷新频率和采集频率解耦。
一个适合直接抄的采集循环如下:
private async void StartCollect() { while (isCollecting) { string raw = await RequestAsync("01", "RCP", "00020004"); if (raw.StartsWith("%01$")) { string data = ExtractData(raw); lblValue.BeginInvoke(new Action(() => lblValue.Text = data)); } await Task.Delay(100); } }这里面RequestAsync已经包含了信号量保护和Task.Run,采集循环本身不再占用UI线程。BeginInvoke是异步投递,不会像Invoke那样等界面画完才返回,所以即使刷新比较频繁,串口采集循环的节奏也不会被界面拖慢。Task.Delay(100)至少保证两次命令之间有100ms间隔,这是RS232半双工切换和PLC内部处理都需要的余量。
不要在一个轮询周期里连续发多条命令。很多数据采集程序喜欢把“读电压”“读温度”“读状态”三个Request顺序写完,结果第一条响应还没回来,第二条命令已经顶到串口缓冲区。把轮询间隔拉到100ms以上,并且每条命令都走同一个RequestAsync,才能保证每条命令都拿到属于自己的响应帧。
ExtractData返回的十六进制字符串可以直接用于记录日志,也可以进一步Convert.ToInt32。如果要做历史曲线,建议在后台线程先把数值算好,扔进ConcurrentQueue,再由UI的Timer只做出队刷新,这样UI刷新卡顿问题就彻底变成了普通的队列消费问题,和串口响应时间无关了。
本文还有配套的精品资源,点击获取