☰
三菱 PLC MC 协议(SLMP / QnA 兼容 3E 帧)深度解析:从帧结构到 C++/Go 双语言采集实战
2026/9/29 20:15:12 网站建设 项目流程

适用对象:需要与三菱 Q/L/FX5U 等系列 PLC 做以太网直连采集的开发者(CNC 设备直连、产线数据采集、边缘网关)。 本文以QnA 兼容 3E 帧(二进制)为主线,给出帧级协议说明与 C++/Go 双语言完整可编译示例,并整理 20+ 条实战踩坑记录。 协议细节以三菱官方《MC 协议参考手册》(Q 系列:SH-080356;FX5U/iQ-F:JY997D56201)为准,本文表项已与多个现场抓包/工业驱动实现交叉核对。


1. 背景与定位

1.1 三菱 PLC 通信方式全景

三菱 MELSEC 系列 PLC 对外通信主要有三条路线:

路线介质特点适用
编程口协议串口/USB简单但速度慢、一机一主调试、小批量
MC 协议(MELSEC Communication Protocol)串口(C24 模块)/ 以太网(内置网口 / 以太网模块)官方标准协议,速度快、帧格式公开、跨系列上位机采集、SCADA、网关(本文主线)
开放式网络(OPC UA / Modbus / PROFINET 等)以太网/现场总线通用标准,但部分机型需模块或网关转换多品牌异构集成

MC 协议是三菱官方为外部设备(上位机)访问 PLC CPU 制定的通信协议,支持 QnA 兼容 3E/3C/4C 帧、4E 帧,以及新一代 SLMP(Seamless Message Protocol)帧。以太网场景下最常见的是QnA 兼容 3E 帧(二进制,TCP 承载),这也是本文的主线;FX5U / iQ-R 等新机型在 QnA 兼容 3E 帧基础上还提供 SLMP 新帧(设备头 5 字节,见 2.5 节与坑点 #5)。

1.2 适用场景

  • CNC 设备直连采集(三菱 M80/M70、iQ-R 系列 CNC 控制器开放 MC 协议口)
  • Q/L/FX5U PLC 的 D 寄存器、M 位元件、特殊寄存器(SM/SD)实时采集
  • 边缘网关:PLC 点位 → MC 协议 → 网关 → Kafka/MQTT → 时序库

2. 协议全景:3E 帧格式详解(API 说明)

2.1 传输模型总览

  • 承载:TCP(默认端口 2000,可在 GX Works3 / GX Developer 中配置,常见 1025/4999)
  • 帧形态:无帧头、无帧尾、无校验和的裸 TCP 载荷(长度字段自带定界)
  • 请求/响应副头部不同:请求帧副头部固定 0x5000,响应帧副头部固定 0xD000(字节序见 2.4)
  • 多字节字段一律小端(LSB first):这是与网络字节序直觉最大的冲突点,也是最多人踩的坑(见坑点 #1)

2.2 请求帧字段表(QnA 兼容 3E 帧,二进制)

字段长度(字节)说明典型值
副头部2请求固定 0x50 0x00(=0x5000)50 00
网络号1目标网络,单机通常 000
PC 号1PLC 编号,通常 255FF
请求目标模块 I/O 号2CPU 模块固定 0xFF03(小端发送)FF 03
请求目标模块站号1通常 000
请求数据长度2从监视定时器字段开始到请求数据末尾的字节数(不含长度字段自身)可变
监视定时器2单位 250ms,0x0010=4000ms10 00
命令2见 2.6 命令表01 04(=0x0401)
子命令20=字单位,1=位单位00 00
软元件头4(QnA)/ 5(SLMP)设备编号 3 字节小端 + 设备代码(QnA 1 字节 / SLMP 2 字节)见 2.5
点数2读/写软元件点数0A 00(10)
数据区可变仅写命令携带:字元件每点 2 字节小端;位元件每点 1 字节(0x00/0x01)可变

请求数据长度计算示例(批量读 D100 起 10 点):监视定时器 2 + 命令 2 + 子命令 2 + 软元件头 4 + 点数 2 =12 字节,即 0x000C,发送字节 0C 00。

2.3 响应帧字段表

字段长度(字节)说明
副头部2响应固定 0xD0 0x00(=0xD000)
网络号 / PC 号 / IO 号 / 站号1+1+2+1与请求一致
响应数据长度2从结束代码字段开始到响应数据末尾的字节数(= 2 + 数据区长度)
结束代码20x0000 正常;非 0 为 PLC 错误码(见 2.9)
响应数据可变批量读字:每点 2 字节小端;批量读位:每点 1 字节(0x00/0x01)

响应帧读取流程:先读固定 9 字节头(副头 2+网络 1+PC 1+IO 2+站 1+长度 2),解析长度 L 后再读 L 字节,其中前 2 字节是结束代码、其余为数据区。

2.4 字节序规则(核心中的核心)

  • 二进制 3E 帧:所有多字节字段按LSB first(低字节在前)发送:命令 0x0401 → 字节 01 04;长度 0x000C → 0C 00;软元件代码 0x00A8 → A8;设备编号 0x0064 → 64 00 00;点数 0x000A → 0A 00。
  • 数据区:字数据 16 位小端(读回 0x1234 → 数据字节 34 12);32 位浮点/整数占 2 个字,低字在前;64 位占 4 个字。
  • ASCII 帧(4E):所有字段用 ASCII 十六进制字符表示,MSB first(字符顺序即十六进制书写顺序),且数据区字符每字 4 字符(高字节字符在前)。两种帧的字段值方向恰好相反,混用必然出错(坑点 #8)。

2.5 软元件(设备)体系与代码表

MC 协议按「软元件代码 + 软元件编号」寻址,同一编号 0 可以同时是 D0 / M0 / X0,全靠代码区分。

QnA 兼容 3E 帧常用软元件代码(二进制帧,1 字节):

软元件说明代码(十六进制)编号进制
X输入继电器0x9C视系列(Q 十六进制 / FX 八进制,见坑点 #11)
Y输出继电器0x9D同上
M内部继电器(位)0x90十进制
S步进继电器(位)0x98十进制
L锁存继电器(位)0x92十进制
F报警器(位)0x93十进制
V边沿继电器(位)0x94十进制
B链接继电器(位)0xA0十六进制
SM特殊继电器(位/字)0x91十进制
D数据寄存器(字)0xA8十进制
W链接寄存器(字)0xB4十六进制
R文件寄存器(字)0xAF十进制
ZR扩展文件寄存器(字)0xB0十六进制
Z变址寄存器(字)0xCC十进制
SD特殊寄存器(字)0xA9十进制
TN定时器当前值(字)0xC2十进制
CN计数器当前值(字)0xC5十进制
TS / TC / CS / CC定时器/计数器触点/线圈(位)0xC1/0xC0/0xC4/0xC3十进制

设备代码因 CPU 系列而异(尤其 T/C 定时器计数器、FX 系列),现场联调以对应手册与抓包为准。

设备头格式两代差异(重要):

帧类型设备头布局长度
QnA 兼容 3E 帧(Q/L 等)设备编号 3 字节小端 + 设备代码 1 字节4 字节
SLMP 新帧(FX5U / iQ-R 等)设备代码 2 字节小端 + 设备编号 3 字节小端5 字节

现场最常见两种实现都在运行:老式 QnA 3E 驱动普遍用 4 字节头,pymcprotocol 等新库常用 5 字节头。联调失败时优先核对设备头长度与顺序(坑点 #5),用 Wireshark 抓包对照官方手册即可快速定位。

2.6 命令 / 子命令表

命令子命令功能说明
0x04010x0000批量读(字单位)最常用,读 D/W/R 等
0x04010x0001批量读(位单位)读 X/Y/M/L 等,每点 1 字节
0x14010x0000批量写(字单位)请求携带数据区
0x14010x0001批量写(位单位)请求携带数据区(每点 1 字节 0x00/0x01)
0x04030x0000随机读读不连续软元件,1~192 点
0x14020x0000随机写写不连续软元件
0x04060x0000多块批量读一次读多段连续块,采集效率最高
0x14060x0000多块批量写一次写多段连续块
0x0801 / 0x08020x0000监视登录 / 监视执行主动上报式采集(部分系列)
0x0901 / 0x09020x0000远程 RUN / 远程 STOP部分 L 系列改用 0x0B01/0x0B02

命令号随系列有差异(QnA 兼容 3E 帧与 L/FX 的 SLMP 表不完全一致),以对应 CPU 手册为准。批量写命令是 0x1401 而不是 0x0402(坑点 #6)。

2.7 常用报文构造示例

批量读 D100 起 10 字(请求,共 9+12=21 字节):

50 00 | 00 | FF | FF 03 | 00 | 0C 00 | 10 00 | 01 04 | 00 00 | 64 00 00 A8 | 0A 00 副头部 网络 PC IO 站 长度 定时器 命令 子命令 编号3B+代码 点数

响应(正常):

D0 00 | 00 | FF | FF 03 | 00 | 16 00 | 00 00 | [20 字节数据,每字小端] 副头部 网络 PC IO 站 长度 结束码 D100..D109 共 10 字

响应长度 0x0016 = 结束代码 2 字节 + 数据 20 字节。

批量写 D200 写 1 字 0x0102(=258):

50 00 | 00 | FF | FF 03 | 00 | 0E 00 | 10 00 | 01 14 | 00 00 | C8 00 00 A8 | 01 00 | 02 01 └─ 数据 0x0102 小端

2.8 ASCII 帧(4E 帧)简要对比

  • 所有字段为 ASCII 十六进制字符:副头部字符 "5000" / "D000",长度字段表示后续字符个数(不是字节数)。
  • 设备头字符顺序:设备代码 2 字符 + 设备编号 6 字符(MSB first),与二进制帧顺序相反。
  • 数据区每字 4 字符(如 "3412" 表示 0x1234)。
  • 体积约为二进制的 2 倍,带宽紧张/点数多时优先二进制帧。

2.9 错误码(结束代码)速查

结束代码含义
0x0000正常
0xC051请求目标站号错误(未连接/站号超范围)
0xC056 / 0xC057请求数据长度错误
0xC05B请求数据错误
0xC05D命令 / 子命令错误
0xC05E软元件类型错误
0xC05F点数超上限
0xC060软元件编号错误
0xC061软元件范围错误
0xC0FF其他错误
0xC105PLC CPU 通信异常(CPU 停止/正在初始化)
0xC10FPLC CPU 处于其他状态不能执行(如 RUN 状态写禁止)
0xC120时间限制错误(监视定时器超时)

错误码按系列略有差异,完整表以官方手册附录为准。写程序时务必把结束代码打印成十六进制,否则 C05F 被当成 49247 很难排查。


3. 环境准备与联调工具

3.1 PLC 侧设置(GX Works3 / GX Developer)

  1. 给 PLC 分配固定 IP(如 192.168.1.10,子网掩码 255.255.255.0)。
  2. 内置以太网端口 → 通信数据代码:二进制(Binary);帧类型:QnA 兼容 3E 帧(FX5U 默认也是此帧)。
  3. 打开 MC 协议端口:默认 TCP2000;记下端口号。
  4. 简单场景无需设置站号(默认网络 0、PC 号 255、IO 号 03FF、站号 0)。

3.2 PC 侧工具

工具用途
Wireshark抓 TCP 包,逐字节核对帧格式(联调必备,坑点 #22)
网络调试助手(NetAssist 等)手动发帧验证,快速二分「协议错」还是「代码错」
GX Works3 软元件监视在 PLC 侧核对数据是否真的写进/读出正确地址
三菱 MX Component官方组件,可用于对照行为,但授权与安装较重,多数采集场景自己实现更轻

4. 详细使用说明(实战代码)

4.1 Go 完整实现(主推,贴合 Go 采集网关场景)

package main import ( "encoding/binary" "errors" "fmt" "io" "math" "net" "sync" "time" ) // ---------- 软元件代码(QnA 兼容 3E 帧,1 字节) ---------- const ( DevX = 0x9C // 输入继电器 X DevY = 0x9D // 输出继电器 Y DevM = 0x90 // 内部继电器 M DevS = 0x98 // 步进继电器 S DevL = 0x92 // 锁存继电器 L DevF = 0x93 // 报警器 F DevV = 0x94 // 边沿继电器 V DevB = 0xA0 // 链接继电器 B DevD = 0xA8 // 数据寄存器 D DevW = 0xB4 // 链接寄存器 W DevR = 0xAF // 文件寄存器 R DevSM = 0x91 // 特殊继电器 SM DevSD = 0xA9 // 特殊寄存器 SD DevTN = 0xC2 // 定时器当前值 TN DevCN = 0xC5 // 计数器当前值 CN ) // ---------- 命令 / 子命令 ---------- const ( CmdBatchRead = 0x0401 CmdBatchWrite = 0x1401 CmdRandomRead = 0x0403 CmdRandomWrite = 0x1402 SubWord = 0x0000 SubBit = 0x0001 ) // Client 三菱 MC 协议 QnA 兼容 3E 帧客户端(4 字节设备头) type Client struct { addr string conn net.Conn timeout time.Duration timer uint16 // 监视定时器,单位 250ms mu sync.Mutex } func NewClient(addr string, timeout time.Duration) *Client { return &Client{addr: addr, timeout: timeout, timer: 0x0010} // 4 秒 } func (c *Client) Connect() error { conn, err := net.DialTimeout("tcp", c.addr, c.timeout) if err != nil { return err } c.conn = conn return nil } func (c *Client) Close() error { if c.conn != nil { return c.conn.Close() } return nil } // buildFrame 构造请求帧(设备头:编号 3 字节小端 + 代码 1 字节) func (c *Client) buildFrame(cmd, subcmd uint16, devCode byte, devNo uint32, points uint16, data []byte) []byte { buf := make([]byte, 0, 64) buf = append(buf, 0x50, 0x00) // 副头部 0x5000 buf = append(buf, 0x00, 0xFF, 0xFF, 0x03, 0x00) // 网络 0 / PC FF / IO 03FF / 站 0 lenPos := len(buf) buf = append(buf, 0, 0) // 请求数据长度(占位) buf = binary.LittleEndian.AppendUint16(buf, c.timer) // 监视定时器 buf = binary.LittleEndian.AppendUint16(buf, cmd) // 命令 buf = binary.LittleEndian.AppendUint16(buf, subcmd) // 子命令 buf = append(buf, byte(devNo), byte(devNo>>8), byte(devNo>>16)) // 设备编号 3 字节小端 buf = append(buf, devCode) // 设备代码 1 字节 buf = binary.LittleEndian.AppendUint16(buf, points) // 点数 buf = append(buf, data...) // 写数据(读命令为空) l := len(buf) - lenPos - 2 // 回填长度(从定时器起) buf[lenPos], buf[lenPos+1] = byte(l), byte(l>>8) return buf } func (c *Client) writeAll(p []byte) error { c.conn.SetWriteDeadline(time.Now().Add(c.timeout)) for len(p) > 0 { n, err := c.conn.Write(p) if err != nil { return err } p = p[n:] } return nil } // readResponse 读取并解析响应:返回结束代码与数据区 func (c *Client) readResponse() (uint16, []byte, error) { c.conn.SetReadDeadline(time.Now().Add(c.timeout)) head := make([]byte, 9) if _, err := io.ReadFull(c.conn, head); err != nil { return 0, nil, err } if head[0] != 0xD0 || head[1] != 0x00 { return 0, nil, errors.New("invalid response header") } length := binary.LittleEndian.Uint16(head[7:9]) // 响应数据长度 = 结束码2 + 数据N body := make([]byte, length) if _, err := io.ReadFull(c.conn, body); err != nil { return 0, nil, err } return binary.LittleEndian.Uint16(body[0:2]), body[2:], nil } // exec 统一执行一帧请求(带互斥,避免多 goroutine 帧交错) func (c *Client) exec(cmd, subcmd uint16, devCode byte, devNo uint32, points uint16, data []byte) ([]byte, error) { c.mu.Lock() defer c.mu.Unlock() if err := c.writeAll(c.buildFrame(cmd, subcmd, devCode, devNo, points, data)); err != nil { return nil, err } endCode, resp, err := c.readResponse() if err != nil { return nil, err } if endCode != 0 { return nil, fmt.Errorf("PLC error 0x%04X", endCode) } return resp, nil } // ---------- 高层 API ---------- // ReadWords 批量读字元件(D/W/R/SD 等) func (c *Client) ReadWords(devCode byte, devNo uint32, points uint16) ([]uint16, error) { resp, err := c.exec(CmdBatchRead, SubWord, devCode, devNo, points, nil) if err != nil { return nil, err } if len(resp) != int(points)*2 { return nil, fmt.Errorf("unexpected resp len %d", len(resp)) } out := make([]uint16, points) for i := range out { out[i] = binary.LittleEndian.Uint16(resp[i*2:]) } return out, nil } // WriteWords 批量写字元件,data 每元素对应一个字 func (c *Client) WriteWords(devCode byte, devNo uint32, vals []uint16) error { data := make([]byte, len(vals)*2) for i, v := range vals { binary.LittleEndian.PutUint16(data[i*2:], v) } _, err := c.exec(CmdBatchWrite, SubWord, devCode, devNo, uint16(len(vals)), data) return err } // ReadBits 批量读位元件(X/Y/M/L 等),返回每点 bool func (c *Client) ReadBits(devCode byte, devNo uint32, points uint16) ([]bool, error) { resp, err := c.exec(CmdBatchRead, SubBit, devCode, devNo, points, nil) if err != nil { return nil, err } if len(resp) != int(points) { return nil, fmt.Errorf("unexpected resp len %d", len(resp)) } out := make([]bool, points) for i, b := range resp { out[i] = b != 0 } return out, nil } // WriteBits 批量写位元件,每点 0/1 func (c *Client) WriteBits(devCode byte, devNo uint32, vals []bool) error { data := make([]byte, len(vals)) for i, v := range vals { if v { data[i] = 1 } } _, err := c.exec(CmdBatchWrite, SubBit, devCode, devNo, uint16(len(vals)), data) return err } // ReadFloat32 读 32 位浮点(占 2 个连续字,低字在前,IEEE754 小端) func (c *Client) ReadFloat32(devCode byte, devNo uint32) (float32, error) { words, err := c.ReadWords(devCode, devNo, 2) if err != nil { return 0, err } b := []byte{byte(words[0]), byte(words[0] >> 8), byte(words[1]), byte(words[1] >> 8)} return math.Float32frombits(binary.LittleEndian.Uint32(b)), nil } // WriteFloat32 写 32 位浮点 func (c *Client) WriteFloat32(devCode byte, devNo uint32, v float32) error { b := make([]byte, 4) binary.LittleEndian.PutUint32(b, math.Float32bits(v)) vals := []uint16{binary.LittleEndian.Uint16(b[0:2]), binary.LittleEndian.Uint16(b[2:4])} return c.WriteWords(devCode, devNo, vals) }

使用示例

func main() { c := NewClient("192.168.1.10:2000", 3*time.Second) if err := c.Connect(); err != nil { panic(err) } defer c.Close() // 读 D100~D109 十个数据寄存器 words, err := c.ReadWords(DevD, 100, 10) if err != nil { panic(err) } fmt.Println("D100..D109 =", words) // 读 M0~M7 八个内部继电器 bits, err := c.ReadBits(DevM, 0, 8) if err != nil { panic(err) } fmt.Println("M0..M7 =", bits) // 写 D200 = 258,D201 = 0x1234 if err := c.WriteWords(DevD, 200, []uint16{258, 0x1234}); err != nil { panic(err) } }

4.2 C++ 最小实现(WinSock / POSIX 通用思路)

#include <cstdint> #include <cstring> #include <iostream> #include <stdexcept> #include <vector> #ifdef _WIN32 #include <winsock2.h> #pragma comment(lib, "ws2_32.lib") #else #include <arpa/inet.h> #include <sys/socket.h> #include <unistd.h> #endif // 软元件代码 enum DevCode : uint8_t { D = 0xA8, M = 0x90, X = 0x9C, Y = 0x9D, SD = 0xA9, W = 0xB4 }; class MCClient { public: MCClient(const std::string& ip, uint16_t port, uint32_t timeoutMs = 3000) : ip_(ip), port_(port), timeoutMs_(timeoutMs), sock_(-1) {} ~MCClient() { close(); } void connect() { #ifdef _WIN32 WSADATA wsa; WSAStartup(MAKEWORD(2, 2), &wsa); #endif sock_ = ::socket(AF_INET, SOCK_STREAM, 0); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(port_); inet_pton(AF_INET, ip_.c_str(), &addr.sin_addr); if (::connect(sock_, (sockaddr*)&addr, sizeof(addr)) != 0) { throw std::runtime_error("connect failed"); } } void close() { if (sock_ >= 0) { #ifdef _WIN32 closesocket(sock_); #else ::close(sock_); #endif sock_ = -1; } } // 批量读字,返回每字 16 位 std::vector<uint16_t> readWords(uint8_t devCode, uint32_t devNo, uint16_t points) { std::vector<uint8_t> frame = buildFrame(0x0401, 0x0000, devCode, devNo, points, {}); sendAll(frame); auto resp = readResponse(); if (resp.size() != points * 2) throw std::runtime_error("resp length mismatch"); std::vector<uint16_t> out(points); for (size_t i = 0; i < points; ++i) { out[i] = (uint16_t)resp[i*2] | ((uint16_t)resp[i*2+1] << 8); } return out; } private: std::vector<uint8_t> buildFrame(uint16_t cmd, uint16_t subcmd, uint8_t devCode, uint32_t devNo, uint16_t points, const std::vector<uint8_t>& data) { std::vector<uint8_t> f; auto push16 = [&](uint16_t v) { f.push_back(v & 0xFF); f.push_back(v >> 8); }; // LSB first f.push_back(0x50); f.push_back(0x00); // 副头部 f.insert(f.end(), {0x00, 0xFF, 0xFF, 0x03, 0x00}); // 网络/PC/IO/站 size_t lenPos = f.size(); f.push_back(0); f.push_back(0); // 长度占位 push16(0x0010); // 监视定时器 4s push16(cmd); push16(subcmd); f.push_back(devNo & 0xFF); f.push_back((devNo >> 8) & 0xFF); f.push_back((devNo >> 16) & 0xFF); f.push_back(devCode); // 设备代码 1 字节 push16(points); f.insert(f.end(), data.begin(), data.end()); uint16_t len = (uint16_t)(f.size() - lenPos - 2); // 从定时器起 f[lenPos] = len & 0xFF; f[lenPos + 1] = len >> 8; return f; } void sendAll(const std::vector<uint8_t>& buf) { size_t sent = 0; while (sent < buf.size()) { int n = ::send(sock_, (const char*)buf.data() + sent, (int)(buf.size() - sent), 0); if (n <= 0) throw std::runtime_error("send failed"); sent += n; } } std::vector<uint8_t> readResponse() { uint8_t head[9]; recvAll(head, 9); if (head[0] != 0xD0 || head[1] != 0x00) throw std::runtime_error("bad resp header"); uint16_t len = (uint16_t)head[7] | ((uint16_t)head[8] << 8); std::vector<uint8_t> body(len); recvAll(body.data(), len); uint16_t endCode = (uint16_t)body[0] | ((uint16_t)body[1] << 8); if (endCode != 0) throw std::runtime_error("PLC error 0x" + hex(endCode)); return std::vector<uint8_t>(body.begin() + 2, body.end()); } void recvAll(uint8_t* p, size_t n) { size_t got = 0; while (got < n) { int r = ::recv(sock_, (char*)p + got, (int)(n - got), 0); if (r <= 0) throw std::runtime_error("recv failed"); got += r; } } static std::string hex(uint16_t v) { char b[8]; snprintf(b, sizeof(b), "%04X", v); return b; } std::string ip_; uint16_t port_; uint32_t timeoutMs_; int sock_; }; // 使用示例 // MCClient c("192.168.1.10", 2000); // c.connect(); // auto vals = c.readWords(D, 100, 10);

4.3 位元件与 32 位数据读写要点

  • 位元件:批量读子命令 0x0001,响应每点 1 字节(0x00=OFF,0x01=ON);写入同理。
  • 32 位整数 / 浮点:占 2 个连续字。低地址字存低 16 位(小端),字节序与字序均为小端,Go 侧用 binary.LittleEndian 拼 4 字节即可,见 4.1 的 ReadFloat32。
  • 64 位:占 4 个连续字,同样小端拼接。
  • BCD 数据:三菱某些元件(如部分 FX 系列地址、时钟 D 区)按 BCD 编码,读取后需自行 BCD→二进制转换((v & 0xF0) >> 4 * 10 + (v & 0x0F) 等),这是容易算错的一类数据(坑点 #18)。

4.4 采集工程实践(贴合数采项目)

  1. 连接管理:每条链路一个 TCP 连接 + 一个采集 goroutine,用 sync.Mutex 串行化帧收发(帧间不允许并发交错,见坑点 #20)。断线后按指数退避重连(1s→2s→4s…上限 30s),并复位监视定时器。
  2. 批量最大化:D/W 等连续点位尽量一次读满(字上限 960 点),把 100 次 1 点请求合并成 1 次 100 点请求,吞吐可提升一个数量级;不连续点位用 0x0403 随机读一次打包(上限约 192 点),比逐点批量读更快。
  3. 点位表驱动:把「设备代码+起始号+点数+缩放系数」做成配置文件,采集循环按表轮询,新增点位不改代码。
  4. 链路落库:采集值 → 打时间戳 → 写 Kafka(kafka-go / librdkafka)→ 消费端落 TDengine;PLC 侧一次批量读的结果作为一条消息体,减少消息量与延迟。
  5. 写操作安全:写命令加白名单地址校验 + 写前读回确认;生产环境尽量只读(RUN 状态下部分系列写被禁止,会返回 0xC10F)。

5. 常错点 / 坑(20+ 条实战记录)

帧格式类

  1. 字节序搞反(最高频):二进制 3E 帧所有多字节字段是小端(命令 0x0401 发 01 04)。拿网络字节序大端拼帧,PLC 直接回 0xC05D(命令错误)或 0xC05E(软元件类型错误)。
  2. 请求长度字段算错:请求数据长度是从监视定时器字段开始算到请求数据末尾,不含长度字段本身、也不含前面 7 字节头部。算多了/算少了都会导致 0xC056/C057 长度错误。响应长度则是从结束代码开始算(= 2 + 数据)。
  3. 副头部写错方向:请求副头部是 0x5000,响应才是 0xD000。不少网文示例把请求头也写成 D0 00,照抄后永远读不到正常响应。正确的请求帧前 9 字节应为 50 00 00 FF FF 03 00 0C 00(读取 10 点时)。
  4. 把 4E(ASCII)和 3E(二进制)字段混用:ASCII 帧字段是字符、MSB first、长度单位是字符数;二进制帧是字节、LSB first。PLC 侧通信数据码设置成「二进制」却发 ASCII 帧(或反之),行为不可预测。用 Wireshark 一眼可辨。
  5. 设备头格式不匹配(QnA 4 字节 vs SLMP 5 字节):QnA 兼容 3E 帧设备头是「编号 3 字节 + 代码 1 字节」(4 字节);SLMP 新帧是「代码 2 字节 + 编号 3 字节」(5 字节)。两者编号和代码的顺序还相反。用错后典型现象:读到的值全部错位或报 0xC05E 软元件类型错误。现场用官方手册 + 抓包对照确认。
  6. 批量写命令写成 0x0402:批量写是 0x1401(字)/ 0x1401+子命令1(位),0x0402 不是标准批量写命令。很多博客示例写错,照抄后 PLC 回 0xC05D。
  7. 监视定时器设为 0 或过小:监视定时器单位 250ms,设为 0 时部分系列按默认值(约 5s)处理,但设得比 PLC 实际响应时间还小会提前报 0xC120 时间限制错误。批量写/远程 STOP 等耗时操作建议 4~10s。
  8. 响应长度解析位置错:响应固定头是 9 字节(副头 2+网络 1+PC 1+IO 2+站 1+长度 2),很多人按 7 字节读,把结束代码当成长度解析,数据全错。正确流程:读 9 字节 → 解析长度 → 再读 N 字节 → 前 2 字节结束码。

寻址类

  1. 软元件编号进制混用:帧中软元件编号按 16 进制编码。D100 → 编号 100 → 0x0064(发 64 00 00)。有人直接填 100 的十六进制显示习惯或直接写 BCD,读出的地址完全不对。
  2. X/Y 编号进制随系列变化:Q/L 系列 X/Y 用十六进制编号(X0X1FFF),FX 系列 X/Y 用八进制编号(X0X7、X10~X17…)。FX 系列要把八进制地址的数字值当十进制转十六进制编码(如 X20 八进制 → 编号 20 → 0x14),编码错误会读到错误点位或越界。
  3. 位元件当字读 / 字元件当位读:子命令 0x0000=字单位、0x0001=位单位。M 等位元件用字单位读会得到「16 点打包」的位模式(每点=16 位),与直觉不符;D 等字元件用位单位读则直接报错或返回 1 字节/点。
  4. 点数量级错误:批量读字元件每点是2 字节,响应长度 = 点数×2;位元件每点 1 字节。按「点数=字节数」解析会把数据整体错位。还有位元件批量读上限按「位」计,超过上限回 0xC05F。
  5. 随机读写点数上限记错:随机读最多约 192 点、随机写约 64192 点(按系列),超过上限报 0xC05F。批量读写字元件上限 960 点、位元件上限 35847168 点(按帧类型/系列)。上限不满足时拆分请求。

数据语义类

  1. 浮点/32 位数据字节序与字序搞错:32 位浮点占 2 个字,低地址字在前(小端),4 字节整体也是小端。按「高字在前」或按大端拼字节,读出的 float 是天文数字或 NaN。64 位同理。
  2. BCD 数据未转换:PLC 时钟、部分计数器/地址参数默认 BCD 编码(如 0x25 表示 25 而不是 37)。采集 D 区时钟类数据不做 BCD→二进制转换,数值全部翻车。判断依据:PLC 侧数据格式设置与数据实际含义。
  3. PLC 停机/RUN 状态与写权限:CPU 处于 STOP/初始化时读会回 0xC105 或 0xC10F;RUN 状态下部分系列禁止写,写失败要区分「协议错」和「状态错」,不要盲目重试写操作。
  4. 读回值与 PLC 监视不一致:先确认你读的地址类型(D 是字、M 是位)与 PLC 侧软元件监视窗口一致;再确认采集程序里有没有把位当字、把 16 位当 32 位、漏掉缩放系数(工程值 = 原始值 × 系数 + 偏移)。

工程实现类

  1. 多 goroutine/线程并发收发不互斥:TCP 上连续两个请求的响应是按顺序返回的,并发写帧会导致响应错配(A 请求的响应被 B 解析)。必须串行化整帧收发(互斥锁包裹 write+read 全过程),或每个连接独占一个 goroutine。
  2. io.ReadFull 缺失 / recv 半包:TCP 是字节流,一次 recv 可能只收到半个响应帧。必须循环读满「9 字节头 + 长度字段声明的 N 字节」,用 io.ReadFull/recvAll 而不是单次 Read/recv。
  3. 连接断开不重连:PLC 重启、网线抖动、CPU 模块复位都会断开 TCP。采集程序要监听错误并指数退避重连,否则静默丢数。重连后建议重读一次全量点位做基线对齐。
  4. 不检查结束代码就解析数据:结束代码非 0 时响应数据区可能为空或为错误信息,直接按正常数据解析会产生「幽灵数据」。务必先判断 endCode != 0 并返回带错误码的错误。
  5. 凭感觉调帧不抓包:MC 协议字节级细节多,肉眼排错效率极低。正确姿势:先 Wireshark 抓一帧官方工具(GX Works3 监视/网络调试助手)发出的正常帧,逐字节对照自己代码的帧,差异即 bug。
  6. 端口号写错:默认 TCP 2000,但很多项目把 PLC 端口改成 1025/4999 或按模块不同。连不上先 ping + 测试端口连通性(Test-NetConnection / nc)。
  7. ASCII 帧长度单位误解:若用 4E 帧,长度字段是后续字符数(一个字节十六进制占 2 字符),不是字节数,差一倍。二进制帧才是字节数。

6. 性能与实践建议

实践收益
连续点位合并批量读(一次 ≤960 字)吞吐提升 10~100 倍,减少 PLC 通信负荷
不连续点位用随机读(0x0403)打包减少请求次数,延迟更低
连接复用 + 每链路单 goroutine 串行收发稳定、无帧错配
多 PLC 并行(每 PLC 一条 TCP)站点数线性扩展
读值→时间戳→Kafka(批量消息)→TDengine端到端低延迟落库
写操作白名单 + 写前读回安全生产

点位规划建议:把高频点位(≤100ms)与低频点位(1s~1min)分不同批次轮询,避免低频慢点拖累高频链路;多块批量读(0x0406)在点数多且分段时最省。


7. FAQ 速查

Q1:连不上 PLC 常见原因?先 ping IP;再用 Test-NetConnection <ip> -Port 2000 测端口;确认 GX Works3 里 MC 协议端口已打开、通信数据码与你的帧类型一致(二进制 3E ↔ 二进制)。

Q2:能 ping 通但读不到数据?大概率帧格式问题:字节序、请求长度、设备头格式(4/5 字节)、软元件代码。抓包对照官方工具帧排查。

Q3:读 D100 得到 0xFFFF / 0x0000 正常吗?可能是未初始化或该地址无数据(返回 0),也可能是地址或类型错(读错元件)。用 GX Works3 软元件监视核对同一地址。

Q4:MC 协议一次最多读多少个点?字批量读 ≤960 点,位批量读 3584~7168 点(按系列/帧类型),随机读 ≤192 点。超过需拆分。

Q5:读浮点数据乱码?检查字序:32 位浮点占 2 个字、低字在前、整体小端;确认 PLC 侧该区域确实是 float 类型而非整数/BCD。

Q6:写操作返回 C10F 是什么?CPU 处于 RUN 且该系列 RUN 状态禁止写,或当前站无写权限。改到 STOP 或给对应系列开启 RUN 中写入(需 PLC 侧参数支持)。

Q7:必须用三菱官方 MX Component 吗?不是。MC 协议帧格式公开,自研 TCP 客户端完全可行(本文即例)。MX Component 适合快速出原型,但部署重、跨平台差;生产采集网关自研更轻。

Q8:FX5U 和 Q 系列帧一样吗?基本一致(都支持 QnA 兼容 3E 帧),但设备代码/命令号/点数上限有细微差异,部分功能需 SLMP 新帧(5 字节设备头)。以 FX5U 手册(JY997D56201)核对。


8. 总结

MC 协议(QnA 兼容 3E 帧)是三菱 PLC 以太网直连的事实标准:裸 TCP + 无校验和 + 全小端多字节字段 + 长度自定界。掌握五个要点即可稳定落地:

  1. 帧结构:9 字节固定头 + 请求长度(从定时器起)/ 响应长度(从结束码起);
  2. 字节序:二进制帧多字节字段全部 LSB first,数据区字/双字同样小端;
  3. 寻址:软元件代码 + 编号(16 进制编码),X/Y 进制随系列,设备头注意 QnA 4 字节 vs SLMP 5 字节;
  4. 命令:批量读 0x0401 / 批量写 0x1401 / 随机读 0x0403 / 随机写 0x1402,子命令 0=字 1=位;
  5. 工程:串行收发 + ReadFull + 结束码检查 + 断线重连 + 批量最大化 + 抓包验证。

这条链路与既有系列篇目自然衔接:Qt SerialPort(串口)/ libmodbus(Modbus 设备)→ MC 协议(三菱 PLC/CNC 直连)→ paho MQTT / librdkafka / kafka-go(传输缓冲)→ TDengine(时序落库)→ NumPy / Pandas / PyTorch / ONNX Runtime(分析与 AI),构成完整的工业数采技术栈。


9. 参考资料

  • 三菱 MELSEC 通信协议参考手册(Q 系列:SH-080356;FX5U/iQ-F:JY997D56201)
  • 三菱 GX Works3 / GX Developer 帮助文档(MC 协议设置、软元件监视)
  • 各 PLC 型号 CPU 编程手册(软元件编号规则、点数上限)
  • Wireshark 抓包实测(联调标准流程)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询