
做自动驾驶和移动机器人开发的朋友肯定没少和Livox雷达打交道。特别是现在MID 360这类非重复扫描固态雷达普及率很高大家手里的pcap抓包文件也越来越多。很多人抓完包就丢一边或者只会用Wireshark看看点云数据可真到了做时间同步、点云运动畸变补偿、离线回放算法的时候才发现必须从pcap里把时间戳字段精确提取出来否则后面全是瞎忙活。这个需求看起来简单但真做起来坑不少。pcap文件里其实藏着两种时间戳一是Libpcap在主机侧打上的接收时间戳二是Livox点云数据包自带的雷达内部时间戳。两者用途完全不同精度也不一样提取方式更是天差地别。这篇文章我就从实际项目出发把为什么提取、怎么提取、提取后怎么用、以及我在Ubuntu 22.04上踩过的各类坑完整梳理一遍。1. 为什么非要拆开pcap挖时间戳1.1 点云畸变校正离不开精确时间先讲一个我实际碰到的场景。用Livox MID 360跑小车速度一快建图就飘。原因很简单雷达一帧点云里的每个点并不是同一时刻采集的。Livox这种非重复扫描雷达一帧内几十万个点是扫描周期内逐步累积出来的车在这段时间里一直在移动于是点云整体产生了运动畸变。要矫正这个畸变就需要知道每个点的精确采样时间。如果只用主机接收时间戳误差包含网络传输、驱动调度、系统中断带来的抖动动静一大就完全不能用。真正该用的是Livox数据包头里那个微秒级的内部时间戳再往下深挖点数据里还有每个点相对包起始时间的offset字段。把这些字段提取出来才能把每个点恢复到它在雷达自身时间轴上的真实位置。1.2 多传感器同步是老生常谈里的硬骨头现在的感知系统基本都是多传感器融合激光雷达要和相机、IMU、毫米波雷达的数据对齐。相机曝光时间、IMU采样时间各有各的时钟源雷达也不例外。想把所有数据统一到一条时间线上必须知道每个传感器数据的精确时间。这时候pcap里的两种时间戳就有分工了主机接收时间戳可以用来做链路延迟诊断和绝对时间对齐雷达内部时间戳则负责还原点云在采样时刻的相对关系。两者结合才能既保证“绝对时间不漂”又保证“相对时间不糙”。1.3 离线开发和调试真的绕不开pcap做算法的人其实都知道整天把雷达扛来扛去不现实。项目现场抓一段pcap拿回实验室反复回放这是最通用的开发模式。特别是涉及nav2导航、3D点云建图这类系统调试pcap回放能让你把某个异常帧反复看几百遍。但pcap回放能不能真正还原现场关键就在时间戳。有些SDK的回放工具会重建时间如果重建逻辑依赖主机接收时间戳而原始数据没有处理好回放出来的点云顺序和真实时序就会有偏差。所以先把时间戳字段提取干净后面所有回放、调试才有可靠基础。2. Livox时间戳到底藏在pcap的哪几层2.1 第一层Linux主机接收时打的pcap包头时间戳pcap文件的每个数据包记录头里前8个字节就是主机侧的时间戳前4字节是Unix秒后4字节是秒的小数部分。这个时间戳是Libpcap在网卡把数据包交上来那一刻记录的精度通常是微秒有些抓包配置能做到纳秒。需要强调的是这个时间戳是“主机看到包”的时间不是“雷达发出包”的时间。两者之间隔着网线、交换机、网卡中断、驱动处理等多层延迟。在局域网直连、网络负载很低的情况下延迟通常比较稳定可以做粗略对齐。一旦中间经过交换机或者系统负载高了抖动就会明显变大拿它做点云级的时间补偿就很不合适。2.2 第二层UDP负载里的Livox以太网包头部时间戳真正关键的时间戳在Livox自定义的以太网数据包里。雷达把点云封装成UDP报文发出来UDP负载的前11个字节是一个固定头结构其中就有一个4字节的timestamp字段单位是微秒记录这个数据包对应的采样时刻。这个时间戳是雷达内部硬件时钟累加出来的从雷达上电开始计数不经过操作系统和网络栈精度比主机时间戳高得多。在雷达开启了GPS或PTP同步的情况下这个内部时钟还能被对齐到UTC时间这就等于给每个数据包发了“标准时间认证”对于多传感器融合来说是救命的。2.3 第三层点数据里的单点级时间偏移有些Livox数据包的data_type字段会告诉解析器每个点后面额外携带4字节的offset_time。这个字段表示该点相对于当前包timestamp字段的微秒偏移量。有了它你就能精确到每个激光点的采样时刻而不再是一包一包地粗粒度对齐。即便data_type没有携带单点offset也不代表没法估算。包头里的time_interval字段会给出点与点之间的时间间隔按点在包内的序号乘上去也能得到近似时间。当然能拿精确值就别用估算值后面我会在代码里讲怎么处理。3. Ubuntu 22.04下的抓包环境准备与操作3.1 软件环境清单我这次是在Ubuntu 22.04上做的内核版本、网络栈对pcap基本没有什么特殊限制普通环境就行。关键是把依赖装齐尤其是Libpcap的dev包后面解析和编译SDK都可能用上。sudo apt update sudo apt install -y git cmake build-essential tcpdump wireshark-common libpcap-dev装Livox SDK2的步骤也很直接官方仓库clone下来编译安装即可git clone https://github.com/Livox-SDK/Livox-SDK2.git cd Livox-SDK2 mkdir build cd build cmake .. make sudo make install这里说一个我在Ubuntu 22.04上遇到的编译问题老版本的Livox SDK2在GCC 11下会报各种高版本编译错误尤其是C标准相关的提示。解决办法是拉最新master分支或者直接把编译器切换到GCC 10。不建议硬改源码去适配后续维护成本太高。3.2 确认网卡和IP配置雷达直连主机时通常会在网卡上生成一个192.168.x.x的静态地址。先确认网卡名和IPip addr show正常情况能看到类似192.168.1.50之类的地址雷达默认IP通常在192.168.1.1xx网段。如果看不到手动配置静态IP再拿Livox Viewer或者SDK自带的例程扫一下设备确认雷达在线。3.3 用tcpdump把雷达数据流落到pcap文件抓包命令本身不复杂核心参数是“指定网卡完整包长写文件”sudo tcpdump -i eth0 -s 0 -w livox_mid360.pcap udp port 7500参数含义-i eth0抓指定网卡按实际网卡名替换-s 0抓完整包长不加这个参数默认只截取前几十字节后面的payload会被截断-w写文件udp port 7500按Livox默认数据端口过滤不同固件可能配置为其他端口不确定就先把过滤条件去掉抓几秒看看抓包过程中可以用另一个终端看文件增长情况确认数据一直有进来。停止时按CtrlCtcpdump会正常关闭文件。3.4 抓完先别急着解析用tshark做快速体检拿到pcap文件后我先习惯性地用tshark快速看几包确认源IP、端口、包长都正常再上代码。这一步能省掉后面很多排查时间。tshark -r livox_mid360.pcap -c 10 -nn -T fields -e frame.number -e frame.time_epoch -e ip.src -e udp.srcport -e udp.length正常情况下udp.length应该是稳定的大包长度源IP是雷达IP源端口固定。如果这里看到的包长忽大忽小或者IP不对说明雷达参数或过滤条件没设置正确先解决问题再继续解析。4. 手写C代码解析pcap并提取时间戳字段4.1 pcap文件全局头和记录头的结构不能搞错pcap文件的全局头固定24字节核心是前4字节魔数magic。常见的两个魔数要记牢0xa1b2c3d4时间戳小数部分单位是微秒0xa1b23c4d时间戳小数部分单位是纳秒如果读取时遇到字节序相反的情况魔数会变成0xd4c3b2a1或者0x4d3cb2a1这是大端文件读出字段后需要做字节交换。这一步不做对后面所有时间都可能是天文数字或者负数。全局头后面每一条记录自带16字节的记录头ts_sec4字节Unix秒ts_frac4字节微秒或纳秒incl_len4字节实际存储的包长度orig_len4字节原始包长度解析流程就是读全局头循环读记录头再按incl_len读取整包数据然后去解析以太网、IP、UDP、Livox负载。4.2 从pcap包头提取主机接收时间戳的C实现下面这段代码实现了读取pcap文件和记录头并把主机时间戳打印出来。注意我按字节手工构造小端整数不用结构体强转原因后面会单独讲。#include stdio.h #include stdlib.h #include stdint.h #include string.h #define PCAP_GLOBAL_HEADER_SIZE 24 #define PCAP_REC_HEADER_SIZE 16 static uint16_t read_u16_le(const uint8_t *p) { return (uint16_t)p[0] | ((uint16_t)p[1] 8); } static uint32_t read_u32_le(const uint8_t *p) { return (uint32_t)p[0] | ((uint32_t)p[1] 8) | ((uint32_t)p[2] 16) | ((uint32_t)p[3] 24); } int main(int argc, char **argv) { if (argc 2) { fprintf(stderr, usage: %s file.pcap\n, argv[0]); return 1; } FILE *fp fopen(argv[1], rb); if (!fp) { perror(fopen); return 1; } uint8_t gh[PCAP_GLOBAL_HEADER_SIZE]; if (fread(gh, 1, PCAP_GLOBAL_HEADER_SIZE, fp) ! PCAP_GLOBAL_HEADER_SIZE) { fprintf(stderr, read pcap global header failed\n); fclose(fp); return 1; } uint32_t magic read_u32_le(gh); int nano 0; if (magic 0xa1b23c4d) { nano 1; } else if (magic 0xa1b2c3d4) { nano 0; } else { fprintf(stderr, unknown pcap magic 0x%08x\n, magic); fclose(fp); return 1; } uint64_t count 0; while (1) { uint8_t rh[PCAP_REC_HEADER_SIZE]; size_t rn fread(rh, 1, PCAP_REC_HEADER_SIZE, fp); if (rn 0) break; if (rn ! PCAP_REC_HEADER_SIZE) { fprintf(stderr, read rec header failed\n); break; } uint32_t ts_sec read_u32_le(rh); uint32_t ts_frac read_u32_le(rh 4); uint32_t incl_len read_u32_le(rh 8); uint8_t *buf malloc(incl_len); if (!buf) break; if (fread(buf, 1, incl_len, fp) ! incl_len) { free(buf); break; } if (nano) { printf(host_ts%u.%09u incl_len%u\n, ts_sec, ts_frac, incl_len); } else { printf(host_ts%u.%06u incl_len%u\n, ts_sec, ts_frac, incl_len); } free(buf); count; } printf(total packets: %llu\n, (unsigned long long)count); fclose(fp); return 0; }这个程序先把全局头读出来识别时间戳精度然后逐条读取记录。实际项目里我一般不会只停在这一层因为主机时间戳只解决了“什么时候收到”还没解决“雷达什么时候采样”。4.3 继续解剖UDP载荷抠出Livox内部时间戳要拿到雷达内部时间戳得从以太网帧一路剥到UDP负载。以太网头14字节如果遇到VLAN标签EtherType为0x8100则额外多4字节。之后是IP头IP头长度不是固定20字节需要通过IHL字段计算遇到带Option的报文要注意。剥到UDP负载后最前面就是Livox以太网包的头。以我处理MID 360数据的经验这个Livox头的关键字段如下偏移长度字段说明01version协议版本12length数据包总长度32time_interval点云数据点间隔微秒54timestamp雷达内部时间戳微秒91frame_cnt帧序号101data_type数据类型0或1代表普通点/带offset点下面这段代码在上一段基础上把Livox头字段也解析出来#define ETHER_TYPE_IPV4 0x0800 #define IP_PROTO_UDP 17 static void parse_livox_packet(const uint8_t *udp_payload, uint32_t len) { if (len 11) { return; } uint8_t version udp_payload[0]; uint16_t length read_u16_le(udp_payload 1); uint16_t time_interval read_u16_le(udp_payload 3); uint32_t timestamp read_u32_le(udp_payload 5); uint8_t frame_cnt udp_payload[9]; uint8_t data_type udp_payload[10]; printf(livox_ts%u time_interval%u frame_cnt%u data_type%u version%u packet_len%u\n, timestamp, time_interval, frame_cnt, data_type, version, length); uint32_t offset 11; if (data_type 1) { // 每个点17字节xyz(12) reflectivity(1) tag(1) offset_time(4) while (offset 17 len offset 17 length) { uint32_t offset_time read_u32_le(udp_payload offset 13); printf( point_offset_us%u point_absolute_ts%u\n, offset_time, timestamp offset_time); offset 17; } } else if (data_type 0) { // 每个点13字节xyz(12) reflectivity(1) tag(1) uint16_t point_count (length - 11) / 13; uint16_t idx 0; while (offset 13 len offset 13 length) { uint32_t estimated_offset idx * time_interval; printf( point_idx%u est_offset%u est_ts%u\n, idx, estimated_offset, timestamp estimated_offset); offset 13; idx; } } }这里有个非常关键的点data_type为1时单点绝对时间戳是timestamp offset_timedata_type为0时只能用timestamp 点序号 * time_interval估算。实测下来同样的雷达在纯点云模式下data_type为0的情况更常见。如果做高精度畸变校正我建议优先把雷达配置成带offset_time的输出模式误差能小一个数量级。4.4 不想编译C就用Python快速验证结果很多时候我只是想快速确认pcap里的时间戳字段是否正常不想专门编译C程序。这种情况下Python加dpkt库是最快的方案pip3 install dpkt然后跑下面这个脚本import dpkt def extract_livox_ts(pcap_path): with open(pcap_path, rb) as f: pcap dpkt.pcap.Reader(f) for ts, buf in pcap: eth dpkt.ethernet.Ethernet(buf) if eth.type ! dpkt.ethernet.ETH_TYPE_IP: continue ip eth.ip if ip.p ! dpkt.ip.IP_PROTO_UDP: continue data ip.udp.data if len(data) 11: continue version data[0] length int.from_bytes(data[1:3], little) time_interval int.from_bytes(data[3:5], little) livox_ts int.from_bytes(data[5:9], little) frame_cnt data[9] data_type data[10] print(fhost{ts:.6f}, livox_ts{livox_ts}, ftime_interval{time_interval}, frame{frame_cnt}, ftype{data_type}, len{length}) if __name__ __main__: extract_livox_ts(livox_mid360.pcap)这个脚本打印出来的每行数据前半部分是主机接收时间后半部分是雷达内部时间戳。两条时间线放在一起对比立刻就能看出网络延迟大概多少雷达时钟和主机时钟漂移了多少。5. 时间戳换算、多传感器对齐和链路诊断实操5.1 微秒、毫秒和年月日之间怎么快速换算Livox内部时间戳单位是微秒pcap主机时间戳的秒是Unix秒。平时用得最多的换算就是把微秒时间戳变成毫秒或者变成人能看懂的UTC时间。Unix时间戳转Excel可读时间我常用两个公式。假设A1单元格存放毫秒时间戳13位数字用这个公式转成UTC日期时间(A1/1000/86400)DATE(1970,1,1)如果要转北京时间再加8/24(A1/1000/86400)DATE(1970,1,1)8/24小数部分就是时分秒Excel会自动显示成时间格式。Python里转可读时间更直接from datetime import datetime, timezone dt datetime.fromtimestamp(ts_sec ts_frac * 1e-6, tztimezone.utc) print(dt.isoformat())这里提醒一句Livox内部时间戳如果没做任何同步它是从雷达上电开始的相对时间不是Unix时间。直接把timestamp / 1e6丢给datetime.fromtimestamp()去转出来的年份可能完全不对。必须先判断雷达是否处于GPS/PTP同步模式否则要先用主机时间戳做映射。5.2 没有PTP时如何把雷达时间映射到UTC实际项目里很多时候雷达并没有接PTP或者GPS内部时间戳就是上电以来的微秒计数。这种情况下想统一到UTC时间线我的做法是采集一段时间的主机时间和雷达时间样本做一次线性回归。原理很简单假设主机时间和雷达时间满足host slope * livox_ts intercept其中slope接近1但不完全等于1因为两边晶振频率有微小差异。用最小二乘拟合出slope和intercept之后就能把任意雷达时间戳近似转换成主机UTC时间。def fit_time_mapping(pairs): n len(pairs) sum_x sum(x for _, x in pairs) sum_y sum(y for y, _ in pairs) sum_xx sum(x * x for _, x in pairs) sum_xy sum(y * x for y, x in pairs) slope (n * sum_xy - sum_x * sum_y) / (n * sum_xx - sum_x * sum_x) intercept (sum_y - slope * sum_x) / n return slope, intercept这个方法的精度取决于样本质量和数量。我在实际测试中取一分钟内的样本大概能做到亚毫秒级的映射精度。注意样本对要尽量均匀分布在时间轴上不要全部集中在一小段时间里。5.3 用时间戳字段诊断网络链路和丢包提取出时间戳之后还有一个隐藏福利可以拿主机时间戳和雷达内部时间戳做差画出这个差的波动曲线。这个差值包含了网络链路延迟和系统处理抖动正常情况下应该是一条围绕某均值的平稳曲线。如果发现差值突然出现尖峰大概率是网卡中断被系统调度延误或者网络链路在那一刻发生了拥塞。如果你在多个雷达并联的场景下做这个测试还能揪出某个雷达是不是因为网络配置问题导致它的数据包走了慢路径。丢包检测则要依靠雷达内部时间戳的差值。Livox数据包的timestamp字段通常会按固定节奏递增相邻包之间的差值理论上接近常数。我写了个简单统计脚本把相邻两个包的timestamp差值打出来一旦某个差值明显大于正常值就说明中间丢了包。prev_ts None for host_ts, livox_ts in stream: if prev_ts is not None: gap livox_ts - prev_ts if gap normal_gap * 3: print(fpacket loss detected, gap{gap} us) prev_ts livox_ts这个技巧在调试雷达链路稳定性时非常实用比数包数更灵敏。尤其是UDP偶尔丢一两个包的时候包数统计不一定能及时发现问题时间差却一眼就能看出来。6. 实战中踩过的坑和常见问题速查6.1 pcap时间戳精度判断错所有时间全部偏大我最早解析pcap的时候拿到一个文件直接按微秒精度处理结果主机时间戳的小数部分数值大得离谱怎么都对不上。后来才发现那个文件是用纳秒精度抓的magic是0xa1b23c4d而不是0xa1b2c3d4。解决办法就是严格判断magic把每条记录的小数部分当成微秒还是纳秒处理。我建议解析器把所有时间统一转成微秒或者纳秒后再输出不要在中间环节混用单位。比如纳秒文件就转换成us ts_sec * 1000000 ts_frac / 1000。6.2 用结构体强转解析Livox头字段偏移错乱很多初学者喜欢定义一下struct LivoxEthPacket然后直接把UDP负载强转过来这是危险的。编译器会对结构体做内存对齐比如在uint16字段后面插入填充字节导致你访问uint32字段时读到错位的数据。规避方法就是按字节逐个读取像我上面代码那样用read_u16_le和read_u32_le函数。虽然看起来多几行代码但数据解析结果可靠跨平台也稳定。这个经验同样适用于解析其他雷达协议。6.3 雷达内部时间戳回绕uint32微秒时间戳最多表示约71.6分钟超过这个时间就会回绕。如果你的雷达连续运行超过一小时解析到后面会发现timestamp突然从很大的值变成很小的值。处理办法是自维护一个更高位宽的计数器检测到当前时间戳小于上一包且差值超过回绕阈值时就加上2的32次方再继续累加。我一般会写成下面这样uint64_t unwrapped_ts timestamp; if (last_ts timestamp last_ts - timestamp 0x80000000u) { wrap_count; } unwrapped_ts (uint64_t)wrap_count 32;这是最容易忽视的问题之一。你想做长时间连续回放或者长时间建图回绕不处理后面的时间线就全乱了。6.4 Livox SDK2在Ubuntu 22.04上编译失败我在新环境第一次编译SDK2时确实被旧版本源码在GCC 11下的报错烦了一阵。印象最深的是缺少部分头文件导致的编译中断网上搜到的解决办法也比较零散。后来直接把仓库更新到最新版本编译就顺畅了。如果你必须用旧版本可以尝试安装linux-libc-dev并降级GCC到10。装GCC 10的命令是sudo apt install gcc-10 g-10 sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-10 100 sudo update-alternatives --install /usr/bin/g g /usr/bin/g-10 100不过我个人的建议是能升级SDK就升级别在旧版本的兼容性上消耗太多时间。6.5 Wireshark和tshark的辅助查看技巧如果只想快速看某个pcap里是否有Livox时间戳不想写代码有两个办法。第一个是编译Livox官方Wireshark插件装好后Wireshark能直接识别出Livox协议在协议树里直接点开timestamp字段看。第二个是用tshark把原始hex导出再丢给脚本处理tshark -r livox_mid360.pcap -Y udp -T fields -e data.data | head -5网上也有一些pcap在线解析工具把文件传上去就能看到包结构和时间戳。但我提醒一句凡是涉及项目现场真实数据的pcap千万不要往第三方在线工具里传。离线开发环境里tshark加本地脚本才是稳妥方案。6.6 常见问题速查表问题现象常见原因解决办法时间戳小数部分巨大解析出的时间和实际差1000倍pcap纳秒/微秒magic判断错误检查magic统一精度单位雷达时间戳突然回小连续运行一小时后时间跳变uint32微秒回绕检测回绕并累加2^32包长度被截断payload解析不完整tcpdump没加-s 0重新抓包加-s 0抓不到UDP包tshark显示空列表端口或网卡过滤条件不对不加过滤条件先抓几秒确认端口主机时间和雷达时间差漂移差值越来越大两侧晶振时钟不同步使用PTP/GPS同步或拟合线性映射结构体解析值异常字段位置对不上C结构体内存对齐按字节偏移读取UDP负载长度不足解析程序直接退出数据包不是Livox协议检查雷达型号和固件配置我自己在实际处理中还有个习惯抓到pcap后第一步不急着写完整解析器先跑一遍tshark命令确认源IP、端口、包长正常再跑一遍Python脚本看时间戳字段有没有明显异常。这两步能过滤掉绝大部分低级问题之后再上C代码做全量解析就稳很多。时间戳字段虽然只是几个字节但它是整个数据回放和时间同步的基础。把这个基础打牢后面无论做畸变校正、多传感器对齐还是网络链路诊断都会省出大量排查时间。