4. 同步与异步通信模式:阻塞式读写、非阻塞式读写(timeout机制)、线程安全读写、事件驱动(serial.threaded)

串口通信,说白了就是两个设备之间通过一根线(或几根线)来回倒腾数据。但怎么倒腾,这里面的门道可不少。你想想看,如果CPU一直死等着数据过来,那其他活儿还干不干了?反过来,如果CPU把数据扔出去就不管了,那数据丢了怎么办?

这一章,我们就来聊聊这些通信模式。我个人习惯把同步/异步、阻塞/非阻塞这些概念放在一起讲,因为它们在实际项目中是纠缠在一起的。搞懂了这些,你写出来的串口驱动才算是真正能上生产线的。

4.1 阻塞式读写:简单粗暴,但得悠着点

阻塞式读写,就是最直白的方式。你调用read(),程序就停在那里,直到读到足够的数据或者文件关闭。同样,write()会等到数据全部写入缓冲区才返回。

核心特点:调用线程在读写操作完成前被挂起,不占用CPU。

import serial

ser = serial.Serial('/dev/ttyUSB0', 115200, timeout=None)  # timeout=None 表示阻塞模式
data = ser.read(10)  # 这里会一直等,直到读到10个字节
print(f"读到数据: {data.hex()}")

我在项目中遇到过一个问题:用阻塞模式读一个传感器,结果传感器偶尔会掉线,程序就永远卡在read()那里了。后来加了看门狗才解决。所以,阻塞模式虽然简单,但用在关键路径上要小心。

避坑指南:我曾经在一个工业项目中,因为用了阻塞模式读串口,结果设备掉线后整个控制线程卡死,导致产线停了5分钟。从那以后,我只要用阻塞模式,一定会配合超时机制或者独立线程。

4.2 非阻塞式读写与timeout机制:给程序一个“耐心值”

非阻塞模式,说白了就是给读写操作加一个“耐心值”。你告诉串口:“我给你0.5秒,有数据就给我,没有你就回来告诉我。”这样程序就不会死等了。

在PySerial中,设置timeout参数就能实现非阻塞效果:

import serial

# timeout=0.5 表示最多等0.5秒
ser = serial.Serial('/dev/ttyUSB0', 115200, timeout=0.5)

# 尝试读10个字节,但最多等0.5秒
data = ser.read(10)

if len(data) == 0:
    print("超时了,没读到数据")
elif len(data) < 10:
    print(f"只读到了{len(data)}个字节,可能数据还没到齐")
else:
    print(f"完整读到10个字节: {data.hex()}")

这里有个细节要注意:timeout的单位是秒,可以是浮点数。设置成0就是立即返回,不管有没有数据。我个人习惯设成0.1到1.0之间,具体看你的应用场景。

timeout值 行为 典型场景
None 阻塞模式,一直等到数据到齐 数据确定性高的场景
0 非阻塞模式,立即返回 轮询查询、UI响应
0.1 ~ 1.0 超时模式,等指定时间后返回 工业通信、传感器读取

经验之谈:我建议你在调试阶段先用timeout=1.0,等通信稳定后再根据实际响应时间调整。太短容易误判超时,太长又影响响应速度。

4.3 线程安全读写:别让数据打架

多线程环境下,串口读写会出问题。你想想看,如果两个线程同时往串口写数据,那数据就混在一起了。同样,一个线程在读,另一个线程在写,也可能导致数据错乱。

解决这个问题,我一般用两种方式:

  1. 加锁(Lock):用Python的threading.Lock保护读写操作。
  2. 专用读写线程:一个线程专门读,一个线程专门写,通过队列传递数据。

先看加锁的方式:

import serial
import threading

class SafeSerial:
    def __init__(self, port, baudrate):
        self.ser = serial.Serial(port, baudrate, timeout=0.5)
        self._lock = threading.Lock()
    
    def read(self, size):
        with self._lock:
            return self.ser.read(size)
    
    def write(self, data):
        with self._lock:
            return self.ser.write(data)
    
    def close(self):
        with self._lock:
            self.ser.close()

这种方式简单直接,但有个问题:如果读操作阻塞了,写操作也得等着。所以,我更喜欢第二种方式——用队列解耦:

import serial
import threading
import queue

class SerialWorker:
    def __init__(self, port, baudrate):
        self.ser = serial.Serial(port, baudrate, timeout=0.1)
        self.tx_queue = queue.Queue()
        self.rx_queue = queue.Queue()
        self._running = True
        
        self._tx_thread = threading.Thread(target=self._tx_loop, daemon=True)
        self._rx_thread = threading.Thread(target=self._rx_loop, daemon=True)
        self._tx_thread.start()
        self._rx_thread.start()
    
    def _tx_loop(self):
        while self._running:
            try:
                data = self.tx_queue.get(timeout=0.1)
                self.ser.write(data)
            except queue.Empty:
                continue
    
    def _rx_loop(self):
        while self._running:
            data = self.ser.read(1024)
            if data:
                self.rx_queue.put(data)
    
    def send(self, data):
        self.tx_queue.put(data)
    
    def receive(self, timeout=0.5):
        try:
            return self.rx_queue.get(timeout=timeout)
        except queue.Empty:
            return None
    
    def close(self):
        self._running = False
        self._tx_thread.join(timeout=1)
        self._rx_thread.join(timeout=1)
        self.ser.close()

关键点:用队列解耦后,读写操作互不干扰。发送方只管往队列里放数据,接收方只管从队列里取数据。串口操作全部在专用线程中完成,天然线程安全。

4.4 事件驱动:serial.threaded 的妙用

PySerial从3.0版本开始,提供了一个叫serial.threaded的模块。这东西说白了就是一个事件驱动的框架,帮你把串口读写、协议解析、事件回调都封装好了。

我刚开始用的时候也觉得有点绕,但用顺手之后,发现它特别适合做工业通信协议的处理。比如Modbus、自定义帧协议这些。

来看一个例子:

import serial
import serial.threaded

class MyProtocol(serial.threaded.LineReader):
    TERMINATOR = b'\n'
    
    def connection_made(self, transport):
        """连接建立时调用"""
        super().connection_made(transport)
        print("串口已打开,开始通信")
    
    def handle_line(self, line):
        """收到一行数据时调用"""
        print(f"收到数据: {line.decode('utf-8', errors='ignore')}")
        # 这里可以做协议解析
        if line.startswith(b'AT'):
            self.transport.write(b'OK\n')
    
    def connection_lost(self, exc):
        """连接断开时调用"""
        if exc:
            print(f"连接异常断开: {exc}")
        else:
            print("连接正常关闭")

# 使用
ser = serial.Serial('/dev/ttyUSB0', 115200, timeout=0.1)
with serial.threaded.ReaderThread(ser, MyProtocol) as protocol:
    protocol.write(b'AT\r\n')
    time.sleep(1)

这个框架的核心思想是:你只需要实现几个回调方法,框架会自动处理线程、读写、数据分割这些事情。我个人觉得,它特别适合处理那些有固定帧格式的协议。

使用建议:如果你的项目需要处理复杂的通信协议,比如自定义帧头帧尾、校验、超时重传等,serial.threaded能帮你省掉很多线程管理的代码。但如果你只是简单读写数据,用前面说的加锁方式就够了。

4.5 知识体系总览

下面这张图,把本章讲到的几种通信模式的关系画出来了。你可以看到,从最简单的阻塞模式,到最复杂的事件驱动,每种模式都有它的适用场景。

串口通信模式知识体系 串口通信模式知识体系 串口通信模式 同步模式 异步模式 阻塞式读写 非阻塞式读写(timeout) 线程安全读写 事件驱动(serial.threaded) 实现方式 直接read/write · 加锁 · 队列 实现方式 ReaderThread · 回调 · 协议类 选择原则:简单场景用同步,复杂协议用异步 多线程环境必须考虑线程安全

4.6 如何选择?我的经验总结

说了这么多,到底该用哪种模式?我根据自己的项目经验,给你几个判断标准:

  • 简单数据采集:用阻塞模式+timeout,代码简单,容易调试。
  • 多设备通信:用非阻塞模式+轮询,避免一个设备卡死影响其他设备。
  • 多线程环境:必须用线程安全方式,加锁或队列二选一。
  • 复杂协议处理:用serial.threaded,省心省力。

最后提醒一句:不管用哪种模式,一定要做好异常处理。串口通信这东西,线松了、设备重启了、干扰来了,什么情况都可能发生。我见过太多因为没做异常处理,导致程序崩溃或者死锁的案例了。