如果你最近在做硬件调试、写上位机、或者接了一个和STM32、Arduino、传感器模块相关的项目,大概率会搜到pyserial这个名字。简单说,pyserial是Python生态里最成熟的串口通信库,没有之一。它把操作系统底层的串口API封装成了一个统一的接口,让你用几行代码就能打开串口、收发数据,完全不用关心Windows、Linux还是macOS之间的差异。这篇内容我按“从零到能用”的思路来写,适合刚接触串口通信的Python开发者,也适合那些以前只用串口助手、现在想把收发过程自动化的小伙伴。
我会把pyserial的安装、核心API、典型读写流程、以及我在实际调试中踩过的坑都过一遍,并且给出一份可以直接抄作业的代码模板。这篇内容不是官方文档的翻译,而是结合真实调试经验整理的实操笔记,看完你应该能独立完成一个简单的串口收发程序。
1. 为什么要用pyserial做串口通信
在开始写代码之前,先搞清楚pyserial在整个串口通信里扮演什么角色,比你急着pip install有用得多。
1.1 pyserial到底解决了什么问题
串口通信本身是个老掉牙的协议,RS232、RS485、UART这些名词你可能都听过,但它们的底层细节对应用层开发者来说非常繁琐。你想想,如果直接调用操作系统API,Windows下是CreateFile、ReadFile、WriteFile那套Win32接口,Linux下是open、read、write配合termios结构体,macOS又是另一套行为差异。真要自己封装,光是处理不同平台的打开参数、超时行为、缓冲区机制,就能耗掉你一个礼拜。
pyserial把这一切都做了。它对外暴露的是统一的serial.Serial类,你只需要告诉它“串口号是多少、波特率多少”,剩下的事情库内部处理。实测下来,同一个Python脚本,在Windows上指定COM3,在Linux上指定/dev/ttyUSB0,代码逻辑几乎不用改,只是端口名字不同。对于做嵌入式联调、写测试脚本、做设备工装的人来说,这种跨平台能力省下的时间非常可观的。
pyserial能做什么呢?几个典型场景:
- 和单片机通信,发送指令控制LED、电机、舵机这类外设
- 读取传感器模块输出的数据,比如GPS模块、指纹模块、激光雷达
- 配合pyqt或tkinter写一个简易上位机界面
- 自动化产线上做设备测试,通过串口发指令并校验返回值
- 调试路由器、交换机等网络设备,通过console口进去敲命令
只要设备露出来的是一个串口,pyserial基本都能接管。我自己最常用的是写自动化测试脚本,以前要人工拿着串口助手一条条发AT指令,现在脚本一键跑完几百条用例,谁用谁知道。
1.2 和C/C++、C#、LabVIEW相比,Python这条路值不值
这个问题在嵌入式圈子经常被争论。搞硬件的写惯了C,觉得Python太“软”,实时性不行;搞上位机的用C#和WinForm,觉得生态成熟;LabVIEW粉丝觉得图形化才是王道。我的看法是:工具看场景。
如果你要给工业设备做一套长期稳定运行的上位机,还要求界面漂亮、部署方便,C#或Qt C++可能更合适。但如果你只是调试期用、写测试脚本、或者做算法验证,Python+pyserial就是最优解。理由很直接:Python写起来快,改起来更快,而且数据处理能力强。比如你通过串口收回来一堆IMU姿态数据,想顺手用matplotlib画个曲线,Python这边一行导入就行,用C#至少得多写几十行临时代码。
pyserial本身不承诺硬实时——这是Python的GIL和操作系统调度决定的,正好热词里有人提到“Python协程”“多进程”,注意这些和串口的硬实时不是一回事。但对绝大多数串口应用来说,数据量也就每秒几十到几百字节,Python的处理速度绰绰有余。真到了每秒几兆字节的吞吐场景,我会直接劝你换个技术栈,别为难pyserial。
2. 环境准备:装好pyserial只是第一步
很多新手在“安装”这一步就卡住了,而且多半不是pyserial本身的问题,是Python环境本身就乱。
2.1 先确认Python环境,再说安装
打开终端或命令提示符,输入:
python --version能正常显示版本号,说明Python已经装好。如果Windows下提示“Python was not found; run without arguments to install from the Microsoft Store”,说明你的Python没装好或者没加入PATH,这和pyserial无关,先去把Python正确安装配置好再回来继续。我记得文章开头列的那些热搜词里就有一大堆“python安装”“python环境配置”,可见这一关确实拦住了不少人。
建议直接用Python 3.8以上的版本,太老的版本虽然pyserial也支持,但没必要给自己添麻烦。另外强烈建议在虚拟环境里操作,避免把系统的Python环境搞乱:
python -m venv serial_env # Windows: serial_env\Scripts\activate # Linux/macOS: source serial_env/bin/activate当然,如果你只是临时跑个脚本,不建虚拟环境也能用,但万一你电脑上同时有多个Python项目,依赖互相打架的时候别怪我没提醒。
2.2 安装pyserial的几种方式和验证方法
pyserial的安装非常简单,PyPI上的包名就叫pyserial,用pip安装即可:
pip install pyserial国内网络如果下载慢,可以换用镜像源:
pip install pyserial -i https://pypi.tuna.tsinghua.edu.cn/simple安装完成后,验证一下是否能用:
python -c "import serial; print(serial.__version__)"能打印出版本号就说明安装成功。如果提示ModuleNotFoundError: No module named 'serial',通常是当前解释器和pip的版本对应不上,检查一下你是不是在同一个虚拟环境里执行的。
另外有个小细节,pyserial的操蛋之处在于它的导入名是serial,不是pyserial。很多人写成import pyserial结果报错,这是新手最常犯的错误。记住:安装用pyserial,导入用serial。
2.3 串口驱动与权限:不解决会一直报错
这一段是纯经验之谈,搜热词的时候看到“win32串口通信”“嵌入�器串口通信实验”这些词,就知道你们很多都在Windows上做。Windows下插上USB转串口模块(比如CH340、CP2102、FT232),一般会自动装好驱动,在设备管理器里能看到对应的COM口编号。如果你Device Manager里看不到新的COM口,大概率是驱动没装,去芯片厂商官网下载对应驱动装上就好。
Linux下情况不太一样。设备节点通常是/dev/ttyUSB0或/dev/ttyACM0,插上后用ls /dev/tty*可以查看。然而即使设备识别到了,普通用户打开串口经常报:
serial.serialutil.SerialException: could not open port /dev/ttyUSB0: [Errno 13] Permission denied: '/dev/ttyUSB0'这是因为当前用户不在dialout组,没有访问串口设备的权限。解决方法:
sudo usermod -a -G dialout $USER加完组后注销重新登录,权限问题就没了。这个坑我当年踩过一次,当时还以为是pyserial的bug,折腾了半天,最后发现就是系统权限。Linux下手边没有组的机器,也可以用sudo临时跑脚本,但正规做法还是加组,别用sudo跑Python,权限太大会带来别的问题。
3. pyserial基础API,一个类打天下
pyserial的核心就一个类:serial.Serial。别被它庞大的参数列表吓到,90%的场景你只需要关心几个关键参数。
3.1 Serial构造参数:先弄懂这些参数再动手
先看一个最基本的打开方式:
import serial ser = serial.Serial( port='COM3', # Windows下是COM3,Linux下是/dev/ttyUSB0 baudrate=115200, # 波特率,要和设备端一致 bytesize=8, # 数据位,通常8 parity='N', # 校验位,N无校验,E偶校验,O奇校验 stopbits=1, # 停止位,通常1 timeout=0.5 # 读超时,单位秒 )这些参数每一个都必须和你的设备端配置一致,否则收到的就是乱码,或者根本收不到数据。端口号、波特率大家肯定都懂,我重点说说容易理解偏差的几个。
bytesize:大多数设备用8位数据位。部分老设备可能用7位,看到7就别奇怪,是历史遗留。parity:最常用的是'N',如果通信偶发乱码、以及数据感觉对不齐,检查一下设备是不是开了奇偶校验,两边不一致就会丢字节或报错。stopbits:常见的是1,也有极少数用2,尤其在低速、长线传输时。timeout:这个参数极其重要,我单独说。
timeout是读取操作的超时时间,单位秒。它有三种取值,行为完全不同:
- timeout=None:阻塞式读取,一直等到要求的字节数到齐才返回。如果设备一直不回数据,你的程序会永远卡在read那里。
- timeout=0:非阻塞读取,有数据立即返回,没数据立即返回b'',不等待,适合轮询模式。
- timeout=0.5:有数据就返回,没数据最多等0.5秒。这个折中方案在实际里用得最多。
还有两个参数你可能在别的代码里见过,xonxoff和rtscts。xonxoff是软件流控,rtscts是硬件流控,大多数设备都不需要,保持默认False即可,千万不要乱开。我见过有人代码里开了rtscts结果数据收发完全失败,排查半天发现是这个参数在捣乱。
3.2 打开与关闭:不要小看资源管理
pyserial的Serial类有个特点:实例化的时候就尝试打开串口。所以你在上面那段代码执行后,串口其实已经打开了。当然,你也可以先用serial.Serial()创建对象,不指定port,等拿到端口名后再用ser.open()手动打开。
手动打开适合程序里动态扫描端口的情况,比如你不想硬编码COM口号,而是让用户选择,或者程序自动侦测设备插在哪个COM口上。
关闭串口用ser.close(),但是更推荐的做法是用上下文管理器。pyserial支持with语法,代码块执行完自动关闭串口,不用手动管:
with serial.Serial('COM3', 115200, timeout=0.5) as ser: ser.write(b'AT\r\n') response = ser.readline() print(response) # 到这里串口已经自动关闭了这个写法的好处是,即使程序中途抛异常,串口也能被正常释放。串口资源是很宝贵的,一个程序没释放串口,其他程序就开不了,设备管理器里会显示“该端口已被占用”。用with是养成好习惯的第一步。
3.3 读写API与编码细节
串口读写的最小单位是字节。write方法接受bytes类型,不接受str。所以你要发送字符串,必须手动编码:
ser.write(b'AT\r\n') # 直接写字节 ser.write('AT\r\n'.encode()) # 字符串转字节 ser.write('AT\r\n'.encode('ascii')) # 也可以指定编码读这边有几种方式:
data = ser.read(10) # 读取10个字节 data = ser.readline() # 读取一行,遇到换行符结束 data = ser.read_until(b'\r\n') # 读到你指定的终止符 data = ser.read_all() # 读取当前缓冲区所有数据 chunk = ser.read(ser.in_waiting) # 读走当前已收到的全部字节read和readline是最常用的。readline有一个坑:它虽然“按行”读,但依赖换行符来判定结束。很多串口设备返回的行结尾不是常见的\n,而是\r\n,你用readline()时,如果设备只发\r而不是\r\n,会一直等不到换行导致超时。后面我在问题排查部分再展开。
接收回来的字节同样是bytes类型,要转成字符串的话:
text = data.decode('utf-8')这里有个很容易炸的坑:设备返回的编码不一定是UTF-8,有很多老设备用的是GBK或ASCII。直接decode('utf-8')遇到无法解析的字节会抛UnicodeDecodeError。稳妥做法是用errors='ignore'或errors='replace'参数来容错:
text = data.decode('utf-8', errors='ignore')另外还有一个细节,数据不一定一次到位。串口数据是流式的,你第一次read(10)可能只读到3个字节,第二次才读到7个。不是因为pyserial有问题,而是底层缓冲区就是这样。所以做完整协议解析时,通常要自己拼包和分包,这里先记住这个知识点,后面实操部分我会给出处理思路。
4. 从零写一个可用的串口收发程序
光看API不够,直接上一份能跑的完整代码。这个例子模拟的是“发AT指令、等设备回应”的过程,也是我做模组调试时最常用的套路。
4.1 最小可用demo:轮询读取的写法
代码逻辑:打开串口,发送指令,轮询等待数据回包,超时则放弃。
import serial import time def send_at_command(ser, cmd, wait_time=1.0): # 清空接收缓冲区,避免读到上一次的残留数据 ser.reset_input_buffer() # 发送指令 ser.write(cmd.encode('ascii')) # 等待设备响应 deadline = time.time() + wait_time response = b'' while time.time() < deadline: # 读取当前所有可用数据 chunk = ser.read(ser.in_waiting) if chunk: response += chunk time.sleep(0.05) # 避免空转占用过高CPU return response.decode('utf-8', errors='ignore') if __name__ == '__main__': try: with serial.Serial('COM3', 115200, timeout=0.2) as ser: ret = send_at_command(ser, 'AT\r\n') print('设备返回:', repr(ret)) except serial.SerialException as e: print('串口打开失败:', e)这段代码里有几个细节我说一下。
send_at_command里先调用reset_input_buffer,是为了清掉上一次通信留下的旧数据。串口不像网络连接,没有“会话”概念,缓冲区里残留的字节会混入本次响应,导致解析错乱。每次发指令前清一次缓冲,是串口调试的基本习惯。
然后是while循环里的time.sleep(0.05)。有人可能觉得多此一举,直接无限循环读不好吗?不好。不加sleep的话,这个循环会以极快的速度狂刷read,CPU占用会飙升,而且会增加系统调用的开销。加个50毫秒的休眠,对0.5秒到1秒级别的响应来说完全够用,CPU占用还低。
ser.read(ser.in_waiting)这里的in_waiting属性返回当前接收缓冲区中的字节数。这么写的效果是:有多少读多少,不会阻塞等待。配合timeout=0.2,万一在sleep间隔里来了数据,read也能及时取走,不会丢数据。
4.2 阻塞式读取与readline的坑
轮询方式适合“发一条、收一条”的交互式通信。但有些设备是主动上报数据的,比如GPS模块每秒输出一帧NMEA语句。这种场景下用阻塞式读取更自然:
import serial ser = serial.Serial('COM3', 115200, timeout=1.0) try: while True: line = ser.readline() if line: print(line.decode('utf-8', errors='ignore').strip()) except KeyboardInterrupt: print('停止接收') finally: ser.close()这段代码的意思是:一直读,每读到一行就打印一行。readline的行为是阻塞等待,直到收够一个换行符才返回。注意我把timeout设成了1.0,意思是:如果1秒内一个字节都没收到,readline返回b'',这样主循环还能继续跑,不至于永久卡死。如果timeout=None,那readline会一直等,直到设备蹦出个换行符来。对主动上报型设备来说,timeout=None反而可能更合适,因为设备会稳定输出,不太会“断流”。
这里有个真实踩坑案例。我之前调一个工业传感器模块,设备文档说数据以\r\n结尾。用readline()去读,读出来的数据总是缺前面的部分,而且经常超时。排查半天后发现问题出在:设备实际发送的是\r,不是\r\n。而readline默认只认\n作为行结束符,看到\r不结束,继续傻等。后来我改用read_until(b'\r'),问题立刻解决。
所以,当你发现readline不按预期工作,先别急着怀疑pyserial,用串口助手看一下设备到底发的什么字节,再选择合适的终止符。
4.3 用线程做持续接收
再进阶一步。如果你的程序既要持续接收设备数据,又要响应用户输入、或者同时处理界面事件,就不能在主线程里死循环读串口了,否则界面会卡死。解决方案是开一个后台线程专门收数据,主线程干别的。
下面是一个简单的线程化接收模板:
import serial import threading import queue import time class SerialReader(threading.Thread): def __init__(self, ser, data_queue): super().__init__(daemon=True) self.ser = ser self.data_queue = data_queue self.running = True def run(self): while self.running: try: # 等待最多0.5秒,避免无限阻塞无法退出 data = self.ser.read(1024) if data: self.data_queue.put(data) except serial.SerialException: break def stop(self): self.running = False if __name__ == '__main__': ser = serial.Serial('COM3', 115200, timeout=0.5) q = queue.Queue() reader = SerialReader(ser, q) reader.start() try: while True: try: data = q.get(timeout=0.5) print('收到:', data.hex()) except queue.Empty: pass # 这里可以干别的事,比如处理界面事件 except KeyboardInterrupt: reader.stop() reader.join() ser.close()为什么要把读到的数据放到queue里?因为串口对象不是线程安全的,如果多个线程同时调用read或write,可能出现数据错乱。用队列做解耦,生产线程(串口读线程)只管往队列塞数据,消费线程(主线程)只管从队列取数据,两边不直接碰串口对象,避免了竞争问题。
发送操作也建议集中管理。如果你有多个地方都要往串口写数据,最好定义一个writer线程,或者给write操作加锁。否则两个线程同时write,字节会交错在一起,设备那边就懵了。
5. 真实调试中会踩的坑,我帮你提前排掉
最后这部分是精华。这些坑都是实际调设备时遇到过的,每一条都有人线上问过。我整理成速查表,你在排查时按图索骥就行。
5.1 端口打不开的几种原因
报错信息一般是serial.serialutil.SerialException: could not open port 'COM3'。逐项排查:
| 原因 | 现象 | 解决办法 |
|---|---|---|
| 端口号不对 | 报错提示PortNotFoundError | 去设备管理器/设备树确认实际端口号 |
| 串口被其他程序占用 | 报错PermissionError | 关闭串口助手、其他Python脚本、固件下载工具 |
| 驱动没装好 | 设备管理器找不到COM口 | 安装CH340/CP2102等驱动 |
| 权限不足 | Linux下Permission denied | 把用户加到dialout组,或查一下是不是用sudo跑的 |
| USB线/模块故障 | 设备管理器里设备带黄色感叹号 | 换线、换USB口、换模块测试 |
这里特别说一句:很多调试板子自带的串口芯片是CH340,这个芯片在某些便宜的USB Hub上供电不足会导致无法识别,遇到“设备偶尔出现、偶尔消失”的情况,优先想想供电问题。
另外,如果你用的是串口助手,它会占用串口,此时再运行Python脚本就打不开。开发时一定要记住:串口是独占的,同一时间只能有一个程序打开。我一般调代码的时候会把串口助手先关掉,改回用Python的日志打印来看数据,这个习惯能避免很多无谓的“串口打不开”问题。
5.2 数据乱码和不完整的处理思路
乱码首先检查波特率。设备用9600,你代码里用115200,收出来的就是一堆“砖块”。波特率对齐是一切通信的基础,其次是数据位、校验位、停止位,这四个参数任何一位不对,数据都不可能正确。
排除参数问题后,再考虑编码问题。设备输出的是GBK编码的汉字,你用utf-8去解码,肯定乱码。这一步需要你查设备的用户手册,或者用串口助手先看原始字节,才能判断正确的编码。
还有一种隐蔽情况:半包和粘包。串口数据流被操作系统切成一段一段的,一次read不一定能读到完整的一帧数据。比如设备发送的数据帧是“AA 55 01 02 03 04”,如果你在中间某个时刻去read,可能只读到“AA 55 01”,剩下的“02 03 04”还在路上。处理这种问题,不能在read之后马上按照“一帧数据”去解析,而是要维护一个接收缓冲区,不断追加新数据,然后尝试从缓冲区里取出完整的一帧。
协议解析的经典做法,是定义一个帧头、帧长度,然后通过状态机或者简单的循环来拆包:
buffer = b'' while True: chunk = ser.read(1024) if not chunk: continue buffer += chunk # 假设帧头是0xAA 0x55,帧长度在第3个字节 while len(buffer) >= 4: if buffer[0] == 0xAA and buffer[1] == 0x55: frame_len = buffer[2] if len(buffer) >= frame_len: frame = buffer[:frame_len] buffer = buffer[frame_len:] print('完整帧:', frame.hex()) else: # 数据还没齐,继续等 break else: # 没找到帧头,丢弃一个字节 buffer = buffer[1:]先别急着套你的协议,理解这个思路就行:串口数据是流式的,协议解析必须自己组帧。
5.3 硬件联调时的几个习惯
调串口和调网络程序心态不一样。串口没有TCP那样的重传机制,发出去的字节丢了就是丢了,设备不回也没有错误提示。所以开发流程上我有几个固定习惯:
第一个习惯:先确认链路通不通。写了半天代码发现设备没反应,先用串口助手手动发一条指令,看有没有回包。如果串口助手都没有回包,那就是设备、接线、参数的问题,别急着改代码。等串口助手里确认数据能通,再上pyserial调自己的代码。这一条能帮你把“硬件问题”和“软件问题”快速分开。
第二个习惯:把波特率、端口号、超时这些参数做成配置文件或者命令行参数,不要硬编码在代码里。设备换了调试口,或者波特率改了,改一行配置重启程序就行,不用重新编辑代码。我自己常用的做法是用argparse或者configparser,简单维护一个配置文件。
第三个习惯:用好hex视图。串口调试时,文本视图会隐藏很多细节,比如\u0000这种控制字符看起来就是个方框。用hex格式打印接收数据,才能准确判断设备到底发了什么字节,尤其调试二进制协议时必须上hex。
第四个习惯:日志记录。用Python的logging模块,把每次发送的指令和接收的原始数据记录下来,特别是跑自动化测试时,日志是定位问题的重要线索。pyserial本身没有日志功能,但你在自己的代码层加几行日志非常简单。
6. 结语:我的一点经验
做串口通信这些年,最大的感受是:pyserial真的很简单,复杂的是它背后的设备、协议和硬件。别把精力都花在研究Serial类的参数上,多花点时间搞清楚你的设备端发的是什么、收的是什么、协议怎么定义的,这些才是串口调试的核心能力。
我见过很多人在网上问“为什么readline收不到数据”,然后在屁股后面刷屏回复几十条。这种问题的答案往往就藏在一个细节里:设备回车的字节形式。用串口助手看一眼设备原生返回,胜过盲目改代码猜半天。
如果你现在刚开始接触pyserial,我的建议是:先把波特率、端口号确认好,然后用最简单的一段代码把收发跑通,再一点点加功能。串口调试本身就是个迭代的过程,代码写得再漂亮,设备连不通,一切都是白搭。先把车跑起来,再去考虑方向盘怎么打,这是最快的路径。