简介:面向三菱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 -> D0100,D1023 -> D03FF。
- M继电器(内部继电器):十进制编号转六位十六进制(前补0),地址前加M。比如M0 -> M000000,M500 -> M0001F4。
- X输入(八进制编号):编号本身是八进制,转成十六进制后地址前加X,但要注意X地址是八进制数,不是十进制。比如X7 -> X7,X10(八进制)-> 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设成None,PLC会直接不响应,而且串口助手看数据也看不出个所以然来——你以为发了很多数据,对方一个字都没回。这类问题排查起来最耗时间,所以第一步一定要确认串口参数。
另外,如果用的是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表示ON,0x31表示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)。
批量读返回的数据是按位紧凑排列的,每位一个bit,1表示ON,0表示OFF。比如读M0到M7这8个点,返回一个字节,bit0对应M0,bit1对应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返回的字节顺序是从低位地址开始还是从高位地址开始,不同型号有差异。FX系列实测是从低位地址开始,而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"或"1"(ASCII字符)。写Word时,数据段为四位十六进制字符串,比如"1234"。写Dword时,连续写两个D寄存器,一次命令写2个字,数据段是8个十六进制字符,如"12345678"。
写操作的响应帧很简单,正确写入时PLC返回STX + 0x30 + ETX + 校验和,也就是ACK帧;写入失败时返回NAK(0x15)。我在实际开发中特别注意一个细节:写操作之间最好间隔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 Task<bool> 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全ON,M8-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协议,一套代码同时兼容三菱和西门子;或者增加日志记录功能,把每次收发帧的原始数据写到文件,方便排查现场问题;又或者用多线程优化,让数据采集和心跳监测并行,互不干扰。
最后分享一个小技巧:调试串口通信时,可以在代码里加一个日志开关,把所有发送和接收的帧按十六进制打印到调试窗口。别看它简单,排查问题的效率能提高一倍以上。很多看起来玄乎的坑,其实只要把原始数据拉出来一看,问题就清清楚楚了。
本文还有配套的精品资源,点击获取