6. Protocol Buffers:从零开始掌握 protobuf-c

说到序列化,大家可能都用过 JSON、XML。但在嵌入式领域,这些文本格式有个致命问题——太胖了。我早年做物联网项目时,一个传感器数据包用 JSON 要 200 字节,换成 Protobuf 后直接压缩到 30 字节。嗯,这就是我们今天要聊的主角。

6.1 Protobuf 是什么?

Protocol Buffers,简称 Protobuf,是 Google 搞出来的一种高效、跨语言的序列化方案。说白了,它就是把结构化的数据变成二进制流,反过来也能从二进制流还原出原始数据。

跟 JSON 比,Protobuf 有几个硬核优势:

  • 体积小:二进制编码,没有冗余的字段名和括号
  • 速度快:解析时不需要做字符串匹配
  • 强类型:字段类型在 .proto 文件中就定死了
  • 向后兼容:老程序可以读新数据,反之亦然

我在项目中遇到过最典型的场景:一个温湿度采集器,每 5 秒上报一次数据。用 JSON 时,一个月流量费多花了 300 块。换成 Protobuf 后,流量直接砍半。你想想看,对于 NB-IoT 这种按流量计费的场景,这省下来的可都是真金白银。

核心概念:Protobuf 不是一种编程语言,而是一种接口定义语言(IDL)。你写 .proto 文件定义数据结构,然后用编译器生成对应语言的代码。

6.2 protobuf-c 库的安装

在 C 语言生态里,最常用的 Protobuf 实现就是 protobuf-c。它轻量、无依赖,特别适合嵌入式环境。

安装方式很简单,我一般用源码编译:

# 1. 下载源码
git clone https://github.com/protobuf-c/protobuf-c.git
cd protobuf-c

# 2. 安装依赖(Ubuntu/Debian)
sudo apt-get install autoconf automake libtool

# 3. 编译安装
./autogen.sh
./configure --prefix=/usr/local
make -j4
sudo make install

# 4. 验证安装
protoc-c --version
# 输出类似:libprotoc 3.21.12

小技巧:如果你在交叉编译环境中使用,记得在 ./configure 时指定 --host=arm-linux-gnueabihf 这类参数。我曾经因为忘记指定交叉编译链,在开发板上跑了一下午才发现库链接错了。

安装完成后,你会得到两个关键工具:

  • protoc-c:编译器,把 .proto 文件转成 .h 和 .c
  • libprotobuf-c:运行时库,提供序列化和反序列化函数

6.3 定义 .proto 文件

.proto 文件是 Protobuf 的灵魂。我们来定义一个传感器数据包:

syntax = "proto3";

package sensor;

message SensorData {
  uint32 device_id = 1;      // 设备ID
  float temperature = 2;     // 温度,单位℃
  float humidity = 3;        // 湿度,单位%
  uint32 timestamp = 4;      // 时间戳,Unix时间
  enum Status {
    NORMAL = 0;
    ALARM = 1;
    ERROR = 2;
  }
  Status status = 5;         // 设备状态
}

这里有几个要点:

  • syntax:指定 proto3 语法,proto2 和 proto3 不兼容
  • package:命名空间,避免不同模块的 message 冲突
  • message:定义数据结构,类似 C 语言的结构体
  • 字段编号:等号后面的数字是字段的唯一标识,1-15 用 1 字节编码,16-2047 用 2 字节

注意:字段编号一旦确定,就不要改了。否则旧数据反序列化时会解析错误。我见过有人把字段 1 和字段 2 互换,结果线上服务全崩了——因为旧数据里的字段 1 被当成了字段 2 来解析。

6.4 编译 .proto 文件

写好 .proto 文件后,用 protoc-c 编译:

protoc-c --c_out=. sensor.proto

这条命令会生成两个文件:

  • sensor.pb-c.h:头文件,包含结构体定义和函数声明
  • sensor.pb-c.c:源文件,包含序列化/反序列化实现

打开头文件,你会看到类似这样的结构体:

typedef struct _SensorData {
  ProtobufCMessage base;
  uint32_t device_id;
  float temperature;
  float humidity;
  uint32_t timestamp;
  SensorData__Status status;
} SensorData;

注意,每个 message 都内嵌了一个 ProtobufCMessage 基类。这是 protobuf-c 实现反射和内存管理的基础。

6.5 在 C 代码中使用

编译出来的代码怎么用?来看一个完整的例子:

#include <stdio.h>
#include <string.h>
#include "sensor.pb-c.h"

int main() {
    // 1. 初始化消息
    SensorData data = SENSOR_DATA__INIT;
    data.device_id = 1001;
    data.temperature = 25.6f;
    data.humidity = 60.2f;
    data.timestamp = 1700000000;
    data.status = SENSOR_DATA__STATUS__NORMAL;

    // 2. 序列化:计算需要的缓冲区大小
    size_t size = sensor_data__get_packed_size(&data);
    uint8_t *buffer = malloc(size);

    // 3. 序列化:打包成二进制
    sensor_data__pack(&data, buffer);

    printf("序列化后大小: %zu 字节\n", size);

    // 4. 反序列化:从二进制还原
    SensorData *decoded = sensor_data__unpack(NULL, size, buffer);
    if (decoded == NULL) {
        fprintf(stderr, "反序列化失败\n");
        free(buffer);
        return -1;
    }

    // 5. 验证结果
    printf("设备ID: %u\n", decoded->device_id);
    printf("温度: %.1f℃\n", decoded->temperature);
    printf("湿度: %.1f%%\n", decoded->humidity);

    // 6. 清理
    sensor_data__free_unpacked(decoded, NULL);
    free(buffer);

    return 0;
}

编译时记得链接 protobuf-c 库:

gcc -o test test.c sensor.pb-c.c -lprotobuf-c -I.

关键函数说明

  • sensor_data__get_packed_size():计算序列化后的字节数
  • sensor_data__pack():把结构体打包成二进制流
  • sensor_data__unpack():从二进制流还原结构体
  • sensor_data__free_unpacked():释放反序列化时分配的内存

6.6 核心流程一览

为了让你更直观地理解整个流程,我画了一张图:

Protobuf 序列化/反序列化核心流程 1. 定义 .proto 文件 message SensorData { ... } 2. 编译生成 C 代码 protoc-c --c_out=. sensor.proto 3. 序列化 pack() → 二进制流 网络传输 / 文件存储 二进制数据流 4. 反序列化 unpack() → 结构体 5. 使用数据 data.temperature, data.status 定义阶段 编译阶段 运行阶段

6.7 避坑指南

最后,分享几个我踩过的坑:

  • 内存泄漏:每次调用 unpack() 都会分配内存,记得用 free_unpacked() 释放。我曾经在一个循环里忘了释放,跑了 3 天内存就爆了。
  • 字段默认值:proto3 中,所有字段都有默认值(数值型为 0,字符串为空)。如果你需要区分「没设置」和「设置为 0」,要用 optional 关键字。
  • 大端小端:protobuf-c 默认使用小端编码。如果你的嵌入式平台是大端(比如某些 PowerPC 芯片),记得在编译时加上 -DPROTOBUF_C_LITTLE_ENDIAN=0
  • 嵌套消息:如果 message 里嵌套了其他 message,记得先初始化子消息。否则序列化时会崩溃。

我的习惯:在嵌入式项目中,我会把 .proto 文件和生成的 .pb-c.c/.pb-c.h 一起提交到 Git 仓库。这样其他同事拉下来就能直接编译,不用每个人都装 protoc-c。而且,如果哪天 .proto 文件改了,对比生成的代码也能快速定位问题。

好了,Protobuf 的基础用法就讲到这里。记住,它的核心价值在于「定义一次,到处使用」。你只需要维护 .proto 文件,编译器帮你搞定所有语言的代码生成。这在多平台、多语言协作的项目中,能省下大量时间。


公众号:蓝海资料掘金营,微信deep3321