数据解析与序列化:字节序处理、自定义协议设计、Protobuf/FlatBuffers应用、CRC校验
做 BLE 开发,说白了就是在跟字节打交道。你发出去的是字节数组,收进来的也是字节数组。怎么把这些原始字节变成有意义的业务数据?这就是我们今天要聊的核心问题。
我刚开始做 BLE 项目时,就踩过一个坑。当时对接一个心率设备,文档里写的是小端序,我习惯性地按大端序解析了。结果心率值显示 180,实际只有 45。嗯,从那以后,字节序这件事我再也不敢马虎了。
字节序处理:大端与小端的抉择
字节序,也叫端序。说白了就是多字节数据在内存里怎么排列。大端序(Big-Endian)把高位字节放在低地址,小端序(Little-Endian)则相反。
在 BLE 协议里,大部分数据使用小端序。比如蓝牙核心规范里定义的 GATT 特征值,几乎都是小端。但有个例外——UUID 和某些厂商自定义数据可能用大端。你想想看,如果不搞清楚这个,解析出来的数据全是错的。
在 Kotlin 里处理字节序,我一般用 java.nio.ByteBuffer。它支持设置字节序,用起来很顺手。
fun parseHeartRate(data: ByteArray): Int {
val buffer = ByteBuffer.wrap(data)
buffer.order(ByteOrder.LITTLE_ENDIAN) // BLE 通常用小端
return buffer.getShort().toInt() // 读取 2 字节心率值
}
这里有个细节要注意:ByteBuffer.wrap() 默认是大端序。如果你不显式设置 order(),解析结果就错了。我曾经在代码 review 时发现同事漏了这行,帮他省了一次线上事故。
val BLE_BYTE_ORDER = ByteOrder.LITTLE_ENDIAN,所有解析统一引用。这样改起来也方便。
自定义协议设计:从零搭建通信规约
很多时候,标准 BLE 服务满足不了需求。比如你要传输自定义的传感器数据、控制指令,就得自己设计协议。我参与过一个工业项目,设备每 100ms 上报 20 多个参数,用标准特征值根本塞不下。
设计自定义协议,我总结了几个要点:
- 固定帧头:用 2 字节魔数(比如
0xAA 0x55)标识帧起始,接收方据此同步。 - 长度字段:1-2 字节表示 payload 长度,方便分包和校验。
- 命令字:1 字节区分不同功能,比如 0x01 表示温度数据,0x02 表示控制指令。
- 序列号:可选,用于去重和应答匹配。
- 校验:CRC 或累加和,保证数据完整性。
- 帧尾:可选,但建议加,比如
0x0D 0x0A。
举个例子,一个简单的温湿度协议帧:
// 帧结构:帧头(2) + 长度(1) + 命令(1) + 数据(N) + CRC(2)
// 温度数据:命令 0x01,数据 4 字节(float)
// 湿度数据:命令 0x02,数据 4 字节(float)
fun encodeTempHumidity(temp: Float, humidity: Float): ByteArray {
val payload = ByteArray(8)
val buffer = ByteBuffer.wrap(payload).apply {
order(ByteOrder.LITTLE_ENDIAN)
putFloat(temp)
putFloat(humidity)
}
val frame = ByteArray(2 + 1 + 1 + 8 + 2)
var idx = 0
frame[idx++] = 0xAA.toByte() // 帧头
frame[idx++] = 0x55.toByte()
frame[idx++] = 8 // 长度
frame[idx++] = 0x01 // 命令(温湿度合并上报)
payload.copyInto(frame, idx)
idx += 8
val crc = calculateCRC16(frame, 0, idx) // 计算 CRC
frame[idx++] = (crc and 0xFF).toByte()
frame[idx] = (crc shr 8).toByte()
return frame
}
Protobuf 与 FlatBuffers:结构化数据的序列化方案
当数据字段越来越多,手动拼字节就变得很痛苦。这时候就该上序列化工具了。BLE 开发里常用的有两种:Protobuf 和 FlatBuffers。
Protobuf 是 Google 的序列化库,压缩率高,适合带宽有限的 BLE 场景。但它的解析需要拷贝整个数据包,延迟稍高。
FlatBuffers 也是 Google 的,但设计思路不同。它支持零拷贝解析,你直接访问底层字节,不需要反序列化。这对实时性要求高的场景很友好。
我个人习惯:如果数据量小(几十字节以内),用 Protobuf 就够了。如果数据量大或者需要频繁解析,比如每秒几十次上报,我会选 FlatBuffers。
来看一个 Protobuf 的例子。假设我们定义了一个传感器数据消息:
// sensor.proto
message SensorData {
uint32 timestamp = 1;
float temperature = 2;
float humidity = 3;
uint32 battery = 4;
}
在 Kotlin 里使用:
// 编码
val data = SensorData.newBuilder()
.setTimestamp(System.currentTimeMillis() / 1000)
.setTemperature(25.3f)
.setHumidity(60.5f)
.setBattery(85)
.build()
val bytes = data.toByteArray() // 直接发送这个字节数组
// 解码
val parsed = SensorData.parseFrom(receivedBytes)
Log.d("BLE", "温度: ${parsed.temperature}")
FlatBuffers 的用法稍微复杂一点,但性能更好。我参与过一个运动手环项目,加速度数据每秒 100 组,每组 6 个 float。用 Protobuf 解析一次要 2ms,换成 FlatBuffers 后降到 0.1ms。差距很明显。
CRC 校验:确保数据在传输中没被篡改
BLE 的空中传输其实有 CRC,但那是在链路层。应用层的数据仍然可能出错——比如手机和设备的缓冲区不同步,或者中间有干扰。所以应用层再加一道 CRC 是很有必要的。
最常用的是 CRC-16,生成多项式是 0x8005。Kotlin 实现起来也不复杂:
fun calculateCRC16(data: ByteArray, offset: Int, length: Int): Int {
var crc = 0xFFFF
for (i in offset until offset + length) {
crc = crc xor (data[i].toInt() and 0xFF)
for (j in 0 until 8) {
crc = if ((crc and 0x0001) != 0) {
(crc shr 1) xor 0xA001
} else {
crc shr 1
}
}
}
return crc.inv() and 0xFFFF // 取反后返回
}
这段代码用的是 CRC-16/MODBUS 算法,很多 BLE 设备都用这个。注意最后要取反,不然和设备的校验值对不上。我曾经在这个细节上折腾了半天,最后发现是取反的问题。
校验时,接收方对帧头到数据部分重新计算 CRC,然后和帧尾的 CRC 比较。如果一致,说明数据完整。不一致,直接丢弃,请求重发。
知识体系总览
下面这张图总结了本章的核心逻辑。从原始字节到业务数据,每一步都有对应的技术方案。
从图中可以看到,原始字节数组经过字节序处理,进入三种不同的序列化方案。无论选哪种,最后都要过 CRC 校验这一关。这三步走完,数据才算真正可用。
好了,关于数据解析与序列化,核心内容就是这些。字节序别搞反,协议设计留好扩展,序列化工具按需选,CRC 校验别省略。做到这四点,你的 BLE 数据通信基本就稳了。
BleDataParser 类,所有协议解析都放在里面,这样调试和修改都方便。
公众号:蓝海资料掘金营,微信deep3321