拓冰建站拓冰建站
首页 / 资讯中心 / 正文

C#实现三菱PLC串口通信:从协议解析到心跳监测

简介面向三菱PLC串口通信场景C#源码包提供了完整的读写与心跳监控方案适合工业自动化上位机开发者参考或二次开发。压缩包内含Visual Studio解决方案压缩后约184KB共49个文件以cs源码为主辅以dll运行库、exe可执行程序、app.config配置文件及pdb调试符号可快速定位核心逻辑与运行环境。工程含DebugClass与MitsubshiPLCSerialPort两个模块前者提供演示界面后者封装串口通信类已有2153人学习下载覆盖单个bool、批量bool、Word、Dword等数据类型的串口收发并在frmMain与MitsubshiPLC、SerialPortClass等类中实现了连接状态监控、心跳信号与多线程读写处理便于理解工业通信中的参数配置、数据转换、线程协作以及异常重连等实用技巧。开发者可直接打开工程对照学习也能提取其中串口通信模块降低与三菱PLC联调的上手成本。1. 为什么用串口读写三菱PLC而不是网口工控圈里做上位机最常见的需求之一就是把三菱PLC的数据读上来、写下去。很多人第一反应是走以太网用MC协议或者TCP/IP直连毕竟现在主流FX5U、Q系列基本都带网口。但实际情况是车间里仍有大量FX3U、FX2N甚至更老的FX1N在跑这些PLC要么没有网口要么网口模块贵得离谱一条FX3U-ENET-ADP的价格够吃好几顿火锅。相比之下PLC自带的编程口MD8F圆形8针接口只靠一根串口线就能通信成本几乎为零稳定性和实时性对大多数场景也完全够用。这个项目解决的正是这个痛点用C#写一个上位机走串口RS232/RS422读写三菱PLC的软元件支持单个bool、批量bool、Word、Dword再加一个心跳信号防止通信静默。整套逻辑下来不管是做一个简单的人机界面还是给设备加数据采集都非常实用。适合谁看刚接触工控上位机开发的C#程序员或者设备维护工程师想自己写个小工具、不想花钱买组态软件的都可以直接参考这套方案。我下面把通信协议、C#实现、踩坑经验全部摊开讲尽量让没有接触过三菱协议的人也能照着写出来。2. 核心原理三菱编程口协议到底在传什么2.1 协议帧结构拆解三菱PLC的编程口协议是一种基于ASCII码的问答式协议也就是说上位机发一条命令帧PLC返回一条响应帧一来一回不存在PLC主动上报这种事除非你额外做特殊处理。搞清楚帧格式整个串口通信就等于成功了一半。命令帧的结构可以分成五个部分帧头、命令、软元件首地址、软元件点数/写入数据、帧尾校验。以读软元件命令为例典型的一条指令长这样STX CMD 地址 点数 ETX 校验和其中STX是帧头ASCII码0x02表示一帧开始CMD是命令码读操作用0x30字符0写操作用0x31字符1。地址部分要特别注意不是直接写十进制的D100而是要换算成三菱专用的十六进制软元件地址格式每个十六进制位都要转成ASCII字符发送。举个例子读取D100这一个字实际发送的帧是这样的STX 0 0 D 0 1 0 0 0 1 ETX 校验码这里D0100是D100的十六进制地址表示前面的0是命令码0表示读最后跟的0001表示读取1个点。ETX是帧尾0x03校验码是把从CMD到ETX之前的所有字符ASCII码求和取低两位十六进制再转成ASCII发送。我记得第一次接触这个协议时被地址换算搞得头大D100为什么地址是D0100三菱的软元件在协议里有自己的映射规则D寄存器直接补零到四位十六进制就行但M继电器、X输入、Y输出的换算方式又不一样。具体规则我放在下一节细讲。2.2 软元件地址映射规则这是整个项目里最容易出错的地方也是面试时经常被问的细节。三菱PLC的编程口协议里不同类型的软元件有独立的地址区间换算规则如下D寄存器数据寄存器十进制编号转四位十六进制地址前加字母D。比如D100 - D0100D1023 - D03FF。M继电器内部继电器十进制编号转六位十六进制前补0地址前加M。比如M0 - M000000M500 - M0001F4。X输入八进制编号编号本身是八进制转成十六进制后地址前加X但要注意X地址是八进制数不是十进制。比如X7 - X7X10八进制- X8。这一块特别容易算错很多人直接用十进制转换结果读出来的数据永远是错的。Y输出八进制编号规则同上地址前加Y。批量读取bool时三菱协议的最小读取单位是位一个地址对应一个位。批量读M0到M31点数值写32返回的数据会按位排列成4个字节32位。这里有个大坑返回的字节序和位序处理不好读出来的bool数组全是乱的。2.3 为什么选择串口协议而不是其他方案有人可能会问三菱PLC不是支持Modbus协议吗为什么不用Modbus RTU确实很多三菱PLC通过扩展板或内置支持Modbus用Modbus串口通信在通用性上更强上位机代码写起来也更标准。但问题是Modbus需要PLC侧做从站配置而且对FX3U这类老型号某些功能码支持不完整而编程口协议走的是PLC的编程口不需要任何额外配置插上就能通信对现场维护人员来说几乎零门槛。代价是协议相对封闭文档不公开只能靠经验积累或逆向分析。不过现在网上能找到的资料不少结合我自己踩过的坑把整个协议弄清楚并不难。再加上C#的SerialPort类很好用开发周期短改动灵活所以我最终选了这条路。3. C#串口通信实现从打开串口到数据解析3.1 串口参数配置的细节三菱PLC编程口的串口参数基本是固定的波特率9600、数据位7位、偶校验Even、停止位1位。这是FX系列PLC的默认编程口参数如果你的PLC被别人改过波特率先用GX Works连一次把参数改回来否则串口通信会直接失败。用C#的SerialPort类配置如下SerialPort plcPort new SerialPort(); plcPort.PortName COM3; plcPort.BaudRate 9600; plcPort.DataBits 7; plcPort.Parity Parity.Even; plcPort.StopBits StopBits.One; plcPort.ReadTimeout 1000; plcPort.WriteTimeout 1000; plcPort.Open();这里特别强调一下三菱编程口协议是7位数据位偶校验不是常见的8位无校验。如果把DataBits设成8、Parity设成NonePLC会直接不响应而且串口助手看数据也看不出个所以然来——你以为发了很多数据对方一个字都没回。这类问题排查起来最耗时间所以第一步一定要确认串口参数。另外如果用的是USB转串口线建议装官方的CH340或FTDI驱动避免用Windows自带的通用串口驱动容易出现打开串口后收发超时的问题。我实际测试下来FTDI芯片的USB转串口线稳定性最好长时间跑心跳不会掉线CH340的也还行但要注意线材质量。3.2 命令帧构建的核心方法这一步是整个通信逻辑的心脏。我写了一个BuildReadFrame方法根据软元件类型和地址动态生成命令帧这样上层业务只需要传设备类型地址长度底层自动拼帧。private string BuildReadCommand(string device, string address, int count) { string cmd 0; // 0读 string addrHex GetDeviceAddress(device, address); string countHex count.ToString(X4); string temp cmd addrHex countHex; int sum 0; foreach (char c in temp) { sum (byte)c; } string checksum (sum 0xFF).ToString(X2); return \u0002 temp \u0003 checksum; }GetDeviceAddress方法用来做地址换算比如D寄存器就是十进制转十六进制补零M继电器则是转六位十六进制。这里有个坑C#的ToString(X4)得到的十六进制字符串是字母大写三菱协议要求ASCII字符值参与校验和计算字母大小写不影响校验结果但发送内容要保持一致否则PLC解析会出问题。还有一点要注意字符串拼接时每个字符都必须是ASCII可打印字符除了STX、ETX控制字符所以地址和点数都要先转成十六进制字符串再拼接不能让PLC去接收原始的二进制数。这跟Modbus RTU完全是两个路子别搞混了。3.3 发送与接收的同步机制串口通信里最容易出现的问题就是收发不同步。我用了一个简单的同步机制发送命令帧后等待PLC返回完整响应再解析。由于串口是流式的PLC返回的数据可能分多次到达所以接收端必须做缓冲和帧尾判断。我推荐用一个队列加状态机来处理private byte[] buffer new byte[4096]; private int bufferLen 0; private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e) { int bytesToRead plcPort.BytesToRead; plcPort.Read(buffer, bufferLen, bytesToRead); bufferLen bytesToRead; // 查找帧头0x02和帧尾0x03提取完整帧 int startIndex -1, endIndex -1; for (int i 0; i bufferLen; i) { if (buffer[i] 0x02) startIndex i; if (buffer[i] 0x03) { endIndex i; break; } } if (startIndex 0 endIndex startIndex) { // 提取完整帧并处理 byte[] frame new byte[endIndex - startIndex 1]; Array.Copy(buffer, startIndex, frame, 0, frame.Length); ProcessFrame(frame); // 移动缓冲区剩余数据 bufferLen - endIndex 1; Array.Copy(buffer, endIndex 1, buffer, 0, bufferLen); } }这个状态机只处理了最简单的情况找帧头帧尾并切出完整帧。实际使用中还要考虑PLC可能返回异常帧比如帧头是0x15表示NAK这时候直接把错误帧交给上层解析会失败最好是先判断第一个字节是0x02还是0x15如果是0x15说明刚才的命令PLC没接收到或校验失败得重发。4. 多种数据类型的读取与解析4.1 单个bool读取读取单个M继电器或X/Y点的bool值返回的响应帧体部分是一个字节值为0x30表示ON0x31表示OFF。你没看错返回的是ASCII字符0和1不是二进制0/1。解析代码很简单public bool ReadBool(string device, string address) { string cmd BuildReadCommand(device, address, 1); plcPort.Write(cmd); Thread.Sleep(50); byte[] response ReadFullResponse(); if (response.Length 11) return false; // 响应格式: STX 0 地址(6字节) 数据(1字节) ETX 校验(2字节) return response[9] (byte)1; }但是注意M继电器的地址是六位十六进制X/Y输入输出是八进制转十六进制所以解析时的索引位置要根据实际帧长度计算别死记硬背索引。最稳妥的办法是把整个响应帧转成ASCII字符串然后按位置截取数据段。4.2 批量bool读取批量读取场景很常见我需要一次性获取30个或甚至256个M继电器的状态可以大大减少通信次数。三菱协议允许一次最多读256个位某些型号限制不同建议不要超过256。批量读返回的数据是按位紧凑排列的每位一个bit1表示ON0表示OFF。比如读M0到M7这8个点返回一个字节bit0对应M0bit1对应M1以此类推。如果读M0到M15返回两个字节先返回的是低位字节M0-M7再返回高位字节M8-M15。解析代码public bool[] ReadBools(string device, string startAddress, int count) { string cmd BuildReadCommand(device, startAddress, count); plcPort.Write(cmd); byte[] data ReadDataSection(cmd, count); bool[] result new bool[count]; for (int i 0; i count; i) { int byteIndex i / 8; int bitIndex i % 8; result[i] (data[byteIndex] (1 bitIndex)) ! 0; } return result; }这里最大的坑是点位分配PLC返回的字节顺序是从低位地址开始还是从高位地址开始不同型号有差异。F​X系列实测是从低位地址开始而Q系列我曾经踩过坑高位在前。如果你发现读出来的bool数组顺序和PLC端对不上优先检查这个方向。4.3 Word与Dword读取Word读取就是读D寄存器一个D寄存器是16位对应C#的ushort。响应帧返回的数据是ASCII码表示的十六进制字符串比如D100的值是0x1234返回的是ASCII字符1234不是二进制。解析方法public ushort ReadWord(string address) { string cmd BuildReadCommand(D, address, 1); plcPort.Write(cmd); string ascii ReadResponseString(); // 响应格式: STX 0 D0100 1234 ETX 校验 string hexStr ascii.Substring(8, 4); return Convert.ToUInt16(hexStr, 16); }Dword读取稍微复杂连续读两个D寄存器比如D100和D101组成一个32位整数。这里涉及高低字节序的问题三菱PLC默认高位字在后也就是说D100存的是低16位D101存的是高16位放在一起就是D100 D101 * 65536。public int ReadDword(string lowAddress) { ushort low ReadWord(lowAddress); ushort high ReadWord(GetNextAddress(lowAddress, 1)); return (high 16) | low; }不过也有人说自己项目里三菱PLC是高位在前实际还是要看PLC里怎么写的。比如用MOV指令把32位数据写入D100占用的就是D100和D101两个寄存器通常情况下D100是低字。但如果PLC程序里特意用了字节交换指令那就得按实际来。我的建议是写一个小测试程序在PLC里放一个已知值比如0x12345678然后上位机读出来看哪个解析正确一测便知。4.4 写操作bool、Word、Dword全搞定读只是第一步控制系统肯定还要写。写操作的命令帧和读类似但命令码变成了1且数据段要放在ETX之前长度是点数的两倍如果是写字或按规定格式。写单个bool的命令帧格式STX 1 M000000 1 数据 ETX 校验写一个bool值时数据段为0或1ASCII字符。写Word时数据段为四位十六进制字符串比如1234。写Dword时连续写两个D寄存器一次命令写2个字数据段是8个十六进制字符如12345678。写操作的响应帧很简单正确写入时PLC返回STX 0x30 ETX 校验和也就是ACK帧写入失败时返回NAK0x15。我在实际开发中特别注意一个细节写操作之间最好间隔100ms以上尤其是连续写多个点时写太频繁PLC会来不及处理导致写失败或数据错乱。5. 心跳信号的实现与通信状态监测5.1 为什么需要心跳串口通信和网口不同它没有物理层的连接检测机制。网线拔了TCP连接会断开你能立刻感知但串口线断了或者PLC断电了上位机这边什么感觉都没有只知道发了一条命令没人响应。如果设备运行中PLC突然重启或通信线松动上位机还傻乎乎地显示运行正常那就非常危险了。心跳信号就是为了解决这个问题上位机定时比如每500ms向PLC发送一条读取命令比如读一个固定地址的值只要收到正常响应就认为通信链路正常连续几次没收到响应就判定为通信故障上位机弹出报警或执行安全逻辑。5.2 心跳的实现方案最简单的实现是起一个定时器或者用异步循环周期性地发送一条读命令private async Task HeartbeatLoop(CancellationToken token) { while (!token.IsCancellationRequested) { bool ok await TryReadHeartbeatAsync(); if (ok) { missCount 0; CommunicationStatus 正常; } else { missCount; if (missCount 3) { CommunicationStatus 通信故障; // 触发报警逻辑 } } await Task.Delay(500, token); } } private async Taskbool TryReadHeartbeatAsync() { try { // 读一个固定地址的D寄存器比如D0 ushort value await ReadWordAsync(D0); lastHeartbeatTime DateTime.Now; return true; } catch (Exception) { return false; } }我这里特意用了异步方式避免阻塞UI线程。如果你在WinForms里用同步方式记得用Task.Run包一层或者用BackgroundWorker否则界面会卡死用户体验很差。心跳地址的选择有个小技巧不要读PLC程序里正在频繁写入的数据区比如设备的运行计数器或报警记录因为读操作本身可能会和PLC的写操作冲突概率不大但存在。最好固定一个很少用到的D地址比如D8000以后的系统区或者专门留一个D地址给上位机做握手用。5.3 心跳超时的判定策略心跳超时怎么判定一开始我用的是单次超时发一条命令1秒没响应就认为故障。后来发现这样不够准因为PLC偶尔会有忙不过来的时候特别是产线刚启动、PLC程序执行负载较高的瞬间响应慢个几百毫秒很正常直接把状态置为故障会引起误报警。我最终用的是连续3次失败才判定故障的策略。每次失败missCount加1成功就清零missCount达到3才置为通信故障。实测下来这个策略既不会漏报也不会误报。时间间隔可以根据你的项目需求调整普通数据采集500ms一次足够了如果是安全联锁类的监控建议设到200ms连续5次失败再报故障既保证实时性又避免抖动。6. 常见问题与排查技巧实录6.1 串口可以打开但PLC不响应这个问题排在工控串口问题第一名。排查思路很简单按顺序走一遍检查串口参数波特率、数据位、校验位、停止位是否完全匹配。三菱FX系列默认是9600-7-E-1有些老型号是9600-7-O-1奇校验这俩最容易搞混。检查线序FX系列的编程口是圆头8针不是DB9。如果你用的是USB转圆头编程线注意辨别是FX专用线还是欧姆龙等其它品牌的线线序定义完全不同接错会通信失败甚至烧坏电路。我见过最心疼的一次是同行把PLC编程口烧了就是线序接错导致的。用串口助手手动发一帧测试打开串口调试助手发送STX 0 D0100 0001 ETX校验看看PLC有没有返回。如果返回了说明硬件和协议没问题问题出在你的软件逻辑如果什么都没返回重点检查接线和串口参数。确认PLC工作模式有些PLC在RUN模式下编程口只开放只读或完全关闭需要在GX Works里把PLC的通信设置改成允许编程口通信。6.2 读回来的数据一直是0xFF或0x00这个大概率是帧格式不对PLC返回了NAK帧0x15你把它当正常数据处理了或者你解析的数据段索引位置不对把帧尾校验当成了数据。我自己的经验是先打印原始响应帧的十六进制字符串对照三菱协议的帧格式一字节一字节抠确认数据段到底在哪个位置。千万别一上来就怀疑是PLC寄存器里的值真的就是0先看CRC校验码对不对——三菱编程口的校验和是你自己计算的如果PLC端算出来的和你发的对不上它会回NAK数据根本不会是正确的。6.3 读取批量bool时顺序是乱的之前提到过这是位序和字节序的问题。我强烈建议在实际项目里第一步先做一个点位自检功能在PLC程序里给M0-M15赋一个确定的模式比如M0-M7全ONM8-M15全OFF然后上位机读回来用二进制显示跟预期比对一看就知道到底是高位在前还是低位在前还是位序反了。6.4 通信一段时间后上位机假死串口接收事件里如果做了大量耗时操作比如解析数据后直接更新UI控件WinForms的消息泵会被阻塞表现出来就是界面卡死。解决方法是把解析和UI更新分开接收事件里只做数据缓冲和帧提取解析完成的数据放到队列里UI线程用定时器取出来更新。还有一个隐藏比较深的坑SerialPort的DataReceived事件是在线程池线程里触发的如果在事件处理里访问了界面控件并且没有做Invoke程序会时不时抛异常异常多了内存溢出表现为假死。这个几乎是C#串口开发必踩的坑我的做法是事件处理里尽可能不碰UI全部通过事件或队列通知主线程。6.5 写入PLC的数据偶尔不生效写入失败常见原因有两个一是两次写操作间隔太短PLC来不及处理二是写地址和写入数据类型不匹配比如给D寄存器写了字符串格式的数据PLC解析出错但不报错只是数据没写进去。解决方法是写完一条命令后立刻读回验证public bool WriteAndVerifyWord(string address, ushort value) { bool ok WriteWord(address, value); if (!ok) return false; Thread.Sleep(50); ushort readBack ReadWord(address); return readBack value; }读回验证虽然多花几十毫秒但对可靠性要求高的场景非常值得。我在做设备参数下发的时候每条参数都要读回确认防止操作员设置了参数却下不去结果设备按旧参数运行出事故就麻烦大了。7. 我的实操体会与扩展建议这套C#串口读写三菱PLC的方案我在两个项目里真正跑过一个是包装设备的数据采集每隔200ms读20多个D寄存器还有一个是小型自动化产线的参数下发与监控涉及批量bool读取和心跳监测。两次做下来最大的体会是协议本身不复杂真正复杂的是各种边界情况——PLC响应慢、串口线接触不良、地址换算出错、字节序搞反这些才是耗时间的大头。所以我一直建议做这类项目时一定先写一个简单的通信测试工具把帧发送、响应解析、原始数据展示这几个基础功能做扎实再往上面加业务逻辑。别一上来就急着把界面做得很花哨基础不稳后面全是返工。这个方案后续还可以扩展的方向很多比如把串口通信层封装成独立的类库支持Modbus协议一套代码同时兼容三菱和西门子或者增加日志记录功能把每次收发帧的原始数据写到文件方便排查现场问题又或者用多线程优化让数据采集和心跳监测并行互不干扰。最后分享一个小技巧调试串口通信时可以在代码里加一个日志开关把所有发送和接收的帧按十六进制打印到调试窗口。别看它简单排查问题的效率能提高一倍以上。很多看起来玄乎的坑其实只要把原始数据拉出来一看问题就清清楚楚了。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门