Skip to content

序列化规范

本文档定义蓝牙协议 V2 中 Payload 的序列化与反序列化规范。

概述

协议 V2 支持三种序列化格式,根据传输通道和设备能力选择:

格式选择

格式适用场景特点
Protobuf蓝牙通道、支持 Protobuf 的设备体积小、解析快、类型安全
JSONMQTT 通道、浏览器/Web 应用可读性好、生态成熟、易于调试
原始字节资源受限的嵌入式设备无依赖、最小体积、手动解析

通道与格式对应

通道推荐格式说明
蓝牙 BLEProtobuf带宽有限,需要高效编码
MQTTJSON便于服务端处理和日志记录
HTTP/WebSocketJSON浏览器原生支持
资源受限设备原始字节无法引入 Protobuf 库时使用

格式协商

通信双方应在连接建立时协商使用的序列化格式。蓝牙通道通过特征值区分,MQTT 通过 Topic 后缀区分。

Protobuf 规范

版本要求

  • 使用 proto3 语法
  • 字段编号规范:1-15 用于高频字段(单字节编码),16+ 用于低频字段

基本类型映射

Protobuf 类型说明示例
uint32无符号 32 位整数用户ID、凭证ID
uint64无符号 64 位整数时间戳、事件ID
int32有符号 32 位整数偏移量、差值
bool布尔值开关状态
stringUTF-8 字符串名称、标签
bytes原始字节密钥、哈希值
enum枚举类型状态码、类型标识

字段规则

  1. 所有字段都是可选的(proto3 默认行为)
  2. 默认值不会被序列化,以减小消息体积
  3. 未知字段会被保留,确保向前兼容

序列化流程

发送方

原始数据 → Protobuf Message → 序列化 → 二进制数据 → 加密 → 传输
  1. 构造 Protobuf Message 对象
  2. 调用 SerializeToString() 或等效方法
  3. 对序列化后的二进制数据进行加密(参见加密规范)
  4. 通过蓝牙通道发送

接收方

传输 → 二进制数据 → 解密 → 反序列化 → Protobuf Message → 原始数据
  1. 从蓝牙通道接收数据
  2. 对数据进行解密
  3. 调用 ParseFromString() 或等效方法
  4. 访问 Message 对象中的字段

默认值处理

Proto3 中,未设置的字段使用类型默认值:

类型默认值
uint32 / int320
uint64 / int640
boolfalse
string"" (空字符串)
bytes空字节序列
enum第一个枚举值(通常为 0)
messagenull / 未设置

默认值语义

由于默认值不会被序列化,接收方无法区分"字段未设置"和"字段值为默认值"。协议设计时已考虑此特性:

  • 枚举类型的 0 值定义为 UNKNOWN 或无意义状态
  • 时间戳 0 表示"未设置"或"永久"
  • ID 为 0 表示"无关联"

枚举处理

定义规范

protobuf
enum UserRole {
  USER = 0;      // 默认值,普通用户
  ADMIN = 1;     // 管理员
  OWNER = 2;     // 拥有者
}
  • 第一个枚举值必须为 0
  • 0 值应为最常见或默认状态

未知枚举值

当接收到未知的枚举值时(如新版本添加的值):

  • 保留原始数值,不丢弃
  • 应用层可将其视为 UNKNOWN 处理
  • 转发时保持原值不变

嵌套消息

定义示例

protobuf
message Credential {
  uint32 credential_id = 1;
  CredentialType type = 2;
  ValidityPeriod validity = 3;  // 嵌套消息
}

message ValidityPeriod {
  uint64 valid_from = 1;
  uint64 valid_until = 2;
  Schedule schedule = 3;
}

处理规则

  • 嵌套消息未设置时,整个字段不存在(非空消息)
  • 判断嵌套消息是否存在:使用 has_xxx() 方法(语言相关)
  • 空嵌套消息(所有字段为默认值)与未设置不同

repeated 字段

定义示例

protobuf
message ListUsersResponse {
  uint32 code = 1;
  repeated User users = 2;  // 用户列表
}

处理规则

  • 空列表不会被序列化
  • 接收方将未设置的 repeated 字段视为空列表
  • 列表元素顺序会被保留

JSON 兼容

当使用 JSON 格式时,遵循 Proto3 JSON 映射 规范:

类型映射

ProtobufJSON示例
uint32number123
uint64string"1735689600000"
boolbooleantrue
stringstring"hello"
bytesbase64 string"SGVsbG8="
enumstring (枚举名)"ADMIN"
messageobject{"field": value}
repeatedarray[1, 2, 3]

uint64 使用字符串

由于 JavaScript 的 Number 类型无法精确表示 64 位整数,uint64int64 在 JSON 中使用字符串表示。

JSON 示例

Protobuf 定义:

protobuf
message User {
  uint32 user_id = 1;
  UserRole role = 2;
  uint64 created_at = 3;
  string nickname = 4;
}

JSON 表示:

json
{
  "userId": 1,
  "role": "ADMIN",
  "createdAt": "1735689600000",
  "nickname": "张三"
}

字段命名

  • Protobuf 使用 snake_case
  • JSON 使用 camelCase
  • 序列化库自动转换

原始字节序列化

对于资源受限、无法引入 Protobuf 库的嵌入式设备,可使用原始字节序列化格式。

适用场景

  • Flash/RAM 极度受限的 MCU
  • 无法移植 nanopb 等轻量库的平台
  • 需要最小化代码体积的 bootloader

编码规则

采用定长 + 变长混合编码,按字段顺序紧密排列:

类型编码方式长度
uint8直接存储1 字节
uint16小端序2 字节
uint32小端序4 字节
uint64小端序8 字节
bool0x00 或 0x011 字节
enumuint8 或 uint161-2 字节
string长度(uint16) + UTF-8 数据2 + N 字节
bytes长度(uint16) + 原始数据2 + N 字节

字节序

所有多字节整数使用小端序(Little-Endian),与大多数嵌入式平台一致。

消息结构

每条消息的原始字节格式:

+----------+----------+----------+----------+
| 字段1    | 字段2    | 字段3    | ...      |
+----------+----------+----------+----------+
  • 字段按 Protobuf 定义中的编号顺序排列
  • 无字段标签,完全依赖位置
  • 可选字段通过"存在标志位"处理

存在标志位

对于可选字段,在消息开头使用位图标记哪些字段存在:

+----------+----------+----------+----------+
| 标志位   | 字段1?   | 字段2?   | ...      |
+----------+----------+----------+----------+
  • 每 8 个可选字段使用 1 字节标志位
  • bit0 = 字段1存在,bit1 = 字段2存在,以此类推
  • 标志位为 0 的字段不占用空间

示例

User 消息定义:

protobuf
message User {
  uint32 user_id = 1;       // 必填
  UserRole role = 2;        // 必填
  uint64 created_at = 3;    // 可选
  string nickname = 4;      // 可选
}

原始字节布局:

+--------+----------+------+------------+----------+
| flags  | user_id  | role | created_at | nickname |
| 1 byte | 4 bytes  | 1 B  | 8 bytes?   | 2+N B?   |
+--------+----------+------+------------+----------+
  • flags: bit0 = created_at 存在, bit1 = nickname 存在
  • 必填字段始终存在,不计入 flags

编码示例(user_id=1, role=ADMIN, nickname="test"):

flags:      0x02        (bit1=1, nickname存在; bit0=0, created_at不存在)
user_id:    01 00 00 00 (uint32 小端序)
role:       01          (ADMIN=1)
nickname:   04 00       (长度=4, 小端序)
            74 65 73 74 ("test" UTF-8)

完整: 02 01 00 00 00 01 04 00 74 65 73 74 (12 字节)

C 语言示例

c
// 序列化
int encode_user(uint8_t *buf, const User *user, bool has_created_at, bool has_nickname) {
    int offset = 0;

    // 标志位
    uint8_t flags = 0;
    if (has_created_at) flags |= 0x01;
    if (has_nickname) flags |= 0x02;
    buf[offset++] = flags;

    // user_id (必填, uint32 小端序)
    buf[offset++] = user->user_id & 0xFF;
    buf[offset++] = (user->user_id >> 8) & 0xFF;
    buf[offset++] = (user->user_id >> 16) & 0xFF;
    buf[offset++] = (user->user_id >> 24) & 0xFF;

    // role (必填, uint8)
    buf[offset++] = user->role;

    // created_at (可选, uint64 小端序)
    if (has_created_at) {
        for (int i = 0; i < 8; i++) {
            buf[offset++] = (user->created_at >> (i * 8)) & 0xFF;
        }
    }

    // nickname (可选, 长度前缀字符串)
    if (has_nickname) {
        uint16_t len = strlen(user->nickname);
        buf[offset++] = len & 0xFF;
        buf[offset++] = (len >> 8) & 0xFF;
        memcpy(&buf[offset], user->nickname, len);
        offset += len;
    }

    return offset;
}

// 反序列化
int decode_user(const uint8_t *buf, User *user, bool *has_created_at, bool *has_nickname) {
    int offset = 0;

    // 标志位
    uint8_t flags = buf[offset++];
    *has_created_at = (flags & 0x01) != 0;
    *has_nickname = (flags & 0x02) != 0;

    // user_id
    user->user_id = buf[offset] | (buf[offset+1] << 8) |
                    (buf[offset+2] << 16) | (buf[offset+3] << 24);
    offset += 4;

    // role
    user->role = buf[offset++];

    // created_at
    if (*has_created_at) {
        user->created_at = 0;
        for (int i = 0; i < 8; i++) {
            user->created_at |= ((uint64_t)buf[offset++]) << (i * 8);
        }
    }

    // nickname
    if (*has_nickname) {
        uint16_t len = buf[offset] | (buf[offset+1] << 8);
        offset += 2;
        memcpy(user->nickname, &buf[offset], len);
        user->nickname[len] = '\0';
        offset += len;
    }

    return offset;
}

与 Protobuf 互转

服务端或网关设备负责格式转换:

设备端 (原始字节) <---> 网关 <---> 服务端 (Protobuf/JSON)

网关需要:

  1. 了解消息类型(通过指令码判断)
  2. 按照消息定义解析原始字节
  3. 构造对应的 Protobuf 消息
  4. 反向转换时同理

版本兼容

向前兼容(新版本读旧数据)

  • 新增字段使用新的字段编号
  • 新字段在旧数据中不存在,使用默认值
  • 枚举新增值时,旧版本将其保留为数字

向后兼容(旧版本读新数据)

  • 未知字段会被保留(proto3 默认行为)
  • 未知枚举值保留原始数值
  • 删除的字段编号不应重用

禁止操作

  • ❌ 修改已有字段的编号
  • ❌ 修改已有字段的类型
  • ❌ 重用已删除字段的编号
  • ❌ 修改枚举值的数值

代码示例

C(nanopb)

c
#include "message.pb.h"

// 序列化
User user = User_init_zero;
user.user_id = 1;
user.role = UserRole_ADMIN;

uint8_t buffer[128];
pb_ostream_t stream = pb_ostream_from_buffer(buffer, sizeof(buffer));
pb_encode(&stream, User_fields, &user);
size_t length = stream.bytes_written;

// 反序列化
User decoded = User_init_zero;
pb_istream_t istream = pb_istream_from_buffer(buffer, length);
pb_decode(&istream, User_fields, &decoded);

JavaScript

javascript
import { User, UserRole } from './message_pb.js';

// 序列化
const user = new User();
user.setUserId(1);
user.setRole(UserRole.ADMIN);
const bytes = user.serializeBinary();

// 反序列化
const decoded = User.deserializeBinary(bytes);
console.log(decoded.getUserId()); // 1

// JSON 互转
const json = user.toObject();

Python

python
from message_pb2 import User, UserRole

# 序列化
user = User()
user.user_id = 1
user.role = UserRole.ADMIN
data = user.SerializeToString()

# 反序列化
decoded = User()
decoded.ParseFromString(data)
print(decoded.user_id)  # 1

# JSON 互转
from google.protobuf.json_format import MessageToJson, Parse
json_str = MessageToJson(user)

最佳实践

  1. 字段编号规划

    • 1-15:高频字段(如 ID、状态)
    • 16-2047:普通字段
    • 预留编号空间给未来扩展
  2. 枚举设计

    • 0 值表示未知或默认状态
    • 按功能分组,预留数值空间
  3. 消息设计

    • 保持消息结构扁平,避免过深嵌套
    • 通用字段(如 code、message)放在前面
  4. 性能优化

    • 复用 buffer 减少内存分配
    • 批量处理时使用 repeated 而非多次请求

物联网设备通信协议文档