解惑pyserial: 串口读取数据现 \x004? 原因与正确显示方法
2025-04-21 09:56:03
解惑pyserial:为何读取串口数据时出现意想不到的 ' \x004'?
搞硬件通信的时候,用 Python 操作串口是个常见活儿。pyserial
库基本上是标配了。但有时候,从串口读出来的数据,打印一看,跟预期的不太一样,甚至有点“奇奇怪怪”,比如冒出来个 \x004
这样的组合,让人摸不着头脑。
问题来了:串口读出来的数据有点怪
想象这么个场景:你正在用 pyserial
跟一个激光设备通过 USB 转串口的方式通信。设备厂家给了文档,说设备会定时发送 16 个字节的状态字。用一个串口调试终端(比如 CoolTerm)连接设备,抓到的数据是这样的:
AA AA 00 01 00 10 00 11 00 03 20 00 34 00 C5 BD
这串十六进制数据,对照文档,每个字节的含义都清清楚楚,没毛病。
然后,咱们信心满满地写了段 Python 代码去读:
import serial
class LPY707G:
def __init__(self):
# 初始化串口配置,但不立即打开端口
self.consettings = serial.Serial(port = None, baudrate = 9600,
bytesize = serial.EIGHTBITS,
parity = serial.PARITY_NONE,
stopbits = serial.STOPBITS_ONE,
timeout = 30) # 设置读取超时30秒
# 在这里指定端口似乎更清晰
self.consettings.port = 'COM7'
self.is_connect = 0 # 初始状态未连接
def connect(self):
try:
self.consettings.open() # 打开串口
self.is_connect = 1
print("串口连接成功")
return self.is_connect
except serial.SerialException as e: # 更具体地捕获串口异常
print(f"串口 {self.consettings.port} 连接失败.")
print(e)
self.is_connect = 0
return self.is_connect
except Exception as e: # 捕获其他可能的异常
print("发生未知错误导致连接失败.")
print(e)
self.is_connect = 0
return self.is_connect
def disconnect(self):
# 最好检查一下是否已连接再关闭
if self.consettings.is_open:
try:
self.consettings.close() # 关闭串口
self.is_connect = 0
print("串口已断开")
return self.is_connect
except Exception as e:
print("关闭串口时发生错误.")
print(e)
# 如果关闭失败,连接状态可能还是打开的,取决于具体错误
self.is_connect = 1 if self.consettings.is_open else 0
print("未能成功断开连接")
return self.is_connect
else:
print("串口本来就没打开,无需断开")
self.is_connect = 0
return self.is_connect
def read_fixed_bytes(self, num_bytes=16):
# 确保串口已连接
if not self.consettings.is_open:
print("错误:串口未连接,无法读取数据")
return None
try:
# 一次性读取指定数量的字节
readbytes = self.consettings.read(num_bytes)
# 检查实际读取到的字节数是否符合预期
if len(readbytes) < num_bytes:
print(f"警告:读取超时或数据不足,预期{num_bytes}字节,实际读取{len(readbytes)}字节")
return readbytes
except serial.SerialTimeoutException:
# 超时异常单独处理可能更好
print(f"读取数据超时 (timeout={self.consettings.timeout}s)")
return None
except Exception as e:
print("读取串口数据时发生错误.")
print(e)
return None
# 主程序入口
if __name__ == "__main__":
instrument = LPY707G()
if instrument.connect(): # 确认连接成功后再操作
received_data = instrument.read_fixed_bytes(16) # 调用读取函数
if received_data: # 确认收到数据再打印
print("读取到的原始bytes数据:")
print(received_data)
instrument.disconnect() # 断开连接
运行上面这段代码,连接设备后,打印出来的 received_data
结果是:
b'\xaa\xaa\x00\x01\x00\x10\x00\x11\x00\x03 \x004\x00\xc5\xbd'
仔细比对一下,开头 b'\xaa\xaa\x00\x01\x00\x10\x00\x11\x00\x03
这 10 个字节,跟预期 AA AA 00 01 00 10 00 11 00 03
完全一致。结尾的 \x00\xc5\xbd
也对应上了预期的 00 C5 BD
。问题就出在中间这块!预期的 20 00 34
变成了 \x004
。这是个啥玩意儿?
不死心,换种方法,一个字节一个字节地读,然后存进列表里:
# ... (省略 LPY707G 类的定义和 connect/disconnect 方法) ...
def read_one_byte(self):
if not self.consettings.is_open:
print("错误:串口未连接")
return None
try:
# 读取单个字节
readbyte = self.consettings.read(1)
# 如果超时或没读到数据,read(1)会返回空bytes b''
if not readbyte:
print("警告:读取单个字节超时或无数据")
return readbyte
except Exception as e:
print("读取单个字节时出错:", e)
return None
# 主程序修改为循环读取
if __name__ == "__main__":
instrument = LPY707G()
if instrument.connect():
bytepacket = []
print("开始逐字节读取...")
for i in range(16): # 循环16次读取
indvbyte = instrument.read_one_byte()
if indvbyte is None: # 如果读取失败,停止循环
print(f"第 {i+1} 个字节读取失败,停止")
bytepacket = None # 标记读取不完整
break
if not indvbyte: # 如果读到空bytes (超时)
print(f"第 {i+1} 个字节读取超时,停止")
bytepacket = None
break
bytepacket.append(indvbyte)
if bytepacket: # 确保列表不是None (即没出错或超时中断)
print("逐字节读取结果 (列表形式):")
print(bytepacket)
instrument.disconnect()
这次运行,输出变成了列表形式:
[b'\xaa', b'\xaa', b'\x00', b'\x01', b'\x00', b'\x10', b'\x00', b'\x11', b'\x00', b'\x03', b' ', b'\x00', b'4', b'\x00', b'\xc5', b'\xbd']
欸?这下更有意思了。之前的 \x004
被拆成了三个独立的 bytes
对象:b' '
, b'\x00'
, b'4'
。跟前面一次性读取的结果一对照,似乎逻辑上能对上了,但还是没变成我们期望的 \x20
, \x00
, \x34
这种十六进制表示。
最后,还试了用 bytearray
来累加字节,结果跟第一种方法大同小异:
# ... (省略 LPY707G 类的定义和 connect/disconnect/read_one_byte 方法) ...
# 主程序使用 bytearray
if __name__ == "__main__":
instrument = LPY707G()
if instrument.connect():
bytepacket_array = bytearray() # 创建空的 bytearray
print("开始逐字节读取并构建 bytearray...")
for i in range(16):
indvbyte = instrument.read_one_byte()
if indvbyte is None or not indvbyte: # 检查失败或超时
print(f"第 {i+1} 个字节读取问题,停止构建 bytearray")
bytepacket_array = None # 标记失败
break
bytepacket_array += indvbyte # 追加字节
if bytepacket_array: # 检查是否成功构建
print("构建的 bytearray 结果:")
print(bytepacket_array)
instrument.disconnect()
输出是:
bytearray(b'\xaa\xaa\x00\x01\x00\x10\x00\x11\x00\x03 \x004\x00\xc5\xbd')
结果跟 bytes
类型的直接打印几乎一样。那个 \x004
就像个谜团,挥之不去。
刨根问底:为什么会看到 ' \x004'?
其实,pyserial
读取数据本身通常是没问题的。问题出在 Python 如何 显示 bytes
对象上。
bytes
类型本质上是一串原始的字节数据,每个字节的值范围是 0 到 255。当我们直接 print()
一个 bytes
对象时,Python 会调用它的 repr()
方法来生成一个“对开发者友好”的字符串表示。这个表示遵循一个规则:
- 如果一个字节的值对应 ASCII 码表中的可打印字符 (比如字母、数字、标点符号、空格等),Python 就直接显示那个字符。
- 如果一个字节的值对应 ASCII 码表中的不可打印字符 (比如控制字符,像回车、换行、tab、空字符
\x00
等),或者对应的字符可能会引起歧义(比如单引号、双引号、反斜杠),Python 就会使用 十六进制转义序列\xNN
来表示它,其中NN
是该字节值的两位十六进制数。
现在,咱们回过头来看那几个“奇怪”的字节:
- 预期的第一个字节是
20
(十六进制)。查 ASCII 码表,0x20
正好是 空格符 (Space) 。所以 Python 在显示bytes
时,就直接把它显示成了一个空格 - 预期的第二个字节是
00
(十六进制)。0x00
是 空字符 (Null) ,属于不可打印字符。于是 Python 用\x00
来表示它。 - 预期的第三个字节是
34
(十六进制)。查 ASCII 码表,0x34
对应的字符是数字 '4' 。这是个可打印字符,所以 Python 就直接显示了4
。
这下清楚了吧? b'\xaa...\x03 \x004\x00...'
这个输出里:
- 那个空格
\x20
。 - 那个
\x00
,就是字节\x00
。 - 那个
4
,就是字节\x34
。
所以,你的 Python 代码 确实 读取到了正确的字节序列 AA AA ... 03 20 00 34 00 C5 BD
。只不过 Python 在打印它的时候,“贴心”地把一部分字节按 ASCII 字符给显示出来了,导致看起来和纯十六进制的预期不太一样。这纯粹是个显示层面的“误会”,数据本身是对的!
解决之道:如何正确查看和处理串口字节流
既然知道了问题根源是 Python 的默认显示方式,那解决办法就很简单了:换种方式来看这些字节数据,或者直接按我们的需要去处理它们。
方案一:打印原始字节的十六进制表示
如果我们就是想看到和 CoolTerm 里一模一样的 AA AA ...
格式的十六进制字符串,可以自己动手格式化一下。
- 原理: 遍历
bytes
对象中的每一个字节(取出来时是 0-255 的整数),然后把每个整数格式化成两位大写的十六进制字符串,中间用空格隔开。 - 代码示例:
# 假设 received_data 是你从串口读到的 bytes 对象
received_data = b'\xaa\xaa\x00\x01\x00\x10\x00\x11\x00\x03 \x004\x00\xc5\xbd'
# 格式化输出
hex_string = ' '.join(f'{b:02X}' for b in received_data)
# f'{b:02X}' 的解释:
# b 是每个字节的整数值
# : 表示后面是格式说明符
# 02 表示输出宽度至少为2位,不足用0填充
# X 表示使用大写字母的十六进制格式
print("格式化后的十六进制字符串:")
print(hex_string)
# 输出: AA AA 00 01 00 10 00 11 00 03 20 00 34 00 C5 BD
- 说明: 这种方法非常直观,可以精确控制输出格式(比如用小写
x
,或者不用空格分隔等)。它只是改变了显示方式,received_data
变量里的原始bytes
数据一点没变。
方案二:使用 bytes.hex()
方法
Python 的 bytes
类型自带了一个非常方便的方法 .hex()
,专门用来干这事儿。
- 原理:
bytes.hex()
方法直接返回一个包含字节流十六进制表示的字符串。默认是小写,不带分隔符,但可以指定分隔符。 - 代码示例:
received_data = b'\xaa\xaa\x00\x01\x00\x10\x00\x11\x00\x03 \x004\x00\xc5\xbd'
# 直接转换,无分隔符,小写
hex_compact = received_data.hex()
print(f"bytes.hex() 默认输出: {hex_compact}")
# 输出: aaaa000100100011000320003400c5bd
# 指定空格作为分隔符
hex_spaced = received_data.hex(' ')
print(f"bytes.hex(' ') 带空格分隔: {hex_spaced}")
# 输出: aa aa 00 01 00 10 00 11 00 03 20 00 34 00 c5 bd
# 如果想要大写并带空格
hex_spaced_upper = received_data.hex(' ').upper()
print(f"bytes.hex(' ').upper() 大写带空格: {hex_spaced_upper}")
# 输出: AA AA 00 01 00 10 00 11 00 03 20 00 34 00 C5 BD
- 说明: 这是更“Pythonic”的做法,简洁高效。推荐优先使用。同样,这只影响显示,不改变原始数据。
方案三:直接处理字节数据
很多时候,我们不仅仅是想看十六进制,而是要根据协议文档,从这串字节里提取有用的信息,比如某个状态位是 0 还是 1,或者某几个字节组合起来表示一个温度值。这时,我们就需要直接操作这些字节。
- 原理:
bytes
对象可以像列表一样通过索引访问单个字节,得到的是 0-255 范围的整数。然后就可以用位运算(&
,|
,^
,<<
,>>
)来检查特定位,或者将多个字节组合成更大的数值。对于更复杂的解析,可以使用 Python 内置的struct
模块。 - 代码示例:
import struct
received_data = b'\xaa\xaa\x00\x01\x00\x10\x00\x11\x00\x03 \x004\x00\xc5\xbd'
# 检查数据长度是否足够 (非常重要!)
if len(received_data) == 16:
# 访问“有问题”的那几个字节 (注意索引从0开始)
byte_10 = received_data[10] # 第11个字节,索引是10
byte_11 = received_data[11] # 第12个字节
byte_12 = received_data[12] # 第13个字节
print(f"直接访问字节 (整数形式):")
print(f"索引10的值: {byte_10} (十六进制: {byte_10:02X})") # 应该输出 32 (十六进制: 20)
print(f"索引11的值: {byte_11} (十六进制: {byte_11:02X})") # 应该输出 0 (十六进制: 00)
print(f"索引12的值: {byte_12} (十六进制: {byte_12:02X})") # 应该输出 52 (十六进制: 34)
# 示例:假设字节 4 和 5 (索引 4, 5) 组成一个 16 位大端序整数
# 大端序 (Big-Endian): 高位字节在前
value_16bit = (received_data[4] << 8) | received_data[5]
# received_data[4] 是高位字节,左移8位
# | (按位或) 合并低位字节 received_data[5]
print(f"字节4-5组成的大端序16位整数: {value_16bit} (十六进制: {value_16bit:04X})")
# 预期是 0x0010 = 16
# 使用 struct 模块解包 (更通用、更安全)
# 假设要解包整个 16 字节的消息
# '>' 表示大端序
# 'H' 表示 unsigned short (2字节)
# 'B' 表示 unsigned char (1字节)
# 我们根据文档结构来写格式字符串,例如:
# AA AA (Header) -> H? B B?
# 00 01 (Cmd) -> H
# 00 10 (Data1) -> H
# ...以此类推
# 这里仅演示解包有问题的区域 0x20, 0x00, 0x34 (索引 10, 11, 12)
# 格式是 'BBB' (3个 unsigned char)
# 需要提供数据源和偏移量 (offset=10)
try:
unpacked_bytes = struct.unpack_from('>BBB', received_data, offset=10)
print(f"使用 struct 从索引10开始解包3个字节: {unpacked_bytes}")
# 输出: (32, 0, 52)
print(f" 对应十六进制: {unpacked_bytes[0]:02X} {unpacked_bytes[1]:02X} {unpacked_bytes[2]:02X}")
# 输出: 20 00 34
except struct.error as e:
print(f"struct 解包错误: {e}")
print(" (可能是数据长度不足或偏移量错误)")
else:
print(f"接收到的数据长度不足16字节 (实际: {len(received_data)}),无法解析。")
- 说明与建议:
- 健壮性: 在访问
bytes
索引或使用struct
解包前,一定 要检查len(received_data)
是否足够长,防止IndexError
或struct.error
。 struct
模块: 对于固定格式的二进制数据,强烈推荐使用struct
。它能处理字节序(大端>
/ 小端<
)、数据类型(整数、浮点数等)和对齐问题,代码更清晰,不易出错。你需要根据设备文档确定正确的格式字符串。- 位操作: 如果需要检查或设置单个比特位,位运算符 (
&
,|
,~
,^
,<<
,>>
) 是你的好朋友。比如检查byte_10
的第 3 位(从0开始数)是否为 1:if byte_10 & (1 << 3): ...
。
- 健壮性: 在访问
优化你的串口代码
顺便也给之前的串口代码提几个改进建议,让它更可靠、更易用。
改进一:使用 with
语句管理串口资源
serial.Serial
对象像文件一样,打开了就需要确保最后能关掉,即使中间出了错。with
语句是干这个的最佳方式。
- 原理:
with
语句能自动管理资源的进入和退出。对于serial.Serial
,它会在代码块开始时尝试打开端口(如果没指定port=None
的话,或者在with
之前手动配置好再传入),在代码块结束时(无论正常结束还是异常退出)自动调用close()
方法。 - 代码示例:
import serial
import time
port = 'COM7'
baudrate = 9600
print(f"尝试连接串口 {port}...")
try:
# 使用 with 语句创建并管理 Serial 对象
# timeout=1 表示 read 操作最多阻塞1秒
with serial.Serial(port=port, baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=1) as ser:
print(f"成功连接到 {ser.name}")
# 示例:循环读取数据,直到手动停止或出错
# 这里假设设备持续发送 16 字节数据包
read_count = 0
max_reads = 5 # 示例:只读5次
while read_count < max_reads:
# 检查缓冲区是否有足够数据 (可选,但对于非阻塞或变长数据有用)
if ser.in_waiting >= 16:
read_bytes = ser.read(16)
if read_bytes: # 确保真的读到了数据
hex_output = read_bytes.hex(' ').upper()
print(f"收到数据包 {read_count+1}: {hex_output}")
# 在这里可以加入数据处理逻辑...
read_count += 1
else:
# 虽然 ser.read(16) 在timeout=1内可能返回空bytes,但较少见
# 如果是 ser.read(ser.in_waiting) 则可能返回空
print("读到了空数据,可能串口有问题?")
time.sleep(0.1) # 短暂等待
else:
# 缓冲区数据不足16字节,稍微等等
# print("等待数据...") # (频繁打印会刷屏,可注释掉)
time.sleep(0.1) # 避免CPU空转
# 异常处理:捕获串口相关的异常
except serial.SerialException as e:
print(f"串口错误: {e}")
except Exception as e:
print(f"发生了其他错误: {e}")
# with 语句结束时,无论如何 ser.close() 都会被自动调用
print("程序结束 (如果连接成功,串口应已自动关闭)")
- 优点: 代码更简洁,资源管理更安全,不易忘记关闭端口。
改进二:合理设置 timeout
你原始代码里 timeout = 30
有点长。这意味着如果设备不发送数据,read()
操作会卡住 30 秒!
- 理解
timeout
:timeout = None
:阻塞模式,read()
会一直等,直到读满指定字节数。timeout = 0
:非阻塞模式,read()
立刻返回,不管有没有读到数据(读到多少算多少)。timeout = x
(x > 0):超时模式,read()
最多等待 x 秒。如果在 x 秒内读够了字节,就返回;如果 x 秒到了还没读够,就返回当前已经读到的字节(可能是空的b''
)。
- 建议:
- 如果设备是固定周期发送数据(比如你例子里每隔几秒发一次状态字),可以设置一个略大于周期间隔但又不至于太长的
timeout
(比如 1-5 秒?)。 - 对于需要快速响应或处理不定时数据的场景,可以设置一个较短的
timeout
(如 0.1 或 1 秒),或者设为 0(非阻塞),然后配合ser.in_waiting
属性(检查接收缓冲区有多少字节可用)来读取。in_waiting
不会阻塞。 - 原始代码中一次性读取 16 字节,配合
timeout=30
可能还行,但逐字节读取时,timeout=30
可能导致每个字节都要等很久。逐字节读取时用短timeout
或检查in_waiting
会更合适。
- 如果设备是固定周期发送数据(比如你例子里每隔几秒发一次状态字),可以设置一个略大于周期间隔但又不至于太长的
改进三:更精细的错误处理
你的代码里用了 Exception as e
来捕获所有错误。最好能区分串口本身的错误和其他逻辑错误。
-
serial.SerialException
:pyserial
库定义的基类异常,涵盖打不开端口、设备断开等串口通信相关的错误。 -
serial.SerialTimeoutException
:如果设置了timeout
并且read()
操作因超时而返回时,通常不 抛出异常,而是返回已读取的数据(可能为空)。需要通过判断返回值的长度来得知是否超时。 (注意:这点可能随版本或平台行为略有差异,但普遍实践是检查返回值长度)。 -
struct.error
:如果用了struct.unpack
,当数据长度、格式或偏移量不对时会抛出这个异常。 -
建议:
- 在
connect
时,优先捕获serial.SerialException
。 - 在
read
/write
操作的try
块里,可以准备好处理可能的serial.SerialException
(比如设备中途拔掉)以及由数据处理(如struct.unpack
)引发的异常。 - 对于超时,主要靠检查
read()
返回的数据长度。
- 在
好了,关于 pyserial
读取串口数据时遇到的 \x004
显示问题以及相关的处理和优化技巧,差不多就聊这些。核心记住:Python 对 bytes
的默认打印方式是“罪魁祸首”,你的数据很可能没读错! 选择合适的方法来查看或处理这些原始字节才是关键。