做机器视觉上位机开发的,早晚要面对一件事:视觉结果怎么交给产线去执行。相机拍完照、算法出结果,OK还是NG如果不送到PLC里,后面的剔除机构、气缸、报警灯全都不会动。我在实际项目里最常用的方案,就是用C#写上位机,通过串口跟Panasonic松下PLC做通讯,把视觉判定结果写进PLC的寄存器,同时读取产线当前状态。这篇文章把我自己在松下FP系列PLC串口通讯上的完整经验整理出来,从Mewtocol协议、报文格式、C#代码实现,再到现场踩过的坑,一次性讲透。适合正在做机器视觉集成、或者手里有松下PLC想接上位机的朋友,也适合第一次接触工控通讯、对着协议手册一头雾水的C#开发者。内容偏实战,看完能直接照着写。
1. 项目背景与整体设计思路
1.1 为什么是“串口通讯”而不是网口
先聊一个方向问题。现在很多PLC都带以太网口,松下FP系列里也有一部分型号支持TCP/IP通讯,那为什么还要用串口?答案很简单:老设备存量太大,串口方案便宜又稳定。
我接触过的不少产线,PLC型号是FP0R、FP-X这类老款,默认配置就是COM口,没有网口,或者网口被厂家程序占用了。就算有新设备,一个RS232/RS485串口模块也就几十块钱,而上位机这边随便一个USB转串口就能解决,布线和配置都简单。对于视觉结果这种“每次就传几字节”的场景,串口的速度完全够用——哪怕9600波特率,一秒也能传几百个数据帧,视觉检测的节拍一般在几十毫秒到几百毫秒,串口根本不是瓶颈。
所以方向选择上我的建议是:能用串口解决的问题,优先串口;等到需要大数据量、多设备组网的时候,再考虑以太网。这跟有些人一上来就追求高大上不一样,工控项目里稳定和够用永远是第一位的。
1.2 机器视觉与PLC的典型交互流程
机器视觉上位机跟PLC的交互,说白了就是三件事:收指令、给结果、查状态。
视觉相机拍照的触发信号,往往就是PLC通过串口发给上位机的。比如PLC检测到产品到位,发送“请拍照”的命令,上位机收到之后控制相机拍照、跑算法,得出OK/NG结果,再写回PLC。PLC拿到结果之后驱动气缸把不合格品剔除。整个过程像一条流水线,两边的配合完全靠通讯协议维持。
这类交互里,有几个关键点要注意:命令要带超时判断,因为相机拍照偶尔会失败;结果写入要确认,不能发出去就不管了;循环采集状态下UI不能卡顿,不然操作员看界面会很难受。这些我在第3章和第4章都会详细展开。
1.3 松下PLC串口通讯的协议选择:Mewtocol
松下PLC的串口通讯有它自己的一套协议,叫Mewtocol,官方手册全称是《松下电工 可编程控制器FP系列 通信命令手册》。ASCII格式,报文清晰,调试起来比Modbus RTU那堆十六进制字符直观多了。
注意,Mewtocol跟Modbus不是一回事。C#社区里常用的NModbus4库,那是走Modbus协议用的。松下FP系列老型号的串口默认走Mewtocol,如果你想用Modbus,得看具体型号支不支持,而且还要在PLC系统寄存器里改协议类型。所以做松下串口,先老老实实把Mewtocol用熟练,这是基本功。
还有一点,Mewtocol不仅支持读取写入数据寄存器(D区),还支持直接读线圈触点(X、Y、R区),甚至可以远程操作PLC的程序状态。这意味着上位机能做的操作非常灵活,不只是传数据,还可以控制PLC启停、读取运行状态,这对产线上位机来说非常实用。
2. Mewtocol报文协议核心拆解
2.1 帧格式与BCC校验原理
Mewtocol的报文格式非常固定,读懂了就一通百通。一帧完整的命令由这几部分组成:
- 起始符:
%(ASCII 0x25),表示这是一条上位机发出的命令;如果返回的响应帧起始符是%,表示正常响应,也可能是!,那代表出错了,后面会说。 - 站号:两位十进制数,范围00到99。PLC侧有对应的系统寄存器设置,默认是01,可以改。如果你的产线上挂了多台松下PLC,靠站号区分。
- 命令类型:两个大写字母,比如
RD读数据、WD写数据、RCS读触点、WCS写触点。 - 文本区:地址、数量、数据等具体信息。
- BCC校验:这是松下Mewtocol特别有意思的地方,它不像Modbus RTU用CRC16,而是用非常简单的异或(XOR)。计算方法:从“%”之后、BCC之前的所有字符,按ASCII码逐字节异或,得到一个8位字节,再转成两位大写十六进制ASCII字符,拼到帧尾部。
- 终止符:回车符
\r(0x0D)。
我举个例子,读Y0触点状态的命令,完整帧是这样的:
%01#RCS050\r拆开来看:%起始符,01站号,#固定分隔符,RCS读触点命令,0是触点编号,50是BCC校验。BCC怎么来的?把“01#RCS0”这7个字符的ASCII码逐个异或,正好算出0x50,转换成ASCII字符就是“50”。
C#里计算BCC就一行代码的事,后面代码部分会写。但如果用串口调试助手手工发命令验证,这个手算过程还是得会,不然现场临时没电脑、没法跑程序的时候就抓瞎了。
2.2 常用命令详解:读数据、写数据、读写触点
Mewtocol的命令不少,但我实际项目中90%以上只用四个:RD、WD、RCS、WCS。
RD(读数据寄存器):格式是%站号#RD起始地址 数据个数。比如读D100开始的一字数据,地址用四位十六进制表示,命令帧就是%01#RD01000001**\r,其中0100是D100的地址,0001是读取1个字。正常响应格式是%01$RD0100数据**\r,比如返回%01$RD01001234**\r,那D100的当前值就是0x1234。
WD(写数据寄存器):格式是%站号#WD起始地址 数据个数 数据内容。比如要把D100写成1,命令帧就是%01#WD010000010001**\r,写完之后PLC会返回一个不带数据区的响应帧%01$WD**\r,收到它就知道写入成功了。
RCS/WCS(读/写单个触点):触点包括输入继电器X、输出继电器Y、内部继电器R。比如读Y0输出状态,发%01#RCS0**\r,返回%01$RC0状态 校验,状态是0就是OFF,1就是ON。写Y0同理,%01#WCS01**\r表示把Y0置ON。
这里有个细节,D区地址从0000开始映射,R区地址规则跟D区不完全一样,不同型号也略有差异。我一般直接参考对应型号的手册里的地址映射表,别靠记忆,容易踩坑。比如老款FP0的D区和FP-X的D区,在某些特殊地址上就不同。
2.3 寄存器地址映射与数据类型
Mewtocol里,寄存器地址映射这件事,新手最容易懵。其实逻辑不复杂:数据寄存器D、链接寄存器L、文件寄存器FL等,都有各自的编号规则。以最常见的D区为例,D100在协议里对应的地址就是0100,直接用十进制转十六进制就行。而W(写入)指令发送的数据区,前面说的四个十六进制字符表示一个字,它是一个无符号十六进制数。
做视觉上位机的时候,我通常会把“检测结果”“产品编号”“测试时间戳”这些放到固定的D区地址。比如约定D100 = 当前产品结果(1表示OK,2表示NG),D101 = 产品计数,D102-D110 = 根据实际需要扩展。这样PLC工程师那边也好写,上位机这边也好维护。
另外,数据类型转换方面要留意:从PLC读回来的原始数据是字符串形式的十六进制,C#里要转成ushort、int或者float,要根据PLC里面存的是什么类型来决定。如果PLC那边是32位浮点,那就得连续读两个寄存器,再手动拼成float,这个我在代码部分会给出示例。
3. C#串口通讯模块的完整实现
3.1 搭建一个松下PLC通讯类
我用C#写松下串口通讯,一般会封装成一个独立的类库,比如叫PanasonicMewtocolClient。这样视觉算法、业务逻辑、UI层都可以调用它,而且以后换PLC型号,只要改这个类就行。
先看最基础的结构,包括串口初始化、打开关闭、BCC计算、报文拼接这几个功能:
using System; using System.IO.Ports; using System.Text; public class PanasonicMewtocolClient : IDisposable { private SerialPort _port; private byte _stationNo = 0x01; // 站号,默认1 private readonly object _lockObj = new object(); public PanasonicMewtocolClient(string portName, int baudRate = 9600) { _port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _port.ReadTimeout = 500; _port.WriteTimeout = 500; } public void Open() { if (_port.IsOpen) return; _port.Open(); } public void Close() { if (_port != null && _port.IsOpen) _port.Close(); } /// <summary> /// 计算BCC校验:从%之后到BCC之前的字符做异或 /// </summary> private byte CalculateBcc(string framePart) { byte result = 0; byte[] bytes = Encoding.ASCII.GetBytes(framePart); foreach (byte b in bytes) { result ^= b; } return result; } /// <summary> /// 拼装完整命令帧:起始符 % + 内容 + BCC + \r /// </summary> private string BuildFrame(string bodyWithoutPercent) { byte bcc = CalculateBcc(bodyWithoutPercent); return "%" + bodyWithoutPercent + bcc.ToString("X2") + "\r"; } }这里有几个细节需要注意:串口参数波特率不一定是9600,得跟PLC系统寄存器里设置的一致,否则发出去全是乱码,这个我在第4章展开。还有,站号默认是1,如果现场改了PLC站号,这里也要对应改。
3.2 读寄存器与写寄存器的完整方法
有了基础类,接下来实现最常用的两个方法:读数据寄存器ReadRegister和写数据寄存器WriteRegister。
/// <summary> /// 读取数据寄存器,例如读取D100一个寄存器 /// </summary> public ushort ReadRegister(int address) { lock (_lockObj) { string body = $"{_stationNo:D2}#RD{address:X4}0001"; string frame = BuildFrame(body); _port.DiscardInBuffer(); _port.Write(frame); string response = ReadResponse(); if (string.IsNullOrEmpty(response) || response[0] == '!') throw new Exception($"读寄存器失败,响应:{response}"); // 响应格式示例:%01$RD01001234**\r // 截取数据区:地址4位 + 数据4位 string dataPart = response.Substring(9, 4); return Convert.ToUInt16(dataPart, 16); } } /// <summary> /// 写入数据寄存器,例如把D100写为1 /// </summary> public void WriteRegister(int address, ushort value) { lock (_lockObj) { string data = value.ToString("X4"); string body = $"{_stationNo:D2}#WD{address:X4}0001{data}"; string frame = BuildFrame(body); _port.DiscardInBuffer(); _port.Write(frame); string response = ReadResponse(); if (string.IsNullOrEmpty(response) || response[0] == '!') throw new Exception($"写寄存器失败,响应:{response}"); } }这里我用了lock _lockObj,原因很现实:产线上位机往往是多线程的,视觉线程在写结果,UI线程在轮询状态,如果不加锁,两个线程同时往串口写数据,报文就会互相穿插,PLC那边收到的就是乱帧。串口操作必须串行化,这是工控通讯的基本素养。
接收响应的ReadResponse这里,我用了一个简单但可靠的循环读取方式:读直到遇到\r结尾为止:
private string ReadResponse() { StringBuilder sb = new StringBuilder(); int start = Environment.TickCount; while (Environment.TickCount - start < 500) { int count = _port.BytesToRead; if (count > 0) { byte[] buffer = new byte[count]; _port.Read(buffer, 0, count); sb.Append(Encoding.ASCII.GetString(buffer)); if (sb.Length > 0 && sb[sb.Length - 1] == '\r') break; } else { Thread.Sleep(10); } } return sb.ToString(); }注意,这个方法是阻塞等待响应的,适合命令/响应模式的场景。如果你的上位机需要全双工、PLC会主动上报数据,那得换事件驱动的方式,用DataReceived事件加队列,后面会讲。
3.3 循环采集数据与UI刷新卡顿的解决思路
做上位机的人基本都遇到过这个热搜问题:C#循环采集串口数据时,UI界面卡成PPT。原因很简单,串口数据接收是在后台线程(或者说DataReceived事件线程)里触发的,如果你在这个线程里直接去改文本框、图表控件,UI线程忙不过来,自然卡顿。再加上有些人图省事用Thread.Sleep死循环去轮询,那更卡。
我常用的解决方案是“生产者-消费者”模式:串口接收线程只管把原始数据塞进队列,UI线程用定时器按固定频率去队列里取数据刷新界面。
private ConcurrentQueue<string> _receiveQueue = new ConcurrentQueue<string>(); private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { SerialPort sp = (SerialPort)sender; int count = sp.BytesToRead; byte[] buffer = new byte[count]; sp.Read(buffer, 0, count); string chunk = Encoding.ASCII.GetString(buffer); _receiveQueue.Enqueue(chunk); }UI这边,在窗体加载时启动一个System.Windows.Forms.Timer,间隔50ms,在Tick事件里批量取出队列数据并解析、刷新控件:
private void TimerRefresh_Tick(object sender, EventArgs e) { while (_receiveQueue.TryDequeue(out string chunk)) { _rawBuffer.Append(chunk); // 尝试从缓冲区里取出完整的一帧(以\r结尾) int endIndex; while ((endIndex = _rawBuffer.ToString().IndexOf("\r")) >= 0) { string frame = _rawBuffer.ToString().Substring(0, endIndex + 1); _rawBuffer.Remove(0, endIndex + 1); ProcessFrame(frame); } } }这样做的核心思想是:UI永远只做轻量级的事情,重活(解析、校验、业务处理)都放到后台线程,或者至少不要在控件里堆积大量数据。实测下来,就算每秒几百帧数据,界面也稳如老狗。
3.4 读取32位浮点数据与批量地址封装
机器视觉项目里,经常会遇到需要读取PLC里Float数据的情况,比如相机曝光值、位置坐标、温度值等。PLC侧如果存的是32位浮点数,那它实际占用了两个连续寄存器,比如D200和D201。Mewtocol读回的是两个16位的十六进制字符串,我们需要拼起来再转成C#的float。
public float ReadFloat(int startAddress) { // 连续读两个寄存器 string body = $"{_stationNo:D2}#RD{startAddress:X4}0002"; string frame = BuildFrame(body); _port.DiscardInBuffer(); _port.Write(frame); string response = ReadResponse(); if (string.IsNullOrEmpty(response) || response[0] == '!') throw new Exception($"读浮点失败,响应:{response}"); // 假设响应数据区是 8 个十六进制字符,如 0100 0200 string dataPart = response.Substring(9, 4) + response.Substring(13, 4); uint raw = Convert.ToUInt32(dataPart, 16); return BitConverter.ToSingle(BitConverter.GetBytes(raw), 0); }这里有个很容易出错的点:大小端顺序。松下PLC和C#的内存字节序不一定一致,如果直接转出来的浮点数完全不对,多半就是寄存器高低字顺序反了。我一般会先把读到的两个寄存器值打印出来,跟PLC侧比对,确认顺序后再封装,避免现场瞎试。
4. 工程上的坑与排查实录
4.1 PLC串口参数不匹配,数据全乱码
这是最开始最常见的坑。上位机串口默认参数一般设置成9600、8、N、1,但PLC侧的系统寄存器里,通信格式可能是19200,甚至有校验位、停止位不同。两边参数只要有一个不一致,收到的就是乱码或者根本没响应。
排查方法很简单:先在PLC编程软件(FPWIN GR)里查看系统寄存器No.20~No.26,确认协议类型是不是“计算机链接”,确认波特率、数据位、校验位、停止位,然后让上位机的SerialPort参数跟它严格一致。
特别提醒:松下PLC的串口协议一定要设置成“计算机链接”,如果还停在编程口模式,你发的Mewtocol命令它会当没看见。我遇到过两次这种问题,都以为代码写错了,最后发现是系统寄存器没改。
4.2 通信模式没切换,命令石沉大海
接上文,很多人配好了波特率,发送数据还是没反应,那就要检查PLC系统寄存器里有没有把通信模式设成“计算机链接”。松下PLC的编程口默认是用于编程软件上传下载程序的,如果不切换模式,上位机发过去的数据PLC根本不会按Mewtocol解析。
这问题最坑的地方在于:串口参数全对、硬件接线也没问题、代码看着也没问题,但它就是不回你。所以这块我建议在项目启动前就先确认好,别等联调的时候浪费时间。改完系统寄存器之后,PLC需要重新上电生效,这一点也要跟现场电工打好招呼。
4.3 粘包、半包与数据校验失败
串口通讯天生没有“消息边界”的概念,它只是一串字节流。PLC返回的数据可能会两帧粘在一起,也可能一帧被拆成两半,这是串口编程里永远躲不开的问题。好在Mewtocol有\r作为帧结束符,所以解析时以\r为边界把数据切分成完整帧,就能解决90%的问题。
我在第3章代码里用的_rawBuffer字符串缓冲区就是这个思路:不断追加新收到的数据,然后循环找\r,找到一个就切走一帧。这里有个小技巧,收到的数据不要直接当字符串处理,最好先按ASCII解码,然后统一用\r分割。响应帧结尾的\r就是天然的帧分隔符,不需要自己去猜帧长。
另外,如果BCC校验失败,PLC会返回!开头的错误响应帧,常见错误码有这几个(具体以手册为准):
| 错误码 | 含义 | 常见原因 |
|---|---|---|
| 21 | BCC校验错误 | 帧尾校验值算错或通讯干扰 |
| 22 | 格式错误 | 报文格式不对,缺字符或多字符 |
| 32 | 超出范围 | 地址或数据数量超出PLC可访问范围 |
| 41 | 密码保护 | PLC设置了密码,禁止远程操作 |
调试时看到!响应,别慌,对照错误码就能定位到问题。
4.4 现场串口频繁断开,USB转串口不稳定的处理
产线上用的上位机,很多是普通工控机,不带原生COM口,几乎全靠USB转串口。这种方案便宜,但偶尔会遇到串口假死、无法打开、拔插之后端口号变了这类问题。我的处理经验是:
- 选用FTDI芯片或CH340芯片的USB转串口线,兼容性好,驱动稳定;国内有些几块钱的转接线芯片太杂,现场干扰一大就容易掉。
- 上位机代码里要处理
SerialPort打开失败、读写异常等情况,不要一崩溃就退出,最好加自动重连机制。 - 固定USB端口号:在设备管理器里,给USB转串口设置固定COM号,避免每次插到不同USB口导致COM号变化,上位机配的端口对不上。
- 程序启动时做一次端口检测,不依赖固定COM号最好,可以自动枚举可用串口并匹配。
自动重连的逻辑不复杂,定时检测串口是否IsOpen,如果关闭了就尝试重新打开。我一般用一个后台线程来做这个检查,间隔2秒,实时性好,也不影响主逻辑。
4.5 机器视觉联调中的典型问题
视觉程序和PLC联调,最典型的一个问题就是通讯时序。比如PLC这边发了一个“拍照”命令,结果视觉程序因为上电启动慢、算法初始化没完成,没有及时响应,PLC那边就超时报错了。解决办法是上位机启动完成后主动向PLC发送一个“上位机就绪”状态,PLC收到之后才允许产线启动,双方约定好握手流程。
还有一个常见问题,就是“结果写进去PLC没反应”。这时候先别怀疑通讯,先用串口调试助手手动发一帧写寄存器命令,比如把D100写成1,看PLC那边对应的寄存器值变没变。如果变了,说明通讯链路和协议都没问题,问题出在PLC程序逻辑里没有去读这个地址,或者地址映射错了。这种排查思路能帮你快速缩小范围,而不是反复改上位机代码又看不到效果。
5. 调试环境搭建与辅助工具
5.1 使用串口调试助手手动验证协议
写代码之前,我强烈建议先用串口调试助手把Mewtocol命令完整跑通一遍。新建一个连接,选择对应的COM口和波特率,然后手动发送一帧,比如:
%01#RCS050\r如果PLC正常响应,应该会返回类似%01$RC0**\r的内容,这就说明物理链路、协议设置都OK了。然后你再把同样的命令拿到C#代码里发,一切水到渠成。
手动验证的价值在于:它把“硬件问题”“协议问题”“代码问题”这三层一次切干净。串口助手发命令PLC有反应,那问题一定在代码侧;串口助手发命令都没反应,那就要查接线、查PLC参数、查站号了。
5.2 虚拟串口工具与本地调试
有时候PLC不在身边,或者实验室里只有一台电脑,怎么写代码?用虚拟串口工具。我现在常用的方案是com0com这类虚拟串口软件,创建一对绑定端口,比如COM3和COM4,然后让C#程序打开COM3,另一个串口调试助手打开COM4,两边就能互发数据。这样开发阶段就可以把协议解析、界面刷新这些逻辑先跑通,拿到现场再对接真PLC。
不过要注意,虚拟串口只能模拟字节流的收发,PLC的响应逻辑、错误码返回这些模拟不了。所以本地调试只能验证上位机的“发送-接收-解析”链路,真正的BCC计算对不对、帧格式对不对,还是得拿真实PLC过一遍。
5.3 日志系统:工控上位机必备的调试手段
做上位机通讯,日志系统真的不能省。我见过太多项目,现场出了问题,上位机界面又不能随便停,最后一堆人围在那里猜。所以我在所有通讯模块里都加了一套简单的文本日志:记录每一帧发出的命令、每一帧收到的响应、异常信息、耗时。
日志级别大概分3类:
- 调试级:记录全部收发帧原文,开发阶段开,现场关闭,避免日志文件疯狂膨胀。
- 信息级:记录读写寄存器成功、结果写入OK这类关键动作。
- 错误级:记录超时、BCC校验失败、串口异常等。
日志文件按天切割,保留最近30天。现场出了问题,翻日志几秒钟就能定位到是哪一帧通讯异常、是PLC没响应还是数据校验不对。这套习惯帮我排掉过无数莫名其妙的现场问题。
6. 从通讯到项目落地的几点体会
代码写完之后,最后聊点我的个人经验。
第一个体会:通讯协议要尽早定下来。视觉程序、PLC程序的开发往往是并行的,如果等到联调那天才坐下来对地址表,两边都会很痛苦。我一般会牵头出一份简单的通讯地址表,列出每个寄存器的地址、读写方向、数据类型、含义,发给PLC工程师确认,双方照着实现,联调效率能提升一大截。
第二个体会:代码里要有容错,但不要过度重试。串口通讯偶尔丢一帧很正常,重试机制可以做,但比如写结果这种操作,重试3次都失败就该停下来报错,而不是无限重试,否则产品已经流到下一工位了,结果还没写进去,产线上会出现漏剔。
第三个体会:松下的Mewtocol协议看着简单,但现场工控环境千奇百怪,地线干扰、USB供电不足、线缆过长都会导致偶发通讯异常。如果产线通讯不稳定,先查硬件、换线、换USB口,别急着怀疑协议。我见过一个项目,最后问题是出在一条用了五年、内部已经折断的串口线上,换线立马就好了,这种事情见得多了,你也会把“硬件优先”放在第一位。
第四个体会:如果有人问我,做机器视觉上位机,最值得提前储备的通讯技能是什么,我的答案不是某个具体协议,而是串口通讯这一整套思维:帧格式、校验、缓冲、超时、重试、多线程访问。这套东西学会了,Mewtocol、Modbus、三菱、西门子,无非就是换一套报文规则而已,底层逻辑完全相通。最后再说一个小技巧:调试串口命令的时候,串口助手上如果开的是“文本模式 + 发送新行(\r\n)”,记得把波特率、停止位这些挨个跟代码里的配置核对一遍,再开始怀疑协议。松下这个系列的PLC,我踩过的坑一半都在参数不匹配上。把这个问题先排掉,很多“疑难杂症”其实都不是问题。