3、字节序与位操作:大端小端、位掩码、移位操作,Python中的struct模块
做广播数据解析,说白了就是在跟字节打交道。你从空中抓下来一包数据,里面每个bit都有它的使命。但这里有个坑——同样的字节,不同的解读方式,结果天差地别。
我记得刚入行那会儿,调一个温湿度传感器,明明文档说温度在第3~4字节,我读出来却是乱码。折腾了半天,才发现是字节序搞反了。嗯,从那以后,我每次看协议文档,第一件事就是确认:这玩意儿是大端还是小端?
3.1 大端与小端:谁先谁后?
先讲个最直观的理解。你想想看,我们写数字"1234",左边是高位,右边是低位。这叫大端序——高位字节在前。
但有些处理器偏不,它要把低位字节放在前面。比如0x1234,它存成0x34 0x12。这就是小端序。
核心区别一句话:
- 大端(Big-Endian):高位字节在低地址,符合人类阅读习惯
- 小端(Little-Endian):低位字节在低地址,符合CPU处理习惯
我在项目中遇到过最典型的场景:BLE广播包。蓝牙核心规范里,大部分多字节字段都是小端序。比如广播信道PDU里的长度字段、UUID、公司ID,统统小端。但有些私有协议偏要用大端,你说气不气人?
| 场景 | 常见字节序 | 举例 |
|---|---|---|
| BLE广播包 | 小端 | UUID、公司ID、长度字段 |
| TCP/IP协议栈 | 大端(网络字节序) | IP地址、端口号 |
| Modbus RTU | 大端 | 寄存器值、CRC |
| Intel x86架构 | 小端 | 本地内存存储 |
| ARM架构(可配置) | 默认小端 | 多数嵌入式系统 |
避坑指南:我曾经在解析一个私有广播协议时,文档里只写了"数据格式遵循标准网络字节序"。结果我按大端解析了三天,怎么都对不上。最后翻出逻辑分析仪抓波形,才发现人家说的"标准"是BLE标准——小端!所以,别信文档,信实测。
3.2 位掩码与移位操作:庖丁解牛
广播数据里,很多信息不是整字节存的。比如一个字节8个bit,可能前3bit表示类型,后5bit表示长度。这时候怎么办?位掩码 + 移位就是你的手术刀。
3.2.1 位掩码:只取我想要的
位掩码,说白了就是一个二进制"筛子"。你想取哪几位,就把对应的bit置1,其他置0。
# 假设一个字节 data = 0b10110110
# 我想取低4位(bit0~bit3)
mask = 0b00001111 # 0x0F
result = data & mask # 结果:0b00000110
# 我想取高2位(bit6~bit7)
mask = 0b11000000 # 0xC0
result = (data & mask) >> 6 # 先与,再右移6位
我个人习惯把常用的掩码定义成常量,比如:
MASK_LOW_NIBBLE = 0x0F # 低半字节
MASK_HIGH_NIBBLE = 0xF0 # 高半字节
MASK_BIT0 = 0x01
MASK_BIT1 = 0x02
MASK_BIT2 = 0x04
MASK_BIT3 = 0x08
# ...以此类推
小技巧:在Python里,你可以用 bin() 函数把结果转成二进制字符串来调试。比如 bin(data & mask),一眼就能看出对不对。
3.2.2 移位操作:对齐到正确位置
取出来之后,往往还要右移到最低位,才能得到真正的数值。反过来,组装数据时要左移到正确位置。
# 解析场景:一个字节,高3bit是类型,低5bit是长度
raw_byte = 0b10110010
# 取类型(高3bit)
type_raw = (raw_byte & 0xE0) >> 5 # 0xE0 = 0b11100000
# 结果:0b101 = 5
# 取长度(低5bit)
length_raw = raw_byte & 0x1F # 0x1F = 0b00011111
# 结果:0b10010 = 18
# 组装场景:类型=3,长度=25,合成一个字节
type_val = 3
length_val = 25
combined = ((type_val & 0x07) << 5) | (length_val & 0x1F)
# 结果:0b01111001 = 0x79
我在项目中遇到过最经典的案例:BLE广播包中的AD Type和AD Data。每个AD Structure的第一个字节是长度(Length),第二个字节是类型(Type),后面是数据(Data)。但有些私有协议会把类型和长度压缩到一个字节里——这时候位操作就派上用场了。
3.3 Python struct模块:打包与解包
手动移位虽然灵活,但遇到多字节、复杂结构时,代码会变得又臭又长。这时候,struct模块就是你的救星。
3.3.1 基本用法
struct的核心就两个函数:pack() 和 unpack()。一个把Python数据变成字节流,一个把字节流变回Python数据。
import struct
# 打包:把整数、浮点数等变成字节
# 格式:'<H' 表示小端无符号短整型(2字节)
packed = struct.pack('<H', 0x1234)
# 结果:b'4\x12' (0x34, 0x12)
# 解包:把字节变回整数
unpacked = struct.unpack('<H', packed)
# 结果:(0x1234,)
格式字符串速查表:
| 格式符 | C类型 | Python类型 | 字节数 |
|---|---|---|---|
B |
unsigned char | int | 1 |
b |
signed char | int | 1 |
H |
unsigned short | int | 2 |
h |
signed short | int | 2 |
I |
unsigned int | int | 4 |
i |
signed int | int | 4 |
f |
float | float | 4 |
d |
double | float | 8 |
s |
char[] | bytes | 指定长度 |
3.3.2 字节序控制
struct的格式字符串,第一个字符控制字节序:
| 前缀 | 字节序 | 对齐方式 |
|---|---|---|
@ |
本地(默认) | 本地对齐 |
= |
本地 | 标准对齐 |
< |
小端 | 标准对齐 |
> |
大端 | 标准对齐 |
! |
网络序(大端) | 标准对齐 |
# 小端 vs 大端对比
import struct
data = 0x12345678
# 小端打包
little = struct.pack('<I', data)
# 结果:b'xV4\x12' (0x78, 0x56, 0x34, 0x12)
# 大端打包
big = struct.pack('>I', data)
# 结果:b'\x124Vx' (0x12, 0x34, 0x56, 0x78)
# 网络序(也是大端)
network = struct.pack('!I', data)
# 结果:b'\x124Vx'
注意:我曾经在解析一个BLE广播包时,用 struct.unpack('<H', ...) 解包UUID,结果总是对不上。后来发现,BLE的UUID虽然是2字节小端,但有些广播包里的UUID是16位完整UUID,有些是16位短UUID。前者用 !H(网络序),后者用 <H(小端)。所以,先确认协议版本,再选字节序。
3.3.3 实战:解析一个BLE广播包
假设我们收到一个BLE广播包,结构如下:
- 前导码:1字节(固定0xAA)
- 接入地址:4字节(固定0x8E89BED6)
- PDU头:2字节(小端)
- PDU负载:可变长度
- CRC:3字节
import struct
def parse_ble_packet(raw_bytes):
"""
解析BLE广播包(简化版)
"""
# 跳过前导码和接入地址(共5字节)
offset = 5
# 解析PDU头(2字节,小端)
pdu_header = struct.unpack_from('<H', raw_bytes, offset)[0]
offset += 2
# 从PDU头中提取PDU类型(高4bit)和长度(低8bit)
pdu_type = (pdu_header >> 12) & 0x0F
pdu_length = pdu_header & 0x00FF
# 解析PDU负载
pdu_payload = raw_bytes[offset:offset + pdu_length]
offset += pdu_length
# 解析CRC(3字节,小端)
crc = struct.unpack_from('<I', raw_bytes[offset:offset+3] + b'\x00')[0]
return {
'pdu_type': pdu_type,
'pdu_length': pdu_length,
'pdu_payload': pdu_payload,
'crc': crc
}
# 示例用法
raw = b'\xAA\xD6\xBE\x89\x8E\x0C\x00\x02\x01\x06\x03\x03\xAB\xCD\x12\x34\x56'
result = parse_ble_packet(raw)
print(result)
3.4 本章知识体系
下面这张图,帮你把本章的核心逻辑串起来:
3.5 实战建议
最后,给你几个我在项目中总结的实战建议:
- 先确认字节序:拿到协议文档,第一件事就是找"字节序"三个字。找不到?找"Endian"或"Byte Order"。还找不到?找逻辑分析仪抓包实测。
- 位掩码用十六进制:别写二进制,太长了。0x0F比0b00001111好读得多。我习惯用
0x0F、0xF0、0xFF这种。 - struct格式字符串先写字节序前缀:我每次写struct,第一件事就是加
<或>。不加?默认用本地字节序,换台机器就崩。 - 多用
unpack_from():别用unpack(),它只能从开头解。unpack_from()可以指定偏移量,配合循环解析多个字段,代码干净很多。 - 写单元测试:字节序和位操作最容易出边界问题。我每次写完解析函数,都会写几个测试用例:全0、全1、边界值、随机值。跑一遍,心里踏实。
我的小习惯:在代码里加一个 assert 来验证字节序假设。比如 assert struct.unpack('<H', b'\x01\x02')[0] == 0x0201。一旦平台变了,这个assert会第一时间告诉你——字节序不对!