Python串口助手怎么实现十六进制(Hex)数据收发与显示
本文详细讲解如何使用Python及pyserial库实现串口助手中的十六进制(Hex)数据收发与显示功能,包含底层原理、数据清洗、偶数对齐、格式化输出及完整代码示例。
bytes(原生二进制字节序列)与十六进制字符串之间的转换。底层驱动 pyserial 始终通过 bytes 与硬件进行全双工数据交换;发送时通过输入清洗与 bytes.fromhex() 将文本打包为字节串写入端口;接收时读取原始 bytes,并通过 bytes.hex(' ').upper() 格式化为大写空格分隔的十六进制字符串进行渲染展示。
一、串口收发模式的本质:ASCII 文本与 Hex 字节流的差异
很多初学者在编写 Python 串口助手时,最容易混淆的是“字符”与“字节”的关系。在物理层面上,RS-232、RS-485 或 TTL 串口传输的只有高低电平构成的位流(Bit),串口控制器(UART)以 8 位为一个字节(Byte)向操作系统交付。串口硬件本身没有“文本模式”或“十六进制模式”的概念,这两种模式完全是上位机软件(Python 脚本)对字节数据的不同理解与呈现策略。
为了在实际应用中做出正确选择,我们需要在相同串口通道条件下,对比这两种数据形态的关键差异:
| 对比维度 | ASCII 文本模式 | 十六进制 (Hex) 二进制模式 |
|---|---|---|
| Python 数据类型 | str(字符串) |
bytes(不可变字节序列) |
| 转换方式 | 依赖编码表:s.encode('utf-8') 与 b.decode('utf-8') |
面向原生数值:bytes.fromhex() 与 b.hex() |
| 特殊字节容忍度 | 极差。遇到 0x00、0xFF、控制符时可能截断、显示乱码或抛出 UnicodeDecodeError |
优秀。0x00 ~ 0xFF 共 256 种状态全部等权透传,物理值 1:1 无损映射 |
| 数据帧占用体积 | 数值 255 若传字符串 "255" 需占 3 个字节,效率较低 | 数值 255 仅占 1 个字节(0xFF),高带宽压缩与高密度传输 |
| 典型应用协议 | 蜂窝模组 AT 指令、GPS NMEA-0183 语句、控制台 CLI 交互 | Modbus RTU 工业总线、传感器私有帧、固件升级包(IAP)、带 CRC 校验帧 |
理解这种差异至关重要:在工业控制与传感器数据采集中,由于校验码(如 CRC16)、传感器读数很可能产生 0x00 或不可见字符,如果误用 ASCII 模式的 .decode() 处理,不仅界面乱码,整个接收逻辑还会因解码异常而中断崩溃。因此,十六进制收发是工业级串口助手必不可少的核心能力。
二、开发准备与串口初始化配置
在 Python 中实现串口通信,标准的第三方库是 pyserial。注意在安装时应安装 pyserial,切勿误装同名的 serial 库:
pip install pyserial
安装完成后,在代码中通过 import serial 导入。初始化串口对象时,需要明确端口号(Port)、波特率(Baudrate)以及超时时间(Timeout)。其中 timeout 参数对 Hex 收发的平稳运行至关重要:
import serial
# 初始化串口对象
# Windows 下端口通常为 'COM3', 'COM4' 等;Linux/macOS 下通常为 '/dev/ttyUSB0', '/dev/tty.usbserial-*'
ser = serial.Serial(
port='COM3',
baudrate=115200,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=0.5 # 读操作超时时间设置为 0.5 秒
)
timeout=None,ser.read() 会无限期阻塞直至请求的字节数全部到达;若设置 timeout=0 则为纯非阻塞模式,调用立即返回;合理设置 timeout=0.2 ~ 0.5 秒,可以在保持响应的同时防止主线程死锁。
三、十六进制数据发送实现:数据清洗与打包
在图形化串口助手或命令行交互中,用户输入的十六进制数据往往形式多样,例如 "AA BB 01"、"0xAA 0xBB 0x01"、"AABB01" 或包含尾部空格换行。Python 的 ser.write() 只接受 bytes 类型作为参数。因此,发送模块必须经历五个步骤:接收原始输入、清洗无效符号、奇数长度校验与补零、构建 bytes 对象、写入串口。
1. 清洗输入文本与偶数补齐
每个十六进制字节严格由 2 位十六进制字符(0-9, A-F)构成。在调用转换函数前,必须剥除多余的空格、逗号与 0x 前缀。如果用户输入的有效字符数为奇数(例如输入 "A" 或 "10A"),直接调用解析会导致报错,应当在高位补 "0",将其规范化为 "0A" 或 "010A"。
2. 使用 bytes.fromhex() 完成类型转换
从 Python 3 起,内置的 bytes.fromhex() 是最高效且标准的做法。它会自动忽略字符串中间的单空格(Python 3.8+),但为了兼容包含前缀与符号的混杂输入,我们编写一个稳健的发送辅助函数:
import re
import serial
def send_hex_data(ser: serial.Serial, hex_string: str) -> int:
"""
清洗用户输入的十六进制字符串,并将其打包成 bytes 发送到串口
:param ser: 已打开的 serial.Serial 实例
:param hex_string: 用户输入的十六进制文本,如 "0xAA, bb 01 f"
:return: 实际发送的字节数
"""
if not ser.is_open:
raise serial.SerialException("串口未打开,无法发送数据")
# 步骤 1 & 2: 过滤 0x 前缀,移除所有非十六进制字符(只保留 0-9, a-f, A-F)
clean_hex = re.sub(r'0[xX]', '', hex_string)
clean_hex = re.sub(r'[^0-9a-fA-F]', '', clean_hex)
if not clean_hex:
return 0 # 无有效发送内容
# 步骤 3: 奇数位检查与高位补零
if len(clean_hex) % 2 != 0:
clean_hex = '0' + clean_hex
# 步骤 4: 转换为 bytes 字节串
try:
data_to_send = bytes.fromhex(clean_hex)
except ValueError as err:
raise ValueError(f"十六进制解析失败: {err}")
# 步骤 5: 写入串口
bytes_written = ser.write(data_to_send)
ser.flush() # 确保缓冲区数据全部刷入硬件
return bytes_written
send_hex_data(ser, "AA bb 01 03 00 01")
>>> 成功写入 6 字节: b'ª» '
# 示例 2:输入包含 0x 前缀和奇数位的控制帧
send_hex_data(ser, "0x01, 0x3, 0x0A")
>>> 自动清洗补零为 "01030A",成功写入 3 字节: b' '
四、十六进制数据接收与显示:缓冲读取与格式化
串口数据在物理线路上是连续的流,当下位机(如单片机)发送一串字节时,操作系统的串口接收缓冲区会逐步蓄积数据。Python 串口助手若要实现稳定、美观的 Hex 显示,需要解决“数据帧读取”与“格式化展示”两个核心环节。
1. 检测待读字节并处理分包断帧
串口通信没有天然的数据包边界。例如从机返回 8 个字节的 Modbus 响应,因为波特率传输耗时或操作系统调度,Python 可能先读到 3 个字节,隔了 2 毫秒又读到 5 个字节。若直接读到即打印,UI 上的十六进制数据就会被拆成破碎的多行。
常见的断帧策略是“字符超时判断”(类似 Modbus 协议中的 3.5 字符时间):当 ser.in_waiting > 0 时,稍作短暂停顿(如 time.sleep(0.02)),若后续不再有新字节涌入,则认为当前帧发送完毕,一次性将缓冲区字节全部读出。
2. 将原始字节格式化为大写 Hex 字符串
从串口读出的数据类型是 bytes。在 Python 3.8 及以上版本中,bytes 对象自带了支持分隔符的 hex() 方法,一行代码即可输出标准大写的 Hex 串:
# Python 3.8+ 原生高效方法
raw_data = b'ªU '
display_hex = raw_data.hex(' ').upper()
print(display_hex)
# 输出: "AA 55 01 03 00 01"
如果在需要兼容较早版本 Python 的环境中,可以使用列表推导式配合格式化字符串:
# 兼容旧版本 Python 的等价写法
display_hex = ' '.join(f'{b:02X}' for b in raw_data)
五、完整可运行代码:具备 Hex 收发的控制台串口助手
下面是一个完整的、工业可用的控制台串口助手示例。该脚本在一个后台守护线程中持续监听串口接收事件并按 Hex 格式化输出,主线程负责接收用户的十六进制输入并调用发送逻辑:
# -*- coding: utf-8 -*-
"""
Python 串口十六进制 (Hex) 收发助手完整参考实现
环境依赖: pip install pyserial
"""
import time
import threading
import datetime
import re
import serial
class HexSerialAssistant:
def __init__(self, port: str, baudrate: int = 115200):
self.port = port
self.baudrate = baudrate
self.ser = None
self.is_running = False
self.rx_thread = None
def open(self):
"""打开串口并启动后台接收监听线程"""
try:
self.ser = serial.Serial(
port=self.port,
baudrate=self.baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=0.1
)
self.is_running = True
self.rx_thread = threading.Thread(target=self._rx_loop, daemon=True)
self.rx_thread.start()
print(f"[系统] 成功打开串口 {self.port},波特率 {self.baudrate}")
except serial.SerialException as err:
print(f"[错误] 打开串口失败: {err}")
raise
def send_hex(self, hex_input: str) -> bool:
"""格式化并发送十六进制数据"""
if not self.ser or not self.ser.is_open:
print("[警告] 串口未连接,无法发送")
return False
# 过滤非 16 进制字符
clean = re.sub(r'0[xX]', '', hex_input)
clean = re.sub(r'[^0-9a-fA-F]', '', clean)
if not clean:
print("[提示] 发送内容为空或无有效 Hex 字符")
return False
# 奇数位补零
if len(clean) % 2 != 0:
clean = '0' + clean
try:
payload = bytes.fromhex(clean)
self.ser.write(payload)
self.ser.flush()
now_str = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
formatted_show = payload.hex(' ').upper()
print(f"[{now_str}] [TX 发送 {len(payload)}B]: {formatted_show}")
return True
except Exception as err:
print(f"[错误] 发送失败: {err}")
return False
def _rx_loop(self):
"""后台数据接收与断帧显示循环"""
buffer = bytearray()
last_receive_time = 0
while self.is_running:
try:
waiting = self.ser.in_waiting
if waiting > 0:
chunk = self.ser.read(waiting)
buffer.extend(chunk)
last_receive_time = time.time()
else:
# 当接收缓冲区有数据,且超过 30ms 无后续字节输入时,判定一帧接收完毕
if len(buffer) > 0 and (time.time() - last_receive_time > 0.03):
now_str = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
hex_display = bytes(buffer).hex(' ').upper()
print(f"[{now_str}] [RX 接收 {len(buffer)}B]: {hex_display}")
buffer.clear()
time.sleep(0.01)
except serial.SerialException:
break
except Exception as e:
print(f"[异常] 接收处理错误: {e}")
break
def close(self):
"""安全释放串口资源"""
self.is_running = False
if self.rx_thread and self.rx_thread.is_alive():
self.rx_thread.join(timeout=1.0)
if self.ser and self.ser.is_open:
self.ser.close()
print("[系统] 串口已安全关闭")
# 交互式执行入口
if __name__ == '__main__':
# 请根据实际开发板端口调整,例如 COM3 或 /dev/ttyUSB0
target_port = "COM3"
assistant = HexSerialAssistant(port=target_port, baudrate=115200)
try:
assistant.open()
print("--- 已进入 Hex 交互模式 (输入 quit 退出) ---")
print("请输入要发送的 Hex 数据 (如: AA 55 01 02 或 0xA0 0x11):")
while True:
user_input = input("> ")
if user_input.strip().lower() in ['quit', 'exit']:
break
if user_input.strip():
assistant.send_hex(user_input)
except KeyboardInterrupt:
print("\n[系统] 捕获中断信号,正在退出...")
finally:
assistant.close()
--- 已进入 Hex 交互模式 (输入 quit 退出) ---
请输入要发送的 Hex 数据 (如: AA 55 01 02 或 0xA0 0x11):
> 01 03 00 00 00 02 C4 0B
[11:21:04.102] [TX 发送 8B]: 01 03 00 00 00 02 C4 0B
[11:21:04.135] [RX 接收 9B]: 01 03 04 00 19 00 00 5B 9E
> aa 55 01
[11:21:08.540] [TX 发送 3B]: AA 55 01
[11:21:08.572] [RX 接收 4B]: AA 55 01 00
六、常见问题排查与避坑指南
在实际串口项目落地时,开发者经常会遭遇几类隐蔽问题,以下是排查与解决方案:
在部分硬件固件(如 C/C++ 编写的单片机程序)中,若下位机误将接收缓冲当做
char* 字符串并使用 strlen() 计算长度,遇到 0x00(ASCII 的 NUL)就会判定为字符串结束。排查建议:检查单片机串口接收中断,确保以实际接收的字节计数值(DMA 计数或中断计数)为准,而非依靠字符串结束符。
如果串口助手打印出来的十六进制数据一会儿只有 2 个字节,一会儿又跟上一帧连在一起,这是典型的分包断帧失步。解决措施:(1)对于固定帧结构协议(如 Modbus),先解析帧头与长度字段,收满指定长度再投递显示;(2)对于不定长协议,采用时间间隔断帧算法,保证每包数据接收间隔超过 3.5 个字符时间后才确认断包。
在计算例如 Modbus RTU 的 CRC16 时,计算结果为一个 16 位的整数(如
0xC40B)。在转换为 Hex 字节流发送时,必须确认设备要求的是低字节在前(小端,如 b'Ä')还是高字节在前(大端,如 b'Ä')。在 Python 中建议使用 struct.pack('<H', crc)(小端)或 struct.pack('>H', crc)(大端)进行转换,避免手动移位产生错误。
七、总结
在 Python 串口助手中实现十六进制收发与显示,本质并不复杂。核心要领可以凝练为两句话:
- 发送管线:“先清洗,再补齐,
bytes.fromhex()转字节,write()写入缓冲区”。 - 接收管线:“定时轮询查
in_waiting,间隔断帧保完整,bytes.hex(' ').upper()转十六进制可视化”。
只要严守 bytes 数据通道,避免对二进制协议帧执行任何文本编码转换,即可实现高可靠、零丢包、全透传的专业串口调试工具。
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









