☰
Python串口通信实战:pyserial核心用法与高频坑位解析
2026/10/3 9:16:45 网站建设 项目流程

最近被好几个朋友问到pyserial,有做嵌入式调试的,有捣鼓单片机上位机的,还有刚入行想搞工业自动化的。问题都很集中:这个东西到底怎么用?为什么我读写老出错?串口调试助手上没问题,换成Python就乱码?

这些问题我基本都踩过一遍,所以直接整理一篇基础篇出来。这篇东西会从串口通信最底层的概念讲起,一直到pyserial读写、超时处理、设备枚举、实机调试,最后把高频坑位拉个清单。目标就一个:你照着走完一遍,能在半小时内跑通和单片机的通信,而不是卡在环境配置和奇奇怪怪的读写异常上。

这篇内容适合刚接触Python串口编程的开发者,也适合已经从串口调试助手转到代码控制的硬件爱好者。

1. 为什么串口通信选pyserial

1.1 串口这玩意到底在干什么

串口通信本质上是两个设备之间按位传输数据的异步通信方式。这个“异步”很关键,意思是收发双方不需要共享时钟信号,而是靠约定的波特率来同步数据的采样节奏。一台PC和一块STM32开发板之间通信,最常见的物理载体就是USB转TTL模块,电脑那端识别成一个COM口(Linux下是ttyUSB0或ttyACM0),MCU那端则是UART引脚。

在Python里做串口通信,目前最主流的选择就是pyserial,它把底层的系统API封装成了统一且简洁的接口,同一份代码在Windows、Linux、macOS上基本不需要改动。你要知道,如果不用pyserial直接调系统API,Windows要操作COM口得走CreateFile、ReadFile那套Win32函数,Linux则要处理termios的原始模式配置,两边的状态机还完全不一样,学习成本高不说,代码量直接翻倍。pyserial做的就是把这层差异全部吃掉,让你只需要关心打开哪个串口、用什么参数、读多少字节。

1.2 pyserial的定位和安装

pyserial目前是Python生态里处理串口通信的事实标准,几乎所有的开源串口工具、嵌入式开发框架、物联网调试脚本都在用它。它支持Python 3.x全系,安装方式也很无脑:

pip install pyserial

如果你用的是conda环境:

conda install pyserial

装完验证一下是否能正常导入:

import serial print(serial.__version__)

能输出版本号就说明装好了。这里有一个常见的坑:serial这个名字太普通了,某些环境里可能和其他库的模块冲突,比如win32serial或者某些自定义模块。如果你import serial报错,先看下报错信息里指向的是不是site-packages下的serial目录;如果不是,去检查一下有没有一个同名py文件把你的路径污染了。这种事情我在实际项目里真的碰到过,一个同事的项目目录下就有个serial.py,结果所有人跑他代码都是一脸懵。

2. 核心API逐个拆解

2.1 serial.Serial参数到底该怎么填

构造一个串口对象是pyserial的第一步,官方文档给了一堆参数,但实际项目中最常用的是这几个:

import serial ser = serial.Serial( port='COM3', # Windows串口号,Linux下一般是/dev/ttyUSB0 baudrate=115200, # 波特率,要和设备端保持一致 bytesize=8, # 数据位,常用8 parity='N', # 校验位,N是无校验,E是偶校验 stopbits=1, # 停止位,常用1 timeout=0.5 # 读超时,单位秒 )

这三个参数bytesize、parity、stopbits组合起来叫“帧格式”,8-N-1是默认配置,意思是8位数据位、无校验、1位停止位。绝大多数场景下用8-N-1就行,如果设备手册特别要求8-E-1或者7-O-1,再改这两个参数。波特率是通信双方约定的数据时钟频率,单位为bps(比特每秒)。你设备端设115200,Python这边就得写115200,不一致就是一堆乱码。为什么这么说?因为波特率决定了每一位数据的时间宽度,比如115200bps下每一位约8.68微秒,接收端就按这个时间宽度去采电平,你设错了宽度,采到的电平序列自然对不上。

timeout这个参数需要单独拎出来讲。它只影响读操作,单位秒,可以接收浮点数表示毫秒级,比如timeout=0.05就是50毫秒。设置超时的核心意义是让read不会永久阻塞。如果不设timeout,read在收不到数据时会一直卡住,在多线程或GUI程序里这是致命的,界面直接假死。我的习惯是:查询类指令给0.1到0.5秒,如果是主动上报类的数据流,给0.5到1秒,具体后面实操部分细说。

2.2 读写数据的基本姿势

一旦创建了serial.Serial对象,调用ser.open()即可打开串口,之后就能读写数据了。注意,如果你在构造对象时传了port参数,open会被自动调用,不需要再手动open。如果你不传port参数创建了空对象,之后可以这样按需打开:

ser = serial.Serial() ser.port = '/dev/ttyUSB0' ser.baudrate = 9600 ser.open()

读数据方式有两种:

# 读一个字节 data = ser.read(1) # 读最多64个字节 data = ser.read(64) # 读一行(以\n结尾) line = ser.readline() # 读指定字节数直到读完缓冲区 data = ser.read(ser.in_waiting)

read(size)返回的是bytes对象,不是str。size指定读多少个字节,实际返回的字节数取决于串口缓冲区里有多少数据和timeout设置。如果你想“把当前收到的所有数据都读出来”,最常用的方式是ser.read(ser.in_waiting),in_waiting会返回输入缓冲区内等待读取的字节数。但这个方法有个小问题是,如果你调用那一刻设备还在传数据,in_waiting返回的值可能小于最终到达的数据量,读到的就是不完整的消息。所以很多人在实际项目里会配合time.sleep或者循环去读,直到拿完一帧完整数据。

写数据方式:

# 写字符串,需要编码为bytes ser.write(b'AT\r\n') # 写字符串的另一个姿势 ser.write('AT\r\n'.encode('utf-8')) # 写入十六进制数据 ser.write(bytes([0x01, 0x03, 0x00, 0x00]))

每次write之后建议加一个ser.flush(),这个操作会等待所有输出的数据都发出去才返回。还有一个注意点:串口通信没有“消息边界”的概念,它只是字节流,哪怕你两次write隔了10毫秒,接收端也可能把它们当成连续数据,所以自定义协议的时候通常要加帧头、帧尾或者长度字段来区分一帧的起止。这一点很多人刚开始会忽略,结果就是设备上报的数据经常粘包,要么少一截要么多一截。

2.3 读超时、写超时和打开串口失败

写超时对应的参数是write_timeout,默认是None,也就是写不阻塞。串口发送缓冲区一般不大,如果对端不读数据,缓冲区满了之后写操作会开始阻塞。对于普通场景,write_timeout设不设问题不大,但如果你要在高并发下发送大量数据,建议设一个合理的write_timeout,比如1到3秒,避免线程卡死。

打开串口失败是新手最容易碰到的问题。常见报错是SerialException: could not open port 'COM3': PermissionError,这个在Windows和Linux上都有,原因通常是串口被其他程序占用了。这个“其他程序”可能是你之前跑过没关的Python脚本,也可能是串口调试助手没退出,或者是后台某个服务把串口占用了。你需要检查并释放串口占用。Windows下可以用设备管理器看端口是否正在使用,Linux下可以用lsof /dev/ttyUSB0命令查谁在占用,查到PID后kill掉。但最好的习惯是代码里用try-finally或with语句,不用时随手关闭:

try: ser = serial.Serial('COM3', 115200, timeout=0.5) # 业务逻辑 finally: ser.close()

3. 让pyserial跑起来:从查询到对接设备

3.1 串口列表:自动发现设备

在实际项目中,串口号经常变,特别是USB转串口的设备,每次插拔的枚举顺序可能不同,导致COM口从COM3变成COM7。如果代码里写死串口号,每次换口都要改代码,很麻烦。pyserial提供了一个工具方法来自动枚举当前可用的串口:

import serial.tools.list_ports ports = serial.tools.list_ports.comports() for port, desc, hwid in sorted(ports): print(f"端口: {port} 描述: {desc} 硬件ID: {hwid}")

打印出来的信息类似这样:

端口: COM3 描述: USB-SERIAL CH340 (COM3) 硬件ID: USB VID:PID=1A86:7523 端口: COM5 描述: USB Serial Port (COM5) 硬件ID: USB VID:PID=10C4:EA60

这里可以看到CH340是常见的国内USB转串口芯片,CP210x是SiLabs的,各自都有特定的VID/PID。如果你的设备固定用某种芯片,可以按VID/PID去匹配,比靠串口号靠谱得多。我自己写调试工具时,经常会加一个参数--auto,自动在这个列表里找到目标设备,不需要每次手动指定端口,效率高很多。

3.2 与STM32设备通信的完整流程

一个很典型的场景是:STM32通过UART向上位机发送传感器数据,Python端通过pyserial接收并处理。假设MCU端每200毫秒发送一次数据帧,格式是:

帧头: 0xAA 0x55 数据: 温度(1字节) 湿度(1字节) 帧尾: 0x0D 0x0A

Python端的代码可以这样写:

import serial import time ser = serial.Serial('COM3', 115200, timeout=0.5) def read_frame(ser, timeout=2): start_time = time.time() buf = b'' while time.time() - start_time < timeout: if ser.in_waiting: buf += ser.read(ser.in_waiting) # 搜索帧头 if len(buf) >= 6: idx = buf.find(b'\xaa\x55') if idx >= 0 and idx + 6 <= len(buf): frame = buf[idx:idx+6] if frame[5] == 0x0A: # 简单校验帧尾 return frame buf = buf[idx+2:] # 帧不完整,跳过帧头继续找 else: # 没找到帧头,丢弃前面的干扰数据 buf = buf[-5:] if len(buf) > 5 else buf else: time.sleep(0.01) return None while True: frame = read_frame(ser) if frame: temp = frame[2] hum = frame[3] print(f"温度: {temp}°C, 湿度: {hum}%") else: print("读取超时,无有效帧") time.sleep(0.1)

这段代码里做了几件事:循环读取in_waiting里的所有数据、用find去搜索帧头、校验帧尾、处理不完整帧的情况。这样即使设备在上电时产生了一些异常字节,或者上位机启动晚了导致丢帧,程序也能自动找到下一个有效帧头重新同步。实际调通这套机制之后,基本就告别了串口调试助手的“串流”模式。

3.3 工控上位机里的“查询-应答”模式

另一种高频场景是工业仪表或者传感器模块,它们通常不是主动上报数据,而是需要上位机发指令去查询。这种“请求-响应”模式最关键的地方在于时序控制。设备收到指令后需要一定时间处理,一般是毫秒到十几毫秒不等,你必须在发完请求后等一个适当的时间再读,读的时候还要按照协议帧格式去解析。

import serial import time ser = serial.Serial('/dev/ttyUSB0', 9600, timeout=1) def send_query(cmd): ser.reset_input_buffer() # 清空之前的残留数据 ser.write(cmd) ser.flush() # 确保数据发出 time.sleep(0.2) # 等待设备处理并回包 return ser.read(ser.in_waiting) # 读取MODBUS温湿度传感器的寄存器 response = send_query(bytes.fromhex('01 03 00 00 00 02 C4 0B')) print(response.hex())

注意我在发送前调用了reset_input_buffer()清除缓冲区残留数据。这是一个非常实用的小技巧,可以避免把上一次通信的脏数据混入这一次的结果里。sleep的时间要根据实际设备响应时间调整,有些设备响应快,慢的也有,你可以用示波器或者直接用这个脚本测试出最合适的等待时间。我用这个方法在树莓派上对接过十几款不同的传感器模块,目前没有出现过因为时序问题导致的通信异常。

3.4 非阻塞读:用多线程和队列接收

上面的示例都是单线程循环读取。如果你的程序还有别的任务要处理,比如同时运行网络服务、刷新UI界面,就得考虑非阻塞的接收方式。基本思路是起一个专门读串口的线程,把读到的数据放到queue.Queue里,主线程或其他线程再从队列里取数据干活。

import threading import queue import serial data_queue = queue.Queue() def reader(ser): while True: if ser.in_waiting: chunk = ser.read(ser.in_waiting) data_queue.put(chunk) else: time.sleep(0.01) ser = serial.Serial('COM3', 115200, timeout=0.1) t = threading.Thread(target=reader, args=(ser,), daemon=True) t.start() # 主线程做别的事 while True: try: data = data_queue.get(timeout=0.2) print('收到:', data.hex()) except queue.Empty: pass

这里把读串口的任务单独放在一个线程里,顺手把粘包、拆包的逻辑也放在这个线程里处理。主线程的业务逻辑完全不用关心底层接收细节,拿到数据直接用就行。如果你的程序用到了asyncio,也可以用pyserial的asyncio支持,但那是进阶玩法,新手阶段先用多线程理解透串口语义就足够了,后面真需要高并发IoT采集的时候再深入也不迟。

4. 工程化细节与经验

4.1 常见异常和排查思路

串口程序跑不起来或行为诡异时,最常见的问题其实就那几种。我直接整理一个速查表,你按图索骥就行:

现象可能原因解决思路
打开串口报PermissionError端口被其他程序占用关闭调试工具、结束占用进程,或拔插USB转串口模块
收不到任何数据波特率不一致、TX/RX接反、硬件没供电用串口调试助手先验证链路通不通
收到乱码波特率不一致、校验位/停止位不匹配核对设备手册,尤其注意要和设备端完全一致
数据收发不完整协议没有帧边界处理、读取时序不对定义帧头/帧尾/长度字段;给足等待时间
读超时频繁设备响应慢、超时设太短增大timeout;排查设备端是否真的在回数据
write不生效文件流缓冲write后加flush()

4.2 换行、编码和十六进制的弯弯绕绕

pyserial的readline依赖\n来判定一行结束。如果你设备端发的是\r\n,默认情况下readline也能处理,因为它内部会以\r\n和\n两条规则都做识别。但如果你想要的是一行里包含\r\n,还是要留意一下这个细节。

编码方面,常见设备文本输出有两种:ASCII和GBK(少数是GB2312或者UTF-8)。你可以用data.decode('ascii', errors='ignore')或data.decode('gbk')来转成字符串。如果转出来还有乱码,先不要改解码方式,先确认波特率一致,再用十六进制打印看原始数据是不是就已经错的了:data.hex()会把你从串口读到的字节以十六进制字符串形式展示出来,对照设备手册里的协议看,一般能快速定位问题。

4.3 避免抖动数据干扰

设备刚上电时,MCU或者传感器模块会有一段不稳定的执行过程,此时串口线上可能有些随机字节冒出来。如果你上位机没有做好帧头搜索和同步,这些垃圾数据会把你的协议解析搅得天翻地覆。我在代码里用buf.find(b'\xaa\x55')搜索帧头,并且在没找到的时候把buf裁剪到只保留最后几个字节,就是为了确保不会因为一两个垃圾字节就丢掉一整帧数据。这种“帧同步”机制在真实硬件环境里几乎是必须的,我调过的所有项目里,依赖精确时序读取而不做帧同步的方案基本都翻过车。

4.4 需要关注的坑:物理层和电气层

最后聊一个容易被新手忽略的点:串口通信有时慢、不稳定,问题不在代码,而在物理串口。USB转TTL模块的CH340芯片驱动在刚插上时可能没装好,导致设备无法识别,设备管理器里出现黄叹号,这种时候代码再改也没用;RS232的收发芯片老化可能让信号电平越来越弱,长距离通信时尤其明显,我见过、真实踩过:工业现场三四十米长的屏蔽双绞线串扰严重,波形畸变导致协议解析大面积失败。这些都属于物理层问题,遇到串口异常时先检查一下线路连接和驱动状态,再回头看代码。

5. 基础阶段的一个完整示例

5.1 一个控制LED的完整上位机脚本

讲了这么多,直接放一个能跑的最小示例:通过串口发送命令控制一块Arduino板载LED的亮灭,同时LED状态会反馈回上位机。Arduino端代码略,Python这边很简单:

import serial import time ser = serial.Serial('COM7', 9600, timeout=0.5) def led_on(): ser.write(b'LED_ON\r\n') reply = ser.readline() print(reply.decode('ascii', errors='ignore').strip()) def led_off(): ser.write(b'LED_OFF\r\n') reply = ser.readline() print(reply.decode('ascii', errors='ignore').strip()) try: led_on() time.sleep(1) led_off() finally: ser.close()

这个例子把前面讲到的内容都用上了:创建串口对象、write发送文本命令、readline读取设备回显、finally保证串口释放。跑通这个例子以后,你就有了完整的串口通信闭环,后面往协议解析上扩展都会顺手很多。

5.2 实战中常用的辅助工具

  • 串口调试助手(Windows下比较多,如SSCOM之类):建议用来做链路验证,确认硬件连接和设备回包是否符合预期。
  • hexdump内建工具(Linux):xxd /dev/ttyUSB0可以直接把串口数据以十六进制形式打在终端上,排查问题非常趁手。
  • 虚拟串口工具(如com0com、Windows的Virtual Serial Port Driver):没有实体设备时可以用它创建一对虚拟串口来测试pyserial代码。我自己写通用工具时就会先创建一对虚拟串口,一端模拟设备端脚本,一端跑上位机逻辑,完全不用接硬件也能把协议逻辑调通。
  • 逻辑分析仪:如果问题定位到硬件层,逻辑分析仪可以抓取TX/RX的电平时序,一眼看出波特率、帧格式和实际波形的关系。

写在最后的经验

pyserial本身并不难,难的是串口通信里那些看不见的细节:时序、帧同步、缓冲区、物理层干扰。初学阶段容易犯的毛病是发现代码逻辑没问题,就不停地在程序库里找原因,结果查了半天其实是线没插对或者驱动没装好。根据我个人经验,遇到串口数据不对,先回到串口调试助手上验证一遍链路,链路通了你再考虑代码层面的问题,这样排查速度能快很多。还有一个建议是尽量在项目里做一个单独的serial_manager类,把打开、关闭、发送、接收、协议解析都封装起来,别在上层业务代码里到处裸调serial库,不然协议一旦要改,全项目都得让你牵连着改一遍,那是真痛苦。

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

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

立即咨询