3. ULog文件解析:从二进制流到结构化数据

拿到一份PX4日志,第一件事是什么?不是急着画曲线,而是先搞懂它的骨架——ULog文件格式。说白了,ULog就是一套二进制协议,把飞控上各种传感器、状态估计、控制指令一股脑塞进一个文件里。你想想看,如果连文件头都读不对,后面分析再多也是白搭。

我个人习惯,拿到一个新日志文件,先用十六进制工具瞄一眼开头几个字节。嗯,看到"ULog"三个ASCII字符,心里就有底了。这就像敲门暗号,对上了才能进门。

3.1 ULog文件头:文件的身份证

每个ULog文件的开头,固定是16字节的文件头。不多不少,就16个。我刚开始接触时,总觉得这头信息太少,后来才发现——够用就行。

偏移量 大小(字节) 字段 说明
0 4 Magic 固定为"ULog" (0x55 0x4C 0x6F 0x67)
4 8 Timestamp 文件创建时间戳,微秒级
12 4 Version ULog版本号,目前固定为1

关键点:Magic字段是校验文件完整性的第一道关卡。我曾经遇到过日志文件被截断的情况,Magic字段对不上,解析器直接报错。所以,第一步检查Magic,能帮你快速排除损坏文件。

文件头之后,紧接着就是消息定义部分。这部分记录了日志里所有可能出现的消息类型。你可以把它想象成一本字典——后面数据部分每一条记录,都要靠这本字典来翻译。

3.2 消息定义:日志的字典

消息定义部分,说白了就是一堆"定义消息"。每个定义消息包含:消息名称、字段列表、每个字段的类型和名称。飞控在记录日志时,会先把这些定义写进去,然后再写实际数据。

为什么会这样设计?我琢磨过这个问题。因为PX4的日志系统需要支持动态添加消息类型——你今天飞的是多旋翼,明天换成固定翼,传感器配置可能完全不同。如果消息定义是硬编码的,那解析器就得跟着改。ULog这种设计,让解析器变得通用,只要按定义来就行。

消息定义的格式如下:

struct message_definition {
    uint16_t msg_size;        // 消息体大小
    uint8_t msg_type;         // 消息类型,定义消息固定为 'B' (0x42)
    char name[16];            // 消息名称,如"sensor_accel"
    uint8_t field_count;      // 字段数量
    // 每个字段定义:
    struct {
        char type[8];         // 字段类型,如"float", "int32_t"
        char name[16];        // 字段名称,如"x", "y", "z"
    } fields[field_count];
};

实战技巧:我在解析日志时,习惯先把所有消息定义提取出来,建一个哈希表。后面数据部分每来一条记录,直接查表就能知道字段名和类型。这样解析速度能快不少。

3.3 数据部分:真正的干货

消息定义之后,就是数据部分了。这部分由一条条数据消息组成,每条数据消息都对应一个之前定义过的消息类型。

数据消息的格式很简单:

struct data_message {
    uint16_t msg_size;        // 消息体大小
    uint8_t msg_type;         // 消息类型,数据消息固定为 'D' (0x44)
    uint16_t msg_id;          // 消息ID,对应定义消息的序号
    uint64_t timestamp;       // 时间戳,微秒级
    uint8_t data[msg_size - 8]; // 实际数据,按定义中的字段顺序排列
};

这里有个坑,我踩过。msg_size是整个消息体的大小,包括msg_id、timestamp和data。但data的大小是msg_size减去8(msg_id占2字节,timestamp占6字节)。有些解析器实现时,忘了减这个偏移量,导致数据错位。嗯,要注意。

数据部分的时间戳,是飞控系统的时间,单位是微秒。不同消息的时间戳可能不一样,因为传感器数据是异步采集的。分析时,我通常会把所有消息按时间戳排序,这样才能看到完整的时间线。

3.4 日志索引结构:快速定位的秘诀

ULog文件还有一个隐藏结构——索引部分。它位于文件末尾,记录了每条数据消息在文件中的偏移量。有了索引,解析器就可以随机访问任意时间点的数据,而不需要从头扫描整个文件。

索引部分的格式:

struct index_entry {
    uint64_t timestamp;       // 消息时间戳
    uint64_t file_offset;     // 消息在文件中的偏移量
};

struct index_section {
    uint32_t entry_count;     // 索引条目数量
    index_entry entries[entry_count]; // 索引条目数组
};

注意:索引部分是可选的。有些日志文件可能没有索引,特别是那些被截断的日志。如果你需要快速定位数据,建议在记录日志时开启索引功能。我一般在PX4参数中设置SDLOG_INDEX_ENABLE为1,这样每次飞行都会生成完整的索引。

有了索引,你可以实现"跳转到第X秒"的功能。比如,你想看起飞后第10秒的传感器数据,先找到时间戳最接近10秒的索引条目,然后直接跳到对应的文件偏移量,读取数据。这比从头扫描快得多,尤其是对于几个GB的大日志文件。

3.5 整体结构图:一张图看懂ULog

说了这么多,不如一张图来得直观。下面是我画的ULog文件整体结构图,把文件头、消息定义、数据部分、索引部分串起来看。

ULog文件整体结构 文件头 (16字节) Magic: "ULog" | Timestamp: 8字节 | Version: 4字节 消息定义部分 (字典) 定义消息1: sensor_accel (x, y, z) 定义消息2: vehicle_attitude (roll, pitch, yaw) 数据部分 (主体) 数据消息1: msg_id=0, timestamp=1000, data=[...] 数据消息2: msg_id=1, timestamp=1001, data=[...] 数据消息3: msg_id=0, timestamp=1002, data=[...] 索引部分 (可选) 索引条目1: timestamp=1000, offset=0x1234 解析顺序:文件头 → 消息定义 → 数据部分 → 索引部分(可选)

这张图把ULog文件的四个主要部分串起来了。你从上往下读,就是解析器的工作流程。先读文件头确认格式,再读消息定义建立字典,然后逐条解析数据消息,最后用索引部分加速随机访问。

3.6 实战:用Python解析ULog文件头

光说不练假把式。我写了一段简单的Python代码,演示如何解析ULog文件头。你可以在自己的机器上跑一下试试。

import struct

def parse_ulog_header(filepath):
    with open(filepath, 'rb') as f:
        # 读取文件头:4字节Magic + 8字节Timestamp + 4字节Version
        header_data = f.read(16)
        
        magic = header_data[0:4].decode('ascii')
        timestamp = struct.unpack('<Q', header_data[4:12])[0]
        version = struct.unpack('<I', header_data[12:16])[0]
        
        print(f"Magic: {magic}")
        print(f"Timestamp: {timestamp} us")
        print(f"Version: {version}")
        
        if magic != 'ULog':
            print("错误:不是有效的ULog文件!")
            return None
        
        return timestamp, version

# 使用示例
parse_ulog_header('sample.ulg')

小提示:struct.unpack中的'<'表示小端字节序。PX4日志默认使用小端,但如果你在ARM平台上解析,要注意字节序问题。我曾在树莓派上踩过这个坑,数据全反了。

解析完文件头,下一步就是遍历消息定义。这部分稍微复杂一点,因为定义消息的数量不固定。你需要循环读取,直到遇到第一个非定义消息为止。我一般会写一个循环,每次读2字节的msg_size,如果msg_type是'B',就按定义消息解析;如果是'D',就说明定义部分结束了,进入数据部分。

嗯,说到这里,你应该对ULog文件的整体结构有了清晰的认识。文件头是身份证,消息定义是字典,数据部分是正文,索引部分是目录。四者缺一不可,共同构成了PX4日志的完整骨架。下次拿到一份.ulg文件,不妨先用十六进制工具打开,对照着这篇文章,亲手验证一下每个字段。相信我,亲手拆过一次,比看十遍文档都管用。