针对现在的“FreeRTOS 下位机 + Linux/ROS 2 上位机 + 后续轮速/IMU/2D雷达融合”,建议直接制定一套统一的时间戳与通信协议。 最核心的原则是: [ \boxed{ \text{MCU原始单调时间戳永不修改} \quad+\quad \text{上位机维护时钟映射} } ] 即: [ T_{\rm host}=a,t_{\rm mcu}+b ] ## 1. 下位机统一时间基准 不要把 `xTaskGetTickCount()` 作为传感器主时间戳。 它适合: * `vTaskDelayUntil()` 周期调度; * 超时判断; * 任务延时; * 毫秒级状态机。 它不适合: * IMU与编码器精确对齐; * 200 Hz以上传感器采样; * 分析通信延迟; * 后续雷达、IMU、轮式里程计融合。 FreeRTOS Tick 的分辨率由 `configTICK_RATE_HZ` 决定;ISR 中若确实要读 Tick,应使用 `xTaskGetTickCountFromISR()`。[FreeRTOS Task Utilities](https://www.freertos.org/Documentation/02-Kernel/04-API-references/03-Task-utilities/00-Task-utilities) ### 推荐时间源 使用一个32位硬件定时器: ```text 计数频率:1 MHz 计数单位:1 us 自动重装值:0xFFFFFFFF 软件扩展:64 bit 时间含义:MCU自本次上电以来经过的微秒数 ``` 例如 STM32 可以使用 TIM2 或 TIM5: [ t_{\rm mcu}\in uint64_t,\qquad 单位=\mu s ] 32位、1 MHz计数器约每71.58分钟回绕一次,所以用更新中断增加高32位。 ```c static volatile uint32_t g_time_high = 0; void TIM2_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim2, TIM_FLAG_UPDATE) != RESET) { __HAL_TIM_CLEAR_IT(&htim2, TIM_IT_UPDATE); g_time_high++; } } uint64_t mcu_time_us(void) { uint32_t high1; uint32_t high2; uint32_t low; uint32_t status; do { high1 = g_time_high; __DMB(); low = TIM2->CNT; status = TIM2->SR; __DMB(); high2 = g_time_high; } while (high1 != high2); /* * 计数器已经回绕,但更新中断可能还没有得到执行。 * low 小于半量程,说明读取发生在回绕之后。 */ if ((status & TIM_SR_UIF) && low < 0x80000000U) { high1++; } return ((uint64_t)high1 << 32) | low; } ``` 注意: * 定时器时钟必须确认是否受 APB 分频后的“定时器倍频”影响; * 不要直接读取一个由中断写入的普通 `uint64_t`,32位MCU上未必原子; * 如果启用了 STOP 模式或 Tickless Idle,要确认这个定时器休眠时是否继续工作; * 时间戳只能递增,不能因为校时而向前或向后跳变。 ## 2. 时间戳应该在哪里打 时间戳必须尽可能靠近数据真正产生的位置。 | 数据 | 推荐时间戳位置 | | -------- | ------------- | | IMU | DRDY中断发生时 | | SPI读取IMU | 不要等SPI读取完成才打点 | | 编码器 | 控制定时器锁存计数器时 | | 电机控制量 | PWM寄存器更新时 | | 控制周期 | 控制周期入口 | | 上位机接收 | 完整帧接收完成时,单独记录 | 例如: ```c typedef struct { uint64_t sample_time_us; int16_t gyro_raw[3]; int16_t accel_raw[3]; } ImuSample; ``` IMU中断中只做: 1. 读取 `mcu_time_us()`; 2. 保存时间戳; 3. 通知采集任务; 4. 立即退出中断。 不要在中断中完成协议编码、CRC计算和串口发送。 如果传感器内部启用了低通滤波,DRDY时间仍然可能比真实物理测量晚一个固定群延迟。后续可以增加: ```c corrected_time_us = drdy_time_us - imu_filter_delay_us; ``` 但必须在确定传感器滤波器延迟后再补偿。 ## 3. FreeRTOS任务结构 建议使用: ```mermaid flowchart TD A["IMU DRDY / 控制定时器 ISR"] --> B["采样环形缓冲区"] B --> C["传感器处理任务"] C --> D["协议发送队列"] D --> E["唯一 UART TX 任务"] F["UART DMA / IDLE ISR"] --> G["接收环形缓冲区"] G --> H["协议解析任务"] ``` 关键规则: * UART发送只能有一个所有者任务,避免多个任务发送的数据交叉; * UART接收使用DMA、IDLE中断和环形缓冲区; * ISR只负责搬运数据和通知任务; * 二进制协议和 `printf()` 调试日志不要共用同一个UART; * 如果ISR调用 FreeRTOS 的 `...FromISR()` 接口,中断优先级必须符合 `configMAX_SYSCALL_INTERRUPT_PRIORITY` 约束; * 周期任务使用 `vTaskDelayUntil()`,它能避免普通相对延时产生的累计漂移,但实际执行时刻仍应调用硬件微秒时钟记录。[FreeRTOS `vTaskDelayUntil()`](https://freertos.org/xtaskdelayuntiltask-control.html) 例如200 Hz低精度周期任务: ```c void SensorTask(void *argument) { TickType_t last_wake = xTaskGetTickCount(); const TickType_t period = pdMS_TO_TICKS(5); for (;;) { vTaskDelayUntil(&last_wake, period); uint64_t actual_time_us = mcu_time_us(); /* 采集和处理 */ } } ``` 如果要求更稳定的200 Hz采样,应由硬件定时器产生中断,再通知任务,而不是完全依赖 FreeRTOS Tick。 ## 4. 推荐二进制帧规范 v1 假设使用 UART 或 USB CDC,建议采用: ```text COBS编码(协议头 + 负载 + CRC32) + 0x00帧分隔符 ``` COBS的好处是任意解析错误后,都可以在下一个 `0x00` 重新找到帧边界。 ### 统一协议头:24字节 之前已有的基础上额外增加 | session_id | uint32 | 4 | 本次MCU启动会话ID | | sample_time_us | uint64 | 8 | MCU采样时间 | 但不要直接执行: ```c uart_send((uint8_t *)&header, sizeof(header)); // 不推荐 ``` 因为不同编译器可能存在: * 结构体填充; * 对齐差异; * 大小端差异; * 浮点格式差异。 | ## 7. 四时间戳同步协议 每次同步记录: * (T_1):上位机发送请求; * (t_2):MCU收到并解析请求; * (t_3):MCU准备发送响应; * (T_4):上位机收到完整响应。 定义MCU时间减上位机时间为: [ \theta= \frac{(t_2-T_1)+(t_3-T_4)}{2} ] 往返延迟: [ d=(T_4-T_1)-(t_3-t_2) ] 初始转换: [ T_{\rm host}\approx t_{\rm mcu}-\theta ] 运行中收集多组: [ x_i=\frac{t_{2,i}+t_{3,i}}{2} ] [ y_i=\frac{T_{1,i}+T_{4,i}}{2} ] 拟合: [ \boxed{T_{\rm host}=a,t_{\rm mcu}+b} ] 推荐参数: * 启动时连续同步20~50次; * 初始阶段先令 (a=1),从最小RTT样本估计 (b); * 运行中每1秒同步一次; * 保存最近60~120组; * 丢弃RTT明显偏大的样本; * 使用RTT最小的20%~30%拟合 (a,b); * MCU时间戳本身永远不被“校准”或重写。 上位机使用 `CLOCK_MONOTONIC_RAW` 或 `std::chrono::steady_clock`,不要使用会被NTP校时改变的墙上时间参与控制。 ## 8. ROS 2时间戳转换 MCU的“上电微秒数”不能直接填进 ROS 消息的 `header.stamp`,否则无法和激光雷达时间对齐。 上位机先得到: [ T_{\rm steady}=a,t_{\rm mcu}+b ] 再在ROS桥接节点中保存一对锚点: [ (T_{\rm steady,0},T_{\rm ros,0}) ] 转换为: [ T_{\rm ros,sample} ================== T_{\rm ros,0} + (T_{\rm steady,sample}-T_{\rm steady,0}) ] 然后将这个时间填入: * `sensor_msgs/Imu.header.stamp`; * `nav_msgs/Odometry.header.stamp`; * 编码器或轮速自定义消息时间戳。 这样轮速、IMU和激光雷达才能在同一ROS时间轴上融合。 ## 9. 上位机命令不要依赖绝对时间 普通运动命令建议使用“接收后立即执行+超时”: ```c typedef struct { uint32_t command_sequence; uint32_t valid_for_us; float velocity_ref; float yaw_rate_ref; } MotionCommand; ``` MCU收到时记录: ```c last_command_rx_us = mcu_time_us(); ``` 安全判断: ```c if (mcu_time_us() - last_command_rx_us > command.valid_for_us) { enter_safe_deceleration(); } ``` 只有真正需要未来定时执行时,才让上位机通过逆映射计算: [ t_{\rm mcu,execute} =================== \frac{T_{\rm host,execute}-b}{a} ]