公司动态

Python串口通信实战:用struct模块精准解析嵌入式设备二进制协议

📅 2026/8/7 4:32:02
Python串口通信实战:用struct模块精准解析嵌入式设备二进制协议
1. 项目缘起为什么用Python玩转嵌入式串口搞嵌入式开发的朋友尤其是做单片机、物联网设备调试的对串口通信肯定不陌生。我们最熟悉的场景可能就是打开一个串口助手发送几个十六进制指令然后盯着接收区看设备有没有回数据。这种手动操作在前期功能验证时没问题但一旦进入自动化测试、数据采集、上位机开发阶段就显得力不从心了。这时候Python的优势就体现出来了。它语法简洁库生态丰富特别是pyserial这个库让串口操作变得和读写文件一样简单。但问题也随之而来很多教程只讲到“如何用pyserial收发字符串”一旦涉及到真实嵌入式设备通信——那种需要严格按照字节流协议进行打包pack和解包unpack的场景——新手就很容易卡壳。比如设备发来一串b\x01\x02\x03\x04你怎么知道第一个字节是命令第二个字节是长度后面两个字节是一个short型的数据反过来你要发送一个浮点数给设备又该怎么把它变成设备能识别的字节序列这就是本篇要解决的核心问题打通Python与嵌入式设备之间基于原始字节流的、协议级别的串口通信。我们将聚焦于如何利用Python的struct模块配合pyserial实现精准的、可复用的数据封包与解包让你能像设备原厂工程师一样自如地通过串口与设备“对话”。2. 环境搭建与核心工具选型在开始写代码之前我们需要一个稳定、可复现的软硬件环境。这里的选择基于最大程度的通用性和避坑考虑。2.1 硬件连接与虚拟串口准备真实的嵌入式设备如STM32、ESP32开发板通过USB转TTL模块连接到电脑是最常见的场景。但为了演示和学习的便利特别是当你手头没有现成硬件时创建一对虚拟串口是极佳的选择。在Windows上我强烈推荐使用VSPD (Virtual Serial Port Driver)。它稳定、易用可以瞬间创建出像COM3和COM4这样一对互联的虚拟串口。一个口用来模拟Python上位机另一个口可以留给其他串口助手软件或者模拟一个简单的设备端程序。这样你所有的通信测试都可以在本机完成无需依赖实体硬件。在Linux或macOS下可以使用socat或pty来模拟但对于初学者在Windows上用VSPD是最快上手的路径。记住一个关键点务必以管理员身份运行VSPD进行端口创建和删除否则可能会出现端口占用无法释放的诡异问题。2.2 Python环境与库安装Python版本建议使用3.7及以上。库的安装很简单核心就是pyserial。pip install pyserial这里有个细节库名是pyserial但导入时用的是import serial。别装错了也别疑惑这是历史原因。至于struct模块它是Python的标准库无需额外安装。它是我们本次任务的“数据翻译官”。2.3 为什么是pyserialstruct你可能会问有的串口助手也支持脚本为什么非要Pythonpyserial的不可替代性它提供了跨平台的、底层的串口访问能力。你可以精细控制波特率、数据位、停止位、校验位可以设置超时timeout和非阻塞读取可以查询CTS、DSR等硬件流控制信号。这些是构建健壮通信程序的基础。struct的精准性嵌入式协议往往是二进制的、紧凑的。一个float占4个字节一个uint16_t占2个字节它们的内存布局必须和C语言端严格一致。struct模块的pack和unpack函数就是专门用来在Python数据类型和C语言风格字节流之间进行转换的它严格遵循你指定的字节序大端/小端和对齐方式。生态与自动化Python庞大的生态意味着你收到数据后可以轻松地用pandas分析用matplotlib画图用pyqt做GUI或者集成到自动化测试框架中。这是专用串口助手无法比拟的。3. 核心一使用pyserial进行串口基础通信让我们先抛开复杂的数据结构看看如何用pyserial建立最基本的连接并收发数据。这是所有高级操作的地基。3.1 串口对象的初始化与参数详解打开一个串口远不止指定一个端口号那么简单。下面是一个包含了关键参数的初始化示例import serial import time # 创建并配置串口对象 ser serial.Serial( portCOM3, # 端口号。Linux下可能是 /dev/ttyUSB0 baudrate115200, # 波特率。必须与设备端严格一致 bytesizeserial.EIGHTBITS, # 数据位8位是最常见的 parityserial.PARITY_NONE, # 校验位无校验 stopbitsserial.STOPBITS_ONE, # 停止位1位 timeout1, # 读超时秒。设置为None则一直阻塞0则为非阻塞。 write_timeout1, # 写超时秒。防止写操作无限期挂起。 xonxoffFalse, # 软件流控制一般不启用 rtsctsFalse, # 硬件RTS/CTS流控制根据设备需求启用 dsrdtrFalse # 硬件DSR/DTR流控制较少用 ) # 检查串口是否成功打开 if ser.is_open: print(f串口 {ser.port} 已打开) else: print(串口打开失败)关键参数避坑指南timeout这是最容易出问题的地方。timeout1意味着read()方法最多等待1秒无论读到多少数据都返回。如果你要读取一个确定长度的数据包比如20字节可以ser.read(20)它会等待最多1秒来凑齐这20字节。如果设置为timeout0那么read()会立刻返回当前缓冲区中的所有数据可能为空这是非阻塞模式。如果设置为timeoutNone则会一直阻塞直到读到指定长度的数据。根据你的通信协议特点谨慎选择。baudrate务必与嵌入式设备程序里的设置完全一致。常见的错误是电脑端115200设备端9600导致收到乱码。rtscts如果设备端使用了硬件流控制必须将此设为True否则在缓冲区满时会导致数据丢失。很多无线模块如某些4G Cat.1模块需要这个。3.2 数据的发送与接收字节与字符串的转换串口通信的本质是字节流bytes。在Python 3中字符串str和字节bytes是严格区分的。发送数据# 发送字符串如AT指令 command ATGMR\r\n # 注意换行符很多设备需要 ser.write(command.encode(utf-8)) # 必须将str编码为bytes # 或者直接写字节 ser.write(bATGMR\r\n) # 发送16进制数据 hex_data bytes.fromhex(01 02 AB CD) ser.write(hex_data)接收数据# 读取所有当前可用的数据非阻塞依赖timeout0 data ser.read_all() if data: print(f收到原始字节: {data}) # 尝试解码为字符串如果是文本协议 try: text data.decode(utf-8) print(f解码为字符串: {text}) except UnicodeDecodeError: print(数据非UTF-8文本可能是二进制数据) # 读取指定长度阻塞依赖timeout expected_length 10 data ser.read(expected_length) if len(data) expected_length: print(f收到完整数据包: {data.hex()}) # 以16进制形式打印 else: print(f超时只收到 {len(data)} 字节: {data.hex()})一个至关重要的经验在开始复杂的协议解析前务必先用一个简单的“回声测试”验证链路是否通畅。你可以用虚拟串口让一端发送固定的字符串或字节另一端接收并打印。确保最基本的收发没问题再进入协议层这能排除至少50%的硬件连接和配置问题。4. 核心二使用struct模块进行协议数据封包当我们需要发送一个结构化的数据给设备时比如一个包含命令字、数据长度、传感器数值可能是整型、浮点型的数据包就需要struct.pack。4.1 struct.pack格式字符串详解struct.pack(fmt, v1, v2, ...)接受一个格式字符串fmt和一系列值返回一个bytes对象。格式字符串由两部分构成字节序指令和格式字符。1. 字节序指令第一个字符小端字节序Little-endian。x86架构的PC、STM32等ARM Cortex-M内核通常都是小端。这是最常用的。大端字节序Big-endian。网络协议如TCP/IP、某些老式处理器用。!网络字节序等于大端。原生字节序依赖当前系统。不推荐在跨平台通信中使用。对于嵌入式通信如果你不确定设备端是什么优先尝试小端。2. 格式字符常见b有符号字符signed char1字节。B无符号字符unsigned char1字节。常用于命令字、状态码。h有符号短整型short2字节。H无符号短整型unsigned short2字节。i或l有符号整型int通常4字节。I或L无符号整型unsigned int通常4字节。f单精度浮点型float4字节。d双精度浮点型double8字节。s字符数组char[]需要指定长度如10s表示10字节的字符串。x填充字节用于对齐。4.2 实战封装一个混合类型的数据包假设我们要向一个STM32设备发送一个控制指令包协议定义如下C语言结构体#pragma pack(1) // 告诉编译器按1字节对齐取消填充让结构更紧凑 typedef struct { uint8_t cmd; // 命令字0x01表示设置参数 uint16_t seq; // 序列号用于请求-应答匹配 float target_temp; // 目标温度浮点数 uint8_t mode; // 模式0-自动1-手动 uint8_t checksum; // 校验和前面所有字节的和取低8位 } ControlPacket;用Python的struct来封装这个包import struct def build_control_packet(seq_num, temperature, work_mode): 构建控制数据包 seq_num: 序列号 (0-65535) temperature: 目标温度 (浮点数) work_mode: 工作模式 (0或1) cmd_byte 0x01 seq_short seq_num temp_float temperature mode_byte work_mode # 1. 先打包除校验和外的所有数据 # 格式: (小端) | B (cmd) | H (seq) | f (temp) | B (mode) fmt BHfB header_and_data struct.pack(fmt, cmd_byte, seq_short, temp_float, mode_byte) # 2. 计算校验和简单求和取低8位 checksum sum(header_and_data) 0xFF # 3. 将校验和追加到包尾 # 注意校验和本身也是数据的一部分按1字节无符号打包 full_packet header_and_data struct.pack(B, checksum) print(f构建的数据包(十六进制): {full_packet.hex()}) return full_packet # 示例发送设置温度25.5℃自动模式序列号100的指令 packet_to_send build_control_packet(100, 25.5, 0) # 假设ser是已打开的串口对象 ser.write(packet_to_send)运行后你会看到类似01 64 00 00 cc 41 00 71的十六进制输出。我们来拆解一下01: 命令字cmd(0x01)64 00: 序列号seq(100)。小端存储所以低位0x64在前高位0x00在后。00 cc 41 00: 浮点数target_temp(25.5)。这是IEEE 754单精度浮点数在内存中的小端表示需要unpack才能还原。00: 模式mode(0)71: 校验和checksum(前面所有字节的和0x010x640x000x000xcc0x410x000x00 0x171取低8位0x71)关键经验对齐问题C语言结构体默认会进行内存对齐以提高访问速度。例如一个uint8_t后面跟一个uint32_t编译器可能会在中间插入3个填充字节。这会导致struct打包出来的字节流和C结构体的内存映像对不上。解决方案有两种一是在C代码中使用#pragma pack(1)或__attribute__((packed))取消填充二是在Python的格式字符串中手动用x插入相同数量的填充字节。前者是更推荐、更通用的做法。校验和计算务必在打包成字节流之后再计算校验和。计算时是对每个字节0-255进行运算而不是对原始的Python整数。5. 核心三使用struct模块进行协议数据解包设备返回的数据我们需要从一串字节流中准确地解析出各个字段。这是struct.unpack的舞台。5.1 struct.unpack与格式字符串复用struct.unpack(fmt, buffer)接受一个格式字符串和一个字节缓冲区返回一个元组。一个强大的技巧是为你的数据包定义一个全局的格式字符串。这保证了打包和解包时使用的是完全相同的格式避免人为错误。# 定义与C结构体对应的格式字符串假设已用#pragma pack(1) # 对应 uint8_t cmd, uint16_t seq, float target_temp, uint8_t mode, uint8_t checksum PACKET_FMT BHfBB # 注意最后两个B分别对应mode和checksum PACKET_SIZE struct.calcsize(PACKET_FMT) # 计算这个格式占用的字节数 print(f数据包预期大小: {PACKET_SIZE} 字节) # 应该是 12411 9 字节5.2 实战解析数据包与校验验证串口数据是流式的没有边界。我们首先需要从流中“捞出”一个完整的数据包。def read_and_parse_packet(ser, packet_fmt, packet_size): 从串口读取并解析一个完整的数据包 ser: 串口对象 packet_fmt: 数据包格式字符串 packet_size: 数据包大小 # 1. 读取固定长度的包头例如包含包类型和长度的最小头 # 这里假设我们知道完整包大小直接读取。更复杂的协议需要先读包头判断长度。 data ser.read(packet_size) if len(data) packet_size: print(f数据不完整期望{packet_size}字节收到{len(data)}字节) return None # 2. 解包 try: # unpack返回元组顺序与格式字符串对应 cmd, seq, temp, mode, recv_checksum struct.unpack(packet_fmt, data) except struct.error as e: print(f解包失败: {e}) return None # 3. 校验和验证 # 计算收到数据中“数据部分”的校验和不包括最后一个字节的校验和本身 calculated_checksum sum(data[:-1]) 0xFF if calculated_checksum ! recv_checksum: print(f校验和错误收到: 0x{recv_checksum:02X}, 计算: 0x{calculated_checksum:02X}) return None # 或根据协议处理如请求重发 # 4. 打印解析结果 print(f解析成功 - 命令: 0x{cmd:02X}, 序列号: {seq}, 温度: {temp:.2f}, 模式: {mode}) return cmd, seq, temp, mode # 在主循环中调用 while True: result read_and_parse_packet(ser, PACKET_FMT, PACKET_SIZE) if result: # 根据解析出的cmd和data进行业务处理 cmd, seq, temp, mode result # ... 你的业务逻辑 ... time.sleep(0.01) # 避免CPU空转5.3 处理粘包与断包上面的例子假设一次read就能读到一个完整包。现实中由于串口是流式传输和操作系统缓冲区机制可能会发生粘包两次发送的数据被一次性收到。断包一次发送的数据被分两次收到。解决方案是设计一个简单的“帧”或“包”结构最常见的是“长度数据”的格式定义一个固定的包头至少包含一个“数据长度”字段。先读取包头解析出后续数据体的长度N。循环读取直到收满N字节的数据体。将包头和数据体合并进行完整解包。def read_packet_with_header(ser, header_fmtHB): 读取带包头假设为2字节长度1字节类型的数据包 header_fmt: 包头格式例如 HB 表示小端、16位长度、8位类型 header_size struct.calcsize(header_fmt) header_data ser.read(header_size) if len(header_data) header_size: return None # 解包头 data_length, packet_type struct.unpack(header_fmt, header_data) # 读取数据体 body_data b while len(body_data) data_length: chunk ser.read(data_length - len(body_data)) if not chunk: # 超时 break body_data chunk if len(body_data) ! data_length: print(f包体数据不完整期望{data_length}收到{len(body_data)}) return None # 返回包头信息和原始数据体供后续根据packet_type解析 return packet_type, header_data body_data6. 进阶技巧与项目实战心得掌握了基本收发和解包后下面这些经验能让你的串口通信程序更健壮、更高效。6.1 超时、重试与异常处理机制工业级应用必须有完善的异常处理。def robust_send_command(ser, data, max_retries3, ack_expectedTrue, ack_timeout1.0): 可靠的命令发送函数支持重试和应答确认 for attempt in range(max_retries): try: ser.write(data) print(f第{attempt1}次发送: {data.hex()}) if not ack_expected: return True # 等待并读取应答假设应答是一个单字节ACK: 0x06 ack ser.read(1) if ack b\x06: print(收到ACK发送成功) return True else: print(f未收到ACK或收到错误应答: {ack.hex() if ack else 超时}) except serial.SerialTimeoutException: print(写操作超时) except serial.SerialException as e: print(f串口通信异常: {e}) # 严重错误可以考虑关闭并重新打开串口 # ser.close() # time.sleep(1) # ser.open() break # 跳出重试循环 time.sleep(0.5) # 重试前等待 print(f发送失败已达最大重试次数{max_retries}) return False6.2 多线程与异步处理如果通信数据量大或需要同时处理用户界面避免在UI主线程中进行阻塞式的ser.read()。import threading from queue import Queue class SerialReaderThread(threading.Thread): def __init__(self, ser, data_queue): super().__init__() self.ser ser self.data_queue data_queue # 用于存放解析好的数据包 self.running True def run(self): print(串口读取线程启动) while self.running: if self.ser.in_waiting: raw_data self.ser.read(self.ser.in_waiting) # 这里可以调用之前写的解包函数 # packet try_parse_packet(raw_data_buffer) # 需要维护一个缓冲区 # if packet: # self.data_queue.put(packet) # 简单示例直接放入队列 self.data_queue.put(raw_data) time.sleep(0.001) # 短暂休眠避免CPU占用过高 print(串口读取线程停止) def stop(self): self.running False # 在主线程中启动 data_queue Queue() reader_thread SerialReaderThread(ser, data_queue) reader_thread.start() # 主线程从队列中获取处理结果 try: while True: if not data_queue.empty(): data data_queue.get() # 处理数据... print(f主线程收到数据: {data.hex()}) # 主线程可以做其他事情 time.sleep(0.01) except KeyboardInterrupt: reader_thread.stop() reader_thread.join()6.3 调试与日志记录详细的日志是排查通信问题的生命线。import logging logging.basicConfig(levellogging.DEBUG, format%(asctime)s - %(name)s - %(levelname)s - %(message)s, handlers[ logging.FileHandler(serial_comm.log), logging.StreamHandler() ]) logger logging.getLogger(__name__) # 在发送和接收的关键位置添加日志 def send_with_log(data): logger.debug(fTX - {data.hex()}) ser.write(data) def read_with_log(size): data ser.read(size) if data: logger.debug(fRX - {data.hex()}) return data把每次收发的原始字节流、时间戳都记录下来。当出现校验错误、数据错位时翻看日志文件往往能一眼看出问题所在——是发送方数据错了还是接收方解析错了或者是中间发生了字节丢失。6.4 与C语言结构体的协同确保Python和C语言端对数据结构的理解一致是成功通信的前提。最好的方法是共享一个协议定义文件。可以创建一个protocol.h文件用C语言定义所有结构体和命令字// protocol.h #pragma pack(1) #define CMD_SET_PARAM 0x01 #define CMD_GET_STATUS 0x02 typedef struct { uint8_t cmd; uint16_t seq; float value; uint8_t mode; uint8_t checksum; } ControlPacket; typedef struct { uint8_t cmd; uint16_t seq; uint32_t timestamp; float sensor_data[4]; uint8_t checksum; } SensorDataPacket;在Python项目中你可以手动或写个小脚本将这个头文件“翻译”成Python的格式字符串和常量字典# protocol.py CMD_SET_PARAM 0x01 CMD_GET_STATUS 0x02 CONTROL_PACKET_FMT BHfBB SENSOR_DATA_PACKET_FMT BHI4fB CONTROL_PACKET_SIZE struct.calcsize(CONTROL_PACKET_FMT) SENSOR_DATA_PACKET_SIZE struct.calcsize(SENSOR_DATA_PACKET_FMT)这样两边的代码就基于同一份“协议契约”进行开发极大减少了因理解偏差导致的通信故障。从打开串口到完成一次可靠的结构化数据交换整个过程就像在搭建一座桥梁。pyserial提供了桥墩和桥面而struct模块则是确保桥上车辆数据格式统一的交通规则。理解字节序、内存对齐、流式处理这些底层概念能让你在调试时不再盲目。最后良好的编程习惯——如完善的错误处理、清晰的日志、模块化的代码结构——则是让这座桥能在各种复杂环境下长期稳定通行的保障。下次当你的设备通过串口传回一长串十六进制数时希望你能会心一笑因为现在你掌握了“翻译”它们的能力。