三菱 PLC MC 协议(SLMP / QnA 兼容 3E 帧)深度解析:从帧结构到 C++/Go 双语言采集实战

适用对象:需要与三菱 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 目标网络,单机通常 0 00
PC 号 1 PLC 编号,通常 255 FF
请求目标模块 I/O 号 2 CPU 模块固定 0xFF03(小端发送) FF 03
请求目标模块站号 1 通常 0 00
请求数据长度 2 从监视定时器字段开始到请求数据末尾的字节数(不含长度字段自身) 可变
监视定时器 2 单位 250ms,0x0010=4000ms 10 00
命令 2 见 2.6 命令表 01 04(=0x0401)
子命令 2 0=字单位,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 + 数据区长度)
结束代码 2 0x0000 正常;非 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 命令 / 子命令表

命令 子命令 功能 说明
0x0401 0x0000 批量读(字单位) 最常用,读 D/W/R 等
0x0401 0x0001 批量读(位单位) 读 X/Y/M/L 等,每点 1 字节
0x1401 0x0000 批量写(字单位) 请求携带数据区
0x1401 0x0001 批量写(位单位) 请求携带数据区(每点 1 字节 0x00/0x01)
0x0403 0x0000 随机读 读不连续软元件,1~192 点
0x1402 0x0000 随机写 写不连续软元件
0x0406 0x0000 多块批量读 一次读多段连续块,采集效率最高
0x1406 0x0000 多块批量写 一次写多段连续块
0x0801 / 0x0802 0x0000 监视登录 / 监视执行 主动上报式采集(部分系列)
0x0901 / 0x0902 0x0000 远程 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 其他错误
0xC105 PLC CPU 通信异常(CPU 停止/正在初始化)
0xC10F PLC 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 协议端口:默认 TCP 2000;记下端口号。
  4. 简单场景无需设置站号(默认网络 0、PC 号 255、IO 号 03FF、站号 0)。

3.2 PC 侧工具

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

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

4.1 Go 完整实现(主推,贴合 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)
}

使用示例

Go 复制代码
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 通用思路)

cpp 复制代码
#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 抓包实测(联调标准流程)
相关推荐
Logic1013 小时前
C语言/数据结构位运算题解:异或XOR找出游戏速通中的“独特初始能量值“——只出现一次的数字
c语言·数据结构·数组·位运算·时间复杂度·算法题·异或性质
程序猿编码3 小时前
C++/CUDA 手写 LLM 推理引擎:拆解 vLLM 核心 PagedAttention 与连续批处理
开发语言·c++·深度学习·神经网络·推理·vllm
hold?fish:palm3 小时前
44 二叉搜索树中第K小的元素
开发语言·c++·算法
念何架构之路3 小时前
zap日志SugaredLogger 剖析
云原生·golang
凉茶钱3 小时前
【吃透C++】万字解析类和对象
开发语言·c++
白杨尚青3 小时前
C++入门篇(十):string(上)——认识string:构造与三大遍历(一条龙讲透operator[]、迭代器、auto、范围for)
java·开发语言·c++·笔记·stl
FlightYe3 小时前
视界原理之2D视频(二):视频文件里有什么
android·linux·网络·c++·ffmpeg·音视频·aac
Chen—LSN3 小时前
C语言——⽂件操作(1)
c语言·开发语言·c++·经验分享·笔记·算法·链表
木由里予4 小时前
GDB 使用手册(详细版)
c语言·vscode