简介:这份资源面向工业自动化与上位机开发方向的C#程序员,聚焦三菱FX5U PLC的以太网通信实现,尤其适用于自动化立体仓库等需要与PLC高速交互的场景。压缩包共35个文件,约79KB,以cs源码为主体,辅以config配置、resx与resources资源、sln与csproj工程文件及exe可执行程序,构成一套可直接运行的WinForm工程。内容围绕3E帧报文结构展开,涵盖连接建立、报文构造与字节序转换、基于RawSocket的数据收发、PLC私有协议解析、超时重发与错误恢复机制,以及通信状态与读写结果的界面展示。读者可据此理解以太网帧的构建与解析思路,掌握上位机与PLC通信的完整代码组织方式,并将业务逻辑与底层通信结合,用于库存管理、货位查询、物料搬运等控制指令的发送与响应处理。目前已有466人学习,适合希望打通C#网络编程与工业通信协议的中级开发者参考。
1. 三菱FX5U上位机以太网通信:从一根网线到稳定读写
车间里一台 FX5U 刚上电,网口灯闪了两下,上位机却连不上——这种场景我见过太多次。三菱 FX5U 上位机以太网通信,说白了就是让 PC 通过 RJ45 网口,用 MC 协议(MELSEC Communication Protocol)读写 PLC 的软元件,替代老旧的 RS-422/485 串口。它解决的是产线数据采集、配方下发、设备状态监控这类刚需,适合做上位机开发的工程师、做非标自动化的电气人员,以及想把 PLC 数据接进 MES 或数据库的集成者。标题里那个 .rar 压缩包,通常装的是通信库、示例工程或协议文档,但真正卡住人的从来不是文件本身,而是 IP 怎么配、端口开哪个、二进制和 ASCII 选哪个、批量读为什么超时。这篇就把这些讲透。
2. 先搞懂 FX5U 以太网通信的三种姿势与选型
2.1 开放还是专用:SLMP、MC 协议、Socket 通信的区别
FX5U 的以太网口支持好几套玩法,新手最容易混。第一种是SLMP(Seamless Message Protocol),这是三菱主推的开放协议,基于 UDP 或 TCP,帧格式公开,第三方上位机不用装三菱的驱动就能读写。第二种是MC 协议,严格说 SLMP 是 MC 协议在以太网上的封装,很多老资料把两者混着叫,实际用起来帧结构一致,只是传输层不同。第三种是Socket 通信(开放式通信),用 PLC 内置的以太网指令(如 OPEN、SEND、RECV)主动跟外部设备收发原始数据,灵活但需要自己定协议,适合跟视觉相机、扫码枪这类设备对接。
选型逻辑很简单:上位机是 PC 或工控机,要读 D、M、X、Y 这些软元件,直接用 SLMP/MC 协议,别折腾 Socket。如果是要让 PLC 主动把数据推给服务器,或者跟非三菱设备做自定义报文交互,才考虑 Socket。我一般建议新手从 SLMP over TCP 入手,因为 TCP 有连接状态,调试时用网络助手能直观看到握手,UDP 虽然快但丢包了不好排查。
2.2 二进制码和 ASCII 码:帧长度差一倍,别选错
MC 协议的数据格式分二进制码和ASCII 码两种。二进制码用原始字节表示地址和长度,帧短、解析快;ASCII 码把每个字节转成两个十六进制字符,帧长翻倍,但肉眼可读,抓包时方便。
举个例子,读 D100 开始的 1 个字,二进制码的请求帧大概 13 字节,ASCII 码要 26 字节左右。批量读 100 个点时差距更明显,ASCII 码容易触发 MTU 分片,增加超时概率。所以生产环境我强烈建议用二进制码,只有在调试阶段、想用串口助手手动发帧验证时,才临时切 ASCII 码看内容。FX5U 的参数里,这个选项在 GX Works3 的「以太网端口设置」→「对象设备连接配置」里,选「SLMP」后能指定格式。
2.3 TCP 还是 UDP:连接数和实时性的权衡
FX5U 的以太网口支持同时开多个连接,TCP 和 UDP 都能用。TCP 是面向连接的,上位机先跟 PLC 的 5000 端口(默认,可改)三次握手,之后每次读写都在这条连接上跑,可靠性高,适合数据采集和配方下发。UDP 无连接,发出去就不管,速度快但丢包不重传,适合高频刷新的监控画面。
这里有个硬限制:FX5U 内置以太网口最多支持8 个连接(TCP+UDP 合计),其中 SLMP 连接通常占 1 个。如果你上位机开了多个线程各自建连接,很容易把连接数占满,后面就连不上了。常见做法是上位机维护一个长连接,所有读写走同一个 socket,用锁或队列串行化,别每个功能模块都去 new 一个连接。UDP 的话,本地端口可以复用,但 PLC 侧对同一 IP 的 UDP 连接也有数量限制,别乱开。
3. 用 Python 跑通 FX5U 读写的最小闭环
3.1 环境准备与网络参数确认
先确认三件事:PLC 的 IP、上位机 IP、端口号。FX5U 默认 IP 是 192.168.1.10 这类,用 GX Works3 在「参数」→「FX5UCPU」→「模块参数」→「以太网端口」里看和改。上位机网卡要设成同网段,比如 192.168.1.100,子网掩码 255.255.255.0。端口号默认 5000,如果被改过,以实际为准。
Python 侧不需要装三菱的库,用标准 socket 就行。我习惯用socket+struct手动拼帧,这样最透明,出问题能逐字节对。下面是最小可运行代码,读 D100 开始的 4 个字。
import socket import struct PLC_IP = "192.168.1.10" PLC_PORT = 5000 def build_read_request(head_device, head_addr, count): # 二进制码,SLMP 3E 帧(Qna 兼容 3E) # 副头部 5000 + 网络号 00 + PLC号 FF + 请求目标模块 IO 03FF + 站号 00 subheader = b"\x50\x00" net_plc = b"\x00\xFF" io_station = b"\x03\xFF\x00" # 监视定时器,单位 250ms,这里 0x0010 = 4s timer = b"\x10\x00" # 命令 0401 = 批量读 command = b"\x01\x04" # 子命令 0000 = 按字读 subcommand = b"\x00\x00" # 首软元件:D 的代码是 0xA8,地址 4 字节小端 device = b"\xA8" + struct.pack("<I", head_addr) length = struct.pack("<H", count) return subheader + net_plc + io_station + timer + command + subcommand + device + length def parse_read_response(data): # 响应:副头部 2 + 网络号 1 + PLC号 1 + IO 2 + 站号 1 + 数据长度 2 + 结束代码 2 + 数据 # 跳过前 9 字节,读结束代码 end_code = struct.unpack("<H", data[9:11])[0] if end_code != 0: raise RuntimeError(f"PLC 返回错误码: {end_code:#06x}") payload = data[11:] values = struct.unpack("<" + "H" * (len(payload)//2), payload) return values with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.settimeout(3.0) s.connect((PLC_IP, PLC_PORT)) req = build_read_request("D", 100, 4) s.sendall(req) resp = s.recv(1024) print("原始响应:", resp.hex()) print("D100~D103:", parse_read_response(resp))这段代码的逻辑:先拼 SLMP 3E 帧的请求部分,副头部固定 0x5000 表示二进制码、3E 帧。网络号和 PLC 号在单机直连时填 0x00 和 0xFF 即可。IO 号 0x03FF 是固定值,站号 0x00 表示本站。定时器 0x0010 对应 4 秒,如果网络慢可以调大。命令 0x0401 是批量读,子命令 0x0000 是按字读。软元件代码 D 是 0xA8,地址用小端 4 字节。响应里第 9、10 字节是结束代码,0 表示成功,非 0 要查手册。
参数说明:head_addr是软元件起始地址,count是读取字数,注意 D 寄存器是 16 位,读 32 位数据要读两个连续 D 再拼。s.settimeout(3.0)是 socket 超时,别设太短,PLC 扫描周期长时响应会慢。
3.2 写单个和批量写:命令码与数据排列
写操作命令码是 0x1401,子命令 0x0000 按字写。批量写时,数据区按小端排列,每个字 2 字节。下面写 D200 开始的 3 个字。
def build_write_request(head_addr, values): subheader = b"\x50\x00" net_plc = b"\x00\xFF" io_station = b"\x03\xFF\x00" timer = b"\x10\x00" command = b"\x01\x14" # 0x1401 小端 subcommand = b"\x00\x00" device = b"\xA8" + struct.pack("<I", head_addr) length = struct.pack("<H", len(values)) payload = b"".join(struct.pack("<H", v) for v in values) return subheader + net_plc + io_station + timer + command + subcommand + device + length + payload # 写入 D200=1234, D201=5678, D202=9012 req = build_write_request(200, [1234, 5678, 9012]) s.sendall(req) resp = s.recv(1024) # 写响应没有数据区,检查结束代码即可 end_code = struct.unpack("<H", resp[9:11])[0] print("写入结束码:", end_code)写操作的响应比读短,没有数据区,只回结束代码。注意批量写时length是字数,不是字节数,别搞混。如果写位软元件(M、Y),子命令要改成 0x0001,数据区按位打包,每字节 2 个点,这个后面避坑章会细说。
3.3 读位软元件:M、X、Y 的地址换算
位软元件读的时候,子命令用 0x0001,返回的数据是每字节 2 个点,高位在前。比如读 M100 开始的 8 个点,返回 1 字节,bit7 对应 M100,bit6 对应 M101,以此类推。地址换算上,M100 的地址就是 100,X 和 Y 是十六进制地址,比如 X10 要写成 0x10。下面读 M100 开始的 16 个点。
def build_read_bit_request(device_code, head_addr, count): subheader = b"\x50\x00" net_plc = b"\x00\xFF" io_station = b"\x03\xFF\x00" timer = b"\x10\x00" command = b"\x01\x04" subcommand = b"\x01\x00" # 按位读 device = device_code + struct.pack("<I", head_addr) length = struct.pack("<H", count) return subheader + net_plc + io_station + timer + command + subcommand + device + length # M 的代码是 0x90,读 M100 开始 16 点 req = build_read_bit_request(b"\x90", 100, 16) s.sendall(req) resp = s.recv(1024) payload = resp[11:] bits = [] for byte in payload: for i in range(7, -1, -1): bits.append((byte >> i) & 1) print("M100~M115:", bits[:16])位读取的坑在于地址进制。M、D、R 这些是十进制地址,X、Y、B、W 是十六进制。如果你把 X10 当成十进制 10 去拼帧,实际访问的是 X0A,读出来全是错的。我一般会在代码里对 X/Y 做int(addr, 16)转换,避免手误。
4. 批量读写与超时:把轮询周期压到 50ms 以内
4.1 一次读 960 个字:帧长度与 MTU 的边界
FX5U 单次批量读的最大字数,二进制码下是960 字(1920 字节数据),加上帧头约 1930 字节,超过以太网 MTU 1500 就会分片。分片本身不是问题,但有些交换机或防火墙对分片包处理不好,容易丢。所以实际项目里我一般把单次读控制在480 字以内,既留余量又方便排错。
如果你要读 2000 个 D 寄存器,别一次发,分成 5 次每次 400 字,或者用循环读。下面是个分批读的封装。
def read_d_batch(sock, start, total, chunk=400): result = [] for offset in range(0, total, chunk): cnt = min(chunk, total - offset) req = build_read_request("D", start + offset, cnt) sock.sendall(req) resp = sock.recv(4096) result.extend(parse_read_response(resp)) return result # 读 D0~D1999 data = read_d_batch(s, 0, 2000) print(len(data), data[:5])chunk设 400 是个经验值,既能减少往返次数,又不会触发分片。如果你的网络环境好,可以试 960,但要在现场用ping -l 2000测一下分片是否正常。
4.2 轮询周期怎么算:扫描周期、网络延迟、处理时间
很多人问为什么轮询跑不到 50ms。算一笔账:FX5U 的扫描周期通常 1~5ms,网络往返 1~3ms,上位机解析 1ms 以内,理论上 10ms 能完成一次。但实际卡在两点:一是 PLC 的以太网处理有排队,多个连接同时请求会串行;二是上位机如果用 Python 的recv阻塞等,加上 GIL,实际周期会飘。
我的做法是:单连接、单线程轮询,每次读固定一批点,周期用time.perf_counter()测。如果要求 50ms 以内,单次读的字数别超过 200,并且把 socket 设成非阻塞或加TCP_NODELAY,减少 Nagle 算法带来的 40ms 延迟。
import time s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) while True: t0 = time.perf_counter() req = build_read_request("D", 0, 100) s.sendall(req) resp = s.recv(4096) vals = parse_read_response(resp) t1 = time.perf_counter() print(f"周期 {(t1-t0)*1000:.1f} ms, D0={vals[0]}") time.sleep(0.01)TCP_NODELAY关掉 Nagle,小包立即发,对实时性帮助明显。time.sleep(0.01)是给 PLC 留喘息,别把 CPU 跑满。
4.3 断线重连:别让一次拔网线搞崩整个采集
现场拔网线、交换机重启是常态,上位机必须能自动重连。我的习惯是包一层ensure_connection,每次读写前检查 socket 状态,异常就关掉重连,重连间隔从 1 秒开始退避到 5 秒。
def ensure_connection(sock, ip, port, retry=3): for i in range(retry): try: if sock is None: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(3.0) sock.connect((ip, port)) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # 发一个空读测试连接 sock.sendall(build_read_request("D", 0, 1)) sock.recv(256) return sock except (socket.error, OSError): if sock: sock.close() sock = None time.sleep(1 + i) raise RuntimeError("PLC 连接失败")这个函数在每次采集循环开头调用,代价是多一次读,但换来的是稳定性。注意重连后 PLC 侧可能还留着旧连接的资源,等几秒再连更稳。
5. 避坑与排查:FX5U 以太网通信的 5 个血泪教训
5.1 现象:能 ping 通但连不上 5000 端口
原因:PLC 的以太网参数里,SLMP 连接没使能,或者「对象设备连接配置」里没添加 SLMP 设备。FX5U 默认可能只开了 Socket 通信,没开 SLMP。
解决:GX Works3 里进「模块参数」→「以太网端口」→「对象设备连接配置」,添加一个「SLMP」设备,指定端口号 5000,协议选 TCP。写完参数要断电重启或复位 PLC 才生效。这个坑我踩过,ping 通不代表协议开了。
5.2 现象:读回来的数据全是 0 或乱码
原因:软元件代码填错,或者地址进制搞混。比如把 D 的代码 0xA8 写成了 0xA9(那是 R 寄存器),或者 X10 按十进制 10 去拼帧。
解决:对照三菱手册的软元件代码表,D=0xA8、M=0x90、X=0x9C、Y=0x9D、R=0xAF。X/Y/B/W 用十六进制地址,其余用十进制。调试时先读一个已知值的 D 寄存器,确认通了再批量。
5.3 现象:批量读超过 100 个字就超时
原因:帧太长触发 MTU 分片,或者 PLC 的以太网缓冲区小,处理不过来。也有可能是 socket 超时设太短。
解决:把单次读的字数降到 400 以内,socket 超时设 3~5 秒。如果还超时,抓包看是不是分片丢了,换台交换机试试。另外检查 PLC 的「以太网端口」设置里有没有开「保持连接」之类的选项。
5.4 现象:写 M 点没反应,读回来还是旧值
原因:位写的子命令用错了,或者数据区没按位打包。位写子命令是 0x0001,数据区每字节 2 个点,不是每个点一个字节。
解决:写位时把要写的点按字节拼,比如写 M100=1、M101=0,拼成 0b10xxxxxx 这种。或者干脆用字写命令,把 M 点所在的字读出来改位再写回,虽然多一次往返但不容易错。
5.5 现象:多个上位机同时连,后来者连不上
原因:FX5U 的连接数上限是 8 个,SLMP 占 1 个,如果每个上位机开多个连接,很快占满。
解决:每个上位机只维护一个长连接,所有读写串行化。如果确实需要多客户端,用一台中间服务器统一采集,再分发给其他客户端。别让每个客户端直连 PLC。
6. 进阶:把 FX5U 数据接进时序库的轻量方案
前面讲的都是单次读写,实际项目里更常见的是持续采集并存储。我一般用 Python 起一个采集进程,按固定周期读一批点,写进 SQLite 或 InfluxDB 这类轻量时序库,再暴露一个 HTTP 接口给上层。这样上位机不用直连 PLC,也方便做历史查询。
具体做法:定义一个点表,用 JSON 描述软元件和地址,采集器读配置后循环批量读,每轮把带时间戳的数据写库。点表长这样:
{ "plc": {"ip": "192.168.1.10", "port": 5000}, "points": [ {"name": "温度", "device": "D", "addr": 100, "type": "int16"}, {"name": "压力", "device": "D", "addr": 101, "type": "int16"}, {"name": "运行", "device": "M", "addr": 50, "type": "bit"} ], "interval_ms": 100 }采集器按device分组,同一类的连续地址合并成一次批量读,减少往返。写库用sqlite3的executemany,100ms 一轮 1000 个点毫无压力。验证方法很简单:手动改一个 D 寄存器的值,看库里 200ms 内有没有更新,有就说明链路通了。
一个我常犯的错:点表里地址没排序,导致批量读时反复跳地址,效率低。后来养成习惯,加载配置后先按device + addr排序,再合并连续段。这个习惯让采集周期从 200ms 降到了 60ms。希望帮到你。
本文还有配套的精品资源,点击获取