三菱FX PLC编程口通讯协议解析与Python实战
1. 项目概述:从“黑盒”到“白盒”的通讯探索
在工业自动化领域,三菱FX系列PLC堪称常青树,以其稳定可靠、性价比高的特点,广泛应用于各类中小型设备控制。对于大多数电气工程师和程序员而言,使用GX Works2或GX Developer进行梯形图编程、通过专用的SC-09或USB-SC09-FX编程电缆连接PLC的COM口进行程序上下载,是再常规不过的操作。但这个看似简单的“连接-下载”过程背后,其实隐藏着一套完整的、由三菱定义的串行通讯协议。我们日常使用的编程软件,正是这套协议的“官方客户端”。
那么,如果我们想绕开官方软件,自己写一个程序来读取PLC的软元件状态(比如D100的当前值)、或者强制置位某个M点,甚至进行远程的在线监控和调试,该怎么办?这就是“三菱FX系列PLC编程口通讯协议”要解决的问题。它不是一个公开的标准协议(如Modbus),而是三菱为其编程口(通常是RS-422/RS-485接口)设计的一套私有、高效的二进制通讯协议。掌握它,就等于拿到了与FX系列PLC直接“对话”的钥匙,能够实现高度定制化的数据采集、设备调试、上位机监控乃至轻量级SCADA系统的开发。
这套协议的核心价值在于“直接”和“灵活”。你不再需要依赖三菱的中间件或OPC服务器,一台带有串口(或USB转串口)的电脑,加上自己编写的代码,就能与PLC建立通讯。这对于开发嵌入式网关、定制化调试工具、或在资源受限的工控机/树莓派上集成PLC控制功能的场景来说,意义重大。接下来,我将结合自己多年在设备联网和数据采集项目中的实战经验,为你层层拆解这套协议,并提供可直接复用的代码实例和避坑指南。
2. 协议基础与通讯环境搭建
2.1 物理连接与端口认知
首先,我们必须明确通讯的物理基础。FX系列PLC的编程口通常是一个圆形的8针MINI-DIN接口,标记为“PORT”或“RS-422”。其引脚定义是固定的:
- 引脚1: FG(框架地)
- 引脚2: SD(发送数据-)
- 引脚3: RD(接收数据-)
- 引脚4: RTS(请求发送)
- 引脚5: SG(信号地)
- 引脚6: 5V(电源,通常不用于通讯)
- 引脚7: SD(发送数据+)
- 引脚8: RD(接收数据+)
注意:这是一个RS-422接口,本质上是差分信号,抗干扰能力强于RS-232。我们常用的SC-09编程电缆,内部集成了RS-422到RS-232的电平转换电路。如果你使用USB-SC09-FX,则内部是RS-422转USB的芯片(如FTDI或CP2102)。
要与电脑通讯,你需要一条可靠的编程电缆。我强烈建议使用原装或口碑好的兼容电缆,很多通讯不稳定的问题都源于劣质电缆的信号转换质量差。连接后,在电脑的设备管理器中,你会看到一个新增的COM口(例如COM3),这就是我们后续编程中需要操作的虚拟串口。
2.2 核心协议帧格式解析
三菱FX编程口协议采用基于字节的二进制帧结构,遵循“主从问答”模式,电脑作为主站,PLC作为从站。每一轮完整的通讯都由主站请求帧和从站响应帧组成。
一个标准的请求帧格式如下:
| 部件 | 长度(字节) | 说明 | 示例值(ASCII/Hex) |
|---|---|---|---|
| 起始符 | 1 | 固定为ASCII码的STX(0x02) | 0x02 |
| 命令码 | 1 | 标识操作类型,如读、写 | 0(0x30) |
| 起始地址 | 4 | 要操作的软元件起始地址,ASCII码表示 | D100表示为0444(ASCII:0,4,4,4) |
| 数据长度 | 2 | 要读/写的软元件数量,ASCII码表示 | 读1个表示为01(ASCII:0,1) |
| 数据区 | 可变 | 写操作时存放要写入的数据,读操作时无此部分 | |
| 结束符 | 2 | 固定为ASCII码的ETX(0x03) | 0x03 |
| 校验和 | 2 | 从命令码到ETX(含)所有字节的ASCII码值累加和,取后2位十六进制数,再转为ASCII码 | 计算得出 |
看起来有点复杂?我们用一个具体的“读取D100寄存器”的例子来拆解。假设我们要读取1个寄存器(即D100)。
确定要素:
- 命令码:读寄存器命令为
0(ASCII0x30)。 - 起始地址:D100。需要先将其转换为协议中的5位十进制地址。FX系列软元件有统一的地址编码规则,D寄存器的基址是0x0A00。D100的偏移量是100(十进制)。协议中地址用5位十进制数表示,计算方式为:
基址 + 偏移量 * 2。所以D100的地址 =0x0A00 + 100 * 2 = 0x0A00 + 0x00C8 = 0x0AC8。将0x0AC8转换为十进制,是2744。在帧中,我们需要用4位ASCII码表示这个5位十进制数的后4位,即“2744”。所以地址部分填充为ASCII码的2,7,4,4。 - 数据长度:读1个,表示为“01”(ASCII码
0,1)。
- 命令码:读寄存器命令为
构建帧:
- 起始符:
0x02(STX) - 命令/地址等:
0 2744 01-> 对应的ASCII字节流:0x30, 0x32, 0x37, 0x34, 0x34, 0x30, 0x31 - 结束符:
0x03(ETX)
- 起始符:
计算校验和:
- 累加
0x30 + 0x32 + 0x37 + 0x34 + 0x34 + 0x30 + 0x31 + 0x03 = 0x1A5。 - 取后两位十六进制数
A5。 - 将
A和5分别转换为对应的ASCII码0x41和0x35。 - 所以校验和部分为
0x41, 0x35。
- 累加
最终请求帧(16进制表示):
02 30 32 37 34 34 30 31 03 41 35
PLC收到正确的请求后,会返回响应帧。响应帧的格式与请求帧类似,但数据区包含了读取到的值。例如,如果D100的值为十进制1234(十六进制0x04D2),响应帧可能类似于:02 30 30 34 44 32 03 46 43。其中30 30可能表示正常状态,34 44 32就是ASCII码表示的“4D2”,即数据0x04D2。
实操心得:地址计算是新手最容易出错的地方。务必理解“5位十进制地址”的概念,并且记住常用软元件的基址:D寄存器为0x0A00,M线圈为0x0100,X输入为0x0080,Y输出为0x00A0。写一个通用的地址转换函数是后续编程的关键。
2.3 串口参数配置
协议运行在串口之上,必须使用正确的参数才能建立通讯。FX系列编程口通讯的串口参数是固定的:
- 波特率:9600 bps
- 数据位:7位
- 停止位:1位
- 校验位:偶校验(Even Parity)
- 流控制:无(None)
在任何串口调试助手或自行编写的代码中,都必须严格按此配置。一个常见的错误是使用了8位数据位或无校验,这将导致PLC完全不响应。
3. 核心功能实现与代码拆解
理解了协议格式,我们就可以着手用代码实现。这里我以Python为例进行讲解,因其语法简洁,易于理解。在实际项目中,C#、Java、C++等语言原理完全相同。
3.1 串口通讯基础模块
首先,我们需要一个可靠的串口操作类。Python中可以使用pyserial库。
import serial import time import binascii class FXSerialComm: def __init__(self, port='COM3', timeout=1): """ 初始化串口连接 :param port: 串口号,如 'COM3' 或 '/dev/ttyUSB0' :param timeout: 读超时时间(秒) """ self.ser = serial.Serial( port=port, baudrate=9600, bytesize=serial.SEVENBITS, parity=serial.PARITY_EVEN, stopbits=serial.STOPBITS_ONE, timeout=timeout ) if self.ser.is_open: print(f"成功打开串口 {port}") else: raise Exception(f"无法打开串口 {port}") def close(self): """关闭串口""" if self.ser and self.ser.is_open: self.ser.close() print("串口已关闭") def send_and_receive(self, frame_hex): """ 发送帧并接收响应 :param frame_hex: 十六进制字符串表示的帧,如 '0230323734343031034135' :return: 十六进制字符串表示的响应帧,如 '023030344432034643' """ # 将十六进制字符串转换为字节数据 data_to_send = binascii.unhexlify(frame_hex) print(f"发送: {frame_hex}") # 清空输入缓冲区,避免旧数据干扰 self.ser.reset_input_buffer() # 发送数据 self.ser.write(data_to_send) # 等待并读取响应 # 协议响应时间通常很快,但增加短暂延时确保数据接收完整 time.sleep(0.05) response_bytes = self.ser.read_all() if response_bytes: response_hex = binascii.hexlify(response_bytes).decode('ascii').upper() print(f"接收: {response_hex}") return response_hex else: print("未收到响应") return None这个类封装了串口的初始化和基础的收发功能。注意read_all()的使用,它读取当前输入缓冲区中的所有数据。在实际应用中,更稳健的做法是根据ETX(0x03)来判断帧的结束,但为了示例清晰,这里采用简单方式。
3.2 协议帧构造与解析器
这是整个项目的核心,负责将用户友好的“读D100”指令,翻译成协议认识的二进制帧,并把PLC返回的二进制数据解析成我们可以理解的数值。
class FXProtocolParser: # 软元件类型基址映射表 (十六进制) DEVICE_BASE = { 'X': 0x0080, # 输入继电器 'Y': 0x00A0, # 输出继电器 'M': 0x0100, # 辅助继电器 'D': 0x0A00, # 数据寄存器 'T': 0x00C0, # 定时器当前值 'C': 0x00E0, # 计数器当前值 } @staticmethod def calculate_address(device_type, device_number): """ 计算协议使用的5位十进制地址 :param device_type: 软元件类型,如 'D', 'M' :param device_number: 软元件编号,如 100 :return: 4位ASCII码字符串表示的地址(5位十进制地址的后4位) """ if device_type not in FXProtocolParser.DEVICE_BASE: raise ValueError(f"不支持的软元件类型: {device_type}") base_addr = FXProtocolParser.DEVICE_BASE[device_type] # 计算实际地址:基址 + 编号 * 2 (对于位元件,编号就是点数;对于字元件,编号*2) actual_addr = base_addr + device_number * 2 # 转换为5位十进制数 dec_addr = actual_addr # 取后4位,格式化为4位字符串,不足补零 addr_str = f"{dec_addr % 10000:04d}" return addr_str @staticmethod def build_read_frame(device_type, device_number, count=1): """ 构建读取软元件的请求帧 :param device_type: 软元件类型 :param device_number: 起始编号 :param count: 读取数量 :return: 十六进制字符串格式的完整帧 """ # 1. 起始符 STX frame_bytes = bytearray([0x02]) # 2. 命令码 '0' (ASCII 0x30) 代表读 frame_bytes.append(0x30) # 3. 起始地址 (4位ASCII) addr_str = FXProtocolParser.calculate_address(device_type, device_number) frame_bytes.extend(addr_str.encode('ascii')) # 4. 数据长度 (2位ASCII) count_str = f"{count:02d}" frame_bytes.extend(count_str.encode('ascii')) # 5. 结束符 ETX frame_bytes.append(0x03) # 6. 计算校验和 checksum = sum(frame_bytes[1:]) # 从命令码开始累加到ETX checksum_hex = f"{checksum & 0xFF:02X}" # 取低8位,转为2位十六进制字符串 frame_bytes.extend(checksum_hex.encode('ascii')) # 转换为十六进制字符串返回 return binascii.hexlify(frame_bytes).decode('ascii').upper() @staticmethod def parse_read_response(response_hex, count=1): """ 解析读取操作的响应帧 :param response_hex: 十六进制字符串响应 :param count: 期望读取的数据个数 :return: 解析出的数值列表(整数),或错误信息 """ if not response_hex or len(response_hex) < 10: return None, "响应帧过短或为空" try: response_bytes = binascii.unhexlify(response_hex) except: return None, "响应帧非法的十六进制格式" # 基本结构检查 if response_bytes[0] != 0x02: return None, "响应帧起始符错误" if response_bytes[-3] != 0x03: # ETX应该在倒数第三位 return None, "响应帧结束符错误" # 校验和验证 received_checksum = response_bytes[-2:].decode('ascii') calculated_checksum = f"{sum(response_bytes[1:-2]) & 0xFF:02X}" if received_checksum.upper() != calculated_checksum.upper(): return None, f"校验和错误: 收到{received_checksum}, 计算{calculated_checksum}" # 检查命令/状态码 status_code = chr(response_bytes[1]) if status_code != '0': error_codes = {'1': 'PLC错误', '2': '奇偶校验错误', '3': '帧格式错误', '4': '和校验错误'} error_msg = error_codes.get(status_code, f'未知错误码: {status_code}') return None, f"PLC返回错误: {error_msg}" # 提取数据部分 # 响应帧格式: STX(1) + 状态(1) + 数据(2*count字节,ASCII) + ETX(1) + 校验和(2) data_start = 2 data_end = -3 # 排除ETX和校验和 data_ascii = response_bytes[data_start:data_end].decode('ascii') # 数据是连续的ASCII字符串,每4个字符代表一个16位寄存器值(十六进制ASCII) values = [] for i in range(count): hex_str = data_ascii[i*4:(i+1)*4] if len(hex_str) != 4: break # 将ASCII表示的十六进制字符串转换为整数 value = int(hex_str, 16) # 注意:协议返回的是16位无符号整数,但D寄存器可能存储有符号数或浮点数 # 这里按无符号整数返回,上层应用根据实际情况转换 values.append(value) return values, "成功"这个解析器类包含了地址计算、帧构建和响应解析的核心逻辑。calculate_address方法是关键,它封装了之前提到的地址转换规则。parse_read_response方法则严谨地检查了响应的每一个部分,包括起始结束符、校验和以及PLC返回的状态码,确保了通讯的可靠性。
3.3 完整功能集成与测试
现在,我们将串口模块和协议解析器组合起来,实现一个完整的读值示例。
def main(): # 1. 初始化串口通讯 (请根据实际情况修改端口号) comm = FXSerialComm(port='COM3') try: # 2. 构建读取D100的请求帧 device_type = 'D' device_number = 100 read_count = 1 request_frame = FXProtocolParser.build_read_frame(device_type, device_number, read_count) print(f"构建的请求帧: {request_frame}") # 3. 发送并接收 response_frame = comm.send_and_receive(request_frame) if response_frame: # 4. 解析响应 values, message = FXProtocolParser.parse_read_response(response_frame, read_count) if values is not None: print(f"读取成功: D{device_number} = {values[0]} (0x{values[0]:04X})") # 如果需要,可以在这里进行有符号数转换 # signed_value = values[0] if values[0] < 32768 else values[0] - 65536 # print(f"有符号值: {signed_value}") else: print(f"读取失败: {message}") else: print("通讯失败,未收到PLC响应。请检查:") print(" 1. 电缆连接是否牢固?") print(" 2. PLC是否上电并处于RUN/STOP状态(非编程状态)?") print(" 3. 电脑COM口选择是否正确?") print(" 4. 是否有其他软件(如GX Works2)占用了该串口?") except Exception as e: print(f"程序运行出错: {e}") finally: # 5. 关闭串口 comm.close() if __name__ == "__main__": main()运行这段代码,如果一切正常,你将在控制台看到类似“读取成功: D100 = 1234 (0x04D2)”的输出。这标志着你已经成功突破了官方软件的壁垒,直接与PLC核心建立了对话。
4. 协议高级应用与功能扩展
掌握了基础的读取功能,我们就可以在此基础上实现更复杂的操作,构建一个实用的工具。
4.1 批量读取与写入操作
实际项目中,我们很少只读一个点。批量操作能极大提高效率。协议本身支持在帧中指定数据长度。例如,读取D100开始的10个寄存器,只需将帧中的数据长度部分改为“10”。解析响应时,我们需要循环解析每4个ASCII字符为一个数据。
写入操作(命令码为1, ASCII0x31)的帧格式略有不同,需要在数据长度后附加要写入的数据区。数据区同样需要将每个16位寄存器的值转换为4位ASCII码表示的十六进制数。例如,向D100写入十进制1234,数据区就是“04D2”。构建写帧的函数需要增加数据列表参数。
@staticmethod def build_write_frame(device_type, device_number, values): """ 构建写入软元件的请求帧 :param device_type: 软元件类型 :param device_number: 起始编号 :param values: 要写入的数值列表(整数) :return: 十六进制字符串格式的完整帧 """ frame_bytes = bytearray([0x02]) frame_bytes.append(0x31) # 命令码 '1' 代表写 addr_str = FXProtocolParser.calculate_address(device_type, device_number) frame_bytes.extend(addr_str.encode('ascii')) count = len(values) count_str = f"{count:02d}" frame_bytes.extend(count_str.encode('ascii')) # 添加数据区 for val in values: # 将数值转换为4位十六进制ASCII字符串 hex_str = f"{val & 0xFFFF:04X}" # 确保是16位无符号数 frame_bytes.extend(hex_str.encode('ascii')) frame_bytes.append(0x03) # ETX checksum = sum(frame_bytes[1:]) & 0xFF checksum_hex = f"{checksum:02X}" frame_bytes.extend(checksum_hex.encode('ascii')) return binascii.hexlify(frame_bytes).decode('ascii').upper()4.2 位元件(X, Y, M)的读写
读写位元件(如X0, Y10, M100)与字元件(D寄存器)原理相同,但寻址方式一致(都是计算5位十进制地址)。区别在于,读位元件时,返回的数据区中,每4个ASCII字符表示的是16个连续位元件的状态(0或1),需要按位解析。写位元件时,数据区也需要按此规则组织。这增加了处理的复杂度,但协议框架不变。
4.3 错误处理与通讯超时优化
工业现场环境复杂,稳定的通讯必须包含完善的错误处理。
- 超时重试:在
send_and_receive方法中,不应只读一次。可以设置一个循环,在超时时间内未收到完整帧(通过判断ETX)则重试,最多重试3次。 - 响应完整性校验:示例中我们用了
read_all(),这依赖于PLC一次性返回完整帧。更健壮的做法是循环读取,直到收到ETX(0x03)字符,并检查帧长度是否合理。 - PLC状态检查:在发送读写帧前,可以先发送一个简单的“设备诊断”请求(协议中有对应命令),确认PLC在线且处于可通讯状态(非编程模式)。
- 日志记录:所有发送和接收的原始帧、解析结果、错误信息都应记录到文件或数据库,便于后期排查偶发性故障。
4.4 构建简易上位机监控界面
有了稳定的通讯底层,用PyQt、Tkinter或Web框架(如Flask+WebSocket)快速搭建一个图形化监控界面就水到渠成了。界面可以包含:
- 连接配置区:COM口、波特率等参数设置。
- 数据点表:以表格形式列出需要监控的软元件(如D100, M200等),并配置轮询周期。
- 实时数据显示区:以数值、进度条、指示灯等形式动态显示数据。
- 手动控制区:提供按钮或输入框,用于对Y点或M点进行置位/复位,或修改D寄存器的值。
- 数据日志与曲线:将关键数据随时间变化记录下来,并绘制趋势曲线。
这个自研的上位机,可以根据项目需求高度定制,摆脱了组态软件的束缚和授权费用,在特定场景下非常有用。
5. 实战避坑指南与常见问题排查
基于大量项目经验,我总结了一些最容易“踩坑”的地方和解决方法。
5.1 硬件与连接问题排查表
| 现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 完全无响应,发送后收不到任何数据 | 1. 物理连接断开或错误 2. 串口被其他程序占用 3. PLC电源未接通或处于编程状态 4. 电缆损坏或型号不对 | 1. 检查电缆两端是否插紧,确认是FX编程口。 2. 关闭GX Works2等所有可能占用COM口的软件。 3. 确认PLC POWER灯亮,拨动RUN/STOP开关到STOP或RUN(非编程模式)。 4. 尝试更换电缆,确认是SC-09或USB-SC09-FX for FX系列。 |
| 收到乱码或非预期响应 | 1. 串口参数设置错误 2. 电缆质量差,信号干扰 3. 接地不良 | 1.反复确认:波特率9600,数据位7,停止位1,偶校验,无流控。 2. 使用带屏蔽层的优质电缆,远离动力线。 3. 确保PLC的FG端子和电脑机壳良好接地。 |
| 偶尔通讯超时或数据错误 | 1. 现场电磁干扰严重 2. 波特率不匹配(PLC侧被修改) 3. 程序轮询过快,PLC处理不过来 | 1. 增强屏蔽,使用磁环,采用双绞线电缆。 2. 用编程软件连接一次,确认通讯参数,或通过PLC参数查看。 3. 增加轮询间隔(如从100ms增加到200ms),避免总线拥堵。 |
5.2 软件与协议层常见错误
- 地址计算错误:这是最高频的错误。务必使用我们封装好的
calculate_address函数,并确保传入的device_number是正确的十进制编号。例如,M100的编号就是100。 - 校验和计算错误:校验和是累加ASCII码值,而不是字符本身。计算范围是从命令码到ETX(包括ETX)。很多自己实现协议的开发者会漏加ETX,导致PLC返回“和校验错误”。
- 数据解析错位:响应帧的数据区是连续的ASCII字符串。例如,读取两个寄存器D100和D101,返回值可能是
04D20064,需要正确分割为04D2和0064进行解析。 - 忽略PLC错误码:响应帧第二字节(状态码)不是
0就是出错。必须解析这个代码,常见的3(帧格式错误)和4(和校验错误)能快速定位问题在请求帧的构建上。 - 字节序问题:协议返回的16位数据,高字节在前还是低字节在前?三菱FX协议是高字节在前(Big-Endian)。例如,返回的ASCII字符串
04D2,对应的16进制就是0x04D2,直接转换即可。这一点在和某些习惯小端序的系统交互时要特别注意。
5.3 性能优化与稳定性建议
- 连接池与单例模式:在需要频繁通讯的系统中,串口连接的开销较大。应设计一个串口连接管理类(单例模式),避免反复打开关闭端口。
- 异步通讯:对于需要同时监控大量数据点且要求实时性的场景,考虑使用异步IO(如Python的
asyncio+pyserial-asyncio)或单独开一个通讯线程,避免主线程被阻塞。 - 心跳与断线重连:定期向PLC发送一个简单的读命令(如读一个固定的M点状态)作为心跳包。如果连续多次失败,则触发断线重连机制,重新初始化串口连接。
- 数据缓存与变化通知:不是每次读取到数据都立刻刷新界面或上报。可以在内存中维护一个数据点缓存,只有值发生变化时,才触发后续处理,能有效减少不必要的运算和网络传输。
通过以上从原理到实践,从基础到进阶的详细拆解,相信你已经对三菱FX系列PLC的编程口通讯协议有了深入的理解。这套协议就像一把精准的螺丝刀,让你能直接拧动PLC内部的“螺丝”。虽然它比Modbus这类通用协议更繁琐,但带来的直接性和高效率是无可替代的。在实际项目中,从简单的数据采集工具到复杂的设备运维平台,这项技能都能让你在工业自动化的深水区游刃有余。最后记住,调试阶段一个可靠的串口调试助手(如AccessPort、格西烽火等)是你的最佳伙伴,它可以直观地对比你生成的帧和正常通讯的帧,是排查协议问题的最快途径。