跳转至

日志、配置与序列化

难度:⭐⭐~⭐⭐⭐⭐ | 建议用时:2 周 | 前置要求:C++语言核心/错误处理与异常安全 错误处理、并发与系统编程/线程管理与互斥同步 线程同步、并发与系统编程/实时约束与高性能数据传递 实时约束、代码质量测试与调试 测试与质量工具


定位:本章讨论机器人 C++ 工程的运行时基础设施。

代码质量测试与调试 关注的是错误如何在开发阶段被测试、静态分析、Sanitizer 和调试器尽早暴露。 本章接着回答另一个问题:

当系统已经运行在真实机器人、数据集或长时间实验中时,如何让日志、参数、地图文件和轨迹数据可追踪、可复现、可演进。


本章学习目标

学完本章后,你应该能做到:

  1. 为 SLAM、感知和控制模块设计分层日志,而不是把所有信息写进一条字符串。
  2. 正确使用 spdlog 的 logger、sink、pattern、编译期过滤和异步队列。
  3. 区分开发日志、诊断日志、性能 trace 和实时路径日志的边界。
  4. 使用 C++20 std::source_location 理解传统日志宏的价值和局限。
  5. 为插件、算法模块和线程边界设计日志注入机制。
  6. 用 YAML 构建带默认值、类型检查、范围校验和配置层级的参数系统。
  7. 判断哪些参数可以运行时变更,哪些参数必须在模块初始化前冻结。
  8. 在 JSON、MessagePack、Protobuf、FlatBuffers、SQLite、PCD 和自定义二进制格式之间做工程取舍。
  9. 为地图、轨迹、关键帧和实验记录设计 schema 兼容策略。
  10. 把日志、配置和序列化纳入可测试的工程接口,而不是当成零散工具函数。

本章的核心思想是:

运行时基础设施不是”辅助代码”。

它决定系统失败时能否定位,实验结果能否复现,数据文件能否跨版本读取。

知识树

日志、配置与序列化
├── 运行时基础设施的定位(34.1)
│   └── 第二控制面:配置→证据→持久化→复现
├── 日志系统
│   ├── spdlog 基础(34.2):logger/sink/level/pattern
│   ├── source_location(34.3):调用点捕获
│   ├── 结构化日志(34.4):字段是接口
│   ├── 异步日志(34.5):队列边界和溢出策略
│   └── 日志注入(34.6):可测试的日志架构
├── 配置管理
│   ├── YAML 参数系统(34.7):默认值/类型/范围
│   ├── 配置层级(34.7.6):base/robot/dataset/CLI
│   └── 运行时参数变更(34.7.7):不可变快照
├── 序列化
│   ├── JSON / MessagePack(34.8)
│   ├── Protobuf(34.9):跨语言 schema
│   ├── FlatBuffers(34.10):零拷贝读取
│   ├── SQLite(34.11):增量保存与查询
│   └── PCD / 自定义二进制(34.11b)
└── 实验归档
    └── 运行目录布局(34.12)

前置自测

如果下面的问题答不出两题以上,建议先回顾 C++语言核心/错误处理与异常安全、并发与系统编程/线程管理与互斥同步、并发与系统编程/原子操作与内存模型、并发与系统编程/实时约束与高性能数据传递 和 代码质量测试与调试 的相关内容。

  1. C++ 异常安全中的“对象不变量”是什么意思?
  2. 为什么数据竞争不能靠“本机跑起来没问题”来证明不存在?
  3. 什么是热路径,为什么热路径中一次动态分配也可能造成长尾延迟?
  4. 代码质量测试与调试 中有限差分测试保护的是什么不变量?
  5. 为什么错误消息必须包含参数名、输入值和上下文,而不能只写 invalid argument

这些问题看似分散。 它们实际上会在本章汇合:

前置概念 本章使用方式
异常安全 配置加载失败后对象不能进入半初始化状态
线程同步 异步日志队列和运行时参数更新都跨线程
实时约束 控制循环和前端匹配热路径不能被日志和 IO 阻塞
测试不变量 参数校验、schema 兼容和序列化往返都要测试
诊断接口 日志字段和错误消息共同构成现场排查入口

回顾 代码质量测试与调试: 测试不是为了证明程序完全正确,而是为了让错误在代价最低的位置暴露。 本章把这个原则推进到运行时: 当错误已经发生时,日志和持久化数据必须保留足够证据,让下一次修复可以落回测试。


34.1 为什么日志、配置和序列化是机器人工程的“第二控制面” ⭐⭐

34.1.1 动机:算法正确不等于系统可运行

从一个真实痛点出发:你在实验室调好了一套 LiDAR-Inertial SLAM 系统,数据集上跑出了漂亮的轨迹。然后你把它部署到实际机器人上,前三次实验都正常。第四次实验时,系统在某个走廊拐角处突然发散——轨迹从建筑物穿出去了。你回到实验室想复现这个问题,却发现完全无法复现:同样的代码、同样的 launch 文件、同样的数据集,结果始终正确。

问题出在哪里?你不知道。因为你没有记录第四次实验时使用的实际参数(命令行覆盖了哪些?ROS 参数服务器运行时改了什么?);你的日志只写了 tracking okmapping done,无法确定哪一帧开始出问题、体素滤波后剩余多少点、ICP 残差是多少;你的地图文件只有点云,没有关键帧之间的约束关系,也没有 schema 版本号。

这个场景在机器人研究中极其常见。问题的根源不是算法弱——算法在其他三次实验中表现正确。问题的根源是**运行时证据链断裂**:你无法重建第四次实验的完整上下文。

日志、配置和序列化就是防止这种证据链断裂的基础设施。它们不是"辅助代码"或"工程美学",而是决定系统能否从失败中学习的关键能力。没有它们,每次失败都是一次性事件,无法积累为可靠性改进。

一个最小 SLAM 程序可以只包含三个部分:

传感器输入
前端匹配
后端优化
地图输出

这张图只描述了数据路径。 真实工程还有另一条路径:

参数如何进入系统
运行时发生了什么
结果如何保存
下次如何复现

这条路径不直接估计位姿。 但它决定了位姿估计坏掉后能不能被解释。

例如同一个 LiDAR-Inertial Odometry 算法,在数据集 A 上漂移 0.5%,在数据集 B 上发散。 如果没有参数快照,没有结构化日志,没有地图和轨迹的版本信息,排查会变成猜谜:

可能原因 没有基础设施时的困境
IMU 外参错误 日志里没有记录使用的外参矩阵
体素分辨率太大 地图文件只保存点,不保存生成它的参数
时间同步偏移 轨迹文件没有记录时间基准
回环阈值太松 回环日志只有“detect loop”,没有候选分数
代码版本不同 地图文件没有 schema 版本,也没有程序版本

日志、配置和序列化合在一起,形成运行时系统的“第二控制面”。

第一控制面是算法对机器人状态的控制:

观测 -> 状态估计 -> 控制或地图

第二控制面是工程对算法行为的控制:

配置 -> 运行证据 -> 持久化 -> 复现与诊断

二者缺一不可。 只有第一控制面,系统也许能跑。 没有第二控制面,系统不可维护。

34.1.2 反面失败:研究代码变成不可复现实验

考虑一个常见场景。

研究代码中有这样的常量:

constexpr double kVoxelSize = 0.4;
constexpr double kLoopScoreThreshold = 0.12;
constexpr int kMaxKeyframes = 2000;

第一次实验跑通后,研究者为了新数据集把它们改成:

constexpr double kVoxelSize = 0.25;
constexpr double kLoopScoreThreshold = 0.08;
constexpr int kMaxKeyframes = 5000;

然后重新编译。 几周后,某段轨迹效果很好。 但没人能确定它对应哪组常量。

再看日志:

tracking ok
mapping ok
loop detected
save map ok

这类日志能证明程序走过某些分支。 它不能解释系统为什么做出某个决策。

地图文件也可能只是一个点云:

map.pcd

它没有记录:

  1. 点云来自哪个数据集。
  2. 使用了哪组传感器外参。
  3. 使用了哪个坐标系约定。
  4. 关键帧如何连接。
  5. 生成程序的 schema 版本。

于是系统出现三个后果:

后果 具体表现
不可复现 同一数据集下次跑不出同样结果
不可诊断 失败时没有中间证据
不可演进 新版本程序无法读取旧地图,旧程序也无法忽略新字段

这不是算法弱。 这是工程证据链断了。

34.1.3 历史和演进:从 printf 到可观测系统

早期 C/C++ 程序常用 printf 调试。 这种方式简单直接:

printf("x = %f\n", x);

对单线程、短时运行、输入固定的小程序,printf 足够。

机器人系统的规模扩大后,问题变了。 程序不再只是一个主函数,而是多个线程、多个模块、多个传感器和长时间状态的组合。

日志系统逐渐演进出几个能力:

阶段 典型方式 解决的问题 新的边界
打印字符串 printf / std::cout 临时观察变量 无级别、无结构、无线程上下文
宏日志 ROS_INFO / 自定义宏 文件行号、级别过滤 依赖预处理器,封装困难
库日志 spdlog / glog / log4cplus sink、格式、异步、轮转 需要统一字段和边界
结构化日志 JSON line / key-value 可检索、可聚合 字段设计成为接口
可观测系统 log + metric + trace 长时间诊断和性能分析 实时路径必须隔离

配置也经历了类似演进:

阶段 典型方式 问题
硬编码常量 写在 .cpp 或头文件 换数据集要重编译
命令行参数 --voxel_size=0.3 参数多时不可读
INI / YAML / JSON 配置文件 需要校验和层级
参数服务器 ROS parameter server 运行时变更需要边界
配置快照 保存有效配置 复现实验必须依赖它

序列化的演进也来自规模压力:

阶段 典型方式 问题
文本文件 CSV / JSON 易读但大、慢、类型弱
二进制块 write(reinterpret_cast<char*>(&x)) 快但不可移植
Schema 格式 Protobuf / FlatBuffers 需要维护模式文件
数据库 SQLite 支持查询、增量写入,但引入事务设计
专用科学格式 PCD / HDF5 / rosbag 适合特定数据模型

这些工具不是为了让代码看起来更“工程化”。 它们是为了把运行时行为变成可查询、可验证、可保存的证据。

34.1.4 理论规则:三类基础设施对应三类不变量

可以把本章内容压缩为三类不变量。

基础设施 守护的不变量 典型测试
日志 关键事件必须带上下文,且不破坏实时路径 字段存在性、级别过滤、异步退化
配置 模块启动时看到的参数合法、完整、可追溯 默认值、类型错误、范围错误、层级覆盖
序列化 数据写出后能跨进程、跨版本、跨语言读取 往返测试、兼容测试、损坏文件测试

这三个不变量共同支撑复现。

复现不是只保存一个随机种子。 机器人复现至少需要:

  1. 输入数据。
  2. 程序版本。
  3. 构建选项。
  4. 有效配置。
  5. 运行环境。
  6. 关键日志。
  7. 输出数据。
  8. 数据 schema 版本。

缺其中任何一个,复现都可能变成“看起来类似”。

本质洞察:日志、配置和序列化的共同本质,是把隐式运行状态显式化。

隐式状态只能靠记忆和猜测。 显式状态可以被比较、测试、迁移和归档。

这三者之间还有一个更深层的关联。配置决定了系统"应该做什么"(意图),日志记录了系统"实际做了什么"(证据),序列化保存了系统"产出了什么"(结果)。当实验不可复现时,通常是这三者之间的某个环节断裂了——要么不知道用了什么配置(意图丢失),要么不知道系统中间经历了什么决策(证据丢失),要么输出文件无法被新版本程序读取(结果不可用)。

34.1.5 工程边界:不能把基础设施塞进热路径

基础设施有副作用。

日志可能写文件。 配置可能加锁。 序列化可能分配内存。 数据库可能 fsync。

这些动作都可能破坏实时性。

所以本章从一开始就区分两条路径:

路径 例子 允许做什么 禁止做什么
热路径 1kHz 控制、前端每帧匹配、IMU 预积分 轻量计数、固定容量缓冲、采样日志 阻塞 IO、动态分配、格式化大字符串
冷路径 初始化、建图保存、回放分析、诊断线程 文件 IO、数据库、完整日志、schema 迁移 影响热路径锁顺序

这条边界后面会反复出现。

如果不这样区分,会出现反事实后果:

  1. 为了排查 bug,在控制循环里加同步文件日志。
  2. 日志偶发阻塞 5ms。
  3. 控制周期从 1ms 变成 6ms。
  4. 机器人抖动。
  5. 你看到更多错误日志,于是继续加日志。
  6. 系统进入自我放大的诊断干扰。

所以工程规则是:

热路径只产生轻量事件。
冷路径负责格式化、写盘、上传和分析。

这个规则类似 并发与系统编程/实时约束与高性能数据传递 的实时约束。 控制循环不是不能记录信息。 它不能在记录信息时失去控制循环的时间上界。

34.1.6 代码验证:把“可复现”变成测试对象

最小可验证接口可以这样设计:

#include <filesystem>
#include <string>
#include <string_view>
#include <utility>

struct RunMetadata {
    std::string git_commit;
    std::string build_type;
    std::string config_hash;
    std::string schema_version;
};

class ExperimentRecorder {
public:
    explicit ExperimentRecorder(std::filesystem::path run_dir)
        : run_dir_(std::move(run_dir)) {}

    void writeMetadata(const RunMetadata& metadata);
    void writeEffectiveConfig(std::string_view yaml_text);
    void writeTrajectory(std::string_view trajectory_json);

private:
    std::filesystem::path run_dir_;
};

测试不需要真的跑 SLAM。 先测试证据链是否完整:

#include <gtest/gtest.h>

TEST(ExperimentRecorderTest, WritesReproducibilityArtifacts) {
    const auto dir = makeTemporaryDirectory();
    ExperimentRecorder recorder(dir);

    recorder.writeMetadata({
        .git_commit = "abc1234",
        .build_type = "RelWithDebInfo",
        .config_hash = "sha256:0011",
        .schema_version = "trajectory.v1",
    });

    recorder.writeEffectiveConfig("mapping:\n  voxel_size: 0.3\n");
    recorder.writeTrajectory(R"({"poses":[]})");

    // 这里验证的是生命周期边界:
    // recorder 析构后,文件仍应独立存在,不能依赖对象内存。
    EXPECT_TRUE(std::filesystem::exists(dir / "metadata.json"));
    EXPECT_TRUE(std::filesystem::exists(dir / "effective_config.yaml"));
    EXPECT_TRUE(std::filesystem::exists(dir / "trajectory.json"));
}

这个测试很小。 但它固定了一个重要承诺: 一次实验至少应留下元数据、有效配置和轨迹输出。

34.1.7 常见陷阱

⚠️ 工程陷阱:把日志当代码注释——写了大量日志但无法用于排查

错误做法:在关键分支写 LOG_INFO(“tracking ok”)LOG_INFO(“loop detected”),觉得日志”够多了”。

现象/后果:生产环境出现轨迹发散。翻看日志,只能看到一连串”ok”和”detected”,无法确定哪一帧开始偏移、候选分数多少、阈值是什么、为什么接受或拒绝。排查退化为猜谜。

根本原因:没有进行日志字段设计。日志不是给程序员看的”注释”,而是给排查工具解析的”事实记录”。一条缺少模块名、帧号、决策变量和阈值的日志,等于一份没有签名和日期的合同——有字但不能作为证据。

正确做法:每条关键日志至少包含 event=(事件类型)、module=(模块名)、frame_id=(帧号)、以及导致该决策的数值变量和阈值。格式化为 key-value 对,使其可被 grep 和日志分析工具检索。

自检方法:在运行 10 分钟后,用 grep “event=frame_dropped” slam.log | head -5 看能否直接提取丢帧原因。如果需要人工阅读上下文才能理解,字段设计不及格。

💡 概念误区:认为”保存了 YAML 文件”就等于”保存了有效配置”

新手想法:”我把配置文件和代码一起 commit 了,实验一定能复现。”

实际上:YAML 文件只是配置的”输入源”之一。真实系统的有效配置还受命令行覆盖、环境变量、ROS 参数服务器动态修改、默认值填充和多层配置合并的影响。保存 YAML 文件,只能证明”系统启动前准备了什么输入”,不能证明”系统实际使用了什么参数”。

为什么重要:假设 YAML 写了 voxel_size: 0.3,但启动时命令行覆盖为 --voxel_size=0.25,运行时参数服务器又改成 0.2。保存的 YAML 说 0.3,实际生效的是 0.2。复现失败时你会怀疑算法,其实只是配置不同。

正确做法:在系统初始化完成后、开始处理数据前,导出当前所有参数的快照到 effective_config.yaml。这个快照才是真正的有效配置。

🧠 思维陷阱:认为”日志越多越好”,不区分热路径和冷路径

新手想法:”bug 很难复现,我在每个关键函数都加上详细日志,下次一定能抓到。”

实际上:日志本身有副作用——文件写入、字符串格式化、锁竞争都会消耗时间。在 1 kHz 控制循环或 SLAM 前端匹配这样的热路径中,一次同步文件写入可能阻塞 5-20 ms,直接把控制周期从 1 ms 拉到 6-21 ms。更糟糕的是,加了日志后系统开始抖动,你看到更多错误,于是加更多日志——系统进入自我放大的诊断干扰循环。

正确思维:热路径只产生轻量事件(原子计数器递增、固定大小缓冲区写入);冷路径负责格式化、写盘、聚合和分析。日志量不是越多越好,而是”正确位置的正确信息”越多越好。回顾 并发与系统编程/实时约束与高性能数据传递:控制循环不是不能记录信息,而是不能在记录信息时失去时间上界。

自检方法:在热路径中搜索所有日志调用,确认没有同步文件写入、没有动态字符串构造、没有 std::endl 刷新。

34.1.8 练习

  1. 选一个你熟悉的 SLAM 系统(如 FAST-LIO、LIO-SAM),列出一次实验最少要保存的 8 类信息(提示:输入数据、程序版本、构建选项、有效配置、运行环境、关键日志、输出数据、schema 版本),并解释缺少任何一类会如何影响复现。
  2. 解释为什么”保存原始 YAML 文件”不等于”保存有效配置”。给出一个具体场景:YAML 写了 A,但实际生效的是 B,导致复现失败。
  3. 设计一个失败案例:系统轨迹发散,日志和地图文件中至少缺少哪三个字段会导致无法定位根因?画出你的排查决策树。
  4. 阅读一个开源 SLAM 系统的日志输出(如 FAST-LIO 的终端输出),评估其日志字段是否满足”事件、模块、帧号、决策变量”四要素。指出不足并给出改进建议。

34.2 spdlog:从字符串打印到可控日志系统 ⭐⭐

34.2.1 动机:日志不是"打印调试信息"——从生产环境出 bug 但无法复现说起

很多初学者把日志理解为"高级版 printf"——开发时加几行打印,发布前删掉或关掉。这个理解遗漏了日志最重要的价值:日志是系统在生产环境中唯一的可观测窗口

考虑一个真实场景:你的 SLAM 系统部署在一台送餐机器人上,每天运行 12 小时。某天机器人在一个走廊拐角处突然定位丢失,导致导航失败。你无法复现这个问题——同样的地图、同样的参数、同样的路径,系统始终正常。但你也无法忽略它——客户要求解释为什么机器人停了 30 秒。

此时你唯一能依赖的就是日志。如果日志只写了 tracking ok,你一无所获。如果日志记录了每帧的特征点数量、ICP 残差、匹配分数和决策阈值,你可以回溯到出问题的那一帧,看到"特征点从 800 骤降到 12,ICP 残差从 0.03 跳到 2.5,匹配分数低于阈值,系统丢帧"。从这条证据链出发,你可以推测原因(走廊拐角处 LiDAR 视角突变导致特征退化),并设计改进方案。

这就是结构化日志的核心价值:不是为了"调试时看看",而是为了**事后分析时能还原决策过程**。

在机器人系统中,一条有用日志应回答四个问题:

  1. 什么时候发生(时间戳,精确到毫秒)。
  2. 哪个模块发生(logger 名称,区分 Tracking、Mapping、LoopClosure)。
  3. 哪个数据对象或帧触发(帧号、关键帧 ID、传感器包序号)。
  4. 程序做出了什么决策(接受/拒绝、分数、阈值、原因)。

对比两条日志:

loop detected

和:

time=173000.120 level=info logger=LoopClosure frame_id=4812 candidate_id=102 score=0.081 accepted=true

第一条只能说明某处检测到回环。 第二条能支持查询:

  1. 哪一帧触发了回环。
  2. 候选关键帧是谁。
  3. 分数是多少。
  4. 是否被接受。
  5. 发生在哪个模块。

这就是日志系统的目标: 不是把字符串写出去,而是把运行时事件变成可筛选的事实。

34.2.2 反面失败:std::cout 在多线程 SLAM 中失效

最朴素的写法是:

std::cout << "tracking frame " << frame_id << " ok\n";

它的问题不只是“不高级”。

多线程场景下会出现:

tracking frame mapping keyframe 102 created
204 ok

两个线程输出交错后,人读不懂,程序也无法解析。

再看性能。 如果每帧都用 std::endl

std::cout << "frame " << frame_id << std::endl;

std::endl 会刷新缓冲区。 刷新可能触发系统调用。 系统调用可能阻塞。 阻塞会进入热路径。

如果日志写在前端线程,后果是:

阶段 原本耗时 加同步输出后
去畸变 1.0ms 1.1ms
体素滤波 2.5ms 2.7ms
ICP 8.0ms 8.5ms
每帧日志刷新 0ms 1-20ms 长尾

平均值可能只增加一点。 长尾延迟会破坏系统稳定性。

34.2.3 历史和演进:为什么 spdlog 适合现代 C++ 工程

spdlog 的核心特点可以概括为:

能力 工程价值
header-only 或编译库模式 集成成本低
基于 fmt 的格式化 类型安全、性能好
多 sink 控制台、文件、轮转文件可组合
同步和异步 logger 可按路径选择阻塞边界
pattern 格式 统一时间、线程、级别、logger 名
编译期级别过滤 低级别日志可以在编译期移除

它不是唯一选择。 glog、log4cplus、Boost.Log 和 ROS 日志都有各自生态。 但 spdlog 对现代 C++ 项目很常见,原因是接口轻、格式化强、性能可控。

类比一下:

std::cout 像手写临时实验记录。
spdlog 像实验室的统一记录系统。

前者适合快速观察。 后者适合多人、多模块、长时间实验。

34.2.4 理论规则:logger、sink、level 和 pattern

spdlog 的四个基本概念是:

概念 含义 类比
logger 日志入口,带名称和级别 一个模块的记录员
sink 输出目的地 控制台、文件、数据库出口
level 日志级别 信息重要程度和启用条件
pattern 输出格式 每条记录的统一模板

常见级别:

级别 用途 机器人示例
trace 极细粒度路径 每个特征点匹配尝试
debug 开发诊断 ICP 迭代残差
info 重要状态 加载配置、开始保存地图
warn 可恢复异常 跳过一个时间戳异常帧
error 当前操作失败 地图保存失败
critical 系统不可继续 必要传感器全部失效

一个基本配置如下:

#include <spdlog/sinks/rotating_file_sink.h>
#include <spdlog/sinks/stdout_color_sinks.h>
#include <spdlog/spdlog.h>

#include <memory>
#include <vector>

std::shared_ptr<spdlog::logger> makeSlamLogger() {
    auto console = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
    auto file = std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
        "logs/slam.log",
        10 * 1024 * 1024,
        5);

    std::vector<spdlog::sink_ptr> sinks{console, file};
    auto logger = std::make_shared<spdlog::logger>("SlamSystem", sinks.begin(), sinks.end());

    logger->set_level(spdlog::level::info);
    logger->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%l] [%n] [tid=%t] %v");

    // 生命周期边界:
    // logger 由 shared_ptr 管理,模块只保存 shared_ptr 或 weak_ptr。
    // 不要保存 sink 的裸指针,因为 sink 的释放顺序由 logger 管理。
    spdlog::register_logger(logger);
    return logger;
}

这里的 _mt 表示多线程 sink。 如果一个 logger 可能被多个线程调用,使用 _mt。 如果确定只在一个线程调用,可以使用 _st 降低锁开销。

但不要为了微小性能收益过早使用 _st。 日志入口一旦被跨线程使用,_st sink 会形成数据竞争。

34.2.5 编译期过滤:日志调用不等于运行时分支

日志级别有两层:

  1. 编译期是否保留调用。
  2. 运行时 logger 是否输出该级别。

spdlog 提供 SPDLOG_ACTIVE_LEVEL 控制编译期过滤。 典型做法是在编译参数中设置:

target_compile_definitions(slam_core PRIVATE SPDLOG_ACTIVE_LEVEL=SPDLOG_LEVEL_INFO)

然后使用宏:

auto logger = makeSlamLogger();

SPDLOG_LOGGER_DEBUG(logger, "residual = {}", residual);
SPDLOG_LOGGER_INFO(logger, "loaded {} keyframes", keyframe_count);

当 active level 是 INFO 时,SPDLOG_LOGGER_DEBUG 调用会在编译期被移除。 这和普通函数调用不同。 这里使用 SPDLOG_LOGGER_*,是因为前文创建的是具名 logger。 不带 LOGGERSPDLOG_DEBUG / SPDLOG_INFO 走的是默认 logger,适合全局默认日志器,不适合说明组件专属日志器。

如果写成:

logger->debug("residual = {}", expensiveResidualToString(residual));

即使运行时级别不输出,参数求值可能已经发生。 如果 expensiveResidualToString 很慢,就已经付出了代价。

正确边界是:

if (logger->should_log(spdlog::level::debug)) {
    // 只有 debug 级别实际启用时,才构造昂贵字符串。
    logger->debug("residual_detail = {}", expensiveResidualToString(residual));
}

这条规则在热路径尤其重要。

34.2.6 工程边界:日志级别不是”越详细越好”

日志级别的设计本质上是一种信息分层策略。它解决的问题是:不同的人在不同的时机需要看到不同粒度的信息。如果所有信息都以相同优先级输出,实际的效果是没有优先级——关键错误会被淹没在海量 info 中。这和机器人系统中控制层级的分层逻辑相同:规划层不需要知道每个电机的电流值,控制层不需要知道任务的目标语义。每一层只关心自己需要的信息粒度。

级别设计还有一个容易忽略的维度:不同级别对应不同的性能预算。trace 和 debug 级别通常只在开发环境启用,可以记录大量细节;info 和 warn 在生产环境常驻,必须控制输出频率;error 和 critical 必须无条件记录,因为它们代表需要立即处理的事件。

日志级别设计要和读者匹配。

读者 需要什么 对应级别
实验运行者 系统是否启动、加载了什么配置、输出在哪里 info
算法开发者 每帧残差、候选匹配、优化迭代 debug
性能分析者 阶段耗时和队列长度 info 或 trace 独立通道
现场维护者 传感器断连、文件失败、参数非法 warn/error
自动监控系统 可聚合字段和计数 结构化日志或 metric

如果把所有内容都写成 info,正常运行日志会被淹没。 如果把关键错误写成 debug,发布版本可能看不到。

级别规则可以这样定:

info: 描述生命周期事件和低频决策。
debug: 描述开发期需要的中间变量。
trace: 描述极高频内部细节,默认编译移除。
warn: 描述可恢复但需要注意的异常输入。
error: 描述一次操作失败。
critical: 描述系统级不可继续。

34.2.7 代码验证:日志不变量也能测试

日志代码常被认为“不值得测”。 但日志字段是诊断接口的一部分。 关键字段缺失会影响排查。

可以用自定义 sink 捕获日志:

#include <fmt/format.h>
#include <spdlog/sinks/base_sink.h>
#include <spdlog/spdlog.h>

#include <mutex>
#include <string>
#include <vector>

class CapturingSink final : public spdlog::sinks::base_sink<std::mutex> {
public:
    std::vector<std::string> messages() {
        // 线程边界:
        // base_sink 使用同一个 mutex_ 保护 log() 调用。
        // 读取 messages_ 时也必须拿同一把锁,不能另建一把锁。
        std::scoped_lock lock(this->mutex_);
        return messages_;
    }

protected:
    void sink_it_(const spdlog::details::log_msg& msg) override {
        spdlog::memory_buf_t buffer;
        formatter_->format(msg, buffer);
        // 线程边界:
        // base_sink 在进入 sink_it_ 前已经加锁,messages_ 在同一临界区内修改。
        messages_.push_back(fmt::to_string(buffer));
    }

    void flush_() override {}

private:
    std::vector<std::string> messages_;
};

测试:

#include <gmock/gmock.h>
#include <gtest/gtest.h>

TEST(LoggerTest, FrameLogContainsDiagnosticFields) {
    auto sink = std::make_shared<CapturingSink>();
    auto logger = std::make_shared<spdlog::logger>("Tracking", sink);
    logger->set_pattern("%v");

    logger->info("event=frame_done frame_id={} duration_ms={} features={}",
                 42,
                 8.3,
                 1280);

    const auto messages = sink->messages();
    ASSERT_EQ(messages.size(), 1);

    EXPECT_THAT(messages[0], ::testing::HasSubstr("event=frame_done"));
    EXPECT_THAT(messages[0], ::testing::HasSubstr("frame_id=42"));
    EXPECT_THAT(messages[0], ::testing::HasSubstr("duration_ms=8.3"));
    EXPECT_THAT(messages[0], ::testing::HasSubstr("features=1280"));
}

这个测试不验证 spdlog 本身。 它验证你的日志字段契约。

34.2.8 常见陷阱

⚠️ 编程陷阱:在热路径中调用 logger->debug(...) 而非编译期宏

错误做法:在 SLAM 前端每帧匹配中写 logger->debug("residual = {}", expensiveToString(residual)),认为"运行时级别是 info,debug 不会输出,没有开销"。

现象/后果:帧率偶发下降 2-5 fps。Profile 发现 expensiveToString 仍然在每帧被调用。

根本原因:C++ 函数调用是"先求值参数,再执行函数"。即使 spdlog 内部判断级别后不输出,参数 expensiveToString(residual) 已经完成了字符串构造。这和 Python 的惰性求值不同——C++ 函数参数总是急切求值的。

正确做法:使用编译期宏 SPDLOG_LOGGER_DEBUG(logger, "residual = {}", residual),当 SPDLOG_ACTIVE_LEVEL 高于 debug 时,整个调用在编译期被移除,参数求值不会发生。如果参数构造本身很昂贵,用 if (logger->should_log(spdlog::level::debug)) 显式保护。

自检方法:搜索热路径代码中所有 logger->debug / logger->trace 调用,检查参数是否包含函数调用或字符串拼接。

⚠️ 编程陷阱:多线程模块使用 _st(单线程)sink

错误做法:为了"减少锁开销",在多线程 SLAM 系统中使用 stdout_color_sink_strotating_file_sink_st

现象/后果:日志输出偶发出现行交错、乱码或文件损坏。问题不稳定,有时跑 100 次才出现一次。

根本原因_st sink 不加锁。当 Tracking 线程和 Mapping 线程同时向同一个 sink 写入时,内部缓冲区被并发修改,产生数据竞争。这不是"输出不好看"的问题——数据竞争在 C++ 中是未定义行为,可能导致崩溃。

正确做法:只要一个 logger 可能被多个线程调用,就使用 _mt sink。日志锁的开销远小于数据竞争的排查成本。只有在性能极端敏感且确认单线程使用的场景(如单独的诊断线程)才考虑 _st

34.2.9 练习

  1. 为 Tracking、Mapping、LoopClosure 三个模块设计 logger 名称和最小字段集合。说明为什么 logger 名应该用层级结构(如 SlamSystem.Tracking)而非平坦名称。
  2. 解释 logger->debug(...)SPDLOG_LOGGER_DEBUG(logger, ...) 在编译期过滤上的差异。在 Release 构建中,哪种写法能彻底消除参数求值开销?
  3. 写一个测试,验证 frame_dropped 日志必须包含 frame_idreasonqueue_size
  4. 设计一个实验:分别用 _st_mt sink 在两个线程中高频写日志,用 ThreadSanitizer 检测 _st 版本的数据竞争。

34.3 std::source_location 与传统日志宏 ⭐⭐

34.3.1 动机:日志必须指向调用点,而不是封装函数

当一个 SLAM 系统有 200 个源文件、50 个类、几百个日志调用时,一条日志如果只写了 "frame dropped" 而没有指明来自哪个文件的哪一行,排查时就需要全局搜索这个字符串——可能匹配到多个位置,每个位置的上下文不同,排查时间成倍增加。

日志中的文件名和行号不是装饰,而是"代码坐标"。它们让你能从日志直接跳转到源代码中产生这条日志的那一行——就像 GPS 坐标让你能直接定位到地球上的某一点一样。没有这个坐标,排查日志就像在没有地图的城市里找人。

但记录调用点有一个技术挑战:如果你把日志函数封装了一层(比如写了一个 MyLogger::info() 方法),__FILE____LINE__ 记录的是封装函数内部的位置,而不是真正调用 info() 的位置。这就是传统日志宏存在的根本原因——宏在预处理阶段展开,所以 __FILE____LINE__ 总是指向调用点。C++20 的 std::source_location 提供了一种不依赖宏的替代方案。

传统做法是宏:

#define LOG_INFO(logger, fmt, ...) \
    (logger)->info("[{}:{}] " fmt, \
                   __FILE__, \
                   __LINE__ __VA_OPT__(,) __VA_ARGS__)

这个宏可以捕获展开位置。 但宏也带来问题。

  1. 宏没有作用域。
  2. 宏参数可能被重复求值。
  3. 宏错误消息难读。
  4. 宏无法像普通函数一样被传递和重载。
  5. 封装层多时,宏和函数边界混在一起。

C++20 引入 std::source_location。 它把调用点信息变成一个普通对象。

34.3.2 反面失败:封装函数记录了错误位置

考虑一个普通函数封装:

void logInfo(const std::shared_ptr<spdlog::logger>& logger,
             std::string_view message) {
    logger->info("[{}:{}] {}", __FILE__, __LINE__, message);
}

void runTracking() {
    logInfo(getLogger(), "tracking started");
}

这里输出的文件和行号是 logInfo 函数内部位置。 不是 runTracking 的调用位置。

于是日志会把所有调用都指向同一行。 这对诊断几乎没有帮助。

宏能解决这个问题:

#define LOG_INFO_AT_CALL_SITE(logger, message) \
    (logger)->info("[{}:{}] {}", __FILE__, __LINE__, message)

但宏会把接口拉回预处理器。 如果想要类型安全封装、依赖注入和测试,就会不舒服。

34.3.3 历史和演进:从预处理器到类型化调用点

__FILE____LINE____func__ 来自 C/C++ 的传统编译环境。 它们是简单而强大的工具。

但现代 C++ 越来越强调:

  1. 普通对象。
  2. 类型检查。
  3. 可组合接口。
  4. 可测试封装。

std::source_location 的价值在于:

保留调用点捕获能力
  +
使用普通 C++ 对象传递位置

它不是完全替代宏。 它是把很多“为了调用点而不得不用宏”的场景收回普通函数。

34.3.4 理论规则:默认参数在调用点求值

关键写法如下:

#include <source_location>
#include <string_view>

void logInfo(std::string_view message,
             std::source_location loc = std::source_location::current());

调用:

void runTracking() {
    logInfo("tracking started");
}

默认参数 std::source_location::current() 在调用点求值。 所以 loc.file_name()loc.line() 指向 runTracking 里的调用行。

一个 spdlog 适配器可以这样写:

#include <source_location>
#include <memory>
#include <spdlog/spdlog.h>
#include <string_view>
#include <utility>

class Logger {
public:
    explicit Logger(std::shared_ptr<spdlog::logger> impl)
        : impl_(std::move(impl)) {}

    void info(std::string_view message,
              std::source_location loc = std::source_location::current()) {
        impl_->log(
            spdlog::source_loc{loc.file_name(),
                               static_cast<int>(loc.line()),
                               loc.function_name()},
            spdlog::level::info,
            "{}",
            message);
    }

private:
    std::shared_ptr<spdlog::logger> impl_;
};

这里有两个位置对象:

对象 来源 作用
std::source_location C++20 标准库 在普通函数接口中捕获调用点
spdlog::source_loc spdlog 类型 交给 spdlog 输出文件、行号、函数名

34.3.5 工程边界:格式化模板仍然需要小心

上面的 info(std::string_view) 很安全。 但它只支持已经构造好的字符串。

如果想支持格式化:

template <class... Args>
void info(fmt::format_string<Args...> fmt,
          Args&&... args,
          std::source_location loc = std::source_location::current());

这看起来直观,但 C++ 形参包和默认参数放在一起会很别扭。 实际工程常用两种方案。

方案 A:继续使用 spdlog 宏处理高频格式化日志。

方案 B:把位置对象放到一个显式包装中:

#include <fmt/core.h>
#include <source_location>
#include <spdlog/spdlog.h>
#include <memory>
#include <utility>

struct LogSite {
    std::source_location loc;

    static LogSite current(
        std::source_location loc = std::source_location::current()) {
        return LogSite{loc};
    }
};

template <class... Args>
void logInfoAt(const std::shared_ptr<spdlog::logger>& logger,
               LogSite site,
               fmt::format_string<Args...> fmt,
               Args&&... args) {
    logger->log(
        spdlog::source_loc{site.loc.file_name(),
                           static_cast<int>(site.loc.line()),
                           site.loc.function_name()},
        spdlog::level::info,
        fmt,
        std::forward<Args>(args)...);
}

调用:

logInfoAt(logger,
          LogSite::current(),
          "frame_id={} residual={}",
          frame_id,
          residual);

这多写了一点。 但它保持了类型安全格式化和调用点位置。

34.3.6 宏和 source_location 的关系

不要把二者理解成“新的一定替代旧的”。

更合理的关系是:

场景 推荐
极简日志调用,要求编译期过滤 spdlog 宏
封装成普通函数,保留调用点 std::source_location
需要跨 C++17 项目 宏或 spdlog 自带 source_loc
热路径低级别日志 宏加编译期过滤
组件接口和依赖注入 source_location 适配器

一句话:

宏擅长编译期删除和捕获展开位置。
source_location 擅长把调用点当普通对象传递。

34.3.7 代码验证:封装函数是否保留调用点

可以测试日志里是否包含调用文件名。 示意:

TEST(SourceLocationLoggerTest, CapturesCallerLocation) {
    auto sink = std::make_shared<CapturingSink>();
    auto spd_logger = std::make_shared<spdlog::logger>("test", sink);
    spd_logger->set_pattern("%s:%#:%! %v");
    Logger logger(spd_logger);

    logger.info("hello from caller");

    const auto messages = sink->messages();
    ASSERT_EQ(messages.size(), 1);

    // 这里验证调用点边界:
    // 文件名应指向测试调用处,而不是 Logger::info 的实现文件。
    EXPECT_THAT(messages[0], ::testing::HasSubstr("SourceLocationLoggerTest"));
    EXPECT_THAT(messages[0], ::testing::HasSubstr("hello from caller"));
}

这类测试可能受编译器输出路径影响。 所以真实项目中不建议断言完整路径。 断言文件名片段或函数名片段更稳。

34.3.8 常见陷阱

陷阱 现象 根本原因 正确做法
在封装函数里直接用 __FILE__ 所有日志指向封装层 宏在封装层展开 用宏包裹调用点或 source_location 默认参数
断言完整绝对路径 CI 不同机器失败 构建目录不同 只断言文件名尾部或函数名
把宏写得过复杂 编译错误难读 预处理器没有类型系统 复杂逻辑放普通函数
为所有日志都加位置 日志太长 低价值字段过多 error/debug 加位置,info 按需

34.3.9 练习

  1. 写一个 Logger 包装类,使用 std::source_location 记录调用点。
  2. 解释为什么 source_location 的默认参数必须放在对外接口上,而不是放在内部辅助函数上。
  3. 对比宏日志和函数日志在编译期过滤、测试、封装上的差异。

34.4 结构化日志:字段设计比字符串措辞更重要 ⭐⭐

34.4.1 动机:机器人日志最终会被查询

结构化日志和普通日志的区别,类似于关系数据库和纯文本文件的区别。纯文本文件可以存储信息,但无法高效查询;关系数据库通过固定 schema 让数据可以被 SQL 查询。结构化日志通过固定字段名让日志可以被 grep、awk 或日志分析工具查询。这不是"日志格式好看不好看"的问题,而是"日志能不能支撑事后分析"的问题。

如果把日志比作实验记录本,非结构化日志就像用自然语言随手写的笔记——写的人能看懂,换个人或过一个月就难以理解了。结构化日志则像标准化的实验报告模板——每个字段位置固定,任何人(包括程序)都能解析。

单机调试时,人可以直接读日志。 长时间实验时,人不会逐行读百万行日志。

你会做的是查询:

grep "event=loop_candidate" slam.log

或者统计:

每 1000 帧中有多少帧被丢弃
回环候选平均分数是多少
哪些 topic 的延迟超过阈值
哪个线程出现过 50ms 以上长尾

如果日志只是自然语言:

found a pretty good loop with old frame

程序很难稳定解析。

结构化日志的思想是:

event=loop_candidate frame_id=4812 candidate_id=102 score=0.081 accepted=true

字段比措辞重要。 字段是一种接口。

34.4.2 反面失败:自然语言日志无法聚合

这个问题可以类比数据库设计。如果一个数据库的所有列都是 TEXT 类型、没有固定 schema、每行的格式取决于写入它的那段代码,那么查询就只能靠全文搜索——效率低、容易遗漏。结构化日志相当于给日志"定义 schema":每种事件有固定的字段名和字段类型,查询可以精确到字段级别。这不是"规范强迫症",而是让日志从"人读的文本"升级为"程序可处理的结构化数据"。

假设项目中有三处日志:

drop frame 102 because timestamp
frame 103 skipped: queue full
bad frame 104, no imu

人能理解。 脚本很难可靠统计。

如果统一成:

event=frame_dropped frame_id=102 reason=timestamp_out_of_order queue_size=3
event=frame_dropped frame_id=103 reason=queue_full queue_size=32
event=frame_dropped frame_id=104 reason=missing_imu queue_size=0

统计就变得直接。

grep "event=frame_dropped" slam.log

再按 reason 聚合,就能知道主要失败来源。

34.4.3 历史和演进:从 syslog 到 JSON line

结构化日志并不新。 服务器领域很早就从自然语言日志演进到 key-value 和 JSON line。

机器人系统引入它稍晚,原因是:

  1. 单机实验阶段日志量小。
  2. ROS topic 和 rosbag 已经保存了大量数据。
  3. 研究代码更关注算法输出,不关注运行证据。

但当系统进入长期运行,日志就开始承担服务器系统中相似的责任。

区别在于机器人日志有更强的时间和空间语义:

字段类型 服务器系统 机器人系统
请求标识 request_id frame_id / keyframe_id / scan_id
用户标识 user_id robot_id / sensor_id
时间 wall time sensor time + steady time
位置 region pose / map_id / submap_id
决策 status code accepted/rejected + score + threshold

所以不能照搬服务器字段。 要按机器人数据流设计。

34.4.4 理论规则:字段分为身份、时间、状态、决策和成本

日志字段的设计本质上是在回答一个问题:"当事后需要排查问题时,我需要知道什么?"答案可以归纳为五类信息:是谁(身份)、什么时候(时间)、关于什么对象(数据)、做了什么决策(决策)、花了多少代价(成本)。

这五类字段的设计优先级不同。身份和时间字段是"框架"——没有它们,其他字段就无法定位上下文。决策字段是"核心"——它直接解释了系统为什么做出某个选择。成本字段是"性能探针"——它揭示了系统在资源消耗上的瓶颈。

一个实用分类:

字段类别 典型字段 作用
身份字段 run_idrobot_idmodulelogger 区分来源
时间字段 stamp_nssteady_msframe_id 对齐事件
数据字段 keyframe_idmap_idtopic 指向对象
决策字段 acceptedreasonthresholdscore 解释分支
成本字段 duration_msqueue_sizealloc_count 性能诊断
版本字段 schemaconfig_hashgit_commit 复现和兼容

可以把字段设计当成一张小型数据表。 如果一个字段将来会用于筛选、统计或关联,就应该独立出来。 如果它只用于人读,可以留在 message 里。

34.4.5 工程边界:不要把日志字段设计成无限字典

结构化日志也会失控。

常见坏例子:

event=tracking detail="{huge nested json with all points and descriptors}"

这会带来:

  1. 日志文件暴涨。
  2. 写入延迟变大。
  3. 查询成本变高。
  4. 隐私或安全字段泄漏。
  5. 真正重要字段反而被淹没。

边界规则:

数据 放日志吗 更合适位置
标量决策 结构化字段
小型向量 低频可放 字段或调试附件
大点云 不放 PCD、rosbag、数据库
图像 不放 图像文件或 rosbag
因子图完整结构 不放普通日志 Protobuf/FlatBuffers/SQLite
异常摘要 error 日志

日志像索引。 大数据文件像正文。 索引要指向正文,而不是把正文塞进索引。

本质洞察:结构化日志不是把所有数据都写成 JSON。

它是为“将来要问的问题”提前设计可查询字段。

34.4.6 推荐字段模板

Tracking 成功:

event=frame_done frame_id=812 stamp_ns=173000120000 duration_ms=9.4 features=1260 inliers=842 status=ok

Tracking 丢帧:

event=frame_dropped frame_id=813 stamp_ns=173000130000 reason=missing_imu imu_count=0 queue_size=5

回环候选:

event=loop_candidate frame_id=4812 candidate_id=102 score=0.081 threshold=0.090 accepted=false

后端优化:

event=optimizer_done graph_id=7 variables=1302 factors=5220 iterations=8 final_chi2=12.4 duration_ms=41.8

配置加载:

event=config_loaded path=config/kitti.yaml config_hash=sha256:8f12 source=cli_override schema=config.v2

地图保存:

event=map_saved path=output/map.fb schema=map.v3 keyframes=4210 landmarks=980321 duration_ms=620

34.4.7 代码验证:字段构造集中化

不要在每个调用点手写字段名。 容易出现拼写漂移:

frame_id
frameId
fid
frame

可以集中构造:

#include <fmt/format.h>
#include <string>

struct FrameLogFields {
    int frame_id = -1;
    long long stamp_ns = 0;
    double duration_ms = 0.0;
    int features = 0;
    int inliers = 0;
};

std::string formatFrameDone(const FrameLogFields& f) {
    return fmt::format(
        "event=frame_done frame_id={} stamp_ns={} duration_ms={:.3f} features={} inliers={}",
        f.frame_id,
        f.stamp_ns,
        f.duration_ms,
        f.features,
        f.inliers);
}

测试:

TEST(StructuredLogTest, FrameDoneUsesStableFieldNames) {
    const std::string line = formatFrameDone({
        .frame_id = 7,
        .stamp_ns = 123456,
        .duration_ms = 8.25,
        .features = 900,
        .inliers = 720,
    });

    EXPECT_THAT(line, ::testing::HasSubstr("event=frame_done"));
    EXPECT_THAT(line, ::testing::HasSubstr("frame_id=7"));
    EXPECT_THAT(line, ::testing::HasSubstr("stamp_ns=123456"));
    EXPECT_THAT(line, ::testing::HasSubstr("duration_ms=8.250"));
    EXPECT_THAT(line, ::testing::HasSubstr("features=900"));
    EXPECT_THAT(line, ::testing::HasSubstr("inliers=720"));
}

这里把字段名当接口测试。 这和测试 JSON schema 的思路相同。

34.4.8 常见陷阱

陷阱 现象 根本原因 正确做法
字段名不统一 查询脚本漏统计 没有字段表 每类事件定义字段模板
日志里塞大数组 文件暴涨、写入阻塞 混淆索引和数据 日志只保存摘要和文件引用
只记录结果不记录阈值 无法解释决策 缺少反事实证据 同时记录 score 和 threshold
只用 wall time 无法对齐传感器 机器人有多种时间基 同时记录 sensor stamp 和 steady time

34.4.9 与可观测性系统的关系:OpenTelemetry 和 rosbag2

结构化日志是可观测性(Observability)的基础层,但完整的可观测性需要三根支柱:日志(Logs)、指标(Metrics)、追踪(Traces)。OpenTelemetry 是当前工业界的开放标准,试图统一这三者的数据模型和采集协议。对机器人系统来说,OpenTelemetry 的价值在于:把分散在 spdlog 文件、ROS topic、自定义计数器中的运行时证据,汇聚到统一的可查询后端(如 Grafana、Jaeger)。

不过,机器人系统的可观测性还有一个独特工具:rosbag2。rosbag2 记录的不只是日志文本,而是完整的 ROS 2 消息流——包括传感器原始数据、TF 变换、算法中间结果。当系统在现场出问题时,一个完整的 rosbag2 录制可以让你在实验室里精确回放当时的全部输入。这比日志文件提供了更丰富的事后分析能力,但代价是文件体积大、录制本身有性能开销。

工具 记录什么 优势 局限
结构化日志 决策事件的关键字段 轻量、可 grep、长时间运行 不包含原始数据
rosbag2 ROS 消息的完整序列 可回放全部输入、可离线重处理 体积大、需要 ROS 基础设施
OpenTelemetry 日志 + 指标 + 追踪 工业标准、可视化生态好 需要额外后端部署

在实际项目中,推荐的组合是:用结构化日志记录所有关键决策(始终开启),用 rosbag2 在调试和数据采集时录制完整消息流,在需要长期监控的部署场景中考虑 OpenTelemetry。三者不是互相替代,而是覆盖不同的诊断场景。

34.4.10 练习

  1. event=imu_preintegration_failed 设计字段,至少包含失败原因、时间范围和 IMU 数量。
  2. 解释为什么回环日志应同时记录 scorethreshold
  3. 写一个日志解析脚本的输入样例,统计不同 reason 的丢帧数量。
  4. 比较 rosbag2 和结构化日志在一次"SLAM 轨迹发散"排查中的各自优势。在什么情况下 rosbag2 是不可替代的?

34.5 异步日志与实时路径边界 ⭐⭐⭐

34.5.1 动机:日志不能成为控制循环的一部分

日志的目标是观察系统。观察本身不能显著改变系统行为。这个原则在物理学中叫做"观测者效应"——测量行为本身不应改变被测量的系统。日志系统也是一样:记录控制循环的行为不应改变控制循环的时序特性。

在普通 Web 后端服务里,日志阻塞几十毫秒可能只是请求变慢,用户感受不明显。但在机器人控制里,几十毫秒可能意味着控制周期错过、里程计队列积压、IMU 数据丢失或安全停机。1 kHz 控制循环的预算只有 1 ms——一次同步文件写入就可能吃掉全部预算。

这个问题在实践中极其常见,而且往往以"间歇性抖动"的形式出现,不容易定位。开发者为了排查抖动加入更多日志,结果抖动变得更严重——形成了诊断干扰的正反馈循环。理解异步日志和实时路径边界,是打破这个循环的关键。

把日志放进实时路径时,需要先问:

这条日志会不会分配内存
这条日志会不会拿锁
这条日志会不会写文件
这条日志会不会格式化大对象
这条日志会不会因为磁盘或终端阻塞

如果答案有一个是“可能”,它就不应该直接出现在硬实时或准实时路径中。

34.5.2 反面失败:同步文件日志导致长尾延迟

一个 100Hz 前端线程每 10ms 处理一帧点云。 为了调试,开发者加了一行:

logger->info("frame_id={} cloud_size={} pose={}", frame_id, cloud_size, poseString(T));

它看起来很轻。 实际成本包括:

  1. poseString(T) 格式化字符串。
  2. spdlog 组装日志消息。
  3. sink 加锁。
  4. 文件写入。
  5. 操作系统缓冲。
  6. 可能的磁盘刷新。
  7. 终端输出时还可能被终端渲染拖慢。

平均耗时可能只有 0.1ms。 但偶发长尾可能是 20ms。

机器人系统害怕的常常不是平均值。 而是长尾。

如果前端偶发阻塞 20ms:

LiDAR 帧到达
前端线程被日志阻塞
输入队列增长
下一帧处理更晚
时间同步窗口错过
IMU 预积分失败或退化

这就是日志干扰算法。

34.5.3 历史和演进:异步日志把 IO 推到后台线程

异步日志的基本思想是:

业务线程只把日志事件放入队列
后台线程从队列取出事件
后台线程格式化或写入 sink

这个结构把最慢的 IO 从业务线程移开。 但它没有让成本消失。 成本只是移动到了队列和后台线程。

异步日志多了一个必须回答的问题:

队列满了怎么办

常见策略:

策略 行为 适合场景 风险
阻塞等待 业务线程等队列有空间 不允许丢日志的离线工具 可能破坏实时性
覆盖旧消息 丢弃队列中较旧日志 实时路径诊断 可能丢失早期根因
丢弃新消息 当前日志不入队 高频重复日志 可能看不到最新失败
降采样 按频率或计数输出 高频状态 统计需额外记录

异步日志不是“总是更安全”。 如果队列满时选择阻塞,它仍会阻塞业务线程。 如果选择覆盖,它会丢证据。

这就是工程权衡。

34.5.4 理论规则:异步边界、队列容量和溢出策略

spdlog 的异步 logger 需要线程池。 示意配置:

#include <spdlog/async.h>
#include <spdlog/sinks/rotating_file_sink.h>

#include <memory>

std::shared_ptr<spdlog::logger> makeAsyncLogger() {
    const std::size_t queue_size = 8192;
    const std::size_t background_threads = 1;

    // 线程边界:
    // 业务线程负责 enqueue,后台线程负责 sink 写入。
    // queue_size 必须按峰值日志量估算,不能只看平均值。
    spdlog::init_thread_pool(queue_size, background_threads);

    auto logger = spdlog::create_async<spdlog::sinks::rotating_file_sink_mt>(
        "async_slam",
        "logs/slam_async.log",
        20 * 1024 * 1024,
        3);

    logger->set_level(spdlog::level::info);
    logger->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%l] [%n] [tid=%t] %v");
    return logger;
}

如果需要非阻塞溢出策略,可以显式创建:

auto sink = std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
    "logs/realtime.log",
    20 * 1024 * 1024,
    3);

auto logger = std::make_shared<spdlog::async_logger>(
    "rt_diagnostics",
    sink,
    spdlog::thread_pool(),
    spdlog::async_overflow_policy::overrun_oldest);

spdlog::register_logger(logger);

这里 overrun_oldest 表示队列满时覆盖旧日志。 这适合实时诊断通道。 它不适合安全审计或实验归档。

还要注意:spdlog 的默认线程池是进程级资源。 真实项目应在程序启动阶段集中初始化一次,避免多个组件构造函数各自调用 init_thread_pool(),否则日志系统的生命周期会变得很难解释。

34.5.5 工程边界:实时路径只记录轻量事件

实时路径的日志策略可以分为三层。

做法 示例
计数器 原子计数或无锁统计 丢帧数量、超时次数
轻量事件 固定大小结构体入环形缓冲 帧号、错误码、耗时
冷路径日志 后台线程格式化写文件 每秒汇总一次

示意结构:

#include <array>
#include <atomic>
#include <cstdint>

struct RtEvent {
    uint64_t stamp_ns = 0;
    int frame_id = -1;
    int code = 0;
    float duration_ms = 0.0f;
};

class RtEventRing {
public:
    void push(RtEvent event) noexcept {
        const auto index = write_index_.fetch_add(1, std::memory_order_relaxed);
        // 实时路径边界:
        // 固定容量覆盖写,不分配内存,不抛异常。
        buffer_[index % buffer_.size()] = event;
    }

private:
    std::array<RtEvent, 4096> buffer_{};
    std::atomic<uint64_t> write_index_{0};
};

这个例子为了教学简化了多读者同步。 真实项目要明确:

  1. 是否允许读线程看到半更新事件。
  2. 事件写入是否需要序列号。
  3. 覆盖时如何统计丢失数量。
  4. 是否使用 SPSC 队列、MPSC 队列或专用 trace 框架。

日志文本不必直接出现在实时线程。 实时线程可以只写 RtEvent。 后台线程再把事件转成 spdlog 日志。

34.5.6 代码验证:证明热路径不分配

严格证明“不分配”并不容易。 但可以在接口层减少风险:

class RealtimeDiagnostics {
public:
    void recordFrameOverrun(int frame_id, float duration_ms) noexcept {
        RtEvent event;
        event.frame_id = frame_id;
        event.duration_ms = duration_ms;
        event.code = 1;
        ring_.push(event);
    }

private:
    RtEventRing ring_;
};

关键点:

  1. noexcept 表达异常边界。
  2. 固定大小事件避免动态分配。
  3. 不接受 std::string
  4. 不接受格式化模板。
  5. 不写文件。

测试可以先保护接口形态:

#include <type_traits>

static_assert(noexcept(std::declval<RealtimeDiagnostics&>()
                           .recordFrameOverrun(1, 2.0f)));

再做压力测试:

TEST(RealtimeDiagnosticsTest, AcceptsManyEventsWithoutThrowing) {
    RealtimeDiagnostics diagnostics;

    for (int i = 0; i < 100000; ++i) {
        diagnostics.recordFrameOverrun(i, 1.5f);
    }

    SUCCEED();
}

这不能证明所有平台都无长尾。 但它固定了接口的实时意图。

34.5.7 常见陷阱

陷阱 现象 根本原因 正确做法
异步 logger 队列满时阻塞 偶发控制周期超时 溢出策略不适合实时路径 实时通道使用覆盖或降采样
热路径传 std::string 隐式分配 字符串所有权不受控 传固定字段或枚举错误码
每帧写完整位姿矩阵 日志量过大 没有采样策略 按频率、事件或阈值采样
退出时不 flush 最后日志丢失 后台线程未写完 冷路径在停机阶段显式 flush

34.5.8 练习

  1. 设计一个 1kHz 控制循环可用的诊断事件结构,要求不包含动态内存。
  2. 解释为什么异步日志队列满时“阻塞等待”和“覆盖旧消息”都不是绝对正确。
  3. 写一个后台线程汇总策略:每秒输出一次最大周期耗时、平均耗时和超时次数。

34.6 日志注入:让组件自己说话,但不自己决定出口 ⭐⭐

34.6.1 动机:大型 SLAM 系统由插件和模块组成

日志注入是依赖注入原则在日志领域的具体应用。依赖注入的核心思想是:一个组件不应该自己创建它依赖的外部资源,而应该由组装层提供。这个原则在数据库连接、网络客户端等领域已经被广泛接受,但在日志领域却常被忽视——很多组件直接在构造函数中创建自己的 logger,决定输出到哪个文件、用什么格式、什么级别。

为什么日志也需要注入?因为日志的"输出位置"和"命名规则"是系统级决策,不应由单个组件独立做出。一个回环检测模块不应该知道日志应该写到 /tmp/slam.log 还是 ~/experiments/run001/logs/——这取决于部署环境和运行目录策略。模块只需要知道"我有一个 logger,我可以往里写事件"。至于这个 logger 写到哪里、什么级别启用、和其他模块的 logger 是什么关系,全部由系统组装层统一决定。

这种设计对测试尤其重要。如果模块内部硬编码了输出到终端的 logger,单元测试无法捕获日志内容来验证字段是否正确。如果 logger 是注入的,测试可以传入一个"捕获 sink",直接验证日志输出是否包含预期的字段和值。

一个工程化 SLAM 系统通常不只有一个类。 它可能包含:

  1. 数据读取器。
  2. 时间同步器。
  3. IMU 预积分器。
  4. 点云去畸变模块。
  5. 特征提取模块。
  6. 局部匹配模块。
  7. 后端优化模块。
  8. 回环检测模块。
  9. 地图管理模块。
  10. 可视化模块。

如果每个模块内部自己创建 logger:

auto logger = spdlog::stdout_color_mt("logger");

会出现命名冲突、输出不统一和测试困难。

更好的方式是由框架创建 logger,然后注入到组件。

系统组装层决定日志出口和命名
组件只负责记录自己的事件

这叫日志注入。

34.6.2 反面失败:组件内部硬编码日志器

错误写法:

class ScanContextLoopDetector {
public:
    ScanContextLoopDetector() {
        logger_ = spdlog::stdout_color_mt("loop");
    }

    void detect() {
        logger_->info("detect loop");
    }

private:
    std::shared_ptr<spdlog::logger> logger_;
};

问题:

  1. 测试时难以捕获日志。
  2. 同名 logger 可能重复注册。
  3. 输出 sink 被组件决定,系统无法统一配置。
  4. 多个插件都叫 loop,无法区分来源。
  5. 组件被复用到离线工具时还会写到原来的终端或文件。

日志是系统横切关注点。 组件不应该独自决定日志出口。

34.6.3 历史和演进:依赖注入在日志中的应用

依赖注入的基本思想是:

组件需要某个能力
但不自己创建具体实现
由外部把实现传入

这和 C++语言核心/RAII与智能指针 的 RAII 不冲突。 组件仍然可以拥有 logger 的共享句柄。 区别是创建权在外部。

类比传感器驱动:

算法模块不应该自己打开串口。
它应该接收已经抽象好的观测流。

日志也一样:

算法模块不应该自己决定写哪个文件。
它应该接收已经命名和配置好的 logger。

34.6.4 理论规则:命名层级和可选接口

Logger 命名不是"起个好听的名字"的问题,而是一种模块归属标记。当系统有 20 个模块、50 个线程同时运行时,一条没有模块归属的日志(比如 "received 100 points")几乎无法定位来源——它可能来自前端点云预处理,也可能来自后端子图维护,甚至来自回环检测的候选评估。层级命名让每条日志自带模块路径,排查时无需靠猜测。

这个设计和文件系统的目录结构类比:把所有文件放在根目录是合法的,但当文件数量超过几十个时就变得不可管理。层级命名是日志系统的"目录结构"。

推荐的 logger 名称采用层级结构:

SlamSystem.Tracking
SlamSystem.Mapping
SlamSystem.LoopClosure.ScanContext
SlamSystem.Backend.GtsamIsam2

它有三个好处:

  1. grep 或过滤时可以按前缀找模块。
  2. 日志配置可以按层级调整级别。
  3. 多个插件实例可以通过后缀区分。

组件接口:

class LoopDetector {
public:
    void setLogger(std::shared_ptr<spdlog::logger> logger) {
        logger_ = std::move(logger);
    }

    void detect(int frame_id) {
        if (logger_) {
            logger_->debug("event=loop_detect_start frame_id={}", frame_id);
        }
    }

private:
    std::shared_ptr<spdlog::logger> logger_;
};

框架组装:

template <class Component>
void injectLoggerIfSupported(Component& component,
                             const std::shared_ptr<spdlog::logger>& logger) {
    if constexpr (requires(Component& c) { c.setLogger(logger); }) {
        // 可选接口边界:
        // 组件支持 setLogger 就注入,不支持也不强迫继承日志基类。
        component.setLogger(logger);
    }
}

这种写法保持非侵入。 不是所有组件都必须继承 Loggable

34.6.5 工程边界:不要把 logger 变成全局状态

全局 logger 很方便:

spdlog::info("hello");

但大型系统中,全局日志入口有边界问题:

问题 后果
初始化顺序不清 静态对象构造期间使用日志可能失败
测试难隔离 一个测试修改全局级别影响另一个测试
组件来源丢失 所有日志看起来来自同一入口
生命周期不清 程序退出阶段 logger 可能已销毁

推荐规则:

  1. 顶层程序拥有 logger 工厂。
  2. 模块构造时获得专属 logger。
  3. 组件只保存 shared_ptr 或轻量包装。
  4. 测试可以传入捕获 sink。
  5. 静态对象构造期间不写日志。

34.6.6 代码验证:测试组件使用注入 logger

TEST(LoggerInjectionTest, ComponentWritesThroughInjectedLogger) {
    auto sink = std::make_shared<CapturingSink>();
    auto logger = std::make_shared<spdlog::logger>(
        "SlamSystem.LoopClosure.ScanContext",
        sink);
    logger->set_pattern("[%n] %v");
    logger->set_level(spdlog::level::debug);

    LoopDetector detector;
    detector.setLogger(logger);
    detector.detect(17);

    const auto messages = sink->messages();
    ASSERT_EQ(messages.size(), 1);
    EXPECT_THAT(messages[0], ::testing::HasSubstr("SlamSystem.LoopClosure.ScanContext"));
    EXPECT_THAT(messages[0], ::testing::HasSubstr("frame_id=17"));
}

这个测试证明两件事:

  1. 组件没有硬编码输出位置。
  2. 系统层命名传到了日志里。

34.6.7 常见陷阱

陷阱 现象 根本原因 正确做法
组件自己创建同名 logger 运行时报重复注册 创建权分散 顶层统一创建并注入
logger 名太短 无法区分插件 没有层级 使用系统、模块、插件三级名称
全局修改日志级别 测试互相影响 全局状态共享 测试中使用局部 logger
析构函数里大量写日志 程序退出卡顿 退出阶段资源顺序复杂 析构只写必要低风险日志

34.6.8 练习

  1. 为一个包含 ICP、NDT、GICP 三种匹配插件的前端设计 logger 命名规则。
  2. 写一个 makeChildLogger(parent, child_name) 的接口草图。
  3. 解释为什么日志注入比在组件内部调用 spdlog::get("global") 更适合测试。

34.7 YAML 参数系统:从配置文件到有效配置对象 ⭐⭐

过渡:前六节覆盖了日志的完整层次——从 spdlog 基础到结构化字段、从异步队列到实时路径边界、从 source_location 到日志注入。日志解决了"运行时发生了什么"的可观测问题。接下来我们进入第二控制面的第二个支柱:配置管理。配置解决的是"系统按什么参数运行"的可追踪问题。两者的关系是:日志记录证据,配置定义意图。当证据和意图能一起保存和复现时,排查问题才有了完整的基础。

34.7.1 动机:从"硬编码参数"到"可追踪配置"的演进

配置管理不是"把常量搬到文件里"那么简单。理解它的价值,需要从配置管理的演进历史看起。

最早期的机器人代码,参数直接写在源文件里:constexpr double kVoxelSize = 0.3;。这种做法在单人单数据集时没问题,但一旦换数据集就要重编译。于是参数搬到了命令行:--voxel_size=0.3。命令行解决了重编译问题,但参数多了之后命令行变得不可读,也无法表达层级关系。接下来参数搬到了配置文件(INI、YAML、JSON),这解决了可读性和层级问题,但引入了新问题:文件里写了什么和系统实际使用了什么可能不一致(命令行覆盖、环境变量、默认值填充)。ROS 的参数服务器进一步允许运行时修改参数,但没有边界控制——任何节点都可以改任何参数,且修改不留痕迹。

硬编码常量 → 命令行参数 → 配置文件 → 参数服务器 → 配置快照
   每一步解决了前一步的问题,又引入了新的问题

到了需要复现实验的阶段,只有配置快照(保存系统实际使用的全部参数)才能真正支撑复现。这条演进线的本质是:参数的"可追踪性"从零逐步建立。

YAML 在机器人项目中很常见。 原因是它比 JSON 更适合人工编辑:

mapping:
  voxel_size: 0.3
  max_range: 80.0

imu:
  topic: /imu/data
  noise_density: 0.001

但配置文件只是入口。 工程真正需要的是“有效配置对象”。

有效配置对象满足:

  1. 所有缺失字段已经用默认值补齐。
  2. 所有类型已经检查。
  3. 所有范围已经验证。
  4. 所有层级覆盖已经解析。
  5. 所有单位已经明确。
  6. 对运行时可变性已经分类。

YAML 文件本身没有保证这些。 你的加载层必须保证。

34.7.2 反面失败:参数在代码里四处读取

错误写法:

double voxel_size = node["mapping"]["voxel_size"].as<double>();
int max_iterations = node["icp"]["max_iterations"].as<int>();
std::string topic = node["imu"]["topic"].as<std::string>();

如果这些读取分散在各个模块中,会出现:

  1. 缺字段时异常发生在模块内部,错误上下文不足。
  2. 默认值散落各处,无法生成有效配置。
  3. 参数名拼错只在运行到对应模块时暴露。
  4. 同一参数可能被多个模块用不同默认值读取。
  5. 范围校验不一致。
  6. 测试很难覆盖所有配置路径。

更严重的是半初始化。

Tracking 已经打开传感器
Mapping 读取参数失败
系统退出
Tracking 资源释放顺序复杂

配置错误应该尽早发生。最好在所有模块创建前发生。这个原则叫做"fail fast"——错误越早暴露,修复成本越低。一个缺失的标定参数如果在启动阶段就被检测到,修复成本是 10 秒(补上参数)。如果在运行 30 分钟后 SLAM 发散时才间接表现出来,修复成本可能是数小时的排查加重新实验。配置加载的目标就是把这些"运行时才会暴露的静态错误"前移到启动阶段。

半初始化是配置错误最危险的后果。如果模块 A 已经打开了传感器连接、分配了内存、启动了线程,然后模块 B 的配置加载失败导致系统退出,模块 A 的资源释放路径可能不完整——传感器连接可能没有正确关闭,线程可能没有正确 join。这就是为什么 34.1.4 强调"配置应在所有模块构造前完成"——把配置加载集中到一个位置,要么全部成功后再创建模块,要么加载失败时没有任何资源需要清理。

34.7.3 历史和演进:从全局参数到类型化配置

机器人项目早期常把参数放在全局可访问的位置。这种做法的问题不只是"代码不优雅",而是参数的生命周期和所有权不清晰——任何代码都可以在任何时候读取或修改参数,导致系统行为取决于代码的执行顺序而非显式的逻辑关系。

常见的参数存放位置及其问题:

  1. 头文件常量——改参数要重编译。
  2. ROS launch 文件——参数散落在 XML/YAML 中,类型不受编译器检查。
  3. 参数服务器——运行时可修改但无边界控制,修改不留痕迹。
  4. 全局变量——任何代码都能读写,线程安全无法保证。
  5. 零散 YAML 节点——同一参数可能被不同模块用不同默认值读取。

随着项目变大,这些做法的问题逐渐累积。类型化配置对象成为更可靠的选择——它把参数集中到一个有类型、有默认值、有校验的结构体中,从根本上解决散落和不一致问题:

struct MappingConfig {
    double voxel_size = 0.3;
    double max_range = 80.0;
    int local_map_size = 50;
};

struct IcpConfig {
    int max_iterations = 20;
    double max_correspondence_distance = 1.0;
    double convergence_epsilon = 1e-4;
};

struct SlamConfig {
    MappingConfig mapping;
    IcpConfig icp;
};

这样做的价值是:

  1. 参数有类型。
  2. 默认值集中。
  3. 模块构造函数表达依赖。
  4. 测试可以直接构造配置。
  5. 有效配置可以序列化保存。

34.7.4 理论规则:默认值、类型检查、范围校验三步走

加载一个参数应按三步:

先确定默认值
再做类型转换
最后做语义范围校验

不要把三者混在一起。

示例辅助函数:

#include <fmt/format.h>
#include <yaml-cpp/yaml.h>

#include <stdexcept>
#include <string>
#include <string_view>

template <class T>
T readRequired(const YAML::Node& node,
               std::string_view key,
               std::string_view full_name) {
    if (!node[std::string(key)]) {
        throw std::invalid_argument(
            fmt::format("missing required config key: {}", full_name));
    }

    try {
        return node[std::string(key)].as<T>();
    } catch (const YAML::Exception& e) {
        // 异常边界:
        // yaml-cpp 的异常转换为项目自己的 invalid_argument,
        // 错误消息必须保留参数名,便于定位配置文件。
        throw std::invalid_argument(
            fmt::format("invalid type for config key {}: {}", full_name, e.what()));
    }
}

template <class T>
T readOptional(const YAML::Node& node,
               std::string_view key,
               T default_value,
               std::string_view full_name) {
    if (!node[std::string(key)]) {
        return default_value;
    }

    try {
        return node[std::string(key)].as<T>();
    } catch (const YAML::Exception& e) {
        throw std::invalid_argument(
            fmt::format("invalid type for config key {}: {}", full_name, e.what()));
    }
}

范围校验单独做:

void requireInRange(double value,
                    double min_value,
                    double max_value,
                    std::string_view name) {
    if (!(value >= min_value && value <= max_value)) {
        throw std::invalid_argument(
            fmt::format("config {}={} outside range [{}, {}]",
                        name,
                        value,
                        min_value,
                        max_value));
    }
}

为什么不只靠类型?因为编程语言的类型系统和应用的语义约束是两个不同的层次。C++ 的类型系统只关心"这是不是一个 double",而机器人系统还关心"这个 double 是否在物理上有意义"。voxel_size: -0.1 是合法 double——C++ 编译器和 YAML 解析器都不会报错。但负数的体素大小在物理上没有意义,使用它会导致点云哈希映射计算出负索引,表现为段错误或无声的数据损坏。

范围校验就是在类型系统和物理语义之间建立一层"语义类型"检查。它回答的问题不是"这个值的 C++ 类型对吗",而是"这个值在这个应用场景下合理吗"。这种检查应该集中在配置加载阶段完成,而不是散落在使用参数的各个模块中——否则同一个参数可能在某些模块被校验、在另一些模块被遗漏。

34.7.5 工程边界:默认值不是掩盖错误

默认值的设计是配置管理中最微妙的权衡之一。好的默认值降低了使用门槛——用户不需要理解所有参数就能让系统运行起来。坏的默认值则掩盖了必须由用户提供的信息——系统看似正常启动,实际上使用了错误的假设,问题在运行很久之后才以性能下降的形式暴露。

区分"适合默认"和"不适合默认"的关键准则是:这个参数的合理值是否依赖于用户的特定硬件或场景。算法参数(如 ICP 迭代上限、队列大小)通常有通用的合理值,适合默认;物理参数(如传感器外参、标定矩阵)则没有通用值,不应默认。

默认值有两类:

类型 例子 是否适合默认
工程策略默认 max_iterations=20 适合
安全范围默认 queue_size=1024 适合
传感器身份 /points_raw 可有项目默认,但要记录
标定外参 LiDAR 到 IMU 变换 不应盲目默认
地图坐标系 map / odom 可默认,但要显式输出
数据集路径 输入文件 通常必填

默认值的反事实风险:

  1. 外参缺失时用单位矩阵。
  2. 系统能启动。
  3. 轨迹慢慢漂移。
  4. 排查者以为外参已经加载。
  5. 日志只写“config loaded”。

所以规则是:

策略参数可以有默认值。
硬件标定和输入数据应显式提供。
默认值必须进入有效配置快照。

34.7.6 配置层级:base、robot、dataset、command line

为什么需要多层配置?考虑一个现实场景:同一套 SLAM 算法需要在三台不同的机器人上运行,每台机器人有不同的传感器配置(LiDAR 型号、安装位置、IMU 品牌);在每台机器人上还要跑不同的数据集(室内、室外、地下车库),每个数据集可能需要不同的参数调整。如果只有一个配置文件,你需要为每个(机器人, 数据集)组合维护一个完整的配置副本——3 台机器人 x 4 个数据集 = 12 个文件,大部分内容重复,修改一个通用参数需要改 12 个文件。

多层配置通过"后层覆盖前层"的合并规则解决这个问题。通用的算法参数放在 base 层,只写一次;机器人硬件参数放在 robot 层,每台机器人一个文件;数据集特殊调整放在 dataset 层。这样修改通用参数只需要改一个地方,而机器人特有的参数又不会被误覆盖。

大型项目通常有多层配置:

base.yaml
robot_x1.yaml
dataset_kitti.yaml
local_override.yaml
command line

每层的意义不同:

层级 内容 示例
base 算法通用默认 ICP 迭代上限
robot 机器人硬件参数 外参、topic、传感器频率
dataset 数据集特殊参数 时间偏移、坐标系
local 本地实验覆盖 输出目录
CLI 一次运行覆盖 --mapping.voxel_size=0.2

合并规则必须明确。

最常见规则是:

后面的层覆盖前面的层
映射节点递归合并
标量和序列整体替换

示例:

# base.yaml
mapping:
  voxel_size: 0.4
  max_range: 80.0
icp:
  max_iterations: 20
# dataset.yaml
mapping:
  voxel_size: 0.25

有效结果:

mapping:
  voxel_size: 0.25
  max_range: 80.0
icp:
  max_iterations: 20

mapping.voxel_size 被覆盖。 mapping.max_range 保留。

34.7.7 运行时参数变更边界

运行时参数变更是机器人系统中一个被严重低估的复杂性来源。表面上看,它只是"修改一个变量的值"。但深入思考就会发现,运行时改参数涉及三个层面的问题:第一是时序问题——新参数从哪一帧开始生效?正在处理的帧用旧参数还是新参数?第二是一致性问题——如果体素大小从 0.3 改成 0.2,已经建好的地图(用 0.3 建的)和新建的地图(用 0.2 建的)分辨率不一致,拼接后会出现什么?第三是线程安全问题——一个线程在读取参数的同时另一个线程在修改它,如果没有同步机制就会产生数据竞争。

运行时改参数很诱人。比如在调参界面上拖动滑块:

voxel_size: 0.3 -> 0.2
loop_threshold: 0.09 -> 0.12

但不是所有参数都能安全变更。

分类:

参数类别 例子 运行时可变性
纯阈值 回环分数阈值 通常可变
采样频率 日志采样周期 可变
可视化开关 是否发布 debug marker 可变
缓冲容量 队列大小 通常不可变
线程数 后端线程池大小 通常需重建
传感器外参 LiDAR-IMU 变换 改变后需重初始化状态
地图分辨率 体素大小 改变后需重建地图
噪声模型 IMU 噪声 可能影响已有因子解释

运行时变更的核心问题是:

新参数从哪一帧开始生效
旧状态是否仍与新参数一致
跨线程读写是否同步
变更是否被记录到日志和配置快照

一个安全模式是不可变快照:

#include <atomic>
#include <memory>

class RuntimeConfigStore {
public:
    std::shared_ptr<const SlamConfig> snapshot() const {
        return std::atomic_load(&config_);
    }

    void update(std::shared_ptr<const SlamConfig> next) {
        // 线程边界:
        // 读者拿到 shared_ptr 快照后,本帧内看到的是一致配置。
        // 写者发布新 shared_ptr,不修改旧对象。
        std::atomic_store(&config_, std::move(next));
    }

private:
    std::shared_ptr<const SlamConfig> config_;
};

不要让多个线程共享可变配置对象。 共享可变对象会把参数系统变成数据竞争源。

34.7.8 代码验证:完整加载 MappingConfig

struct MappingConfig {
    double voxel_size = 0.3;
    double max_range = 80.0;
    int local_map_size = 50;
};

MappingConfig loadMappingConfig(const YAML::Node& root) {
    const YAML::Node node = root["mapping"];

    MappingConfig config;
    config.voxel_size = readOptional(
        node, "voxel_size", config.voxel_size, "mapping.voxel_size");
    config.max_range = readOptional(
        node, "max_range", config.max_range, "mapping.max_range");
    config.local_map_size = readOptional(
        node, "local_map_size", config.local_map_size, "mapping.local_map_size");

    requireInRange(config.voxel_size, 0.01, 5.0, "mapping.voxel_size");
    requireInRange(config.max_range, 1.0, 300.0, "mapping.max_range");

    if (config.local_map_size <= 0) {
        throw std::invalid_argument("config mapping.local_map_size must be positive");
    }

    return config;
}

测试默认值:

TEST(MappingConfigTest, UsesDefaultsForMissingOptionalKeys) {
    const YAML::Node root = YAML::Load("mapping: {}\n");

    const MappingConfig config = loadMappingConfig(root);

    EXPECT_DOUBLE_EQ(config.voxel_size, 0.3);
    EXPECT_DOUBLE_EQ(config.max_range, 80.0);
    EXPECT_EQ(config.local_map_size, 50);
}

测试类型错误:

TEST(MappingConfigTest, RejectsInvalidType) {
    const YAML::Node root = YAML::Load(
        "mapping:\n"
        "  voxel_size: large\n");

    EXPECT_THROW(loadMappingConfig(root), std::invalid_argument);
}

测试范围错误:

TEST(MappingConfigTest, RejectsNegativeVoxelSize) {
    const YAML::Node root = YAML::Load(
        "mapping:\n"
        "  voxel_size: -0.1\n");

    try {
        (void)loadMappingConfig(root);
        FAIL() << "negative voxel size should be rejected";
    } catch (const std::invalid_argument& e) {
        EXPECT_THAT(std::string(e.what()),
                    ::testing::HasSubstr("mapping.voxel_size"));
    }
}

这里的测试对应三个不变量:

  1. 缺失可选字段时默认值稳定。
  2. 类型错误不会进入模块构造。
  3. 范围错误包含参数名。

34.7.9 有效配置快照

加载完成后,应保存有效配置。 不是保存用户输入的原始文件。

原因:

  1. 原始文件可能经过多层覆盖。
  2. 命令行参数可能覆盖文件。
  3. 默认值可能没有出现在原始文件里。
  4. 环境变量可能影响路径。

有效配置可以输出成 YAML:

schema: slam_config.v2
mapping:
  voxel_size: 0.25
  max_range: 80.0
  local_map_size: 50
icp:
  max_iterations: 20
  max_correspondence_distance: 1.0
runtime:
  config_hash: sha256:8f12

这个文件应和实验输出放在一起。

34.7.10 常见陷阱

⚠️ 工程陷阱:传感器外参使用默认单位矩阵——SLAM 轨迹缓慢漂移但不报错

错误做法:LiDAR 到 IMU 外参在配置结构体中默认为单位矩阵 T_lidar_imu = I。用户忘记填写标定结果时,系统正常启动,不报任何错误。

现象/后果:轨迹在大场景下缓慢发散,但短距离测试看起来"差不多正确"。排查时怀疑算法参数、地图分辨率、回环检测,反复调参数但不见改善。

根本原因:默认值的目的是"在没有显式指定时提供合理的工程策略",而不是"掩盖必须由用户提供的物理量"。外参是传感器的物理属性,不存在通用默认值——单位矩阵意味着假设 LiDAR 和 IMU 完全重合,这几乎不可能是真的。

正确做法:标定参数(外参、内参、时间偏移)应标记为必填。如果配置中缺失,系统在启动阶段就抛出明确异常,而不是在算法运行时以性能下降的方式隐式失败。错误消息应包含参数名:"missing required config: sensor.T_lidar_imu"

💡 概念误区:认为"运行时可以随意修改任何参数"——不区分冻结参数和可变参数

新手想法:"ROS 参数服务器支持动态 set,所以所有参数都应该支持运行时修改。"

实际上:很多参数在模块初始化时就固化到了数据结构中。例如体素大小决定了地图的网格分辨率,修改它意味着已建的地图和新建的地图使用不同分辨率,拼接后产生不一致。外参修改需要重新计算所有已有的位姿图。输出目录修改可能导致同一次实验的文件散落在两个位置。

正确做法:把参数分为"初始化前冻结"和"运行时可变"两类。冻结参数在配置对象构造后成为不可变字段。可变参数通过不可变快照机制更新——非实时线程准备新快照,实时线程原子切换,避免多线程读写同一可变对象。

34.7.11 练习

  1. 为 IMU 配置设计必填字段和可选字段,说明哪些不能有默认值。将你的设计与一个真实系统(如 LIO-SAM 的 params.yaml)对比。
  2. 实现一个递归合并 YAML 节点的规则,说明序列节点为什么通常整体替换而非逐元素合并。
  3. 判断下列参数哪些可运行时变更、哪些必须冻结:回环阈值、外参、日志级别、体素大小、输出目录。对每个给出理由。
  4. 设计一个配置加载的单元测试:测试当 imu.noise_density 设为负值时,系统是否在启动阶段就报错(而不是在运行 10 分钟后 EKF 发散时才发现)。

34.8 序列化总览:保存数据之前先确定语义 ⭐⭐

过渡:配置管理解决了"系统按什么参数运行"的问题。接下来进入第二控制面的第三个支柱:序列化。序列化解决的是"系统产出了什么、这些产出如何被保存和读取"的问题。如果说日志记录了系统的"过程",配置定义了系统的"输入",那么序列化就是系统的"输出"。三者合在一起,构成了完整的实验证据链——输入(配置)+ 过程(日志)+ 输出(序列化数据)= 可复现的实验。

序列化不是简单的"把数据写到文件"。数据写到文件后,还要考虑:文件格式是否独立于编程语言和平台?新版本程序能否读取旧版本产生的文件?文件损坏时能否检测到而不是静默给出错误结果?这些问题的核心是:文件格式是一种长期接口,它的生命周期可能远超产生它的代码的生命周期。

34.8.1 动机:地图文件不是内存快照

SLAM 输出的数据很复杂。

它可能包含:

  1. 关键帧位姿。
  2. 路标点。
  3. 点云子图。
  4. 描述子。
  5. 因子图边。
  6. 回环约束。
  7. 传感器外参。
  8. 配置摘要。
  9. 时间戳。
  10. 坐标系定义。

把这些数据写入文件,不能简单理解为“把内存保存一下”。 内存布局是程序实现细节。 文件格式是长期接口。

这两者的生命周期不同:

对象 生命周期 变化频率
C++ 类布局 随代码重构变化
地图文件格式 随发布版本变化
轨迹文本格式 随评测工具约定变化
数据库 schema 随产品升级变化

如果直接保存内存布局,代码一改,文件就可能读不回来。

34.8.2 反面失败:把 struct 原样写入二进制

错误示例:

struct PoseRecord {
    double timestamp;
    double tx;
    double ty;
    double tz;
    double qx;
    double qy;
    double qz;
    double qw;
};

void savePose(std::ofstream& out, const PoseRecord& pose) {
    out.write(reinterpret_cast<const char*>(&pose), sizeof(PoseRecord));
}

这段代码看起来快。 但它隐藏了多个未定义的文件契约:

  1. 字节序是什么。
  2. double 是否一定是 IEEE 754。
  3. 结构体是否有 padding。
  4. 字段顺序是否永远不变。
  5. 新增字段怎么办。
  6. 文件损坏怎么检测。
  7. 不同编译器对齐是否相同。

如果明年你在 PoseRecord 中间加入一个 uint32_t frame_id,旧文件就会被新程序误读。

所以原样写内存只适合非常受控的短期缓存。 不适合作为长期地图格式。

34.8.3 三种序列化哲学:不是三种格式的 API,而是三种设计权衡

在深入具体格式之前,先理解序列化背后的三种根本哲学。很多教程把 Protobuf、JSON、YAML 当成"三种不同的 API"来罗列——但这样只学到了表面。真正的区分是它们代表三种不同的设计权衡,每种权衡优化的目标不同。

第一种哲学:二进制紧凑(Protobuf、FlatBuffers、MessagePack)

核心理念是"机器优先"。数据以二进制编码存储,人类无法直接阅读。这种选择牺牲了可读性,换取了三个工程优势:文件体积小(通常是 JSON 的 1/3 到 1/5)、解析速度快(无需字符串→数字转换)、以及 schema 强制(编译期检查字段类型)。Protobuf 还通过字段编号实现了前向兼容——新增字段不会破坏旧代码的解析。这种哲学适合大量数据的长期存储,比如 100 万个关键帧的图结构、10 GB 的轨迹文件。代价是调试时必须借助专用工具(如 protoc --decode),不能用文本编辑器直接查看。

第二种哲学:人类可读(JSON、CSV)

核心理念是"人机共读"。数据以文本形式存储,任何人打开文件就能理解内容。这种选择牺牲了体积和速度,换取了调试便利性和生态兼容性——几乎所有编程语言都有 JSON 解析器,Web 工具和脚本可以直接处理。适合元数据、小型配置快照、评测轨迹(如 TUM 格式)。代价是大数组文本化后文件巨大(100 万个 3D 点的 JSON 可能达到 200 MB),解析耗时是二进制格式的 10-100 倍。

第三种哲学:配置友好(YAML、TOML)

核心理念是"人写优先"。相比 JSON,YAML 允许注释、支持多行字符串、不需要引号包裹键名,专门为人工编辑优化。但 YAML 的解析规则比 JSON 复杂得多(隐式类型转换、缩进语义、锚点引用),容易在边界情况下产生意外。适合配置文件、参数文件。不适合作为数据交换格式——YAML 的解析性能差,且不同解析器的行为可能不一致。

本质洞察:选择序列化格式不是在选"哪个 API 更方便",而是在回答"这份数据主要给谁看、存多久、有多大"。给机器看的大数据用二进制;给人和机器共看的小数据用 JSON;给人编辑的配置用 YAML。把大点云存成 JSON 是错误;把配置文件存成 Protobuf 也是错误。

哲学 代表格式 优化目标 牺牲的 机器人典型场景
二进制紧凑 Protobuf, FlatBuffers, MessagePack 体积、速度、schema 强制 人类可读性 地图、大型轨迹、关键帧图
人类可读 JSON, CSV 调试便利、生态兼容 体积、解析速度 元数据、评测轨迹、配置快照
配置友好 YAML, TOML 人工编辑体验 解析性能、跨解析器一致性 参数文件、launch 配置

这三种哲学不是互斥的——同一个系统通常同时使用多种格式。SLAM 的参数用 YAML,有效配置快照用 JSON,地图和关键帧图用 Protobuf,轨迹评测用 CSV。选错格式不会导致编译错误,但会在维护成本、文件大小和调试效率上付出代价。

34.8.4 历史和演进:文本、二进制、schema 和数据库

序列化格式大致可以按两个维度分类:

人类可读性
体积和读取速度

无 schema 灵活性
强 schema 兼容性

常见格式:

格式 可读性 体积 速度 schema 适合
JSON 配置、元数据、小轨迹
MessagePack 中小 中高 JSON 结构的紧凑二进制
Protobuf 跨语言消息、轨迹、图结构
FlatBuffers 大结构低拷贝读取
SQLite 数据库 schema 增量写入、查询、索引
PCD 大到中 点云字段 schema 点云地图
自定义二进制 最小 最高 自己维护 极致性能或特定硬件

没有一个格式适合所有数据。

本质洞察:序列化格式的选择本质上是三个维度的权衡——人类可读性、机器效率和 schema 演进能力。JSON/YAML 赢在可读性,人可以直接打开检查;Protobuf/FlatBuffers 赢在效率和 schema 管理,字段编号是长期契约;自定义二进制赢在极致性能,但维护成本最高。没有”最好的格式”,只有”最适合当前数据生命周期的格式”。短期调试用 JSON,长期地图用 Protobuf,点云交换用 PCD,增量存储用 SQLite——同一个系统混用多种格式是常态,不是问题。

格式选择的第一步不是问”哪个最快”。 而是问:

数据以后会被谁读取
是否需要跨语言
是否需要跨版本
是否需要随机访问
是否需要人类手工检查
是否需要增量写入
文件损坏后是否要局部恢复

34.8.4 理论规则:按数据形态选格式

把 SLAM 数据按形态分类:

数据形态 示例 访问模式 推荐格式
小型元数据 版本、参数摘要、统计量 一次读取 JSON / YAML
轨迹 时间戳 + 位姿序列 顺序读写 JSON、CSV、Protobuf
稀疏图 关键帧、边、因子 结构化读取 Protobuf / FlatBuffers / SQLite
稠密点云 点数组 顺序读写、大体积 PCD binary / custom binary
长期地图 子图、关键帧、描述子 按需加载 SQLite / FlatBuffers + blob
实时日志事件 高频小事件 追加写 binary trace / JSON line

注意“点云地图”和“SLAM 地图”不是同一个概念。

点云地图可以只是:

x, y, z, intensity

SLAM 地图还包含:

  1. 谁观测了这个点。
  2. 哪个关键帧拥有它。
  3. 哪些约束连接了关键帧。
  4. 哪些点已经被边缘化。
  5. 哪个坐标系是全局坐标。

只保存 PCD 不能恢复完整 SLAM 状态。 它只能恢复几何点云。

34.8.5 工程边界:序列化边界也是模块边界

一个健康设计通常把序列化放在边界层:

算法内部类型
  ↓ 转换
持久化 DTO
  ↓ 序列化库
文件或数据库

DTO 是 Data Transfer Object。 它不应该包含复杂算法行为。 它只表达要跨进程或跨版本保存的数据。

例如算法内部使用 Sophus:

Sophus::SE3d T_world_body;

持久化结构可以是:

struct PoseDto {
    double stamp_sec = 0.0;
    std::array<double, 3> translation{};
    std::array<double, 4> quaternion_xyzw{};
};

为什么要转换?

  1. 避免把第三方库内部布局暴露到文件格式。
  2. 明确四元数顺序。
  3. 明确时间单位。
  4. 方便 schema 演进。
  5. 方便跨语言读取。

34.8.6 代码验证:序列化最基本的往返测试

往返测试格式:

对象 -> 序列化 -> 反序列化 -> 对象

它保护的是语义不丢失。

struct PoseDto {
    double stamp_sec = 0.0;
    std::array<double, 3> translation{};
    std::array<double, 4> quaternion_xyzw{};
};

void expectPoseNear(const PoseDto& a, const PoseDto& b) {
    EXPECT_NEAR(a.stamp_sec, b.stamp_sec, 1e-12);
    for (int i = 0; i < 3; ++i) {
        EXPECT_NEAR(a.translation[i], b.translation[i], 1e-12);
    }
    for (int i = 0; i < 4; ++i) {
        EXPECT_NEAR(a.quaternion_xyzw[i], b.quaternion_xyzw[i], 1e-12);
    }
}

这只是测试辅助函数。 后面每种格式都可以复用这个思想。

34.8.7 常见陷阱

⚠️ 编程陷阱:用 reinterpret_cast 直接保存内存布局——换平台或编译器后文件读不回

错误做法write(reinterpret_cast<const char*>(&pose), sizeof(pose)) 直接把 C++ 结构体的内存布局写入文件。

现象/后果:在 x86 上保存的地图文件,拿到 ARM 平台上读取时数据全错。或者升级编译器后,由于成员对齐方式改变,旧文件解析出乱码。

根本原因:C++ 结构体的内存布局取决于字节序(x86 小端、某些 ARM 可配置)、成员对齐(编译器和 pragma 控制)、填充字节和类型大小(double 在所有平台都是 8 字节,但 long 不是)。直接写内存等于把所有这些隐式假设硬编码进文件——这不是"序列化",而是"内存快照"。

正确做法:使用字段级格式(Protobuf、FlatBuffers、JSON)或自定义二进制格式并显式写入 magic number、版本号、字节序标志和字段布局描述。序列化的本质是把内存中的类型化数据转换为可移植的字节流,这个转换必须是显式的。

💡 概念误区:认为"保存了点云就等于保存了地图"

新手想法:"PCD 文件有了,SLAM 地图就保存好了。"

实际上:SLAM 地图不只是点云。它是一个图结构——关键帧位姿、关键帧之间的约束(里程计因子、回环因子)、描述子、时间戳和 schema 版本。只保存点云,意味着丢失了所有拓扑信息。下次加载时无法增量建图、无法回环优化、无法定位。这就像只保存了一本书的目录页码,丢掉了所有正文。

正确做法:点云和图结构分开保存。点云用 PCD 或自定义二进制格式;图结构用 Protobuf 或 JSON。两者通过关键帧 ID 关联。schema 版本写入文件头,确保新版本程序能读取或迁移旧数据。

34.8.8 练习

  1. 列出一个关键帧地图除了点云以外还需要保存的 6 类信息。思考:如果缺少其中任何一类,哪些功能会失效?
  2. 解释为什么 Sophus 或 Eigen 类型不应直接成为长期文件格式。提示:这些库的内存布局是否保证跨版本稳定?
  3. PoseDto 增加 frame_id 字段,说明旧文件(没有 frame_id)如何处理。这就是 schema 演进的最基本场景。
  4. 比较 Protobuf 和 JSON 作为地图文件格式的优劣。从文件大小、读写速度、schema 演进和人类可读性四个维度分析,并说明在什么场景下选择哪种。

34.9 JSON 与 MessagePack:适合元数据和小型结构 ⭐⭐

34.9.1 动机:人能读懂的格式仍然很有价值

回顾 34.8.3 的三种序列化哲学:JSON 属于"人机共读"这一类。它不是最快的格式,也不是最紧凑的格式,但它有一个无法替代的优势——透明性。当一个实验结果出问题时,你可以用任何文本编辑器打开 JSON 文件,直接看到每个字段的值。用 Protobuf 做同样的事情需要专用工具(protoc --decode)和 .proto 文件;用自定义二进制格式则需要专门编写解析代码。在排查问题时,"能直接打开看"这个特性的价值远超文件大小的节省。

JSON 的另一个重要优势是生态兼容性。Python 的 json.load() 可以直接解析;JavaScript 原生支持;几乎所有评测工具(evo、rpg_eval、plotjuggler)都能读取 JSON。这意味着你的实验数据可以被团队中使用不同编程语言的人直接使用,不需要为每种语言编写解析器。

但 JSON 有明确的适用边界。回顾前面的序列化哲学——JSON 是为"小数据、需要人看"设计的。一旦数据量增大(比如 10 万个 3D 点),JSON 的文本化表示会导致文件体积膨胀到二进制格式的 5-10 倍,解析速度慢 10-100 倍。此时应切换到 MessagePack(JSON 的二进制近亲,保留了 JSON 的数据模型但用二进制编码)或 Protobuf。

JSON 在机器人项目中最适合的场景:

  1. 实验元数据(git commit、构建选项、参数快照)。
  2. 轨迹摘要(TUM 格式的 JSON 变体)。
  3. 评测结果(ATE、RPE 统计值)。
  4. 小型关键帧索引(帧 ID、时间戳、是否为回环帧)。
  5. 配置摘要。

不适合:

  1. 百万点点云。
  2. 高频 IMU 原始数据。
  3. 大规模描述子矩阵。
  4. 对读取延迟极敏感的地图。

34.9.2 反面失败:用 JSON 保存所有点云

点云写成 JSON 可能长这样:

{
  "points": [
    {"x": 1.0, "y": 2.0, "z": 3.0, "intensity": 0.5},
    {"x": 1.1, "y": 2.1, "z": 3.1, "intensity": 0.6}
  ]
}

两点看起来很好。 两百万点就会很糟。

问题包括:

  1. 文件体积巨大。
  2. 解析需要大量字符串处理。
  3. 浮点文本转换耗时。
  4. 内存峰值高。
  5. 随机访问困难。

所以 JSON 应用于控制信息和小型结构。 大数组应该使用二进制格式或专用点云格式。

34.9.3 历史和演进:JSON 到 MessagePack

MessagePack 可以理解为“二进制 JSON 风格数据”。 它保留类似 map、array、number、string 的数据模型。 但用紧凑二进制编码。

nlohmann/json 同时支持 JSON 文本和 MessagePack。 这让一个 C++ 类型可以用同一套 to_json / from_json 规则转成两种表示。

格式 优势 边界
JSON 文本 可读、易调试 大、慢
MessagePack 紧凑、解析更快 不适合手工查看

它们共享一个问题: schema 主要由代码约定,不像 Protobuf 那样有独立 .proto 文件。

34.9.4 理论规则:用 ADL 定义类型转换

nlohmann/json 常见写法:

#include <nlohmann/json.hpp>

#include <array>
#include <cstdint>
#include <optional>
#include <string>
#include <vector>

using nlohmann::json;

struct PoseDto {
    double stamp_sec = 0.0;
    std::array<double, 3> translation{};
    std::array<double, 4> quaternion_xyzw{};
};

void to_json(json& j, const PoseDto& pose) {
    j = json{
        {"stamp_sec", pose.stamp_sec},
        {"translation", pose.translation},
        {"quaternion_xyzw", pose.quaternion_xyzw},
    };
}

void from_json(const json& j, PoseDto& pose) {
    // schema 兼容性边界:
    // 读取端显式要求必要字段,避免缺字段静默变成默认值。
    j.at("stamp_sec").get_to(pose.stamp_sec);
    j.at("translation").get_to(pose.translation);
    j.at("quaternion_xyzw").get_to(pose.quaternion_xyzw);
}

使用:

PoseDto pose;
pose.stamp_sec = 12.3;
pose.translation = {1.0, 2.0, 3.0};
pose.quaternion_xyzw = {0.0, 0.0, 0.0, 1.0};

json j = pose;
PoseDto recovered = j.get<PoseDto>();

MessagePack:

std::vector<std::uint8_t> bytes = json::to_msgpack(j);
json decoded = json::from_msgpack(bytes);
PoseDto recovered = decoded.get<PoseDto>();

34.9.5 工程边界:atvalue 和兼容读取

JSON 读取有两种常见风格:

j.at("stamp_sec").get_to(pose.stamp_sec);

和:

pose.stamp_sec = j.value("stamp_sec", 0.0);

二者区别:

方法 缺字段行为 适合
at 抛异常 必填字段
value 使用默认值 新增可选字段

如果所有字段都用 value,旧文件会悄悄被读成默认值。 这可能掩盖损坏文件。

如果所有字段都用 at,新增可选字段会破坏旧文件读取。

规则:

对象身份和几何语义字段用 at。
新增可选统计字段用 value。

示例:

struct TrajectoryMetadata {
    std::string schema = "trajectory.v2";
    std::string frame_id = "map";
    std::string config_hash;
    std::optional<std::string> note;
};

void from_json(const json& j, TrajectoryMetadata& meta) {
    j.at("schema").get_to(meta.schema);
    j.at("frame_id").get_to(meta.frame_id);
    j.at("config_hash").get_to(meta.config_hash);

    if (j.contains("note")) {
        meta.note = j.at("note").get<std::string>();
    }
}

34.9.6 代码验证:JSON 和 MessagePack 往返

TEST(PoseJsonTest, RoundTripsThroughJsonText) {
    PoseDto pose;
    pose.stamp_sec = 1.25;
    pose.translation = {1.0, -2.0, 3.5};
    pose.quaternion_xyzw = {0.0, 0.0, 0.70710678, 0.70710678};

    const json j = pose;
    const PoseDto recovered = j.get<PoseDto>();

    expectPoseNear(pose, recovered);
}
TEST(PoseJsonTest, RoundTripsThroughMessagePack) {
    PoseDto pose;
    pose.stamp_sec = 1.25;
    pose.translation = {1.0, -2.0, 3.5};
    pose.quaternion_xyzw = {0.0, 0.0, 0.70710678, 0.70710678};

    const json j = pose;
    const auto bytes = json::to_msgpack(j);
    const json decoded = json::from_msgpack(bytes);

    expectPoseNear(pose, decoded.get<PoseDto>());
}

损坏字段测试:

TEST(PoseJsonTest, RejectsMissingRequiredField) {
    const json j = {
        {"stamp_sec", 1.0},
        {"translation", {1.0, 2.0, 3.0}},
    };

    EXPECT_THROW((void)j.get<PoseDto>(), json::exception);
}

34.9.7 常见陷阱

陷阱 现象 根本原因 正确做法
所有字段都用默认值读取 损坏文件被误读 必填语义丢失 必填字段使用 at
JSON 存大点云 读取慢、文件大 文本格式不适合大数组 PCD 或二进制
四元数字段名写 q 顺序不清 字段语义不足 quaternion_xyzw
MessagePack 当成强 schema 新旧兼容混乱 数据模型仍偏动态 写 schema 字段和兼容测试

34.9.8 练习

  1. TrajectoryMetadata 增加可选字段 dataset_name,要求旧文件仍可读取。
  2. 写一个 JSON 示例,保存三个位姿和一个 schema 字段。
  3. 解释为什么 MessagePack 比 JSON 紧凑,但仍不能替代 Protobuf 的 schema 管理。

34.10 Protobuf 与 FlatBuffers:为跨版本地图设计 schema ⭐⭐⭐

34.10.1 动机:地图文件会活得比类定义更久

一个地图文件可能在今天生成。半年后用新程序加载。一年后用 Python 工具分析。两年后作为基准数据归档。在这两年里,C++ 代码可能经历了数十次重构——类被重命名、成员被添加或删除、数据表示方式被优化。如果地图文件格式和当前 C++ 类定义绑定在一起,每次重构都可能导致旧文件无法读取。

这个问题在机器人研究中特别突出。一篇论文的评测可能基于去年的地图数据,审稿人要求你补充实验时需要重新加载这些地图。如果新版本代码读不了旧地图,你就必须回退到旧版本代码重新运行,或者手动转换数据格式——这两者都费时且容易出错。

所以地图格式需要独立于当前 C++ 类定义。格式应该有自己的生命周期,不随代码重构而失效。

这正是 schema 格式的价值。schema 定义了数据的"契约"——独立于任何编程语言的类定义,可以被不同语言的生成器读取,并通过字段编号和兼容规则支持前向和后向兼容。

schema 文件明确:

  1. 有哪些字段。
  2. 字段编号或布局是什么。
  3. 字段类型是什么。
  4. 哪些字段可选。
  5. 新字段如何添加。
  6. 旧字段如何保留兼容。

Protobuf 和 FlatBuffers 都属于 schema 驱动格式。 但它们的读取模型不同。

34.10.2 反面失败:Boost 或自定义序列化绑定类内部结构

有些项目直接让类自己序列化:

class Keyframe {
public:
    template <class Archive>
    void serialize(Archive& ar) {
        ar & id_;
        ar & pose_;
        ar & descriptors_;
    }

private:
    int id_;
    Sophus::SE3d pose_;
    DescriptorMatrix descriptors_;
};

这种方式方便。 但它把文件格式和类内部结构绑在一起。

一旦类成员重命名、拆分、改变第三方类型,持久化格式就受影响。 更麻烦的是跨语言工具难以读取。

这不一定完全错误。 短期研究工具可以这样做。 长期地图格式不应依赖它。

34.10.3 历史和演进:Protobuf 的消息模型与 FlatBuffers 的表模型

Protobuf 的核心模型是消息。 写入时把对象编码成二进制。 读取时解析成生成的对象。

FlatBuffers 的核心模型是 buffer 中的表。 读取时可以直接在 buffer 上访问字段,减少反序列化拷贝。

对比:

维度 Protobuf FlatBuffers
读取方式 解析成对象 在 buffer 上访问
更新对象 易于构造和修改 构造时通常用 builder
跨语言 很强 很强
schema 演进 成熟 成熟但需要遵守字段规则
随机访问大结构 需要解析相关对象 更适合低拷贝读取
调试文本 有文本格式生态 主要依赖工具

如果地图加载后要频繁修改,Protobuf 的对象模型更自然。 如果地图很大,读取时希望减少拷贝,FlatBuffers 更有吸引力。

34.10.4 Protobuf 理论规则:字段编号是长期契约

示例 .proto

syntax = "proto3";

package mini_slam;

message Pose {
  double stamp_sec = 1;
  double tx = 2;
  double ty = 3;
  double tz = 4;
  double qx = 5;
  double qy = 6;
  double qz = 7;
  double qw = 8;
}

message Keyframe {
  uint64 id = 1;
  Pose pose = 2;
  repeated float descriptor = 3;
}

字段编号 123 是文件兼容的关键。 一旦发布,就不能随便改。

演进规则:

改动 是否安全 说明
添加新字段 通常安全 旧程序会忽略不认识字段
删除字段 有风险 应保留编号,不再复用
改字段编号 不安全 旧数据会被误读
改字段类型 多数不安全 二进制含义变化
重命名字段 对二进制通常安全 对 JSON/text 工具可能有影响

删除字段时使用 reserved

message Keyframe {
  reserved 4;
  reserved "old_score";

  uint64 id = 1;
  Pose pose = 2;
  repeated float descriptor = 3;
}

reserved 的意义是防止未来误用旧编号。

34.10.5 FlatBuffers 理论规则:表字段可演进,布局要守纪律

示例 .fbs

namespace mini_slam;

table Pose {
  stamp_sec:double;
  tx:double;
  ty:double;
  tz:double;
  qx:double;
  qy:double;
  qz:double;
  qw:double;
}

table Keyframe {
  id:ulong;
  pose:Pose (required);
  descriptor:[float];
}

root_type Keyframe;

FlatBuffers 读取:

const mini_slam::Keyframe* keyframe =
    mini_slam::GetKeyframe(buffer.data());

const auto id = keyframe->id();
const auto* pose = keyframe->pose();
if (pose == nullptr) {
    // schema 边界:
    // 读取历史文件或损坏文件时,非标量字段仍可能缺失,不能直接解引用。
    throw std::invalid_argument("keyframe.pose is missing");
}

const auto tx = pose->tx();

读取看起来像访问对象。 但实际数据仍在 buffer 中。

这带来一个生命周期边界:

const auto* keyframe = mini_slam::GetKeyframe(buffer.data());
buffer.clear();
// keyframe 现在悬空,不能再访问。

必须保证 buffer 活得比访问器更久。

兼容规则:

改动 建议
新增字段 给默认值,保持旧读取安全
删除字段 标记 deprecated,不复用语义
改字段类型 避免
改字段含义 新增字段替代
重排已有字段 避免,或使用显式 id 管理

34.10.6 工程边界:Protobuf 和 FlatBuffers 如何选

决策表:

问题 更偏 Protobuf 更偏 FlatBuffers
是否频繁修改对象
是否重视低拷贝读取 一般
是否要流式消息 一般
是否保存大地图索引 可以 很适合
是否团队熟悉程度高 常见 需要学习 builder
是否需要稳定跨语言 schema

地图系统常见组合:

元数据: JSON 或 Protobuf
关键帧图: Protobuf 或 FlatBuffers
稠密点云块: PCD binary 或自定义二进制
索引和增量更新: SQLite

不要强行用一个格式保存所有东西。 混合格式更常见,也更合理。

34.10.7 代码验证:schema 兼容测试

兼容测试思想:

用旧 schema 样例文件
新程序读取
验证必要字段和默认行为

示意:

TEST(MapSchemaTest, ReadsVersion1KeyframeWithVersion2Code) {
    const std::vector<std::uint8_t> bytes =
        readBinaryFile("testdata/keyframe_v1.pb");

    mini_slam::Keyframe keyframe;
    ASSERT_TRUE(keyframe.ParseFromArray(bytes.data(), static_cast<int>(bytes.size())));

    EXPECT_EQ(keyframe.id(), 42);
    EXPECT_TRUE(keyframe.has_pose());

    // schema 兼容性:
    // v2 新增字段在 v1 文件中缺失时,应走明确默认策略。
    EXPECT_EQ(keyframe.sensor_name(), "");
}

FlatBuffers 也需要类似样例文件。 不要只测试“当前代码写出的文件能被当前代码读回”。 那只能证明当前版本自洽。 不能证明跨版本兼容。

34.10.8 常见陷阱

陷阱 现象 根本原因 正确做法
复用 Protobuf 字段编号 旧文件被误读 编号是长期契约 删除字段后 reserved
FlatBuffers 访问器超过 buffer 生命周期 偶发崩溃 零拷贝依赖原始 buffer buffer 持有者必须更长寿
把 schema 当实现细节 工具无法协作 文件格式没有公开接口意识 schema 文件进入版本管理
只做当前版本往返测试 升级后读不了旧数据 缺少样例兼容测试 保存多版本测试数据

34.10.9 练习

  1. Keyframe 新增 sensor_id 字段,分别写出 Protobuf 和 FlatBuffers 的兼容思路。
  2. 解释为什么字段编号比字段名更重要。
  3. 设计一个兼容测试目录,保存 map_v1map_v2 两组样例文件。

34.11 SQLite、PCD 与自定义二进制:地图和轨迹的存储取舍 ⭐⭐⭐

34.11.1 动机:SLAM 地图需要随机访问和增量写入

前面讨论的 Protobuf 和 FlatBuffers 适合"一次性写入、按需读取"的场景——比如算法运行结束后保存地图。但长期运行的机器人面临不同的挑战:它不能等到运行结束才保存数据,因为运行可能持续数小时甚至数天,中途断电或程序崩溃随时可能发生。

一次离线保存可以只写一个文件。长期运行的机器人更复杂。它的数据存储需求可以总结为六种操作:

  1. 增量写入:每隔几秒保存新增关键帧,不需要重写整个地图。
  2. 崩溃恢复:程序崩溃后能从最近的持久化状态恢复,而不是从零开始。
  3. 随机读取:按区域或按时间段加载子图,不需要把整张地图加载到内存。
  4. 条件查询:查询某个时间段的轨迹,或查找与当前描述子最相似的历史关键帧。
  5. 删除过期数据:移除过期的局部地图以控制数据库大小。
  6. 并发安全:建图线程写入的同时,回环线程可能在读取历史数据。

这些需求组合在一起,指向的是数据库而非简单的文件 I/O。SQLite 作为嵌入式数据库,不需要单独的数据库服务器进程,以单文件形式存在,可以嵌入到机器人程序中。它提供了事务(原子写入,崩溃安全)、索引(快速查询)和 SQL(灵活的条件查询),这些能力是二进制文件和 Protobuf 都不具备的。

PCD 则占据了另一个生态位——它是点云工具链(PCL、Open3D、CloudCompare)的通用交换格式。它的价值不在于性能或功能丰富,而在于互操作性:几乎所有点云处理工具都能直接读写 PCD。但 PCD 只保存点云本体,不保存图结构、关键帧关系或回环约束。

自定义二进制格式适合极致性能或严格控制的数据通道——比如实时地图流、高频状态记录、或需要 mmap 直接映射内存的场景。但维护成本最高:每个新字段都需要手动处理序列化/反序列化、版本兼容和字节序。选择自定义二进制格式意味着接受"用开发时间换运行时性能"的权衡。

34.11.2 反面失败:每次保存都重写整张地图

假设地图有 5GB。 每次新增关键帧都重写 map.bin

后果:

  1. 保存耗时长。
  2. 崩溃时文件可能损坏。
  3. SSD 写放大严重。
  4. 无法按需加载。
  5. 多进程分析困难。

增量系统应更像:

新增关键帧 -> 追加记录
新增边 -> 追加记录
点云块 -> 独立 blob 或文件
索引表 -> 更新少量行
事务提交 -> 原子可恢复

这就是数据库的用武之地。

34.11.3 SQLite:单文件数据库和事务边界

SQLite 的特点:

特点 工程意义
单文件 部署简单
事务 崩溃时保持一致性
SQL 查询 轨迹、关键帧、子图可筛选
索引 按时间、id、区域查询
WAL 模式 读写并发体验更好

示例 schema:

CREATE TABLE keyframes (
    id INTEGER PRIMARY KEY,
    stamp_ns INTEGER NOT NULL,
    tx REAL NOT NULL,
    ty REAL NOT NULL,
    tz REAL NOT NULL,
    qx REAL NOT NULL,
    qy REAL NOT NULL,
    qz REAL NOT NULL,
    qw REAL NOT NULL,
    submap_id INTEGER NOT NULL
);

CREATE TABLE factors (
    id INTEGER PRIMARY KEY,
    from_keyframe INTEGER NOT NULL,
    to_keyframe INTEGER NOT NULL,
    type TEXT NOT NULL,
    payload BLOB NOT NULL
);

CREATE INDEX idx_keyframes_stamp ON keyframes(stamp_ns);
CREATE INDEX idx_keyframes_submap ON keyframes(submap_id);

写入事务:

void saveKeyframeTransaction(sqlite3* db, const KeyframeDto& keyframe) {
    execSql(db, "BEGIN IMMEDIATE TRANSACTION");
    try {
        insertKeyframe(db, keyframe);
        insertDescriptors(db, keyframe.id, keyframe.descriptors);
        execSql(db, "COMMIT");
    } catch (...) {
        // 异常边界:
        // 事务内任意一步失败,都回滚到一致状态。
        execSql(db, "ROLLBACK");
        throw;
    }
}

事务是 SQLite 的核心价值之一。 没有事务,数据库只是一个复杂文件。

34.11.4 SQLite 工程边界

SQLite 不适合所有场景。

场景 建议
单机器人本地地图 很适合
多进程大量并发写 谨慎
高频实时写入 先进入缓冲,后台批量提交
超大点云 blob 可存路径或分块 blob
需要网络数据库 SQLite 不是网络服务

实时路径不要直接提交事务。 可以让后端持久化线程批量写入。

Mapping 线程产生 KeyframeDto
有界队列
Persistence 线程批量事务写 SQLite

这里又出现本章反复强调的边界: 热路径产生轻量对象,冷路径做 IO。

34.11.5 PCD:点云交换格式

PCD 是 Point Cloud Library 常用的点云文件格式。 它描述点字段和点数据。

典型头部:

# .PCD v0.7 - Point Cloud Data file format
VERSION 0.7
FIELDS x y z intensity
SIZE 4 4 4 4
TYPE F F F F
COUNT 1 1 1 1
WIDTH 100000
HEIGHT 1
VIEWPOINT 0 0 0 1 0 0 0
POINTS 100000
DATA binary

PCD 的价值:

头部每一行都在描述后面数据的解释方式:

字段 含义 对读取的影响
FIELDS 每个点有哪些字段 决定字段名和顺序,例如 x y z intensity
SIZE 每个字段的字节数 4 常对应 floatint32
TYPE 字段类型 F 浮点,I 有符号整数,U 无符号整数
COUNT 一个字段包含几个数 法向量、直方图描述子可能大于 1
WIDTH 每行点数或无组织点总数 HEIGHT 一起决定点云形状
HEIGHT 有组织点云的行数 1 表示普通无组织点云
POINTS 点总数 通常应等于 WIDTH * HEIGHT
DATA 存储方式 asciibinarybinary_compressed

WIDTH=100000, HEIGHT=1 表示无组织点云。 如果来自深度相机,可能是 WIDTH=640, HEIGHT=480,这时点的二维邻接关系仍然有意义。

DATA binary 时,头部后面紧跟点数据。 每个点按 FIELDS 顺序排列:

x(float32) y(float32) z(float32) intensity(float32)
x(float32) y(float32) z(float32) intensity(float32)
...

这就是为什么 FIELDS/SIZE/TYPE/COUNT 必须一起看。 只知道字段名,不知道字段宽度和类型,读取端无法正确移动字节偏移。

  1. PCL 工具直接支持。
  2. 字段名明确。
  3. 支持 ASCII、binary、binary_compressed。
  4. 适合点云地图交换。

PCD 的边界:

  1. 不表达因子图。
  2. 不表达关键帧连接关系。
  3. 不适合频繁增量更新一个大文件。
  4. 复杂描述子和多表索引不自然。

所以它适合作为点云块格式,而不是完整 SLAM 地图格式。

34.11.6 自定义二进制:只有在你能维护契约时才使用

自定义二进制格式可以最快、最小、最贴合数据。 但你必须自己承担所有格式责任。

一个基本文件头应包含:

字段 作用
magic 判断文件类型
version 判断 schema
endian 字节序
header_size 跳过头部
record_count 记录数量
flags 压缩、量化等选项
checksum 检测损坏

示意:

#include <array>
#include <cstdint>
#include <ostream>

struct BinaryMapHeader {
    std::array<char, 8> magic{'M', 'I', 'N', 'I', 'M', 'A', 'P', '\0'};
    std::uint32_t version = 1;
    std::uint8_t endian = 1;  // 1 表示 little-endian,2 表示 big-endian。
    std::array<std::uint8_t, 3> reserved{};
    std::uint32_t header_size = sizeof(BinaryMapHeader);
    std::uint64_t keyframe_count = 0;
    std::uint64_t point_count = 0;
    std::uint32_t flags = 0;
    std::uint32_t header_crc32 = 0;
};

写入时不要直接假设机器字节序。 至少要在文档中明确“小端编码”。 跨平台项目应使用显式编码函数:

void writeLittleEndianU32(std::ostream& out, std::uint32_t value) {
    const std::array<unsigned char, 4> bytes{
        static_cast<unsigned char>(value & 0xffu),
        static_cast<unsigned char>((value >> 8) & 0xffu),
        static_cast<unsigned char>((value >> 16) & 0xffu),
        static_cast<unsigned char>((value >> 24) & 0xffu),
    };
    out.write(reinterpret_cast<const char*>(bytes.data()), bytes.size());
}

自定义二进制的反事实风险很直接:

  1. 第一版很快。
  2. 第二版加字段。
  3. 第三版需要兼容旧文件。
  4. 第四版需要 Python 分析工具。
  5. 最终你维护了一个不完整的 Protobuf 或 FlatBuffers。

所以只有在性能、体积或硬件约束确实需要时才使用。

34.11.7 轨迹存储:TUM、KITTI、自定义 JSON

轨迹文件常用于评测。

TUM 风格:

timestamp tx ty tz qx qy qz qw

KITTI 风格通常是每行一个 3x4 位姿矩阵。

它们适合评测工具。 但语义字段少。

如果做实验归档,建议旁边放一个元数据文件:

{
  "schema": "trajectory_metadata.v1",
  "format": "tum",
  "frame_id": "map",
  "child_frame_id": "body",
  "time_unit": "sec",
  "quaternion_order": "xyzw",
  "config_hash": "sha256:8f12"
}

不要指望轨迹文本本身表达所有语义。 评测格式和归档格式目标不同。

34.11.8 代码验证:损坏文件和版本检查

自定义二进制至少要测三类错误:

TEST(BinaryMapTest, RejectsWrongMagic) {
    BinaryMapHeader header;
    header.magic = {'B', 'A', 'D', 'M', 'A', 'P', '\0', '\0'};

    EXPECT_THROW(validateHeader(header), std::invalid_argument);
}
TEST(BinaryMapTest, RejectsUnsupportedVersion) {
    BinaryMapHeader header;
    header.version = 999;

    EXPECT_THROW(validateHeader(header), std::invalid_argument);
}
#include <limits>

TEST(BinaryMapTest, RejectsImpossibleCounts) {
    BinaryMapHeader header;
    header.keyframe_count = 100;
    header.point_count = std::numeric_limits<std::uint64_t>::max();

    EXPECT_THROW(validateHeader(header), std::invalid_argument);
}

这些测试保护的是文件边界。 不要让损坏文件进入算法内部。

34.11.9 常见陷阱

陷阱 现象 根本原因 正确做法
SQLite 每帧单次提交 写入抖动 事务过频 后台批量事务
PCD 当完整地图 回环和关键帧丢失 PCD 只表达点云 图结构另存
自定义二进制无 magic 误读文件 缺少类型识别 文件头写 magic 和版本
轨迹无坐标系 评测结果错 frame 语义缺失 元数据写 frame 和四元数顺序

34.11.10 练习

  1. 设计一个 SQLite 表结构,支持按 submap_id 查询点云块文件路径。
  2. 说明 PCD binary 和 binary_compressed 在调试和读取速度上的取舍。
  3. 为自定义二进制地图设计 8 字节 magic 和版本升级策略。

34.12 组合设计:一个可复现的 Mini-SLAM 运行目录 ⭐⭐

34.12.1 动机:单个文件不能承载所有语义

本章前面的各节分别解决了日志(如何记录过程)、配置(如何追踪意图)和序列化(如何保存结果)的独立问题。本节把它们组合在一起,形成一个完整的可复现实验架构。

实验可复现性不是靠记住一个随机种子就能实现的。机器人实验的复现至少需要:输入数据的来源和版本、程序版本(git commit)、构建选项(Release/Debug、编译器版本)、有效配置(所有参数的实际生效值)、运行环境(操作系统、硬件型号)、关键日志(中间决策的证据)、输出数据(轨迹、地图)、以及数据的 schema 版本(确保新程序能读取旧数据)。这八类信息缺少任何一类,复现就可能退化为"看起来类似但无法验证"。

把这八类信息组织到一个一致的目录结构中,就是运行目录的核心思想。工程上更稳的方式是运行目录。

一次运行输出:

run_2026_05_13_120000/
  metadata.json
  effective_config.yaml
  logs/
    slam.log
    realtime_summary.log
  trajectory/
    trajectory.tum
    trajectory_metadata.json
  map/
    graph.pb
    submaps.sqlite
    clouds/
      submap_0001.pcd
      submap_0002.pcd

这个结构承认不同数据有不同格式。

文件 格式 原因
metadata.json JSON 人可读,记录版本和哈希
effective_config.yaml YAML 保留参数层级
slam.log text / key-value 可 grep
trajectory.tum TUM text 兼容评测工具
graph.pb Protobuf 因子图跨语言
submaps.sqlite SQLite 查询子图索引
submap_0001.pcd PCD binary 点云工具兼容

34.12.2 反面失败:一个 result.bin 管所有东西

一个大文件看起来简单。

result.bin

但它让很多问题变难:

  1. 查看元数据要写专门工具。
  2. 评测轨迹要先导出。
  3. 单个子图损坏可能影响整个文件。
  4. 小改动也要重写大文件。
  5. Python 工具难以按需读取。

除非有明确性能理由,否则不要把所有数据塞进一个黑盒。

34.12.3 理论规则:索引、数据和元数据分离

推荐三层:

元数据层: 版本、配置、坐标系、时间单位
索引层: keyframe、submap、文件路径、时间范围
数据层: 点云、描述子、轨迹、图结构

分离后,每层可以选择合适格式。

这类似数据库中的“表”和“blob”分离。 小字段用于查询。 大数据按路径或 blob 存储。

34.12.4 工程边界:路径也是持久化接口

如果数据库里保存绝对路径:

/home/user/experiments/run/map/clouds/submap_0001.pcd

移动目录后就失效。

更好的方式是保存相对路径:

map/clouds/submap_0001.pcd

然后以运行目录作为根解析。

路径规则也要进入测试。

TEST(RunDirectoryTest, StoresRelativeCloudPaths) {
    const SubmapIndex index{
        .submap_id = 1,
        .cloud_path = "map/clouds/submap_0001.pcd",
    };

    EXPECT_FALSE(std::filesystem::path(index.cloud_path).is_absolute());
}

34.12.5 代码验证:运行目录完整性检查

struct RunDirectoryCheckResult {
    bool ok = false;
    std::vector<std::string> missing_files;
};

RunDirectoryCheckResult checkRunDirectory(const std::filesystem::path& root) {
    RunDirectoryCheckResult result;

    const std::array<std::filesystem::path, 8> required{
        "metadata.json",
        "effective_config.yaml",
        "logs/slam.log",
        "trajectory/trajectory.tum",
        "trajectory/trajectory_metadata.json",
        "map/graph.pb",
        "map/submaps.sqlite",
        "map/clouds/submap_0001.pcd",
    };

    for (const auto& rel : required) {
        if (!std::filesystem::exists(root / rel)) {
            result.missing_files.push_back(rel.string());
        }
    }

    result.ok = result.missing_files.empty();
    return result;
}

测试:

TEST(RunDirectoryTest, ReportsMissingArtifacts) {
    const auto root = makeTemporaryDirectory();
    writeText(root / "metadata.json", "{}");

    const auto result = checkRunDirectory(root);

    EXPECT_FALSE(result.ok);
    EXPECT_THAT(result.missing_files,
                ::testing::Contains("effective_config.yaml"));
}

这类检查适合在实验结束时运行。 它不会证明算法正确。 但能证明复现材料没有明显缺失。

34.12.6 常见陷阱

陷阱 现象 根本原因 正确做法
输出目录无结构 文件难找 没有数据分层 固定运行目录布局
保存绝对路径 换机器失效 路径绑定本机 保存相对路径
元数据和数据分离丢失 无法判断版本 缺少完整性检查 结束时检查必要文件
轨迹和地图坐标系不一致 评测错 frame 未记录 元数据写 frame 关系

34.12.7 练习

  1. 为一个多机器人建图实验设计运行目录,要求区分 robot_id
  2. 写一个完整性检查表,覆盖日志、配置、轨迹、地图和版本信息。
  3. 解释为什么相对路径比绝对路径更适合归档。

34.13 故障排查手册 ⭐⭐

症状 可能原因 排查步骤 相关主题
日志文件巨大但没有可用信息 没有结构化字段,级别设计混乱 先按 event= 检查字段,再统计 info/debug 比例,最后抽样看是否记录阈值和原因 结构化日志
控制循环偶发超时 同步日志或格式化进入热路径 用 trace 看超时帧,搜索热路径 logger 调用,检查是否有字符串构造和阻塞 sink 实时日志边界
配置文件换数据集后启动失败 YAML 类型错误或缺必填字段 查看错误消息是否含完整参数名,运行配置加载单元测试,输出有效配置 YAML 校验
轨迹无法复现 没保存有效配置或程序版本 检查运行目录是否有 metadata.jsoneffective_config.yaml 和 config hash 运行目录
新版本读不了旧地图 schema 演进规则被破坏 找到旧样例文件,运行兼容测试,检查字段编号或 FlatBuffers 字段变更 schema 兼容
点云能加载但回环不能恢复 只保存了 PCD,没有保存图结构 检查是否有 keyframe、factor、descriptor 数据 地图存储
SQLite 写入卡顿 实时线程直接提交事务 检查写入线程,改为队列加后台批量事务 SQLite 边界
JSON 读取很慢 大数组文本化 统计文件大小和解析耗时,改用 MessagePack、PCD 或二进制格式 JSON 边界
FlatBuffers 访问偶发崩溃 buffer 生命周期短于访问器 检查 buffer 所有权,确保访问器不跨越 buffer 释放 FlatBuffers 生命周期

排查顺序建议:

先确认配置有效
再确认日志字段能解释决策
再确认输出文件 schema 和版本
最后进入算法内部调试

很多”算法问题”最终会回到配置、日志和数据契约。这不是算法不重要,而是算法运行在这些契约之上。就像一座建筑的结构问题可能不是设计图纸的错,而是地基处理不当——同样,SLAM 轨迹发散可能不是优化算法的错,而是外参配置缺失、日志没有记录匹配分数、或者地图文件不包含图结构导致回环无法恢复。

故障排查的正确顺序是从外向内:先排查基础设施(配置是否正确?日志是否记录了决策?数据文件是否完整?),再排查算法(数学公式是否正确?数值是否稳定?参数是否合理?)。这个顺序的理由是基础设施问题通常更容易验证(检查一个文件是否存在比验证一个非线性优化是否收敛更简单),而且基础设施问题的误导性更强(一个缺失的外参会让所有”算法调参”都白费)。

本章的一个核心教训是:当系统出现问题时,不要急于”改算法”。先用本章的工具检查证据链是否完整——有效配置是否保存了?日志是否记录了关键决策的数值依据?输出文件的 schema 版本是否一致?很多时候,修复证据链本身就能揭示问题的真正根因。


34.14 累积项目:为 Mini-SLAM 增加运行时基础设施 ⭐⭐⭐

34.14.1 项目目标

为一个 Mini-SLAM 核心库增加四个模块:

mini_slam_runtime/
  logging/
    logger_factory.hpp
    structured_events.hpp
  config/
    slam_config.hpp
    yaml_config_loader.hpp
  serialization/
    pose_json.hpp
    map_schema.proto
  storage/
    run_directory.hpp

目标不是追求完整框架。 目标是把本章的不变量落实到最小工程骨架。

34.14.2 模块职责

模块 职责 必测不变量
logger_factory 创建分层 logger 和 sink logger 名称、级别、pattern
structured_events 统一格式化关键事件 字段名稳定
yaml_config_loader 加载并验证配置 默认值、类型、范围
pose_json 轨迹 JSON/MessagePack 往返和缺字段
map_schema.proto 关键帧图 schema 兼容样例
run_directory 输出目录检查 必要文件存在

34.14.3 建议测试清单

LoggerFactoryTest.CreatesHierarchicalLogger
StructuredEventsTest.FrameDroppedContainsReason
YamlConfigTest.UsesDefaults
YamlConfigTest.RejectsInvalidRange
YamlConfigTest.RejectsMissingCalibration
PoseJsonTest.RoundTripsJson
PoseJsonTest.RoundTripsMessagePack
MapSchemaTest.ReadsVersion1Sample
RunDirectoryTest.ReportsMissingArtifacts

这些测试不是形式。 它们分别固定本章四类边界:

  1. 日志字段边界。
  2. 配置合法性边界。
  3. 序列化 schema 边界。
  4. 实验归档边界。

34.14.4 运行时流程

main()
解析命令行
加载多层 YAML
生成 SlamConfig
保存 effective_config.yaml
创建 logger factory
构造 Tracking/Mapping/Backend
注入 logger 和不可变配置
运行系统
保存轨迹、地图和 metadata
检查运行目录完整性

注意顺序。 配置应在模块构造前完成。 日志应在模块构造前可用。 输出目录应在运行前创建。 地图和轨迹应在停机阶段保存并 flush。

34.14.5 最小 main 结构

int main(int argc, char** argv) {
    const CommandLine cli = parseCommandLine(argc, argv);

    SlamConfig config = loadConfigLayers(cli.config_paths, cli.overrides);
    validate(config);

    RunDirectory run_dir(cli.output_root);
    run_dir.create();
    run_dir.writeEffectiveConfig(config);

    auto logger_factory = LoggerFactory(run_dir.logDirectory());
    auto system_logger = logger_factory.create("MiniSlam.System");

    system_logger->info("event=system_start config_hash={} output_dir={}",
                        config.hash,
                        run_dir.path().string());

    MiniSlamSystem system(config);
    system.setLogger(logger_factory.create("MiniSlam.Core"));

    int exit_code = 0;
    try {
        system.run(cli.input);
        system.saveOutputs(run_dir);
    } catch (const std::exception& e) {
        // 异常边界:
        // 顶层捕获负责记录失败原因和输出目录,但不在这里直接 return。
        // 异步日志需要统一 shutdown 才有机会刷出最后的错误消息。
        system_logger->error("event=system_failed reason={} output_dir={}",
                             e.what(),
                             run_dir.path().string());
        exit_code = 1;
    }

    if (exit_code == 0) {
        system_logger->info("event=system_done output_dir={}", run_dir.path().string());
    }

    spdlog::shutdown();
    return exit_code;
}

这里有几个重要边界:

  1. 配置错误在系统构造前暴露。
  2. 顶层捕获异常并记录输出目录。
  3. spdlog::shutdown() 放在退出前,给异步日志机会收尾。
  4. 模块获得配置对象,而不是自己读取 YAML。

34.14.6 练习

  1. 按上面的目录结构实现 RunDirectorycreate()writeMetadata()
  2. MiniSlamSystem 设计构造函数,使它只接收已经验证过的 SlamConfig
  3. 写一个端到端小测试:加载配置、创建运行目录、写入空轨迹、检查必要文件。

34.15 本章小结 ⭐

本章从运行时基础设施的角度补齐了 代码质量测试与调试 之后的工程链条。代码质量测试与调试 解决的是"开发阶段如何尽早暴露错误";日志配置与序列化 解决的是"运行阶段如何让行为可追踪、可复现、可演进"。两者的关系是互补的——代码质量测试与调试 的测试需要 日志配置与序列化 的日志和配置作为输入证据(测试失败时日志和有效配置是定位根因的第一手材料),日志配置与序列化 的运行时基础设施需要 代码质量测试与调试 的测试来保证自身的正确性(配置加载、日志字段和序列化往返都需要单元测试)。

回顾本章开头提出的核心痛点:生产环境出 bug 但无法复现。现在我们可以给出系统性的解决方案——建立完整的运行时证据链:

  1. **配置管理**确保我们知道系统"按什么参数运行"(意图可追踪)。
  2. **结构化日志**确保我们知道系统"实际做了什么决策"(过程可查询)。
  3. **序列化和存储**确保系统"产出了什么数据"可以被保存和跨版本读取(结果可持久化)。

这三者合在一起,构成了"第二控制面"——它不直接控制机器人的物理运动,但它决定了物理运动出问题时能否被诊断和修复。

本章的另一个核心教训是**热路径和冷路径的分离**。日志、配置和序列化都有副作用(文件 I/O、内存分配、锁竞争),这些副作用如果进入实时控制路径,会导致控制周期不确定,表现为间歇性抖动。正确做法是:热路径只产生轻量事件(原子计数器、固定大小缓冲区),冷路径负责格式化、写盘和分析。这个原则贯穿了本章的所有小节。

核心结论可以压缩成一张表:

主题 核心问题 关键规则
spdlog 如何可靠记录运行事件 logger、sink、level、pattern 分层设计
结构化日志 如何让日志可查询 字段是接口,记录决策变量和阈值
异步日志 如何避免 IO 阻塞业务线程 明确队列容量和溢出策略
实时日志 如何不破坏热路径 热路径只写固定大小轻量事件
日志注入 如何让组件可测试 创建权在系统层,组件只使用注入 logger
source_location 如何保留调用点 默认参数捕获调用位置,宏仍适合编译期过滤
YAML 配置 如何从文件到有效对象 默认值、类型检查、范围校验分层
配置层级 如何支持多机器人多数据集 后层覆盖前层,保存有效配置
运行时参数 如何安全变更 不可变快照,明确生效边界
JSON/MessagePack 如何保存小型结构 必填字段用 at,大数组不要文本化
Protobuf 如何跨语言跨版本 字段编号是长期契约
FlatBuffers 如何低拷贝读取大结构 buffer 生命周期必须长于访问器
SQLite 如何增量保存和查询 后台批量事务,索引查询字段
PCD 如何交换点云 保存点云本体,不保存完整 SLAM 图
自定义二进制 如何追求极致性能 magic、version、endian、checksum 缺一不可

如果只记住一句话:

好的机器人运行时系统,不是从不失败,而是失败时留下足够证据,让下一次修复能被测试固定下来。

下一组内容会进入内存与缓存优化。 那里关注的是运行时性能的另一面: 在日志、配置和持久化边界清晰之后,如何减少分配、改善缓存局部性,并控制大规模点云和图优化的数据移动成本。


34.16 延伸阅读 ⭐

主题 资料 阅读建议
spdlog https://github.com/gabime/spdlog 阅读 README、async logging 和 sinks 示例
yaml-cpp https://github.com/jbeder/yaml-cpp/wiki/Tutorial 重点看 LoadFileas<T> 和异常行为
nlohmann/json https://json.nlohmann.me/ 重点看 arbitrary type conversions 和 binary formats
Protobuf https://protobuf.dev/programming-guides/proto3/ 重点看字段编号、reserved 和 message 更新规则
FlatBuffers https://flatbuffers.dev/ 重点看 schema evolution 和 C++ 使用方式
PCD https://pointclouds.org/documentation/tutorials/pcd_file_format.html 重点看 FIELDS、TYPE、COUNT 和 DATA
SQLite https://www.sqlite.org/docs.html 重点看 transactions、WAL 和 file format guarantees
C++20 source_location https://en.cppreference.com/w/cpp/utility/source_location 重点看默认参数捕获调用点

34.17 跨章综合练习 ⭐⭐

  1. 结合 C++语言核心/错误处理与异常安全 的异常安全,设计一个配置加载流程: 加载失败时任何传感器、线程和文件句柄都不能进入半初始化状态。
  2. 结合 并发与系统编程/线程管理与互斥同步 和 并发与系统编程/原子操作与内存模型,分析运行时参数快照为什么使用不可变对象比共享可变对象更容易证明线程安全。
  3. 结合 并发与系统编程/实时约束与高性能数据传递,设计一个 1kHz 控制循环可用的实时诊断方案: 要求热路径不写文件、不分配内存、不格式化字符串。
  4. 结合 代码质量测试与调试,给 YAML 参数系统、JSON 轨迹和 Protobuf 地图各写一个最小测试不变量。
  5. 设计一个 Mini-SLAM 运行目录,并说明每个文件为什么选择对应格式。