news 2026/10/26 5:41:22

PyQt5+pyserial打造串口上位机:实时显示与解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyQt5+pyserial打造串口上位机:实时显示与解析

简介:这份资源面向具备一定 Python 基础、希望入门桌面端串口通信开发的开发者,围绕 PyCharm 与 PyQt5 组合,给出一个从串口数据读取、写入 SQLite 数据库到界面实时展示的完整小程序示例,可用于工业设备调试、传感器数据采集等场景的练手与二次开发。压缩包共 14 个文件,约 16KB,以 7 个 xml 与 3 个 meta 等 IDE 工程配置、2 个 py 源码及 1 个 pyc 缓存文件为主,源码与工程结构一并保留,便于直接在 PyCharm 中打开运行和调试。目前已有 7062 人学习下载,说明该示例在串口读取与界面显示方向具有较好的参考价值。读者可从中了解 PyQt5 界面与串口线程的配合方式、数据落库的基本流程以及工程目录的组织思路,并在此基础上替换串口参数、扩展显示字段或调整数据库表结构,快速搭建自己的采集与展示原型。

1. 从一块“哑巴”开发板说起:为什么我要用 PyQt5 重写串口上位机

去年帮一个做硬件的朋友调一块采集板,板子每隔 100ms 往外吐一帧十六进制数据,他原来的调试方式是开一个黑框终端,靠print一行行刷。数据一多,滚动条根本追不上,想看一眼某个字节的变化得往上翻半天,串口还时不时被别的软件抢占,报个PermissionError就卡死。这种“哑巴”调试体验,相信写过 Python 串口通信的人都懂。后来我干脆用 PyCharm 起了一个 PyQt5 工程,把串口读取、十六进制解析、实时曲线和日志落盘一次性做进一个桌面窗口里,插上 USB 转串口就能用。这个项目解决的就是“串口数据看得见、看得清、留得下”这件事,适合正在做嵌入式联调、传感器采集、工控小工具,又不想碰 C# 或 Qt C++ 的 Python 开发者。整套东西不依赖任何硬件厂商 SDK,核心就是pyserial加PyQt5两条腿走路,下面把我拆这套代码的顺序原样讲一遍。

2. 技术选型与工程骨架:pyserial 负责搬字节,PyQt5 负责摆界面

2.1 为什么是 pyserial + PyQt5 这套组合

串口这一层没有太多悬念,Python 生态里pyserial是事实标准,跨 Windows、Linux、macOS 的串口枚举和读写 API 基本一致,serial.tools.list_ports.comports()一行就能把当前机器上所有 COM 口和/dev/ttyUSB*列出来,省掉自己调系统 API 的麻烦。界面层选 PyQt5 而不是 Tkinter,主要看中三点:一是QSerialPort虽然 Qt 自带,但和 Python 的pyserial配合更顺手,线程模型也清晰;二是pyqtgraph这类实时绘图库和 Qt 的QGraphicsView体系天然契合,刷曲线不掉帧;三是信号槽机制让“串口线程读到数据 → 主线程更新 UI”这条链路不用手写锁。

常见做法是把串口读取放在一个独立的QThread里,通过pyqtSignal把bytes抛回主线程,主线程只负责渲染。这样即使串口以 921600 波特率狂吐数据,UI 也不会假死。我一般还会在工程里固定一个requirements.txt,把版本钉死,避免pyserial和PyQt5在不同机器上升级后行为漂移。

# 建议的依赖清单,版本按自己环境微调 pip install PyQt5==5.15.9 pip install pyserial==3.5 pip install pyqtgraph==0.13.3

PyQt5提供窗口、布局、定时器和信号槽;pyserial负责底层字节流;pyqtgraph是可选项,用来画实时波形,如果只做文本显示可以不加。三个包加起来安装体积不大,PyCharm 里新建一个 Virtualenv 解释器就能隔离干净。

2.2 目录结构:把“界面”和“协议”彻底分开

这套工程我习惯按职责切成四层,目录不深但边界清楚,二次开发时改哪块一目了然:

serial_monitor/ ├── main.py # 程序入口,创建 QApplication 和主窗口 ├── ui/ │ ├── main_window.py # 主窗口布局与控件组装 │ └── main_window.ui # Qt Designer 设计文件(可选) ├── core/ │ ├── serial_worker.py # QThread 子类,负责打开/读取/关闭串口 │ └── protocol.py # 帧解析、校验、十六进制转换 ├── utils/ │ └── logger.py # 日志落盘与界面日志追加 └── requirements.txt

ui层只关心控件怎么摆,core层不 import 任何界面类,protocol.py是纯函数集合,输入bytes输出解析结果,方便单独写单元测试。这种分层在项目小的时候看着有点“过度设计”,但一旦要加第二路串口或者换协议,就知道好处了——我踩过的坑是早期把所有逻辑塞进一个MainWindow类,后来加个 CRC 校验改了七个地方,血泪经验。

2.3 串口枚举与参数配置:别让用户手敲 COM 号

打开串口前先把可用端口列出来,是提升可用性最划算的一步。list_ports.comports()返回的对象带device、description、hwid三个常用字段,device是COM3这种系统名,description往往是芯片厂商信息,插拔设备时靠它区分哪个是目标板。

from serial.tools import list_ports def scan_ports(): """返回 [(设备名, 描述), ...],供下拉框填充""" result = [] for p in list_ports.comports(): # device 形如 COM3 或 /dev/ttyUSB0 # description 可能是 "USB-SERIAL CH340 (COM3)" result.append((p.device, p.description or "未知设备")) return result

参数区一般给五个可调项:波特率、数据位、停止位、校验位、流控。波特率做成可编辑下拉框,常用值 9600、115200、921600 预置进去,允许用户手动输入非标值;数据位默认 8,停止位默认 1,校验默认N。这些值最终拼成serial.Serial(port=..., baudrate=..., bytesize=..., parity=..., stopbits=..., timeout=0.05)。timeout建议给一个小值而不是None,否则read()在没数据时会一直阻塞,线程退出信号就响应不了,这是新手最常翻车的地方。

3. 核心模块拆解:一个字节从串口到界面的完整链路

3.1 SerialWorker:把阻塞读放进 QThread

串口读取本质是阻塞 IO,直接放主线程会让窗口在等待数据时失去响应。正确姿势是继承QThread,在run()里循环读,读到数据就emit出去。注意QThread的run()是线程体,start()才会真正拉起线程,别把耗时逻辑写在__init__里。

from PyQt5.QtCore import QThread, pyqtSignal import serial class SerialWorker(QThread): # 抛给主线程的信号:原始字节、错误信息 data_received = pyqtSignal(bytes) error_occurred = pyqtSignal(str) def __init__(self, port, baudrate=115200, parent=None): super().__init__(parent) self.port = port self.baudrate = baudrate self._running = False self._ser = None def run(self): try: self._ser = serial.Serial( port=self.port, baudrate=self.baudrate, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=0.05, # 短超时,保证能及时响应停止 ) except serial.SerialException as e: self.error_occurred.emit(f"打开串口失败: {e}") return self._running = True while self._running: try: # in_waiting 返回缓冲区待读字节数,避免无数据时死等 n = self._ser.in_waiting if n: chunk = self._ser.read(n) self.data_received.emit(chunk) else: self.msleep(10) # 让出 CPU,别空转 except serial.SerialException as e: self.error_occurred.emit(f"读取异常: {e}") break if self._ser and self._ser.is_open: self._ser.close() def stop(self): self._running = False self.wait(1000) # 等线程体退出,最多 1 秒

这段代码有三个关键点。第一,in_waiting先探缓冲区再读,比直接read(1)效率高得多,尤其在高波特率下能一次取走整批数据。第二,msleep(10)是必要的让位,纯while空转会吃满一个核。第三,stop()里wait(1000)给线程一个优雅退出的窗口,如果超时还没退,说明run()里有阻塞点没解开,通常就是timeout设成了None。data_received传的是原始bytes,解析交给下一层,线程里不做任何字符串拼接,避免编码问题污染数据。

3.2 协议解析:十六进制显示与粘包处理

串口数据是流式的,一次read拿到的可能是半帧,也可能是两帧半粘在一起。如果只是做“十六进制监视器”,直接把bytes转成' '.join(f'{b:02X}' for b in chunk)就能显示;但要做帧解析,就得先定帧头帧尾。下面是一个常见的定长帧解析器,帧头0xAA 0x55,长度字段一字节,末尾一字节校验和。

class FrameParser: def __init__(self): self._buf = bytearray() def feed(self, data: bytes): """喂入原始字节,返回本次解析出的完整帧列表""" self._buf.extend(data) frames = [] while True: # 找帧头 start = self._buf.find(b"\xAA\x55") if start < 0: # 没有帧头,保留最后 1 字节防止帧头被截断 if len(self._buf) > 1: self._buf = self._buf[-1:] break if start > 0: del self._buf[:start] # 丢弃帧头前的垃圾数据 if len(self._buf) < 3: break # 长度字段还没到 length = self._buf[2] total = 3 + length + 1 # 头2 + 长度1 + 载荷 + 校验1 if len(self._buf) < total: break # 半帧,等下次 frame = bytes(self._buf[:total]) if self._checksum(frame[:-1]) == frame[-1]: frames.append(frame) del self._buf[:total] return frames @staticmethod def _checksum(payload: bytes) -> int: return sum(payload) & 0xFF

feed()是增量式的,内部维护_buf,每次把新数据追加进去再循环切帧,这样粘包和半包都能正确处理。find找不到帧头时只保留最后 1 字节,是为了防止0xAA落在本次数据末尾、0x55落在下次数据开头的情况被误丢。校验失败直接丢弃该帧但不影响后续解析,这个容错策略在干扰大的现场很实用。解析出的frame再交给界面层做十六进制展示或字段提取,职责清晰。

3.3 界面刷新:QTextEdit 追加与定时批量更新

如果每收到一帧就append一次QTextEdit,高频率下界面会明显卡顿,因为每次追加都触发重绘和滚动。我的做法是用一个QTimer每 50ms 把缓冲区里的文本批量刷一次,既保证实时感又不拖垮 UI。

from PyQt5.QtCore import QTimer from PyQt5.QtWidgets import QTextEdit class LogView(QTextEdit): def __init__(self, parent=None): super().__init__(parent) self.setReadOnly(True) self._pending = [] self._timer = QTimer(self) self._timer.timeout.connect(self._flush) self._timer.start(50) # 50ms 批量刷新 def push_line(self, text: str): self._pending.append(text) def _flush(self): if not self._pending: return # 一次插入多行,减少重绘次数 self.append("\n".join(self._pending)) self._pending.clear() # 限制最大行数,防止内存无限增长 if self.blockCount() > 5000: cursor = self.textCursor() cursor.movePosition(cursor.Start) cursor.movePosition(cursor.Down, cursor.KeepAnchor, 1000) cursor.removeSelectedText()

push_line只入队不渲染,_flush由定时器驱动,blockCount超过 5000 行时裁掉最老的 1000 行,这是长时间挂机监视的必要保护,否则跑一晚上内存能吃掉几个 G。十六进制格式化放在push_line之前做,比如f"[{time.strftime('%H:%M:%S')}] RX: {chunk.hex(' ').upper()}",时间戳加上后排查时序问题方便很多。

4. 避坑与排查:串口上位机最容易翻车的五个地方

4.1 现象:点“打开串口”没反应,也不报错

原因通常是端口被占用或权限不足。Windows 上另一个调试助手开着同一个 COM 口,serial.Serial会抛SerialException: could not open port,但如果异常被try吞掉没emit,界面上就毫无反馈。Linux 下则是当前用户不在dialout组,打开/dev/ttyUSB0直接Permission denied。解决办法是把error_occurred信号接到一个QMessageBox或状态栏,任何打开失败都弹出来;Linux 下用sudo usermod -aG dialout $USER加组后重新登录。我一般还会在打开前先scan_ports()确认目标口还在列表里,设备被拔掉时能提前拦住。

4.2 现象:界面卡死,窗口标题栏显示“未响应”

这是把read()或while循环写进主线程的典型症状。pyserial的read()在timeout=None时会一直阻塞,主线程一卡,Qt 事件循环就停了。排查方法是看卡死时 CPU 占用:单核跑满多半是空转循环,CPU 接近零但界面不动多半是阻塞读。解决就是第 3.1 节那套QThread加短timeout加msleep的组合,别图省事用QTimer在主线程里轮询read(),数据量一大照样卡。

4.3 现象:中文乱码,或者十六进制里混进奇怪字符

串口传的是字节,不是文本。如果设备发的是 GBK 编码的中文字符串,直接chunk.decode('utf-8')必然乱码甚至抛UnicodeDecodeError。正确做法是界面提供编码选择(UTF-8 / GBK / ASCII),解码时用errors='replace'兜底;纯二进制协议则一律走十六进制显示,不要尝试解码。我吃过一次亏:某传感器返回的载荷里恰好有一段字节序列能被 UTF-8 解成汉字,导致日志里出现莫名其妙的字符,后来强制十六进制模式才看清真实数据。

4.4 现象:高波特率下丢数据,帧解析总是校验失败

921600 波特率下,如果in_waiting判断和read之间隔太久,或者每次只读 1 字节,驱动缓冲区可能溢出。解决是每次把in_waiting返回的字节数一次性读完,并且把解析放在独立缓冲区里做增量切帧,不要假设一次read等于一帧。另外 USB 转串口芯片(CH340、CP2102 之类)本身缓冲区有限,长时间高速传输时适当降低波特率或加硬件流控更稳。排查时可以在data_received里打印每次收到的字节数,看是否出现异常小的值。

4.5 现象:关闭窗口后进程还在,任务管理器里残留

QThread还在跑,窗口对象被销毁但线程持有它的引用,程序退不干净。解决是在MainWindow.closeEvent里显式调用worker.stop(),并且stop()内部先置_running=False再wait()。如果wait超时,说明run()里有阻塞点,回到 4.2 检查timeout。另外信号槽连接建议用Qt.QueuedConnection的默认行为即可,别手动跨线程直接调 UI 方法,那是另一个崩溃来源。

5. 二次开发改造:从“能看”到“能存能画”的三个落点

这套代码跑通之后,真正花时间的是按自己场景做扩展。第一个落点是日志落盘,把data_received再连一个槽,按天切文件写logs/2024-xx-xx.log,每行带毫秒时间戳和方向标记,出问题时能拿日志回放。第二个落点是实时曲线,用pyqtgraph.PlotWidget替换或并列于文本区,把解析出的数值字段push进collections.deque(maxlen=2000),定时器里setData刷新,比matplotlib嵌入 Qt 流畅得多。第三个落点是协议可配置,把帧头、长度字段偏移、校验方式抽到一个config.json,换设备时改配置不改代码。

验证改造是否成功,我习惯用两个手段:一是写一个“回环测试”脚本,用socat或虚拟串口对把发送端和接收端接起来,灌固定帧序列看解析结果是否与预期一致;二是故意注入错误帧(改坏校验位、插入垃圾字节),确认解析器能跳过而不崩溃。下面这个片段是我常用的回环自测入口:

# 不接硬件也能验证解析逻辑 from core.protocol import FrameParser parser = FrameParser() # 模拟半包 + 粘包:第一段只有帧头和一个长度字节 assert parser.feed(b"\xAA\x55\x03") == [] # 补齐载荷和校验,校验 = 前面所有字节之和 & 0xFF payload = b"\x01\x02\x03" frame = b"\xAA\x55\x03" + payload frame += bytes([sum(frame) & 0xFF]) assert len(parser.feed(frame)) == 1

这种纯函数测试不依赖串口和界面,PyCharm 里直接右键运行,改协议时先跑它,比插着板子反复试快得多。从那以后我每次动protocol.py都强制先跑一遍这组断言,再上真机,省下不少“明明代码没改怎么又不对”的自我怀疑。希望这套拆解能帮到你,把手里那块只会吐字节的板子变成看得懂、留得住的数据源。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 5:41:21

工业皮带选型指南:核心参数与2026技术趋势

1. 工业皮带选型的基本逻辑与常见误区工业皮带作为机械传动系统的核心部件&#xff0c;其选型质量直接影响设备寿命和生产效率。新手工程师常犯的错误是直接根据设备型号或皮带尺寸进行匹配&#xff0c;这种"按图索骥"的做法往往导致早期失效。我在某食品厂就遇到过典…

作者头像 李华
网站建设 2026/10/26 5:40:49

C++/WinRT BLE开发:广播扫描与GATT Notify实战

简介&#xff1a;面向希望在 Windows 平台用 C/C 直接操控低功耗蓝牙的开发者&#xff0c;这套工程示例以 WinRT 接口为核心&#xff0c;演示 BLE 设备枚举、广播扫描、服务与特征值读写、通知订阅等常见流程&#xff0c;适合有一定 C 基础、需在桌面端集成蓝牙通信或排查连接问…

作者头像 李华
网站建设 2026/10/26 5:40:46

智能合约安全漏洞分析与防御实践

1. 智能合约安全现状与核心挑战区块链技术发展至今&#xff0c;智能合约已成为去中心化应用&#xff08;DApp&#xff09;的核心组件。根据我过去三年审计过的127个智能合约项目统计&#xff0c;平均每个合约存在3.2个高危漏洞&#xff0c;其中可导致直接资金损失的就占67%。这…

作者头像 李华
网站建设 2026/10/26 5:40:45

FLUX 3 Image实测前瞻:4K原生生图与坐标网格精准构图

FLUX 3 Image 发布&#xff0c;4K生图、坐标网格精准排布、开放权重版数周内公开——这三个信息点放在同一个公告里&#xff0c;对经常折腾本地生图的人来说还是挺有冲击力的。我之前从 FLUX.1 dev 一路用过来&#xff0c;中间也试过 FLUX.2 的图像编辑能力&#xff0c;这次 3.…

作者头像 李华
网站建设 2026/10/26 5:40:35

GAN对抗训练原理与DCGAN实战技巧详解

1. 对抗训练的本质&#xff1a;从博弈论到参数更新生成对抗网络&#xff08;GAN&#xff09;的核心创新在于将生成模型的训练过程转化为一个双人博弈场景。这个框架由蒙特利尔大学的Ian Goodfellow在2014年首次提出&#xff0c;其灵感来源于博弈论中的零和游戏概念。在实际训练…

作者头像 李华
网站建设 2026/10/26 5:39:57

人工智能入门:从核心概念到实战应用全解析

1. 人工智能ABC&#xff1a;从零开始的认知地图第一次接触AI时&#xff0c;我被各种术语轰炸得晕头转向——机器学习、深度学习、神经网络...这些概念就像散落的拼图碎片。直到我把它们按逻辑串联起来&#xff0c;才真正看清人工智能的全貌。这个领域其实有清晰的认知路径&…

作者头像 李华