C#实现三菱MC通讯协议库(4C帧-格式1)
运行环境:VS2022 .net Standard2.0
通讯库项目地址(Gitee):通讯库项目Gitee 仓库
Melsec通讯手册链接(蓝奏云):三菱Q系列与L系列MELSEC通讯协议手册
C24模块用户手册链接(蓝奏云):三菱Q系列串行通信模块用户手册(基础篇)
QnA兼容4C帧格式1报文分析:QnA兼容4C帧格式1报文分析
通讯工具(蓝奏云):Commix 1.4
概要:根据三菱的 Melsec 通讯协议(本文称MC协议)手册内容,使用串口实现了 PC 与 PLC 的通讯,能够通过QnA兼容4C帧的格式1实现 PC 读写 PLC 的软元件存储器内容(异步方法),最后用一个 C#控制台项目测试了通讯库功能
背景介绍
MC协议是三菱 PLC 与主机通讯的一种公开协议,PC 可通过三菱C24或者E71模块读取 PLC 的运行状态和I/O点位
以下是MC协议的两种模块和适用的通信帧和通信格式代码表格
| 对象模块 | 可使用的通信帧 | 通信数据代码 | |
| C24 | QnA兼容3C帧 | 格式1~4 | ASCII代码 |
| QnA兼容4C帧 | 格式5 | 二进制代码 | |
| QnA兼容2C帧 | 格式1~4 | ASCII代码 | |
| A兼容1C帧 | ASCII代码 | ||
| E71 | 4E帧 | ASCII代码或二进制代码 | |
| QnA兼容3E帧 | ASCII代码或二进制代码 | ||
| A兼容1E帧 | ASCII代码或二进制代码 | ||
通过MC协议进行的数据通信是以半双工通信进行,在对PLC发送指令报文后会接收到来自PLC的响应报文,接收完全后才能再次发送下一个指令报文
在没接收完全响应报文就发送下一个指令报文会发生错误!
示意图如下所示

本文主要介绍QnA兼容4C帧的格式1,需要使用RS232线连接PC主机与PLC,连接示意图与RS232线序图如下所示


QnA兼容4C帧(格式1)报文分析
QnA兼容4C帧的格式1通过ASCII代码进行通信,通信报文如下表
以QnA兼容4C帧(格式1)读写M0~M4、D0~D1的两个例子,通过表格说明
报文表格文件:QnA兼容4C帧格式1报文分析
读写M0~M4报文例子
- 读取M0~M4

- 写入M0~M4

读写D0~D1报文例子
- 读取D0~D1

- 写入D0~D1

QnA兼容4C帧的通用数据内容说明
此部分在官方的协议手册有详细说明,相关内容通过下列图片表示
控制代码

数据字节数(格式5用)

帧识别编号

站号

网络编号与可编程控制器编号

请求目标模块I/O编号

请求目标模块站号


本站编号

和校验代码
在报文中参与和校验的部分在各个格式中不同,需要自行查阅协议手册

出错代码
C24模块与E71模块的错误代码可能不相同,需要自行查阅协议手册
C24模块用户手册:三菱Q系列串行通信模块用户手册(基础篇)

软元件的批量读写指令
指令的部分内容说明

位单元的读写指令


字单元的读写指令


MC协议的功能很强大,本文的内容只是分享了其中的一小部分,如果大家有需要的话,可以通过它的通讯手册更深入的了解MC协议
MC协议手册:三菱Q系列与L系列MELSEC通讯协议手册
MC通讯库的C#实现
和校验实现
根据上文提供的和校验代码规则制作了一个和校验的方法,以下代码可以用于手动调试和校验代码的内容
Console.WriteLine("Start Test!!");//测试用,Frame1的和校验代码应为"0x31,0x43";或者十进制的"49,67"
List<byte> Frame1 = new List<byte> { 0x46, 0x39, 0x30, 0x30, 0x30, 0x30, 0x46, 0x46, 0x30, 0x30, 0x30, 0x34, 0x30, 0x31, 0x30, 0x30, 0x30, 0x31, 0x58, 0x2A, 0x30, 0x30, 0x30, 0x30, 0x34, 0x30, 0x30, 0x30, 0x30, 0x35 };List<byte> Frame2 = new List<byte> { 0x46, 0x38, 0x30, 0x30, 0x30, 0x30, 0x46, 0x46, 0x30, 0x33, 0x46, 0x46, 0x30, 0x30, 0x30, 0x30, 0x30, 0x34, 0x30, 0x31, 0x30, 0x30, 0x30, 0x31, 0x4d, 0x2a, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x35 };List<byte> result = CheckSum(Frame2);System.Console.WriteLine($"{result[0]},{result[1]}");System.Console.WriteLine("Over!!");public static List<byte> CheckSum(List<byte> frame)
{try{List<byte> checkResult = new List<byte>();//取和int sum = 0;foreach (byte b in frame){sum += b;}//截取最后后两位byte lowByte = (byte)(sum & 0xFF);//转为十六进制字符串string hexString = lowByte.ToString("X2");if (hexString.Length >= 2){char num1 = hexString[hexString.Length - 2];char num2 = hexString[hexString.Length - 1];//按照高位在前顺序添加checkResult.Add((byte)num1);checkResult.Add((byte)num2);}else{checkResult.Add(0x30);checkResult.Add((byte)hexString[0]);}return checkResult;}catch (Exception){throw;}
}
串口通讯实现
通讯库使用了Serial Port进行串口通讯,通过使用SemaphoreSlim(信号量限制)、DataReceived(串口接收数据方法)和TaskCompletionSource(异步任务传输串口数据)等内容实现串口通讯
以下是部分代码
//构造函数
public Melsec4CClient(string portname, int baudrate, System.IO.Ports.Parity parity, int databits, System.IO.Ports.StopBits stopbits){this.PortName = portname;this.BaudRate = baudrate;this.Parity = parity;this.DataBits = databits;this.StopBits = stopbits;}
//串口接收数据方法
private void Melsec_4C_ReadIO_DataReceived(object sender, SerialDataReceivedEventArgs e){try{//获取并添加到缓冲区int bytesToRead = serialPort.BytesToRead;byte[] buffer = new byte[bytesToRead];serialPort.Read(buffer, 0, bytesToRead);receiveBuffer.AddRange(buffer);//处理数据//查找完整的报文while (true){if (format == Melsec_4C_FormatEnum.Format1){int startIndex = receiveBuffer.IndexOf((byte)0x02);//STXint errIndex = receiveBuffer.IndexOf((byte)0x15);//NAKif (startIndex != -1 && errIndex == -1)//只找到STX,正常结束{if (receiveBuffer.Count > 17 + startIndex)//接收保文到Data部分{int seqStartIndex = FindSeq(receiveBuffer, new byte[] { 0x02, 0x46, 0x38 });if (seqStartIndex == -1){//继续接收数据break;}if (seqStartIndex != startIndex){startIndex = seqStartIndex;}int endIndex = receiveBuffer.IndexOf((byte)0x03, startIndex + 17);//ETXif (endIndex == -1){//继续接收数据break;}//找到和校验位置if (isCheckSum)//有和校验{if (receiveBuffer.Count >= (endIndex + 3)){int frameEnd = endIndex + 3;//提取完整报文(从STX到和校验代码)List<byte> completeFrame = receiveBuffer.GetRange(startIndex, frameEnd - startIndex);//读取到的和校验数值List<byte> receivedCheckSum = new List<byte>() { completeFrame[completeFrame.Count - 2], completeFrame[completeFrame.Count - 1] };//用于计算和校验的数据List<byte> dataForCheckSum = new List<byte>();for (int i = 1; i < completeFrame.Count - 2; i++)//待测试{dataForCheckSum.Add(completeFrame[i]);}List<byte> calculatedCheckSum = Melsec_4C_Check.CheckSum(format, dataForCheckSum);if (!calculatedCheckSum.SequenceEqual(receivedCheckSum)){throw new InvalidOperationException("读取的和校验数值与计算的不符");}//完成结果receiveTcs.TrySetResult(completeFrame);//清除缓冲区中已处理的报文receiveBuffer.RemoveRange(0, frameEnd);}else{//继续接收数据break;}}else//无和校验{List<byte> completeFrame = receiveBuffer.GetRange(startIndex, endIndex - startIndex);//待测试,是否要+1?//完成结果receiveTcs.TrySetResult(completeFrame);//清除缓冲区中已处理的报文receiveBuffer.RemoveRange(0, endIndex);}}else{//继续接收数据break;}}else if (startIndex == -1 && errIndex != -1)//只找到NAK,异常结束{//到达固定字数if (receiveBuffer.Count >= 21 + errIndex){int seqErrIndex = FindSeq(receiveBuffer, new byte[] { 0x15, 0x46, 0x38 });if (seqErrIndex == -1){//继续接收数据break;}if (seqErrIndex != errIndex){errIndex = seqErrIndex;}int frameEnd = errIndex + 21;List<byte> completeFrame = receiveBuffer.GetRange(errIndex, frameEnd - errIndex);//待测试,是否要+1?//完成结果receiveTcs.TrySetResult(completeFrame);//清除缓冲区中已处理的报文receiveBuffer.RemoveRange(0, frameEnd);}else{//继续接收数据break;}}else //两个开头都没找到{//继续接收数据break;}}else{//format数值错误,抛出异常throw new ArgumentOutOfRangeException("format error!");}}}catch (Exception ex){if (receiveTcs.Task.IsCompleted == false){receiveTcs.TrySetException(ex);}}}
//读取位单位的异步方法
public async Task<List<bool>> ReadIOBitAsync(Melsec_4C_IOAreaEnum IOArea, uint IOAdr, uint ReadCount){try{Melsec_4C_FrameConfig config = new Melsec_4C_FrameConfig();if (format == Melsec_4C_FormatEnum.Format1){config.IDCode = Melsec_4C_ControlCode.IDCode_ASCII_4C;//F8config.SNCode = new List<byte> { 0x30, 0x30 };//00config.NetCode = new List<byte> { 0x30, 0x30 };//00config.CPUCode = new List<byte> { 0x46, 0x46 };//FFconfig.TargetModuleIOCode = new List<byte> { 0x30, 0x33, 0x46, 0x46 };//03FFconfig.TargetModuleSNCode = new List<byte> { 0x30, 0x30 };//00config.ThisSNCode = new List<byte> { 0x30, 0x30 };//00config.Command = new List<byte> { 0x30, 0x34, 0x30, 0x31 };//0401config.SonCommand = new List<byte> { 0x30, 0x30, 0x30, 0x31 };//0001List<byte> datas = new List<byte>();//选择IO区域代码switch (IOArea){case Melsec_4C_IOAreaEnum.IO_X:datas.AddRange(Melsec_4C_IOAreaCode.IOArea_ASCII.IOArea_X);break;case Melsec_4C_IOAreaEnum.IO_Y:datas.AddRange(Melsec_4C_IOAreaCode.IOArea_ASCII.IOArea_Y);break;case Melsec_4C_IOAreaEnum.IO_M:datas.AddRange(Melsec_4C_IOAreaCode.IOArea_ASCII.IOArea_M);break;case Melsec_4C_IOAreaEnum.IO_L:datas.AddRange(Melsec_4C_IOAreaCode.IOArea_ASCII.IOArea_L);break;case Melsec_4C_IOAreaEnum.IO_F:datas.AddRange(Melsec_4C_IOAreaCode.IOArea_ASCII.IOArea_F);break;case Melsec_4C_IOAreaEnum.IO_V:datas.AddRange(Melsec_4C_IOAreaCode.IOArea_ASCII.IOArea_V);break;case Melsec_4C_IOAreaEnum.IO_B:datas.AddRange(Melsec_4C_IOAreaCode.IOArea_ASCII.IOArea_B);break;case Melsec_4C_IOAreaEnum.IO_TC:datas.AddRange(Melsec_4C_IOAreaCode.IOArea_ASCII.IOArea_T);break;case Melsec_4C_IOAreaEnum.IO_CC:datas.AddRange(Melsec_4C_IOAreaCode.IOArea_ASCII.IOArea_C);break;case Melsec_4C_IOAreaEnum.IO_S:datas.AddRange(Melsec_4C_IOAreaCode.IOArea_ASCII.IOArea_S);break;default:throw new ArgumentOutOfRangeException("IO区域选择出错");}datas.AddRange(MelsecConverter.Uint_D6String_ByteList(IOAdr));datas.AddRange(MelsecConverter.Uint_D4String_ByteList(ReadCount));config.Datas = datas;var result = await ReadIOAreaAsync(config);//result解析if (result.IsSuccessed){List<bool> listResult = MelsecConverter.ByteList_ASCII_BoolList(result.Datas);return listResult;}else{throw new Exception(result.ExMessage);}}else{throw new ArgumentOutOfRangeException("format选择出错");}}catch (Exception){throw;}}
详细代码可参考:通讯库项目Gitee 仓库
控制台试验
使用控制台进行通讯库试验,以下是试验使用的代码和试验结果图
using Mitsubishi.MelsecLib;
using Mitsubishi.MelsecLib.Melsec4CBase;
using System.IO.Ports;namespace MelsecTest
{internal class Program{private static Melsec4CClient? plc;private static Melsec_4C_FormatEnum format;static async Task Main(string[] args){plc = new Melsec4CClient("COM6",9600, Parity.Even,7,StopBits.Two);var isConnect = await plc.ConnectAsync(Melsec_4C_FormatEnum.Format1,true,false);if (isConnect){Console.WriteLine("读取X0~X6");var result1 = await plc.ReadIOBitAsync(Melsec_4C_IOAreaEnum.IO_X, 0, 6);foreach (var b in result1){Console.WriteLine(b.ToString());}await Task.Delay(1000);Console.WriteLine("读取M300~M306");var result2 = await plc.ReadIOBitAsync(Melsec_4C_IOAreaEnum.IO_M, 300, 6);foreach (var b in result2){Console.WriteLine(b.ToString());}await Task.Delay(1000);Console.WriteLine("读取D3000~D3006");var result3 = await plc.ReadIOWordAsync(Melsec_4C_IOAreaEnum.IO_D, 3000, 6);foreach (var b in result3){Console.WriteLine(b.ToString());}await Task.Delay(1000);Console.WriteLine("写入M300~M306");var result4 = await plc.WriteIOBitAsync(Melsec_4C_IOAreaEnum.IO_M, 300, new List<bool> { true, false, true, true, false, true });if (result4){Console.WriteLine("写入M300~M306:OK");}else{Console.WriteLine("写入M300~M306:NG");}await Task.Delay(1000);Console.WriteLine("读取M300~M306");var result5 = await plc.ReadIOBitAsync(Melsec_4C_IOAreaEnum.IO_M, 300, 6);foreach (var b in result5){Console.WriteLine(b.ToString());}await Task.Delay(1000);Console.WriteLine("写入D3000~D3006");var result6 = await plc.WriteIOWordAsync(Melsec_4C_IOAreaEnum.IO_D, 3000, new List<short> { 11,22,33,44,55,66 });if (result6){Console.WriteLine("写入D3000~D3006:OK");}else{Console.WriteLine("写入D3000~D3006:NG");}await Task.Delay(1000);Console.WriteLine("读取D3000~D3006");var result7 = await plc.ReadIOWordAsync(Melsec_4C_IOAreaEnum.IO_D, 3000, 6);foreach (var b in result7){Console.WriteLine(b.ToString());}await Task.Delay(1000);Console.WriteLine("恢复M300~M306");await plc.WriteIOBitAsync(Melsec_4C_IOAreaEnum.IO_M, 300, new List<bool> { false, false, false, false, false, false });await Task.Delay(1000);Console.WriteLine("恢复D3000~D3006");await plc.WriteIOWordAsync(Melsec_4C_IOAreaEnum.IO_D, 3000, new List<short> { 0, 0, 0, 0, 0, 0 });await Task.Delay(1000);}else{Console.WriteLine("连接错误");}await plc.DisconnectAsync();Console.ReadKey();}}
}
详细代码可参考:通讯库项目Gitee 仓库
试验结果图如下图

后续
项目还有很多值得改进的地方,例如使用ConcurrentQueue多线程队列来实现串口通讯的队列;开发4C帧的其他格式和E71模块的通讯代码等,这些后续进行改进了都会在仓库上进行更新。😃