3、日志数据结构解析:理解ulog格式、消息类型与时间同步

说实话,我第一次打开一个 .ulog 文件时,脑子里就一个想法——这堆二进制数据到底在说什么?

后来我花了整整两天,把 PX4 的 ulog 规范从头啃了一遍。嗯,从那以后,看日志就像看自己的掌纹一样清楚。这一章,我就带你把这个「二进制黑盒」彻底拆开。

3.1 ulog 文件整体结构

ulog 不是那种「从头读到尾」的简单格式。它更像一本带目录的书——前面是总览,后面是详细内容。

整个文件由三个大块组成:

  • 文件头(Header):固定 16 字节,告诉你「这是 ulog 文件」以及它的版本。
  • 消息流(Message Stream):主体部分,包含定义、数据、标记。
  • 索引区(Index Section,可选):方便快速跳转的「书签」。

核心要点:ulog 是 基于消息的、自描述的、带时间戳的二进制日志格式。自描述的意思是——日志文件自己就包含了所有字段的名字和类型,不需要外部定义文件。

我早期调试时犯过一个低级错误:用十六进制编辑器直接改 ulog 文件里的一个字节,结果整个文件解析失败。后来才明白,ulog 的每个消息块都有严格的长度校验,改一个字节,CRC 校验就过不去。

3.2 消息类型详解

ulog 的消息流里,每一条消息都遵循同一个套路:

【消息头】 + 【消息体】

消息头只有 3 个字节:

偏移 大小 含义
0 2 字节 消息长度(包含头,小端序)
2 1 字节 消息类型('B'、'I'、'M'、'D'、'L'、'C'、'S' 等)

你看,就这么简单。长度 + 类型,后面跟着的就是对应类型的消息体。

3.2.1 标记块(Flag Bits)—— 'B' 消息

这是文件里第一条消息。它告诉解析器:这个日志用了什么格式标记。

目前只有一个标记有用:0x01 表示「包含默认参数」。如果这个位没置,那日志里就没有参数段。

我的习惯:拿到一个陌生日志,我第一件事就是看这个标记位。如果它没置 1,那很多地面站解析参数时会报错,别慌,不是日志坏了,是它本来就没存参数。

3.2.2 信息块(Info Message)—— 'I' 消息

这类消息存的是键值对。比如:

key: "sys_name"
value: "PX4"

或者:

key: "ver_sw"
value: "1.14.0"

我经常用 'I' 消息来确认固件版本。有一次用户反馈日志异常,我一看 'I' 消息里的版本号,发现他刷错了固件分支。嗯,这种问题排查起来其实很快。

3.2.3 多信息块(Multi Info Message)—— 'M' 消息

当某个键的值太长(比如完整的编译参数),一条 'I' 消息装不下,就会拆成多条 'M' 消息。

格式上,'M' 消息比 'I' 多了一个 分片序号 字段。解析时要把同 key 的多个分片拼起来。

3.2.4 数据块(Data Message)—— 'D' 消息

这是日志里占比最大的消息类型。你关心的所有传感器数据、姿态估计、控制输出,全在这里。

'D' 消息的结构:

【消息头 3 字节】 + 【订阅 ID 2 字节】 + 【数据负载 N 字节】

订阅 ID 指向一个事先定义好的「数据定义」。这个定义在 'A'(添加定义)消息里。

我曾经踩过的坑:解析 'D' 消息时,如果订阅 ID 找不到对应的定义,那整条消息就是废的。所以解析顺序必须是:先处理所有 'A' 消息建立定义表,再处理 'D' 消息。顺序搞反了,数据全乱套。

3.2.5 添加定义(Add Definition)—— 'A' 消息

'A' 消息定义了一个数据的「骨架」:

struct sensor_accel_s {
    uint64_t timestamp;
    float x;
    float y;
    float z;
    uint32_t error_count;
}

解析器读到这个定义后,再遇到对应的 'D' 消息,就知道怎么把二进制字节翻译成有意义的物理量了。

3.2.6 日志结束(Logging End)—— 'L' 消息

就一个字节的 'L'。表示日志正常结束。如果日志文件突然中断(比如飞控掉电),没有这个 'L',解析器就知道日志不完整。

3.2.7 同步字节(Sync Byte)—— 0x2F

ulog 文件里,每一条消息的 第一个字节 必须是 0x2F。这是同步标记。

为什么需要它?因为日志是流式写入的,如果中间有字节错位,解析器可以通过扫描 0x2F 来重新同步。

实际排查技巧:如果你用自己写的解析器读日志,发现某条消息长度异常,别急着报错。先往后扫描下一个 0x2F,跳过损坏的消息,继续解析后面的内容。这样至少能拿到部分有效数据。

3.3 时间戳与同步机制

时间戳是日志分析里最容易出问题的地方。我见过太多人因为时间戳理解错误,画出来的曲线时间轴是乱的。

3.3.1 两种时间戳

类型 位置 含义
文件时间戳 文件头第 8~15 字节 飞控开机到开始记录日志的微秒数
消息时间戳 数据定义中的第一个 uint64_t 字段 该数据采样时刻的微秒数(相对于飞控开机)

注意:消息时间戳 不是 写入日志的时间,而是传感器采样的时间。这两个时间可能差几十微秒到几毫秒。

3.3.2 时间同步问题

PX4 内部有多个时钟源:

  • 硬件定时器:高精度,但可能溢出
  • 系统时间:由 RTOS 维护
  • GPS 时间:绝对时间,但更新频率低

日志里的时间戳默认使用硬件定时器。但如果你同时记录多个传感器(比如 IMU 和 GPS),它们的时间基准可能不同步。

我遇到过的情况:有一次分析 GPS 融合异常,发现 IMU 时间戳和 GPS 时间戳差了整整 200 毫秒。排查到最后,是 GPS 驱动里时间戳转换代码写错了。如果没有理解 ulog 的时间戳机制,这种问题根本无从下手。

3.3.3 时间戳的解析方法

解析时,我一般这样做:

  1. 读取文件头里的起始时间 t0
  2. 每解析一条 'D' 消息,提取它的第一个 uint64_t 字段作为 t_msg
  3. 绝对时间 = t0 + t_msg(单位微秒)

但要注意:有些消息(比如字符串日志)的第一个字段不是时间戳。这时候需要查定义,确认哪个字段是时间戳。

3.4 整体数据流关系图

下面这张图,把 ulog 文件从写入到解析的完整流程画出来了。我每次培训都会先讲这张图——理解了它,后面所有分析技巧都有了根基。

ulog 数据流与解析流程 ulog 文件结构 & 解析流程 文件头(16 字节) 消息流(主体) 索引区(可选) 消息流 = 多条消息串联,每条消息格式:【同步字节 0x2F】 + 【消息头 3 字节】 + 【消息体】 'B' 标记块 'I' 信息块 'A' 添加定义 'D' 数据块 'L' 结束块 'M' 多信息 ① 读取文件头 ② 建立定义表 ③ 解析数据块 ④ 时间戳对齐 💡 解析关键点: • 先处理所有 'A' 消息,建立完整的订阅 ID → 数据结构映射表 • 再处理 'D' 消息,根据订阅 ID 查找对应定义来解析二进制负载 • 时间戳 = 文件头起始时间 + 消息内第一个 uint64_t 字段值(微秒)

3.5 实战:手写一个极简解析器思路

理解了上面的结构,写一个解析器其实就几步:

  1. 打开文件,读前 16 字节,校验 magic number(0x55 0x4C 0x6F 0x67)
  2. 从第 16 字节开始,循环读取消息:
    • 读 1 字节,检查是不是 0x2F(同步字节)
    • 读 2 字节,得到消息长度
    • 读 1 字节,得到消息类型
    • 根据类型解析消息体
  3. 遇到 'A' 消息,存入定义表
  4. 遇到 'D' 消息,查定义表,按格式解析数据
  5. 遇到 'L' 消息,停止解析

我个人的建议:别自己从头写解析器。PX4 官方提供了 pyulog 库,直接用。但理解底层结构能帮你快速定位问题——比如某个字段解析出来数值异常,你能立刻想到是字节序问题还是字段偏移算错了。

3.6 本章小结

ulog 格式并不复杂,但细节很多。记住三个核心:

  • 自描述:定义和数据分离,解析器必须先读定义再读数据
  • 流式结构:每条消息以 0x2F 开头,长度 + 类型 + 负载
  • 时间戳:相对时间,需要加上文件头起始时间才能得到绝对时间