简介:《三菱MC协议配置和使用简析》面向需要让外部设备与三菱PLC进行数据交互的自动化工程师、设备调试人员及入门学习者,重点解决MC协议参数配置、3E帧报文构造与读写调试问题。内容从MC协议开放PLC内部寄存器的目的切入,介绍TCP/IP下一发一收的通信方式、3E帧二进制格式的特点,以及PLC内置以太网端口的设置流程;并以向D7000写入H000C、连续读取5个软元件为例,拆解发送帧与响应帧,补充软元件代码对应、指令及子指令列表和错误帧排查思路,便于对照实际项目完成通信验证。
资源包共1个docx文件,约1.12MB,以图文说明和示例帧解析为主,适合作为学习手册与现场配置参考。目前已有4092人学习,读者可据此掌握报文构造、参数设置和错误定位方法,建立从配置到读写验证的完整认识。
1. MC 协议:PLC 把寄存器开放给外部设备,却不肯替你记住任何事
第一次用抓包工具看 MC 协议的通信,很多人会以为抓错了:一次读请求只有 21 个字节,响应帧同样短得不像话,没有握手协商、没有会话 ID、没有心跳保活。可就是这几行十六进制,能让上位机直接读写 PLC 内部的 D 寄存器、M 继电器、W 链接寄存器。MC 协议要解决的问题很朴素——把 PLC 的软元件空间变成一张能用字节寻址的表,让外部设备不写一行 PLC 程序就能拿到现场数据。
值得搞懂的地方在于它的"不负责":PLC 收到一帧就回一帧,不重传、不排队、不记住上一次是谁问的。帧格式错了,它不会沉默,而是回一个带结束代码的短帧告诉你错在哪;帧格式对了,它也只回"写成功"或一串原始数据。换句话说,通信的复杂度几乎全部被推给了上位机。读懂这 21 个字节,等于读懂了整条链路的契约。
2. 3E 帧的二进制结构:23 个字节里每个位置都不能错
MC 协议有 3E、3C、4C、4E 几种帧格式,其中 3E 是最常见的一种,也是绝大多数上位机对接场景的默认选择。3E 帧又分二进制和 ASCII 两种表达,传的内容完全一致,只是形式不同——二进制一帧 21~23 字节,ASCII 要把每个字节拆成两个字符,长度差不多翻倍,还要额外做字符与数值的来回转换。所以除非要往串口或只支持文本的中间件上凑,二进制是更划算的选择。
先看一个完整的写入帧,把 D7000 写成 H000C:
50 00 | 00 FF FF 03 00 | 0E 00 | 10 00 | 01 14 | 00 00 | 58 1B 00 | A8 | 01 00 | 0C 00PLC 回的响应帧只有 11 个字节:
D0 00 | 00 FF FF 03 00 | 02 00 | 00 002.1 副头部与访问通路径:50 00 00 FF FF 03 00到底在说什么
帧开头的两字节叫副头部,50 00表示"3E 帧、二进制、请求方向",响应帧对应的是D0 00。这个字段最大的作用是给解析器做一次廉价的类型断言——收到以D0开头的字节流,你就知道这是响应而不是别的什么东西,后面所有的偏移量才有意义。
紧接着的 5 个字节00 FF FF 03 00是访问通路径,拆开是四段:网络号00(自身网络)、PLC 号FF(自身站)、请求目标模块 I/O 号FF 03(小端,即 0x03FF,指本站 CPU)、请求目标模块站号00。直连一台 PLC 时这 5 个字节永远是这一组常量,很多人直接硬编码,倒也不算错;但只要中间挂了网络模块或者要走多层网络,这 5 个字节就必须按实际拓扑改,硬编码的代码会在换现场时集体翻车。
再往后是请求数据长度0E 00,也就是十进制的 14。这里是最容易出错的地方:它统计的是"监视定时器"到帧尾的字节数,不包括副头部、不包括访问通路径、也不包括它自己。手算一下就能对上——监视定时器 2 字节 + 指令 2 字节 + 子指令 2 字节 + 起始地址 3 字节 + 软元件代码 1 字节 + 点数 2 字节 + 数据 2 字节,正好 14。你要是把副头部和通路径也算进去,PLC 会按错误的长度去截后面的字节,结果要么是结束代码报错,要么是"写成功"了但数据落在了一个莫名其妙的位置。
监视定时器10 00是 0x0010,单位 250 毫秒,也就是 4 秒,这是手册里的常用默认值。它约束的是 PLC 侧等待一帧剩余字节的时间,不是你上位机的读超时——这两个超时是两回事,后面第 5 章会专门说。
2.2 软元件代码与三字节地址:58 1B 00为什么不是00 1B 58
起始软元件号占 3 个字节,58 1B 00小端还原成 0x001B58,十进制正好 7000。这里有一个血泪经验:除了这 3 个字节的地址,帧里所有多字节数值都是低位字节在前,0E 00是 14 不是 3584,10 00是 16 不是 4096,0C 00是 12 不是 3072。写封装的时候如果混用了大端和小端,症状往往不是报错,而是"数据能读能写,但值全错",排查起来非常费劲。
地址后面紧跟 1 个字节的软元件代码。D 寄存器在二进制模式下是A8,这个值不是随便定的,而是手册里一张固定的对照表。常见的几个如下表,注意这张表在不同机型、不同帧格式下并不完全一样,换机型必须回查手册:
| 软元件 | 二进制代码 | ASCII 模式写法 | 说明 |
|---|---|---|---|
| D 数据寄存器 | A8 | D | 最常用,16 位字单位 |
| M 内部继电器 | 90 | M | 位单位,读写按位算 |
| X 输入继电器 | 9C | X | 十六进制编号,别按十进制算 |
| Y 输出继电器 | 9D | Y | 同上 |
| B 链接继电器 | A0 | B | 位单位 |
| W 链接寄存器 | B4 | W | 字单位 |
| R 文件寄存器 | AF | R | 块切换机型要注意 |
| ZR 文件寄存器 | B0 | ZR | 扩展地址空间 |
| SM 特殊继电器 | 91 | SM | 位单位 |
| SD 特殊寄存器 | A9 | SD | 位单位访问会报错 |
X、Y 这类软元件的编号是十六进制的,这一点非常容易被忽略。你手上的点位表写的是 X10,实际地址就是 0x10 而不是十进制的 10;如果直接拿十进制数去做to_bytes,读回来的就是隔壁的点。位单位访问还有一个细节:子指令要用0001,每个点位占 2 个字节,10 00表示 ON,00 00表示 OFF,而不是压缩成 1 bit。
2.3 把帧拼出来的代码长什么样
理解了字段,拼帧就是纯粹的字节搬运。下面这段 Python 用struct生成一个批量写请求,注意长度字段是在 payload 拼完之后才算的:
import struct def build_batch_write_word(device_no: int, device_code: int, values: list[int], timer: int = 0x0010) -> bytes: # 访问通路径:网络号 00 + PLC 号 FF + 模块 IO 号 03FF(小端) + 站号 00 route = bytes([0x00, 0xFF, 0xFF, 0x03, 0x00]) # 软元件起始编号固定 3 字节,低位在前 addr = device_no.to_bytes(3, "little") # 字单位写入:每个点 2 字节,按有符号短整型打包,负数也能正确表达 data = b"".join(struct.pack("<h", v) for v in values) payload = ( struct.pack("<H", timer) # 监视定时器,单位 250ms + struct.pack("<H", 0x1401) # 指令:批量写 + struct.pack("<H", 0x0000) # 子指令:字单位 + addr + bytes([device_code]) # 软元件代码,例如 D -> 0xA8 + struct.pack("<H", len(values)) + data ) # 请求数据长度只统计 payload,不含副头部、通路径和长度字段本身 return b"\x50\x00" + route + struct.pack("<H", len(payload)) + payload响应侧同样简单,但要先做长度校验再取数据,否则半包会让解析器读到上一次残留的字节:
def parse_response(buf: bytes) -> tuple[int, bytes]: if len(buf) < 11 or buf[0] != 0xD0: raise ValueError(f"不是 3E 二进制响应帧: {buf[:4].hex(' ')}") # 响应数据长度从"结束代码"开始算 data_len = int.from_bytes(buf[7:9], "little") if len(buf) < 9 + data_len: raise ValueError("半包,继续读 socket") end_code = int.from_bytes(buf[9:11], "little") # 0x0000 表示正常 body = buf[11:9 + data_len] return end_code, bodyparse_response里有两个细节值得单独说。第一,0xD0这个断言看着多余,实际能挡掉一大类问题——比如上位机误开了 ASCII 模式却发二进制帧,PLC 的回帧首字节不会是D0,这时候你会得到一句明确的报错,而不是拿着乱码去猜结束代码。第二,data_len是从结束代码开始统计的,所以读操作返回 5 个字时,这个值是 12(结束代码 2 + 数据 10),而不是 10。
3. 批量读与时序:为什么读 5 个点比读 5 次快一个数量级
掌握了单点读写,接下来要面对的是真实场景——上位机通常要周期性采集几十上百个寄存器。这时候"一次读一个"和"一次读一批"的差距,远不止请求数量的倍数关系。
3.1 一回一收模型下,请求次数就是延迟
MC 协议是严格的一发一收:外部设备主动发一帧,PLC 回一帧,同一连接上不能流水线。PLC 侧的通信处理不是在中断里立刻响应的,而是挂在扫描周期的通信服务里做,所以一次请求的往返时间实际上是"网络 RTT + 等待到通信服务 + 处理时间",经常落在几毫秒到几十毫秒之间。读 100 个寄存器,如果一个个读,100 次往返就是几百毫秒到几秒,采集周期根本压不下来;改成一次批量读,请求数从 100 降到 1,时间直接掉到一次往返。
批量读的帧结构和写法几乎一样,只是指令换成0x0401,子指令0x0000表示字单位,点数写你要读的个数:
50 00 00 FF FF 03 00 0C 00 10 00 01 04 00 00 58 1B 00 A8 05 00请求数据长度是0C 00,也就是 12——比写入少了 2 字节的写数据。PLC 的响应是:
D0 00 00 FF FF 03 00 0C 00 00 00 | 0C 00 00 00 00 00 00 00 00 00结束代码00 00之后跟着 10 个字节,正好对应 D7000~D7004 五个字,第一个字0C 00就是上一节写进去的 12。这条链路走通之后你会发现,读写其实是同一套骨架,只有指令、子指令和数据区不同。
点数不是无限的。常见的上限是字单位 960 点、位单位 7168 点,具体数值随帧格式和机型手册变化,超过上限 PLC 会直接回错误码而不是截断。实际工程里我更倾向把一批控制在几十到两百点之间:一次要太多,单帧变长,出错重传的代价跟着变大,而且响应数据要整体解析完才能用,灵活性反而变差。
3.2 监视定时器不等于你的读超时
很多人第一次踩的坑是把监视定时器当成读超时来用,设成10 00就以为 socket 会在 4 秒后放弃。这两者的作用域完全不同:监视定时器约束的是 PLC 侧"等这一帧剩余字节"的耐心,超过它 PLC 会丢弃并可能断开连接;而你的socket.recv如果没有设settimeout,会一直阻塞下去。一个常见的实现思路是分三层设置超时——socket 层设成 2~3 秒,应用层设成比 socket 略长,监视定时器保持默认 4 秒,让 PLC 侧总是比你更"宽容",这样报错信息落在你这边更容易定位。
顺带说一个容易忽略的点:0x0000在某些手册里表示不做监视、一直等,这在上位机异常退出时会把 PLC 的连接资源一直占着。除非有特殊理由,不要设 0。
重试也不是无脑加。读请求是幂等的,失败可以直接重发;写请求就没这么幸运了——响应丢了但你不知道 PLC 到底执行没执行,盲目重发在"加 1"这类语义上会写两次。稳妥的做法是写操作失败后先读一次目标软元件核对当前值,而不是直接重发。这也是为什么很多成熟的采集程序会把写操作单独放进一条低优先级通道。
3.3 ASCII 与二进制:省下的不只是带宽
ASCII 模式的帧是把每个字节写成两个十六进制字符,比如50 00变成字符5、0、0、0。同样一次读 5 个字的请求,二进制 21 字节,ASCII 要 40 多个字符,长度翻倍。但真正拉开差距的不是这几十个字节——在百兆链路上这点带宽可以忽略——而是转换开销和出错面。
ASCII 模式下,起始地址是 6 位字符,点数是 4 位字符,数据区是每点 4 位字符,全部要在字符串和数值之间来回转换;一旦某一位写错,比如把点数0005写成0050,PLC 不会觉得帧有问题,它会老老实实读 50 个点回来,然后你在解析时越界。二进制模式下数值就是数值,05 00就是 5,长度字段对不上会立刻表现为截断错误。所以除了必须兼容老设备的场合,二进制是更省心的选择。
剩下 3C、4C、4E 几种格式,差别集中在两点。4E 在访问通路径之后多插了 2 个字节的序列号,PLC 会把它原样抄回响应帧,这样多线程或多请求并发时可以把响应和请求对上号——3E 帧没有这个字段,同一个连接上只要出现并发,响应就可能被错误地分配给另一个请求。3C、4C 是更早的纯 ASCII 格式,帧里带固定的文本头和校验字段,解析要按字符切,新项目基本不会再选。至于副头部,3E 二进制是50 00/D0 00,4E 二进制是54 00/D4 00,靠这两个字节就能快速判断对面回的是哪种帧。
4. PLC 侧只需要点几下:参数配置背后的通信服务
从外部看,MC 协议像是 PLC 主动开放了一个服务端口;从 PLC 内部看,它其实是在以太网端口上挂了一个"通信服务",由参数决定这个服务听哪个端口、说哪种帧格式、允不允许改数据。理解这一层,配置界面上那几个勾选项就不再是玄学。
4.1 内置以太网端口与网络模块:两个入口,同一套服务
如果 PLC 自带网口,配置入口在工程栏的 PLC 参数里,双击后选内置以太网端口设置,先把 IP 地址、子网掩码填好,再设置通信数据代码(也就是二进制还是 ASCII)。如果 PLC 本身没有网卡,就要在的网络参数里选对应的以太网模块,配置项大同小异,只是多了一层模块的站号和起始 I/O 号要对应。
这两条路径的本质是一样的:都是在一块以太网硬件上开一个通信服务,然后把软元件空间暴露给它。所以外部代码里那 5 个字节的访问通路径才需要区分——走内置端口时目标模块 I/O 号是03FF(本站 CPU),走独立的网络模块时可能要换成模块自己的 I/O 号。这也是"同一份代码在 A 现场能跑、B 现场连不上"的常见原因,八成是通路径没跟着拓扑改。
4.2 打开设置:协议、端口、以及那个必须勾的复选框
内置以太网端口设置里有一个"打开设置"入口,进去之后第一栏要选 TCP 协议、再选 MC 协议,端口号自己定,常见习惯是用 4999 或者 5000 这类不冲突的值。这一步决定了 PLC 在哪个端口上监听 MC 帧——端口号填错,表现出来就是 TCP 连接直接被拒,连错误帧都收不到。
同一个界面里还有一个容易被漏掉的勾选项:允许 RUN 中写入。PLC 处于运行状态时,默认是拒绝外部写入软元件的,这是保护机制。你不勾它,读操作一切正常,写操作会稳定返回错误码,而且因为读能通,很多人会误以为是帧格式问题,白白排查半天。如果业务上确实需要在运行中下发参数或配方,这个勾必须打上;如果只是采集,那就别勾,少一个误写的风险。
配置改完记得把参数烧写到 PLC 里,在线修改而不烧写,掉电重启后会回到旧配置。这一条听起来像废话,但"配置明明改了却不生效"的现场问题里,相当一部分就是忘了这一步。
4.3 连接数、扫描周期与响应延迟
PLC 的以太网端口能同时维持的 TCP 连接数是有限的,常见是 8 路左右,具体看机型和模块。这个数字直接决定上位机能开几个连接——如果你用了连接池,池子上限要小于它,否则第 9 个连接会被拒,而且拒绝的方式可能是静默丢弃而不是回 RST,表现出来就是"偶尔超时"。
另一个绕不开的约束是扫描周期。MC 协议的通信处理挂在扫描周期的通信服务里,所以扫描周期越长,响应延迟的抖动越大。一个扫描周期 20ms 的程序,你发两次读请求,间隔再短,也可能被排到相邻两个周期里处理,实际耗时是 40ms 而不是 20ms。上位机做采集周期规划时,要把这个量级算进去,别指望做到毫秒级同步——那不是 MC 协议擅长的场景。
5. 排查与避坑:错误帧、超时和写不进去的现场记录
MC 协议的调试之所以让人头疼,是因为它给了你很多"看起来成功"的假象。下面这几条是从实际项目里攒下来的,每条都按现象、原因、解决三段来说。
5.1 五个高频翻车现场
现象一:响应结束代码是00 00,读回来的值却永远是旧的。原因通常是地址算错但没报错——比如把十进制 7000 直接按大端写成了00 1B 58,PLC 把它当成另一个合法地址照读不误,你拿到的是隔壁寄存器的值。解决方式是把拼帧的地址单独打日志,和device_no.to_bytes(3, "little").hex()对照一遍,X、Y 这类十六进制编号的软元件尤其要确认点位表本身是几进制。
现象二:TCP 能连上,一发帧就没响应或者立刻断开。十有八九是帧格式和端口设置对不上——端口设成了 ASCII,你发的是二进制。PLC 按字符去解析一堆不可见字节,解析失败后直接断开连接,连错误帧都懒得回。解决的判断依据很直接:抓包看回帧首字节是不是D0,不是就说明模式错了。顺便检查请求数据长度的算法,把通路径算进去也会造成同样的症状。
现象三:读正常,写稳定报错。先看是不是没勾"允许 RUN 中写入",这是最常见的原因。其次看软元件本身——特殊寄存器 SD、特殊继电器 SM 里有一部分是系统占用的只读区,写它们必然报错;再就是点数超限或者目标地址超出了该机型的软元件范围。这三类错误在结束代码里是能区分的,把错误码拿去查对应机型手册的错误描述表,比盲改帧格式快得多。
现象四:多线程采集时,偶尔会读到别的线程请求的数据。这是 3E 帧没有序列号带来的结构性问题。同一个 TCP 连接上如果两个线程同时发请求,PLC 按到达顺序回两帧,你的两个线程谁先recv谁就拿走第一帧,对不上号。解决只有两条路:给连接加锁做严格串行,或者每个线程一条独立连接,再或者换成 4E 帧用序列号配对。我一般选加锁,连接数紧张的时候更稳。
现象五:跑几个小时之后开始大面积超时,重启程序又好了。典型的连接管理问题。要么是每次请求都新建 TCP 连接,主动关闭的一端堆了一地 TIME_WAIT,本地端口耗尽;要么是长连接没有任何保活,中间的网络设备把空闲连接悄悄清了,上位机还以为连着。解决是长连接加应用层心跳(周期性读一个固定寄存器即可),socket 上开SO_KEEPALIVE,并且在捕获到异常时彻底关闭重连,而不是在旧 socket 上反复重试。
5.2 把黑匣子变成可读的字节数组
排查 MC 协议问题时,最有效的手段永远是打印完整的十六进制。请求发出去之前打一次,响应收全之后打一次,两边一对照,绝大多数问题当场现形:
def send_and_log(sock, frame: bytes) -> bytes: print("TX:", frame.hex(" ")) # 发出去的完整帧 sock.sendall(frame) head = sock.recv(9) # 先读固定 9 字节拿到长度 if len(head) < 9: raise IOError("响应头不完整") data_len = int.from_bytes(head[7:9], "little") body = b"" while len(body) < data_len: # 再按长度补齐,避免半包 chunk = sock.recv(data_len - len(body)) if not chunk: raise IOError("连接被对端关闭") body += chunk resp = head + body print("RX:", resp.hex(" ")) # 打印出来和 TX 逐字段对照 return resp这段代码里有两个工程上必须做的动作。一是先读 9 字节的固定头再按长度补齐,直接用一次recv(1024)在真实网络下迟早会遇到半包——PLC 回得快的时候两段数据可能分两个 TCP 段到达,解析器读到半个浮点数就会算出离谱的值。二是异常时彻底关闭连接重来,不要在状态不明的 socket 上继续发帧。
还有一个进阶的自检手段:MC 协议里有自测试(回环)指令0x0619,PLC 会把收到的数据原样回给你。这条指令不碰任何软元件,用来验证"网络通不通、帧格式对不对、长度字段算没算对"非常干净,能把协议层的问题和业务层的问题彻底分开。链路调通之前我一般都会先跑一遍回环。
6. 设计取舍:为什么 MC 协议不肯替你记住任何事
回头看这套协议,最值得琢磨的不是字段怎么排,而是它为什么如此克制——没有会话、没有序列号(3E)、不重传、不推送,连"上一次请求的上下文"都不保留。这种设计不是偷懒,而是把不确定性挡在了 PLC 外面:PLC 的通信服务跑在扫描周期里,任何一个需要维护状态的机制都会挤占扫描时间,而扫描时间的抖动会直接反映到被控对象的动作上。对一个工业控制器来说,几毫秒的通信便利远不如一个稳定的扫描周期值钱。所以它选择了最笨也最确定的模型:收到一帧、当场解释、回一帧、忘掉一切。
代价全部落在上位机这边。串行化、超时分层、重试的幂等性、连接保活、异常后的状态重建,这些都要你自己实现。我的习惯是把采集和写入拆成两条通道:采集用一条长连接高频批量读,失败随便重试;写入用另一条连接低频执行,失败先回读核对再决定是否重发。两条通道各自加锁,互不阻塞,这样一次写入卡住不会把整条采集链路拖死。
帧格式的选择上也是同样的取舍逻辑。3E 够简单,但并发要靠加锁;4E 多了序列号,能安全并发,代价是帧头多 2 个字节、解析多一层配对逻辑。点数也不是越大越好——单帧越长,出错重传的损失越大,而且整帧数据必须收全才能用。我在点数上一般取一个折中:单次批量控制在 100 点上下,一帧数据几百字节,既能把请求数降下来,又不至于一次失败就丢掉太多。
验证方法其实很朴素:先跑回环指令确认协议层没问题,再用一读一写的最小帧确认真实数据通路,最后才上批量。这个顺序别跳,跳过回环直接调业务帧,出了问题你分不清是网络、帧格式还是软元件地址的锅。
有一次在一个现场,采集程序连续跑了两天没问题,第三天开始随机超时,重启就好。当时怀疑过 PLC、怀疑过交换机、甚至怀疑过网线,最后抓包才发现是长连接空闲被中间设备清了,而上位机在旧 socket 上一直重试。从那以后我每次写这类对接代码,都会强制走一遍"心跳 + 异常必重建连接"的检查,不管当时看着多稳定。希望这篇拆解能帮到你,少走一次这样的弯路。
本文还有配套的精品资源,点击获取