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.h 和 user.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_size 和 user_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 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文件。如果你在嵌入式环境里用,建议把内存分配器换成静态池,避免动态分配带来的不确定性。