简介C#通过3E帧SLMP/MC协议读写三菱FX5U/Q系列PLC的案例源码由工控老马整理发布面向工控新手及有一定经验的开发人员专注于数据读写报文测试与协议对接。压缩包共31个文件包含C#源代码、可执行程序、调试符号、窗体资源与工程配置文件还单独整理了SLMP与MC协议3E帧通讯协议表以及较完整的WinForms工程整体仅1.72MB方便直接打开对照学习。工程实现了远程监视界面和核心数据读写逻辑读者可直观看到如何构造3E帧、发送请求并解析PLC响应也能在现成工程上修改软元件地址或数据区进行验证。作者整理的通讯协议表覆盖帧格式、指令码、返回码等要点配合源码中的报文演示能显著降低三菱PLC以太网通信的上手成本。目前已有936人学习下载适合在项目前期做协议连通性测试也适合作为二次开发的起点。1. 3E帧SLMP/MC协议读写三菱FX5U/Q系列PLC为什么还要自己拼报文拿到三菱FX5U或者Q系列PLC做数据采集最容易陷入两种极端要么直接封装好的DLL点几个属性拿到数据要么上来就用Modbus TCP把PLC当从站硬轮询。这套源码走的是第三条路——用C#原生Socket拼SLMP/MC协议的3E帧二进制报文不依赖MX Component也不经过网关转换。它解决的问题很具体当你需要高频读D区、M区、X/Y点又不想被商业库的黑盒逻辑拖累时直接构造和解析3E帧是最可控的方案。源码附带一本自己整理的SLMP/MC协议表把帧头、命令码、软元件编码都列出来了对新手友好对五年以上的开发者也能当作速查手册。适合要做上位机远程监视、产量统计、设备状态采集的工控开发人员。2. 3E帧报文结构子头、请求长度与软元件编码2.1 选3E帧之前先想清楚网络拓扑三菱PLC以太网通信里SLMP/MC协议常见的有3E帧和4E帧。4E帧主要出现在多个PLC通过CC-Link IE或以太网模块组网、需要跨站访问的场景报文里要带网络号、站号和模块IO编号解析起来绕。3E帧去掉了复杂路由只保留请求目标模块信息和站号适合一台PLC配一台工控机的直连架构。FX5U内置以太网口直接支持SLMPQ系列通过内置以太网或QJ71E71模块也支持这套C#源码正是针对这种拓扑设计的。选型上还有二进制和ASCII之分。ASCII帧每个字节要转成十六进制字符串再发送报文长度翻倍调试时肉眼能看但现场数据量大时不划算。源码用的是二进制帧命令码和地址都按UInt16小端拼进去节省流量也更合理。PLC侧需要把以太网模块的通讯协议设为SLMP/MC波特率无所谓关键是端口号和IP段不能跟工控机冲突。2.2 一张表拆开3E帧请求头3E帧二进制请求结构从第一个字节开始就非常有规律下面这张表是批量读取D100一个字的请求帧拆解对应源码附带协议表里“批量读”那一行字段相对位置长度(字节)示例值说明子头0200 FF二进制3E帧固定头网络号2100直连场景为0PC号31FF访问PLC时固定FF请求目标模块IO编号4203 FF低字节在前默认PLC CPU请求目标模块站号6100本站为0请求数据长度720C 00从监视定时器算到报文末尾的字节数监视定时器9210 00250ms为单位0x104秒命令11201 00批量访问命令子命令13200 0000字单位读取01字单位写入软元件编号15264 00D100十进制100小端软元件代码172A8 00D设备代码0x00A8访问点数19201 00读1个字注意三菱协议里多字节一律小端存储也就是低字节在前。写代码时直接赋值给byte数组容易搞反最好用BitConverter.GetBytes(ushort.Value)再手动确认顺序。源码里Program.cs没有过度封装核心就是围绕这十几个字节在做文章。2.3 软元件地址编码设备代码和编号怎么拼三菱的D、M、X、Y在3E帧里不是直接写字符串而是映射成两字节设备代码加两字节编号。下面是我在源码协议表里常用的几个映射软元件设备代码(十六进制)备注D0x00A8数据寄存器M0x0090内部继电器X0x009C输入信号Y0x009D输出信号拼接时编号用UInt16小端放前设备代码用小端放后。D100在帧里就是64 00 A8 00。有人把设备代码按十进制算或者把编号和代码顺序写反这是3E帧通讯最常见的第一类错误。X和Y这类位软元件地址一般按十六进制编号比如X0对应编号0X10对应16拼接规则和D区一致只是后续命令和返回数据处理要按位来。3. C# Socket封装从连接握手到读写命令3.1 TcpClient连接与超时设置这一章直接落地。下面是封装的三菱FX5U/Q系列读写类核心代码先看连接和底层收发逻辑public class Fx5uAccess { private readonly string _ip; private readonly int _port; public Fx5uAccess(string ip, int port) { _ip ip; _port port; } private byte[] Transceive(byte[] request, int timeout 3000) { using var tcp new TcpClient(); tcp.Connect(_ip, _port); tcp.ReceiveTimeout timeout; var stream tcp.GetStream(); stream.Write(request, 0, request.Length); // 3E帧响应头固定9字节帧头7字节 响应数据长度2字节 byte[] head new byte[9]; ReadExactly(stream, head, 9); int dataLen BitConverter.ToUInt16(head, 7); byte[] body new byte[dataLen]; ReadExactly(stream, body, dataLen); byte[] full new byte[9 dataLen]; Buffer.BlockCopy(head, 0, full, 0, 9); Buffer.BlockCopy(body, 0, full, 9, dataLen); return full; } private void ReadExactly(NetworkStream stream, byte[] buffer, int count) { int offset 0; while (offset count) { int read stream.Read(buffer, offset, count - offset); if (read 0) throw new IOException(连接被PLC断开); offset read; } } }Transceive每次调用新建TcpClient适合案例级别的数据读写测试。如果做高频采集建议把TcpClient提升为字段并复用连接但要注意PLC侧SLMP最大连接数限制。ReadExactly是关键网络流不保证一次读完必须循环读满count字节。dataLen是响应码加数据的长度从响应头第7字节开始取UInt16小端值。3.2 构建批量读取请求接着构造读请求帧这里以读D区字数据为例private static ushort DeviceCode(string device) { return device.ToUpperInvariant() switch { D 0x00A8, M 0x0090, X 0x009C, Y 0x009D, _ throw new NotSupportedException($不支持的软元件: {device}) }; } public byte[] BuildReadFrame(string device, ushort start, ushort count) { byte[] frame new byte[7 2 2 2 4 2]; int index 0; frame[index] 0x00; // 子头低 frame[index] 0xFF; // 子头高 frame[index] 0x00; // 网络号 frame[index] 0xFF; // PC号 frame[index] 0x03; // 目标模块IO编号低 frame[index] 0xFF; // 目标模块IO编号高 frame[index] 0x00; // 站号 int dataLen 2 2 2 4 2; // 监视定时器命令子命令软元件点数 frame[index] (byte)dataLen; frame[index] (byte)(dataLen 8); frame[index] 0x10; // 监视定时器低 frame[index] 0x00; // 监视定时器高 frame[index] 0x01; // 命令访问软元件 frame[index] 0x00; frame[index] 0x00; // 子命令字单位读取 frame[index] 0x00; frame[index] (byte)start; frame[index] (byte)(start 8); frame[index] (byte)DeviceCode(device); frame[index] (byte)(DeviceCode(device) 8); frame[index] (byte)count; frame[index] (byte)(count 8); return frame; }这里请求数据长度是从监视定时器开始算的所以dataLen把监视定时器、命令、子命令、软元件地址、点数全部计入。监视定时器0x10表示等待PLC响应的上限为4秒如果PLC侧程序扫描周期长可以调大。命令码0x01表示访问软元件读操作子命令为0x00写操作为0x01不要记混。3.3 响应解析响应码、数据体与字节序响应帧的前9字节结构与请求类似第9、10字节是结束代码判断是否成功后面才是真正的数据public ushort[] ReadWords(string device, ushort start, ushort count) { byte[] request BuildReadFrame(device, start, count); byte[] response Transceive(request); int index 9; ushort endCode BitConverter.ToUInt16(response, index); if (endCode ! 0) throw new IOException($PLC返回错误 0x{endCode:X4}); int dataBytes response.Length - index - 2; if (dataBytes ! count * 2) throw new IOException($返回数据长度异常: 期望{count*2}字节实际{dataBytes}字节); ushort[] values new ushort[count]; for (int i 0; i count; i) { values[i] BitConverter.ToUInt16(response, index 2 i * 2); } return values; }结束代码为0x0000时表示正常。错误码常见有0x1100和0x1101分别代表软元件地址超范围和访问点数超过上限。dataBytes必须是点数的两倍否则说明PLC返回的数据和请求不一致。BitConverter在小端机器上可以直接用但要注意PLC返回的每个字低字节在前正好和Windows小端一致。3.4 写入操作与写后校验写单个D字数据比读复杂一点因为请求体里多了要写入的数据public void WriteWord(string device, ushort start, ushort value) { byte[] frame new byte[7 2 2 2 4 2 2]; int index 0; frame[index] 0x00; frame[index] 0xFF; frame[index] 0x00; frame[index] 0xFF; frame[index] 0x03; frame[index] 0xFF; frame[index] 0x00; int dataLen 2 2 2 4 2 2; frame[index] (byte)dataLen; frame[index] (byte)(dataLen 8); frame[index] 0x10; frame[index] 0x00; frame[index] 0x01; frame[index] 0x00; frame[index] 0x01; // 子命令字单位写入 frame[index] 0x00; frame[index] (byte)start; frame[index] (byte)(start 8); frame[index] (byte)DeviceCode(device); frame[index] (byte)(DeviceCode(device) 8); frame[index] 0x01; // 写1个字 frame[index] 0x00; frame[index] (byte)value; frame[index] (byte)(value 8); byte[] response Transceive(frame); ushort endCode BitConverter.ToUInt16(response, 9); if (endCode ! 0) throw new IOException($写入失败PLC返回错误 0x{endCode:X4}); }写操作子命令是0x01数据长度比读请求多两个字节。写入后我一般会紧接着读一次确认PLC内部确实更新了尤其是写M点和Y点这种位元件时因为外部输出还可能被PLC程序再次覆盖。4. WinForms远程监视轮询采集与UI防卡顿4.1 把读写封装成可复用的数据访问类上面几个方法已经能支撑最基本的读写测试但放到FX5U远程监视.cs这个窗体里还需要处理界面生命周期。源码工程是WinForms入口在Program.cs主窗体叫FX5U远程监视。我习惯把所有PLC通信封装到独立类窗体只负责调用ReadWords和WriteWord不直接碰Socket逻辑这样界面卡顿和通信线程分离后问题好定位。窗体上可以放一组TextBox显示D100到D111再加一个启动按钮和停止按钮。轮询周期用System.Windows.Forms.Timer会阻塞UI线程里面不能直接做网络读写。正确做法是后台线程发请求把结果通过BeginInvoke交还UI线程private async void btnStart_Click(object sender, EventArgs e) { var access new Fx5uAccess(192.168.1.10, 2000); _cts?.Cancel(); _cts new CancellationTokenSource(); var token _cts.Token; while (!token.IsCancellationRequested) { try { var values await Task.Run(() access.ReadWords(D, 100, 12), token); UpdateTextBoxes(values); } catch (OperationCanceledException) { break; } catch (Exception ex) { lblStatus.Text ex.Message; } await Task.Delay(200, token); } }Task.Run把网络请求丢到线程池等待PLC响应期间UI线程不被占用。UpdateTextBoxes内部根据控制句柄判断是否需要BeginInvoke避免跨线程访问控件异常。轮询周期200毫秒已经能满足大多数设备监视需求太快反而会给PLC增加不必要的负载。4.2 Timer轮询与Task异步的取舍WinForms里Timer只能给出一个Tick事件如果直接在Tick里同步读PLC界面会卡死。有人改成BackgroundWorker跑循环也可以但取消机制和异常传递不如async/await顺手。上面代码用CancellationTokenSource控制循环退出关闭窗体时在FormClosing事件里调用_cts.Cancel()防止后台线程继续访问已销毁的控件。private void UpdateTextBoxes(ushort[] values) { if (txtD100.IsDisposed) return; string[] names { txtD100, txtD101, txtD102 }; for (int i 0; i Math.Min(values.Length, names.Length); i) { var control Controls[names[i]]; if (control ! null) { var text values[i].ToString(); control.BeginInvoke(new Action(() control.Text text)); } } }这里用到Control.BeginInvoke而不是Invoke因为异步更新数据时不需要等待UI处理完避免后台线程被UI操作拖住。高频采集场景下如果每次更新所有控件能明显感觉到UI刷新卡顿这时需要降低更新频率比如数据采集周期50毫秒界面刷新周期500毫秒中间用队列攒最新值。4.3 高频采集时UI刷新卡顿怎么解决C#上位机做循环数据采集时UI刷新卡顿几乎是必经之路。卡顿根源不在网络而在BeginInvoke调用次数。每个200毫秒周期更新12个控件每秒5次也就是60次交叉线程调用WinForms消息泵会忙于处理控件刷新鼠标拖动窗口都会变得不跟手。常见做法是合并刷新把采集到的数据存到字段UI线程用独立Timer按固定频率拉取private ushort[] _latestValues; private readonly object _lock new object(); // 采集任务只写最新快照 private void OnDataReceived(ushort[] values) { lock (_lock) { _latestValues values; } } // UI定时器每500ms刷新一次 private void uiTimer_Tick(object sender, EventArgs e) { ushort[] snapshot; lock (_lock) { snapshot _latestValues; } if (snapshot null) return; for (int i 0; i snapshot.Length; i) { // 直接更新控件 } }这种生产者消费者模式把写控件频率降到2次每秒界面流畅度提升明显。注意锁粒度要小不要在lock里做控件赋值。另外PLC读到的ushort值对于D区寄存器来说通常是原码如果是BCD格式需要按位转换协议表里也标注了哪些软元件常用BCD。5. 协议表.xlsx排错实战三个常见坑5.1 地址边界和连续区限制批量读取虽然方便但三菱PLC对一次访问的点数和地址边界有约束。比如D区按字读点数不能超过协议表里“读取上限”列的值有的系列限制960字有的限制2048字。跨边界读取会把连续区域切断比如从D4090读20个字可能一部分落在D4090-D4095一部分落到D4100PLC会直接报错。我一般把读取长度限制在64字以内并且起始地址用协议表里的边界值检查。下面是边界校验片段if (start count 0xFFFF) throw new ArgumentOutOfRangeException(nameof(count), 地址越界);5.2 端口连上但无响应时看什么TcpClient能连上发送报文后却一直超时这类问题优先级最高。打开源码附带的SLMP与MC协议3E帧通讯协议表.xlsx重点检查PLC侧以太网参数打开方式是否选择了“TCP”SLMP通讯端口是否和C#代码里一致CPU号是否为默认。三菱FX5U默认端口需要看工程设置案例里写成2000现场不一定一样。还有一点PLC侧开了帧发送延迟或通信数据代码转换时二进制3E帧的响应字节序会被重新映射这种情况建议把PLC侧通信协议设成二进制透明传输。现象检查点处理方向连接成功但读超时PLC侧SLMP端口核对代码端口与工程端口返回错误0x1100软元件编号编码检查4字节是否小端返回错误0x1101访问点数减小点数或拆分请求数据全是0或错位设备代码映射打开xlsx比对代码表5.3 用协议表反查帧格式最后说一个实用技巧手头有SLMP与MC协议3E帧通讯协议表.xlsx排错时不要只看文字要把抓包工具抓到的请求字节按表格逐行对照。比如读D100的请求是00 FF 00 FF 03 FF 00 0C 00 10 00 01 00 00 00 64 00 A8 00 01 00如果在某个字节上偏离一位后续解析全乱。常见错误是把软元件编号和设备代码顺序写反或者把子命令的0x01当成了命令码。把表格里读和写两行粘贴到代码注释里每次调试前先静态比对比盲目试快得多。对于Q系列PLC如果网络内存在跨站访问需求再回过来看协议表里4E帧的路由字段这时候才值得切换到4E帧。本文还有配套的精品资源点击获取