搞机器人开发和自动驾驶测试的,估计都经历过这个“至暗时刻”:熬夜录了三个小时的 ros2 bag,第二天要用,ros2 bag info直接报错,要么提示无法打开存储,要么播放到某个时间点突然退出。尤其是从 ROS 2 Humble 开始,mcap 已经成了默认录制格式,这类问题并不少见。mcap 比老款 sqlite3 格式更紧凑、更利于流式写入,但正因为是“流式 + 索引”结构,一旦录制进程异常终止、磁盘写满或者文件拷贝中断,很容易出现“数据还在,索引却坏了”的尴尬局面。
这篇文章不是纯理论科普,是我用 mcap bag 过程中,真遇到文件损坏后尝试过的修复方法和踩坑记录。会从诊断思路、命令行自动恢复、Python 脚本手动重构,到修复后的完整性验证都写清楚。适合一线机器人开发、SLAM 调试、自动驾驶数据采集的同学直接参考。如果你手头正好有一个打不开的 bag,这部分内容基本可以拿来抄作业。
1. mcap bag 为什么会“打不开”:格式设计与损坏成因
1.1 mcap 的文件骨架:数据、索引和门牌号
mcap 本质上是一串二进制记录流,文件从头到尾由不同类型的小记录拼接而成。每条记录开头都有一小块标头,写着这条记录的类型和长度,有点像盖楼时的“预制板”,一块一块拼起来。与老式 sqlite3 bag 不一样,mcap 不是为了“随机查表”设计的,而是为了“边录边写”设计的,速度更快,文件也更紧凑。
里面几个关键结构需要先认识一下。Header 是文件开头的固定标志,相当于集装箱箱号。Schema 记录保存消息类型定义,比如 sensor_msgs/msg/Image 的完整消息定义。Channel 记录描述一个话题,写上 topic 名字、消息编码格式,以及它关联的 Schema。Message 就是实际的消息数据本体。Chunk 是把一段时间内的多条消息打包成一个数据块,可以整体做 zstd 或 lz4 压缩,减少磁盘占用。
文件后半段还有 MessageIndex、ChunkIndex、Statistics、Footer。这些都属于“索引区”,作用类似仓库门口的货架清单。ChunkIndex 告诉你哪一段数据在文件的哪个位置,Statistics 统计消息总数和每个话题的消息数,Footer 则标注了索引区从哪里开始。
我习惯把 mcap 理解成仓库:Chunk 和 Message 是仓库里真实存放的货物,Summary 和 Footer 是贴在门口的货架清单。货物还在,清单丢了,我们还能按顺序把货一件件搬出来;如果货物本身被压坏了,那就真的只能看着办。理解这个结构,是后面所有修复手段的基础。
1.2 常见的损坏场景:从 kill -9 到磁盘写满
先说最常见的损坏原因,九成以上都是“非正常结束”。ros2 bag record 在录制过程中,数据是持续写进文件的,但索引区和结尾的 Footer 并不会在每条消息后都更新,而是等录制结束、进程收到 Ctrl+C 或者正常收尾时才统一落盘。如果你直接kill -9杀掉录制进程,或者机器人断电、系统崩溃,那么最后一个 Summary 和 Footer 根本没机会写进去。结果就是:文件里堆满了消息数据,但没有索引,文件尾部也没有正常结束标记,解析器打开时一头雾水。
第二个高频原因是磁盘空间不足。机器人上的磁盘剩余空间往往不够宽裕,录制大 topic 时瞬间写爆。磁盘写满时,chunk 可能只写入一半,操作系统返回写入失败,但 mcap 写入端不一定能及时感知,随后文件和实际磁盘状态就脱节了。这种损坏往往伴随 chunk 的 CRC 校验失败,比单纯缺索引更难处理。
第三个原因容易被忽视:文件拷贝中断或跨文件系统搬运。用 scp 传一个大文件传到一半断掉,文件尾部缺失;拿 U 盘从工控机拷到开发机,Windows 和 Linux 文件系统之间缓存机制不同,也可能导致最后一段内容没有真实落盘。我甚至见过程序员用微信文件传输助手传 40GB 的 bag,传完了一看,文件大小对,但打开就是报错。跨平台传大文件,最好打包成压缩包再传,别裸传。
1.3 “打不开”不等于“没数据”:修复思维的转变
很多同学一看到ros2 bag info报错,第一反应是“这个 bag 废了,要重新录”。实际上,绝大多数情况下数据区还是完好的,只是索引或者尾部标记丢了。打个比方:一本书被撕掉了目录和封底,书里每页文字其实还在。你只要按顺序翻,仍然能读完绝大部分内容。
所以修复的核心思路不是“恢复原文件”,而是“忽略损坏的索引,从数据区重新把消息捞出来,再重新打包成一份带完整索引的新 mcap”。一切修复工具和脚本,本质上都在做这件事。把这个思维转过来,你再看后面那些操作就会觉得顺理成章:备份是为了保护现场,诊断是为了判断“数据区和索引区到底谁坏了”,修复则是看情况选择自动工具还是手写脚本。
2. 修复前先诊断:损伤分级与工具选择
2.1 第一件事永远是备份与保护现场
我见过有人拿到损坏的 bag,上来就敲修复命令,结果工具把原始文件改写了,越修越坏,最后连抢救的余地都没有。正确的做法是:第一步先复制一份原始文件,所有操作都在副本上进行。
具体来说:
cp damaged.mcap /data/recovery/damaged_raw.mcap md5sum damaged.mcap /data/recovery/damaged_raw.mcap记录 md5 和后端文件大小,后续修复后的文件可以和这份原始大小做对比,判断到底丢了多少数据量。另外,如果这个 bag 是从别人那里拿来的,最好把原始文件保留在只读挂载的目录下,不要随便给它写权限。这不是小题大做,修复工具的行为有时候不可控,多留一份备份,你的试错空间就大很多。
2.2 体检组合拳:ros2 bag info 与 mcap doctor
诊断阶段,我会依次跑两条命令。第一条是:
ros2 bag info damaged.mcap这条命令能正常输出话题列表和消息统计,说明索引区大概率还能读,问题可能出在数据区某一段;如果直接报错,比如提示无法打开存储、无法读取记录,说明 Summary 或 Footer 已经损坏。这时不要慌,它只是说明索引没了,不代表数据全丢。
第二条命令用的是 mcap 官方 CLI 里的 doctor 子命令:
mcap doctor damaged.mcapmcap doctor会严格按照 mcap 格式规范,逐项检查文件头、chunk、消息索引、统计信息、尾部标记、CRC 校验。输出会直接指出缺了哪些部分,或者哪些 chunk 的 CRC 不对。这是整个修复过程中最有价值的一步,因为它能帮你判断该走哪条修复路线。拿到 doctor 的报告,你基本就知道问题是“缺索引”还是“数据区损坏”了。
2.3 症状、原因与修复路线对照表
我把日常工作中最容易碰到的几种情况整理成了一张对照表。表格不能替代mcap doctor的精确诊断,但可以帮你快速判断方向:
| 症状 | 可能原因 | 最合适的修复方式 |
|---|---|---|
| ros2 bag info 报错,无法打开存储 | Summary/Footer 未写入或损坏 | mcap recover 重建索引 |
| mcap doctor 提示 chunk CRC 错误 | chunk 写入中断或数据损坏 | 跳过该 chunk,保留其他消息 |
| 能打开,但播放到某时间点退出 | 某个 chunk 中间读取出错 | 时间范围裁剪,绕过损坏段 |
| 文件大小远小于录制日志的预期 | 拷贝中断或系统崩溃截断 | 从数据区抢救可读消息 |
| 能打开,但消息时间戳乱跳 | 消息内部时间戳异常 | 按时间戳过滤重写 |
如果你发现同时命中多行,那大概率是多重损伤叠加,修复难度会高一些。比如“文件尾部被截断”加“某个 chunk 也写坏”,那就得先抢救能读到的消息,再想办法跳过坏数据。这种时候,脚本方案比一条命令更灵活。
2.4 问录制人三个问题,省掉一半无用功
如果这份 bag 是团队里其他人录的,我建议在动手前先问清楚三件事。第一,录制结束方式是正常 Ctrl+C 还是直接 kill -9?第二,当时磁盘剩余空间大概还有多少?第三,这个文件后续是否经过网络或存储介质拷贝?
这三个问题基本对应前面说的三类损坏原因。如果录制人说是正常结束,但文件依然损坏,那大概率是拷贝环节出了问题,优先检查文件大小和尾部;如果他说是磁盘满了,那重点检查 chunk 完整性;如果他说拷贝过程中断过,那直接用 recover 往往能救回大部分数据。问清楚这几点,可以避免你在错误的方向上反复试错,省下大量时间。
3. 数据抢救实操:从一条命令到手工脚本
3.1 首选方案:mcap recover 重建索引
最简单、也最值得先试的方法,是用 mcap CLI 的 recover 子命令。它做的事情就是最核心的修复逻辑:忽略损坏的索引,从头到尾扫描文件里的合法记录,能读多少读多少,然后重新生成一份带完整索引的新 mcap 文件。
安装 mcap CLI 有两种常见方式。一种是直接从官方 GitHub 仓库的 Releases 页面下载对应系统的可执行文件;另一种是通过 Python 包安装:
pip install mcap安装完成后,先跑 doctor 确认损伤范围,然后执行恢复:
mcap doctor damaged.mcap mcap recover damaged.mcap repaired.mcaprecover 在执行时会打印一些处理日志,告诉你它读到了多少条记录、跳过了哪些坏区域。处理完以后,再跑一次 doctor 检查新文件,error 级别的问题应该清零。
需要说明的是,recover 不是万能药。如果某个 chunk 内部的压缩数据流已经坏了,它只能跳过这个 chunk,这个 chunk 里面的消息就会丢失。但绝大多数“进程被杀、尾部索引没写”的场景,数据区本身并没有真正损坏,recover 能把绝大部分消息原样捞回来。我实测过几次,在正常录制但被异常终止的 bag 上,救回率通常能到 95% 以上。
3.2 进阶方案:Python 脚本按消息流重写 bag
有时候 mcap recover 也会失效,或者你只想保留部分话题、部分时间段的数据,这时候就要自己写脚本。mcap 官方提供了 Python 库,安装方式很简单:
pip install mcap下面是一个我实际用过的恢复脚本骨架,思路是:用make_reader打开损坏文件时,明确告诉它不要依赖索引、不要校验 chunk CRC,然后顺序遍历所有消息,遇到能读到的消息就写入一个新的 Writer,最后重新生成完整索引。
from mcap.reader import make_reader from mcap.writer import Writer def recover_mcap(src_path, dst_path): reader = make_reader( src_path, use_chunk_index=False, # 不走索引,顺序读 verify_chunk_crcs=False, # 不校验 chunk CRC,尽量多读 ) writer = Writer(dst_path) writer.start() schema_map = {} channel_map = {} try: for schema, channel, message in reader.iter_messages(): if schema is None and channel is None: continue new_schema_id = None if schema is not None: new_schema_id = schema_map.get(schema.id) if new_schema_id is None: new_schema_id = writer.register_schema( schema.name, schema.encoding, schema.data ) schema_map[schema.id] = new_schema_id if channel is None: continue new_channel_id = channel_map.get(channel.id) if new_channel_id is None: new_channel_id = writer.register_channel( channel.topic, channel.message_encoding, schema_id=new_schema_id, metadata=channel.metadata, ) channel_map[channel.id] = new_channel_id writer.add_message( channel_id=new_channel_id, log_time=message.log_time, data=message.data, publish_time=message.publish_time, ) except Exception as e: print(f"warning: {e}") writer.finish()这段脚本有一个明显的限制:如果iter_messages()在读某个 chunk 时抛异常,生成器会中断,后面的数据全都会丢。所以这里更推荐搭配使用一个思路——先用 mcap recover 处理普通损坏,再用脚本做定向过滤。真到了 recovery 都不能handle的场景,你需要的就不是这种高层迭代 API,而是更底层的 record 流解析,按记录粒度捕获异常、跳过坏字节。相比起来,投入产出比不高,一般团队不需要碰那么深。
3.3 定向抢救:按时间范围裁剪和单话题筛选
有一种情况很典型:mcap doctor报告显示 35 分钟到 38 分钟之间的 chunk 损坏了,前面和后面的数据都正常。这时候就没必要整文件重建,可以只保留完好区间,去掉损坏窗口之间的数据。
在脚本里只加一个时间过滤条件就能做到。mcap 内部时间戳单位是纳秒,注意别把秒和纳秒搞混:
start_ns = int(35 * 60) * 1_000_000_000 end_ns = int(38 * 60) * 1_000_000_000 for schema, channel, message in reader.iter_messages(): if start_ns <= message.log_time <= end_ns: continue # 跳过损坏区间 # 其余逻辑同上,写入新文件如果你想按话题筛选,只救回个别重要 topic,就更简单。在循环里判断 channel.topic 就行。比如传感器融合算法最需要 /camera 和 /odom,但 /pointcloud 已经坏了,那就只把摄像头和里程计消息写进新文件,避免把无用的坏数据带到下游。
3.4 终极保底:重新播放再录制,别让数据真丢
如果文件损坏程度已经大到连按需抢救都做不了,手里又只剩这一份数据,还有一个“土办法”:把 bag 当作半录制数据源,播放到损坏点之前,同时重新录制一遍。
具体操作是:
ros2 bag play damaged.mcap --start 0 --duration 500上面这个命令会尝试从头开始播放前 500 秒的数据。播放到损坏 chunk 时进程可能会退出,但只要退出前已经把前 500 秒的数据发出来了,你就可以在另一个终端同时执行:
ros2 bag record /topic1 /topic2 -o recovered_from_play这个方案有两个问题。第一,消息时间戳不再是原始录制时间,而是按当前系统时间重新生成,下游算法如果对时间敏感会受影响。第二,播放过程是实时速度,500 秒的数据要等 500 秒。但好处是,它能绕过解析器层面的“拒读”,把数据区里还没坏的内容通过正常话题管道重新采集出来。属于兜底方案,能救一点是一点。
3.5 顺手而为的优化:压缩算法与归档习惯
修复文件时其实是个调整读取策略和压缩参数的窗口。mcap 文件里的 chunk 可以选择不压缩、zstd 或 lz4。zstd 压缩率更高,适合长期归档;lz4 速度更快,适合高频回放。如果你修的这份 bag 是要拿来做算法评测、长期保存的,建议在重写时显式指定 zstd。
在 Python Writer 里注册时,可以先设置全局压缩选项。比如 mcap 的 Writer 支持往Writer(...)传入压缩策略,部分版本在writer.start()之前通过writer.compression之类的属性配置。不同版本语法略有差异,最好查一下你安装的库的 API。如果不想折腾,也可以用 mcap CLI 自带的 convert 命令统一处理:
mcap convert repaired.mcap repaired_zstd.mcap --compression zstd这种顺手优化,能让修复后的文件在磁盘占用、读取性能之间取得一个更好的平衡。
4. 修复完成不等于结束:验证流程与常见问题
4.1 修复后的三道验证关卡
修复完的文件,不要急着直接跑算法,先过三道关卡。第一关是完整性检查:
mcap doctor repaired.mcap ros2 bag info repaired.mcapdoctor 没有 error,info 能正常列出话题和消息数,说明文件结构已经是合法的 mcap 了。
第二关是抽样播放。用ros2 bag play选几个关键话题播放 10 到 20 秒,确认不会中途退出,话题频率和消息内容基本正常:
ros2 bag play repaired.mcap --topics /camera /scan /odom第三关是转换验证。用ros2 bag convert把修复后的 mcap 转成 sqlite3 格式,这一步非常有用,因为转换过程相当于用另一种存储后端完整解析了一遍所有消息:
ros2 bag convert -i repaired.mcap -o repaired_db --storage sqlite3转换成功,且结果目录大小符合预期,基本可以放心使用。如果这三关哪一步有问题,说明数据区损坏比预想的严重,回到诊断阶段重新评估。
4.2 我用过的修复合集:典型问题与排查过程
先说一个很常见的坑:recover 成功后,ros2 bag info能打开,但某个 topic 的消息数明显偏少。这大概率是包含该话题的某个 chunk 损坏被跳过了。mcap 的默认 chunk 大小通常不大,损坏一个 chunk,影响的往往是一小段时间窗口的数据,前后数据都能保留。遇到这种情况,我一般直接接受损失,因为被跳过的消息确实无法恢复。
另一个问题是“修复后 topic 列表为空”。原因通常是数据区里的 Channel 记录也丢了。mcap 的 Message 记录只保存 channel_id,不带话题名,如果 Channel 记录找不回来,消息即使还在,也无法正确归类。这种情况除非你有原始 ROS 接口定义可以手工补 Channel,否则基本无解。所以看到这个现象,先确认一下原始文件是否在数据区也严重破损。
还有一次遇到跨平台拷贝导致的玄学问题:在 Windows 上用 WSL 录制的 bag,拷到 Linux 服务器上打开报错。后来发现是 WSL 挂载盘与 Linux 原生文件系统之间的缓存差异,导致文件尾部数据没完全刷盘。从那以后,我定了一条规矩:录制和拷贝全程在 Linux 原生文件系统上做,跨平台转移一定先 tar 再传。
4.3 长期主义:从源头降低 bag 损坏概率
修复再熟练,也不如从源头减少损坏。我现在的做法有三条。第一条,录制节点上预留磁盘空间,确保剩余空间至少是预计 bag 大小的两倍,同时在磁盘使用率超过 85% 的阈值时设置告警。第二条,录制结束用优雅方式退出,不要 kill -9,ros2 bag record 的 Ctrl+C 会触发正常的索引和 Footer 写入。如果需要在无人值守场景下自动停止,用超时参数或外部发送 SIGINT,而不是直接杀掉进程。
第三条,录制完成后立刻跑一次mcap doctor,有问题当场重录,而不是等两周后要用数据才发现损坏。这个检查动作可以写成一个简单的 post-record 脚本,bag 录制一结束就自动执行。另外,重要数据建议同时保存一份 mcap 原始文件和一份转好的 sqlite3 副本,两个文件同时损坏的概率,远比一个文件损坏的概率低得多。
最后再分享一个实际体会。碰到 mcap bag 损坏,我的第一原则永远是“先备份、再诊断、后拯救”。多数情况下,尾部索引丢失并不等于数据消失,mcap recover能救回绝大多数消息,真正救不回来的是那些 chunk 内部也写坏的数据。与其依赖事后修复,不如把完整性检查前置到录制流程里。团队里准备一个顺手的小工具脚本,录完就跑一下 doctor,发现问题马上处理,比任何修复方案都更省心。