简介:本资源是一份面向Python初学者与嵌入式/物联网入门开发者的串口通信实践案例,聚焦计算机与传感器、单片机等外部设备的数据交互场景,解决串口参数配置、数据收发、异常处理等典型实操问题。压缩包为1KB的ZIP文件,共含2个Python源码文件(.py),分别实现串口发送端(serialA.py)与接收端(serialB.py)功能,代码结构清晰、注释完整,覆盖pySerial库导入、串口初始化、波特率与帧格式设置、readline()/write()调用、超时控制及close()资源释放等核心环节。目前已有831人学习下载,适合边学边练:读者可直接运行双文件模拟串口通信闭环,快速掌握阻塞式读写逻辑、字节流处理方式,并复用代码框架适配真实硬件调试。
1. 为什么“Python串口通信案例”不是抄几行代码就能跑通的玄学?
你手边有一块STM32开发板,刚烧好固件,串口打印着[OK] sensor: temp=23.5C, hum=48%;你打开PyCharm,照着某篇博客贴了5行serial.Serial()代码,却卡在SerialException: could not open port 'COM3'——不是端口号错,是驱动没装?还是Python进程占着口?抑或设备刚插上系统还没完成枚举?
这不是个“调库即用”的小功能,而是一条横跨操作系统内核、USB转串芯片固件、Python C扩展层、缓冲区时序控制的完整链路。它解决的是嵌入式设备与PC间低延迟、高确定性数据交换这个刚需:工业PLC状态上报、传感器实时采集、单片机OTA升级指令下发、示波器原始波形导出……所有这些场景里,“能发能收”只是起点,“不丢包、不错帧、不阻塞、可复位”才是交付线。适合两类人:一是刚从Arduino跳到真实产线的硬件工程师,需要把调试逻辑搬进Python脚本;二是做边缘AI部署的算法同学,得让YOLO推理结果实时触发串口继电器动作。本文不讲pyserial文档里已有的API列表,只拆解从设备识别失败到稳定收发万条数据不翻车的全链路实操路径——包括Windows/Linux/macOS三端差异、波特率抖动的真实影响、以及那个被90%教程忽略的inter_byte_timeout参数怎么救你的命。
2. 串口通信的本质:不是“连上就行”,而是“时序契约”的落地
2.1 为什么必须理解RS-232/USB-UART的物理分层?
很多开发者卡在第一步:serial.tools.list_ports.comports()列不出设备。这不是Python的问题,而是你没看清信号链路的物理本质。
现代PC没有原生RS-232接口,所谓“串口”实际是USB转UART桥接芯片(如CH340、CP2102、FT232)的虚拟化产物。当设备插入USB口,系统要完成三件事:
- USB协议栈识别VID/PID,加载对应内核驱动(Windows需手动装.inf,Linux通常自带,macOS需确认
/dev/cu.usbserial-*是否存在); - 驱动创建虚拟串口设备节点(Windows为
COMx,Linux为/dev/ttyUSBx,macOS为/dev/cu.usbserial-xxxx); pyserial通过操作系统提供的串口API(Windows用CreateFile+SetCommState,Linux/macOS用open()+ioctl())获取句柄。
提示:若
list_ports.comports()为空,先执行底层验证:Windows用设备管理器看“端口(COM和LPT)”下是否有黄色感叹号;Linux运行dmesg | grep -i "usb\|ch340\|cp210";macOS执行ls /dev/cu.*。未识别到设备节点,Python层再怎么写都是空中楼阁。
2.2pyserial不是黑匣子:它的核心参数如何映射到硬件行为?
pyserial的Serial()构造函数有12个参数,但真正决定通信成败的只有5个。其他参数要么是历史兼容项,要么在特定场景才生效。我们聚焦最关键的:
| 参数名 | 典型值 | 硬件意义 | 不设的后果 |
|---|---|---|---|
port | 'COM3'或'/dev/ttyUSB0' | 操作系统分配的设备节点路径 | SerialException: Port is not open |
baudrate | 115200 | UART时钟分频系数,决定比特传输速率 | 波特率不匹配 → 接收乱码(如ÿÿÿ) |
bytesize | serial.EIGHTBITS | 数据位长度(5~8),对应UART寄存器UCSZn | 发送方8位/接收方7位 → 帧错误 |
parity | serial.PARITY_NONE | 校验位(None/Odd/Even/Mark/Space),影响起始/停止位计数 | 校验不一致 →serial.SerialException: device reports readiness to read but returned no data |
stopbits | serial.STOPBITS_ONE | 停止位数量(1/1.5/2),决定帧尾空闲时间 | 停止位不足 → 接收方误判帧边界 |
import serial # ✅ 最小可靠初始化(显式声明所有关键参数) ser = serial.Serial( port='COM3', # 必须准确,Windows注意COM编号可能动态变化 baudrate=115200, # 与设备固件配置严格一致 bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=1, # 读超时:1秒内无数据则返回空bytes write_timeout=1 # 写超时:1秒内无法写入则抛异常 )这段代码背后,pyserial会调用Windows APISetCommState()设置DCB结构体,其中BaudRate、ByteSize等字段直接写入UART控制器寄存器。若timeout=0,read()将变成非阻塞轮询,CPU占用飙升;若timeout=None,则永久阻塞——这在GUI程序中等于冻结界面。
2.3 为什么inter_byte_timeout是解决“粘包”的后悔药?
这是pyserial最被低估的参数。默认inter_byte_timeout=None,意味着read(n)会等待整整n个字节收齐才返回。但现实设备常因中断延迟、缓冲区溢出导致字节间隔不稳定。例如设备每100ms发一帧0x02 0x01 0x03(STX-DATA-ETX),但第二字节因MCU忙延迟了150ms,read(3)就会卡住直到超时。
# ✅ 启用字节间超时:收到首字节后,后续每个字节等待不超过0.1秒 ser = serial.Serial( port='COM3', baudrate=115200, inter_byte_timeout=0.1, # 关键!单位:秒 timeout=1 ) # 此时 read(3) 行为变为: # - 收到0x02后启动计时器 # - 0.1秒内收到0x01 → 重置计时器 # - 0.1秒内收到0x03 → 返回b'\x02\x01\x03' # - 若0.1秒内未收到下一字节 → 立即返回已收字节(如b'\x02')这个参数直接映射到Linux的termios.c_cc[VMIN]/VTIME设置,是应对嵌入式设备时序抖动的物理层兜底方案。没有它,你在实验室测100%成功,一上产线就丢帧。
3. 从“能连上”到“稳收发”:一个可复现的温湿度传感器通信案例
3.1 硬件与协议约定:以常见AT指令集传感器为例
我们以一款基于ESP32的温湿度模块为例(非真实型号,代称“SensorX”),其串口协议定义如下:
- 物理层:TTL电平,波特率115200,8N1
- 帧格式:
0xAA 0xBB <CMD> <LEN> <DATA...> <CHK>,共LEN+4字节 - 命令示例:
0xAA 0xBB 0x01 0x02 0x00 0x01→ 查询温度(CMD=0x01, LEN=2, DATA=0x0001) - 响应:设备返回
0xAA 0xBB 0x01 0x04 0x17 0x0A 0x00 0x00→ 温度23.0℃(0x170A = 5898 → 5898/256 = 23.0)
注意:该协议无固定帧头长度,0xAA 0xBB是同步字,但设备可能在上电瞬间发送垃圾数据,需软件过滤。
3.2 构建健壮接收循环:状态机比readline()更可靠
readline()依赖\n或\r\n,但二进制协议中换行符可能是有效数据。正确做法是实现有限状态机(FSM)解析:
import serial import time class SensorXReader: def __init__(self, port, baudrate=115200): self.ser = serial.Serial( port=port, baudrate=baudrate, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=0.1, # 读超时设短,避免长阻塞 inter_byte_timeout=0.05 # 字节间超时,应对MCU调度抖动 ) self.buffer = bytearray() # 接收缓冲区 self.state = 'SYNC' # FSM状态:SYNC/LEN/CMD/DATA/CHK def parse_frame(self, byte): """单字节状态机解析""" if self.state == 'SYNC': if byte == 0xAA: self.buffer = bytearray([0xAA]) self.state = 'SYNC2' else: self.buffer = bytearray() elif self.state == 'SYNC2': if byte == 0xBB: self.buffer.append(0xBB) self.state = 'CMD' else: self.state = 'SYNC' # 重置 elif self.state == 'CMD': self.buffer.append(byte) self.state = 'LEN' elif self.state == 'LEN': self.buffer.append(byte) frame_len = byte + 4 # LEN字段值 + 4字节头尾 if frame_len > 256: # 防止恶意数据撑爆内存 self.state = 'SYNC' return None self.expected_len = frame_len self.state = 'DATA' elif self.state == 'DATA': self.buffer.append(byte) if len(self.buffer) == self.expected_len - 1: # 已收满DATA,还差CHK self.state = 'CHK' elif self.state == 'CHK': self.buffer.append(byte) # 验证校验和(此处简化为异或校验) chk_calc = 0 for b in self.buffer[:-1]: chk_calc ^= b if chk_calc == byte: frame = bytes(self.buffer) self.buffer = bytearray() self.state = 'SYNC' return frame else: self.state = 'SYNC' # 校验失败,丢弃整帧 return None return None # 未完成一帧 def read_sensor_data(self): """主接收循环:持续读取并解析""" while True: try: # 一次最多读100字节,避免阻塞过久 data = self.ser.read(100) if not data: continue for byte in data: frame = self.parse_frame(byte) if frame: print(f"✅ 收到完整帧: {frame.hex()}") # 解析温度:frame[4:6]为温度值(大端) if len(frame) >= 6 and frame[2] == 0x01: # CMD=0x01 temp_raw = (frame[4] << 8) | frame[5] temp_c = temp_raw / 256.0 print(f"🌡️ 温度: {temp_c:.1f}°C") except serial.SerialException as e: print(f"❌ 串口异常: {e}") break except KeyboardInterrupt: break self.ser.close() # 使用示例 if __name__ == "__main__": reader = SensorXReader(port='COM3') # 先发送查询命令 query_cmd = bytes([0xAA, 0xBB, 0x01, 0x02, 0x00, 0x01]) reader.ser.write(query_cmd) print("📩 已发送查询命令") # 开始接收 reader.read_sensor_data()逻辑说明:此状态机不依赖
readline()或read(n),而是逐字节喂入解析器。inter_byte_timeout=0.05确保即使设备发送间隔波动,也不会卡死在某个状态。timeout=0.1让read(100)在无数据时快速返回,保持循环响应性。
3.3 发送与应答的时序控制:为什么time.sleep()不是解决方案?
新手常写:
ser.write(cmd) time.sleep(0.05) # 等待设备响应 response = ser.read(10)这是危险的!sleep()无法保证设备处理完成,且在不同负载下表现不一。正确做法是基于协议特征等待:
def send_and_wait_response(self, cmd, expect_cmd=None, max_wait=2.0): """发送命令并等待指定CMD的响应帧""" start_time = time.time() self.ser.write(cmd) while time.time() - start_time < max_wait: # 尝试解析当前缓冲区 while self.ser.in_waiting > 0: byte = self.ser.read(1)[0] frame = self.parse_frame(byte) if frame and len(frame) >= 3 and frame[2] == (expect_cmd or cmd[2]): return frame time.sleep(0.01) # 短暂让出CPU,避免空转 return None # 超时未收到 # 使用 response = reader.send_and_wait_response( cmd=bytes([0xAA, 0xBB, 0x01, 0x02, 0x00, 0x01]), expect_cmd=0x01, max_wait=1.0 )in_waiting属性返回接收缓冲区中未读字节数,是操作系统内核维护的精确计数,比sleep()可靠百倍。
4. Windows/Linux/macOS三端避坑指南:那些让你怀疑人生的平台差异
4.1 Windows:COM端口号的“幽灵漂移”问题
现象:设备插在COM3,拔插几次后变成COM4,脚本报错SerialException: could not open port 'COM3'。
原因:Windows为每个USB设备分配唯一实例ID,但同一芯片(如CH340)的不同物理设备可能被分配相同VID/PID,导致系统混淆。
解决:
- 硬件层:使用带唯一序列号的USB转串芯片(如FTDI FT232RL);
- 软件层:不硬编码
COM3,改用设备描述符匹配:
import serial.tools.list_ports def find_sensor_port(): """根据设备描述符查找端口,而非固定COM号""" ports = serial.tools.list_ports.comports() for port in ports: # CH340设备在Windows设备管理器中显示为"USB-SERIAL CH340" if "CH340" in port.description or "CP210" in port.description: return port.device raise RuntimeError("未找到SensorX设备") ser = serial.Serial(port=find_sensor_port(), baudrate=115200)4.2 Linux:权限黑洞与dmesg日志里的真相
现象:PermissionError: [Errno 13] Permission denied: '/dev/ttyUSB0'。
原因:Linux默认将串口设备归入dialout组,普通用户无权访问。
解决:
# 将当前用户加入dialout组(需重启终端或重新登录) sudo usermod -a -G dialout $USER # 验证 groups # 应包含dialout注意:不要用
sudo python script.py绕过权限——这会导致GUI程序(如PyQt)无法显示窗口。
4.3 macOS:cu.vstty.设备节点的生死抉择
现象:/dev/tty.usbserial-xxxx存在,但pyserial打开时报OSError: [Errno 16] Resource busy。
原因:macOS为每个USB串口创建两个节点:
/dev/tty.usbserial-xxxx:用于传统串口工具(如screen),不支持setserial控制;/dev/cu.usbserial-xxxx:用于调制解调器(Call-Up),支持完整串口控制。pyserial必须使用cu.前缀节点。
验证:
# 查看可用设备 ls /dev/cu.* # 正确打开方式 ser = serial.Serial(port='/dev/cu.usbserial-1410', baudrate=115200)4.4 所有平台通用陷阱:Python进程残留锁
现象:拔掉设备后,再次运行脚本报OSError: [Errno 16] Device or resource busy。
原因:Python进程崩溃未释放串口句柄,操作系统仍认为端口被占用。
解决:
- 强制关闭:Linux/macOS执行
lsof /dev/ttyUSB0找PID,kill -9 PID;Windows用Process Explorer搜索COM3; - 代码防护:在
Serial()前加端口占用检测:
import os def is_port_available(port): """检查端口是否可被打开""" try: s = serial.Serial(port, timeout=0.1) s.close() return True except (OSError, serial.SerialException): return False if not is_port_available('COM3'): print("⚠️ 端口被占用,请关闭其他串口工具") exit(1)4.5 缓冲区溢出:in_waiting的隐藏陷阱
现象:设备高速发送数据(如1Mbps),Python来不及读取,in_waiting显示值越来越大,最终丢帧。
原因:pyserial内部使用操作系统串口缓冲区(Windows约4KB,Linux默认64KB),但Python层read()调用有开销。
解决:
- 增大系统缓冲区:Linux执行
stty -F /dev/ttyUSB0 115200 min 0 time 0; - 高频读取:将
read(100)改为read(min(100, ser.in_waiting)),避免单次读取过多阻塞; - 多线程分离IO:用
threading.Thread专职读取,主线程处理解析。
5. 进阶技巧:用pyserial构建可诊断、可热插拔的工业级通信模块
5.1 实现热插拔检测:监听dmesg或/sys文件系统
Windows无标准热插拔事件API,Linux/macOS可通过监控/sys/class/tty/目录变化实现:
import glob import time from pathlib import Path class HotplugMonitor: def __init__(self, vendor_id="1a86"): # CH340的VID self.vendor_id = vendor_id self.last_devices = set(self._get_usb_serial_devices()) def _get_usb_serial_devices(self): """获取当前所有USB串口设备路径""" devices = [] for tty in glob.glob('/sys/class/tty/ttyUSB*'): try: # 检查是否为USB设备 device_path = Path(tty) / 'device' if device_path.exists(): vid_pid = (device_path / 'idVendor').read_text().strip() if vid_pid == self.vendor_id: devices.append(str(Path(tty).name)) except (IOError, OSError): pass return devices def wait_for_device(self, timeout=30): """等待设备插入,返回设备名如'ttyUSB0'""" start = time.time() while time.time() - start < timeout: current = set(self._get_usb_serial_devices()) new_devs = current - self.last_devices if new_devs: dev = new_devs.pop() self.last_devices = current return f"/dev/{dev}" time.sleep(0.5) return None # 使用 monitor = HotplugMonitor() print("🔌 请插入SensorX设备...") port = monitor.wait_for_device() if port: print(f"✅ 检测到设备: {port}") ser = serial.Serial(port=port, baudrate=115200) else: print("❌ 超时未检测到设备")5.2 通信质量自检:用get_settings()和get_statistics()量化链路健康度
pyserial3.5+ 版本提供get_settings()返回当前串口配置,但更关键的是统计信息:
def get_comm_stats(ser): """获取串口通信统计(需pyserial>=3.5)""" stats = { 'in_waiting': ser.in_waiting, 'out_waiting': ser.out_waiting, 'is_open': ser.is_open, 'baudrate': ser.baudrate, 'timeout': ser.timeout } # Linux/macOS可读取内核统计 if hasattr(ser, '_port_handle') and ser._port_handle: try: # Linux: 读取/sys/class/tty/.../device/statistics pass # 实际需root权限,生产环境慎用 except: pass return stats # 在主循环中定期打印 while running: stats = get_comm_stats(ser) if stats['in_waiting'] > 1000: # 接收缓冲区持续大于1KB print("⚠️ 接收积压严重,可能解析速度不足") time.sleep(1)5.3 错误恢复机制:自动重连与配置重载
工业场景要求设备断连后自动恢复,而非脚本退出:
import time class RobustSerial: def __init__(self, port, baudrate, retry_delay=2.0): self.port = port self.baudrate = baudrate self.retry_delay = retry_delay self.ser = None self.connect() def connect(self): """尝试连接,失败则重试""" while not self.ser: try: self.ser = serial.Serial( port=self.port, baudrate=self.baudrate, timeout=0.1, inter_byte_timeout=0.05 ) print(f"✅ 串口已连接: {self.port}") except serial.SerialException as e: print(f"❌ 连接失败: {e}, {self.retry_delay}s后重试...") time.sleep(self.retry_delay) def write(self, data): """带重连的写入""" try: self.ser.write(data) except serial.SerialException as e: print(f"❌ 写入失败: {e},尝试重连...") self.ser.close() self.ser = None self.connect() self.ser.write(data) # 重试 def close(self): if self.ser and self.ser.is_open: self.ser.close() # 使用 robust_ser = RobustSerial(port='COM3', baudrate=115200) robust_ser.write(b'\xAA\xBB\x01\x02\x00\x01')5.4 我的血泪经验:三个必须写进项目README的检查清单
- 波特率一致性检查:永远用示波器抓UART波形,测量实际周期。曾遇到某国产MCU在115200波特率下因晶振误差导致±3%偏差,Python端必须设
baudrate=111500才能对齐; - 电源噪声排查:USB供电不足时,CH340芯片在高波特率下会随机丢帧。给设备加装100μF电解电容,或改用带外接电源的USB集线器;
- Python版本陷阱:
pyserial3.4在Python 3.11+上有ResourceWarning,升级到3.5+可解决。用pip install pyserial --upgrade前,务必pip list | grep pyserial确认版本。
最后说一句:串口通信不是炫技的玩具,它是嵌入式世界的“呼吸管”。我见过太多项目因为一个timeout=0参数,在客户现场凌晨三点重启服务器。希望这篇笔记帮你绕过那些看不见的坑——毕竟,让设备安静地吐出数据,才是工程师最朴素的浪漫。希望帮到你。
本文还有配套的精品资源,点击获取