4、串口通信脚本:pySerial库详解、自定义串口协议解析器、多串口并发测试

串口通信,在嵌入式测试里可以说是最基础也最常用的手段了。我做了这么多年测试,跟串口打交道的次数,比跟家人吃饭的次数还多(笑)。今天咱们就聊聊怎么用 Python 的 pySerial 库,把串口测试玩出花来。

4.1 pySerial 库:你的串口瑞士军刀

pySerial 这个库,说白了就是 Python 操作串口的标准答案。它封装了底层操作系统的串口 API,让你不用关心 Windows 和 Linux 的差异。我个人习惯在项目一开始就把 pySerial 装好,因为后面调试基本离不开它。

安装方式:

pip install pyserial

嗯,这里要注意,别装错了。有个叫 serial 的库是旧的,pySerial 才是正主。我刚开始用的时候也踩过这个坑。

4.1.1 基本操作:打开、读写、关闭

先看一个最基础的例子:

import serial

# 打开串口
ser = serial.Serial(
    port='COM3',        # Windows 下用 COMx,Linux 下用 /dev/ttyUSB0
    baudrate=115200,    # 波特率,常见的有 9600、115200
    bytesize=8,         # 数据位,一般是 8
    parity='N',         # 校验位:N 无校验,E 偶校验,O 奇校验
    stopbits=1,         # 停止位,一般是 1
    timeout=1           # 超时时间,单位秒
)

# 发送数据
ser.write(b'AT\r\n')

# 读取数据
response = ser.read(100)  # 读取 100 字节
# 或者按行读取
line = ser.readline()     # 读到换行符为止

# 关闭串口
ser.close()

这里有个小细节:timeout 参数我建议一定要设。不设的话,read() 会一直阻塞,程序就卡死了。我在项目中遇到过测试脚本跑了一整夜,结果因为串口没数据,第二天早上发现脚本还卡在第一行——那叫一个尴尬。

4.1.2 常用方法与属性

方法/属性 说明 我的使用场景
ser.read(size) 读取指定字节数 读取固定长度的协议帧
ser.readline() 读取一行(直到换行符) 读取 AT 指令的响应
ser.in_waiting 缓冲区中等待读取的字节数 判断是否有数据可读
ser.flushInput() 清空输入缓冲区 测试前清空残留数据
ser.flushOutput() 清空输出缓冲区 确保数据已发送

小技巧:我经常用 ser.in_waiting 来判断有没有数据,避免无意义的读取。比如:

if ser.in_waiting > 0:
    data = ser.read(ser.in_waiting)

这样一次就能把缓冲区里的数据全读出来,效率高很多。

4.2 自定义串口协议解析器

实际项目中,很少有设备直接用纯文本通信。大多数都是自定义的二进制协议。你想想看,如果每次都要手动解析字节流,那不得累死?所以我们需要一个协议解析器。

4.2.1 协议帧结构设计

我见过最常用的协议帧结构是这样的:

| 帧头(2B) | 长度(1B) | 命令(1B) | 数据(NB) | 校验(1B) | 帧尾(1B) |
| 0xAA 0x55 |   N+3    |  0x01    |  ...     |  XOR     |  0x0D    |

为什么用 0xAA 0x55 做帧头?因为这两个字节的二进制是 10101010 和 01010101,交替的 0 和 1,不容易跟数据混淆。这是我个人的经验之谈。

4.2.2 解析器实现

来看一个完整的解析器代码:

class ProtocolParser:
    def __init__(self):
        self.buffer = bytearray()
        self.FRAME_HEAD = bytes([0xAA, 0x55])
        self.FRAME_TAIL = 0x0D
    
    def feed(self, data):
        """喂数据给解析器"""
        self.buffer.extend(data)
        frames = []
        
        while len(self.buffer) >= 5:  # 最小帧长度
            # 查找帧头
            head_idx = self.buffer.find(self.FRAME_HEAD)
            if head_idx == -1:
                # 没找到帧头,清空缓冲区
                self.buffer.clear()
                break
            
            # 丢弃帧头前的无效数据
            if head_idx > 0:
                self.buffer = self.buffer[head_idx:]
            
            # 检查长度是否足够
            if len(self.buffer) < 5:
                break
            
            frame_len = self.buffer[2]  # 长度字段
            
            # 检查完整帧是否到达
            if len(self.buffer) < frame_len + 4:
                break
            
            # 提取一帧
            frame = self.buffer[:frame_len + 4]
            
            # 校验
            if self._check_frame(frame):
                frames.append(self._parse_frame(frame))
            
            # 移除已处理的帧
            self.buffer = self.buffer[frame_len + 4:]
        
        return frames
    
    def _check_frame(self, frame):
        """校验帧:XOR 校验"""
        calc_xor = 0
        for b in frame[1:-2]:  # 从长度字段到数据末尾
            calc_xor ^= b
        return calc_xor == frame[-2]
    
    def _parse_frame(self, frame):
        """解析帧内容"""
        return {
            'cmd': frame[3],
            'data': frame[4:-2],
            'raw': frame
        }

避坑指南:我曾经在解析协议时犯过一个低级错误——没有处理粘包。设备发送数据快的时候,两帧数据可能粘在一起。上面的代码通过 while 循环处理了这种情况,每次只取一帧,剩下的继续解析。这个坑我踩过,所以现在写解析器都会特别注意。

4.3 多串口并发测试

做嵌入式测试,经常要同时跟多个设备通信。比如测试一个网关设备,它可能有 4 个串口,每个串口接一个从设备。这时候单线程就不够用了。

4.3.1 使用 threading 实现并发

我推荐用 Python 的 threading 模块,每个串口开一个线程:

import serial
import threading
import time

class SerialWorker(threading.Thread):
    def __init__(self, port, baudrate, name):
        super().__init__()
        self.port = port
        self.baudrate = baudrate
        self.name = name
        self.running = True
        self.ser = None
    
    def run(self):
        try:
            self.ser = serial.Serial(
                port=self.port,
                baudrate=self.baudrate,
                timeout=0.1
            )
            print(f"[{self.name}] 串口 {self.port} 已打开")
            
            while self.running:
                if self.ser.in_waiting > 0:
                    data = self.ser.read(self.ser.in_waiting)
                    print(f"[{self.name}] 收到: {data.hex()}")
                time.sleep(0.01)
        
        except Exception as e:
            print(f"[{self.name}] 错误: {e}")
        finally:
            if self.ser:
                self.ser.close()
    
    def stop(self):
        self.running = False

# 启动多个串口
workers = []
ports = ['COM3', 'COM4', 'COM5', 'COM6']

for i, port in enumerate(ports):
    worker = SerialWorker(port, 115200, f"Worker-{i+1}")
    worker.start()
    workers.append(worker)

# 运行一段时间后停止
time.sleep(60)
for w in workers:
    w.stop()
for w in workers:
    w.join()

这里有个关键点:每个线程的 timeout 要设得短一点,比如 0.1 秒。这样线程能及时响应停止信号,不会卡死。

4.3.2 使用队列管理数据

多线程环境下,数据共享是个麻烦事。我习惯用 queue.Queue 来做线程间通信:

import queue

# 全局数据队列
data_queue = queue.Queue()

class SerialWorker(threading.Thread):
    def run(self):
        # ... 省略串口打开代码 ...
        while self.running:
            if self.ser.in_waiting > 0:
                data = self.ser.read(self.ser.in_waiting)
                # 把数据放到队列里
                data_queue.put({
                    'port': self.port,
                    'data': data,
                    'time': time.time()
                })

# 主线程处理数据
def process_data():
    while True:
        try:
            item = data_queue.get(timeout=1)
            print(f"处理来自 {item['port']} 的数据: {item['data'].hex()}")
        except queue.Empty:
            pass

我的经验:用队列的好处是,生产者(串口线程)和消费者(处理线程)完全解耦。即使某个串口突然涌进来大量数据,也不会阻塞其他串口。我曾经用这个架构同时测试过 8 个串口,跑了 72 小时没出问题。

4.3.3 并发测试的注意事项

  • 资源限制:Windows 下串口数量有限,一般不超过 256 个。Linux 下好一些,但也要注意文件描述符限制。
  • 线程安全:pySerial 的读写操作是线程安全的,但 open()close() 不是。建议在同一个线程里打开和关闭串口。
  • 异常处理:串口可能随时断开,每个线程都要有完善的异常处理机制。

我曾经踩过的坑:有一次测试 6 个串口,每个串口每秒发送 1000 条数据。结果程序跑了 10 分钟就卡死了。查了半天,发现是打印日志太多,控制台缓冲区满了。后来改成只打印关键信息,或者把日志写到文件里,问题就解决了。所以,高并发场景下,IO 操作一定要谨慎。

好了,关于串口通信脚本,今天就聊这么多。pySerial 的用法其实不难,难的是怎么把它用好、用稳。多串口并发测试更是考验代码的健壮性。希望这些经验能帮你少走些弯路。