
简介C#上位机与松下PLC通信项目示例面向工业自动化开发者聚焦手机屏幕异物检测场景中上位机与PLC的数据交互与控制流程实现。资料内容覆盖通信接口配置、寄存器读写指令封装、异常处理以及视觉检测上位机界面设计适合需要快速上手C#与工业PLC联调的人群。压缩包共113个文件以C#源码44个cs为主附带Windows窗体设计资源resx/resources、可执行程序、PNG图标、Access数据库及工程文件包体5.64MB结构完整便于直接运行与二次开发。示例包含串口与TCP/IP两种连接方式详细展示波特率、数据位等参数设置以及PLC寄存器读写指令的拼接与解析方法界面部分提供检测状态显示和控制操作并可通过Access数据库保存检测记录。目前已有1895人学习下载。通过该项目可快速掌握C#与松下PLC通信的关键写法并参考集成了视觉处理与数据存储的完整上位机框架为工业通信与图像检测结合的实际开发提供有效参考。1. 通信方案选型与基础原理1.1 为什么要搞清楚松下PLC的通信协议做上位机开发遇到松下PLC是很常见的事。无论是新项目选型还是老设备改造C#和松下PLC通信几乎是绕不开的环节。很多新手上来就搜“C# 松下PLC通信例子”结果找到一堆零散的代码有的用串口有的用网口协议格式还不一样抄过来根本跑不通。先说结论松下PLC最常用的是Mewtocol协议这个协议同时支持串口RS232/RS485和以太网TCP/UDP两种物理通道。也就是说无论你用的是FP系列、FP-X系列还是FP0R系列只要把报文格式写对了通信逻辑基本是通用的。这一点非常重要意味着你只需要学会一套报文规则就能应对绝大多数松下PLC的通信需求。Mewtocol协议本质上是一种主从问答式协议。上位机作为主站发送请求帧PLC作为从站返回响应帧一问一答逻辑非常简单。它的报文格式大致是这样% 站号(2位) 识别码 命令码 参数 CRC校验(2位) CR(回车)这里的识别码通常用#表示这是一条指令帧命令码比如RCS表示读取单个字WCS表示写入单个字RCP读取多个字WCP写入多个字。CRC校验是异或校验把%之后到CRC之前的所有字符逐字节异或得到一个字节再转成两位十六进制大写字符。我刚开始接触时也觉得这东西有点绕但拆开看就很简单发送一条格式正确的请求然后解析返回的数据。理解了这一点后面所有代码都是围绕“组装报文”和“解析报文”两个动作来写的。1.2 串口和以太网怎么选在实际项目里选串口还是网口主要看现场情况。串口通信适合PLC离上位机比较近的场景比如设备机柜内、控制室隔壁。接线简单一根RS232线或者RS485两芯线就能搞定。但串口有几个问题波特率、数据位、校验位这些参数必须和PLC侧完全一致否则通信直接失败另外串口线长度有限RS232一般不超过15米RS485可以到几百米但对布线要求更高。以太网通信现在是主流选择。松下PLC很多型号自带网口即使没有也可以通过扩展模块比如FP-X的通信插件或者外接串口服务器转成网络。以太网的好处很明显传输距离远、抗干扰能力强、可以穿过交换机连接多台上位机而且C#这边直接用TcpClient或Socket就能写比操作串口还要简单一点。这里有个实操建议如果现场PLC有以太网口优先走以太网如果只有串口也可以用一个串口服务器比如网络热词里提到的TAS-WIFI-265S那类设备把485信号转成TCP这样上位机这边统一走网络通信代码更干净后期维护也方便。我见过不少项目就是PLC走串口服务器传感器数据通过485汇总到串口服务器再通过MQTT或TCP上传给上位机整体架构很清晰。2. C#通信核心代码框架2.1 串口通信基础实现串口通信在C#里主要靠System.IO.Ports.SerialPort类用法很直接。先配置端口参数然后打开端口发送报文等待响应。下面是一段最小可用的串口读取代码using System; using System.IO.Ports; using System.Text; using System.Threading; public class PanasonicPlcSerial { private SerialPort _serialPort; public PanasonicPlcSerial(string portName, int baudRate 9600, Parity parity Parity.Even, int dataBits 8, StopBits stopBits StopBits.One) { _serialPort new SerialPort(portName, baudRate, parity, dataBits, stopBits); _serialPort.ReadTimeout 1000; _serialPort.WriteTimeout 1000; } public void Open() { if (!_serialPort.IsOpen) { _serialPort.Open(); } } public void Close() { if (_serialPort.IsOpen) { _serialPort.Close(); } } public string SendCommand(string command) { Open(); _serialPort.DiscardInBuffer(); _serialPort.Write(command); Thread.Sleep(50); // 等待PLC响应 string response _serialPort.ReadExisting(); return response; } }这段代码有几个细节值得注意。DiscardInBuffer()在发送前清空接收缓冲防止上次残留的数据干扰本次解析。Thread.Sleep(50)是给PLC留出处理时间这个时间可以根据实际通信距离和PLC负载调整但不要太短否则容易读到空数据。更严谨的做法是用ReadLine()按行读取因为Mewtocol协议帧以CR结尾正好可以按行分割。串口的参数配置要和PLC编程软件里的设置保持一致。松下PLC的串口出厂默认一般是9600、8、Even、1但不同型号可能不一样务必在松下PLC编程软件里确认。这一点我刚入行时吃过亏拿着默认参数去连死活不通最后发现PLC那边被改成19200了。2.2 以太网通信基础实现以太网通信更简单TcpClient连上之后就是收发字节流。松下PLC的以太网通信端口通常固定为1024这个值在PLC侧的网络设置里可以看到也可以改但默认就是1024。下面是TCP方式的代码骨架using System; using System.Net.Sockets; using System.Text; public class PanasonicPlcTcp { private TcpClient _tcpClient; private NetworkStream _stream; private string _ip; private int _port; public PanasonicPlcTcp(string ip, int port 1024) { _ip ip; _port port; } public bool Connect() { try { _tcpClient new TcpClient(); _tcpClient.Connect(_ip, _port); _stream _tcpClient.GetStream(); _stream.ReadTimeout 1000; _stream.WriteTimeout 1000; return true; } catch (Exception ex) { Console.WriteLine(连接失败: ex.Message); return false; } } public string SendCommand(string command) { if (_tcpClient null || !_tcpClient.Connected) { throw new Exception(未连接到PLC); } byte[] sendBytes Encoding.ASCII.GetBytes(command); _stream.Write(sendBytes, 0, sendBytes.Length); _stream.Flush(); byte[] buffer new byte[1024]; int bytesRead _stream.Read(buffer, 0, buffer.Length); string response Encoding.ASCII.GetString(buffer, 0, bytesRead); return response; } public void Close() { _stream?.Close(); _tcpClient?.Close(); } }这里我用的是同步阻塞模式简单直观适合工具类程序。但如果你的上位机需要同时轮询多台PLC或者需要快速响应用户操作建议改成async异步模式配合ReadAsync和WriteAsync避免界面卡死。异步的问题后面在UI刷新部分还会详细说。3. Mewtocol报文组装与完整实操3.1 CRC校验计算CRC是很多新手容易卡住的地方。Mewtocol协议的CRC计算方法是从站号开始即%之后第一个字符到CRC前一个字符为止把这些字符的ASCII码逐字节异或得到的单字节值转成两位大写十六进制字符串。下面这个函数可以通用public static string CalculateCRC(string command) { byte crc 0; char[] chars command.ToCharArray(); foreach (char c in chars) { crc ^ (byte)c; } return crc.ToString(X2); }调用方式也很简单string commandWithoutCrc %01#RCSD0100; string crc CalculateCRC(commandWithoutCrc); string fullCommand commandWithoutCrc crc \r;注意\r是Mewtocol帧的结束标志一定要加上。有些PLC对帧尾检查很严格缺了\r直接不响应。我遇到过一个坑CRC计算范围搞错了。有人把%也包含进去算结果怎么都对不上。记住%是起始标志不参与CRC计算从站号开始算。另外CRC结果是大写小写的a到f在某些PLC固件版本上会报错所以统一用X2格式化。3.2 读取寄存器完整示例现在来一个完整的读取示例。假设PLC站号为01要读取数据寄存器DT100的当前值。报文结构拆解如下组成部分内容说明起始标志%帧头站号01PLC站号范围00-99识别码#指令帧标志命令码RCS读取单个字Read Contact Single寄存器地址DT00100DT区域地址编号占5位这里寄存器地址的格式很容易写错。松下的地址字符串是“区域类型5位数字”比如DT100写成DT00100DT123写成DT00123DT0写成DT00000。刚开始写代码时我直接拼字符串DT100.ToString(00000)这样处理就不会漏位了。完整的发送读取代码如下public string ReadSingleWord(string areaCode, int address) { string addr areaCode address.ToString(00000); string command %01#RCS addr; string fullCommand command CalculateCRC(command) \r; string response SendCommand(fullCommand); return ParseResponse(response); }ParseResponse的解析逻辑要看响应帧格式。正常的响应帧一般是%01$RCS... 数据 ... CRC CR其中识别码从#变成了$表示这是响应帧。返回的数据部分是ASCII表示的十六进制字符串比如读到的值是0x012C十进制300响应数据里就是012C。解析时提取出来转成int即可public static int ParseResponse(string response) { if (string.IsNullOrEmpty(response) || response.Length 9) { throw new Exception(响应帧长度异常); } // 实际项目里要检查响应帧的识别码为$, 且不包含错误码 string dataPart response.Substring(6, response.Length - 6 - 4); return Convert.ToInt32(dataPart, 16); }这里的Substring(6是跳过了%01$RC这些固定字符后面截掉CRC2位和\r1位。不同命令码返回数据的长度不一样解析方式也需要相应调整所以建议针对每种命令码单独写解析函数别搞一个万能解析。3.3 写入寄存器与批量读写写入单字的命令是WCS报文格式和读取类似只是把要写入的数据放在命令后面。写入DT100为0x0064十进制100的报文public string WriteSingleWord(string areaCode, int address, int value) { string addr areaCode address.ToString(00000); string hexValue value.ToString(X4); string command %01#WCS addr hexValue; string fullCommand command CalculateCRC(command) \r; string response SendCommand(fullCommand); return ParseResponse(response); }批量读写用的是RCP和WCP可以一次操作连续地址。比如一次读取DT100到DT109共10个字public string ReadMultiWords(string areaCode, int startAddress, int count) { string addr areaCode startAddress.ToString(00000); string command %01#RCP addr count.ToString(X2); string fullCommand command CalculateCRC(command) \r; string response SendCommand(fullCommand); return response; }批量读取返回的数据是一长串十六进制字符每4个字符表示一个字。解析时按4个字符一组切分再逐一转换成数值。批量读写能显著减少通信次数特别是轮询大量寄存器时速度差距非常大。举个例子读100个字用RCP只需要一次往返用RCS就得100次你自己算算这个差距。3.4 实现效果验证写好代码之后怎么验证通信是否成功最直接的方法是先用串口调试助手或者网络调试工具手动发送一条报文看PLC有没有响应。调试时我会先抓PLC编程软件里的通信监控看它发出来的报文长什么样然后照着拼一条。我之前做的一个项目里为了验证通信正确性第一步是只读DT0这个寄存器手动发%01#RCSDT00000C2\rCRC是算出来的这个帧看返回是不是%01$RCS...。确认基础通信通了再开始写批量逻辑。这样一步步来出问题也容易定位不会一上来就是一堆报文堆在一起出了错都不知道是组装问题还是解析问题。4. 常见问题与排查技巧实录4.1 通信超时与无响应最典型的故障就是发送命令之后半天没响应最终超时。遇到这种情况我一般按照下面这个顺序排查检查物理连接。串口线松了、网线没插好这些低级错误反而最难发现。特别是工业现场线缆多、震动大插头松脱很常见。检查IP地址和端口。用ping命令测试PLC的IP是否通。如果ping不通检查上位机网卡IP是否和PLC在同一网段。检查站号。报文里的站号必须和PLC设置一致。比如PLC站号是02你发%01开头它根本不搭理你。检查CRC计算是否正确。用调试助手把报文原样发一遍如果手动发能通、程序发不通那问题就出在代码拼帧上。检查时序和延时。串口通信时PLC处理需要时间发送后稍微等一下再读我一般用50到100毫秒。TCP方式稍微好点但也别太急。4.2 读取的数据不对通信通了但读出来的数值和PLC监控软件里看到的不一致这种问题也很常见。原因多半是地址格式或者数据格式问题。地址方面比如PLC程序里用的是DT100有人直接写成DT100漏了补零结果读到的是别的地址。比较稳妥的做法是在代码里做一个统一的地址格式化函数确保地址永远符合“区域类型5位数字”的标准格式。数据格式方面松下PLC的寄存器可能是十进制、十六进制或者BCD码存储。如果PLC程序里用的是BCD码那C#这边用Convert.ToInt32(hex, 16)去解数值就会对不上。遇到这种情况需要把读取到的十六进制字符串按BCD规则转换成十进制public static int BcdToInt(string bcdHex) { int result 0; foreach (char c in bcdHex) { result result * 10 (c - 0); } return result; }4.3 界面卡顿与数据采集不同步网络热词里有“c# 循环数据采集和ui刷新卡顿”这几乎是每个上位机开发者都踩过的坑。最原始的做法是在Timer的Tick事件里直接发命令、收数据、刷新界面。通信一慢界面就卡死通信报错界面直接“未响应”。这个问题的根源是UI线程被通信阻塞了。解决思路很简单通信放到后台线程UI只管接收结果并刷新。用async/await就能优雅处理private async void Timer_Tick(object sender, EventArgs e) { // 防止上一次还没完成又发起新的请求 if (_isReading) return; _isReading true; try { string response await Task.Run(() plc.ReadSingleWord(DT, 100)); int value PanasonicPlcTcp.ParseResponse(response); txtValue.Text value.ToString(); } catch (Exception ex) { // 记录日志不弹窗打断操作 } finally { _isReading false; } }关键点有两个一个是用_isReading标志位防止重入另一个是不要直接在UI线程里做耗时操作。界面刷新频率也不要太快工业场景一般200毫秒到500毫秒刷一次就够用了刷太快反而容易造成通信拥堵。4.4 多台PLC和扫码枪场景项目大了之后可能一台工控机要同时连接多台PLC、扫码枪、传感器。这个时候如果每台设备都开一个线程阻塞读取资源消耗很大而且后续扩展很痛苦。我常用的做法是写一个设备管理类把每台PLC的通信对象、状态、数据缓存都封装起来再统一由一个调度器定时轮询。扫码枪这种主动上传数据的外设则用事件驱动串口收到完整数据帧就触发一个事件上位机在事件处理里更新数据。代码结构大致这样public class DeviceManager { private Dictionarystring, PanasonicPlcTcp _plcList new Dictionarystring, PanasonicPlcTcp(); public void AddPlc(string name, string ip, int port 1024) { var plc new PanasonicPlcTcp(ip, port); plc.Connect(); _plcList.Add(name, plc); } public PanasonicPlcTcp GetPlc(string name) { return _plcList.TryGetValue(name, out var plc) ? plc : null; } }扫码枪那边也一样把串口接收逻辑做成一个独立服务解析出条码后触发BarcodeScanned事件主界面订阅事件更新显示。这样各设备各司其职代码不会乱成一锅粥。5. 工程化经验从能跑到稳定跑5.1 日志和异常处理上位机软件最怕的就是现场跑着跑着突然崩了还没留下任何线索。所以日志系统一定要从一开始就架好。也不用搞太复杂一个文本日志就够了但要把每次收发报文、错误信息、时间戳都记下来。出了问题翻日志是最快的定位手段。我习惯把所有通信相关的操作都包在try-catch里但不在捕获异常时弹窗而是写日志并标记通信状态。界面上的状态栏显示一个指示灯通信正常是绿色断开是红色这样操作工能直观看到设备状态。5.2 通信状态的自动恢复现场环境复杂偶尔会出现通信卡死、PLC重启、网线被误拔等情况。软件要有自恢复能力。我的做法是在轮询任务里连续几次通信失败后自动重新连接连续成功后再恢复正常轮询。这个逻辑虽然简单但能大幅提升系统的稳定性。private int _failCount 0; private const int MaxFailCount 3; public bool SafeSendCommand(string command, out string response) { try { response SendCommand(command); _failCount 0; return true; } catch (Exception ex) { _failCount; if (_failCount MaxFailCount) { Reconnect(); _failCount 0; } response null; return false; } }5.3 通信频率的合理规划关于通信频率我个人的经验是不是越快越好。PLC扫描周期一般都在几毫秒到几十毫秒上位机的轮询频率太高一方面增加PLC负担另一方面也可能影响其他设备的通信。常规的HMI数据刷新100毫秒一次已经算很快了普通数据显示500毫秒足够。如果是数据采集要做曲线分析那也是把数据存在PLC里由上位机按需批量读取而不是靠高频轮询去“抓”瞬时值。6. 写在最后的实操体会做了几年上位机开发最大的感受是通信本身不难难的是让它稳定运行。协议格式、CRC算法、串口参数这些都是固定的查一下文档就能搞定。真正考验人的是各种边界情况PLC掉电了怎么处理通讯模块被配置错了怎么发现长时间运行后内存泄漏怎么排查我一般会在项目一开始就把通信层独立封装成一个类库不掺入任何业务逻辑。这样既方便调试也方便以后复用。换一个PLC型号、换一种通信方式只需要改协议层的实现界面和业务代码都不用动。代码写完后用模拟器或者一台备用的PLC做长时间的稳定性测试连续跑个48小时看有没有通信丢失、内存增长。另外一个小技巧所有固定报文格式、寄存器地址定义都尽量集中放在一个常量类或配置文件中别散落在各处。后期要改一个地址搜半天代码是常有的事提前做好规划能省下大量维护时间。如果这篇文章能让你在C#和松下PLC通信这条路上少走一点弯路那我的时间就没白花。有问题的话多翻翻松下PLC的通信手册那才是最权威的参考资料。本文还有配套的精品资源点击获取