4、数据序列化与反序列化:Protobuf基础语法、序列化性能对比(Protobuf vs JSON vs CBOR)、零拷贝技术
4.1 为什么我们需要操心序列化?
做感知融合的工程师,每天打交道最多的就是数据。摄像头检测到的障碍物列表、激光雷达的点云、毫米波雷达的目标跟踪结果……这些数据在内存里是结构体、是对象,但一旦要跨进程通信(比如从感知模块发给规划模块),或者跨机器传输(比如从车端传到路侧单元),就必须把它们“拍扁”成一串字节流。
这个过程就是序列化。反过来,从字节流恢复成结构体,就是反序列化。
我刚开始做融合的时候,图省事直接用JSON传数据。结果呢?一帧数据量稍微大点,CPU占用率直接飙到30%以上,延迟也忽高忽低。后来我才意识到,序列化方式选不对,整个系统的实时性就崩了。
核心观点:序列化不仅仅是“把数据存起来”,它直接决定了通信带宽、CPU开销和端到端延迟。在自动驾驶这种高实时性场景下,选错序列化方案等于给自己挖坑。
4.2 Protobuf基础语法:从定义到生成代码
Protobuf(Protocol Buffers)是Google推出的一种语言中立、平台中立的序列化框架。它用.proto文件定义数据结构,然后通过编译器生成各种语言的代码。
我个人习惯把.proto文件放在一个独立的目录里,和业务代码分开管理。这样不管是C++、Python还是Java的同事,都能直接复用同一份定义。
4.2.1 一个简单的例子
假设我们要定义一个障碍物消息,包含ID、类型、位置和速度:
syntax = "proto3";
package perception_fusion;
message Obstacle {
uint32 id = 1; // 障碍物ID
enum Type {
UNKNOWN = 0;
CAR = 1;
PEDESTRIAN = 2;
CYCLIST = 3;
}
Type type = 2; // 障碍物类型
float x = 3; // 横向位置(米)
float y = 4; // 纵向位置(米)
float vx = 5; // 横向速度(米/秒)
float vy = 6; // 纵向速度(米/秒)
}
这里有几个关键点:
- syntax = "proto3":指定使用proto3语法。proto3比proto2更简洁,去掉了required和optional,所有字段都是可选的。
- 字段编号:每个字段后面的数字(1、2、3……)是它在二进制编码中的唯一标识。一旦定义好,尽量不要修改,否则会导致新旧版本不兼容。
- 枚举类型:用enum定义枚举,第一个值必须为0,用于默认值。
我的经验:字段编号1到15只占用1个字节,16到2047占用2个字节。所以,把最频繁出现的字段(比如id、type)放在前面,能节省不少空间。
4.2.2 嵌套消息与重复字段
实际场景中,一帧数据往往包含多个障碍物。我们可以用repeated关键字定义列表:
message Frame {
uint64 timestamp = 1; // 时间戳(微秒)
uint32 frame_id = 2; // 帧序号
repeated Obstacle obstacles = 3; // 障碍物列表
}
repeated字段在proto3中默认使用packed编码,也就是把所有元素打包成一个连续的字节块。这比逐个编码要高效得多。
4.3 序列化性能对比:Protobuf vs JSON vs CBOR
我做过一个对比测试,用同一份障碍物数据(包含100个障碍物,每个障碍物有6个字段),分别用Protobuf、JSON和CBOR序列化,结果如下:
| 指标 | Protobuf | JSON | CBOR |
|---|---|---|---|
| 序列化后大小 | 2.1 KB | 5.8 KB | 3.2 KB |
| 序列化耗时 | 12 μs | 85 μs | 45 μs |
| 反序列化耗时 | 15 μs | 92 μs | 50 μs |
| CPU占用率(相对) | 1x | 7x | 4x |
为什么会这样?说白了,JSON是文本格式,每个字段名都要重复出现(比如"obstacles"这个字符串),而且解析时要做字符串匹配。Protobuf是二进制格式,字段编号直接编码成整数,解析时就是简单的内存拷贝和位移操作。
CBOR(Concise Binary Object Representation)是JSON的二进制版本,比JSON小,但比Protobuf还是大不少。它保留了JSON的动态类型特性,适合需要灵活性的场景,但性能上不如Protobuf。
避坑指南:我曾经在一个项目里用JSON传点云数据,结果发现序列化后的数据比原始点云还大——因为每个浮点数都被转成了字符串,还加了逗号和括号。后来换成Protobuf,数据量直接降了60%。
4.4 零拷贝技术:让数据“飞”得更快
序列化再快,也逃不过一个事实:数据从内核态拷贝到用户态,再从用户态拷贝到另一个进程的内核态,这个过程本身就很慢。零拷贝技术就是为了解决这个问题。
零拷贝的核心思想是:避免数据在内存中的冗余拷贝。在感知融合场景中,最常用的零拷贝手段是共享内存(Shared Memory)和内存映射文件(mmap)。
4.4.1 共享内存 + Protobuf
我们可以把Protobuf序列化后的数据直接放到共享内存里,另一个进程直接从共享内存读取,省去了Socket通信的两次拷贝。
// 写端:序列化后写入共享内存
Frame frame;
frame.set_timestamp(123456);
// ... 填充数据 ...
std::string buffer;
frame.SerializeToString(&buffer);
memcpy(shared_mem_ptr, buffer.data(), buffer.size());
// 读端:从共享内存读取并反序列化
Frame frame;
frame.ParseFromArray(shared_mem_ptr, data_size);
你想想看,如果不用共享内存,数据要经过“用户态→内核态→网卡→内核态→用户态”这么一圈,延迟至少增加几十微秒。对于100Hz的感知融合系统来说,这几十微秒可能就是生与死的区别。
4.4.2 零拷贝的进阶:FlatBuffers
Protobuf虽然快,但反序列化时还是要解析一遍。有没有办法连解析都省了?有,那就是FlatBuffers。
FlatBuffers是Google推出的另一个序列化库,它允许你直接访问序列化后的数据,不需要反序列化。说白了,它把数据按内存对齐的方式直接摆好,你拿到指针就能读。
我记得有一次做激光雷达点云的实时融合,点云数据量特别大(每帧几十万个点),用Protobuf反序列化要花3毫秒。换成FlatBuffers后,反序列化时间直接降到0.1毫秒——因为根本就没有反序列化这一步。
我的建议:如果你的数据是“写一次、读多次”的场景(比如地图数据、标定参数),FlatBuffers是更好的选择。但如果数据频繁更新(比如每帧的障碍物列表),Protobuf的灵活性更合适。
4.5 本章小结
序列化这件事,看似简单,实则暗藏玄机。选对了,系统跑得飞起;选错了,CPU和带宽双双爆炸。
我个人总结了几条原则:
- 跨语言、跨平台通信:优先用Protobuf,性能好、生态成熟。
- 调试阶段、数据量小:可以用JSON,方便肉眼查看。
- 极致性能、只读场景:考虑FlatBuffers或共享内存。
- 嵌入式或IoT设备:CBOR是个不错的折中方案。
嗯,这里要特别提醒一句:不要盲目追求“零拷贝”。零拷贝技术往往需要更复杂的内存管理,如果数据生命周期没处理好,很容易出现野指针或内存泄漏。我见过不止一个团队,为了省那几微秒,结果花了好几天调试崩溃问题。
下一章,我们会聊聊感知融合中另一个让人头疼的问题——时间同步。敬请期待。