news 2026/9/15 16:03:57

C#与三菱PLC通讯实战:MC协议帧格式与地址映射详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#与三菱PLC通讯实战:MC协议帧格式与地址映射详解

简介:这是一份面向工业自动化开发者的C#与三菱PLC通讯源码,聚焦上位机与PLC之间的数据交互场景,适用于需要实现PLC自动读取数值、设备监控与控制的应用开发。源码基于Windows Forms搭建界面,可让开发人员快速理解OPC协议与串口(RS-232/485)通讯的实际用法。资源包为zip压缩包,共62个文件,大小4.83MB。文件类型涵盖11个C#源码文件(cs)、8个动态链接库(dll)、2个可执行程序(exe)、配置文件(config、xml)以及项目工程文件(sln、csproj)等,还包含运行所需的图标、声音资源和调试信息(pdb),结构较为完整,基本覆盖从源码到可运行程序的各个环节。目前已有380人学习/下载这份源码。通过阅读示例,开发者可以掌握PLC地址映射、寄存器读写、异常处理和定时采集等关键编码技巧,同时也能借鉴其用户界面与事件驱动设计,作为实际项目中快速搭建C#与三菱PLC通讯程序的参考模板。

1. C# 与三菱PLC通讯源码,最容易被卡住的是帧格式

做上位机的人第一次接触三菱PLC,多半是在现场:设备动作不对,拿串口助手去抓 PLC 发出来的数据,全是十六进制,翻手册翻到“帧格式”三个字就放弃了。真正把 C# 的通讯源码写通,其实瓶颈不在 C# 这门语言,而在三菱的 MC 协议(MELSEC Communication Protocol)。同一个 PLC,用 ASCII 帧、二进制帧、QnA 兼容帧还是 FX 专用帧,报文的头尾、站号位置、软元件地址算法都不一样,一旦选错,后面全是白干。这篇文章不是为了贴一份万能源码,而是把从选帧、拼报文、解析响应到现场轮询的完整链路讲清楚,让手里有一台三菱 FX5U、Q 系列或者 L 系列的工程师,照着章节能写出自己能维护的通讯代码,而不是网上拷贝一段“能用但不敢改”的代码。

2. 先分清三菱的三种帧和两类 PLC,再决定用哪种通信源码

2.1 MC 协议的三要素:帧格式、命令号、软元件地址

三菱 PLC 的以太网通讯(SLMP/MC 协议)本质上是“请求-响应”模型:上位机发一段十六进制报文,PLC 收到后回一段响应报文。报文里真正要关心的东西只有三个:用哪种帧格式、想让 PLC 干什么(命令号)、对哪个软元件操作(地址)。

帧格式决定报文的“皮”,常见的有三种:

帧类型适用场景特点
ASCII 帧(3E 帧)Q/L/iQ-R 系列以太网可读性强,抓包一眼能看到命令号,调试方便,但数据量多一倍
二进制帧(3E 二进制)Q/L/iQ-R 系列以太网报文短,适合批量读写,但抓包全是十六进制,要对照命令码表解析
4C 帧(FX 专用)FX3U/FX5U 串口或以太网FX 系列人机界面常用,报文头是 4C,地址和 Q/L 系列算法不同

命令号决定 PLC 执行什么动作,常用的是:

  • 0401:按字批量读取
  • 1401:按字批量写入
  • 0403:按位批量读取
  • 1402:按位批量写入

软元件地址不是 PLC 程序里的十进制编号直接抄上去。三菱协议里地址要换算成十六进制或按“软元件编号 = 起始地址 + 偏移量”的规则计算,不然明明 D100 有数据,读回来的却是 D0 的内容。

2.2 Q 系列和 FX 系列的地址算法不一样,这是源码里最大的分叉口

第一次写三菱通讯代码的人最容易犯的错,是把 Q 系列的 D 地址算法套在 FX 上,或者反过来。两个系列 D 区的报文表示方式都不一样。

对于 Q/L/iQ-R 系列的以太网 3E 帧,D 地址的换算规则是:D100 = 0xA0 + 100 = 0x64A0,转成十六进制报文时,D100 对应地址 64A0,占两个字节,发送时低字节在前。

对于 FX5U 的以太网通讯,虽然也叫 3E 帧,但 D 地址算法不同,常见做法是用“软元件名称+起始地址+点数”的扩展头部结构,而不是 Q 系列的纯地址转换。

判断思路是这样:如果现场是 Q 系列、L 系列、R 系列,走标准 3E 帧没问题。如果现场是 FX3U/FX5U,第一选择是查对应的通讯手册里的“软元件地址”章节里给的例子去反推,而不是凭 Q 系列的经验去猜地址。

2.2.1 会话报文里的 PLC 站号和网络号

3E 帧的报文头里固定有网络号(Subheader 后的第一个字节)、PC 号、请求目标模块 I/O 号、请求目标模块站号。绝大多数单机通讯场景下,这几个字段用固定值就行:

  • 网络号:0x00
  • PC 号:0xFF
  • 请求目标模块 I/O 号:0x03FF
  • 请求目标模块站号:0x00

这组值在三菱官方例程里出现频率最高。只有当 PLC 挂了多个以太网模块或者用了 CC-Link IE 网络时,才需要动这几个字节。

2.3 抓包对比是最快的验证方式,别一上来就写完整封装

刚起步时先别急着搭大框架。用 TcpClient 拼一段 D100 的读取报文发给 PLC,再把响应抓下来对比手册。能通,再去封装类;通不了,先看是帧格式写错还是地址算错。这一阶段推荐在 C# 里直接把发送和接收都按 List<byte> 拼,打印出 HEX 字符串跟手册逐字节对。

// 读Q系列PLC D100一个字的3E二进制帧,命令号0401 byte[] header = { 0x50, 0x00, 0x00, 0xFF, 0xFF, 0x03, 0x00 }; byte[] command = { 0x04, 0x01 }; byte[] dataLength = { 0x00, 0x04 }; // 数据区长度:3字节起始地址+1字节点数 byte[] address = { 0xA0, 0x64, 0x00 }; // D100 -> 0x64A0,低字节在前 byte[] points = { 0x01, 0x00, 0x00, 0x00 }; // 读取1点 var frame = new List<byte>(); frame.AddRange(header); frame.AddRange(command); frame.AddRange(dataLength); frame.AddRange(address); frame.AddRange(points);

3E 帧的头部 7 个字节里,第一个字节 0x50 表示二进制帧,第二字节 0x00 是监视定时器,中间四个字节是网络号 PC 号和 I/O 号,最后是站号。数据长度字段记录的是“数据区长度”,即从起始地址到点数结束一共多少字节,起始地址 3 字节加点数 1 字节就是 4,换算十六进制就是 0x0004,发送时低字节在前。

3. 用 TcpClient 实现最小通讯源码,先让 PLC 肯搭理你

3.1 同步收发还是异步收发:通讯源码的骨架怎么选

C# 里写 PLC 通讯,网上能看到的源码八成是TcpClient+NetworkStream同步收发,少部分用Socket+BeginReceive。同步方式代码容易读,适合功能验证和工具类软件,缺点是 UI 线程直接调用会卡界面。异步方式适合长时间轮询,但状态管理复杂。我的建议是:基础收发方法用同步,调用时放进Task.Run,或者干脆在基础方法内部就做超时控制,上层再决定是同步还是异步。

这里先把TcpClient这一个最小骨架固定下来,后面所有读写命令都跑在它上面:

public class McBinaryFrameClient { private TcpClient _client; private NetworkStream _stream; private readonly object _lock = new object(); private readonly int _timeoutMs = 1000; public bool Connect(string ip, int port = 6000) { _client = new TcpClient(); // 同步Connect在PLC未就绪时会卡住,这里用异步方式配合超时 var task = _client.ConnectAsync(ip, port); if (!task.Wait(_timeoutMs)) { _client.Close(); return false; } _stream = _client.GetStream(); _stream.ReadTimeout = _timeoutMs; _stream.WriteTimeout = _timeoutMs; return _client.Connected; } public byte[] Exchange(byte[] request) { lock (_lock) { if (_client == null || !_client.Connected) throw new InvalidOperationException("PLC连接已断开"); _stream.Write(request, 0, request.Length); _stream.Flush(); // 响应头固定9字节:2字节副头+2字节响应码+4字节数据长度 byte[] header = new byte[9]; int offset = 0; while (offset < header.Length) { int n = _stream.Read(header, offset, header.Length - offset); if (n <= 0) throw new IOException("连接被关闭"); offset += n; } int dataLen = BitConverter.ToUInt16(header, 7); byte[] data = new byte[dataLen]; offset = 0; while (offset < data.Length) { int n = _stream.Read(data, offset, data.Length - offset); if (n <= 0) throw new IOException("读取响应数据失败"); offset += n; } var result = new byte[header.Length + data.Length]; Buffer.BlockCopy(header, 0, result, 0, header.Length); Buffer.BlockCopy(data, 0, result, header.Length, data.Length); return result; } } }

这段代码把同步读写和超时控制封装在一个类里,用lock保证同一时间只有一个命令在收发,避免 PLC 那边因为并发收到的报文错位而报错。超时时间_timeoutMs是 1000 毫秒,现场如果 PLC 程序扫描周期长或者网络交换机有延迟,可以适当调大,建议上限是 3000 毫秒,再大就要查网络本身的问题。

3.2 组装请求报文时最容易错的两个字节:子报文头和监视定时器

上一个代码块里的Exchange方法发送的是第 2 章的frame,但组装frame时还有两个细节,新手经常栽在这:

第一是子报文头。读 Q/L/iQ-R 的二进制帧,报文头第一个字节是 0x50;如果是 ASCII 帧,则换成四个 ASCII 字符0x300x300x300x30。一旦用错,PLC 直接不回包,抓包也看不出问题,只能对着手册比对头部。

第二是监视定时器。3E 帧第二字节的 0x00 是监视定时器,表示 PLC 处理这个指令最多允许多长时间。0x00 表示使用 PLC 参数里的默认值,一般够用。如果 PLC 的指令在复杂逻辑里跑得慢,或者后面接了第三方模块,响应时间可能超时,这个字节可以设置为 0x10(约合 1 秒)。现场如果一个指令经常不回来,我一般先把报文原样抓出来,看定时器是不是太小。

3.3 响应报文的解析固定分三步:校验副头、看响应码、取数据

PLC 正常响应二进制 3E 帧时,返回的报文有固定规律:

  • 前 2 字节仍是 0xD0 0x00(响应副头,对应请求里的 0x50 0x00)
  • 第 3、4 字节是响应结果,0x0000 表示正常
  • 第 5、6 字节是数据区长度,低字节在前
  • 第 7 字节开始才是真正的数据
public ushort ReadD(ushort address, string plcSeries = "Q") { // 地址换算 int baseAddr = plcSeries == "Q" ? 0xA0 : 0x0000; // FX系列需另算 uint realAddr = (uint)(baseAddr + address); var request = BuildReadWordRequest(realAddr, 1); // 内部按3E帧拼报文 byte[] response = Exchange(request); // 响应码校验 ushort status = BitConverter.ToUInt16(response, 2); if (status != 0x0000) throw new Exception($"PLC返回错误: 0x{status:X4}"); return BitConverter.ToUInt16(response, 9); }

BuildReadWordRequest内部就是按第 2 章那段逻辑拼报文,区别只在把地址参数化。响应数据的起始位置是 9,原因是响应头 9 字节(副头 2 字节 + 响应码 2 字节 + 数据长度 4 字节 + 附加 1 字节)之后才是数据区。注意这里用的是BitConverter.ToUInt16,它按小端序解析恰好符合三菱协议的字节顺序,不需要再手动交换高低字节。

4. 地址映射、批量读写、数值拼装才是实战大头

4.1 D、M、X、Y 的地址换算规则不同,一张表能解决大部分项目

三菱 PLC 里最常用的四个软元件分别是 D(数据寄存器,按字读写)、M(内部继电器,按位读写)、X(输入继电器,按位读)、Y(输出继电器,按位读写)。它们在 MC 协议里的地址算法不一样,很多人写了半年通讯代码还在用 switch case 一条条写。

软元件Q系列 3E帧公式十六进制示例字节序
D0xA0 + 地址号D100 = 0x64A0低字节在前
W0x00 + 地址号W0 = 0x0000低字节在前
M0x00A0 + 地址号 / 8M100 = 地址 0x006C,位为 4低字节在前
X0x009C + 输入号 / 8X0 = 地址 0x009C低字节在前
Y0x009D + 输出号 / 8Y0 = 地址 0x009D低字节在前

M 区的地址计算是“地址号除以 8,余数作为位编号”。比如 M100,100 除以 8 商 12 余 4,对应的 3E 帧地址是 0x00A0 + 12 = 0x00AC,位编号是 4。发送时地址字段填 0x00AC,位编号字段填 4。这个换算规则不是人为约定的,是三菱协议里用“8 个位共用一个字地址”来压缩报文长度的设计,如果按字地址为单位去读 M 区,读回来的每一位对应关系会完全错位。

4.2 一次读一个寄存器是玩具,批量读才是现场轮询的正确姿势

通讯源码写通了以后,最常见的性能瓶颈是循环里一条条读:

for (int i = 0; i < 100; i++) { ushort val = client.ReadD((ushort)(100 + i)); values[i] = val; }

这种写法单个请求就要走一次 TCP 收发,100 个寄存器就是 100 次 RTT,加上 PLC 扫描周期延迟,一轮下来几百毫秒是常态。更致命的是 TCP 报文里有大量重复的头部信息,利用率极低。更好的做法是批量读:

public ushort[] ReadDBlock(ushort startAddress, ushort count) { int baseAddr = 0xA0; uint realAddr = (uint)(baseAddr + startAddress); var request = BuildReadWordRequest(realAddr, count); byte[] response = Exchange(request); // 响应码校验 ushort status = BitConverter.ToUInt16(response, 2); if (status != 0x0000) throw new Exception($"PLC返回错误: 0x{status:X4}"); int dataStart = 9; var result = new ushort[count]; for (int i = 0; i < count; i++) { result[i] = BitConverter.ToUInt16(response, dataStart + i * 2); } return result; }

BuildReadWordRequest里的点数参数是 4 字节,低字节在前,最大能一次读 480 个字(三菱协议的限制,超出会返回错误码)。实际项目中我一般控制在 200 字以内,留够裕量。一次批量读 200 个 D 区的时间,和单读一个 D 区基本一样,因为 PLC 处理批量读的耗时远比网络 RTT 短。

4.3 float 是两个 word 拼出来的,高低字顺序看 PLC 程序里的指令

三菱 PLC 里单精度浮点数(32 位 float)占用两个连续的 D 寄存器,比如 D100 和 D101 组合成一个 float。PLC 程序里用DMOVFMOV指令存一个浮点,它的存储顺序是三菱特有的:高 16 位(D101)在前,低 16 位(D100)在后

C# 侧读出来两个 ushort 后,需要手动拼回 float:

ushort lowWord = result[0]; // D100 ushort highWord = result[1]; // D101 uint combined = ((uint)highWord << 16) | lowWord; float value = BitConverter.ToSingle(BitConverter.GetBytes(combined), 0);

这是整个通讯过程里最容易搞反的一步。写 float 到 PLC 时反过来即可:先拿BitConverter.GetBytes(floatValue)取得 4 字节,再拆成两个 ushort,高的写 D101、低的写 D100。如果现场的 PLC 程序里用了BMOV做数据搬运,或者用的不是DMOV而是MOV指令手动拼接,顺序就可能反过来,最好的确认方式是在 PLC 里放一个已知浮点数,读出来对比。

5. 断线、粘包、错码:通讯源码里必须预留三道防线

5.1 超时判断:TCP 连接看起来通,不代表 PLC 会理你

三菱 PLC 通讯里最折磨人的一种现象是:网线通、ping 通、PLC 编程软件能连,但自己写的上位机就是读不到数据。这种时候十有八九是报文发过去了但格式不对,PLC 直接丢弃,TCP 层面没有 RST,所以Read会一直阻塞到超时。

处理办法是给NetworkStream.ReadTimeout设置一个合理值,然后捕获IOExceptionSocketException。前面Connect方法里已经用了Task.Wait(_timeoutMs)做连接超时控制,读操作同理。建议把所有可能抛异常的方法封装成一个重试策略:

public T WithRetry<T>(Func<T> action, int retryCount = 3, int delayMs = 200) { for (int i = 0; i < retryCount; i++) { try { return action(); } catch (Exception ex) when ( ex is IOException || ex is SocketException || ex is TimeoutException) { if (i == retryCount - 1) throw; Thread.Sleep(delayMs * (i + 1)); // 重试间隔递增 } } throw new InvalidOperationException("重试失败"); }

重试间隔用线性退避(200ms、400ms、600ms)而不是固定值,是因为 PLC 在刚上电或程序刚切换模式时,通讯模块可能短暂繁忙,连续快速重发反而可能加重拥堵。重试次数不建议超过 5 次,超过就有可能是协议或者接线层面的问题,反复重试只会拖慢上层的故障响应。

5.2 粘包和半包:按“响应头”和“数据长度”精确切包

TCP 是流协议,没有消息边界。PLC 响应一个较大的数据块时,可能分成多个 TCP 包到达;连续快速请求时,多个响应也可能一次到达被合并。前面Exchange里用的两次Read循环,第一次固定读 9 字节头,第二次根据头里的数据长度读取剩余数据,已经是标准的“读头-解析长度-读体”切包方案。

这里容易忽略的是:数据长度字段的值在不同型号 PLC 上有细微差别。

PLC 系列响应头长度数据长度字段含义
Q/L/iQ-R9 字节只统计数据区字节数,不含头
FX5U9 字节统计范围和 Q 系列不同(要对照手册)

如果现场是 FX5U 且响应解析总是错位,优先怀疑dataLen的偏移位置是不是 7,而不是 5 或者 11。可以抓一次完整报文,手工数一下长度字段在哪个位置,比对实际数据区大小,就能确定是读偏了还是长度字段本身就不同。

5.3 响应码不是只有 0x0000 一种,PLC 返回的错误要单独建表

PLC 返回的响应码里,0x0000 是正常,其余常见的有:

错误码含义常见的触发原因
0x0051不能访问软元件地址公式算错,比如 D 区用了 M 区的算法
0x0052软元件数量设置错误点数超过上限或为 0
0x0053请求内容错误命令号写错或帧类型不匹配
0x0054软元件属性错误对只读软元件执行了写操作
0x0001远程 PLC 异常PLC 侧程序停止或看门狗超时

ReadD这类方法里统一抛出异常,然后在日志里把原始响应报文以十六进制字符串打出来。比我一条条 switch 去翻译错误码更高效的办法是,只把0x00510x0052在代码注释里写清楚含义,其余错误码直接输出十六进制值,查手册翻到对应章节即可。真实场地上最值钱的调试手段往往是这个十六进制响应报文本身,而不是错误码的翻译文字。

6. 循环数据采集和 UI 刷新卡顿:把轮询线程和界面线程彻底拆开

6.1 一个 Channel 搞定“PLC 读得勤”和“界面画得动”

C# 上位机做数据采集时,最常见的错误是直接在 Timer 的 Tick 里读写 PLC,再在同一个线程里更新 TextBox 或 DataGridView。PLC 读写稍微慢一点,UI 就会卡;UI 卡了,Timer 触发的间隔就飘,读数据的时间点全乱了。解决办法是让采集线程和 UI 线程完全不共享调用栈,中间只通过一个线程安全的队列传数据。

// 采集线程只负责读PLC,写入Channel var channel = Channel.CreateBounded<ushort[]>(new BoundedChannelOptions(100) { FullMode = BoundedChannelFullMode.DropOldest }); Task.Run(async () => { while (!cts.Token.IsCancellationRequested) { try { var data = client.ReadDBlock(100, 20); await channel.Writer.WriteAsync(data); } catch (Exception ex) { // PLC断线重连逻辑放在这里,不要吞异常 TryReconnect(); } } }); // UI线程只负责从Channel里取 private async void TimerTick(object sender, EventArgs e) { while (await channel.Reader.WaitToReadAsync()) { if (channel.Reader.TryRead(out var data)) { textBoxTemperature.Text = data[0].ToString(); } } }

这个模式用Channel.CreateBounded限制队列长度,采集速度如果快于 UI 刷新速度,旧的重复数据会被丢弃,保证 UI 永远拿到的是最新的一个批次,而不是把积压的数据一个个补画出来。DropOldest模式在仪表盘类界面里特别合适——你需要的永远是当前值,没人关心 5 秒前那个值有没有被画上去。

6.2 批量读 + 加权平均,让 UI 上的数值不抖

轮询间隔稳定以后,下一个坑是跳数。PLC 里读取的模拟量(比如温度传感器经过 AD 模块转出来的值)在末位经常跳一两格,直接显示在界面上非常难看。这时在采集线程里做一次简单的滑动平均,不要等 UI 层再去处理:

private Queue<ushort> _history = new Queue<ushort>(10); public ushort GetSmoothedValue() { _history.Enqueue(ReadD(100)); if (_history.Count > 10) _history.Dequeue(); return (ushort)_history.Average(); }

滑动窗口取近 10 次的均值,每次调用都会把最旧的值挤出去。窗口大小不建议超过 20,否则数值响应相位落后严重,现场调 PID 参数时会觉得“滞后半拍”。这个技巧在“三菱plc如何自整定pid参数”这类调试场景里特别实用:给自整定提供稳定的过程值,比把原始抖动的数值喂给 PID 指令,整定出来的参数可靠得多。

6.3 最后再留一手:把每一条原始报文写入滚动日志,保证能够复盘

不管是 Q 系列还是 FX5U,通讯代码写完、界面流畅了,最后再加一个体积很小的调试钩子:在Exchange里把请求和响应的十六进制字符串追加到一个日志文件。不需要加日志框架,就File.AppendAllText一行。平时不觉得有用,遇到半夜现场报数据错乱时,日志里的完整报文能立刻告诉你:是地址算错了,还是 PLC 返回了0x0051,还是 TCP 分包异常导致解析错位。排查完把日志级别调回去,这个钩子保留在源码里,它对运行的影响远小于一次现场返修的成本。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 16:01:54

Unity纯ECS实现RTS核心链路:性能重构实战

简介&#xff1a;本资源是一个基于 Unity DOTS 架构的轻量级 RTS 游戏原型项目&#xff0c;面向中高级 Unity 开发者及 ECS 学习者&#xff0c;旨在解决传统 MonoBehaviour 架构在大规模单位运算场景下的性能瓶颈问题。项目完整实现了资源采集、单位生成、基础寻路与指令响应等…

作者头像 李华
网站建设 2026/9/15 16:00:46

单目+IMU SLAM部署:ORB-SLAM3编译与EuRoC实测

搞ORB-SLAM3部署这事&#xff0c;最气人的往往不是算法本身&#xff0c;而是环境、依赖、版本、数据格式这些琐碎问题。我最近在Ubuntu 20.04上把ORB-SLAM3从源码完整编译了一遍&#xff0c;用EuRoC数据集跑通了单目IMU&#xff08;Mono-Inertial&#xff09;模式&#xff0c;期…

作者头像 李华
网站建设 2026/9/15 16:00:34

AI修图工作流重构:国产工具与Photoshop协同实战指南

1. 这不是功能替代&#xff0c;而是工作流重构&#xff1a;当修图师开始用国产AI工具批量处理300张电商图“国产AI修图工具卷到飞起”——这句话最近在设计群、电商运营组和摄影工作室的茶水间里高频出现。我上个月帮一家做家居软装的客户做春季新品图集&#xff0c;原计划用Ph…

作者头像 李华
网站建设 2026/9/15 16:00:13

Unity超级冰火人源码拆解:双角色协作与机关触发实现

简介&#xff1a;这是基于Unity 2021.1及以上版本的超级冰火人风格双人合作益智迷宫游戏完整项目源码&#xff0c;面向Unity游戏开发者、独立制作人和解谜游戏爱好者。项目内置三十张地图&#xff0c;设有红男孩与水女孩双角色控制&#xff0c;包含火钻石与冰钻石收集、多种关卡…

作者头像 李华