简介:这份资源是面向Python初学者的PyQt5串口通信与数据可视化入门项目,基于PyQt5框架实现串口数据读取、SQLite数据库存储,并通过UI界面实时显示,尤其适合需要快速掌握上位机开发或课程设计参考的读者。压缩包共14个文件,核心包含2个Python脚本(如GL.py与data.py)及1个编译缓存pyc,其余为PyCharm工程配置类文件(xml、iml、meta),可帮助了解项目结构搭建方式,整体包体仅16KB,轻量易部署。目前已有7000余人学习,代码逻辑清晰,目录简洁,便于初学者对照理解串口通信、数据库写入与界面刷新的完整流程,也为二次开发或功能扩展提供了可修改的基础框架。
1. 自己动手写串口助手的理由:PyCharm + PyQt5 正好补齐现成工具的短板
用现成的串口调试助手,就像拿手电筒找螺丝——大多数时候够用,但一旦面对非标协议、需要实时换算工程值、或者要把收到的字节流直接联动到采集流程时,黑匣子就会卡住你的进度。在 PyCharm 里用 PyQt5 实现串口读取和显示,本质上就是把“黑匣子”打开,按自己的协议去解析、按自己的布局去展示。这篇笔记面向的是嵌入式开发、设备调试和产测工具维护的同行,我会从环境搭建一路讲到 QSerialPort 参数配置、读取模型、上屏策略,最后给出 5 条实测踩坑记录。每段代码都是可以在 PyCharm 里直接跑通的最小实现,参数含义和替换方式也会说清楚。
2. 先把地基打牢:PyCharm 的 PyQt5 环境与 Qt Designer 界面
2.1 环境搭建:pip 安装 PyQt5 的两个前提和一个虚拟环境习惯
我见过太多人把包装进系统 Python 里,接着被另一个项目的依赖顶掉版本,界面还没画就先把暗坑踩了一遍。所以第一步我会建一个虚拟环境,把 PyQt5 和它的工具链关在里面。PyCharm 里可以直接在 Terminal 面板操作,不用纠结图形界面那套解释器配置。
py -3 -m venv venv venv\Scripts\activate # Windows 下激活虚拟环境 # source venv/bin/activate # macOS / Linux 下改用这行 pip install pyqt5 pyqt5-tools解释一下这三步:py -3 -m venv venv会在当前目录生成一个干净的venv文件夹,避免污染全局 Python;activate之后,命令行提示符前面会出现(venv),说明后续安装都进这个隔离环境;pyqt5是运行时库,pyqt5-tools里带着 Qt Designer 和 pyuic5,后面把.ui界面文件转成.py就靠它。没有装pyqt5-tools,你也能跑代码,但手写界面布局的效率会低很多,所以这两个包我都建议一次装齐。
验证环境是否正常,用下面这段最小代码。它能弹出一个空窗口,同时导入串口模块成功,说明 PyQt5 的 GUI 绑定和 QtSerialPort 组件都可用了:
import sys from PyQt5.QtWidgets import QApplication, QMainWindow from PyQt5.QtSerialPort import QSerialPort app = QApplication(sys.argv) win = QMainWindow() win.show() sys.exit(app.exec_())这里值得注意:QtSerialPort是 PyQt5 自带的串口模块,不需要额外装pyserial。很多教程还在用 pyserial 配合 QTimer 定时读,不是说不行,而是既然用了 PyQt5,直接用 Qt 官方封装的 QSerialPort 能无缝对接信号与槽,少一层线程中转。如果你跑这段代码时在import就报错,优先检查是不是没在虚拟环境里执行,而不是急着重装 PyQt5。
2.2 用 Qt Designer 拖出串口面板:从 .ui 到 .py 的三种落地路径
环境就绪后,我不建议纯代码写布局。Qt Designer 拖控件虽然看起来“不够极客”,但它能让你肉眼确认串口面板的每个控件位置,比反复调setGeometry(0, 0, 400, 300)试坐标高效得多。新建一个serial_gui.ui,放上串口号下拉框、波特率下拉框、打开按钮、发送编辑框、发送按钮和一个多行文本显示区。命名很重要,我习惯把控件名起成combo_port、combo_baud、btn_open、btn_send、text_display,这样转到 Python 代码后,成员变量名就是可读的,不用回去查self.ui.comboBox_3到底是谁。
界面拖好后,转成 Python 文件有常见的三种做法,落地时我推荐第二种。第一种是运行时用QUiLoader动态加载.ui文件,好处是改界面不用重新编译,坏处是 IDE 对动态生成的控件没有代码提示;第二种是我最常用的,用 pyuic5 一次性转成.py,后续直接import,代码提示和调试都舒服;第三种是把生成的Ui_SerialGui类直接改写成主窗口的一部分,适合界面简单到不需要 Designer 的项目。
pyuic5 -x serial_gui.ui -o serial_gui.py-x参数会附加一段if __name__ == "__main__":的启动代码,方便单独预览界面;-o指定输出文件名。转换完成后,你在主程序里这样引用它:
import sys from PyQt5.QtWidgets import QApplication, QMainWindow from serial_gui import Ui_SerialGui class SerialMainWindow(QMainWindow): def __init__(self): super().__init__() self.ui = Ui_SerialGui() self.ui.setupUi(self) self.ui.btn_open.clicked.connect(self.open_serial) def open_serial(self): # 后续在这里配置并打开串口 pass app = QApplication(sys.argv) win = SerialMainWindow() win.show() sys.exit(app.exec_())这套写法的好处是:self.ui.combo_port、self.ui.btn_open这些名字都和 Designer 里的控件一一对应,界面改版后只需要重新跑一遍 pyuic5,主窗口的业务代码不用大改。如果转换时报ModuleNotFoundError: No module named 'PyQt5',先回到 2.1 确认虚拟环境激活状态——这种低级错误往往浪费最多时间。
3. 配置串口并开始读取:QSerialPort 参数与两种读取模型
3.1 QSerialPort 的 6 个必配参数:端口名、波特率之外还要管哪 4 个
打开串口前,QSerialPort 至少要设置端口名、波特率、数据位、校验位、停止位,流控大多数设备需要显式关掉。下面这段代码是打开串口的最小配置函数,我把它独立出来是为了让“打开”和“配置”解耦——调试的时候改一个参数,不需要重启整个窗口。
from PyQt5.QtSerialPort import QSerialPort def apply_serial_config(serial, port_name, baudrate): serial.setPortName(port_name) # 例如 "COM3" 或 "/dev/ttyUSB0" serial.setBaudRate(baudrate) # 115200 / 9600 等 serial.setDataBits(QSerialPort.DataBits8) # 数据位:8 位最常见 serial.setParity(QSerialPort.NoParity) # 校验位:无校验最常见 serial.setStopBits(QSerialPort.OneStop) # 停止位:1 位最常见 serial.setFlowControl(QSerialPort.NoFlowControl) # 先关掉流控 return serial.open(QSerialPort.ReadWrite)参数含义可以这样理解:setPortName对应操作系统里的串口枚举名,Windows 是COM3这类名字,Linux 下通常是/dev/ttyUSB0;setBaudRate是每秒传输的比特数,两端不一致时收到的就是乱码或空数据;setDataBits、setParity、setStopBits合起来描述的是一帧的电气格式,大部分传感器和开发板出厂默认 8N1——8 数据位、无校验、1 停止位。setFlowControl默认是NoFlowControl,但如果设备文档明确要求硬件流控,这里忘了开,表现为“能发不能收”或者“发一大段才回一点”。我把这 6 个参数做成表格,方便排查时对着看:
| 参数 | 方法 | 常见取值 | 出错表现 |
|---|---|---|---|
| 端口名 | setPortName | COM3 / /dev/ttyUSB0 | 打开失败 |
| 波特率 | setBaudRate | 9600 / 115200 / 921600 | 乱码或完全无响应 |
| 数据位 | setDataBits | DataBits8 | 帧对齐错误 |
| 校验位 | setParity | NoParity / EvenParity | 通信偶发中断 |
| 停止位 | setStopBits | OneStop / TwoStop | 帧尾抖动 |
| 流控 | setFlowControl | NoFlowControl | 发送后无回包 |
serial.open(QSerialPort.ReadWrite)的返回值是布尔值,但失败原因不能只看False。后面第 5 章会讲怎么从errorString()拿到真正原因,这里先记住一点:串口打开是一系列参数组合校验的过程,任何一个参数不能落在设备支持的范围内,Windows 下有可能直接抛AccessDeniedError,不是代码逻辑问题。
3.2 两种读取模型对比:readyRead 信号驱动和 QTimer 轮询
串口数据来了怎么知道?QSerialPort 提供了事件驱动的readyRead信号,也有传统 QTimer 轮询模式。事件驱动是首选:Qt 内部收到串口事件后触发信号,你只需要在槽函数里把缓冲读空。轮询模式适合把 QSerialPort 包在非 Qt 线程里、不方便跑事件循环的场景,代价是延迟不确定。
self.serial = QSerialPort() self.serial.readyRead.connect(self._on_ready_read) def _on_ready_read(self): data = bytes(self.serial.readAll()) if data: self._feed(data)注意readAll()一次把当前缓冲里的数据全部取出,返回的是QByteArray,用bytes()包一层转成 Python 字节串,方便后续自己切帧。槽函数里不要做耗时解析,只负责把数据交给_feed()放入队列,这一条在 5.4 会再强调。
如果你项目里已经有一套 QTimer 轮询的代码,它是这么写的:
self.poll_timer = QTimer(self) self.poll_timer.timeout.connect(self._poll_once) self.poll_timer.start(50) # 每 50 毫秒查一次 def _poll_once(self): while self.serial.bytesAvailable() > 0: data = bytes(self.serial.read(128)) self._feed(data)关键区别在于:poll_timer的间隔决定了数据延迟上限,间隔设太短会空转占用 CPU,设太长又会丢实时性;read(128)限定单次最多读 128 字节,防止在while循环里一次性把整个系统缓冲读完导致事件饥饿。两种模型合在一张表里:
| 维度 | readyRead 信号驱动 | QTimer 轮询 |
|---|---|---|
| 实时性 | 操作系统事件触发,延迟低 | 受定时器间隔限制 |
| CPU 占用 | 空闲时几乎为 0 | 空闲时也在空转 |
| 实现复杂度 | 低,但要避免槽内耗时 | 低,但要注意间隔设置 |
| 典型场景 | PC 上位机、Linux 桌面 | 嵌入脚本、非 Qt 线程 |
我自己的选择标准很简单:项目里已经用了 Qt 事件循环,就无脑用readyRead;如果串口被塞进一个非 Qt 的后台线程里,用 QTimer 轮询反而是最不容易出幺蛾子的方案。
3.3 什么时候必须上 QThread:解析耗时与批量上屏的边界
很多教程讲串口就必提多线程,仿佛不开线程就会卡死 UI。实际上 QSerialPort 本身是异步的,readyRead信号不会阻塞界面;真正卡住 UI 的是你在槽函数里做了协议解析、类型转换、写日志、逐条 append 到文本框。所以我的原则是:串口留在主线程,把“解析”丢给 QThread。
from PyQt5.QtCore import QThread, pyqtSignal import time class ParseWorker(QThread): result_ready = pyqtSignal(float, float) def __init__(self, queue): super().__init__() self.queue = queue self.running = True def run(self): while self.running: try: raw = self.queue.get_nowait() except Exception: self.msleep(1) continue value, ts = parse_frame(raw) if value is not None: self.result_ready.emit(value, ts) def stop(self): self.running = False self.wait()主线程的_feed()只做一件事:
def _feed(self, data): self.parse_queue.put_nowait(data)put_nowait是非阻塞的,即使解析线程来不及消费,也不会让串口事件线程停下来。pyqtSignal(float, float)是线程安全的,工作线程 emit 之后,主线程对应的槽函数会在 UI 线程执行,这就是 Qt 信号与槽跨线程调用的标准姿势。要说明的边界是:不要在run()里直接创建 QSerialPort 或操作串口对象,串口对象的事件循环归属主线程,跨线程直接调用容易出“对象已在其他线程”的警告。把数据从串口到解析线程的传递方式限定为队列,是最稳妥的。
4. 数据上屏的显示策略:文本框、表格和刷新节奏
4.1 十六进制收发与 ASCII 切换:乱码的根源在解码时机
串口传的是字节流,不是文本。把收到的字节按 UTF-8 去解码,遇到非 ASCII 数据就会出现“锟斤拷”;而十六进制视图能让你看到原始字节55 AA这样的帧头,排错时不会被乱码带偏方向。显示格式切换的代码并不复杂,关键是让切换动作发生在“格式化”而不是“存储”阶段。
def _format_data(self, data: bytes, use_hex: bool): if use_hex: return data.hex(" ") return data.decode("utf-8", errors="replace")data.hex(" ")把b'\x55\xAA'显示成55 aa,每个字节之间加空格方便人眼对齐;decode("utf-8", errors="replace")里errors="replace"的作用是把无法解码的字节替换成\ufffd,而不是直接抛异常中断显示线程。如果设备端发的其实是 GBK 编码的中文,你在 ASCII 模式看到乱码是必然的,这时候换成十六进制视图反而比换编码更直观——先用十六进制确认帧结构,再决定要不要按 GBK 解码。
发送端同样会遇到“把界面上的十六进制字符串转成字节”的问题。接收区里看到55 AA时,你可能想原样回发给设备,这时候需要把空格去掉再转字节:
def _send_hex_text(self, text: str): clean = text.strip().replace(" ", "") try: payload = bytes.fromhex(clean) except ValueError: self.ui.text_display.append("发送内容不是合法十六进制字符串") return self.serial.write(payload)bytes.fromhex遇到奇数长度或非法字符会抛ValueError,所以先做一次异常兜底。这个地方我踩过坑:设备要求发55 AA 01,手滑写成55 AA 1,程序直接崩溃,加上这个提示后才能把错误显示在界面上而不是终端里。
4.2 上屏限流:QTextEdit 卡顿不是数据多,是重绘太多
QTextEdit 单次 append 一条数据不算贵,但在高波特率下每秒几百条数据时,Qt 每 append 一次就要重新计算文本块布局、触发重绘,界面就慢慢糊了。解决思路是限制文档最大行数,再把多次显示合并成一批刷新。第一个参数是setMaximumBlockCount:
self.ui.text_display.document().setMaximumBlockCount(5000)setMaximumBlockCount是 QTextDocument 提供的裁剪机制,超过 5000 行后自动丢弃最早的行。对串口调试窗口来说,5000 行已经能覆盖大部分排查场景,内存占用也稳得住。但这只解决了行数无限膨胀的问题,还没有解决“每条数据都触发一次重绘”。
批量刷新的思路是:数据仍然实时进队列,但 UI 刷新由一个 80 毫秒的 QTimer 驱动,到点后把这段时间攒下的数据拼成一段文本,一次性 append。体现为:
self.display_queue = [] self.display_timer = QTimer(self) self.display_timer.timeout.connect(self._flush_display) self.display_timer.start(80) def _flush_display(self): if not self.display_queue: return merged = b"".join(self.display_queue) self.display_queue.clear() text = merged.decode("utf-8", errors="replace") self.ui.text_display.append(text)原本每秒几十次 append 被压缩到 12 次左右,QTextEdit 的文本块布局计算量大幅下降。display_queue的消费端在定时器里,生产端在_on_ready_read里,二者都是主线程操作,不存在锁竞争。要注意的是 80 毫秒这个值:设太长会让人觉得数据慢了半拍,设太短又回到高频重绘的老路,我一般在 50 到 100 毫秒之间选。
4.3 表格显示:把一帧里的字段拆成行和列,滚动屏做不到的事
串口调试里除了滚动的原始数据,我还经常需要“盯住某个参数的最新值”。比如一帧协议里包含温度、电压、状态字,用 QTextEdit 滚动查找很累,改成 QTableWidget 固定表头、刷新最新值就直观得多。表头在初始化时设置一次,后面只更新单元格内容:
def _init_param_table(self, fields): self.ui.table_widget.setRowCount(len(fields)) self.ui.table_widget.setColumnCount(2) for row, key in enumerate(fields): item_key = QTableWidgetItem(key) item_val = QTableWidgetItem("--") self.ui.table_widget.setItem(row, 0, item_key) self.ui.table_widget.setItem(row, 1, item_val) def _update_param_table(self, parsed_fields): for row, key in enumerate(parsed_fields.keys()): val_item = QTableWidgetItem(str(parsed_fields[key])) self.ui.table_widget.setItem(row, 1, val_item)_update_param_table每次只重建第 1 列的值,第 0 列参数名不动,这样 setItem 的次数也能减半。如果你解析出来的字段比表头多,先判断row < self.ui.table_widget.rowCount(),防止越界。这种“最新值表格”适合配合 4.2 的定时刷新,每 200 毫秒更新一次表格,人的视觉感知刚好跟上,又不至于因为刷新太快导致表格闪烁。
5. 串口开发避坑清单:5 条血泪经验,每条都能救一晚上
5.1 打开成功但设备无响应,先怀疑 DTR/RTS 默认电平
现象:串口能正常打开,发送区发数据设备没有任何反应,用万用表量 TX 引脚也有电平变化。
原因:一部分开发板和无线模块用 DTR 或 RTS 做复位或进入 Bootloader 的使能信号。QSerialPort 打开串口时,DTR 和 RTS 默认处于置位状态,设备刚被拉进复位流程,自然不响应你的数据。
解决:在open()之后显式把这两个信号拉低:
if self.serial.open(QSerialPort.ReadWrite): self.serial.setDataTerminalReady(False) self.serial.setRequestToSend(False)setDataTerminalReady对应 DB9 里的 DTR 引脚,setRequestToSend对应 RTS。如果你的设备文档里写了“需要拉低 DTR 进入工作模式”,这两行就能直接救回来。
5.2 USB 转串口乱码:波特率数值一样,但误差率不一样
现象:设备和上位机都配成 115200,收到的数据却是乱码,换成 9600 反而正常。
原因:主板原生串口的波特率由晶振精确分频得到,USB 转串口芯片的波特率来自芯片内部时钟,质量差的芯片在 115200 下的误差可能超过 2%,超过 UART 接收端容忍范围就乱码。
解决:先换一个正规渠道的 USB 转串口模块对比测试,排除芯片硬件问题。如果只能用现有模块,把波特率降到 38400 或 9600,通常就能恢复正常。另一个排查点:设备端的波特率是“标称值”,不代表实际值,有些设备实际是 115200 的 1.5 倍,需要结合设备手册确认。
5.3 程序重跑第二次报“端口被占用”,大概率是析构顺序错了
现象:第一次运行能打开串口,关闭窗口后立即重新运行,报端口被占用或PermissionError。
原因:窗口关闭时serial对象没有调用close(),Python 引用计数直到解释器退出才释放串口句柄,而句柄释放后系统还需要一小段时间回收。
解决:重写主窗口的closeEvent,按“先停线程、再关串口”的顺序清理:
def closeEvent(self, event): if self.worker and self.worker.isRunning(): self.worker.stop() if self.serial.isOpen(): self.serial.close() event.accept()先停worker.stop(),确保解析线程不再持有任何数据引用;再serial.close()释放句柄。注意不要在worker还在跑的时候先关串口——线程里可能还在读队列,这时候资源释放顺序乱了,下次运行就会出现随机性的“端口被占用”。
5.4 readyRead 槽里做了解析导致丢帧
现象:低波特率一切正常,波特率提高到 921600 后,收到的帧频繁不完整,时间戳跳动毫无规律。
原因:readyRead槽函数里直接做了协议解析和文件写入,槽函数执行期间事件循环被阻塞,串口硬件缓冲持续接收新数据,缓冲区溢出后旧数据被覆盖,于是一帧被拦腰截断。
解决:槽函数只把原始字节放入队列,解析和写文件全部挪到工作线程。如果你只是临时调试不想引入线程,至少也要把耗时操作拆到 QTimer 批量处理里。丢帧问题一旦出现,不要先怀疑硬件,先看槽函数里有没有超过 1 毫秒的阻塞操作。
5.5 粘包和半包不是串口的错,是你还没切帧
现象:设备的协议是帧头55 AA加长度字节,但接收到的数据有时候一帧被拆成两段,有时候两帧黏在一起。
原因:串口是字节流,没有网络协议里的“包”概念。设备发送时机、驱动缓冲、USB 转串口的批处理都可能让字节到达时间出现随机分组。
解决:维护一个累积缓冲,按“帧头 + 长度”来切帧。这是我常用的切帧逻辑:
def feed_chunk(self, chunk): self.buffer += chunk while len(self.buffer) >= 3: start = self.buffer.find(b"\x55\xAA") if start < 0: self.buffer = self.buffer[-2:] return if start > 0: self.buffer = self.buffer[start:] if len(self.buffer) < 3: return frame_len = self.buffer[2] if len(self.buffer) < 3 + frame_len: return frame = self.buffer[:3 + frame_len] self.buffer = self.buffer[3 + frame_len:] self.on_frame(frame)self.buffer是累积字节串,find定位帧头,frame_len取帧头后第 3 个字节作为数据长度。找不到完整帧时return,等到下一个feed_chunk把剩余部分拼接上,这样就同时解决了半包和粘包。注意start < 0时保留最后两个字节是为了防止帧头55刚收到一半,下一包才到AA的情况。
6. 收尾技巧:从能读到好用,一个验证方法和两个习惯
6.1 先把回环测试做扎实,再谈协议解析
拿到一套串口程序,不要急着接真实设备。最可靠的验证是用一根杜邦线把 TX 和 RX 短接,打开串口后发什么就收到什么。如果回环正常,说明打开、读取、显示这条链路是通的;如果回环乱码,问题就锁定在波特率或引脚连接上;如果回环完全没有数据,多半是流控或 DTR/RTS 配置出了问题。这个测试耗时五分钟,但能省下一个下午拿着示波器追协议的时间。真正的设备联调放到回环通过之后。
6.2 让工具记住配置和留下时间戳:两个十秒习惯
第一个习惯是把端口号、波特率存进 QSettings,下次启动自动带出。QSettings("MyTools", "SerialTool")在 Windows 上写入注册表,在 Linux 上写入~/.config下的配置文件,跨平台不用自己管路径:
settings = QSettings("MyTools", "SerialTool") settings.setValue("port", self.ui.combo_port.currentText()) settings.setValue("baud", self.ui.combo_baud.currentText())第二个习惯是接收区每一行都带毫秒时间戳。排查时序问题时,没有时间戳的日志基本等于废纸:
from datetime import datetime ts = datetime.now().strftime("%H:%M:%S.%f")[:-3] self.ui.text_display.append(f"[{ts}] RX: {hex_str}")%f是微秒,截断到前 3 位得到毫秒,正好用来判断两帧数据之间到底隔了多久,粘包还是丢包一眼就能分辨。这套验证和两个习惯加起来不用半小时就能养成,但之后每次调试都能少走一段弯路。我最早写串口工具时也是只接收发不看时序,后来在数据对不上的夜里意识到这些细节才是工具好不好用的分水岭——希望帮到你。
本文还有配套的精品资源,点击获取