简介:《基于QT的三菱FX3U PLC编程口通信详解》配套压缩包,面向工业自动化上位机开发初学者,以及需要打通QT与三菱FX系列PLC通信的工程师,聚焦编程口串行通信这一典型场景。包内共7个文件,以2个cpp源文件、2个头文件及ui界面文件、pro工程文件为主,另有user配置文件,压缩包约12KB,体量轻便,便于导入Qt Creator阅读与调试。源码围绕QSerialPort串口参数配置、数据收发与FINS指令解析展开,包含读写PLC寄存器、执行控制指令及通信异常处理与重试思路;界面部分提供基础交互入口,工程结构简洁,适合按串口初始化、报文组装、响应解析逐层拆读,可作为理解编程口协议和搭建自有通信模块的参考。目前已有1004人学习下载,适合希望从示例代码入手掌握QT串口通信、对照排查连接与数据包问题的读者。
1. 从一根编程口线开始:FX_serial_asst.rar 到底解决哪类串口通信活
FX_serial_asst.rar 这个包,冲的就是 FX 系列 PLC 编程口通信里最琐碎的那段:用 Qt 写上位机,不依赖大型编程软件,自己拼帧、算校验、收响应。现场常见的场景是设备上只留一个 MD8 编程口,想读 D 寄存器、写几个参数、做个产线小采集盒,结果卡在 7 位数据位、偶校验和两位十六进制校验和上。这个资源适合已经会一点串口、但不想从零啃协议的人;也适合做课程设计或小工具原型的开发者。它把编程口通信的帧构造、串口参数、联调样例收在一起,拿下来先跑通一条读 D0 的响应,再往自己的 Qt 工程里搬。不要把它当成万能驱动,它更像一个可对照的“通信样板间”。
2. 先立住协议:FX 编程口帧结构、校验和与地址映射
很多串口助手能打开口,却读不出 FX 编程口的数据,问题不在代码,在协议层没对齐。编程口不是 Modbus,帧头帧尾、命令字符、地址格式、校验和都有自己的规矩。先把四件事拆开:物理层怎么接、串口参数怎么设、请求帧每个字段什么意思、校验和和地址怎么算。后面用 Qt 或 Python 只是把这些字段按顺序写进缓冲区。这一章不急着打开串口,先把纸面协议对齐,后面少走很多弯路。
2.1 物理层与串口参数:9600 7E1 不是随便填
FX 系列 PLC 的编程口常见是 MD8 圆头,电气上是 RS422 差分四线:发送正、发送负、接收正、接收负,外加信号地。它和 RS232 的三线收发不是一回事,拿一条只接 TX/RX/GND 的线去怼,最容易出现“发得出去、收不回来”。现场常用的是 USB 转串口编程线,线内已经做了差分转换;如果你自己接线,先确认 A/B 差分对没有反,再确认 PLC 侧和转换器侧是交叉连接还是直连。这个环节没有玄学,线序错了后面所有帧都白搭。
串口参数是第二个固定项:9600 bps、7 位数据位、偶校验、1 位停止位、无流控。很多串口助手默认 8N1,打开后能收到一堆乱码,或者校验和怎么算都不对,原因就是数据位多了一位。编程口协议里传输的都是 ASCII 字符,0x00 到 0x7F 足够表示,7 位数据位不是省资源,而是协议约定。偶校验负责让每帧字符的 1 的个数保持偶数,PLC 侧会校验这个位;停止位固定 1 位,流控不要开。这个参数最容易被忽略,翻车多半在这里。
| 参数 | 常见设置 | 说明 |
|---|---|---|
| 波特率 | 9600 | 多数 FX 编程口默认值,改之前先看 PLC 侧参数 |
| 数据位 | 7 | 不是 8,协议字符为 ASCII |
| 校验 | 偶校验 | Even,PLC 侧参与校验 |
| 停止位 | 1 | 固定一位 |
| 流控 | 无 | 不要开 RTS/CTS 或 XON/XOFF |
| 物理层 | RS422 四线 | 差分收发,注意正负和交叉 |
提示:先用包内助手按 9600 7E1 发一条固定读帧,确认线序和参数,再把同样的设置搬进 Qt 工程。不要一上来就在代码里改波特率试。
2.2 读帧与写帧的字段拆解:STX、命令、地址、长度、ETX、校验
编程口的请求帧是 ASCII 可见字符加两个控制字符。帧头 STX 是 0x02,帧尾 ETX 是 0x03,中间依次是命令、地址、长度,写帧再多一段数据区,最后跟两位 ASCII 十六进制校验和。命令字符用 '0' 表示读,用 '1' 表示写;地址是 4 位 ASCII 十六进制,表示软元件在对应区里的偏移地址;长度是 2 位 ASCII 十六进制,表示读写的字数,常见上限是 0x40,超过就分帧。数据区每个字用 4 位 ASCII 十六进制表示,低字在前还是高字在前按资源内协议表来,不要凭感觉调。
以读 D0 一个字为例,D 区基址常见是 0x1000,D0 的地址字符串就是 "1000",长度字符串是 "01",命令是 '0'。组出来的字节序列是:STX、'0'、"1000"、"01"、ETX、校验高位、校验低位。按常见校验算法算,十六进制显示类似02 30 31 30 30 30 30 31 03 35 37。这里的30是字符 '0',31是字符 '1',不是数值 0 和 1。写 D0 一个值 0x1234 时,命令换成 '1',长度仍是 "01",数据区加 "1234",再算校验。读响应一般没有命令回显,格式是 STX + 数据区 + ETX + 校验;写响应常见是 STX + ACK 字符 + ETX + 校验,具体以包内说明为准。
| 字段 | 长度 | 读 D0 示例 | 说明 |
|---|---|---|---|
| STX | 1 字节 | 0x02 | 帧头 |
| 命令 | 1 字符 | '0' | '0' 读,'1' 写 |
| 地址 | 4 字符 | "1000" | D0 常见基址 0x1000 |
| 长度 | 2 字符 | "01" | 读写字数,十六进制 ASCII |
| 数据区 | 4×N 字符 | 无 | 写帧才有,每个字 4 位 ASCII |
| ETX | 1 字节 | 0x03 | 帧尾 |
| 校验 | 2 字符 | "57" | 低 8 位累加和转大写十六进制 |
注意:地址和长度是 ASCII 字符串,不是二进制整数。用
f"{addr:04X}"得到的是字符串,再.encode("ascii"),不要直接struct.pack。
2.3 校验和算法与地址换算的边界
校验和最容易出两种错:范围算错、大小写不统一。常见做法是把命令字符到 ETX(含 ETX)的所有字节做累加,取低 8 位,再格式化成两位大写十六进制 ASCII。也有的实现把 STX 一起算进去,或者不算 ETX,这两种在不同资料里都能看到。稳妥做法是以资源包内协议表为准,先用一条已知正确的读 D0 帧反推范围,再固定到代码里。不要一边改范围一边改地址,否则排查时根本分不清是校验错还是地址错。
下面这段 Python 把帧构造和校验和放在一起,地址参数传数值,函数内部转 4 位十六进制字符串。读帧和写帧共用同一个校验函数,写帧的数据区按每个字 4 位大写十六进制拼接。
# fx_frame.py STX = 0x02 ETX = 0x03 def checksum(payload: bytes) -> str: """payload 为命令字符到 ETX(含 ETX)的字节序列""" total = sum(payload) & 0xFF return f"{total:02X}" def build_read(addr: int, length: int = 1) -> bytes: # addr 传软元件地址数值,例如 D0 常见为 0x1000 addr_str = f"{addr:04X}" len_str = f"{length:02X}" body = b"0" + addr_str.encode("ascii") + len_str.encode("ascii") frame = bytes([STX]) + body + bytes([ETX]) cs = checksum(body + bytes([ETX])) return frame + cs.encode("ascii") def build_write(addr: int, values: list[int]) -> bytes: addr_str = f"{addr:04X}" len_str = f"{len(values):02X}" data_str = "".join(f"{v & 0xFFFF:04X}" for v in values) body = b"1" + addr_str.encode("ascii") + len_str.encode("ascii") + data_str.encode("ascii") frame = bytes([STX]) + body + bytes([ETX]) cs = checksum(body + bytes([ETX])) return frame + cs.encode("ascii") if __name__ == "__main__": print(build_read(0x1000, 1).hex(" ").upper())这段代码里,addr不是 D0 的 0,而是 D 区地址数值,常见基址 0x1000 再加偏移;length最大按 0x40 控制,超过就拆成多次请求;values每个元素按 16 位字处理,& 0xFFFF防止负数或超范围值把字符串撑成 5 位。校验函数只接收“命令到 ETX”的字节,STX 不参与,这是常见实现。运行后输出类似02 30 31 30 30 30 30 31 03 35 37,拿这个和包内助手发出的帧对比,能快速确认校验范围。
地址换算的边界要单独说。D 区常见基址是 0x1000,D100 就是 0x1064,格式化成 "1064";M、X、Y 各区基址不同,不能拿 D 区的公式硬套。长度填 0 没有意义,填超过 PLC 允许的单帧上限会被拒或返回错误。地址超出 PLC 实际软元件范围时,有的型号返回错误帧,有的直接不响应,表现很像“线断了”。我一般先把地址限制在 D0 到 D100 之间做联调,确认通了再扩范围。包里如果有地址映射表,优先用表里的值,不要用记忆里的偏方。
3. 把资源跑起来:从串口枚举到第一条读 D 寄存器响应
协议对齐后,下一步是让电脑真正发出一条帧并收到响应。资源包里的助手可以先用来确认端口、线序和参数,但最终要落到自己的代码里。这一章用 Python 和 pyserial 做最小复现,重点不是写一个完整上位机,而是把“枚举端口、打开串口、发读帧、解析响应、超时重试”这条链路跑通。跑通之后再搬进 Qt,问题范围会小很多。
3.1 环境准备与串口权限
Windows 上插上 USB 转串口线后,设备管理器里会多出一个 COM 口,记下编号。Linux 下一般是/dev/ttyUSB0或/dev/ttyACM0,用ls /dev/tty*看插入前后的变化。Python 环境装 pyserial 就够,不需要额外驱动包。安装命令如下:
python -m pip install pyserial装完后先枚举端口,确认代码看到的端口和系统里的一致。下面这段代码会列出当前可用串口名称和描述,Windows 和 Linux 都能跑。
import serial.tools.list_ports for p in serial.tools.list_ports.comports(): print(p.device, p.description, p.hwid)comports()返回的是对象列表,device是打开串口时要传的名称,description用来区分哪个是编程线,hwid里能看到 USB 转串口的 VID/PID。如果列表为空,先查驱动和线,不要继续改代码。Linux 下普通用户可能没有串口权限,常见做法是把当前用户加入dialout组,重新登录后生效;临时测试也可以用sudo跑一次,但不要长期用 root 跑采集脚本。
提示:枚举端口时把编程线拔插一次,看哪个设备消失又出现,比看描述更准。多根线同时插时,不要凭 COM 编号猜。
3.2 用 Python 模拟资源里的请求帧
下面这段代码把上一章的帧构造接上串口。打开串口时显式设置 9600、7 位数据位、偶校验、1 位停止位、无流控,超时 0.3 秒。发帧前清一次输入缓冲区,避免上次残留的响应混进来;读的时候一次读最多 64 字节,读不到就重试。重试三次仍为空就报错,不要无限循环。
import time import serial from fx_frame import build_read, parse_read_response ser = serial.Serial( port="COM3", # 换成实际端口 baudrate=9600, bytesize=serial.SEVENBITS, # 7 位数据位 parity=serial.PARITY_EVEN, # 偶校验 stopbits=serial.STOPBITS_ONE, timeout=0.3, # 单次读超时 ) frame = build_read(0x1000, 1) # 读 D0 一个字 raw = b"" for attempt in range(3): ser.reset_input_buffer() ser.write(frame) raw = ser.read(64) if raw: break time.sleep(0.05) print("TX:", frame.hex(" ").upper()) print("RX:", raw.hex(" ").upper()) if raw: print("values:", parse_read_response(raw)) ser.close()port必须换成枚举出来的实际端口;bytesize用SEVENBITS而不是默认的EIGHTBITS;timeout控制单次读阻塞时间,0.3 秒对 9600 波特率下的一帧足够;reset_input_buffer()清掉旧数据,避免把上一次的响应当成这一次的;read(64)是上限,不是每次都能读满。重试之间加 50 毫秒间隔,给 PLC 一点处理时间。如果 TX 打印正确但 RX 一直为空,先回到助手确认线序和参数,不要继续加长超时。
响应解析函数按“STX + 数据区 + ETX + 两位校验”处理。先检查帧头和 ETX 位置,再对“数据区 + ETX”算校验,最后把数据区按每 4 个字符切成一个 16 位字。数据区长度不是 4 的倍数时直接报错,避免把半包当完整帧解析。
from fx_frame import STX, ETX, checksum def parse_read_response(raw: bytes) -> list[int]: if len(raw) < 5 or raw[0] != STX or raw[-3] != ETX: raise ValueError("帧头/帧尾不匹配") body = raw[1:-3] # 数据区 cs_recv = raw[-2:].decode("ascii").upper() cs_calc = checksum(raw[1:-2]) # 数据区 + ETX if cs_recv != cs_calc: raise ValueError(f"校验和错误: recv={cs_recv}, calc={cs_calc}") if len(body) % 4 != 0: raise ValueError("数据区长度不是 4 的倍数") return [int(body[i:i+4], 16) for i in range(0, len(body), 4)]raw[-3]是 ETX 的位置,raw[-2:]是两位 ASCII 校验;raw[1:-2]正好是数据区加 ETX,传给校验函数。数据区每个字 4 位十六进制,int(..., 16)转成整数。这个函数只处理读响应,写响应里的 ACK 字符要单独判断,不要混用。
3.3 响应解析与超时重试
串口通信没有“发出去就一定回”的保证。PLC 忙、线序接触不良、地址越界、校验错,都可能表现为超时。重试策略要区分“没收到任何字节”和“收到半包”。没收到任何字节可以重发;收到半包不要立刻重发,先把缓冲区里的数据留下来,等下一次read补齐,或者按超时清掉。上面代码每次重试前清输入缓冲区,适合调试阶段;正式采集时我一般保留一个接收缓冲区,按 STX/ETX 找完整帧,避免把半包丢掉。
超时时间不要设得太短。9600 波特率下,一个字节大约 1 毫秒,读 64 个字的一帧加上 PLC 处理时间,0.3 秒比较稳。重试次数 3 次够用,再多会把界面拖慢。每次重试记录 TX/RX 的十六进制日志,出问题时能回放。日志里至少包含时间、端口、TX、RX、错误类型,不要只打“通信失败”。后面在 Qt 里也是同一套逻辑:先找帧头,再等帧尾和校验,最后解析。
4. 在 Qt 工程里接上:QSerialPort 的线程模型与帧拼包
Python 跑通后,搬到 Qt 主要是换 API,协议层不变。Qt 的QSerialPort支持 7 位数据位和偶校验,打开口之前把参数设全,打开之后用readyRead信号收数据。容易出问题的地方有两个:一是在 UI 线程里用waitForReadyRead阻塞等数据,界面直接卡死;二是把readyRead当成“一次一定收到完整帧”,结果半包、粘包一起出现。这一章把初始化、拼包、联调分开写。
4.1 QSerialPort 初始化参数与信号槽
初始化时端口名、波特率、数据位、校验、停止位、流控一个都不能少。setDataBits(QSerialPort::Data7)和setParity(QSerialPort::EvenParity)是编程口的关键,默认值不是这个。打开失败时打印errorString(),不要只弹一个“打开失败”。打开成功后连接readyRead,在槽里readAll()追加到缓冲区,再调用解析函数。不要在槽里做长时间计算,解析出数据后通过信号发给界面。
QSerialPort port; port.setPortName("COM3"); port.setBaudRate(QSerialPort::Baud9600); port.setDataBits(QSerialPort::Data7); port.setParity(QSerialPort::EvenParity); port.setStopBits(QSerialPort::OneStop); port.setFlowControl(QSerialPort::NoFlowControl); if (!port.open(QIODevice::ReadWrite)) { qWarning() << "open failed:" << port.errorString(); return; } connect(&port, &QSerialPort::readyRead, this, [this, &port]() { rxBuf.append(port.readAll()); parseBuffer(); });setPortName传系统里的端口名,Windows 是COM3这种,Linux 是/dev/ttyUSB0;Baud9600对应 9600;Data7和EvenParity必须显式设置;NoFlowControl避免 RTS/CTS 干扰。readyRead只表示“有数据可读”,不保证是一帧。rxBuf是成员变量,跨多次信号累积。打开失败时先看errorString(),常见是端口被助手占用或权限不足。
注意:不要在 UI 线程里循环
waitForReadyRead()。Qt 的事件循环被阻塞后,界面不刷新,串口信号也进不来,表现像“程序死了”。
4.2 半包/粘包处理与状态机
拼包的核心是“找 STX、找 ETX、等校验两位”。缓冲区里可能同时有多帧,也可能只有半帧。每次readyRead后循环处理:先找 STX,把前面的杂字节删掉;再找 ETX,找不到就返回等下一次;找到 ETX 后确认后面还有两位校验,不够也返回;够就切出STX...ETX+2的完整帧,从缓冲区移除,再校验和解析。这样粘包会被拆成多帧,半包会留在缓冲区里等补齐。
void parseBuffer() { while (true) { int stx = rxBuf.indexOf(char(0x02)); if (stx < 0) { rxBuf.clear(); return; } if (stx > 0) { rxBuf.remove(0, stx); } int etx = rxBuf.indexOf(char(0x03)); if (etx < 0) { return; // 半包,等下一次 readyRead } if (rxBuf.size() < etx + 3) { return; // 校验两位还没到 } QByteArray frame = rxBuf.left(etx + 3); rxBuf.remove(0, etx + 3); processFrame(frame); } }indexOf(char(0x02))找帧头,找不到说明缓冲区里没有有效帧,直接清掉;stx > 0说明前面有杂字节,删掉;indexOf(char(0x03))找帧尾,找不到就返回,保留半包;etx + 3是 ETX 本身加两位校验,长度不够也返回;切出完整帧后从缓冲区移除,再交给processFrame。processFrame里再做校验和、数据区长度检查,校验不过就丢弃并记日志,不要拿错帧去解析。
4.3 与资源内助手的联调方法
联调时不要同时开两个程序抢同一个串口。先用包内助手发固定读 D0 帧,确认能收到响应,记下 TX/RX 十六进制;关掉助手,再用 Qt 程序发同一帧,对比两边 TX 是否一致。如果 TX 一致但 RX 不同,问题在接收参数或拼包;如果 TX 不一致,问题在帧构造。下面这张表可以对着改。
| 项目 | 助手设置 | Qt 设置 | 常见偏差 |
|---|---|---|---|
| 波特率 | 9600 | Baud9600 | 一边 9600 一边 19200 |
| 数据位 | 7 | Data7 | 默认 8 位 |
| 校验 | 偶 | EvenParity | 默认无校验 |
| 停止位 | 1 | OneStop | 默认 1,一般不用改 |
| 流控 | 无 | NoFlowControl | 开了 RTS/CTS |
| 发送格式 | HEX | QByteArray::fromHex | 把 ASCII 当 HEX 发 |
助手发 HEX 时,输入的是02 30 31...这种字节;Qt 里如果用字符串拼帧,注意不要再次转码。联调顺序是:助手通、Qt 发同一帧、Qt 收响应、Qt 解析出值。每一步只改一个变量,不要同时换线、换端口、改代码。
5. 避坑与排查:编程口通信最常见的五类翻车
编程口通信的坑很集中,线序、参数、校验、地址、线程,五类能覆盖大部分现场问题。下面每条按“现象、原因、解决”写,现象尽量描述成你能在日志里看到的样子,方便对照。血泪经验是:不要一上来怀疑 PLC 坏,先把 TX/RX 十六进制打出来。
5.1 现象:能发不能收
现象是串口助手显示发送成功,TX 日志有完整帧,但 RX 一直为空,换读其他地址也一样。原因常见是 RS422 差分线只接了两根,或者 A/B 正负接反,转换器在发的时候有信号,收的时候差分对没有回路。另一个原因是 USB 转串口线只支持 RS232 电平,没有差分转换。解决方法是按四线全双工接:发送正对接接收正、发送负对接接收负,地线共地;不确定线序时先用包内助手和已知能通的编程线对比。如果换线后能收,问题就在线序,不在代码。
5.2 现象:校验和总是错
现象是 RX 有数据,帧头帧尾也对,但解析时报校验和错误,收到的校验和与本地算出来的差一个固定值。原因通常是校验范围不一致:有的实现把 STX 算进去,有的不算 ETX,还有的把两位校验字符也加进去。另一个原因是大小写混用,收到小写a-f,本地按大写比较。解决方法是先用一条已知正确的读 D0 帧反推范围,固定“命令到 ETX”的累加方式,输出统一转大写。不要每次改代码都换范围,否则永远对不上。
5.3 现象:读回来数据错位
现象是能收到响应,校验也对,但解析出的值和 PLC 里监控的值对不上,或者高低字颠倒。原因常见是串口数据位设成了 8 位,字符被多读一位,帧整体错位;也可能是写帧数据区的高低字顺序和协议表不一致,读回来按相反顺序拼。解决方法是先把串口参数锁死为 7E1,用固定值 0x1234 写 D0 再读回,确认字节序。
本文还有配套的精品资源,点击获取