适用对象:需要与三菱 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)
- 给 PLC 分配固定 IP(如 192.168.1.10,子网掩码 255.255.255.0)。
- 内置以太网端口 → 通信数据代码:二进制 (Binary);帧类型:QnA 兼容 3E 帧(FX5U 默认也是此帧)。
- 打开 MC 协议端口:默认 TCP 2000;记下端口号。
- 简单场景无需设置站号(默认网络 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 采集工程实践(贴合数采项目)
- 连接管理:每条链路一个 TCP 连接 + 一个采集 goroutine,用 sync.Mutex 串行化帧收发(帧间不允许并发交错,见坑点 #20)。断线后按指数退避重连(1s→2s→4s...上限 30s),并复位监视定时器。
- 批量最大化:D/W 等连续点位尽量一次读满(字上限 960 点),把 100 次 1 点请求合并成 1 次 100 点请求,吞吐可提升一个数量级;不连续点位用 0x0403 随机读一次打包(上限约 192 点),比逐点批量读更快。
- 点位表驱动:把「设备代码+起始号+点数+缩放系数」做成配置文件,采集循环按表轮询,新增点位不改代码。
- 链路落库:采集值 → 打时间戳 → 写 Kafka(kafka-go / librdkafka)→ 消费端落 TDengine;PLC 侧一次批量读的结果作为一条消息体,减少消息量与延迟。
- 写操作安全:写命令加白名单地址校验 + 写前读回确认;生产环境尽量只读(RUN 状态下部分系列写被禁止,会返回 0xC10F)。
5. 常错点 / 坑(20+ 条实战记录)
帧格式类
- 字节序搞反(最高频) :二进制 3E 帧所有多字节字段是小端(命令 0x0401 发 01 04)。拿网络字节序大端拼帧,PLC 直接回 0xC05D(命令错误)或 0xC05E(软元件类型错误)。
- 请求长度字段算错 :请求数据长度是从监视定时器字段 开始算到请求数据末尾,不含 长度字段本身、也不含前面 7 字节头部。算多了/算少了都会导致 0xC056/C057 长度错误。响应长度则是从结束代码开始算(= 2 + 数据)。
- 副头部写错方向:请求副头部是 0x5000,响应才是 0xD000。不少网文示例把请求头也写成 D0 00,照抄后永远读不到正常响应。正确的请求帧前 9 字节应为 50 00 00 FF FF 03 00 0C 00(读取 10 点时)。
- 把 4E(ASCII)和 3E(二进制)字段混用:ASCII 帧字段是字符、MSB first、长度单位是字符数;二进制帧是字节、LSB first。PLC 侧通信数据码设置成「二进制」却发 ASCII 帧(或反之),行为不可预测。用 Wireshark 一眼可辨。
- 设备头格式不匹配(QnA 4 字节 vs SLMP 5 字节) :QnA 兼容 3E 帧设备头是「编号 3 字节 + 代码 1 字节」(4 字节);SLMP 新帧是「代码 2 字节 + 编号 3 字节」(5 字节)。两者编号和代码的顺序还相反。用错后典型现象:读到的值全部错位或报 0xC05E 软元件类型错误。现场用官方手册 + 抓包对照确认。
- 批量写命令写成 0x0402:批量写是 0x1401(字)/ 0x1401+子命令1(位),0x0402 不是标准批量写命令。很多博客示例写错,照抄后 PLC 回 0xC05D。
- 监视定时器设为 0 或过小 :监视定时器单位 250ms,设为 0 时部分系列按默认值(约 5s)处理,但设得比 PLC 实际响应时间还小会提前报 0xC120 时间限制错误。批量写/远程 STOP 等耗时操作建议 4~10s。
- 响应长度解析位置错:响应固定头是 9 字节(副头 2+网络 1+PC 1+IO 2+站 1+长度 2),很多人按 7 字节读,把结束代码当成长度解析,数据全错。正确流程:读 9 字节 → 解析长度 → 再读 N 字节 → 前 2 字节结束码。
寻址类
- 软元件编号进制混用:帧中软元件编号按 16 进制编码。D100 → 编号 100 → 0x0064(发 64 00 00)。有人直接填 100 的十六进制显示习惯或直接写 BCD,读出的地址完全不对。
- X/Y 编号进制随系列变化 :Q/L 系列 X/Y 用十六进制编号(X0X1FFF),FX 系列 X/Y 用八进制编号(X0X7、X10~X17...)。FX 系列要把八进制地址的数字值当十进制转十六进制编码(如 X20 八进制 → 编号 20 → 0x14),编码错误会读到错误点位或越界。
- 位元件当字读 / 字元件当位读:子命令 0x0000=字单位、0x0001=位单位。M 等位元件用字单位读会得到「16 点打包」的位模式(每点=16 位),与直觉不符;D 等字元件用位单位读则直接报错或返回 1 字节/点。
- 点数量级错误 :批量读字元件每点是 2 字节,响应长度 = 点数×2;位元件每点 1 字节。按「点数=字节数」解析会把数据整体错位。还有位元件批量读上限按「位」计,超过上限回 0xC05F。
- 随机读写点数上限记错:随机读最多约 192 点、随机写约 64192 点(按系列),超过上限报 0xC05F。批量读写字元件上限 960 点、位元件上限 35847168 点(按帧类型/系列)。上限不满足时拆分请求。
数据语义类
- 浮点/32 位数据字节序与字序搞错:32 位浮点占 2 个字,低地址字在前(小端),4 字节整体也是小端。按「高字在前」或按大端拼字节,读出的 float 是天文数字或 NaN。64 位同理。
- BCD 数据未转换:PLC 时钟、部分计数器/地址参数默认 BCD 编码(如 0x25 表示 25 而不是 37)。采集 D 区时钟类数据不做 BCD→二进制转换,数值全部翻车。判断依据:PLC 侧数据格式设置与数据实际含义。
- PLC 停机/RUN 状态与写权限:CPU 处于 STOP/初始化时读会回 0xC105 或 0xC10F;RUN 状态下部分系列禁止写,写失败要区分「协议错」和「状态错」,不要盲目重试写操作。
- 读回值与 PLC 监视不一致:先确认你读的地址类型(D 是字、M 是位)与 PLC 侧软元件监视窗口一致;再确认采集程序里有没有把位当字、把 16 位当 32 位、漏掉缩放系数(工程值 = 原始值 × 系数 + 偏移)。
工程实现类
- 多 goroutine/线程并发收发不互斥:TCP 上连续两个请求的响应是按顺序返回的,并发写帧会导致响应错配(A 请求的响应被 B 解析)。必须串行化整帧收发(互斥锁包裹 write+read 全过程),或每个连接独占一个 goroutine。
- io.ReadFull 缺失 / recv 半包:TCP 是字节流,一次 recv 可能只收到半个响应帧。必须循环读满「9 字节头 + 长度字段声明的 N 字节」,用 io.ReadFull/recvAll 而不是单次 Read/recv。
- 连接断开不重连:PLC 重启、网线抖动、CPU 模块复位都会断开 TCP。采集程序要监听错误并指数退避重连,否则静默丢数。重连后建议重读一次全量点位做基线对齐。
- 不检查结束代码就解析数据:结束代码非 0 时响应数据区可能为空或为错误信息,直接按正常数据解析会产生「幽灵数据」。务必先判断 endCode != 0 并返回带错误码的错误。
- 凭感觉调帧不抓包 :MC 协议字节级细节多,肉眼排错效率极低。正确姿势:先 Wireshark 抓一帧官方工具(GX Works3 监视/网络调试助手)发出的正常帧,逐字节对照自己代码的帧,差异即 bug。
- 端口号写错:默认 TCP 2000,但很多项目把 PLC 端口改成 1025/4999 或按模块不同。连不上先 ping + 测试端口连通性(Test-NetConnection / nc)。
- 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 + 无校验和 + 全小端多字节字段 + 长度自定界。掌握五个要点即可稳定落地:
- 帧结构:9 字节固定头 + 请求长度(从定时器起)/ 响应长度(从结束码起);
- 字节序:二进制帧多字节字段全部 LSB first,数据区字/双字同样小端;
- 寻址:软元件代码 + 编号(16 进制编码),X/Y 进制随系列,设备头注意 QnA 4 字节 vs SLMP 5 字节;
- 命令:批量读 0x0401 / 批量写 0x1401 / 随机读 0x0403 / 随机写 0x1402,子命令 0=字 1=位;
- 工程:串行收发 + 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 抓包实测(联调标准流程)