简介:SanLingMC是一套基于C#开发的三菱PLC调试示例程序,专注于MC协议单地址读取与写入,适合需要入门PLC通信、理解上位机与三菱设备之间报文交互的开发者。程序以Visual Studio工程形式组织,包含窗体界面、资源文件、程序集信息与可执行程序,读者可直接打开工程逐行查看代码,掌握Socket连接建立、请求命令帧构建、响应数据解析及异常处理等关键环节。PLC调试过程中常见的寄存器地址映射、位元件与字元件读写规则,也能在这个最小可运行示例中对照验证。资源压缩包共40个文件,涵盖cs源码、resx资源、exe可执行文件、pdb调试符号、settings配置、licenses许可文件等类型,整体仅113KB,结构紧凑且易读。目前已有1112人学习下载,附带Release与Debug编译产物,可边运行边观察输出结果。通过这份示例,开发者既能看到C#网络编程在工业通信中的落地方式,又能以MC协议为基础,向批量读取、异步轮询、异常重试等真实项目能力延伸。
1. 用C#写三菱PLC调试程序,MC协议绕不开的3个事实
做C#上位机绕不开一个场景:对面设备是三菱PLC,FX5U、Q系列、L系列,网口都支持MC协议。很多工程师第一次写调试程序时,会被三菱MC协议的报文格式卡住,尤其是3E帧的二进制模式。网上能搜到SanLingMC这类C#项目,命名通常是“三菱+MC”,但代码质量参差,直接抄过来往往在地址转换、响应解析和超时处理上踩坑。这也解释了为什么搜三菱PLC编程教学的人很多,能一次跑通读写的人却不多。
三菱MC协议和Modbus TCP有个本质差别:Modbus有现成的NModbus4这类库,寄存器模型单一;MC协议则是三菱各系列PLC共用一套帧格式,命令码和软元件代码非常多。掌握3E帧的头部、命令、地址封装,调试程序就能脱离具体PLC型号通用。下文不依赖某一份现成源码,从MC协议帧结构开始,用C#把读写三菱PLC的调试程序完整实现。适合已经会用TcpClient但没碰过三菱协议的工程师,也适合想把手里的Modbus上位机迁移到MC协议的开发者。
2. MC协议3E帧结构拆解与C#报文封装实现
2.1 二进制帧与ASCII帧的选型,头部字段逐字节说明
三菱MC协议在以太网上最常见的形态是QnA兼容3E帧。3E帧有二进制和ASCII两种编码,上位机里优先选二进制,原因很直接:同样一帧读请求,二进制比ASCII少一半字节,解析响应时不用做十六进制字符到数值的转换,CPU开销小。FX5U、Q系列、L系列都支持,PLC侧在以太网参数里把帧类型选成“3E帧二进制”即可,默认端口按PLC型号不同,常见在2000到5000之间,具体在GX Works的模块参数里能看到。
3E帧二进制头部固定占用9个字节,后面跟着监视定时器和命令数据。头部有一个著名的字节序陷阱:子头D0 00和IO号03 FF在报文里保持原样,不做大小端交换;但请求数据长度、监视定时器、软元件地址、点数全是小端序,低字节在前。很多第一版调试程序连不上PLC,问题就出在把D0 00写成了00 D0。
| 字段 | 字节数 | 典型值 | 说明 |
|---|---|---|---|
| 子头 | 2 | D0 00 | 二进制3E帧固定,不交换字节序 |
| 网络号 | 1 | 00 | 与PLC网络号对应,单机写00 |
| PC号 | 1 | FF | 上位机编号,默认FF |
| IO号 | 2 | 03 FF | CPU模块IO编号,固定03FF |
| 站号 | 1 | 00 | 多CPU系统时区分 |
监视定时器字段的用途是防止连接长期不活动:单位是250ms,0x0010等于4秒。上位机请求发出后,PLC会在定时器之内返回,如果超过定时器没收到新请求,PLC侧会主动断开。调试程序里给0x0010比较稳妥,批量写文件或写大数组时不会因为指令间隔太长被断开,也避免了异常连接长时间占用PLC资源。
2.2 用C#构建批量读取请求帧
有了头部和字段分工,C#里构建批量读取D寄存器的报文就是纯字节操作。下面方法从D100开始连续读取16个字:
public static byte[] BuildReadD(int start, int count) { var buf = new List<byte>(21); buf.AddRange(new byte[] { 0xD0, 0x00, 0x00, 0xFF, 0x03, 0xFF, 0x00 }); int lenPos = 7; // 请求数据长度字段的起始下标 buf.AddRange(new byte[] { 0x00, 0x00 }); // 长度占位,稍后回填 buf.AddRange(new byte[] { 0x10, 0x00 }); // 监视定时器 4秒 buf.AddRange(new byte[] { 0x01, 0x04 }); // 0x0401 批量读取 buf.AddRange(new byte[] { 0x00, 0x00 }); // 子命令 0x0000 字单元 buf.Add((byte)(start & 0xFF)); // 起始地址低字节 buf.Add((byte)((start >> 8) & 0xFF)); // 起始地址高字节 buf.Add(0x00); // 位号,字单元固定0 buf.Add(0xA8); // 软元件代码:D buf.Add((byte)(count & 0xFF)); // 点数低字节 buf.Add((byte)((count >> 8) & 0xFF)); // 点数高字节 int len = buf.Count - lenPos - 2; // 长度=监视定时器到数据末尾 buf[lenPos] = (byte)(len & 0xFF); buf[lenPos + 1] = (byte)((len >> 8) & 0xFF); return buf.ToArray(); }逻辑说明:前7个字节是固定头部,第7、8字节是请求数据长度,长度值从监视定时器开始算,到命令数据结束为止,所以用总字节数减去头部7字节再减去长度字段2字节。命令0x0401是批量读取,子命令0x0000表示按字单元读取。三菱D寄存器编号在报文里按十六进制放,D100就是0x0064,小端序写入64 00。
参数说明:start是十进制软元件号,方法内部会自动截成低字节和高字节;count是读取点数,D区连续读256个字没问题,批量读取上限通常是960字,超过会返回结束码错误。软元件代码0xA8对应数据寄存器D,如果读取R文件寄存器,把0xA8换成0xAF,起始地址的位号字节保持0x00。
2.2.1 读取位软元件时的差异
读M、X、Y这类位软元件时,命令还是0x0401,但子命令要改成0x0001,起始地址第三个字节变成位号。M100的报文地址按64 00 00发送,前两字节是十六进制软元件号,第三字节是位号0;X和Y的软元件号是八进制,X7实际编号是7,X10实际编号是8,转换时先做八进制到十进制的换算,再按十六进制放入报文。
2.3 软元件代码与常用命令速查
写调试程序最常用的软元件可以收敛到下面这张表,表格里的代码是二进制帧里的单字节值:
| 软元件 | 代码 | 编号进制 | 典型用途 |
|---|---|---|---|
| D | A8 | 10进制 | 数据寄存器,整数、浮点、字符串都靠它 |
| R | AF | 10进制 | 文件寄存器 |
| ZR | B0 | 10进制 | 扩展文件寄存器,容量更大 |
| M | 90 | 10进制 | 内部继电器,跑流程状态 |
| X | 9C | 8进制 | 输入继电器,接传感器信号 |
| Y | 9D | 8进制 | 输出继电器,控制执行机构 |
| SM | 91 | 10进制 | 特殊继电器,PLC状态位 |
| SD | A9 | 10进制 | 特殊数据寄存器,系统诊断值 |
| W | B4 | 10进制 | 链接寄存器,CC-Link用 |
命令码方面,批量读写是调试程序的主干:读取命令0x0401,写入命令0x1401,随机读取0x0403。随机读取可以一次读多个不连续地址,适合把散落在不同段的温度、压力、设备状态拉回来,请求体里先放点数,再放一组“地址3字节+软元件代码1字节”的结构。批量写入则把命令0x1401和写入数据拼接在点数后面,每个字占2字节,小端序。
我平时做上位机时,喜欢用一个字典把软元件字符串(D、M、X)映射到代码字节,再用一个方法把“Y10”这种带编号的字符串拆成八进制编号和位号。这样调试界面上输入Y10就能直接读,不用每次查表,对刚接触三菱PLC编程教学的同事更友好。
3. SanLingMC式C#上位机:三菱PLC读写通道与参数配置
3.1 C#上位机的连接通道:TcpClient的封装与断线重连
项目叫SanLingMC还是plcmc并不重要,核心都是三件事:连接管理、帧收发、数据解析。连接管理用System.Net.Sockets.TcpClient就够,PLC侧没有Modbus TCP那种并发连接数限制,同一个端口可以挂多个TCP客户端,但调试程序应该只维护一个连接,轮询逻辑挂在同一个Socket上,避免多个连接并发读写同一个软元件区造成数据竞争。
public class McTcpClient { private TcpClient? _client; private NetworkStream? _stream; private readonly object _lock = new(); public async Task ConnectAsync(string ip, int port) { _client = new TcpClient(); _client.ReceiveTimeout = 3000; _client.SendTimeout = 3000; await _client.ConnectAsync(ip, port); _stream = _client.GetStream(); } public byte[] Transceive(byte[] req) { lock (_lock) { _stream!.Write(req, 0, req.Length); _stream.Flush(); var resp = ReadFrame(_stream); return resp; } } }连接方法里把接收和发送超时都设成3秒,这个值对本地局域网或单机调试足够,如果PLC在远端又经过工业交换机,可以放大到5秒。Transceive用lock串行化收发,轮询周期短的时候不会出现前一帧响应还没读完、后一帧请求已经发出去的情况。三菱PLC侧对同时到达的多帧请求处理能力有限,串行IO是最稳妥的写法。
Response帧长度:3E帧响应没有在TCP流里自带帧结束标志,要靠响应数据长度字段判断读多少字节。读头部9个字节后(字段布局和请求一致),取出第7、8字节的小端长度值,再继续读对应长度,拼成完整响应帧。这个拆解逻辑是调试程序里最容易出错的地方,不能只按固定字节数去读,因为错误码响应比正常响应短。
3.2 C#上位机批量读写三菱PLC的通用方法
把帧构建和连接管理合起来,封装通用读写方法。下面给出读取Word数组和写入Int16数组的完整样例:
public async Task<ushort[]> ReadWordsAsync(string device, int start, int count) { byte devCode = DeviceCodes[device]; // 从字典取软元件代码 byte[] req = BuildReadRequest(device, start, count, devCode); byte[] resp = Transceive(req); // 结束码:响应第9、10字节,大端序,0xC05D 这种格式 ushort end = (ushort)((resp[9] << 8) | resp[10]); if (end != 0) throw new McProtocolException($"读失败 结束码={end:X4}"); var result = new ushort[count]; for (int i = 0; i < count; i++) result[i] = BitConverter.ToUInt16(resp, 11 + i * 2); return result; }逻辑说明:BuildReadRequest根据软元件类型选择子命令是0x0000还是0x0001,DeviceCodes是软元件字符串到单字节代码的映射。响应里从第11字节开始是数据区,每个字占2字节,小端序。结束码不为0时直接抛异常,异常信息里带十六进制结束码,后面排查错误码时非常有用。
写入方法的帧结构只是把命令换成0x1401,并在末尾追加数据区。写32位整数时要注意:三菱的数据格式是低位字在前,比如要写int值123456到D100,需要先写D100=低16位,再写D101=高16位。C#里可以用BitConverter.GetBytes把int拆成4个字节,按低字、高字的顺序拼进数据区,读出来时反向组合。float类型也是同样的规矩,把4字节按低字在前放入两个D寄存器,解析时用BitConverter.Int32BitsToSingle恢复。
3.3 监视定时器、超时与重试的参数怎么调
调试程序跑不稳,九成是三个参数没调对:重试次数、超时时间和监视定时器。监视定时器在请求帧里,上位机设置0x0010(4秒)基本够用;超时时间放在TcpClient上,设置为监视定时器的1.5到2倍比较合理,万一PLC侧没来得及返回,上位机不会先放弃。轮询型上位机最常见的错误是让所有读请求共享一个超时时间,读写频繁时会误伤写操作,建议读、写各用一组超时参数。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| ReceiveTimeout | 3000 ms | 局域网调试,跨网段放大到5000 |
| SendTimeout | 3000 ms | 与接收一致,避免写卡住 |
| Retries | 3 | 线性退避,间隔200ms起 |
| 监视定时器 | 0x0010 | 4秒,写入大块数据时保持 |
public async Task<byte[]> TransceiveWithRetryAsync(byte[] req, int retries) { for (int i = 0; i < retries; i++) { try { return Transceive(req); } catch (IOException ex) { if (i == retries - 1) throw; await Task.Delay(200 * (i + 1)); await ReconnectAsync(); } } return null!; // 不会执行到这里 }重试间隔用200毫秒乘重试次数做线性退避,第一次等200ms,第二次等400ms。重连前必须关闭旧Socket,否则Windows上端口会进入TIME_WAIT状态,频繁重连很快会把可用端口耗光。重试次数默认给3次,再往上容易在PLC真实停机时让上位机界面卡死,不如直接弹报警让操作员介入。
4. 三菱PLC调试实战:响应解析、错误码与轮询性能调优
4.1 响应帧解析:把字节还原成int、float和字符串
读回来的ushort数组只是原始数据,调试程序要显示的是具体物理量。三菱PLC里D寄存器存整数、浮点、字符串的方法是固定的:Int16直接一个D字,Int32用连续两个D字,低位字在前;Float也用两个D字,按IEEE 754单精度排列;字符串按每2字节一个字符存放,规则在PLC侧程序里约定,上位机按同样规则解析。
public static float ReadFloat(ushort[] words, int index) { int raw = (words[index + 1] << 16) | words[index]; // 高字在前 return BitConverter.Int32BitsToSingle(raw); } public static string ReadString(ushort[] words, int index, int charCount) { var bytes = new byte[charCount * 2]; for (int i = 0; i < charCount; i++) { bytes[i * 2] = (byte)(words[index + i] & 0xFF); bytes[i * 2 + 1] = (byte)(words[index + i] >> 8); } return Encoding.ASCII.GetString(bytes).Trim('\0'); }逻辑说明:ReadFloat把两个D字组装成32位整数,再按单精度浮点解释。注意组合顺序,三菱的存储方式是D100存低16位,D101存高16位,所以左移的是words[index+1]。ReadString按照字内低字节在前的规则展开字节序列,再交给Encoding解析,实际项目里字符串可能含日文或中文,根据PLC侧设定改用Encoding.Unicode。
解析时最容易出现的问题是数值漂移:比如用ReadWords读回模拟量通道,直接把ushort当成十进制显示,但PLC侧的模拟量模块可能输出的是带符号的Int16或者做了量程缩放。调试程序里最好保留原始值,同时提供换算表达式,界面显示工程值时再套系数和偏移量,不要污染底层原始数组。
4.2 结束码排查:遇到C0 5B、C0 5D别急着改代码
MC协议响应帧的第9、10字节是结束码,结束码为0x0000表示成功。排查顺序先看结束码,再检查报文长度,最后检查网络。实际调试中C0 5D(地址越界)和C0 5C(软元件不存在)配合出现的频率最高,原因是软元件号转换出错,尤其是X和Y的八进制编号被当成十进制处理。很多工程师对着三菱PLC编程教学视频写了一晚上代码,最后发现是X20的八进制转成了十进制32,报文里却按十进制20去编址。
| 结束码 | 含义 | 常见原因与处理 |
|---|---|---|
| 0x0000 | 正常 | 无需处理 |
| 0xC051 | 点数超限 | count超过上限,分块读取 |
| 0xC058 | 请求长度错误 | 构建帧时长度回填算错,检查lenPos |
| 0xC05B | 命令/子命令不支持 | 确认PLC型号和帧类型,核对命令码 |
| 0xC05C | 软元件不存在 | 设备代码错误,核对软元件代码表 |
| 0xC05D | 地址越界 | start+count超过范围,或八进制未转换 |
| 0xC060 | 数据长度不一致 | 响应解析偏移量算错 |
结束码还有一个实用技巧:把结束码记进日志,连续出现3次以上的同一个结束码,就把该软元件区标记为故障,在调试界面用红色高亮。这样现场排查时不用逐个寄存器点查,报警信息已经能定位到具体地址段。错误码日志用十六进制大写记录,方便和PLC手册对照。
4.3 轮询周期、异步收发与性能调优
一套典型的调试程序会同时轮询D区参数、M区状态、SD区诊断三个块,如果每块一帧请求,1秒周期下CPU压力很小;但要想把周期压到100ms以内,就要减少帧数。常见做法是把相邻软元件合并成一片读取,D区连续的话一次读256个字,再在内存里按地址偏移切片,比发几十帧小请求快得多。M区也类似,连续32位的布尔量可以装进一个字,用按位与、按位或去提取状态。
TcpClient的收发用同步方法已经能满足500ms周期的调试场景,如果做的是数据采集站,建议用async/await改写ReadFrame,避免PLC响应慢时阻塞UI线程。WinForms或WPF界面里不要直接在事件处理器里做网络同步调用,把轮询逻辑放到BackgroundService或ThreadPool里,再用Control.BeginInvoke或事件把结果送回到界面线程。
这里要提一个容易忽略的细节:三菱PLC对连续快速访问有限制,扫描周期短的PLC可能还没来得及刷新软元件,上位机就会读到旧值。轮询周期不要低于PLC扫描周期的10倍量级,FX5U扫描周期一般在1ms到10ms,上位机50ms轮询已经很快了。想获得更平滑的曲线,宁可在上位机侧做时间戳和插值,也不要盲目提高请求频率。
5. 用GX Works仿真和帧回环验证C#调试程序
5.1 GX Works仿真器跑通全链路
调试程序写完不要直接怼着PLC调,先开GX Works2或GX Works3的仿真模式。方法是新建工程后点“模拟开始”,PLC程序跑在PC里,然后给仿真器配置一个虚拟以太网端口,通过本机回环地址访问。仿真器支持的MC协议帧和真实PLC基本一致,能覆盖大部分协议调试需求。
需要注意:仿真器的默认端口可能和真实PLC不同,需要在仿真设置里找到通信参数,把TCP端口固定下来。调试程序把IP和端口做成配置项,连仿真器填127.0.0.1和对应端口,连真实PLC就改成实际IP,不用改代码。这样在家用笔记本上也能做C#上位机的功能开发和界面预览。
5.2 帧回环自检代码
没有PLC也没有仿真器时,可以用一个最简单的回环方法验证帧构建是否正确:
public static void LoopbackCheck() { byte[] req = BuildReadD(100, 16); int len = BitConverter.ToUInt16(req, 7); // 请求数据长度应该等于总长度减头部7字节和长度字段2字节 Debug.Assert(len == req.Length - 9); // 命令码位置固定在第9、10字节 Debug.Assert(req[9] == 0x01 && req[10] == 0x04); }逻辑说明:BuildReadD返回的报文第7、8字节存着请求数据长度,校验它等于总长度减9,能抓住长度回填的错误。命令码位置固定在第9、10字节,0x0104是正确的批量读取命令。这个自检可以挂进单元测试,每次改协议层代码后跑一遍,避免把报文基础结构改坏。
进一步可以做端到端自检:写一个简易的TCP Server,监听本地端口,收到请求帧后按同样的帧格式拼一组响应数据,回给客户端。客户端把接收到的响应按第4章的解析方法还原成ushort数组,比对预期值。这样在没有真实PLC的环境里也能把协议栈的收发、解析两个方向都验证到。
5.3 一个收尾技巧:把原始帧写进日志
给Transceive方法加一个可选的原始帧日志,输出请求和响应的十六进制字节,文件按小时滚动。现场排查“上位机显示正常但值不对”的诡异问题时,原始帧日志比任何断点都好用,因为协议层之外的编码问题全都会在字节序列里留下痕迹。日志里同时打印帧方向、时间戳和结束码,不需要等到复现,翻日志就能定位是哪一帧命令在什么时间点返回了C0 5D。
帧日志不需要把每个轮询周期的帧都记录下来,只记录失败帧或结束码非0的响应,磁盘占用小,排查效率更高。给日志类加一个开关,默认开、轮询时静默,遇到异常再输出完整帧,这套验证方式在项目里帮我省掉过不止一次现场出差。
本文还有配套的精品资源,点击获取