
简介面向工控上位机开发的C#案例源码通过3E帧SLMP/MC协议完成三菱FX5U/Q系列PLC的数据读写聚焦报文测试场景适合需要快速掌握SLMP协议组包与解析的初学者也适合有经验者作为参考模板。整套源码包共31个文件以.cs核心源码为主包含完整的窗体工程配置、界面资源、可执行程序、编译中间文件以及作者整理的SLMP与MC协议3E帧通讯协议表压缩包大小仅1.72MB结构清晰便于直接打开和二次修改。目前已有936人学习下载案例覆盖从报文构建、发送接收、回应解析到异常处理的关键环节同时结合协议表可直观理解3E帧请求/响应报文格式有效缩短工业通讯功能的开发调试周期。工程基于.NET Framework开发可直接用Visual Studio打开运行代码注释清晰便于理解通讯逻辑和二次开发。1. C# 通过 3E 帧 SLMP 读写三菱 FX5U / Q 系列先懂帧再动手做 C# 上位机的人迟早会遇到三菱 PLC。FX5U 自带以太网口Q 系列配上以太网模块以后对外都能走统一的一套协议SLMP也就是 MC 协议在以太网上的形态。实际开发里最常见的坑是从网上拷来的“案例源码”能连上却读不到数或者读 D 区正常、写 M 位就报错原因大多不在 PLC 参数而在 3E 帧里的长度字段和设备代码算错了。这篇文章不依赖 MX Component也不用第三方通信库直接用 C# 按 3E 帧格式自己封装一个最小 SLMP 客户端从报文结构讲到 Socket 收发再给一个 WinForm 读写 D 区、M 区的可运行案例。适合要在 C# 上位机里对接三菱 FX5U/Q 系列的工程师也适合想彻底搞懂 SLMP 报文而不是只会复制代码的人。2. SLMP 的 3E 帧报文结构头、长度、命令与设备代码2.1 3E 帧请求与响应报文的固定头部3E 帧是 SLMP 走 TCP 时最常用的一种二进制帧。不管是 Q 系列还是 FX5UTCP 端口收到数据后先解析固定头部再按命令区分功能。请求报文的固定部分一共 12 字节之后是 2 字节请求长度再往后才是监视时间和命令数据。字段字节数常见值说明子头250 00二进制 3E 帧标记请求源网络号100通常填 0请求源 PC 号1FFFF 表示不指定请求源 IO 号2FF 03默认 03FF低字节在前请求源站号100默认 0请求目标网络号100直连场景填 0请求目标 PC 号1FF不指定请求目标 IO 号2FF 03默认 03FF请求目标站号100多数模块默认 0请求数据长度210 00低字节在前表示后面还有多少字节响应报文的前 12 字节结构基本一样只是子头会变成D0 00表示这是响应。第 13、14 字节是响应体长度从结束代码开始计算。结束代码固定占 2 字节00 00表示成功非零值对应具体错误。很多源码读不到数据就是只盯着返回的 ushort 直接转却没有先判断结束代码。2.2 批量读取与批量写入的命令数据区在 3E 帧里命令字段按高位在前的习惯写出批量读取是01 04批量写入是01 14子命令固定为00 00。监视时间占 4 字节通常按高位到低位填00 00 00 0A单位由 PLC 通信参数决定常见默认单位是 250ms也就是约 2.5 秒。以读取 D100 这一个字为例完整请求帧是50 00 00 FF FF 03 00 00 FF FF 03 00 10 00 00 00 00 0A 01 04 00 00 A8 00 64 00 00 00 01 00拆开看10 00是请求长度 16表示监视时间 4 字节加命令 2 字节加子命令 2 字节加后续数据 8 字节。后续 8 字节依次是设备代码A8 00、设备号64 00 00 00、点数01 00。设备号是 32 位低字节在前点数是 16 位同样低字节在前。写入 D100 一个字的请求命令改为01 14数据区在“点数”之后再追加要写的 2 字节。比如写 12345数据就是39 30。对写入请求PLC 只返回结束代码不再回传写入值所以接收响应时数据段长度为 0。2.3 设备代码表与位元件地址换算设备代码在协议文档里习惯写成十六进制实际发送时按低字节在前。下面是开发中最常用的一张对照表设备代码发送字节类型地址规则M0x009090 00位每 16 点为一组SM0x009191 00位每 16 点为一组X0x009C9C 00位八进制编号每 16 点一组Y0x009D9D 00位八进制编号每 16 点一组L0x009292 00位每 16 点为一组D0x00A8A8 00字设备号直接对应地址SD0x00A9A9 00字设备号直接对应地址W0x00B4B4 00字设备号直接对应地址读写位元件时设备号按 16 点一组拆分。M100 先做 100 / 16 16 的整数位号余数 4也就是设备号 6、位偏移 4。读取 M100 时请求 1 点PLC 返回 1 字节位 4 就是 M100 的值。X 和 Y 的编号是八进制X10 对应十进制 8再按同一套 16 点分组逻辑处理。3. C# 实现 SLMP 客户端帧构造、收发与结束码校验3.1 构造请求帧固定头、监视时间和批量读命令用一个类把 TcpClient 和帧构建逻辑封装起来这种写法在 C# 上位机里最容易维护。把 12 字节固定头拆出来后面只拼动态部分便于复用读取和写入两个方向。using System.Net; using System.Net.Sockets; public sealed class SlmpClient : IDisposable { private readonly TcpClient _tcp; private readonly NetworkStream _ns; public SlmpClient(string ip, int port 2001) { _tcp new TcpClient(); _tcp.Connect(ip, port); _ns _tcp.GetStream(); _ns.ReadTimeout 3000; _ns.WriteTimeout 3000; } // 固定头 2 字节长度length 是监视时间之后的字节数 private byte[] BuildHeader(int bodyLength) { var head new Listbyte { 0x50, 0x00, // 子头3E 帧二进制模式 0x00, 0xFF, 0xFF, 0x03, 0x00, 0x00, 0xFF, 0xFF, 0x03, 0x00 }; head.Add((byte)(bodyLength 0xFF)); head.Add((byte)(bodyLength 8)); return head.ToArray(); } }先连接后构建头bodyLength是监视时间、命令、子命令、设备代码、设备号、点数的总长度。对批量读取来说这个值是 4 2 2 2 4 2 16。端口默认值这里写了 2001对应 FX5U 内置以太网口默认端口如果连 Q 系列以太网模块通常要改成 2000以 PLC 参数页面实际配置为准。3.2 构造批量读请求并处理接收响应读取请求只需给定设备代码、设备号和点数。命令0x0401在帧里写成01 04子命令补00 00设备号和点数用BitConverter转成低字节在前。private byte[] BuildReadRequest(ushort code, int deviceNo, ushort pointCount) { var body new Listbyte(); body.AddRange(new byte[] { 0x00, 0x00, 0x00, 0x0A }); // 监视时间 body.AddRange(new byte[] { 0x01, 0x04 }); // 批量读取命令 body.AddRange(new byte[] { 0x00, 0x00 }); // 子命令 body.Add((byte)(code 0xFF)); // 设备代码低字节 body.Add((byte)(code 8)); // 设备代码高字节 body.AddRange(BitConverter.GetBytes((uint)deviceNo)); // 设备号32 位小端 body.AddRange(BitConverter.GetBytes(pointCount)); // 点数16 位小端 var frame new Listbyte(); frame.AddRange(BuildHeader(body.Count)); frame.AddRange(body); return frame.ToArray(); }设备代码传入0x00A8截取高低字节后正好得到A8 00。这里没有用BitConverter.GetBytes(ushort)是因为协议里设备代码的低字节在前直接用移位最直观。注意监视时间必须手动写成高位在前不能拿BitConverter.GetBytes(10)直接转否则会得到0A 00 00 00顺序就反了。接收响应时先读 14 字节其中后 2 字节是响应体长度再按长度读取剩余内容。结束代码从响应体前 2 字节解析非零直接抛异常这样上层代码不需要到处判断错误码。private byte[] SendAndReceive(byte[] request, int expectedLength) { _ns.Write(request, 0, request.Length); byte[] head ReceiveExactly(14); int bodyLength head[12] | (head[13] 8); if (bodyLength 2) throw new IOException(响应长度异常); byte[] body ReceiveExactly(bodyLength); ushort endCode (ushort)(body[0] | (body[1] 8)); if (endCode ! 0) throw new IOException($SLMP 错误 0x{endCode:X4}); var data new byte[bodyLength - 2]; Array.Copy(body, 2, data, 0, bodyLength - 2); if (expectedLength 0 data.Length ! expectedLength) throw new IOException(响应数据长度不符); return data; } private byte[] ReceiveExactly(int length) { var buffer new byte[length]; int offset 0; while (offset length) { int n _ns.Read(buffer, offset, length - offset); if (n 0) throw new IOException(PLC 连接已断开); offset n; } return buffer; }响应长度是从结束代码开始算的所以bodyLength - 2才是真实数据长度。读 D 区一个字时预期长度是 2读 5 个字就是 10用expectedLength可以提前发现握手错位。ReceiveExactly必须循环读因为NetworkStream.Read一次不一定拿满所有字节。提示别直接用Read()的返回值判断帧完整性TCP 分包时常导致只读到半个帧。用循环读完固定长度再解析是 C# 上位机通信最稳妥的做法。4. 可运行的 C# 案例WinForm 读 D 区、轮询刷新、写 M 位4.1 地址解析把 D100、M100、X20 转成设备号把地址解析独立成静态方法上层代码就可以直接写D100、M100不必到处都是除法取余。位元件返回设备号、位偏移字元件位偏移固定为 0。public readonly record struct Addr(ushort Code, int DeviceNo, int Bit); public static Addr Resolve(string address) { string name address[..1].ToUpper(); string numText address[1..]; int value name is X or Y ? Convert.ToInt32(numText, 8) // X/Y 是八进制编号 : int.Parse(numText); ushort code name switch { M 0x0090, SM 0x0091, X 0x009C, Y 0x009D, L 0x0092, D 0x00A8, SD 0x00A9, _ throw new NotSupportedException($不支持的设备 {name}) }; return name switch { D or SD new Addr(code, value, 0), _ new Addr(code, value / 16, value % 16) }; }X10 在 PLC 里是八进制第 10 号转成十进制是 8处在 0~15 区间内所以设备号是 0、位偏移是 8。这个细节最容易写错很多案例源码直接int.Parse就把 X10 当成十进制的 10最后读到的是 X12 的值。4.2 读 D100 并在 WinForm 里显示按钮事件里直接创建客户端读取 D100 开始的 5 个字拼成字符串显示。连读 5 个字只发一个请求比循环单点读快得多也符合 C# 上位机里常见的“批量采集”场景。private void btnRead_Click(object sender, EventArgs e) { try { using var plc new SlmpClient(192.168.1.10, 2001); ushort[] values plc.ReadWords(D100, 5); txtResult.Text string.Join(, , values); } catch (Exception ex) { MessageBox.Show(ex.Message, 读取失败); } }ReadWords内部复用SendAndReceive根据地址类型自动选择设备代码。对于 D 区点是直接按字解析每次取 2 字节转 ushort。这里尽量在 UI 线程少做拼接如果一次读几十个点建议先存数组再一次性赋值给文本框避免频繁触发控件重绘。4.3 循环数据采集与 UI 刷新卡顿的对策WinForm 里常见的卡顿是因为在Timer回调里直接 new 连接、读取、再逐条往控件塞值。SLMP 走 TCP 本身很轻一次读 10 个字也就是几毫秒卡顿基本来自 UI 线程被阻塞和控件重复布局。下面用一个常驻连接加BeginInvoke的方式把网络读取放到线程池UI 只做最小更新。private readonly SlmpClient _plc new SlmpClient(192.168.1.10, 2001); private readonly object _sync new(); private void PollOnce() { ushort[] values; lock (_sync) { values _plc.ReadWords(D100, 10); } BeginInvoke((Action)(() { lblD0.Text values[0].ToString(); lblD1.Text values[1].ToString(); // 只更新真正变化的控件避免整体刷新 })); }连接对象需要复用加锁是为了避免多个定时器周期同时读写同一个NetworkStream。轮询间隔建议不小于 100ms三菱 PLC 的 CPU 扫描周期通常在几毫秒到几十毫秒读得太快没有意义如果上位机还要同时写数据写入和读取之间最好留一个扫描周期否则可能读到写入前的旧值。4.4 写 D 区单字与写 M 位元件的安全姿势写 D 区比较简单把 ushort 数组转成字节数组追加在写入请求后面。写 M 位不能直接构造 1 字节覆盖因为一个字节代表 16 个位直接写会把同组其他位清掉。常见做法是先读回整字节改其中一个位再写回去。public void WriteBitSafe(string address, bool value) { Addr addr Resolve(address); byte[] current SendAndReceive( BuildReadRequest(addr.Code, addr.DeviceNo, 1), 1); if (value) current[0] | (byte)(1 addr.Bit); else current[0] (byte)~(1 addr.Bit); SendAndReceive(BuildWriteRequest(addr.Code, addr.DeviceNo, 1, current), 0); }BuildWriteRequest和读请求的区别只有命令改为01 14并在“点数”字段后追加数据。写 M100 时设备号是 6、位偏移是 4读回的是 M96 到 M111 组成的字节改位 4 再写回其它位保持不变。第一次写位元件时建议先用这个安全方法等确认不会再动同一组的其它点再考虑直接构造成组写入。private void btnWriteTest_Click(object sender, EventArgs e) { using var plc new SlmpClient(192.168.1.10, 2001); plc.WriteWords(D200, new ushort[] { 123, 456, 789 }); plc.WriteBitSafe(M100, true); }5. 上线前必查端口、监视时间与轮询踩坑清单5.1 三个最常踩的连接参数SLMP 帧本身的内容大部分场景不用改真正让通信失败的是连接参数。下面这张表列了 Q 系列与 FX5U 最常见的差异和现象排查项Q 系列以太网模块FX5U 内置以太网异常现象TCP 端口模块参数里的端口默认多为 2000SLMP 连接端口默认多为 2001连接超时或直接被拒目标站号模块参数设置默认 0CPU 参数设置默认 0连接正常但无响应监视时间单位由模块参数决定单位由 CPU 参数决定大批量写入时返回超时错误码虚拟机里做开发时网卡要选桥接模式NAT 模式下 PLC 的回包到不了虚拟网卡表现是 TCP 能通、但收不到完整响应Wireshark 里只能看到 SYN 看不到 ACK。5.2 响应错误码与轮询优化技巧SLMP 错误码集中在响应体的前 2 字节最常遇到的是这几个错误码含义处理0xC051请求长度错误回查 2 字节长度字段是否算错0xC056命令或子命令错误检查批量读写命令是否为 01 04 / 01 140xC059请求点数超范围减少单次读写点数0xC0D6PLC 通信中忙降低轮询频率0xC0F1目标站无响应检查站号和网络号轮询性能上尽量把连续地址合并成一次请求。D100 到 D199 一次读 100 个字比循环 100 次单点读快一个数量级位元件也同理M 区一次最多能读多少点要以 PLC 型号和版本为准FX5U 的批量限制通常低于 Q 系列。采集循环里不要把ReadWords和 UI 赋值放在同一个lock里锁只保护网络流数据拷贝出来以后立刻释放UI 更新用BeginInvoke异步执行。最后给一个抓包建议调试时用 Wireshark 抓 2000 或 2001 端口先看请求的长度字段是否和实际字节数一致再看响应结束代码。SLMP 帧格式固定、字段位置明确抓包时重点看长度字段和结束码能解决八成问题。本文还有配套的精品资源点击获取