做多传感器融合的兄弟应该都体会过时间同步的痛。激光雷达、IMU、相机各说各话,时间戳对不上,点云和图像就是错位的,跑SLAM时候那个运动畸变能让人怀疑人生。Livox雷达本身不带惯导,靠内部晶振走时间,长时间运行飘起来根本没法看。我去年做Fast-LIVO相关的硬件方案时,专门用STM32F4的通用定时器做了一套PPS硬件同步的装置,把Livox的点云时间戳和IMU硬生生拉到了同一个时间基准上。这篇文章就把这套方案的原理、配置、代码和踩坑过程完整复盘一遍,给正在搞激光雷达同步的同行一个可以直接抄作业的参考。
这套东西解决的问题很具体:Livox雷达支持PPS加GPRMC报文的外部同步方式,只要给雷达一个秒脉冲信号和对应的UTC时间,它就能把每一帧点云的时间戳对齐到整秒上。STM32F4的通用定时器刚好能干这个活,既能输出1Hz的PWM脉冲当PPS,又能用输入捕获模式测量外部基准秒脉冲来校准本地晶振漂移,还能靠中断记录精确的时间戳。一套下来,同步精度可以做到亚毫秒级别,完全满足LiDAR-IMU紧耦合里程计的需求。
1. 为什么非要上PPS硬件同步
1.1 时间戳错位带来的那些糟心事
很多人刚开始做多传感器融合时,觉得时间同步不就是把各个传感器的时间戳统一一下嘛,反正都有时间戳,对齐一下不就行了。实际上完全不是这么回事。
软件时间戳有个致命问题:每个传感器的主控时钟源不一样,晶振精度和温漂也不同。Livox雷达内部的时间基准是它自己的晶振,IMU的时间基准是主控板的晶振,两者哪怕出厂时都校准过,运行一小时后也会积累几毫秒甚至几十毫秒的偏差。几毫秒对点云来说意味着什么?以一帧点云周期100ms计算,如果雷达和IMU时间错位5ms,相当于点云里的每个点都带上了5ms的运动误差。雷达装在车上或者机械臂上,这5ms里平台已经移动了一段距离,直接导致点云畸变和特征误匹配。
我自己跑过一组对比实验:只用软件时间戳,不接PPS,把雷达放在转台上旋转,点云的边缘会明显出现拖影和锯齿;接上PPS硬件同步之后,同样转速下点云干净利落,边缘锐利,效果立竿见影。这也是为什么Fast-LIVO、LIO-SAM这类对精度要求高的框架,官方文档里都明确建议做硬件时间同步。
1.2 PPS加GPRMC到底是怎么工作的
Livox雷达的同步原理并不复杂,核心就两个输入信号:一个PPS秒脉冲,一串GPRMC格式的NMEA报文。
PPS就是每秒一个上升沿,用来告诉雷达“这一瞬间是整秒的起点”。GPRMC报文里带的是UTC时间和日期。雷达收到这两个信号后,会把PPS上升沿和GPRMC里的整秒时间对应起来,建立起自己的绝对时间基准。之后每一帧点云的时间戳,都是基于这个基准加上内部相对时间算出来的。
这里有个关键细节:GPRMC一定要在PPS上升沿之前送到雷达,雷达才有足够时间解析出时间信息,然后在下一次PPS上升沿完成时间锁定。如果报文和脉冲的时序反了,雷达大概率会同步失败。我实测下来,比较稳妥的做法是在整秒到来前一段时间就把包含下一秒时间信息的GPRMC发出去,然后在整秒时刻抬升PPS。Livox不同型号对报文提前量的容忍度不太一样,一般提前200ms以上比较保险。
不同同步方式的效果差异很明显,我整理了一个对比表:
| 同步方式 | 精度量级 | 成本 | 适用场景 | 实际体验 |
|---|---|---|---|---|
| 纯软件时间戳(ROS时间同步) | 毫秒级到十几毫秒 | 无 | 低速、短时实验 | 跑一会就飘,点云容易畸变 |
| PTP网络时间同步 | 微秒级 | 需要交换机支持 | 多雷达、多设备组网 | 配置复杂,依赖网络环境 |
| PPS加GPRMC硬件同步 | 亚毫秒级 | 一块STM32开发板 | LiDAR-IMU紧耦合 | 稳定、精准,调好就一劳永逸 |
| PPS加PTP主时钟 | 微秒级 | 需要专用时钟源 | 车载多传感器系统 | 精度最高,但成本也最高 |
从性价比来看,用STM32F4做PPS加GPRMC同步是最务实的选择。F4系列芯片几十块钱,通用定时器资源丰富,还能顺便干点别的活。
2. 整体方案设计:STM32F4在同步链路里的角色
2.1 方案里需要哪些硬件
我用的主控是STM32F407VET6最小系统板,主要看重它的APB1定时器时钟能跑到84MHz,通用定时器数量多,TIM2、TIM3、TIM4、TIM5都是32位定时器,计数范围大,做时间戳和周期测量很从容。
硬件连接也很简单。STM32F4的一个定时器通道输出1Hz的PWM信号,直接接到Livox雷达的PPS输入引脚上。另一个串口连接雷达的同步数据输入,每秒发一帧GPRMC报文。如果要给雷达做外部时间基准校准,还需要预留一个定时器输入捕获引脚,用来接外部高精度PPS信号(比如GPS模块的秒脉冲输出)。
Livox雷达的PPS输入一般要求3.3V或5V电平,STM32F4的GPIO输出电平是3.3V,可以直接对接。不过要注意雷达端的输入阻抗和静电防护,最好在信号线上串一个100欧姆左右的电阻做阻抗匹配,防止信号反射导致脉冲边沿抖动。雷达的GPRMC输入接口一般也是串口TTL电平,同样可以直接连。如果雷达是RS232电平,就要加MAX3232做电平转换,这个务必在动手前查清楚型号手册。
2.2 通用定时器一鱼三吃
STM32F4的通用定时器在这套方案里承担了三个角色,这也是我推荐用通用定时器而不是专门做PPS发生器芯片的原因。
第一个角色是PPS脉冲发生器。定时器工作在PWM模式,配置成1Hz输出,占空比调成5%左右(也就是高电平持续50ms)。Livox对PPS高电平宽度有一定要求,太短了雷达可能检测不到,太长了又可能干扰下次上升沿的检测。50ms是我反复试出来的稳定区间。
第二个角色是时间戳记录器。PPS上升沿触发定时器更新中断或者外部中断,在中断服务函数里记录当前UTC时间,并把软件时间基准同步到整秒。这个动作保证了后续所有事件的时间戳都从同一起点计算。
第三个角色是晶振漂移测量器。这也是最容易被人忽略但非常重要的功能。STM32内部晶振精度一般在20ppm到50ppm左右,如果环境温度变化大,长期运行后1秒钟会漂移几十微秒。对于一般需求无所谓,但Livox做紧耦合里程计用,几十微秒的漂移积累几小时后就会变成几毫秒的误差。所以我把定时器配置成输入捕获模式,接一个外部高精度PPS基准源,测量本地PPS和基准PPS之间的相位差,用软件做补偿。
这三个功能共用一组外设,代码结构也很清晰,比起用独立芯片或者FPGA方案,开发成本和物料成本都低得多。
3. PPS信号生成:通用定时器PWM模式的详细配置
3.1 分频系数和重装载值怎么算
用定时器产生1Hz的PWM,本质上就是把84MHz的计数器时钟分频到1Hz。计算公式很简单:
PWM频率 = 定时器时钟 / ((PSC + 1) * (ARR + 1))
其中PSC是预分频值,ARR是自动重装载值。目标是1Hz,所以(PSC + 1) * (ARR + 1)要等于84000000。
我选的组合是PSC = 8399,ARR = 9999。验算一下:84000000 / (8400 * 10000) = 1Hz,完全正确。
占空比由比较寄存器CCR决定。占空比 = CCR / (ARR + 1),50ms对应5%的占空比,CCR取500就合适了。这里要注意,CCR的值是在PWM模式1下,计数器的值小于CCR时输出高电平,大于等于CCR时输出低电平。所以CCR = 500意味着每个周期前500个计数单位输出高电平,也就是高电平宽度 = 500 / 84000000 ≈ 5.95微秒?等等,这样算不对。我再仔细捋一下。
定时器时钟84MHz,PSC = 8399意味着每8400个时钟周期计数器加1,所以计数器频率是84MHz / 8400 = 10kHz,也就是计数器每0.1ms加一。ARR = 9999意味着计数器从0数到9999为一个周期,一个周期正好1秒。CCR = 500表示计数到500之前输出高电平,从第501个计数开始输出低电平,高电平宽度 = 500 * 0.1ms = 50ms,这个逻辑就对了。
这里也能看出PSC和ARR的选择灵活性。同样1Hz,可以用PSC = 83,ARR = 999999的组合,计数器频率是1MHz,一个周期仍是1秒。但这样ARR值太大,定时器是16位的,最大只能到65535,溢出就出问题了。所以稍微大一点的PSC值更稳妥,计算也直观。
3.2 CubeMX里的配置流程
我用STM32CubeMX生成工程,配置步骤分享出来:
打开CubeMX,选择STM32F407VET6。在Pinout视图中找到TIM2,把Channel1设置为PWM Generation CH1。TIM2是32位定时器,挂在APB1总线上,时钟84MHz。
进入Clock Configuration,确保APB1 Timer Clocks显示84MHz。然后是Parameter Settings:
- Prescaler填8399
- Counter Period填9999
- Auto-reload preload使能,这样改ARR值不会立即生效,避免运行中产生毛刺
- PWM模式选Mode 1
- Pulse值设500
GPIO配置里,TIM2的CH1引脚要设置为复用推挽输出模式,复用功能选择AF1。我用的引脚是PA0,也可以根据芯片封装不同换别的引脚,只要功能映射表里支持TIM2_CH1就行。
串口方面,我用UART1做GPRMC输出,波特率115200,8位数据位,1位停止位,无校验。这个配置和雷达端的同步串口参数保持一致就能通。
如果要做输入捕获校准,把TIM5的Channel1配置为Input Capture direct mode,引脚接外部PPS基准源。TIM5同样挂在APB1上,时钟84MHz。这里要注意输入捕获的滤波设置,建议把滤波器拉高到几十纳秒级别,防止边沿抖动误触发。
3.3 核心代码实现
生成工程后,在main.c里启动PWM输出。核心代码就是这样的:
// main.c 初始化部分 TIM_OC_InitTypeDef sConfigOC = {0}; // TIM2 PWM输出配置 sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 500; // 占空比 500/10000 = 5% sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1); // 启动PWM输出 HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);到这里PPS脉冲就已经持续输出了,用示波器看PA0引脚,应该能测到稳定的1Hz方波,高电平50ms。
但光有脉冲还不够,还得在每秒的固定时刻把GPRMC报文发给雷达。这里我建议用定时器的更新中断来触发发送。为什么?因为更新事件发生在计数器溢出时,正好是PWM周期的起点,也就是PPS上升沿的同一时刻。但前面说了GPRMC要提前发,所以不能在这个中断里才发。我实际的做法是:在更新中断里,不直接发下一秒的报文,而是利用一个软件计数器,在PPS上升沿之后马上生成并缓存好下一帧GPRMC,然后在下一个PPS上升沿之前(比如提前300ms)用定时器通道2的PWM输出或者一个软件定时标志来触发串口发送。
其实更简单可靠的办法是:把定时器中断分成两件事,更新中断只用来记录和调度,真正的发送动作放在主循环里检查标志位执行。比如整秒时刻产生更新中断,中断里设置UTC时间基准、清标志位,然后在主循环里检查到时间已到达整秒前300ms,就调用HAL_UART_Transmit发送GPRMC。这样发送动作不会阻塞中断,也避免在中断里调用耗时函数。
// 定时器更新中断回调 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { // 每秒钟到达整秒时刻 PPS_Flag = 1; // 更新软件时间基准 sync_utc_second++; // 记录本地精确时间戳(计数器和系统节拍) local_timestamp = __HAL_TIM_GET_COUNTER(&htim2); } } // 主循环里发送GPRMC while (1) { if (gprmc_send_flag) { char gprmc_buf[100]; snprintf(gprmc_buf, sizeof(gprmc_buf), "$GPRMC,%02d%02d%02d.000,A,3146.5204,N,11709.1176,E,0.00,0.00,%02d%02d%02d,,,A*%02X\r\n", utc_hour, utc_min, utc_sec, utc_day, utc_month, utc_year, checksum); HAL_UART_Transmit(&huart1, (uint8_t *)gprmc_buf, strlen(gprmc_buf), 10); gprmc_send_flag = 0; } }3.4 GPRMC报文与PPS的时序配合
GPRMC报文是NMEA协议里最常用的定位数据帧之一,Livox雷达通过它获取UTC时间。一个标准的GPRMC长这样:
$GPRMC,064011.000,A,3146.5204,N,11709.1176,E,0.00,0.00,160324,,,A*5D字段含义分别是:UTC时间(064011.000表示6点40分11秒000毫秒),定位状态(A为有效),纬度,经度,速度,航向,日期(160324表示2024年3月16日),磁偏角,模式指示。
这里要注意LIVOX雷达解析GPRMC时,主要看UTC时间和日期字段。定位状态字段给A还是V,不同固件版本行为不一样。我的经验是最好给A,也就是有效定位状态,否则部分固件会把PPS上升沿当成无效时间拒绝锁定。经纬度字段雷达不一定用得上,但格式要正确,不能缺字段,否则整帧解析会出错。
校验和计算也很容易忽视。NMEA校验和是$和*之间所有字符的ASCII码异或值,用十六进制表示。写代码时要用一个循环逐字节异或,不能漏掉逗号。我最初就是因为校验和算错,雷达一直提示同步失败,后来用串口助手逐字节比对才排查出来。
实际的时序关系我这样设计:在整秒时刻,PPS上升沿正好到来,同时更新中断被触发。中断里把当前UTC时间记录为这个整秒对应的时间。然后立即生成下一整秒的GPRMC报文并缓存。再过50ms,PPS下降沿结束后,我开始持续检测距离下一整秒还剩多少时间,当剩余时间小于300ms时,把缓存的GPRMC报文通过串口发送给雷达。这样雷达在下一整秒到来前能完整收到并解析完报文,下一整秒的PPS上升沿一到,雷达就完成时间锁定。
这个提前量需要根据实测调整。如果雷达显示时间同步异常,可以试着增大提前量到500ms,或者把发送时机改为在整秒后100ms内发完(也就是提前约900ms)。Livox不同系列、不同固件对时序的宽容度确实有差异,我测试过Mid-40和HAP,前者对时序要求更严格,后者宽松一些。
4. 时间戳对准与晶振漂移补偿
4.1 如何用通用定时器记录精确时间戳
PPS生成之后,整个系统的时间基准就定下来了,但还有一个细节:STM32自己的系统时间也要和这个PPS严格对齐,否则主控给IMU或者其他传感器打时间戳的时候还是会错位。
我采用的方案是系统节拍和定时器计数结合的方式。STM32的HAL库默认用SysTick做系统节拍,一般配置成1ms一次。但SysTick的计数精度只有1ms,对亚毫秒级同步来说不够。所以在PPS上升沿的更新中断里,我会同时记录当前TIM2的计数器值和一个系统节拍计数值,这两个值共同组成一个高精度的时间锚点。
之后任何需要打时间戳的地方,都可以用“当前系统节拍 + 当前定时器计数器”反推出距最近PPS的时间偏移。因为定时器计数器单位是0.1ms(10kHz计数频率),所以时间戳精度可以到0.1ms,对激光雷达和IMU同步来说足够用了。
当然,如果对精度要求更高,可以把定时器时钟配置成84MHz不分频,也就是计数器直接以84MHz的频率跑,这样计数器每增加一位就代表约11.9纳秒。但84MHz的计数频率下,32位定时器大约51秒就会溢出一次,需要处理溢出中断来扩展计数范围,实现起来稍微绕一点。我在实际项目中用的是10kHz计数频率,综合了精度和实现复杂度。
4.2 晶振误差的来源与分析
STM32的时钟源主要有两种选择:内部RC振荡器和外部晶振。内部RC振荡器精度低,温漂大,做PPS同步场景基本不能用,我直接用外部8MHz无源晶振。
但即使是外部晶振,频率精度也就是±20ppm到±50ppm的水平。别小看这几十个ppm,算一笔账就清楚:假设晶振实际频率比标称值高20ppm,也就是每秒会多出20微秒,一天下来就是1.7秒。这意味着如果你不做任何校准,STM32输出1Hz PPS时,实际周期是0.99998秒,雷达和真实时间之间每1000秒就会累积20毫秒的误差。
这种误差对点云影响有多大?Livox雷达在锁定PPS加GPRMC之后,内部时间基准跟随外部PPS跳动。如果PPS本身周期不准,雷达每个整秒就会被“拉”偏一次,长期运行会看到点云时间戳相对于真实时间整体偏移,影响里程计的前后端对齐。
所以我在方案里加了一个校准机制:通过测量外部高精度PPS基准和本地PPS的实际偏差,计算晶振误差并做软件补偿。
4.3 基于输入捕获的秒脉冲漂移校准
校准的思路是利用TIM5输入捕获来测量两个PPS上升沿之间,本地84MHz或10kHz计数器的计数值。测量结果和理论值做比较,就能算出本地晶振的误差比例。
我这里用10kHz计数器为例:理论上1秒内计数器应该增加10000,但如果测出来是10002,说明本地晶振比标称快了0.02%,也就是200ppm。用这个值作为校准系数,后续软件计时时做一个线性补偿就行了。
代码实现思路如下:
// TIM5输入捕获中断回调 void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM5) { uint32_t capture = HAL_TIM_ReadCapturedValue(&htim5, TIM_CHANNEL_1); static uint32_t last_capture = 0; static uint16_t overflow_count = 0; // 处理计数器溢出 if (capture < last_capture) { // 说明发生了溢出,这里用软件逻辑处理 overflow_count++; } // 计算两次上升沿之间的计数值 uint32_t period_count = capture - last_capture; if (overflow_count > 0) { period_count += 10000 * overflow_count; overflow_count = 0; } // period_count 理论上是10000,偏差就是晶振误差 if (period_count > 0) { freq_correction = (double)10000.0 / (double)period_count; } last_capture = capture; } }注意我这里用的是10kHz计数频率举例。实际使用中TIM5也可以配置成不预分频,直接用84MHz计数,这样测量精度更高。但那样处理溢出溢出的逻辑要复杂不少,对大多数场景来说用10kHz甚至1kHz也够了。
校准系数算出来后,在主循环里维护一个软件时间变量,每次累加时乘上校准系数,就能显著抑制晶振漂移的累积效应。做了这个补偿之后,我在实验室实测,PPS脉冲周期精度可以从原来的几百微秒误差压缩到几十微秒以内,基本能满足Livox雷达对输入PPS的要求。
这里有一点要说明:这种校准是相对校准,需要一个外部基准源。如果没有GPS模块或者原子钟提供参考PPS,那至少可以通过测量本地PPS两次上升沿间隔来验证输出周期是否稳定。漂移补偿的价值在于长时间稳定运行,短时间实验不校准也能凑合。
5. 联调验证与问题排查实录
5.1 在Livox Viewer里确认同步状态
硬件和代码都接好之后,最关键的一步就是联调验证。Livox官方提供的Livox Viewer软件可以直接查看雷达的同步状态,不需要自己写代码去解析,调试效率高很多。
把雷达接上,打开Livox Viewer,在设置里把同步方式选择为GPS同步(PPS加GPRMC)或者二次开发同步模式,不同型号叫法略有差异。然后观察设备状态栏,正常情况下应该能看到同步状态变成绿色锁定。
如果状态一直是黄色或者红色,最常见的几个原因就是GPRMC报文没收到、校验和错误、PPS边沿检测异常。这时先用串口调试助手确认STM32是否在持续发送GPRMC,然后检查报文内容是否合法、校验和计算是否正确,最后用示波器看PPS波形是否干净、脉宽是否合适。
我第一次调的时候就是PPS脉宽设成了50%,高电平长达0.5秒,雷达死活锁不上,后来查手册发现高电平持续太长时间会让雷达误以为进入了异常状态,改回5%之后一次就通了。
5.2 用点云时间戳检查同步精度
Viewer显示同步锁定只是第一步,更严格的验证是查看点云时间戳是否连续。
用Livox官方SDK或者ROS驱动订阅点云数据,把每个点的时间戳字段导出来,做一个时间序列图。正常情况下,相邻时间戳的差值应该严格保持在固定的帧周期上,比如10Hz点云就是100ms差,而且抖动应该在亚毫秒级。
如果时间戳出现跳变,比如某几个点的时间戳突然比前面多出几百毫秒甚至一秒,那大概率是PPS信号不稳定或者GPRMC报文和PPS上升沿的对应关系错了。这种问题用软件看是看不出来的,必须回到示波器上查PPS波形和串口时序。
我在一次实测中发现,给STM32供电的USB线接触不良会导致PPS输出瞬间拉低,随后雷达同步丢失又自动重锁,点云时间戳上就会出现明显的台阶。后来换成独立稳压电源供电,问题就消失了。这个小细节提醒我:PPS信号对电源稳定性非常敏感,不要和电机驱动共用电源。
5.3 常见问题速查表
我把调试过程中遇到的高频问题整理成了一个表格,方便大家排查:
| 现象 | 可能原因 | 排查方法与处理措施 |
|---|---|---|
| 示波器看不到PPS波形 | 定时器没启动、引脚复用配置错误 | 检查HAL_TIM_PWM_Start是否调用,确认GPIO复用AF是否正确 |
| PPS有波形但雷达不同步 | GPRMC报文未发送或格式错误、时序不对 | 串口助手监听UART1输出,核对报文字段和校验和,调大发送提前量 |
| 雷达同步状态偶尔丢失 | 供电不稳、PPS脉宽不合适、电磁干扰 | 独立电源供电,脉宽调成20到100ms,信号线加磁环 |
| 点云时间戳整体偏移 | 本地晶振漂移未补偿 | 加上输入捕获校准机制,用外部基准PPS实测漂移量 |
| GPRMC校验和一直报错 | 异或计算漏了字段或字符编码问题 | 逐字节和上位机工具对比,检查CR LF换行符是否被误算 |
| 输入捕获测量值乱跳 | 捕获引脚没有滤波、边沿抖动 | 在CubeMX里配置数字滤波器,设成几十纳秒级别 |
| 雷达锁定后点云仍有畸变 | 雷达和IMU时间基准没有真正统一 | 确认STM32主控的时间戳信息同步分发给IMU,不能只同步雷达 |
最后再聊几句调试中的真实体会。我开始时一直纠结要不要用专门的高精度时钟芯片,觉得STM32的晶振不够准,后来发现其实绝大多数场景下,一个校准过的STM32通用定时器方案完全够用。关键在于把“绝对精度”和“相对一致性”分开来看:Livox雷达最终需要的是PPS上升沿和GPRMC报文时间的相对对齐,以及长时间运行下的稳定输出。用外部基准PPS测量本地漂移,再用软件补偿把累积误差压下去,这一段我踩过不少坑,但调通之后的效果确实让人安心。
另外一个容易被忽略的点是日志记录。建议在调试阶段把每次PPS锁定的时间、校准系数、同步状态都通过串口打出来,存到本地文件里。出了问题对着日志分析,比对着示波器瞎猜快得多。我现在这套装置已经连续跑了快两个月,日志显示同步始终稳定锁定,点云时间戳的抖动基本在0.2ms以内,跑Fast-LIVO前端时没有再出现过因为时间不同步导致的里程计跳变。这套方案不敢说是最优解,但对于做激光雷达同步的工程师来说,它够简单、够稳定、也够便宜。