从PX4转过来搞Apollo的时候,我对数据录制的理解还停在ulog那套——飞控芯片上跑,RAM抠到KB级别,日志格式必须极致紧凑。Apollo这边完全不一样,工控机算力富裕,存储空间按GB算,数据录制系统的设计哲学截然不同。
花了两个周末把modules/cyber/record/和modules/cyber/logger/的源码翻了一遍,记下来。

Record:不止是录像
Apollo的数据录制系统叫Cyber Record,核心思路是把所有channel上的消息序列化到文件里,后面可以反复回放。跟ROS的bag文件概念类似,但底层实现是另一套。
源码入口在cyber/record/record_writer.h和record_reader.h。RecordWriter和RecordReader都继承自RecordBase,共享channel列表、消息计数、消息类型查询这些基础接口。RecordBase持有Header信息和文件路径,读写两端共用同一套proto定义。RecordReader的构造函数直接接收文件路径,内部完成Open操作,用起来比Writer简单。
RecordWriter的用法很直接:
RecordWriter writer;writer.SetSizeOfFileSegmentation(0);writer.SetIntervalOfFileSegmentation(0);writer.Open("test.record");writer.WriteChannel("/test/channel1", "apollo.cyber.proto.Test", proto_desc);for (uint32_t i = 0; i < 100; ++i) {auto msg = std::make_shared<RawMessage>("abc" + std::to_string(i)); writer.WriteMessage(CHANNEL_NAME_1, msg, 888 + i);}writer.Close();SetSizeOfFileSegmentation和SetIntervalOfFileSegmentation控制文件分片。传0表示不分片。实际路测中通常设成2GB或60秒切一个文件,避免单个record文件过大不好管理。源码里HeaderBuilder定义了默认值:CHUNK_INTERVAL_是20秒,SEGMENT_INTERVAL_是60秒,CHUNK_RAW_SIZE_是16MB,SEGMENT_RAW_SIZE_是2GB。
写入是异步的。调WriteMessage之后不阻塞,消息先进内存里的ChunkBodyCache缓冲区,后台线程负责落盘。录制本身不应该拖累实时性,这个设计对路测场景很关键——你不想因为录制导致感知模块丢帧。
当缓冲区里的消息数或时间跨度达到chunk_interval阈值,Writer会触发flush,把当前chunk序列化写入文件,同时更新Header里的chunk_number和message_number。flush的时候先把ChunkHeader写进去,紧跟ChunkBody,再更新Header里的统计字段。如果配置了压缩(BZ2或LZ4),压缩发生在序列化之后、写入之前。LZ4压缩速度快但压缩率一般,BZ2压缩率高但CPU开销大。实车场景下我选LZ4,平衡压缩比和写入延迟。

文件格式:Section堆叠
Record文件的物理结构是一堆Section顺序拼接。每个Section由固定16字节的Section Header加变长Data组成。Header里前4字节是SectionType(int32枚举),后面跟着DataSize等字段。
Section类型五种,定义在cyber/proto/record.proto里:
SECTION_HEADER = 0SECTION_CHUNK_HEADER = 1SECTION_CHUNK_BODY = 2SECTION_INDEX = 3SECTION_CHANNEL = 4第一个Section固定是HEADER,占2048字节——不管实际Header结构多大,填满到2048。Header里存着版本号、压缩类型(NONE/BZ2/LZ4三种)、chunk_interval(默认20秒)、segment_interval(默认60秒)、chunk_raw_size(默认16MB)、segment_raw_size(默认2GB),以及index_position指向文件末尾的索引区。Header还记录了begin_time、end_time、message_number、size,以及is_complete标记——写完后置true,recover命令靠这个判断文件是否完整。
HEADER之后是CHANNEL Section,记录channel名和消息类型。比如name: "/apollo/perception/obstacles",type: "apollo.perception.PerceptionObstacles"。同时存了proto_desc——protobuf的FileDescriptorProto序列化数据,反序列化时用它重建消息类型。这也是为什么record文件能自描述——你不需要额外的proto文件就能解析内容。
然后是CHUNK_HEADER加CHUNK_BODY的配对。ChunkHeader记录这个chunk的起止时间戳、消息数量和raw_size。ChunkBody里是消息列表,每条SingleMessage包含channelName、time(纳秒级)和content(protobuf序列化的二进制数据)。
文件末尾是INDEX Section,记录所有Channel、ChunkHeader、ChunkBody在文件中的偏移位置。通过Header里的index_position可以直接跳转到这里。

这个设计为随机访问服务。你想读某个时间段某个channel的数据,不用从头扫文件。先读Header拿index_position,从Index里查对应Chunk的偏移,直接seek过去。RecordReader的ReadMessage方法支持begin_time和end_time参数,底层就是走这个索引链路。Reader还提供GetChannelList返回所有channel名,GetMessageNumber按channel查消息数,GetMessageType返回protobuf类型字符串。
cyber_recorder:命令行干活
实际录制不用写代码。cyber_recorder命令行工具覆盖了常用场景,源码在cyber/tools/cyber_recorder/,入口是main.cc,核心逻辑在recorder.cc。
录制:
cyber_recorder record -o test.record -c /apollo/sensor/lidar64 /apollo/perception/obstacles回放:
cyber_recorder play -f test.record --loop查看文件信息:
cyber_recorder info test.record还有split按时间或大小切割大文件,recover修复写了一半断电的损坏文件。split在处理几小时的录制数据时很实用,把大文件切成按场景或按时间段的小文件,分析时不用加载整个文件。recover则通过检查Header的is_complete字段判断文件是否正常关闭,没关闭的会尝试从INDEX区重建索引。
回放时有个实用参数-k,屏蔽某些channel。比如你录了一段数据,想用新算法重新跑感知,屏蔽掉原始感知channel,让新模块自己算:
cyber_recorder play -f sensor_rgb.record -k /perception/vehicle/obstacles /apollo/perception/obstacles回放时时间对齐自动完成。Record里的消息带着原始纳秒时间戳,play按时间戳顺序发布到对应channel,模拟原始数据流的时序。这一点对仿真调试特别重要——模块收到的消息时序必须和实车一致,否则复现不了问题。

GLOG:日志这把双刃剑
Apollo的日志系统建在GLOG(Google Log)上。源码在cyber/logger/,核心三个类:Logger、AsyncLogger、LogFileObject。
Logger继承自google::base::Logger,包装原生GLOG的写入接口,实现Write、Flush、LogSize三个方法。写的时候加了mutex保护,保证线程安全。
AsyncLogger是重点。它起独立线程,用双缓冲机制异步写日志。业务线程把消息塞进当前buffer,后台线程swap出buffer批量落盘。两块buffer交替使用,写入端永远不用等落盘完成。日志量再大也不卡业务,这个设计在实车场景下很必要,感知模块每秒打上千条日志是常态。如果同步写盘,IO等待会直接拉高消息延迟。双缓冲的好处是写入端只需要做一次指针swap,开销几乎可以忽略。坏处是进程崩溃时buffer里没来得及落盘的日志会丢,所以FATAL级别的日志会强制flush。
日志API极简:
AINFO << "INFO log";AWARN << "WARN log";AERROR << "ERROR log";AFATAL << "FATAL log";这些宏定义在cyber/common/log.h里,封装的是GLOG的LOG(INFO)到LOG(FATAL)。AINFO里还会自动加上文件名和行号,调试时定位代码很方便。

配置走环境变量,在cyber/setup.bash里:
export GLOG_log_dir=/apollo/data/logexport GLOG_alsologtostderr=0export GLOG_colorlogtostderr=1export GLOG_minloglevel=0GLOG_minloglevel控制最低输出级别,0全量输出。实车调试通常调到1只保留WARNING以上,不然磁盘很快就满。GLOG_alsologtostderr设为1时同时输出到终端和文件,调试初期很有用,能看到实时日志流。
FindModuleName这个函数值得单独说。它从日志消息里提取模块名——在消息文本中找方括号[],提取里面的内容作为模块名。如果没找到方括号,fallback到GlobalData::Instance()->ProcessGroup()拿进程组名。这个设计让日志能按模块路由到不同文件,info.log、warning.log、error.log分开存。实车跑起来十几个模块同时打日志,不分开根本没法看。

Dreamview:可视化回放
录好的record文件,最直观的查看方式是扔进Dreamview。源码在modules/dreamview/,本质是Web应用,后端C++起HTTP服务,前端React渲染。
用法三步:
aem bootstrap start # 启动Dreamviewcyber_recorder play -f demo_3.5.record --loop # 回放数据# 浏览器打开 localhost:8888车辆在地图上跑起来,感知的障碍物框、规划的轨迹线、定位信息,全部按时间戳对齐展示。左栏channel列表勾选要显示的数据通道,底部时间轴拖动定位到任意时刻,还能调回放速度,0.5倍速慢放看细节。
Dreamview不仅能看数据,还能做模块替换实验。你可以在回放record的同时启动自己的感知模块,屏蔽record里的原始感知channel,让你的模块处理原始传感器数据。算出来的结果直接在Dreamview里和原始数据叠加显示。这个工作流省去了实车测试的成本,算法迭代速度快了好几倍。
我调一个感知漏检问题的时候,靠Dreamview逐帧回放,发现某个时刻激光雷达点云有0.1秒的断裂。光看终端日志根本发现不了——AINFO里一切正常,但点云数据实际上缺了一帧。后来查到是录制线程在那个时刻被优先级更高的进程抢了CPU,导致丢了一帧点云消息。record里时间戳不连续,但日志没有任何报错。这种问题只有可视化回放才看得到。

Apollo Record vs PX4 ulog:两条路
做过PX4的人对ulog不陌生。两套系统解决同一个问题——把运行时数据存下来供事后分析——但设计取向完全不同。
ulog文件头固定16字节,7字节魔数0x55 0x4c 0x6f 0x67 0x01 0x12 0x35加1字节版本号加8字节时间戳。之后是Definitions区,用纯文本定义消息格式,比如sensor_combined:uint64_t timestamp;float[3] gyro_rad;float[3] accel_m_s2;。Data区二进制紧凑存储,一条传感器数据32字节搞定,文本格式能省70%空间。整个格式为嵌入式环境量身定做,RAM占用极小,SD卡写入开销低。PX4还支持日志加密,存成.ulge文件,用密钥对加解密。
Apollo Record走protobuf路线。每条消息SerializeToString,channel信息里存着proto descriptor。功能上确实丰富——任意protobuf消息类型都能存,schema自描述,跨语言解析方便。Python端调record.RecordWriter和record.RecordReader就能读写,不用编译C++。RecordReader的read_messages方法返回一个generator,迭代出每条消息的channel_name、data、datatype和timestamp,写离线分析脚本很顺手。代价是体积大,同样一段数据,record文件往往是ulog的好几倍。

争论一:protobuf Record vs 自定义二进制格式
我的看法是取决于应用场景。Apollo跑在x86工控机上,算力和存储不是瓶颈,protobuf的通用性和可读性价值更大。拿到一个record文件,不需要额外文档就知道里面有什么消息类型,schema是自描述的。换团队接手或用Python离线分析,protobuf库一加载就能解析。
嵌入式平台则不同。飞控芯片上每KB都值钱,protobuf序列化反序列化的计算开销和内存占用不容忽视。ulog用纯文本定义格式、二进制存储数据,既轻量又有一定可读性,px4的pyulog库解析起来也很方便。
Apollo选protobuf还有一个隐含原因:消息体系本身就是protobuf构建的,所有channel消息都是protobuf message。Record直接复用这套序列化机制,不需要额外维护格式定义,工程上省事。但代价是record文件动辄几个GB,路测一天下来几百GB,存储和传输都是负担。
在线调试 vs 离线分析:数据量的平衡术
实车路测的时候,你永远不知道哪段数据有用。我经历过一次场景:跑了一下午,回来发现某个变道场景下规划异常,但当时没注意到。30GB的record文件,翻找那个10秒钟的异常窗口,花了将近两个小时。
争论二:在线日志 vs 离线分析,怎么平衡?
在线调试的诉求很明确:实时看到问题,当场定位。但自动驾驶的数据量太大,10路摄像头加激光雷达,一分钟录制就是几个GB。全部在线分析不现实,CPU和带宽都扛不住。
Apollo的做法是分层。GLOG负责轻量级文本日志,按级别过滤,实时输出到终端和文件,查问题的时候先看日志。Record负责重量级消息数据,全量录制channel消息,事后做深度分析。Dreamview做可视化,连接在线和离线两端,把抽象的数据流变成能看到的车和路。
但中间有个断层。你看到AERROR报了一行错,想知道那个时刻的感知数据长什么样,得手动去record文件里找对应时间戳。GLOG的时间戳精度到秒,Record是纳秒,对不上的时候只能靠肉眼估算。
PX4在这点上反而更紧凑。ulog里传感器数据和控制状态在同一个文件里,时间戳统一,Flight Review平台拖时间轴就能看全部数据。代价是格式不够灵活,扩展新消息类型要改定义区,不像protobuf那样天然支持schema演进。
我的经验是:录制宁多勿少,磁盘便宜时间贵。但一定要养成标注习惯——遇到异常立刻记下大致时间,回来看record的时候有个锚点。Apollo没有内置场景标注功能,我自己的做法是在GLOG里打特殊标记字符串,用时间戳关联到record。
这篇就到这里。Record和日志看似简单,但它们是整个调试体系的基石。没有可靠的数据录制,所有算法问题都只能靠猜。
夜雨聆风