Livox雷达的pcap包里,时间戳从来不是“一个字段”那么简单。抓包文件本身自带一套主机时间戳,Livox报文载荷里又藏着雷达自己的时间基准,两套时间戳差一个系统时钟偏置。这篇博文要讲清楚的就是:怎么用C语言把这层结构剥开,把pcap记录头里的毫秒时间戳和Livox载荷内的相对时间戳都提取出来,并校准成同一套时间轴。
1. 为什么需要单独把时间戳从pcap里抠出来
1.1 一个典型的Livox数据采集流程
先还原一下使用场景。很多人用Livox MID-360做移动测绘、机器人SLAM或者无人机巡检时,采集链路通常是这样的:雷达通过网线接入工控机,工控机上跑着Livox SDK的例程,点云数据默认走UDP协议发到本机或者另一台设备的某个端口,最常见的是7500和7501这两个端口。为了后续能重放数据、排查丢包、做时间同步评估,工程上最常见的做法是直接用tcpdump或者Wireshark把网口上的流量抓下来,落成一个pcap文件。
这个pcap文件里每一帧UDP数据包都有一个“包时间”,但这只是抓包主机的网卡驱动在收包瞬间盖上去的戳,精度取决于网卡驱动和操作系统的调度,通常能到微秒级。如果只是回放点云,这个时间戳勉强够用;但如果要把Livox点云和IMU、相机、RTK做时间对齐,光靠包时间是不够的。真正反映雷达坐标系下每个点云帧产生时刻的,是Livox报文里自带的那套时间戳,它在每一帧激光数据发出之前由雷达固件写入。所以项目上说的“提取时间戳”,通常意味着两件事:把pcap记录头的时间戳读出来,再把Livox载荷里的时间基准读出来。
1.2 双时间戳在工程上的角色分配
为什么非要区分主机时间和雷达时间?举个最直观的例子:一部MID-360以10Hz的频率发点云数据,每帧之间间隔大约100ms。如果主机负载高或者USB转网卡抖动,Wireshark抓到的包时间有可能偏离真实发包时刻几十毫秒。意味着你拿pcap里的包时间直接当点云时间戳去做SLAM的帧间匹配,里程计漂移会被这个抖动直接喂进去。反过来,雷达自己在MEMS扫描过程中对每个点云帧记录的time_base,是光机扫描时序的真实反映,精度和确定性都比主机时间可靠得多。
在实际工程里,合理做法是:用pcap记录头的时间戳确定“数据到达主机的绝对时刻”,用Livox报文里的相对时间戳确定“雷达内部扫描的精确间隔”,再用PTP或者秒脉冲把两套时钟的偏差拉齐。只做离线解析的话,至少要能分别提取出这两个时间戳,再根据各自的单位换算成统一的时间轴。
1.3 什么样的人需要读这篇
如果你是做感知算法、点云后处理、雷达标定或者数据回放的工程师,需要从离线pcap里恢复带精确时间戳的点云帧,那这篇文章里的代码和思路可以直接拿来用。如果你只是临时跑个demo,Wireshark上肉眼看看时间戳也没有问题,那倒也未必需要读到这里。不过一旦你想要批量处理几个GB的pcap、想把时间戳字段导出成CSV喂给Python脚本做时序分析,手写一个轻量解析器就是绕不开的基本功。
2. pcap文件里的时间戳到底存在哪:两级结构拆解
2.1 pcap的Global Header:先看清时间戳精度
任何叫pcap的文件,开头都有一个24字节的Global Header,也就是文件头。结构非常固定,我用C语言结构体写出来就是这样的:
typedef struct pcap_global_hdr { uint32_t magic_number; /* 魔数,用于识别字节序和时间精度 */ uint16_t version_major; /* 主版本号,一般还是2 */ uint16_t version_minor; /* 次版本号,一般是4 */ int32_t thiszone; /* 时区,实际很少用,是GMT offset */ uint32_t sigfigs; /* 时间戳精度,几乎都是0 */ uint32_t snaplen; /* 最大抓包长度,通常65535 */ uint32_t network; /* 链路层类型,以太网是1 */ } pcap_global_hdr_t;这里最需要注意的是第一项magic_number。以太网抓包最常见的字节流是d4 c3 b2 a1,对应小端序的0xa1b2c3d4,表示记录头里的时间戳单位是微秒。如果看到的是4d 3c b2 a1,也就是0xa1b23c4d,说明这个pcap里的时间戳单位是纳秒。这两个魔数决定了你后面读出来的ts_sec和ts_usec怎么换算,很多人在这一步就翻车了——把纳秒时间戳当微秒除,差了整整1000倍。
2.2 Record Header:每包一个16字节的时间盖戳
接下来每个数据包前面都有一个16字节的Packet Record Header:
typedef struct pcap_pkthdr { uint32_t ts_sec; /* 秒,从1970年1月1日0点开始 */ uint32_t ts_usec; /* 微秒或者纳秒,取决于魔数 */ uint32_t incl_len; /* 当前这一包实际存了多少字节 */ uint32_t orig_len; /* 原始包长,如果超过snaplen会被截断 */ } pcap_pkthdr_t;ts_sec和ts_usec就是Wireshark列表里那一列时间戳的来源。Wireshark默认显示成“秒.小数”的格式,比如1695801234.123456,前面整数部分是ts_sec,后面小数部分是ts_usec。如果你把pcap送到在线解析工具里看,通常也是把这两个字段拼一下就显示出来了。
实际解析时要注意,每一帧数据是“16字节记录头 + incl_len字节的原始包数据”这样连续排列下去的。文件里没有包头也没有包间间隔,解析器必须严格按这个顺序迭代。incl_len才是真正跳转到下一个记录头的依据。
2.3 只要会看偏移量,十六进制dump就能完成最原始的提取
给一个真实的以太网抓包片段的十六进制前40字节,你就能直观感知层级结构:
d4 c3 b2 a1 02 00 04 00 00 00 00 00 00 00 00 00 ff ff 00 00 01 00 00 00 d0 4a 61 66 12 34 56 78 50 00 00 00 50 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 08 00 ...第一行的d4 c3 b2 a1确认是微秒时间戳的小端pcap。从偏移量24开始是第一个记录头:d0 4a 61 66小端读出是0x66614ad0,对应秒;12 34 56 78小端读出是0x78563412,对应微秒;接着的50 00 00 00是incl_len,80字节。再往后80字节才是真正的以太网帧头和数据。这个例子说明,即使没有代码,用任何十六进制查看器也能人工抽出时间戳,只是当包里数据包数量到十万级别后,就必须写脚本了。
3. Livox报文的载荷里还藏着一套时间戳体系
3.1 Livox报文的自定义UDP结构
Livox雷达的UDP报文不是标准的点云格式,它是一套私有协议。以常见的点云数据帧为例,打开Wireshark按UDP端口过滤后,能在Payload里看到一段结构化数据。协议头的字段大致包括版本号、数据ID、数据类型,以及一段非常重要的time_base时间基值。不同型号、不同固件版本在字段顺序上可能有细微差异,但基于MID-360和SDK2.x的抓包实践,payload前部一定会有一个表示“相对雷达启动时刻”的时间字段,单位是纳秒。
读这种报文千万不要按固定偏移写死完事。正确做法是先抓一小段包,对照Wireshark的十六进制视图,确认本设备固件里time_base的偏移量和长度,再去写解析器。我这里给的是通用常见布局,实际工程务必自行核对。
3.2 time_base和载荷里的相对时间戳
Livox SDK里点云包的数据通常长这样(伪代码描述):
struct livox_pointcloud_packet { uint8_t version; /* 协议版本 */ uint8_t data_id; /* 数据标识 */ uint8_t data_type; /* 0x00表示点云,0x01表示IMU,0x02表示位置 */ uint8_t length; /* 数据长度单位 */ uint32_t time_base; /* 相对时间基准,单位纳秒 */ uint8_t data[...]; /* 实际点云或IMU数据体 */ };time_base可以理解为这一帧数据的起始时刻距雷达开机的时间偏移。雷达每个扫描周期结束时会更新一下这个值。如果需要得到绝对时间,就得拿pcap记录头的绝对时间加上相对偏移来构造。而IMU数据包里的时间基准则是IMU采样瞬间的对应tab值,两者数据率不一样,时间字段的递进节奏也不一样。
3.3 两个time_base的换算方法
前面已经提到pcap记录头的ts_usec单位可能是微秒也可能是纳秒,而Livox载荷里的time_base单位是纳秒。换算成同一个轴时,要么全部转纳秒,要么全部转毫秒。我习惯先把pcap时间戳换算成“自1970年以来的毫秒整数”,把time_base除以1e6变成毫秒,再相加。这样得到一个整数型毫秒时间戳,放到Excel或者数据库里做join特别方便,不会因为浮点精度丢位数。
具体公式:
abs_ms = ts_sec * 1000 + ts_usec / 1000 (微秒单位时) abs_ms = ts_sec * 1000 + ts_usec / 1000000 (纳秒单位时) livox_abs_ms = abs_ms + time_base / 1000000如果你的pcap是livox_sdk2采集工具通过libpcap库直接保存的,那包时间戳大多已经和系统时钟对齐,time_base加不加影响不大;但如果你是用tcpdump在雷达和目标机之间抓的,那两者之间的偏差可能就是几十毫秒到几百毫秒,处理SLAM数据时会直接影响精度,一定不能忽略。
3.4 怎样从混合流量中捞出Livox协议包
实测中网口上经常同时存在点云、IMU、位置数据、服务发现等不同类型的UDP流量。判断一个包是不是Livox点云,第一层看UDP目的端口(配置的端口,常见7500/7501),第二层看data_type字段。imagent数据帧和imu数据帧的载荷头部有这个区分字节。如果端口都不是约定的,还需要在payload里扫描特征值,工作量就大了。所以抓包一开始就锁定端口是最省事的办法,最好在采集脚本里直接把端口参数固化下来。
4. 先用现成工具快速过一遍,别急着写代码
4.1 capinfos看整体概貌
拿到任何未知pcap,第一件事不是写解析器,而是用Wireshark自带的capinfos看看文件基本信息:
capinfos xxx.pcap重点看这几个输出项:
- File type:确认是pcap还是pcapng,后续解析策略不同
- File encapsulation:确认是Ethernet还是Raw IP
- Number of packets:确认总包数
- Capture timestamp precision:直接告诉你时间戳是微秒还是纳秒
- Data byte order:告诉你这是小端还是大端
如果显示Capabilities: Microsecond resolution,就直接按微秒处理,省去翻魔数的时间。
4.2 tshark把时间戳字段导出为CSV
如果你只是想快速抽取pcap记录头的包时间,完全没有必要写C代码,tshark一行命令就能把时间戳列表拖出来:
tshark -r input.pcap -T fields -e frame.time_epoch -e udp.srcport -e udp.dstport > timestamps.csvframe.time_epoch输出的是秒精度浮点数,精确到微秒。这个命令适合先验证你抓的包时间戳是否合理,比如两个包的间隔是否和雷达帧率匹配。如果10Hz点云,时间间隔大约是0.1秒;如果IMU数据是200Hz,那间隔就是0.005秒。看到间隔和预期不符,说明抓包链路本身有问题,后面再怎么解析pcap意义也不大。
4.3 Wireshark图形界面里看字段注释
Wireshark能自动识别出Livox私有协议时,通常会在协议树里标注字段名,比如Livox LIDAR或者自定义的解析器名称。你可以点开某一条UDP报文,直接在协议的详情面板里看到time相关字段。这一招主要用于验证你自己解析器读出的值对不对。当代码算出来的时间和Wireshark显示值一致,基本可以放心进入批量处理环节。
需要提醒一下,Livox默认是没有自带Wireshark官方解析插件的,所以很多情况下Wireshark把载荷当成普通Data显示。这时可以用“右键->Decode As”选一列自定义协议,或者是靠肉眼对照十六进制找time_base。这也是为什么我强调必须先确认偏移量,不要盲目套模板。
4.4 在线pcap解析工具的边界
有些网页工具支持上传pcap在线解析,小文件看结构很快。但我不建议拿它做生产依赖。原因有三个:一是几GB的大pcap传到网页上既慢又容易卡死;二是激光雷达点云pcap通常包含真实地点环境数据,涉密或者涉及客户隐私时上传到第三方网站有合规风险;三是这类工具多数只解出包时间戳和常规协议字段,对Livox这种私有协议载荷几乎没有识别能力,最终还是导回到本地处理。
5. 自己动手写提取程序:核心代码与逐段讲解
5.1 环境准备:Ubuntu 22.04下的libpcap
解析pcap文件有两个方案:一是用libpcap库,它封装好了读文件头、记录头甚至过滤器的逻辑,非常省心;二是直接用C标准库按二进制读取,自己解析每个字节,完全不依赖外部库,适合交叉编译和资源受限的嵌入式环境。
如果你只是做离线分析,推荐先用libpcap,它有现成的函数pcap_open_offline、pcap_next_ex,不需要手动处理千奇百怪的链路层类型。Ubuntu 22.04上安装只需要:
sudo apt update sudo apt install libpcap-dev编译链接参数是-lpcap。Livox SDK2本身在Ubuntu 22.04上也可以自己编译,但这里只解析时间戳的话不需要SDK,别给自己加依赖。
5.2 用libpcap做最轻量的时间戳读取
下面这段代码是提取pcap记录头时间戳的最小实现,来自我实际项目里的一个工具模块:
#include <pcap.h> #include <stdio.h> #include <stdint.h> int main(int argc, char *argv[]) { if (argc < 2) { fprintf(stderr, "usage: %s <pcap_file>\n", argv[0]); return 1; } char errbuf[PCAP_ERRBUF_SIZE]; pcap_t *handle = pcap_open_offline(argv[1], errbuf); if (!handle) { fprintf(stderr, "open pcap failed: %s\n", errbuf); return 1; } struct pcap_pkthdr *header; const u_char *data; int ret; uint64_t frame_count = 0; while ((ret = pcap_next_ex(handle, &header, &data)) == 1) { /* header->ts.tv_sec 是秒 */ /* header->ts.tv_usec 是微秒,纳秒精度pcap里可能对应tv_nsec */ uint64_t ms = (uint64_t)header->ts.tv_sec * 1000 + (uint64_t)header->ts.tv_usec / 1000; printf("%llu\n", (unsigned long long)ms); frame_count++; } pcap_close(handle); return 0; }这段代码干的事情就是循环读记录头,取出ts_sec和ts_usec拼成毫秒时间戳,打印到标准输出。如果是从libpcap源码编译,纳秒精度的时间戳在pkthdr里叫ts.tv_nsec,但pcap_next_ex的返回值告诉你当前文件是否纳秒,实际解析时要根据文件类型判断,不能死板地始终除以1000。
5.3 深度解析payload里的Livox time_base
光有包时间还不够,我们还需要把UDP载荷中的time_base抠出来。由于My project usually encapsulates the pcap file and need manual checks, 我最后用的方案是自己读文件,不走libpcap的高级封装,因为要同时控制字节序和偏移。
核心函数长这样:
#include <stdio.h> #include <stdint.h> #include <stdlib.h> #include <string.h> typedef struct { uint32_t ts_sec; uint32_t ts_usec; uint32_t incl_len; uint32_t orig_len; } record_header_t; static uint32_t read_u32_le(const uint8_t *buf) { return ((uint32_t)buf[0]) | ((uint32_t)buf[1] << 8) | ((uint32_t)buf[2] << 16) | ((uint32_t)buf[3] << 24); } int main(int argc, char *argv[]) { if (argc < 2) return 1; FILE *fp = fopen(argv[1], "rb"); if (!fp) { perror("fopen"); return 1; } uint8_t gh[24]; if (fread(gh, 1, 24, fp) != 24) { fclose(fp); return 1; } int is_nano = 0; if (memcmp(gh, "\xd4\xc3\xb2\xa1", 4) == 0) { is_nano = 0; /* 微秒 */ } else if (memcmp(gh, "\x4d\x3c\xb2\xa1", 4) == 0) { is_nano = 1; /* 纳秒 */ } else { fprintf(stderr, "not a classic pcap file\n"); fclose(fp); return 1; } uint8_t rh[16]; while (fread(rh, 1, 16, fp) == 16) { record_header_t r; r.ts_sec = read_u32_le(rh + 0); r.ts_usec = read_u32_le(rh + 4); r.incl_len = read_u32_le(rh + 8); r.orig_len = read_u32_le(rh + 12); if (r.incl_len > 65535) { fprintf(stderr, "bad incl_len: %u\n", r.incl_len); break; } uint8_t *packet = malloc(r.incl_len); if (!packet) break; if (fread(packet, 1, r.incl_len, fp) != r.incl_len) { free(packet); break; } /* 以太网帧头14字节,跳过;IP头通常20字节,跳过;UDP头8字节,跳过 */ /* 这里假设是标准IPv4 UDP,并且payload从偏移42开始 */ int eth_off = 14; if (r.incl_len <= eth_off) { free(packet); continue; } int ihl = (packet[eth_off] & 0x0f) * 4; int udp_off = eth_off + ihl; if (r.incl_len <= udp_off + 8) { free(packet); continue; } /* 跳过UDP头留8字节,payload开始 */ int payload_off = udp_off + 8; int payload_len = r.incl_len - payload_off; if (payload_len >= 8) { /* 真实项目中,这里要按你自己的协议偏移读取time_base */ uint32_t time_base = read_u32_le(packet + payload_off + 4); uint64_t pcap_ms = (uint64_t)r.ts_sec * 1000 + (uint64_t)r.ts_usec / (is_nano ? 1000000 : 1000); printf("%llu,%u,%u,%llu\n", (unsigned long long)pcap_ms, time_base, payload_len, (unsigned long long)(pcap_ms + time_base / 1000000)); } free(packet); } fclose(fp); return 0; }这块代码有几个注意点。一是payload_off的计算没有覆盖所有情况,如果你的pcap是Linux cooked capture或者VLAN标签,偏移量会变得完全不同,需要根据Global Header里的network字段来分支。二是time_base到底在载荷偏移量多少,我这里的packet + payload_off + 4只是示意,拿到真实pcap后必须先用Wireshark确认一次偏移,否则后面导出的时间整体偏移固定值,数据对不齐。
5.4 输出成Excel友好的CSV格式
输出格式上,我会生成这样一份CSV:
pcap_ms,livox_time_base_ns,payload_len,livox_abs_ms 1695801234123,12345678,120,1695801234123 1695801234124,22345678,120,1695801234134第二行中pcap_ms没有变,说明同一毫秒内来了两个包;livox_abs_ms单独往后跳了10ms,代表雷达端认为这两帧数据间隔是10ms,但网络端接收间隔只有1ms。这种差异就是抓包队列缓冲造成的延迟抖动,特别适合用来评估整个采集链路的时间同步质量。
把文件拖进Excel后,可以用公式把pcap_ms还原成可读时间:
=TEXT((A2/1000+8*3600)/86400+25569,"yyyy-mm-dd hh:mm:ss.000")注意这里的8小时时区偏移要看你的用例,默认按北京时间来,但雷达数据集的标准做法是直接用UTC时间,免得做跨时区对齐时搞混,我的建议是导出的时间戳全保存成UTC自纪元毫秒,显示时间再在Excel里另算。
5.5 代码优化:大pcap怎么扛得住
实测一个100万包的pcap大约几百MB到1GB。上面这种每包malloc/free的写法在数据量大时会拖慢速度。优化策略有三个方向:
- 复用缓冲区:malloc一次Buffer,每次fread之后只覆盖内容,避免反复分配释放
- 只解析需要处理的payload:如果只想提取时间戳,没必要完整保存packet,直接滑窗读就行
- 启用多线程分段处理:先快速扫描一遍所有记录头,记录每个包的偏移量,然后分段丢给多个线程解析
我实际做离线提取时,先用一遍单线程扫描把偏移量表建出来,这一步是纯顺序读,非常快。然后按8线程处理,能把一个120MB的pcap从30多秒压缩到5秒内。不过要注意,单线程扫描时的I/O瓶颈一般不在CPU上,如果硬盘是机械盘,多线程收益反而很小。
6. 输出时间戳之前绕不开的四个坑
6.1 把GPS时间当UTC时间用,导致整段数据偏移
有些Livox型号支持接GNSS授时,雷达内部的时间基准会跟着GPS走。从pcap里提出来的包时间戳是主机UTC时间,而报文里的time_base如果已经切到了GPS时间,两者的秒数是同一个时间轴,但GPS时间不算闰秒,UTC在涉及到闰秒时会慢若干秒。绝大多数民用应用不会触发这个差异,但如果你碰到时间偏差正好是27秒左右的场景,第一反应应该是检查是不是闰秒问题。
6.2 微秒、纳秒、毫秒三种单位切换时容易热血上头
这个坑我踩过不止一次。pcap魔数决定了记录头是微秒还是纳秒,而Libpcap在读纳秒pcap时如果还用ts.tv_usec = / 1000,一瞬间就把晚纳秒当成微秒,换算后被放大了1000倍。建议在程序入口处就定义一个枚举:
typedef enum { TS_PCAP_MICRO, TS_PCAP_NANO } pcap_ts_precision;所有后续时间计算都显式传入这个精度值,不要通过一个is_nano的整数到处分支,否则某一天你在某个函数里忘了判断,就会产出间隔看起来完全不对的时间轴。
6.3 报文被截断时如何识别
tcpdump默认snaplen是262144字节,足够装下Livox的UDP报文,一般不会截断。但如果你用了-smaller参数或者抓包设备本身snaplen不够,就会出现incl_len小于UDP payload实际长度的情况。解析时当incl_len等于snaplen时就要高度警惕,后面所有字段都不可信。我的做法是把这类被截断的包的序号打到一个warning文件里,再统一统计,而不是强制跳过,因为跳过会影响帧序和时间连续性分析。
6.4 雷达自身时钟的漂移与PTP同步
即便你在软件层面把所有时间戳都提了出来,雷达内部晶振漂移仍然会在长时间采集中产生累积误差。MID-360这类设备在常温下时钟漂移大约是几十ppm量级,采集一小时就可能偏移几毫秒到几十毫秒。对于SLAM这类对帧间时间要求高的应用,最稳的办法是在采集链路里加入PTP同步,让雷达和工控机的时钟锚定到同一个主钟上。离线解析也可以给时间戳做个线性校正:以pcap里的主机时间作为参考,对time_base拟合一个钟速偏差,再修正每个点云帧的绝对时间。这个思路展开讲又可以写一篇长文,但在提取阶段先把raw时间戳存好总不会错。
6.5 怎么证明你提取的时间戳是对的
交叉验证永远比肉眼可靠。我的验证流程是:先在Wireshark里随机挑五帧,记录下显示的时间和协议树里的time_base,再用工具导出同样的字段,两者一致才认为解析器正确。第二步是用tshark独立提取一遍pcap_ms,和我的代码输出做diff,这一步能抓出字节序和截断处理的bug。过了这两关,再拿去和IMU的已知触发间隔做频率比对,比如IMU标称200Hz,提出来平均间隔=5.0ms,基本就可以确认时间戳链路没问题。
最后说一个实际项目里的小体会:时间戳提取这种工作没什么技术含量,但它往往决定整个后处理链条的成败。那些在SLAM里怎么调参都调不出来的漂移,有可能只是pcap里的一行字段没对齐。我的习惯是每次拿到新的pcap格式,先花十分钟做一份“字段注释卡”,把偏移、单位、字节序、和主机时钟的关系都写在注释里。等踩完坑回头看,最值钱的不是那几百行解析代码,而是注释里那几行你通过反复比对验证得出的协议偏移记录。