ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python串口通信实战:pyserial核心用法与高频坑位解析

Python串口通信实战:pyserial核心用法与高频坑位解析 最近被好几个朋友问到pyserial有做嵌入式调试的有捣鼓单片机上位机的还有刚入行想搞工业自动化的。问题都很集中这个东西到底怎么用为什么我读写老出错串口调试助手上没问题换成Python就乱码这些问题我基本都踩过一遍所以直接整理一篇基础篇出来。这篇东西会从串口通信最底层的概念讲起一直到pyserial读写、超时处理、设备枚举、实机调试最后把高频坑位拉个清单。目标就一个你照着走完一遍能在半小时内跑通和单片机的通信而不是卡在环境配置和奇奇怪怪的读写异常上。这篇内容适合刚接触Python串口编程的开发者也适合已经从串口调试助手转到代码控制的硬件爱好者。1. 为什么串口通信选pyserial1.1 串口这玩意到底在干什么串口通信本质上是两个设备之间按位传输数据的异步通信方式。这个“异步”很关键意思是收发双方不需要共享时钟信号而是靠约定的波特率来同步数据的采样节奏。一台PC和一块STM32开发板之间通信最常见的物理载体就是USB转TTL模块电脑那端识别成一个COM口Linux下是ttyUSB0或ttyACM0MCU那端则是UART引脚。在Python里做串口通信目前最主流的选择就是pyserial它把底层的系统API封装成了统一且简洁的接口同一份代码在Windows、Linux、macOS上基本不需要改动。你要知道如果不用pyserial直接调系统APIWindows要操作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( portCOM3, # Windows串口号Linux下一般是/dev/ttyUSB0 baudrate115200, # 波特率要和设备端保持一致 bytesize8, # 数据位常用8 parityN, # 校验位N是无校验E是偶校验 stopbits1, # 停止位常用1 timeout0.5 # 读超时单位秒 )这三个参数bytesize、parity、stopbits组合起来叫“帧格式”8-N-1是默认配置意思是8位数据位、无校验、1位停止位。绝大多数场景下用8-N-1就行如果设备手册特别要求8-E-1或者7-O-1再改这两个参数。波特率是通信双方约定的数据时钟频率单位为bps比特每秒。你设备端设115200Python这边就得写115200不一致就是一堆乱码。为什么这么说因为波特率决定了每一位数据的时间宽度比如115200bps下每一位约8.68微秒接收端就按这个时间宽度去采电平你设错了宽度采到的电平序列自然对不上。timeout这个参数需要单独拎出来讲。它只影响读操作单位秒可以接收浮点数表示毫秒级比如timeout0.05就是50毫秒。设置超时的核心意义是让read不会永久阻塞。如果不设timeoutread在收不到数据时会一直卡住在多线程或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(bAT\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, timeout0.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:PID1A86:7523 端口: COM5 描述: USB Serial Port (COM5) 硬件ID: USB VID:PID10C4:EA60这里可以看到CH340是常见的国内USB转串口芯片CP210x是SiLabs的各自都有特定的VID/PID。如果你的设备固定用某种芯片可以按VID/PID去匹配比靠串口号靠谱得多。我自己写调试工具时经常会加一个参数--auto自动在这个列表里找到目标设备不需要每次手动指定端口效率高很多。3.2 与STM32设备通信的完整流程一个很典型的场景是STM32通过UART向上位机发送传感器数据Python端通过pyserial接收并处理。假设MCU端每200毫秒发送一次数据帧格式是帧头: 0xAA 0x55 数据: 温度(1字节) 湿度(1字节) 帧尾: 0x0D 0x0APython端的代码可以这样写import serial import time ser serial.Serial(COM3, 115200, timeout0.5) def read_frame(ser, timeout2): 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:idx6] if frame[5] 0x0A: # 简单校验帧尾 return frame buf buf[idx2:] # 帧不完整跳过帧头继续找 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, timeout1) 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, timeout0.1) t threading.Thread(targetreader, args(ser,), daemonTrue) t.start() # 主线程做别的事 while True: try: data data_queue.get(timeout0.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, errorsignore)或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, timeout0.5) def led_on(): ser.write(bLED_ON\r\n) reply ser.readline() print(reply.decode(ascii, errorsignore).strip()) def led_off(): ser.write(bLED_OFF\r\n) reply ser.readline() print(reply.decode(ascii, errorsignore).strip()) try: led_on() time.sleep(1) led_off() finally: ser.close()这个例子把前面讲到的内容都用上了创建串口对象、write发送文本命令、readline读取设备回显、finally保证串口释放。跑通这个例子以后你就有了完整的串口通信闭环后面往协议解析上扩展都会顺手很多。5.2 实战中常用的辅助工具串口调试助手Windows下比较多如SSCOM之类建议用来做链路验证确认硬件连接和设备回包是否符合预期。hexdump内建工具Linuxxxd /dev/ttyUSB0可以直接把串口数据以十六进制形式打在终端上排查问题非常趁手。虚拟串口工具如com0com、Windows的Virtual Serial Port Driver没有实体设备时可以用它创建一对虚拟串口来测试pyserial代码。我自己写通用工具时就会先创建一对虚拟串口一端模拟设备端脚本一端跑上位机逻辑完全不用接硬件也能把协议逻辑调通。逻辑分析仪如果问题定位到硬件层逻辑分析仪可以抓取TX/RX的电平时序一眼看出波特率、帧格式和实际波形的关系。写在最后的经验pyserial本身并不难难的是串口通信里那些看不见的细节时序、帧同步、缓冲区、物理层干扰。初学阶段容易犯的毛病是发现代码逻辑没问题就不停地在程序库里找原因结果查了半天其实是线没插对或者驱动没装好。根据我个人经验遇到串口数据不对先回到串口调试助手上验证一遍链路链路通了你再考虑代码层面的问题这样排查速度能快很多。还有一个建议是尽量在项目里做一个单独的serial_manager类把打开、关闭、发送、接收、协议解析都封装起来别在上层业务代码里到处裸调serial库不然协议一旦要改全项目都得让你牵连着改一遍那是真痛苦。
返回列表