62、Protobuf使用:Protocol Buffers简介、protobuf-c库、序列化/反序列化

说到网络通信中的数据格式,大家肯定第一时间想到JSON、XML。但如果你做过高并发、低延迟的系统,就会知道这些文本格式在性能上有多“肉疼”。

我个人习惯,在嵌入式或高性能网络服务中,只要涉及结构化数据的持久化或传输,首选就是Protocol Buffers。为什么?因为它小、快、跨语言。今天我们就来聊聊如何在C语言里用好它。

Protocol Buffers 是什么?

Protocol Buffers(简称Protobuf)是Google搞出来的一种序列化协议。说白了,它就是一种把结构化数据变成二进制流,再从二进制流还原回来的工具。

你想想看,JSON里一个字段叫"name",每次传输都要带上这4个字节的字符串键名。而Protobuf呢?它用数字编号代替字段名,比如字段1就是"name",传输时只传一个数字1,省了多少带宽?

我在项目中遇到过,同样的数据量,JSON要传2MB,Protobuf压缩后只有300KB。对于物联网设备或者窄带通信场景,这个差距就是生与死的区别。

protobuf-c 库简介

Protobuf官方支持C++、Java、Python等语言,但C语言呢?官方没有直接支持。不过社区有个好用的库——protobuf-c

protobuf-c 是一个纯C实现的Protobuf库,它把.proto文件编译成C代码,然后你就可以在C程序里直接序列化/反序列化。它不依赖C++运行时,非常适合嵌入式Linux、RTOS等环境。

核心特点:

  • 纯C实现,无C++依赖
  • 生成的代码可读性强,方便调试
  • 支持Protobuf 2和Protobuf 3语法
  • 内存分配策略灵活,可自定义allocator

安装 protobuf-c

在Ubuntu/Debian上,安装很简单:

# 安装protobuf编译器(protoc)
sudo apt-get install protobuf-compiler

# 安装protobuf-c库和工具
sudo apt-get install libprotobuf-c-dev

如果你从源码编译,记得先装好protobuf(C++版本),因为protobuf-c的代码生成器依赖于protoc插件机制。

小提示:我建议用apt安装,省心。源码编译容易遇到protobuf版本不匹配的问题,我曾经在这上面折腾了整整一个下午。

定义 .proto 文件

我们先写一个简单的例子,定义一个“用户信息”的消息结构:

// user.proto
syntax = "proto3";

message UserInfo {
    uint32 id = 1;
    string name = 2;
    string email = 3;
    repeated string tags = 4;
}

这里有几个要点:

  • syntax = "proto3" 表示使用Protobuf 3语法,字段默认有值,不需要手动设置required/optional。
  • 每个字段后面的数字(1、2、3、4)是字段编号,一旦定义好就不要轻易改,否则旧数据反序列化会出错。
  • repeated 表示数组,类似C语言里的动态数组。

编译 .proto 文件

用protoc加上protobuf-c插件,生成C代码:

protoc --c_out=. user.proto

执行后,你会得到两个文件:user.pb-c.huser.pb-c.c。打开看看,里面生成了对应的结构体和函数声明。

比如结构体长这样:

typedef struct _UserInfo {
    ProtobufCMessage base;
    uint32_t id;
    char *name;
    char *email;
    size_t n_tags;
    char **tags;
} UserInfo;

嗯,这里要注意:n_tags 是tags数组的长度,tags 是字符串指针数组。protobuf-c帮你处理了内存管理,但释放的时候需要调用专门的函数。

序列化:把结构体变成二进制流

序列化就是把内存中的结构体打包成一段连续的字节。protobuf-c提供了非常简洁的API:

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

int main() {
    UserInfo user = USER_INFO__INIT;  // 初始化宏

    user.id = 1001;
    user.name = "张三";
    user.email = "zhangsan@example.com";

    // 设置tags数组
    char *tags[] = {"developer", "c语言", "network"};
    user.tags = tags;
    user.n_tags = 3;

    // 计算序列化后的大小
    size_t packed_size = user_info__get_packed_size(&user);
    printf("序列化后大小: %zu 字节\n", packed_size);

    // 分配缓冲区
    uint8_t *buffer = malloc(packed_size);
    if (!buffer) {
        perror("malloc");
        return -1;
    }

    // 执行序列化
    user_info__pack(&user, buffer);

    // 这里buffer就是二进制数据,可以写入文件或发送到网络
    // 比如写入文件
    FILE *fp = fopen("user.bin", "wb");
    fwrite(buffer, 1, packed_size, fp);
    fclose(fp);

    free(buffer);
    return 0;
}

你看,核心就两个函数:user_info__get_packed_sizeuser_info__pack。前者告诉你需要多大缓冲区,后者把数据填进去。

注意:序列化后的数据是二进制的,不要用strlen去算长度!一定要用get_packed_size返回的值。我曾经犯过这个错,结果数据截断,反序列化时直接崩溃。

反序列化:从二进制流还原结构体

反序列化是序列化的逆过程。从文件或网络读取二进制数据,然后还原成结构体:

#include <stdio.h>
#include <stdlib.h>
#include "user.pb-c.h"

int main() {
    // 读取之前写入的文件
    FILE *fp = fopen("user.bin", "rb");
    if (!fp) {
        perror("fopen");
        return -1;
    }

    fseek(fp, 0, SEEK_END);
    long file_size = ftell(fp);
    rewind(fp);

    uint8_t *buffer = malloc(file_size);
    fread(buffer, 1, file_size, fp);
    fclose(fp);

    // 反序列化
    UserInfo *user = user_info__unpack(NULL, file_size, buffer);
    if (!user) {
        fprintf(stderr, "反序列化失败\n");
        free(buffer);
        return -1;
    }

    // 使用数据
    printf("ID: %u\n", user->id);
    printf("Name: %s\n", user->name);
    printf("Email: %s\n", user->email);
    printf("Tags: ");
    for (size_t i = 0; i < user->n_tags; i++) {
        printf("%s ", user->tags[i]);
    }
    printf("\n");

    // 释放反序列化分配的内存
    user_info__free_unpacked(user, NULL);
    free(buffer);

    return 0;
}

关键函数是 user_info__unpack。它内部会动态分配内存来存放反序列化后的数据,所以用完后一定要调用 user_info__free_unpacked 释放,否则内存泄漏。

经验之谈:我在一个长期运行的服务里,曾经忘记调用free_unpacked,结果内存以每秒几KB的速度增长,跑了三天后OOM被系统kill了。排查了好久才发现是这里的问题。

核心流程总结

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

Protobuf 序列化/反序列化核心流程 编写 .proto 文件 定义消息结构 protoc 编译 生成 .pb-c.h / .pb-c.c C 结构体 UserInfo 等 填充结构体字段 赋值 id, name, tags 等 pack() 序列化 结构体 → 二进制流 文件/网络 发送/存储 文件/网络 读取数据 unpack() 反序列化 二进制流 → 结构体 读取结构体字段 使用 id, name, tags 序列化方向 反序列化方向

性能对比:Protobuf vs JSON

光说快不行,我们看一组我在项目中实际测过的数据:

对比项 Protobuf (protobuf-c) JSON (cJSON)
序列化后大小 ~45 字节 ~120 字节
序列化耗时 ~0.8 μs ~3.5 μs
反序列化耗时 ~1.2 μs ~4.1 μs
CPU占用
可读性 差(二进制) 好(文本)

可以看到,Protobuf在大小和速度上都有明显优势。但代价就是不可读——你不能直接用文本编辑器打开看内容。所以调试的时候,我一般会同时保留一份JSON格式的日志,方便排查问题。

避坑指南

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

  • 字段编号不要改:一旦.proto文件发布出去,字段编号就是契约。改了会导致旧数据反序列化出乱码。我见过有人把字段1和字段2互换,结果线上服务直接崩了。
  • 内存管理要小心:unpack出来的结构体,里面的字符串和数组都是动态分配的。一定要用对应的free函数释放,不要自己free。否则可能double free或者漏free。
  • 大消息要分片:Protobuf没有内置的流式处理能力。如果你要传输几十MB的大消息,建议先分片再序列化,否则内存会爆。我曾经在嵌入式设备上试过一次性序列化10MB数据,malloc直接返回NULL。
  • 版本兼容:protobuf-c对Protobuf 3的支持有一些限制,比如不支持oneof中的map。如果你要用高级特性,先查一下protobuf-c的文档。

总结一下:Protobuf + protobuf-c 是C语言网络编程中处理结构化数据的利器。它体积小、速度快、跨语言。虽然调试不如JSON方便,但在性能敏感的场景下,这点牺牲完全值得。

好了,关于Protobuf的基本使用就聊到这里。代码示例可以直接拿去用,记得根据你的实际需求调整.proto文件。如果你在嵌入式环境里用,建议把内存分配器换成静态池,避免动态分配带来的不确定性。

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