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文件的四个主要部分串起来了。你从上往下读,就是解析器的工作流程。先读文件头确认格式,再读消息定义建立字典,然后逐条解析数据消息,最后用索引部分加速随机访问。
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文件,不妨先用十六进制工具打开,对照着这篇文章,亲手验证一下每个字段。相信我,亲手拆过一次,比看十遍文档都管用。