1. 项目缘起:为什么我们要深挖FX编程口协议?
在工业自动化领域,三菱FX系列PLC堪称常青树,以其稳定可靠、性价比高的特点,在小型设备、产线工站中应用极广。无论是做设备维护、二次开发,还是做数据采集、上位机监控,我们总绕不开一个核心问题:如何与PLC“对话”?市面上有成熟的组态软件和OPC服务器,但对于需要深度定制、成本敏感或追求极致性能的项目,直接与PLC的编程口“打交道”就成了必备技能。
这个编程口,通常指的是PLC本体上那个圆形的、标着“422”的通讯口。很多新手一上来就找现成的库,比如用一些封装好的DLL,写两行代码能读到数据就以为万事大吉。但一旦遇到通讯不稳定、数据错乱、或者需要实现一些特殊功能时,就会一头雾水。我见过太多项目,因为底层通讯协议理解不透彻,导致现场调试时问题频发,最后只能推给“干扰”或“PLC老了”,其实根子在于对协议本身一知半解。
所以,这次我们不依赖任何现成的商业库,就从一个最朴素的目标开始:从零开始,用代码“敲开”FX系列PLC编程口,理解每一字节数据的来龙去脉,实现最基础的读写操作。这不仅是解决一个具体的技术问题,更是掌握一种与工业设备底层交互的思维方式。当你真正吃透了它,无论是面对三菱的其他系列,还是其他品牌的PLC,其通讯逻辑都能触类旁通。
2. FX编程口通讯协议核心:MC协议的精简与变体
三菱FX系列PLC编程口使用的协议,是基于其Melsec Communication Protocol(简称MC协议)的一种简化版本,有时也被称为“FX协议”或“编程口协议”。它运行在RS-422物理接口上(通过编程电缆转换为RS-232或USB与电脑连接),是一种主从问答式的串行通讯协议。
2.1 协议的核心特征与帧结构
与标准的Modbus等协议不同,FX编程口协议是面向字节的,而不是面向寄存器的。这意味着我们操作的最小单位是PLC内部的“位”(Bit)和“字”(Word)软元件,如X、Y、M、D等。协议帧可以看作由三部分组成:控制代码、文本段和结束符。一个完整的命令帧(从计算机到PLC)和响应帧(从PLC到计算机)都遵循这个结构。
一个典型的读命令帧(ASCII模式)看起来是这样的:
STX CMD ADDRESS DATA_NUM ETX CHECK_SUM- STX (Start of Text): 帧开始符,固定为
0x02(ASCII模式)或0x05(二进制模式,更常用)。 - CMD (Command): 命令代码,例如读位元件是
0x30,写字元件是0x31。 - ADDRESS: 要读写的软元件地址,需要转换成特定的5字符ASCII码格式。
- DATA_NUM: 要读写的点数,同样需要转换。
- ETX (End of Text): 帧结束符,固定为
0x03。 - CHECK_SUM: 校验和,从CMD到ETX所有字节的累加和,取低8位,再转换成2位ASCII码。
这里有一个关键点:地址和点数都需要进行“ASCII化”和“反转”处理。例如,我们要读取D100开始的2个数据。首先,地址100要转换成5位十六进制数0064H,然后每两位字节交换(三菱的习惯,低字节在前),变成64 00,再转换成ASCII码0x36, 0x34, 0x30, 0x30。点数2转换成0002H,交换后为02 00,再ASCII化为0x30, 0x32, 0x30, 0x30。这个过程是新手最容易出错的地方。
2.2 二进制模式与ASCII模式的选择
协议通常支持两种模式:ASCII模式和二进制(Binary)模式。在实际项目中,我强烈推荐并默认使用二进制模式。原因很简单:效率。ASCII模式下,一个字节的数据(如0x0A)需要被编码成两个ASCII字符(0x31, 0x41,即“1”和“A”),帧长度翻倍,传输效率减半。在需要实时性的数据采集场景下,这无疑是无法接受的。二进制模式直接传输十六进制值,帧结构紧凑,是工业通讯的首选。我们后续的实例也将基于二进制模式展开。
2.3 软元件地址的“编码学”
理解软元件地址的编码规则,是成功通讯的基石。不同软元件类型有各自的地盘:
- X, Y, M, S, TS, CS等位元件:地址计算方式是
元件类型代码 + 十进制地址。例如,X20的地址编码,需要先知道X的元件类型码(假设为0x9C),然后计算20的二进制/十六进制表示。但请注意,FX系列中,像X、Y这类元件,其地址编码往往不是简单的十进制转十六进制,而是涉及到位(bit)的偏移计算,在批量读取时尤其要注意。 - D, T, C等字元件:相对直观。例如D100,地址就是100(十进制),转换为十六进制
0064H,然后进行前述的字节交换。
这里有一个极易踩坑的细节:对于位元件(如M)的批量读取,协议规定一次最多只能读取256个点。但如果你天真地以为读M0到M255就是256个点,那就错了。因为M8000以上的特殊辅助继电器地址空间不连续,在计算地址偏移时需要特别小心。我的经验是,在编写通用读写函数时,一定要对地址和点数做严格的合法性校验,防止发出非法帧导致PLC无响应或报错。
3. 手把手实现:从串口配置到数据读写
理论说得再多,不如一行代码。下面,我将以C#语言为例,结合System.IO.Ports,展示如何一步步实现与FX3U PLC的编程口通讯。选择C#是因为其在Windows上位机开发中普及率高,原理同样适用于C++、Python等语言。
3.1 环境准备与串口初始化
首先,你需要一根可靠的USB-SC09-FX编程电缆(或兼容电缆),并安装好对应的USB转串口驱动。在设备管理器中确认好COM口号。
using System.IO.Ports; using System.Text; public class FXSerialComm { private SerialPort _serialPort; private string _comPort; private int _baudRate; public FXSerialComm(string comPort, int baudRate = 9600) { // FX编程口默认波特率通常是9600,但部分新型号或设置后可能为19200等 _comPort = comPort; _baudRate = baudRate; } public bool Open() { try { _serialPort = new SerialPort(_comPort, _baudRate, Parity.Even, 7, StopBits.One); // 关键参数! _serialPort.ReadTimeout = 1000; // 设置读取超时,避免死等 _serialPort.WriteTimeout = 500; _serialPort.Open(); return _serialPort.IsOpen; } catch (Exception ex) { Console.WriteLine($"打开串口失败: {ex.Message}"); return false; } } public void Close() { if (_serialPort != null && _serialPort.IsOpen) _serialPort.Close(); } }注意:串口参数是第一个关键点。数据位7,偶校验,停止位1(7,E,1)是FX编程口通讯的典型设置。如果参数不对,你收到的将是乱码或无响应。有些电缆或驱动可能需要额外的流控制(RTS/DTR)设置,具体需参考电缆手册。
3.2 核心帧构造与校验和计算
我们实现一个构建二进制模式读命令帧的私有方法。以读取字元件(D寄存器)为例。
private byte[] BuildReadWordCommand(string deviceType, int startAddress, int points) { // 1. 固定帧头 List<byte> frame = new List<byte>(); frame.Add(0x05); // ENQ,二进制模式起始符 // 2. 命令码: 读字元件为 0x30, 写字元件为 0x31。读位元件(如M)为 0x30,但后续地址编码不同。 byte commandCode = 0x30; // 假设为读字 frame.Add(commandCode); // 3. 子命令/站号等(对于FX编程口,通常为0xFF) frame.Add(0xFF); frame.Add(0xFF); // 4. 等待时间(PLC处理时间),单位ms,通常设0 frame.Add(0x00); // 5. 构造软元件地址部分(这是最复杂的部分) // 假设读取 D100 开始的2个点 // 地址 100 -> 十六进制 0064H -> 字节交换后 64 00 // 需要转换为4字节的ASCII码?不,在二进制模式下,我们直接发送字节值。 // 实际上,FX协议二进制模式下,地址部分是一个4字节的二进制数。 ushort address = (ushort)startAddress; // 例如 100 // 将地址转换为低字节在前(Little-Endian)的两个字节 byte[] addressBytes = BitConverter.GetBytes(address); // 结果如 [100, 0] // 但根据协议文档,地址可能需要按特定格式组装,包含软元件类型标识。 // 对于D寄存器,其软元件代码是 0xA8。 // 一个典型的地址块是: 软元件类型(1字节) + 地址值(3字节) // 例如: D100 -> [0xA8, 0x00, 0x00, 0x64] (注意地址100=0x64放在最后一个字节) byte[] fullAddress = new byte[4]; fullAddress[0] = 0xA8; // D寄存器的类型码 fullAddress[1] = 0x00; fullAddress[2] = 0x00; fullAddress[3] = (byte)(address & 0xFF); // 地址低字节 // 注意:这里做了简化。实际地址可能超过255,需要用到addressBytes[1]。 fullAddress[2] = (byte)((address >> 8) & 0xFF); // 地址高字节 frame.AddRange(fullAddress); // 6. 读写点数 // 点数同样需要处理。例如读2个点 -> 0x0002 -> 字节交换后 02 00 ushort pointCount = (ushort)points; byte[] pointsBytes = BitConverter.GetBytes(pointCount); // [2, 0] frame.AddRange(pointsBytes); // 7. 帧尾和校验(简化,实际可能以ETX结束并计算校验和) // FX二进制协议帧可能以固定的结束符如 0x04 (EOT) 结束,也可能需要计算校验和。 // 这里演示一个常见格式:在数据后直接结束。 // 更完整的格式是: STX(0x02) + 数据区 + ETX(0x03) + 校验和 // 注意:不同系列/模式可能有差异,务必以官方手册为准。 // 以下为示例性代码,强调校验和计算: List<byte> dataForChecksum = new List<byte>(); // 假设从命令码开始到点数之前的数据参与校验 for(int i=1; i<frame.Count; i++) // 跳过开始的0x05 dataForChecksum.Add(frame[i]); byte checksum = CalculateChecksum(dataForChecksum.ToArray()); frame.Add(checksum); return frame.ToArray(); } private byte CalculateChecksum(byte[] data) { int sum = 0; foreach (byte b in data) sum += b; return (byte)(sum & 0xFF); // 取低8位 }这段代码揭示了协议构造的核心:地址转换和校验和计算。我强烈建议你将这部分逻辑封装成独立的函数,并进行充分的单元测试。你可以先用串口调试工具(如AccessPort、格西烽火等)手动构造一个已知正确的帧,与你的函数生成的帧进行逐字节对比,这是调试协议最有效的方法。
3.3 发送、接收与响应解析
构造好命令帧后,就是发送和接收了。PLC的响应帧也有固定格式,我们需要正确解析。
public byte[] ReadDRegisters(int startAddress, int points) { if (!_serialPort.IsOpen) return null; byte[] commandFrame = BuildReadWordCommand("D", startAddress, points); _serialPort.DiscardInBuffer(); // 清空输入缓冲区,避免旧数据干扰 _serialPort.Write(commandFrame, 0, commandFrame.Length); // 读取响应 // 首先,读取响应起始符。可能是 0x02 (STX) 或直接是数据。 Thread.Sleep(50); // 给予PLC少量处理时间,时间根据波特率和数据量调整 int bytesToRead = _serialPort.BytesToRead; if (bytesToRead == 0) { Console.WriteLine("未收到PLC响应"); return null; } byte[] responseBuffer = new byte[bytesToRead]; _serialPort.Read(responseBuffer, 0, bytesToRead); // 解析响应 // 一个成功的读响应帧结构可能是: STX(0x02) + 状态码 + 数据 + ETX(0x03) + 校验和 // 状态码为0表示正常。 if (responseBuffer.Length < 5) // 最小长度判断 { Console.WriteLine("响应帧长度异常"); return null; } if (responseBuffer[0] != 0x02) // 检查STX { Console.WriteLine("响应帧起始符错误"); return null; } byte status = responseBuffer[1]; if (status != 0x00) { Console.WriteLine($"PLC返回错误状态码: 0x{status:X2}"); return null; } // 提取数据部分。假设数据紧跟在状态码之后。 // 需要知道数据长度。对于读字命令,每个点返回2字节数据。 int dataLength = points * 2; int dataStartIndex = 2; // 假设数据从索引2开始 byte[] dataBytes = new byte[dataLength]; Array.Copy(responseBuffer, dataStartIndex, dataBytes, 0, Math.Min(dataLength, responseBuffer.Length - dataStartIndex - 2)); // -2 预留ETX和校验和位置 // 验证帧结束和校验和(此处省略详细校验代码) // ... // 数据字节顺序转换:PLC返回的数据通常是低字节在前 ushort[] result = new ushort[points]; for (int i = 0; i < points; i++) { int idx = i * 2; result[i] = (ushort)((dataBytes[idx + 1] << 8) | dataBytes[idx]); // 高低字节交换 } return dataBytes; // 或返回 result }在解析响应时,超时处理和异常帧判断至关重要。工业现场环境复杂,干扰可能导致数据错位或丢失。我的做法是:设置合理的串口超时;对接收到的每一个字节进行逻辑判断(如起始符、结束符、数据长度);如果校验失败,则丢弃该帧并重试(需有重试次数上限,避免死循环)。一个健壮的通讯程序,必须能从容应对这些“脏数据”。
4. 实战避坑指南:那些手册上不会写的细节
掌握了基本读写,只算成功了一半。真正让项目稳定运行的,往往是下面这些从坑里爬出来的经验。
4.1 通讯超时与重试机制
PLC不是电脑,它的扫描周期和响应优先级决定了其响应速度。绝对不能使用同步阻塞读取且无限等待的方式。我推荐采用“发送-等待-超时重发”的机制。
public byte[] SendCommandWithRetry(byte[] command, int maxRetries = 3) { int retryCount = 0; while (retryCount < maxRetries) { try { _serialPort.Write(command, 0, command.Length); byte[] response = WaitForResponse(500); // 自定义等待响应的方法,超时500ms if (response != null && ValidateResponse(response)) return response; } catch (TimeoutException) { Console.WriteLine($"第{retryCount+1}次尝试超时"); } catch (Exception ex) { Console.WriteLine($"发送命令异常: {ex.Message}"); break; // 非超时异常,直接退出 } retryCount++; Thread.Sleep(100); // 重试前稍作等待 } Console.WriteLine($"命令发送失败,已达最大重试次数{maxRetries}"); return null; }同时,在WaitForResponse方法中,不要一次性读取所有BytesToRead然后傻等。应该循环读取,直到收到完整的帧(通过判断结束符ETX或计算出的预期长度),或者超时。这能有效应对数据分包到达的情况。
4.2 软元件地址的“边界”与“黑洞”
不同型号的FX PLC,其软元件范围是有区别的。例如,FX1N的D寄存器可能到D128,而FX3U可以到D7999。如果你试图读取一个不存在的地址,PLC可能不会报错,但返回的数据是未定义的(通常是0)。更棘手的是位元件和字元件混合编址的问题。
比如,你想连续读取M0到M15这16个位,然后紧接着读D0。你不能简单地用一个“读字”命令去读M区,因为M是位元件。你需要用“读位”命令(命令码也是0x30,但软元件类型码不同)将M0-M15的状态读回来(每个点占1位,通常以字节为单位返回),然后再发一个“读字”命令读D0。协议本身不支持跨软元件类型的连续读取。在设计数据映射表时,必须按软元件类型分组打包请求,这会增加通讯帧的数量和复杂度。
4.3 校验和的“陷阱”与字节顺序
校验和计算看起来简单,但极易出错。第一,确认计算范围:是从命令码开始到ETX结束,还是包括STX?不同文档可能有不同描述,必须用实际PLC测试验证。第二,注意字节顺序(Endianness):在构造地址和点数时,三菱协议常用“低字节在前,高字节在后”的方式。但在解析PLC返回的16位整数(如D寄存器值)时,同样需要将接收到的高低字节顺序反转。例如,你收到两个字节[0x2C, 0x01],它代表的数值是0x012C,即十进制300,而不是0x2C01。
一个实用的调试技巧是:先用成熟的第三方软件(如三菱GX Works2的通讯监控功能,或一些免费的PLC调试助手)捕获一次成功的通讯数据包。将捕获到的原始十六进制数据与你程序构造的数据进行逐字节比对,这是排查协议层错误最快的方法。
4.4 长连接下的稳定性与“心跳”
对于需要长时间运行的数据采集系统,不能假设连接永远可靠。除了基本的重试机制,建议实现一个简单的心跳或状态轮询。例如,周期性地读取PLC的某个特定寄存器(如D8000,系统时钟寄存器),或者一个自增的计数器。如果连续多次心跳失败,则判定为通讯中断,触发重新初始化串口甚至报警。这比等到业务数据读失败再处理要主动得多。
另外,避免在PLC的扫描周期结束时进行密集的写操作。大量写命令可能会轻微影响PLC的扫描时间。对于非实时性要求极高的写操作,可以适当降低写频率,或将其分散在不同周期进行。
5. 协议扩展:从读写到监控与调试
掌握了基础的读写,这个协议还能玩出什么花样?其实,它还能做很多有用的事情。
5.1 批量读写优化策略
如果需要读取上百个分散的D寄存器,逐条发送命令效率极低。协议支持连续读取多个点,但一次读取的点数有限制(例如最多64个字)。我们需要设计一个批量调度算法:将需要读取的地址按连续块分组,每个块生成一个读命令。这比随机单点读取的吞吐量高一个数量级。对于写操作亦然,尽量将需要写入的连续地址合并成一个写命令帧。
5.2 实时监控与状态触发
通过编程口协议,可以实现对特定位元件(如急停按钮X0,运行标志M8000)的状态监控。一种简单的做法是短周期轮询。但更高效的方式是利用协议的“随机读”特性,在一个帧内读取多个不连续的位状态。虽然协议本身没有“订阅”或“变化通知”机制,但通过高频率的轮询关键点,可以在上位机实现近乎实时的状态监控和事件触发(如报警触发、产量计数)。
5.3 辅助调试:读取PLC型号与运行状态
协议中有些命令可以读取PLC的系统信息,例如PLC型号、系列代码、运行/停止状态等。例如,发送特定的命令帧可以查询PLC状态(0x71命令)。这在构建上位机系统时非常有用,可以在连接初期自动识别PLC类型,并监控PLC的运行模式,避免在STOP状态下进行无意义的写操作。
// 示例:构建读取PLC型号的命令帧(命令码可能为0x01) private byte[] BuildGetPlcTypeCommand() { // 这是一个简化的示例,实际命令帧格式需查手册 List<byte> frame = new List<byte>(); frame.Add(0x05); // ENQ frame.Add(0x01); // 假设0x01为读型号命令 frame.Add(0xFF); frame.Add(0xFF); frame.Add(0x00); // 等待时间 // ... 补充其他必要字节 frame.Add(CalculateChecksum(frame.ToArray(), 1)); // 从命令码开始校验 return frame.ToArray(); }解析返回的帧,就能得到像“FX3U-64MT”这样的字符串,这对于自动化配置和日志记录很有帮助。
6. 超越编程口:其他通讯方式的思考
虽然编程口协议直接、无需额外模块,但它有其局限性:速度慢(受限于串口波特率,通常9600bps)、距离短、无法多主站通讯。当项目需求超出这些限制时,我们就需要考虑其他方案。
FX-232BD/FX-485BD通讯板:在PLC上安装这些扩展板,可以提供标准的RS-232或RS-485接口,可以使用更通用的协议(如无协议通讯、Modbus RTU从站)与上位机或其他设备连接。RS-485支持多点连接,距离可达千米。
以太网通讯(FX3U-ENET等):对于FX3U等新型号,可以增加以太网模块。通讯协议也升级为基于TCP/IP的MC协议(MC-Ethernet)。这带来了质的飞跃:百兆带宽、远程访问、多客户端连接。协议帧的基本逻辑(命令码、地址编码)与串口版MC协议一脉相承,只是传输层变成了TCP Socket。如果你已经吃透了串口编程口协议,过渡到以太网协议会非常轻松。
与触摸屏(HMI)的共存:一个常见场景是PLC既要用编程口连接你的上位机,又要连接昆仑通态、威纶通等触摸屏。很多触摸屏也使用编程口进行通讯。这时必须注意,编程口是单主站协议,同一时间只能有一个主设备(电脑或HMI)与之通讯。常见的做法是让HMI作主站,你的上位机通过HMI提供的穿透功能(如果支持)间接访问PLC,或者为上位机增加额外的通讯模块(如485BD),与HMI使用不同的通讯通道。
从头实现FX编程口通讯,就像亲手拆解一个精密的机械钟表。过程可能充满挑战,需要反复查阅手册、抓包调试、处理边界情况。但一旦你走通了这条路,收获的不仅是一个可用的通讯驱动,更是对工业设备底层数据交换的深刻理解。这种理解,让你在面对更复杂的系统、更诡异的现场问题时,能有拨云见日的底气和思路。记住,最可靠的代码,往往来自于你对每一个字节的掌控。