简介:一份面向Python初学者的PyQt5上位机开发示例,在PyCharm工程中实现串口数据读取、SQLite存储和UI实时显示。资源将界面逻辑与数据存取分离,构成完整的串口调试小工具,读者可从事件触发、串口解析、数据库写入到界面刷新的链路中理解小型桌面应用的模块划分。压缩包共14个文件,以py源码、项目配置文件及元数据为主,xml与iml记录工程结构,pyc为编译缓存,整体仅16KB,目录简洁却覆盖核心功能,通过PyCharm打开后可快速体验数据采集、入库、显示的完整闭环。该资源已有7000余人学习下载,适合正在学习Python桌面开发、需要快速搭建串口数据采集界面的学习者,可直接基于此看清PyQt5与数据库协同工作的典型写法,再按需扩展数据解析或波形展示等功能;示例依赖少、可读性强,也适合课程设计或毕设仿写。
1. PyCharm + PyQt5 实现串口读取和显示:调试台上缺的那把上位机
想用 PyCharm + PyQt5 实现串口读取和显示这种小工具,通常不是课程作业,而是调试台上真的缺一个趁手的上位机。通用串口助手能收发、能看十六进制,可一旦要按协议解析帧、记录时间戳、画波形或者给客户定制界面,它就变成一个改不了的黑匣子。这篇文章把这条路完整拆开:PyCharm 负责工程管理,PyQt5 负责窗口、按钮和显示区,pyserial 负责打开串口和收发字节,三者通过 Qt 的信号槽机制把后台读到的数据实时刷到界面上。适合嵌入式开发、硬件测试和设备调试的从业者,不要求你会 C++,按步骤走就能在半天内跑出第一个可用版本。
2. 环境搭建:PyCharm 虚拟环境与 pyqt5、pyserial 两个库的安装
2.1 新建项目时选对虚拟环境,别把依赖装进系统 Python
打开 PyCharm,新建项目时选择 Virtualenv 作为解释器环境,Python 版本选 3.8 或更高。虚拟环境的作用是让 pyqt5 和 pyserial 只属于当前项目,换项目时不会互相干扰。有些开发者图省事直接往系统 Python 里装,结果另一套代码用了不同版本的 PyQt5,导入时报一堆 sip 绑定错误,返工成本反而更高。
项目创建完成后,PyCharm 底部打开 Terminal,执行安装命令:
pip install pyqt5 pyserial这条命令同时安装两个库。pyqt5 会额外拉取 PyQt5-Qt5 和 PyQt5-sip 两个依赖,前者是 Qt 运行库本体,后者是 Python 和 C++ 对象绑定的桥接层;pyserial 是纯 Python 实现,没有编译步骤。如果公司网络访问公网 PyPI 超时,先配置内网镜像源再执行安装,能省很多等待时间。
2.2 两条验证命令,确认 Qt 绑定和串口库都可用
安装完成后别急着写界面,先用命令验证环境是否真的可用:
python -c "from PyQt5.QtWidgets import QApplication; print('PyQt5 OK')" python -c "import serial; print('pyserial', serial.__version__)"第一条命令只要能执行完并打印 PyQt5 OK,说明 Qt 的 Python 绑定完整,sip 也正常。第二条命令打印 pyserial 的版本号,确认导入路径正确。这两条命令跑完后,再顺手看一眼系统当前有哪些串口:
python -c "import serial.tools.list_ports; print([p.device for p in serial.tools.list_ports.comports()])"在 Windows 上会返回 COM3、COM5 这类设备名,Linux 上返回 /dev/ttyUSB0、/dev/ttyACM0。这一步提前确认端口枚举正常,后面打开串口时就不必纠结是驱动问题还是代码问题。
还有一个容易忽略的细节:在创建 QApplication 之前,建议打开 Qt 的高 DPI 缩放。
from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QApplication QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) app = QApplication([])不设置这一行,高分屏下 PyQt5 界面会明显发虚,按钮和文字边缘模糊。这个设置必须在 QApplication 实例创建之前调用,放在文件最顶部即可。
3. 串口读取第一步:pyserial 参数配置与 readline/read 两种读法
3.1 串口参数对照:波特率、数据位、停止位、校验位与流控
串口通信的本质是双方按照约定好的电气参数收发字节,参数不对,数据就是乱码。绝大多数设备手册里会写明通信格式,比如常见写法“115200, 8, N, 1”,意思是波特率 115200、数据位 8、无校验、停止位 1。pyserial 里对应的配置参数如下:
| 参数 | 常用值 | 说明 |
|---|---|---|
| port | COM3 / /dev/ttyUSB0 | 端口设备名,不同系统写法不同 |
| baudrate | 9600 / 115200 / 460800 | 波特率,必须和设备端一致 |
| bytesize | serial.EIGHTBITS | 数据位,常用 8,旧设备可能用 7 |
| parity | serial.PARITY_NONE | 校验位,无校验最常用,部分设备用偶校验 |
| stopbits | serial.STOPBITS_ONE | 停止位,常用 1 |
| timeout | 0.1 到 1 | 读超时秒数,设太短会截断数据 |
关于流控,多数上位机场景不需要硬件流控,RTS/CTS 保持默认关闭就好。有些设备要求开启 DSR/DTR 信号,这时可以在打开串口后手动拉高,后面避坑章节会提到。
3.2 打开与关闭串口的最小代码
import serial ser = serial.Serial( port='COM3', # Windows 端口名,Linux 下是 /dev/ttyUSB0 baudrate=115200, # 和设备端保持一致 bytesize=serial.EIGHTBITS, # 8 数据位 parity=serial.PARITY_NONE, # 无校验 stopbits=serial.STOPBITS_ONE, # 1 停止位 timeout=0.1 # 读取超时 100ms ) print(ser.is_open) # True 表示端口已打开 ser.close() # 用完必须关闭,否则端口一直被占用这段代码里有几个细节值得说明。timeout 参数的单位是秒,设置成 0.1 意味着每次 readline 或 read 最多阻塞 100 毫秒。这个值太小时,设备发来的数据如果传输慢,可能读半截就超时返回;设置太大,又会让线程停住很久,影响界面刷新。我的习惯是先按波特率粗算:115200 波特率下传 1 字节约 0.09 毫秒,一帧 50 字节的数据约 4.5 毫秒,超时给 100 毫秒对按行发送的设备足够宽裕,对高频连续流也不会明显拖沓。
还有一点要注意:Serial 对象创建时如果端口不存在或被占用,会直接抛出 SerialException。实际项目里不要裸调,外面要包 try/except,后面避坑章节会给完整写法。
3.3 readline 与 read 的取舍:按行读还是按字节数读
pyserial 提供两种读取姿势,选择依据是设备的数据格式。
# 方式一:按行读取,适合文本协议,数据以换行符结尾 def read_one_line(ser): raw = ser.readline() if not raw: return None return raw.decode('ascii', errors='ignore').strip() # 方式二:按固定字节数读取,适合二进制帧协议 def read_fixed_frame(ser, length): frame = ser.read(length) if len(frame) < length: return None return frame.hex()readline 会一直读到换行符 \n 为止,适合设备以文本行形式输出数据的场景,比如 GPS 的 NMEA 语句、传感器轮询返回字符串。read(n) 则只读 n 个字节,适合协议里定了帧头帧尾和固定长度的二进制包。
这里最容易翻车的是 readline 没有等来换行符的情况。timeout 一到,readline 会把已接收的内容直接返回,于是你拿到一行残缺数据。避免办法有两个:一是把 timeout 调大一点,二是先读一帧试试,确认设备是否真的以 \n 结尾,而不是 \r 结尾。很多老设备只发 \r,此时 readline 会一直等到超时才返回,表现为“数据能读到但非常慢”。
4. PyQt5 界面与实时刷新:布局、信号槽、后台线程缺一不可
4.1 界面骨架:顶部配置区加底部显示区的最小布局
PyQt5 做串口上位机,界面不需要花哨,核心是三块:端口选择、打开关闭按钮、数据文本区。下面是一份能直接跑起来的最小界面代码:
from PyQt5.QtWidgets import (QWidget, QComboBox, QPushButton, QTextEdit, QLabel, QVBoxLayout, QHBoxLayout) class SerialWindow(QWidget): def __init__(self): super().__init__() self.setWindowTitle('串口读取显示') self.resize(720, 520) self._build_ui() def _build_ui(self): self.port_combo = QComboBox() self.port_combo.addItem('COM3') # 实际项目里用端口枚举填充 self.open_btn = QPushButton('打开串口') self.output_box = QTextEdit() self.output_box.setReadOnly(True) # 只读,避免用户误删日志 top = QHBoxLayout() top.addWidget(QLabel('端口')) top.addWidget(self.port_combo) top.addWidget(self.open_btn) layout = QVBoxLayout(self) layout.addLayout(top) layout.addWidget(self.output_box)布局逻辑很直白:水平布局放端口选择和按钮,垂直布局上半部分塞水平布局、下半部分塞文本区。QTextEdit 设置只读后仍然可以用 append 追加内容,但用户没法手动编辑,保证日志区不被误操作污染。如果后续想加发送区,只需要在 top 层再追加一个输入框和发送按钮,不动整体结构。
4.2 信号与槽:把按钮点击和串口开关动作连起来
Qt 的界面事件靠信号槽机制驱动。按钮被点击时发出 clicked 信号,通过 connect 把它和某个槽函数绑定:
from PyQt5.QtCore import QThread, pyqtSignal from PyQt5.QtWidgets import QMessageBox import serial class SerialWindow(QWidget): # 省略 _build_ui 中的控件创建 def _setup_connections(self): self.open_btn.clicked.connect(self.on_open_clicked) def on_open_clicked(self): if self.open_btn.text() == '打开串口': self._open_serial() else: self._close_serial() def _open_serial(self): port_name = self.port_combo.currentText() try: self.ser = serial.Serial(port=port_name, baudrate=115200, timeout=0.1) except serial.SerialException as e: QMessageBox.warning(self, '打开失败', str(e)) return self.open_btn.setText('关闭串口') self.output_box.append(f'[已打开] {port_name}') def _close_serial(self): if hasattr(self, 'ser') and self.ser.is_open: self.ser.close() self.open_btn.setText('打开串口')这段代码展示了串口资源管理的两个习惯:一是打开失败必须给用户明确提示,SerialException 的文本在 Windows 上通常是“Access is denied”或“拒绝访问”,直接弹出来比在控制台里找堆栈省事得多;二是按钮文本作为状态开关,用“打开串口/关闭串口”双向切换,避免引出第二个布尔变量。
4.3 后台读取线程:为什么读循环不能在按钮回调里写
如果在 on_open_clicked 里直接写 while 循环读串口,窗口会在点击按钮的瞬间卡死。原因在于 Qt 的界面刷新依赖主线程事件循环,而 readline 是阻塞调用,设备不传数据时它一直占着主线程不放,拖拽窗口、点击按钮全部失效。
正确的做法是把读取循环放进 QThread 子类,读到的数据通过信号发回主线程:
class SerialReadThread(QThread): data_received = pyqtSignal(str) error_occurred = pyqtSignal(str) def __init__(self, ser): super().__init__() self.ser = ser self._running = False def run(self): self._running = True while self._running: try: raw = self.ser.readline() except serial.SerialException as e: self.error_occurred.emit(str(e)) break if raw: text = raw.decode('utf-8', errors='ignore').strip() self.data_received.emit(text) self.ser.close() def stop(self): self._running = False在主线程里连接信号和槽:
def _start_read_thread(self): self.thread = SerialReadThread(self.ser) self.thread.data_received.connect(self.append_line) self.thread.error_occurred.connect(self.on_serial_error) self.thread.start() def append_line(self, text): self.output_box.append(text)这个模型的核心是跨线程通信。SerialReadThread 在后台线程里执行 run 方法,读取循环不会阻塞界面;每读一行就 emit 一次 data_received 信号。Qt 检测到发送信号的对象和接收槽不在同一线程,会自动改用队列连接,append_line 始终在主线程执行,UI 更新也就变得安全。
关闭程序时记得先停线程再关窗口:
def closeEvent(self, event): if hasattr(self, 'thread'): self.thread.stop() self.thread.wait(1000) event.accept()stop 只是把循环标志置 False,线程会在当前 readline 超时返回后退出,wait 最多等 1 秒。如果设备一直发数据导致 readline 快速返回,线程会很快结束;如果设备静默,readline 最多阻塞 timeout 时长,线程也能在超时后退出。
5. 串口上位机避坑:乱码、占用、丢数据、界面卡死的排查思路
5.1 界面卡死:把读循环写进了主线程
现象:点击“打开串口”后,窗口立刻失去响应,标题栏显示“未响应”,强制关闭都没反应。
原因:读循环直接写在按钮回调里,readline 阻塞了主线程事件循环。
解决:参考上一章的 QThread 方案,把串口读取循环挪到后台线程。判断主线程是否被阻塞有个简单办法:打开窗口后拖动标题栏,如果拖不动,说明事件循环被卡住。如果数据一直在快速到达,界面可能看起来还“活着”,但按钮点击已无响应。
5.2 中文乱码:解码表不一致
现象:设备返回的中文显示为乱码,比如“温度 25.3℃”变成“娓╁害 25.3鈩”。
原因:发送端和接收端使用了不同的编码表。代码里写死了 asc,或设备实际用的是 GBK 而界面按 UTF-8 解码。
解决:先看设备手册确认编码表,再把解码语句改成对应编码:
text = raw.decode('gbk', errors='ignore').strip()如果设备协议支持配置编码,统一改成 UTF-8 最省事。拿到未知设备时,可以分别用 utf-8、gbk、ascii 各解码一次,看哪边输出可读文本,这个试探过程建议写成一个小工具函数,不然每换一种设备就要重编译一遍界面。
5.3 打开串口报拒绝访问:端口被其他程序占用
现象:程序运行报 serial.SerialException,文本包含“Access is denied”或“拒绝访问”,但前一天同样的代码还能正常打开。
原因:另一个上位机软件或上一次程序未正常退出,占用了同一 COM 口。Windows 下串口是独占资源,同一时刻只能有一个进程打开。
解决:先把占用程序关掉,再确认上一次运行是否发生过异常退出。代码层面做好两件事:一是 open_btn 对应的关闭逻辑里必须 ser.close(),二是进程结束前用 closeEvent 确保线程停掉、串口释放。如果还是提示占用,用资源监视器找出占用进程。
5.4 偶发丢数据:readline 超时截断
现象:设备连续输出大量数据时,偶尔读到半行,比如“123456”只显示“1234”。
原因:timeout 设置太小,readline 在下一个字节到达前超时,把已读到的部分当作完整一行返回。
解决:先把 timeout 从 0.05 提到 0.2~0.5 再观察。如果丢帧仍然存在,说明设备可能没有以换行符结尾,或者波特率太低导致一行的传输时间整体超标。对于二进制帧协议,不要用 readline,改用 read(frame_length) 并按帧头校验后再解析。
5.5 拔插 USB 转串口后端口消失
现象:同一个设备,早上插在左边 USB 口是 COM3,下午插到右边变成 COM5,程序里写死 COM3 就打不开。
原因:Windows 按 USB 枚举和端口分配,插口变化会让设备重排。
解决:界面上加一个“刷新端口”按钮,点击后重新枚举:
import serial.tools.list_ports def refresh_ports(self): self.port_combo.clear() for p in serial.tools.list_ports.comports(): self.port_combo.addItem(p.device)更省事的习惯是在打开前自动枚举一次,如果上次使用的端口不在列表里,就自动切换到第一个可用端口。很多现成串口助手也是这么处理的,能省掉来回查设备管理器的操作。
6. 进阶:把数据流画成波形,并自动存进 CSV
基础版做到能读能显示只是起点,实际调试中真正提效的是数据可视化和自动落盘。先说波形显示:在上一章代码基础上,把读取线程的信号从字符串改成一个浮点数值,然后用 pyqtgraph 画实时曲线。
pip install pyqtgraphfrom pyqtgraph import PlotWidget from collections import deque class WaveWindow(QWidget): def __init__(self): super().__init__() self.plot = PlotWidget() self.data_buffer = deque(maxlen=200) # 只保留最近 200 个点 def append_point(self, value): self.data_buffer.append(value) self.plot.getPlotItem().clear() self.plot.plot(list(self.data_buffer))注意 pyqtgraph 的 update 不要高频调用 clear,否则 CPU 占用飙升。简单做法是定时器每 100ms 刷新一次曲线,串口信号只往缓冲区写数。
数据落盘我用 text 模式追加写入 CSV,文件名带上时间戳:
from datetime import datetime import csv def append_line(self, text): self.output_box.append(text) with open(f'log_{datetime.now():%Y%m%d_%H%M%S}.csv', 'a', newline='', encoding='utf-8') as f: writer = csv.writer(f) writer.writerow([datetime.now().isoformat(), text])写入量不大时不需要额外线程,每来一行写一行,程序异常退出最多丢最后一条。这样做的价值在于,调试结束后拿 CSV 数据可以直接进 Excel 画趋势图,不用对着界面截图一条条抄数据。
我自己的习惯是,无论多小的串口工具都保留“原始数据显示”和“解析后数据”两个显示区,前端做协议解析时,原始区用来核验解析逻辑是否算错,解析区用来直接看业务含义。第一次写串口上位机时我曾省掉原始区,结果解析算法出了偏差,排查了很久才发现是帧对齐问题,后来一律双区显示。希望这个习惯对你也有帮助。
本文还有配套的精品资源,点击获取