简介:三菱MC协议配置和使用简析文档面向PLC自动化初学者,以及需要联调三菱PLC的工程师,聚焦MC协议开放内部寄存器、实现外部设备读写寄存器的配置方法与通信细节。文档围绕TCP/IP下的一发一收模式展开,说明3E帧与二进制传输的帧结构、PLC内置以太网参数设置、D7000读写实例、软元件代码对照、指令子指令列表及错误帧排查思路,可帮助读者理解报文含义并完成上位机与PLC的联调。资源为单个docx文件,压缩包约1.12MB,内容以图文步骤和实例解析为主,便于按章节查阅。已有4092人学习下载,适合作为入门参考与现场调试时的速查材料。
1. MC协议的真面目:不是通信协议,而是一张远程内存读写指令表
给 PLC 发一串 22 字节的十六进制数,某个数据寄存器里就会多出一个值,整个过程 PLC 没有执行一行用户程序,也没有为这次通信写过任何功能块。MC协议做的就是把 PLC 内部的软元件存储区开放成一块可以被外部设备寻址的内存。很多人第一次接触它,会把它归类成“又一个工业通信协议”,但拆开帧来看,它更像一套极简的远程读写指令:没有会话、没有认证、没有重传,一帧请求换一帧响应,成败只看一个两字节的结束代码。这个认知一旦建立,后面所有字节排布都不再需要硬背,而是能一步步推出来。也正因为协议本身这么简单、这么轻量,出问题时中间没有任何可归罪的层次,只能老老实实看字节,而这恰恰是它值得彻底搞懂的原因。
2. 3E帧的报文骨架:一发一收的帧里每个字节在干什么
3E 帧二进制的结构分两部分:前 9 个字节是固定到几乎不用改的“寻址头”,后面是从“监视定时器”到帧尾的“请求数据体”。头部负责告诉 PLC 这帧发往哪个网络、哪个站、哪个模块,数据体负责告诉 PLC 要做什么、对谁做、做多少。把这两段分清楚,后面算长度、拼地址时就不会乱。
2.1 副头部与寻址头:前九个字节为什么基本不用改
以写 D7000 的请求为例:50 00 00 FF FF 03 00 0E 00 ...。前 4 个字节50 00 00 FF里,50 00是副头部(小端存储的 0x0050,表示这是一帧 3E 请求),00是网络号,FF是 PLC 号(FF 表示当前站)。接着的FF 03是请求目标模块的 I/O 编号,小端读出来是 0x03FF,也就是以太网模块的编号;00是目标模块站号。
这 9 个字节在单机直连的以太网场景下几乎是常量,可以直接照抄。但有一个容易出错的地方:FF 03是两字节的小端表示,不是两个独立的字节。有人图省事写成03 FF,PLC 会认为你在寻址 I/O 编号为 0xFF03 的模块,直接返回错误码,或者根本不理你。多级网络(比如以太网后面再挂一层现场网络)时,网络号和站号才有实际意义,普通上位机读写方根本用不到。
| 偏移 | 字节 | 字段 | 典型值 |
|---|---|---|---|
| 0-1 | 2 | 副头部 | 50 00 |
| 2 | 1 | 网络号 | 00 |
| 3 | 1 | PLC 号 | FF |
| 4-5 | 2 | 目标模块 I/O 编号 | FF 03 |
| 6 | 1 | 目标模块站号 | 00 |
| 7-8 | 2 | 请求数据长度 | 见 2.2 |
| 9 起 | n | 请求数据体 | 见第 4 章 |
2.2 请求数据长度:那个最容易算错的计数字段
0E 00这个小端值是 14,它统计的是从第 9 个字节开始的整个请求数据体的字节数,不包括前面 9 个字节。拆开看:监视定时器 2 字节、指令 2 字节、子指令 2 字节、起始软元件号 3 字节、软元件代码 1 字节、点数 2 字节、写入数据 2 字节,合计 14。
这个字段是新手最容易出错的地方。写错了会怎样?如果比实际大,PLC 会一直等剩下的字节,直到监视定时器超时才丢弃,表现出来就是“发出去没反应”;如果比实际小,PLC 会按这个长度截断,剩下的字节被当成下一帧的开头,连接直接乱掉。还有一种情况是长度算对了但漏掉写入数据本身,比如写一个字却把长度记成 12,PLC 收到的数据区是空的,会返回长度相关的错误码。
计算方法是固定的:请求数据体有多大就填多大,千万别把头部算进去。调试时把整个帧的字节数减 9,再和长度字段对一下,是最快的自查手段。
2.3 响应帧的镜像结构:结束代码才是唯一可信的回执
写操作收到的响应是D0 00 00 FF FF 03 00 02 00 00 00。结构完全是请求帧的镜像:副头部从 0x0050 变成 0x00D0,即D0 00,网络号、PLC 号、I/O 编号、站号原样返回,长度字段02 00表示响应数据体只有 2 字节,也就是结束代码00 00,表示写入正常。
读操作的响应长度不一样。读 5 个寄存器的响应是D0 00 00 FF FF 03 00 0C 00 00 00 0C 00 ...,这里的0C 00是 12,等于结束代码 2 字节加上 5 个字共 10 字节。也就是说,响应长度字段的结构永远是“结束代码 + 数据长度”,读的时候先减 2 才是真正的数据字节数。
重点在结束代码。它非 0 就代表出错,后面的数据一律不可信,必须去查对应 PLC 手册的错误码说明。很多上位机程序只判断“收到了字节”,把错误帧里的内容当数据取走,结果读出来的值看起来很离谱,却一直查不出原因。这就是典型的黑匣子式翻车:程序跑得很顺,数据全是错的。
3. 二进制与ASCII、3E与4E:帧格式选型背后的机制代价
帧格式一次定错,后面所有调试都会加倍。它主要体现在三个选择上:二进制还是 ASCII、用 3E 还是 4E、一发一收还是流水线。这三个选择看起来是“习惯问题”,其实是在带宽、可读性、复杂度之间做交换。
3.1 二进制与ASCII的字节数差异:为什么一帧能省一半
ASCII 帧把每个字节拆成两个可打印字符。比如写入值 0x000C,二进制帧里是0C 00两个字节,ASCII 帧里是四个字符 “000C”;起始地址 7000 在二进制里是三字节58 1B 00,ASCII 里则要写成六位十六进制 “001B58”。一帧下来,字符数差不多是二进制字节数的两倍,还要多出帧头帧尾的字符开销。
多出来的不只是带宽。ASCII 模式下上位机要做字符串到整数的转换,要处理大小写、前导零、帧内字符边界;二进制模式下内存里的值直接附在帧尾发出去,收到也直接按小端还原成整数,中间没有任何转换环节。所以在同样的 PLC 和网络下,二进制方式的吞吐量明显更高,这也是协议手册推荐二进制的原因。
代价是可读性。二进制帧在抓包工具里就是一堆十六进制,不看字段表根本不知道在干什么。所以调试期的做法通常是:用代码把帧结构打出来,逐字段标注,而不是直接盯着字节流猜。
注意:PLC 侧的“通信数据代码设置”必须和上位机一致。选了二进制却发 ASCII 帧,PLC 解析不了,表现往往是 TCP 连接建起来了、发出去毫无回应,连错误帧都不给你,非常容易误判成网络问题。
3.2 3C、4C、3E、4E的差异:校验和与序列号各自的用途
这四种帧格式并不是新旧替代关系,而是针对不同链路和不同并发模型设计的。
3C 帧是 ASCII 格式,帧内带和校验(SUM),主要在串口和较早的设备上使用;4C 帧可以理解成 3C 的二进制版本,保留帧内校验;3E 帧是目前以太网场景下的主流,它去掉了帧内校验和,把完整性交给 TCP 本身;4E 帧在 3E 的基础上,在副头部后面多加了两个字节的序列号,响应会把序列号原样带回。
序列号是理解 4E 的关键。3E 是一发一收:发一帧,等一帧回来,再发下一帧,每一次都要付一次网络往返时间。要读 200 个寄存器,如果一帧只读 20 个,就要往返 10 次。4E 的序列号允许上位机连续发出多个请求,靠序列号把响应和请求配对,不需要串行等待,这就是“同时请求”能力。协议手册里把这种用法叫流水线,本质上是把等待时间重叠掉。
但绝大多数现场用不上 4E。PLC 侧默认配置通常就是 3E,点数够多的话一帧读几百个字,往返次数本来就少。真正需要 4E 的是高频轮询场景,比如几十毫秒级的数据采集。
3.3 一发一收模型留下的空白:超时、重连、心跳全靠自己
MC协议本身很轻:没有心跳帧、没有连接状态查询、没有重传机制,甚至连“这帧是谁发的”都不关心。它假设底层 TCP 已经把所有可靠性问题处理掉了。
现实里 TCP 只保证连接的可靠性,不保证连接一定存在。PLC 断电重启、网线被拔掉再插回、交换机重启,上位机这边 socket 可能很长时间还处于“已连接”状态,send 返回成功,recv 永远等不到数据。这是工业现场最常见的假连接,表现出来就是“程序运行正常,但数据不更新了”。
协议层没有后悔药,只能在应用层补:给每次请求设超时,超时若干次就主动断开重连。这个逻辑不能省,也不能指望某个库替你做好。
4. 指令与软元件寻址:01 04和14 01背后是怎样的读写模型
3E 帧的数据体里,真正决定“做什么”的只有四样东西:指令码、子指令、软元件代码、起始地址和点数。理解了这四样怎么组合,就能自己拼出任意读写请求,不用再照抄别人的帧。
4.1 指令码与子指令:字单位和位单位是两条路
指令码在帧里以小端存放。批量读是 0x0401,帧里写成01 04;批量写是 0x1401,帧里写成01 14。这两个是最常用的,除此之外还有随机读 0x0403、随机写 0x1402,以及多点批量读写等,区别在于寻址方式:批量读写只给一个起始地址和一个点数,地址必须连续;随机读写的报文里每个软元件都自带地址和代码,适合读一堆不连续的点。
子指令决定数据粒度。00 00是按字(16 位)处理,00 01是按位处理。这里有个硬性约束:位软元件(X、Y、M、B 这类)必须用位子指令,字软元件(D、W、R 这类)必须用字子指令。混着用不会“自动转换”,而是直接返回错误。有人想读 M100 的状态却用了字子指令,PLC 的回应就是结束代码非 0。
4.2 软元件代码表:A8为什么代表D寄存器
二进制模式下,每种软元件有一个单字节代码。最常用的几个:D 是 0xA8,W 是 0xB4,M 是 0x90,X 是 0x9C,Y 是 0x9D,B 是 0xA0,特殊的 SM 和 SD 一般在 0x91 和 0xA9 附近。这套代码表在不同系列、不同机型上会有差异,写代码时最好把用到的几个常量抽出来做成枚举,别散落在各处。
注意 ASCII 模式和二进制模式用的是两套完全不同的编码。ASCII 帧里软元件不是单字节代码,而是用字符表示,比如 D 寄存器写成对应的字符组合再加上 6 位十六进制地址。同一个“D7000”,二进制帧里是A8加58 1B 00,ASCII 帧里则是一串字符。如果把两套表混用,PLC 会去一个你根本没打算碰的地址上读写,而且可能不报错,这才是真正难查的问题。
| 软元件 | 二进制代码 | 单位 | 说明 |
|---|---|---|---|
| D | 0xA8 | 字 | 数据寄存器,最常用 |
| W | 0xB4 | 字 | 链接寄存器 |
| M | 0x90 | 位 | 内部继电器 |
| X | 0x9C | 位 | 输入 |
| Y | 0x9D | 位 | 输出 |
| B | 0xA0 | 位 | 链接继电器 |
4.3 从写D7000到读D7000到D7004:完整字节推演
把组帧逻辑写成代码,比背诵字节顺序可靠得多。下面这段按 3E 二进制格式拼请求帧,参数全部显式命名,方便对照手册:
import struct # 3E 帧二进制固定头:副头部 0x0050 + 网络号 + PLC号 + 模块I/O编号 + 站号 FRAME_HEAD = bytes([0x50, 0x00, 0x00, 0xFF, 0xFF, 0x03, 0x00]) def build_request(cmd: int, sub: int, dev_code: int, addr: int, points: int, data: bytes = b"", timer: int = 0x0010) -> bytes: """按 3E 帧二进制格式组一帧请求。 cmd : 指令码,0x0401 批量读 / 0x1401 批量写 sub : 子指令,0x0000 字单位 / 0x0001 位单位 dev_code : 软元件代码,D 为 0xA8 addr : 起始软元件号,如 7000 points : 点数,一次读写多少个软元件 data : 仅写操作用,已按小端打包好的数据字节 timer : CPU 监视定时器,单位 250ms,0x0010 即 4 秒 """ body = struct.pack("<H", timer) # 10 00 body += struct.pack("<H", cmd) # 1401 -> 01 14 body += struct.pack("<H", sub) # 00 00 body += addr.to_bytes(3, "little") # 7000 -> 58 1B 00,低字节在前 body += bytes([dev_code]) # A8 body += struct.pack("<H", points) # 01 00 body += data # 写操作才有的数据区 # 长度字段只统计 body,不含 7 字节固定头 + 2 字节长度字段 return FRAME_HEAD + struct.pack("<H", len(body)) + body调用build_request(0x1401, 0x0000, 0xA8, 7000, 1, struct.pack("<H", 0x000C)),得到的就是50 00 00 FF FF 03 00 0E 00 10 00 01 14 00 00 58 1B 00 A8 01 00 0C 00,共 22 字节,与手册示例逐字节一致。换成读 5 个点:build_request(0x0401, 0x0000, 0xA8, 7000, 5),得到50 00 00 FF FF 03 00 0C 00 10 00 01 04 00 00 58 1B 00 A8 05 00,长度字段从 14 变成 12,因为读请求没有数据区。
响应解析要注意两件事,一是副头部的判定,二是长度字段的口径:
def parse_response(buf: bytes) -> tuple[int, bytes]: """返回 (结束代码, 数据区)。结束代码为 0 才代表成功。""" if buf[:2] != b"\xD0\x00": raise ValueError("副头部不是 D0 00,可能收到 ASCII 帧或字节错位") length = int.from_bytes(buf[7:9], "little") # 响应数据长度 if len(buf) != 9 + length: raise ValueError(f"帧长不符:应为 {9 + length},实际 {len(buf)}") end_code = int.from_bytes(buf[9:11], "little") # 0 表示正常 return end_code, buf[11:9 + length] # 结束代码之后全是数据读 5 个点的响应是D0 00 00 FF FF 03 00 0C 00 00 00 0C 00 00 00 00 00 00 00 00 00:长度 12,结束代码00 00,数据区 10 字节按小端还原,第一个字是 0x000C,也就是上一次写入的 12,其余四个是 0。这一步能对上,说明组帧、寻址、字节序、数据区边界全部正确。
5. 避坑与排查:连接通了却读不到数,八成是这几处
现场调试绝大多数时间不是花在写代码上,而是花在“为什么没反应”上。下面这几条都是很常见的错误,每条都按现象、原因、解决三步来看。
5.1 三字节地址写成大端,PLC不报错但地址全错
现象:请求发出去有响应,结束代码却是非 0;或者更糟,写操作返回成功,但目标寄存器的值没变,反而是另一个寄存器被改了。
原因:起始软元件号是三字节字段,同样遵循低字节在前。7000 是 0x001B58,正确写法是58 1B 00。按大端写成00 1B 58,PLC 会把它解释成另一个地址,而且这个地址往往落在合法范围内,于是既不报错也不写到正确位置。
解决:不要手写这 3 个字节,一律用addr.to_bytes(3, "little")生成。写完之后打印帧,把地址段单独切出来按小端还原一次,确认等于预期值再发。
5.2 忘记勾选“允许RUN中写入”,写操作永远失败
现象:读操作一切正常,写操作返回错误码,PLC 里的值纹丝不动。
原因:PLC 侧的以太网参数里有一项允许运行中写入的开关,默认可能是不允许。不勾选时,PLC 在运行状态下会拒绝所有写请求,但读请求照常响应,所以很容易误判成“数据格式问题”。
解决:在 PLC 参数的内置以太网端口设置里勾选允许 RUN 中写入,然后把配置重新下载到 PLC 并让模块生效。参数改完不下载,等于没改。
5.3 通信数据代码不一致,TCP连上了却一帧都收不到
现象:socket 连接成功,发送返回的字节数也对,但 recv 一直超时,PLC 那边没有任何回应,抓包只看到单向数据。
原因:PLC 侧设成了 ASCII,上位机发的是二进制帧,或者反过来。PLC 解析不出副头部就直接丢弃,连错误帧都不会回,所以表现为完全没反应,而不像参数错误那样还会给个结束代码。
解决:三处必须一致,分别是 PLC 参数里的通信数据代码、上位机组帧用的编码方式、抓包工具解析时选择的编码。查的时候先看 PLC 参数界面,再打印上位机实际发出的前几个字节,两者对不上就改,不用再怀疑网络。
5.4 按整帧长度去recv,短读长读都会解析错位
现象:程序偶尔正常、偶尔报长度不符,或者读回来的数据整体偏移一个字段,重启程序又好了。
原因:TCP 是字节流,不是消息流。一次 recv 可能只收到半帧,也可能一次收到两帧连在一起。如果代码里写sock.recv(1024)然后就按整帧解析,帧短的时候会拿到残缺数据,帧长的时候会把下一帧的开头当成数据。
解决:先读固定长度,再按长度字段补齐。响应头固定 9 个字节,其中第 7、8 字节就是剩余长度:
def recv_exact(sock, n: int) -> bytes: buf = b"" while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise ConnectionError("对端关闭连接") buf += chunk return buf def recv_frame(sock) -> bytes: head = recv_exact(sock, 9) # 先拿 9 字节头 length = int.from_bytes(head[7:9], "little") # 其中含剩余长度 return head + recv_exact(sock, length) # 再按长度读完整帧这套写法对 3E 帧永远成立,因为长度字段的位置是固定的。改协议版本时,只要确认长度字段的偏移没变,这段代码就能复用。
5.5 参数改了没下载,配置只在工程里活着
现象:端口连不上,connect 直接被拒绝,PLC 其他功能正常。
原因:参数在编程软件里改完只是保存在工程中,没有下载到 PLC 的模块里,实际运行的还是旧配置。换台电脑打开同一个工程,看到的界面是新的,PLC 里跑的却是旧的,非常容易误判。
解决:改完参数后执行下载,然后让以太网模块重启或断电重上电。验证方法是换一台机器直接 telnet 那个端口,能建立连接再回来看应用层。
6. 设计取舍:为什么MC协议把复杂度都留在了上位机这一侧
回头看整个协议,会发现一个很明显的倾向:PLC 那一侧薄得几乎什么都没有。它不维护连接表,不记录谁连过、连了多久;不做重传,丢了就丢了,反正 TCP 会兜底;不主动发数据,永远是被动应答;甚至连会话号都没有,3E 帧里没有任何字段能区分“这是第几次请求”。一个几百字节的接收缓冲、一个解析循环,就能支撑起整个通信服务。
这个取舍是有道理的。PLC 的固件资源要留给扫描周期和 I/O 处理,通信模块能省一点是一点;协议越薄,不同年代、不同型号的设备就越容易实现同一套帧格式,老设备不会因为新特性被淘汰;无状态也意味着多台上位机同时连接时,PLC 不需要为每条连接维护上下文,谁发请求就服务谁,回完就忘。
代价全部转移到客户端。超时、重连、半开连接检测、帧边界切分、字节序换算、错误码翻译、软元件代码表维护,这些都得自己写。所以同一套协议,有人写出来的客户端跑几年不出问题,有人写出来的隔三差五丢数据,差别不在协议理解,而在这层“协议没管的空白”补得够不够。
3E 和 4E 的关系也是同一个思路的延伸。一发一收在低频率轮询下完全够用,协议就不加序列号;等到有人需要高并发,再加两个字节的序列号支持流水线,而不是一开始就把复杂度塞进所有设备。这种“按需加码”的设计,在工业协议里很常见,因为它要照顾的是十几年跨度的设备兼容性。
验证一套客户端是否可靠,我习惯分四层走:TCP 层能稳定连上并识别断开,这一层用 telnet 或抓包确认;收到一帧且副头部为D0 00,这一层打印原始字节;结束代码为 0,这一层把非 0 的情况全部打日志并翻译成可读描述;数据按小端还原后与 PLC 监控界面的值一致,这一层做人工比对。任何一层不过就停下来,不往下调。
从那以后我每次接一个新设备,都先用工具手拼一帧最小的读请求,把字节流确认清楚,再动手写代码,不再上来就调库然后对着超时发呆。希望帮到你。
本文还有配套的精品资源,点击获取