简介:面向 PyQt5 初学入门与嵌入式数据采集场景的小型上位机源码包,基于 Python + PyQt5 实现串口数据读取,并将数据写入 SQLite 数据库,最终通过自定义 UI 界面实时展示。适合需要快速搭建串口调试工具、理解 PyQt5 与数据库交互的开发者参考。压缩包共 14 个文件、约 16KB,体积小巧;内含 2 个 Python 源代码文件、7 个 XML 工程配置文件、3 个 meta 元数据文件以及 iml/pyc 辅助文件,涵盖 PyCharm 工程结构、源码、数据库与数据源相关配置。目前已有 7000 余人学习下载,属于同类串口开发资料中较受关注的小型示例。通过源码可重点学习 PyQt5 信号与槽、串口通信初始化与读数据处理、SQLite 建表与插入操作、界面控件更新等关键环节;工程内还包含数据库文件与数据源配置,便于对照实际数据流理解采集-存储-展示的完整链路。压缩包结构清晰,适合直接导入作为练手项目或在此基础上扩展业务逻辑。 做嵌入式或者硬件调试的朋友,桌面上基本都会放一个串口调试助手。但通用助手用多了,痛点其实很明显:有的接收区乱码,有的日志多了滚动卡顿,有的想按协议解析还得自己复制到文本里处理。后来我干脆用 PyCharm + PyQt5 自己写了一个串口读取和显示的小工具,收发数据、彩色日志、十六进制显示、波形曲线全都按自己的想法来,调板子的时候顺手很多。这篇文章就把完整的实现流程、关键代码和踩过的坑都摊开讲一遍,适合想快速做出一个能用串口上位机、或者刚接触 PyQt5 的朋友直接照抄。
1. 整体设计思路:先拆需求,再选方案
动手写代码之前,我建议大家先花十分钟想清楚工具要做什么。串口读取和显示看起来简单,拆开之后其实是六件事:扫描可用串口、设置波特率等参数、打开关闭串口、接收数据并显示、发送数据、处理异常情况。把这六件事在脑子里过一遍,UI 长什么样、代码怎么写,就基本清楚了。
1.1 需求拆解:为什么功能拆得越细越好写
我最早写串口助手的时候,上来就写一个serial.read()然后往文本框塞,结果界面卡死、数据乱码、打不开串口各种问题一起冒出来。后来慢慢养成习惯:任何小工具都先拆功能模块再写代码。
就拿这个串口助手来说,模块划分是这样的:
- 界面模块:端口下拉框、波特率下拉框、打开/关闭按钮、接收区、发送区、发送按钮
- 串口模块:负责扫描端口、打开串口、读数据、写数据
- 线程模块:数据接收不能放在 UI 主线程,否则数据量大时界面直接无响应
- 日志模块:接收区的数据展示,带时间戳、颜色区分,方便调协议的时候定位问题
拆完之后你会发现,核心难点就剩两个:一个是串口的读写逻辑,一个是串口线程与 UI 线程之间的数据传递。搞定这两块,其他的都是堆控件的问题。
1.2 技术选型:PyQt5 + pyserial 为什么比 QSerialPort 顺手
界面框架我在 PyQt5、PySide2、Tkinter 三者里对比过。Tkinter 虽然轻量,但控件太丑,做上位机界面需要反复调样式,效率太低。PySide2 是 Qt 官方支持的 Python 绑定,协议比 PyQt5 更宽松,但资料和现成代码明显比 PyQt5 少。遇到问题去查,绝大多数答案都是 PyQt5 的写法,直接抄就行。所以我最终选了 PyQt5。
串口库这边,PyQt5 自带的QSerialPort也能用,但我在实际使用中更推荐 pyserial。理由有三点:第一,pyserial 跨平台表现稳定,Windows、Linux、macOS 都能直接列端口;第二,serial.tools.list_ports拿到的端口信息非常全,设备描述、硬件 ID 都有,做嵌入式调试时一眼能看出插的是哪个 USB 转串口芯片;第三,pyserial 的线程模型很自由,配一个 QThread 就能解决数据读取问题,而QSerialPort的事件模型在 PyQt 里用起来我还得再包一层反而麻烦。组合方案就是 PyQt5 管界面,pyserial 管串口,中间用 QThread + 信号槽做桥接。
2. 开发环境搭好:PyCharm、Python 与驱动检查
不少朋友卡在环境这关,尤其是驱动问题,代码写对了串口也打不开。这块我多说几句,能帮你省不少排查时间。
2.1 环境准备:PyCharm 社区版 + 虚拟环境
PyCharm 社区版完全免费,做这种小工具完全够用,没必要折腾专业版。Python 版本我建议 3.8 到 3.11 之间,PyQt5 对这几个版本的适配最稳,3.12 以上偶尔会遇到一些.pyi类型提示和部分库的兼容问题。安装包的指令就一条:
pip install pyqt5 pyserial这里强烈建议创建虚拟环境,不要直接pip install到全局。用 PyCharm 新建项目的时候,解释器那里选择 Virtualenv,Python 版本选你本机的版本,让 PyCharm 自动创建一个虚拟环境。之后在 PyCharm 底部的 Terminal 里执行上面的 pip 命令就行。这样以后装别的包,比如 pandas、matplotlib,不会把整个 Python 环境搞乱。
如果你用的是 Anaconda,也可以在 PyCharm 里Settings -> Project -> Python Interpreter,把解释器指向 Anaconda 的python.exe,效果一样。热词里有人问“pycharm怎么安装pandas包”,其实就是在这个解释器对应的终端里执行pip install pandas,道理完全相同。
2.2 串口驱动排查:电脑认不到端口时,别急着改代码
我之前遇到过一个朋友,代码写了一下午,端口下拉框永远是空的,最后发现是 USB 转串口模块的驱动没装。这块真的要先检查再写代码,否则容易误判成自己的问题。
常见 USB 转串口芯片有三种,对应的驱动也不同:
| 芯片型号 | 常见模块 | 查找方式 |
|---|---|---|
| CH340 / CH341 | 蓝色 USB 转 TTL 小板、ESP8266 开发板 | 设备管理器里看端口,或去芯片官网下驱动 |
| FTDI(如 FT232RL) | 比较贵的 USB 转串口线、JTAG 调试器 | 设备管理器看 USB 设备是否出现"USB Serial Converter" |
| CP210x(如 CP2102) | 某些开发板自带串口 | 设备管理器找 Silicon Labs CP210x |
Windows 下检查方式是Win + X打开设备管理器,看“端口(COM 和 LPT)”里有没有多出 COM 号。如果插上模块后这里直接多了一个带感叹号的设备,那就是驱动没装好。Linux 下用lsusb或者dmesg | tail看内核有没有识别到设备,正常会出现/dev/ttyUSB0或者/dev/ttyACM0。macOS 用ls /dev/cu.*查看。
3. 核心代码:串口读取和显示的完整实现
这里我把代码拆开讲,尽量让你能照着敲出来。界面我用纯代码生成,没有用 Qt Designer,理由很简单:这个工具界面不复杂,手写控件反而比用 Designer 生成.ui文件再转.py更快,也更好理解控件之间的关系。
3.1 搭界面:不用 Designer,手写控件也很快
主窗口布局我用的是垂直布局QVBoxLayout,从上到下一次是:端口参数行、接收区、发送行。接收区我用的不是QTextEdit,而是QTextBrowser。原因后面扩展部分会说——它能直接渲染 HTML,做彩色时间戳日志特别方便。
from PyQt5.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QComboBox, QPushButton, QLabel, QLineEdit, QTextBrowser, QCheckBox ) class SerialAssistant(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("串口调试助手") self.resize(800, 560) self.port_combo = QComboBox() self.baud_combo = QComboBox() self.baud_combo.addItems(["9600", "19200", "38400", "115200"]) self.baud_combo.setCurrentText("115200") self.open_btn = QPushButton("打开串口") self.open_btn.setCheckable(True) # 第一行:端口 + 波特率 + 打开按钮 row1 = QHBoxLayout() row1.addWidget(QLabel("串口:")) row1.addWidget(self.port_combo) row1.addWidget(QLabel("波特率:")) row1.addWidget(self.baud_combo) row1.addWidget(self.open_btn) # 接收区 self.receive_view = QTextBrowser() self.receive_view.setOpenExternalLinks(False) # 发送行 self.send_edit = QLineEdit() self.send_btn = QPushButton("发送") self.hex_recv_check = QCheckBox("HEX 接收") self.hex_send_check = QCheckBox("HEX 发送") row3 = QHBoxLayout() row3.addWidget(QLabel("发送:")) row3.addWidget(self.send_edit, 1) row3.addWidget(self.hex_send_check) row3.addWidget(self.send_btn) container = QWidget() layout = QVBoxLayout(container) layout.addLayout(row1) layout.addWidget(self.receive_view, 1) layout.addLayout(row3) self.setCentralWidget(container)代码里addWidget(self.receive_view, 1)的1表示拉伸系数,让接收区占据剩余的全部垂直空间。这是最简单也最常用的布局策略。
3.2 扫描端口与打开串口:细节都在异常处理里
端口扫描直接调 pyserial 的list_ports,它会返回所有可用端口。下拉框更新逻辑很简单,清空再重新 add,但要注意:如果某个端口正在被打开,扫描时会跳过或者列表里出现但打开时报错,这时候要有好一点的异常提示。
import serial from serial.tools import list_ports from serial.serialutil import SerialException def scan_ports(self): self.port_combo.clear() ports = list_ports.comports() for p in ports: desc = p.description if p.description else "未知设备" self.port_combo.addItem(f"{p.device} - {desc}", p.device)真正打开串口的逻辑,核心就三句话:构造串口对象、设置超时、打开。但实际写的时候要把打开和关闭做成一个切换,同时考虑几种异常情况:端口不存在、端口被占用、权限不足。
def toggle_serial(self): if self.serial is not None and self.serial.is_open: self.close_serial() return port_name = self.port_combo.currentData() if not port_name: print("请选择一个串口") return try: self.serial = serial.Serial( port=port_name, baudrate=int(self.baud_combo.currentText()), bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=0.05 ) except SerialException as e: print(f"打开失败: {e}") self.open_btn.setChecked(False) return self.open_btn.setText("关闭串口") self.start_read_thread()我特别要提醒一点:serial.Serial(...)构造函数里有个timeout=0.05。很多人不写 timeout,默认是 None,这会导致后面读取线程里的read()一直阻塞,线程退出的时候会卡住。设置 0.05 秒的超时,意思是没数据时最多等 50 毫秒就返回空字节,这个策略对及时响应界面关闭操作特别重要。
3.3 读取线程:用信号槽把数据送回主线程
这是整个工具里最关键的部分。如果直接把serial.read()放在 UI 线程里,串口一旦有大量数据进来,read 会持续占用主线程,界面就会卡成“未响应”。正确做法是单独开一个 QThread,在run()里循环读数据,读到之后通过信号发到主线程。
from PyQt5.QtCore import QThread, pyqtSignal class SerialReadThread(QThread): data_received = pyqtSignal(bytes) def __init__(self, serial_obj): super().__init__() self.serial = serial_obj self._running = True def run(self): while self._running: if not (self.serial and self.serial.is_open): continue try: n = self.serial.in_waiting if n > 0: data = self.serial.read(n) self.data_received.emit(data) else: # 没数据时读一个字节做超时阻塞,避免 CPU 忙等 data = self.serial.read(1) if data: self.data_received.emit(data) except SerialException: # 串口被拔出或关闭时退出循环 break def stop(self): self._running = False self.wait()线程类里定义了一个data_received = pyqtSignal(bytes)信号,参数类型是 bytes。run()里用in_waiting判断当前缓冲区有多少字节,一次性全部读出来,这样比每次只读一个字节高效得多。当缓冲区为 0 时,调用read(1)配合第 3.2 节设置的timeout=0.05,相当于做了一个 50 毫秒的轮询,既不会 CPU 占用拉满,又能及时响应新到的数据。
在主窗口里,把这个信号连接到更新接收区的槽函数:
def start_read_thread(self): self.read_thread = SerialReadThread(self.serial) self.read_thread.data_received.connect(self.on_data_received) self.read_thread.start() def on_data_received(self, data: bytes): if self.hex_recv_check.isChecked(): self.receive_view.insertPlainText(data.hex().upper() + " ") else: self.receive_view.insertPlainText(data.decode("utf-8", errors="replace"))这里用insertPlainText而不是append,是因为串口数据是连续数据流,append 会自动加换行,会把断断续续的串口数据拆得没法看。errors="replace"也很关键:串口设备发来的数据不一定都是合法的 UTF-8 编码,遇到无法解码的字节,replace 会把它替换成问号而不是直接抛异常让程序崩溃。
3.4 发送数据与 HEX 模式:一条 write 全搞定
发送逻辑比接收简单,就是把输入框内容转成字节数组再serial.write()。但 HEX 模式的处理值得注意,我见过不少人在这里写错:直接bytes.fromhex("AA BB")是没问题的,但用户输入可能是通过空格分隔的,也可能文本模式里带着换行,这些都要处理干净。
def send_data(self): if not (self.serial and self.serial.is_open): print("串口未打开") return text = self.send_edit.text().strip() if not text: return try: if self.hex_send_check.isChecked(): cleaned = text.replace(" ", "").replace(",", "") payload = bytes.fromhex(cleaned) else: payload = (text + "\r\n").encode("utf-8") self.serial.write(payload) except Exception as e: print(f"发送失败: {e}")文本模式末尾加\r\n是跟设备调试时的常见习惯,很多 MCU 端的串口中断程序是以回车换行作为一帧结束的标志。如果目标设备不要求加换行,把加号那行去掉即可。另外,bytes.fromhex对非法字符会抛异常,所以我把转换过程放在 try 里,错误信息直接打印出来,方便定位是输入格式问题还是串口问题。
3.5 关闭串口:别让 residual 线程把程序拖死
关闭串口看起来就一行serial.close(),但实际操作中有两个坑:一是关闭前必须先把读取线程停掉,否则线程还阻塞在read()里,主线程的wait()会一直等下去;二是串口对象可能已经因为异常被系统释放,直接close()会二次关闭报错。
def close_serial(self): if hasattr(self, "read_thread") and self.read_thread.isRunning(): self.read_thread.stop() if self.serial and self.serial.is_open: try: self.serial.close() except SerialException: pass self.open_btn.setChecked(False) self.open_btn.setText("打开串口")再看看SerialReadThread.stop(),它先把_running置为 False,再调用wait()。由于串口的 timeout 设置为 0.05 秒,线程最多 50 毫秒后就会从read()返回,发现循环条件为 False 就正常退出,所以wait()不会卡死。如果你的 timeout 是 None,这个关闭流程会直接卡在wait()上,这就是为什么我前面特别强调 timeout 必须设。
4. 高频问题排查:串口打不开、数据乱码、界面闪退
代码写完后,真正调试时遇到的问题往往比写代码时多。这一节把我自己和身边朋友遇到的高频问题整理成一个速查表,按这个顺序排查基本能解决 90% 的问题。
4.1 典型故障速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 端口下拉框空白 | USB 转串口驱动没装好 | 检查设备管理器 /lsusb,重装 CH340、FTDI 或 CP210x 驱动 |
| 打开串口提示 PermissionError | Linux 下当前用户无权限 | 执行sudo usermod -aG dialout $USER后重新登录 |
| 打开失败,提示端口占用 | 串口被串口助手、minicom 或其他程序占用 | 关闭占用程序,或用lsof /dev/ttyUSB0查看占用进程 |
| 数据乱码 | 波特率或数据位、校验位、停止位不一致 | 核对两端串口参数,特别注意设备是否要停止位 2 位 |
| 收到数据丢字节 | 上位机读取线程处理不过来 | 一次读in_waiting全部字节,提高串口 timeout 精度,检查是否 USB 转串口质量差 |
| 串口拔掉后程序再打开报错 | 设备节点消失但 serial 对象还引用旧端口 | 读取线程捕获 SerialException 退出,重开前重新扫描端口 |
| 程序退出后串口灯还亮 | 进程没退出或未调用 close | 在closeEvent里调用关闭逻辑,或强制结束 PyCharm 里的 Python 进程 |
| Linux 下用 minicom 打开 ttyACM0 显示 locked | 存在锁文件 | 删除/var/lock/LCK..ttyACM0,或在 minicom 配置里关闭锁文件 |
| PyQt5 下拉框一点就闪退 | 显卡驱动 / 远程桌面相关问题 | 程序最前面设置QApplication.setAttribute(Qt.AA_UseSoftwareOpenGL) |
这个表格里每个问题我都实际遇到或帮别人定位过,挑几个展开说。
4.2 下拉框闪退:一个很少被写进文档的修正方案
热词里有“pyqt5 下拉框闪退”,这个问题我在一台远程桌面开发机上遇到过,具体表现是:程序能正常启动,但一点端口下拉框的箭头,整个窗口立刻闪退,没有任何报错。当时我以为是代码里空列表的问题,反复检查逻辑没毛病。后来查到这是 PyQt5 在 Windows 下与特定显卡驱动、尤其是远程桌面环境下的兼容问题,和代码本身完全无关。
解决方式很简单,在创建QApplication之后、创建窗口之前加一行:
import sys from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QApplication if __name__ == "__main__": QApplication.setAttribute(Qt.AA_UseSoftwareOpenGL) app = QApplication(sys.argv) window = SerialAssistant() window.show() sys.exit(app.exec_())Qt.AA_UseSoftwareOpenGL强制让 Qt 使用软件渲染而不是硬件加速。对串口工具这种界面简单、不需要复杂动画的软件来说,用软件渲染完全没有任何性能损失,却能让闪退问题彻底消失。如果你的上位机还有别的诡异崩溃现象,这一行基本都值得先试一下。
4.3 Linux 下串口权限与 tty 锁文件
在 Linux 下调串口,PermissionError和 minicom 锁文件是最常见的两道坎。前者是因为当前用户不在 dialout 组里,执行一次sudo usermod -aG dialout $USER,重新登录后就能直接访问/dev/ttyUSB0和/dev/ttyACM0。
后者我之前在上位机里遇到过:板子用 USB CDC 方式枚举成 ttyACM0,我自己的工具读写正常,但想用 minicom 看原始数据时,提示/dev/ttyACM0被锁。原因是 minicom 默认会在/var/lock/下创建锁文件,防止两个程序同时打开同一个串口。解决方法是删掉锁文件,或者修改 minicom 的配置:
sudo rm -f /var/lock/LCK..ttyACM0如果频繁出现,可以打开 minicom 设置,在“Serial port setup”里把Use lock files设为 No。不过我自己现在基本不用 minicom 了,自己写的这个工具比 minicom 好用得多,还能顺便记录日志。
4.4 接收数据不完整:USB 转串口的“隐性问题”
热词里有一条“linux从串口接收数据丢失”,这种情况我也遇到过。大多数时候不是代码效率不够,而是 USB 转串口芯片的缓冲区和驱动处理能力有限。典型场景是设备每 10 毫秒发一帧 60 字节的数据,上位机没有及时读走,芯片内部 FIFO 溢出,多出来的字节就丢了。
三招应对:第一,读取线程里尽量用read(in_waiting)一次取完,减少调用次数;第二,如果数据量实在大,可以加大线程优先级,或者把timeout调小到 0.01 秒;第三,如果还是丢,换质量好一点的 FTDI 芯片模块,CH340 低端模块在大量数据下确实更容易丢包。另外还要确认板子端的串口配置里没有开流控,如果开了但上位机没处理 RTS/CTS,也会表现为数据丢失。
5. 从“能用”变“好用”:几个小扩展
基础版工具差不多十来行核心代码就能跑起来,但实际使用中你会发现,加点小功能能让调试效率提升一个档次。下面这几个扩展我都实际用在了自己的工具里,尤其是第一个,强烈推荐做。
5.1 接收区升级成带颜色的协议日志
接 3.1 节说的,接收区用QTextBrowser而不是QTextEdit,原因就是它可以渲染 HTML。串口数据虽然是文字,但调试的时候如果每一行都带上时间戳、用不同颜色区分接收和发送,定位问题会快得多。
import html import time def append_log(self, direction: str, text: str): timestamp = time.strftime("%H:%M:%S.%f")[:-3] color = "#2d7dd2" if direction == "RX" else "#e05d44" label = "RX" if direction == "RX" else "TX" safe_text = html.escape(text) self.receive_view.append( f'<span style="color:#888888">[{timestamp}]</span> ' f'<span style="color:{color};font-weight:bold">[{label}]</span> ' f'<span style="font-family:Consolas,monospace">{safe_text}</span>' )这里html.escape特别重要。串口数据里如果包含<或>这样的字符,直接拼进 HTML 会被当成标签解析,轻则样式错乱,重则程序崩溃。time.strftime("%H:%M:%S.%f")[:-3]可以拿到带毫秒的时间戳,排查一帧数据的时间间隔时非常好用。
热词里另一条“pyqt5 显示html”指的就是这类场景。用QTextBrowser.append()传入带标签的字符串,是最简单的富文本日志实现方式,不需要额外安装任何包。
5.2 用波形显示串口数值:PID 调参的神器
热词里有一条“stm32串口调试pid”,我用这个上位机干活的时候正好用到了波形功能。调的板子每隔 20 毫秒发一组类似pid:100,200,150这样的三个数值,单纯看文本完全看不出趋势,但画成曲线就一目了然了。做法不复杂:读取线程收到完整一行后解析出三个浮点数,放入一个环形缓冲区,再用QTimer每隔 50 毫秒触发一次重绘。如果不想自己手写绘图控件,可以用 matplotlib 嵌入 PyQt5,用FigureCanvasQTAgg作为画布,数据累积后刷新line.set_data()即可。
整体流程是:串口线程 -> 解析线程/主线程 -> 数据缓冲 -> 定时重绘。注意解析和绘图不要都放在串口读取线程里,否则读数据频率会被绘图拖慢,导致丢包。我这边的做法是读取线程只负责把原始字节放进一个queue.Queue,主线程的 QTimer 周期性从队列取数据进行解析和绘制,这样接收和显示彻底解耦。
5.3 数据自动导出与断开重连
调试现场经常遇到这样的情况:设备运行到某个时间点出问题,但问题发生时的串口日志谁也没盯着看。所以后来我给工具加了自动记录功能,接收到的原始数据按小时滚动写入文件,同时每 2 秒做一次端口状态检查,发现串口被拔掉后自动重连。
自动重连的判断用serial.is_open不太可靠,因为 USB 转串口模块被拔掉后,serial 对象有时仍然认为自己是打开的。我采用的是读取线程在read()或in_waiting过程中捕获SerialException,一旦捕获就把异常信息用另一个信号发到主线程,主线程收到后自动标记串口关闭,定时器开始尝试重连。
def check_ports(self): if self.serial is None or not self.serial.is_open: self.scan_ports() if self.port_combo.count() > 0: self.toggle_serial()自动重连的时间间隔设置成 2 秒比较合适。太短了占 CPU,太长了设备重新插上后要等很久。重连前一定要先重新扫描端口,因为重新插拔后端口的名字可能从 ttyUSB0 变成了 ttyUSB1,Windows 下 COM 号也可能变化。
最后说点实在的。串口读取和显示这个工具本身不难,难的是把各种边角情况处理好。我最初那个版本只有几十行代码,后来因为调试需要,陆续加了 HEX 显示、彩色日志、波形、自动重连和文件记录,现在代码加起来快一千行,但核心的东西还是那几块:pyserial 负责读写,QThread 负责不让界面卡死,信号槽负责线程间通信。你照着这个思路自己写一遍,收获会比直接下载一个串口助手大得多。写完这个工具之后,后续如果要做 Modbus RTU 解析、多设备通信甚至远程数据上传,都是在现有框架上加模块的事,底子打好了,扩展起来很顺手。
本文还有配套的精品资源,点击获取