基于PyQt5的串口调试工具开发:线程模型与数据收发详解
2026/9/16 18:49:50 网站建设 项目流程

简介:基于PyQt5开发的串口调试工具源码,面向计算机相关专业学生及嵌入式开发者,适合课程作业、毕业设计或入门PyQt5与串口编程的参考实践。项目代码经过测试运行成功,功能完整,可直接运行学习,也支持在此基础上二次扩展。压缩包共2000个文件,约86.77MB,其中410个Python文件是核心业务逻辑,799个txt文本为配置与说明文档,705个html文件可作为接口或使用帮助,C/C++源文件辅助底层交互,XML用于界面或配置描述,整体结构清晰便于检索。已有401人学习下载,可帮助读者掌握串口参数配置、数据收发与界面交互等关键环节,同时理解PyQt5信号槽机制、多线程接收及协议解析思路,尤其适合初次接触PyQt5图形界面开发的学习者对照练习。

1. PyQt5 串口调试工具:课程作业要求,也是串口开发者的第一把尺子

嵌入式课程设计里,串口调试工具几乎是必做题。很多同学交上去的作业是“能打开串口、能发能收”的演示版,但真正拿到设备前调协议时才发现:收一帧就卡界面、十六进制显示缺失、没有时间戳、日志没法回放。这个标题里的 PyQt5 串口调试工具,本质上要做的不只是串口收发,而是把“串口链路是否正常、设备回包对不对、协议解析有没有 bug”这件事变成可观测的过程。PyQt5 负责界面和交互,pyserial 负责底层通信,两者组合起来就是一条完整的串口调试链路。这篇博文面向两类人:一类是正在做课程作业的学生,想知道怎么把界面做得不丢分;另一类是刚接触嵌入式上位机开发的工程师,想快速搭一个顺手的内网调试工具,替代那些动不动弹广告的闭源串口助手。

2. 用 PyQt5 界面设计原则搭出串口工具的高频操作区

课程作业里的 PyQt5 串口工具,界面布局通常不会太复杂:顶部是串口参数配置区,中间是数据收发区,底部是状态栏。真正拉开差距的地方在于“高频操作是否顺手”和“长时间运行时界面是否稳定”。这章从界面搭建开始,但重心放在线程模型上——大多数翻车现场都出在这里。

2.1 Qt Designer 拖控件优先,代码布局只补动态部分

稳妥的做法是先用 Qt Designer 把静态布局拖出来,再用代码补充动态逻辑。pycharm 配置 pyqt5 之后,你可以在 IDE 里直接双击 .ui 文件打开 Designer,画完保存后用pyuic5转成 Python 类:

pyuic5 serial_dialog.ui -o ui_serial_dialog.py

生成文件里是一个Ui_SerialDialog类,提供了setupUi方法。你新建的窗口继承QMainWindow,然后在__init__里调用self.ui = Ui_SerialDialog(); self.ui.setupUi(self)完成绑定。需要放置的控件大致如下:

  • 串口:QComboBox用于端口下拉,旁边加一个QPushButton做“刷新”;
  • 参数:波特率QComboBox、数据位/校验位/停止位也用下拉框;
  • 收发区:发送框QPlainTextEdit,接收区QPlainTextEdit,接收区设置为只读;
  • 操作:打开串口、清空接收区、发送数据三个按钮,发送区旁边加QCheckBox控制 Hex 模式。

控件数量不多,用 Designer 拖半小时就能完成。手动布局适合动态控件,比如后面要加的自动发送时间间隔输入框,放在代码里补更灵活。我的建议是:界面用 Designer 定死,业务逻辑全写在窗口类里,避免把 .ui 文件改得面目全非。

2.2 串口读写必须放 QThread,主线程只接收信号

初次接触 PyQt5 串口调试工具的人最容易犯的错是:直接在按钮回调里while True读串口。这样会导致主线程的事件循环被阻塞,窗口拖不动、按钮点不了、接收区不刷新,设备数据量一大直接假死。界面卡死的根因就在这里:串口读取是阻塞操作,而 Qt 的主线程要不停处理重绘、事件分发和用户输入。

解决思路是用QThread做数据采集线程,把串口对象交给它管理,线程内循环读数据,通过 Qt 信号把数据交回主线程更新界面。信号槽机制的默认连接类型是队列连接,跨线程发射时槽函数会在接收者所在线程执行,这正好符合“串口线程负责读、主线程负责画”的分工。需要注意别在子线程直接操作界面控件,Qt 的控件不是线程安全的,界面更新统一走信号。

2.3 QThread 与 pyserial 组合的最小实现

下面这段代码是串口读线程的基础版,实现“打开串口后持续读数据并通过信号抛给窗口”:

import serial from PyQt5.QtCore import QThread, pyqtSignal class SerialReadThread(QThread): data_received = pyqtSignal(bytes) # 子线程 -> 主线程 的原始数据 error_occurred = pyqtSignal(str) def __init__(self, ser, parent=None): super().__init__(parent) self.ser = ser self._running = True def run(self): while self._running: try: waiting = self.ser.in_waiting if waiting: data = self.ser.read(waiting) self.data_received.emit(data) except serial.SerialException as e: self.error_occurred.emit(str(e)) break self.msleep(10) # 降低空转 CPU 占用 self.ser.close() def stop(self): self._running = False self.wait()

逻辑说明:in_waiting返回缓冲区中已接收的字节数,不阻塞;只读有数据的部分,避免使用固定字节数的read(n)卡住线程。msleep(10)把循环频率压到 100Hz 以内,一个空转线程的 CPU 占用可以控制在 1% 以下。发射信号时带上bytes对象,主线程槽函数负责解码、显示和落盘。

停止逻辑要重点看:_running置 False 后循环退出并关闭串口,wait()等待线程真正结束。这里有个坑:如果线程正阻塞在read()调用上(比如timeout=None),置位布尔值无法打断阻塞,线程不会退出。解决办法是打开串口时设置timeout=0.1,让read()定期返回一次。这个细节直接影响程序退出时会不会卡在销毁过程。

3. 串口参数配置与端口枚举:从 serial.tools.list_ports 到 Serial 实例

串口工具的“正确打开”不是点一下按钮那么简单。枚举端口、匹配设备 VID/PID、设置波特率等参数、处理打开冲突,每一步都有可踩的坑。

3.1 用 serial.tools.list_ports 枚举系统串口

pyserial 提供了serial.tools.list_ports模块,可以拿到当前系统的全部串口列表。刷新按钮的槽函数里我一般这样写:

import serial import serial.tools.list_ports def refresh_ports(self): self.ui.port_combo.clear() ports = serial.tools.list_ports.comports() for p in ports: label = f"{p.device} - {p.description}" self.ui.port_combo.addItem(label, p.device) # 显示用 label,实际值存在 data if not ports: self.ui.port_combo.addItem("未检测到串口设备", "")

说明:p.device在 Windows 下是COM3这种形式,Linux 下是/dev/ttyUSB0/dev/ttyACM0p.description通常包含芯片型号和厂商信息,比如USB-SERIAL CH340,对快速区分多个 USB 转串口设备很有用。addItem的第二个参数userData存的是规范设备名,后续打开串口时从currentData()取,不要用currentText()去截字符串,因为描述文本在不同平台格式不统一。

更靠谱的过滤方式是用p.hwid:它包含 VID 和 PID。假设你要限定某个自研设备的 USB 转串口:

usb_ports = [] for p in serial.tools.list_ports.comports(): if "VID:PID=1A86:7523" in p.hwid: # CH340 的 VID:PID usb_ports.append(p.device)

这样哪怕电脑上挂了 8 个串口设备,也能只列出目标设备,避免用户选错。注意解析hwid时要做异常兜底,部分蓝牙串口或虚拟串口的hwid可能为空字符串。

3.2 参数配置的必要性与选型依据

串口通信参数不是随便填的。设备端的 MCU 固件里已经写死了波特率、数据位、校验位、停止位,上位机必须完全一致才能解析出正确数据。下表是常用参数的取值和适用场景:

参数常用值适用说明
波特率9600 / 115200 / 460800 / 921600115200 是大多数开发板和传感器模块的默认值;距离超过 1 米建议降速
数据位8 / 7 / 6 / 5绝大多数场景用 8;7 位常用于部分老式 ASCII 设备
校验位None / Even / OddModbus 常设 None;高级仪表偶用 Even;Odd 少见
停止位1 / 1.5 / 2默认 1;2 用于低速设备保持解析边界
流控None / RTS/CTS / XON/XOFF普通调试选 None;接工业网关或对讲设备时再看硬件说明书

界面下拉框的默认值建议设为“115200 / 8 / N / 1 / None”,这是串口调试工具里最通用的组合。有的设备会用到RTS/CTS硬件流控,pyserial 里通过rtscts=True开启,但初版工具不建议打开,因为一旦接线不对会导致只能收不能发,低级问题难排查。

3.3 用 Serial 打开端口,常用参数解释

打开串口的代码要写成独立方法,因为关闭窗口、重新打开串口时都会调用:

def open_serial(self): try: port = self.ui.port_combo.currentData() baud = int(self.ui.baud_combo.currentText()) self.ser = serial.Serial( port=port, baudrate=baud, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=0.1, write_timeout=1.0 ) self.read_thread = SerialReadThread(self.ser, parent=self) self.read_thread.data_received.connect(self.handle_received_data) self.read_thread.error_occurred.connect(self.show_serial_error) self.read_thread.start() except serial.SerialException as e: QMessageBox.critical(self, "打开失败", f"无法打开 {port}: {e}")

参数说明:timeout=0.1是读超时,单位秒,配合线程退出机制使用;write_timeout=1.0是写超时,防止write()在缓冲区满时永久阻塞。Serial构造时如果端口被占用、设备被拔出或者权限不足,会抛出SerialException,必须用 try/except 接住。打开成功后立刻启动读线程,这样数据从设备发过来就能实时进界面。关闭串口时要先停线程再关句柄,顺序反了可能读到已关闭对象的异常。

4. 数据收发与协议调试:Hex 显示、日志落盘和自动发送

收到的原始数据默认是bytes,怎么展示全看工具做得好不好。课程作业把这块做扎实,答辩时可以从“能通信”讲到“能调试”,这是质的差别。

4.1 Hex 与 ASCII 双模接收,用 QTextCursor 限长刷新

串口调试工具里最常用的接收展示是 ASCII 和 Hex 两种模式。ASCII 模式适合打印类日志,比如 AT 指令回显;Hex 模式适合解析二进制协议,比如 Modbus RTU。实现槽函数时,我一般这样处理:

def handle_received_data(self, data: bytes): if self.ui.hex_checkbox.isChecked(): text = data.hex(" ").upper() + " " else: text = data.decode("utf-8", errors="replace") self.ui.rx_edit.moveCursor(QTextCursor.MoveOperation.End) self.ui.rx_edit.insertPlainText(text) self.ui.rx_edit.moveCursor(QTextCursor.MoveOperation.End)

逻辑说明:bytes.hex(" ")方法把每个字节转成两位十六进制并用空格分隔,比如b"\x01\x02"变成"01 02"errors="replace"保证非 UTF-8 字节不会导致解码崩溃,乱码字节会替换成替换符。moveCursor到文末再插入文本,保证实时滚动。

接收区长时间运行会积累大量文本,QPlainTextEdit文档越长越卡。稳妥做法是限制总行数,超过阈值时裁剪掉前一部分:

limit = 5000 # 最大显示行数 doc = self.ui.rx_edit.document() if doc.blockCount() > limit: cursor = QTextCursor(doc) cursor.movePosition(QTextCursor.MoveOperation.Start) cursor.movePosition(QTextCursor.MoveOperation.Down, QTextCursor.MoveMode.KeepAnchor, doc.blockCount() - limit) cursor.removeSelectedText()

裁剪逻辑放在接收槽函数里,每次超过 5000 行就移除最老的块。这样界面滚动不会越来越卡,内存也控得住。

4.2 发送一帧数据的正确姿势:区分 Hex 与字符串

发送区同样要支持 Hex 和字符串两种模式。用户输入可能是01 03 00 00 00 0A C5 CD这种带空格的 hex 串,也可能是普通指令文本。发送槽函数的实现:

def send_data(self): raw = self.ui.tx_edit.toPlainText().strip() if not raw: return if self.ui.hex_mode_checkbox.isChecked(): try: raw_hex = raw.replace(" ", "").replace("\n", "") payload = bytes.fromhex(raw_hex) except ValueError: QMessageBox.warning(self, "格式错误", "Hex 模式请输入十六进制字节,如 01 03 00 0A") return else: payload = raw.encode("utf-8", errors="ignore") try: written = self.ser.write(payload) except serial.SerialException as e: QMessageBox.critical(self, "发送失败", str(e)) return self.append_sent_log(payload, written)

关键点:Hex 输入要先去掉空格和换行再调用bytes.fromhex,否则输入不规范会抛ValueError;校验失败直接 return,不破坏用户的输入框内容。发送成功后在发送框上方追加一条本地回显,方便对照设备有没有回包。回显格式包含时间和数据长度:[14:23:01] TX 15B: 01 03 00 00...。这条回显只存在于本地发送记录里,不进接收区,避免把发送和接收混在一起看。

4.3 用 logging 模块把收发数据落盘,支持回放

课程作业如果写了“支持数据日志”,通常是用open(file, "a")手动写。更好的方案是用 Python 标准库logging,自带时间戳和格式化,代码也更整洁:

import logging from datetime import datetime log_format = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S") file_handler = logging.FileHandler( f"serial_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log", encoding="utf-8" ) file_handler.setFormatter(log_format) tx_logger = logging.getLogger("tx") rx_logger = logging.getLogger("rx") for lg in (tx_logger, rx_logger): lg.addHandler(file_handler) lg.setLevel(logging.INFO)

参考用法:tx_logger.info("TX %d: %s", written, payload.hex(" ").upper())rx_logger.info("RX %d: %s", len(data), data.hex(" ").upper())。日志文件按启动时间命名,同一天的多次运行不会互相覆盖。Hex 和 ASCII 模式下的日志要保持同一种格式,推荐统一用 Hex,因为二进制数据转回文本可能带乱码,而 Hex 在任何编辑器里都可读。回放时直接用 Python 脚本按行解析RX前缀,提取hex字段就能还原数据流。

4.4 自动发送与定时发送:用 QTimer 替代手动 sleep

调试心跳包或周期上报逻辑时,自动发送是不可缺的功能。有人喜欢在while Truetime.sleep(1)再发送,这同样会阻塞界面。推荐用 Qt 的QTimer

from PyQt5.QtCore import QTimer class AutoSender: def __init__(self, interval_ms=1000, callback=None): self.timer = QTimer(self) self.timer.setInterval(interval_ms) self.timer.timeout.connect(callback) def start(self): if not self.timer.isActive(): self.timer.start() def stop(self): self.timer.stop()

QTimer在窗口事件循环中触发,不会卡住界面,最小间隔可以给到 50ms,但实际能不能按 50ms 发出去还取决于发送内容的长度和 USB 转串口的响应速度。帧间隔极短时,数据可能在内核缓冲区排队,设备收到的是一整包拆不开的数据,所以单片机端要么做粘包处理,要么把自动发送间隔拉大到 200ms 以上。

4.5 从串口调试工具延伸:数据通道可变,界面不必重写

课程作业做完了“串口收发”这一环,再进一步就是数据通道的替换。设备接入方式不只串口一种,常见的扩展需求是串口转 TCP 服务器或串口转 telnet 远程调试工具,本质上是把serial.Serial换成socket,保持收发接口一致。实操时可以在读写线程里抽象出一层DataChannel

class DataChannel(ABC): @abstractmethod def read(self, size) -> bytes: ... @abstractmethod def write(self, data: bytes): ...

SerialChannelSocketChannel都实现这两个方法,界面的收发逻辑全部依赖抽象接口。这样“串口转 TCP 服务器”——也就是把本地串口设备暴露到内网上给别人调试——只需要加一个监听线程,收串口数据往 socket 写,收 socket 数据往串口写,UI 层完全不动。这个方向适合作为作业的加分项,也贴合实际调试场景。

5. 打包和发布:用 pyinstaller 把 PyQt5 串口工具做成 exe

课程作业交付经常要求交可执行文件,不能用“在 PyCharm 里能跑”当成完成。PyQt5 工具用 pyinstaller 打包确实有坑,这章只讲三个最常见的。

5.1 打包命令写对:-w别漏,pyserial 的隐藏导入要注意

在项目根目录执行:

pip install pyinstaller pyinstaller -w -F --hidden-import serial.tools.list_ports -i app.ico serial_tool.py

-w表示打包为窗口程序,不出现黑色控制台窗口,漏了它程序打开时会带着一个黑底白字的 cmd 窗口很丑。-F生成单文件 exe,方便拷贝和提交作业,缺点是启动稍微慢一点。--hidden-import serial.tools.list_ports是必须加的,pyinstaller 的静态分析器在识别 pyserial 的子模块时经常漏掉它,不加这个参数打包出来的 exe 点刷新按钮会直接闪退,错误信息是ModuleNotFoundError: No module named 'serial.tools.list_ports'

5.2 图标和资源文件处理:转 base64 内嵌

课程作业如果用了自定义图标,图标文件.ico会被 pyinstaller 打包进临时目录,运行时如果按相对路径读取,会报找不到文件。最省事的方式是把启动图标转成 base64 字符串写进代码:

import base64 ICON_B64 = "AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAQA..." # 省略 def set_window_icon(window): pixmap = QPixmap() pixmap.loadFromData(base64.b64decode(ICON_B64)) window.setWindowIcon(QIcon(pixmap))

转换命令行:base64 app.ico > icon_b64.txt。这样 exe 单文件自包含,图标不会丢,打包体积也不受影响。

5.3 验证打包结果时的完整性检查清单

拿到 exe 后,不要直接发给老师,先按这个顺序自测:

  • 双击 exe,确认窗口能正常打开,无命令行黑窗;
  • 插入 USB 转串口设备,点击“刷新”,确认下拉框能列出端口——这步能验证--hidden-import serial.tools.list_ports是否生效;
  • 打开串口,用另一头短接 TX 和 RX 做回环测试,发送55 AA,看接收区是否原样收到55 AA
  • 拔掉设备再点“打开”,确认弹出错误对话框而不是程序崩溃;
  • 关闭窗口,确认进程退出后任务管理器里没有残留 python 进程。

逐条验证通过后,再连同截图和使用说明一起放进压缩包,作业的完整度就很能打了。

PyQt5 串口调试工具的技术纵深比标题看起来要深得多:界面设计只是表层,线程模型、串口参数、数据通道抽象和发布打包,每一层都有独立的经验积累。把收发链路从“能用”调到“好用”,再去扩展 TCP 通道时,你会发现自己已经在写一个真正的隔间调试基础设施的雏形了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询