4、数据采集软件框架:RTOS基础、传感器驱动与时间戳

这一章,我们聊聊无人机数据采集的软件骨架。说白了,就是怎么让飞控板上的代码有条不紊地干活,同时把传感器数据整得明明白白。

我见过不少团队,硬件选型挺牛,传感器也高级,但软件架构一塌糊涂。结果呢?数据乱跳,时间对不上,飞起来心里发虚。嗯,咱们今天就把这块地基打牢。

4.1 实时操作系统(RTOS)基础

无人机不是手机。你不能让一个高优先级的任务被低优先级的任务堵住。比如,电机控制必须准时,GPS数据晚来几毫秒可能问题不大,但姿态控制要是晚了——炸机。

所以,我们需要RTOS。它跟Windows、Linux那种“大家排队等”的思路不一样。RTOS的核心是:谁急谁先跑。

RTOS在无人机中的三大作用:

  • 任务调度:按优先级分配CPU时间,确保关键任务不被饿死。
  • 中断管理:传感器数据来了,立刻响应,不拖沓。
  • 资源同步:多个任务同时访问同一个数据时,不出乱子。

我个人习惯用FreeRTOS,轻量、开源、资料多。当然,也有用uC/OS、RT-Thread的。选哪个不重要,关键是理解它的调度机制。

4.1.1 任务优先级与调度策略

RTOS里,每个任务都有一个优先级。数值越小,优先级越高(或者反过来,看具体实现)。调度器会保证:高优先级任务就绪时,低优先级任务立刻让路。

举个例子,我做过一个四旋翼项目,任务优先级是这样分配的:

任务名称 优先级 执行频率 说明
姿态控制 最高 1 kHz 电机PWM输出,延迟不能超过1ms
传感器读取 高 1 kHz IMU数据采集,与姿态控制同步
GPS处理 中 10 Hz 位置更新,可以容忍几十毫秒延迟
数据记录 低 100 Hz 写SD卡,慢一点没关系

你看,姿态控制和传感器读取绑在一起,优先级最高。GPS慢一点没关系,数据记录最不着急。这样设计,飞控的实时性就有保障了。

小技巧: 我曾经踩过一个坑——把传感器读取和姿态控制放在同一个任务里。结果传感器初始化卡了一下,姿态控制直接超时。后来我改成两个独立任务,用队列传递数据,问题就解决了。

4.1.2 任务间通信:队列与信号量

RTOS里,任务之间不能直接访问对方的变量。为啥?因为可能同时读写,数据就乱了。我们需要用队列(Queue)或信号量(Semaphore)来通信。

  • 队列:一个任务往里面放数据,另一个任务取数据。先进先出,适合传感器数据流。
  • 信号量:一个任务发个“我干完了”的信号,另一个任务收到后开始干活。适合同步。

你想想看,IMU数据每秒1000次往队列里塞,姿态控制任务每秒1000次从队列里取。只要队列深度够,就不会丢数据。

// FreeRTOS 队列示例
QueueHandle_t imuQueue;

// 传感器读取任务
void vSensorTask(void *pvParameters) {
    imu_data_t data;
    while(1) {
        read_imu(&data);
        xQueueSend(imuQueue, &data, portMAX_DELAY);
        vTaskDelay(pdMS_TO_TICKS(1)); // 1ms周期
    }
}

// 姿态控制任务
void vControlTask(void *pvParameters) {
    imu_data_t data;
    while(1) {
        if(xQueueReceive(imuQueue, &data, portMAX_DELAY) == pdTRUE) {
            attitude_control(&data);
        }
    }
}

嗯,这里要注意:队列的等待时间别设成无限阻塞(portMAX_DELAY),万一传感器挂了,整个任务就卡死了。我一般设个超时,比如10ms,超时了就报错。

4.2 传感器驱动开发

传感器驱动,说白了就是让MCU跟传感器芯片“说话”。常见的接口有I2C、SPI、UART。无人机里,IMU(MPU6050、BMI088)多用SPI或I2C,气压计(MS5611)用I2C,GPS用UART。

我开发驱动时,习惯分三层:

  1. 硬件抽象层(HAL):封装MCU的SPI/I2C读写函数。
  2. 芯片驱动层:实现传感器的寄存器读写、初始化、数据读取。
  3. 应用接口层:提供校准、滤波、单位转换等高级功能。

这样做的好处是,换MCU时只改HAL层,芯片驱动不用动。

4.2.1 驱动开发的关键步骤

以BMI088(6轴IMU)为例,我讲讲驱动开发的流程:

  • 第一步:读数据手册。别偷懒。搞清楚寄存器地址、通信时序、初始化序列。
  • 第二步:实现读写函数。用SPI或I2C读写单个寄存器。
  • 第三步:验证芯片ID。读WHO_AM_I寄存器,确认通信正常。
  • 第四步:配置传感器。设置量程、滤波器带宽、输出数据速率。
  • 第五步:读取数据。连续读取加速度和角速度寄存器。

避坑指南: 我曾经在BMI088驱动上栽过跟头。数据手册说SPI模式是“模式0”,但我没注意它要求时钟极性是“空闲低电平”。结果读出来的数据全是0xFF。查了两天才发现是SPI模式配错了。所以,一定要用逻辑分析仪抓波形,确认时序对不对。

4.2.2 驱动代码示例(BMI088 SPI)

// BMI088 加速度计初始化
uint8_t bmi088_accel_init(void) {
    uint8_t chip_id;
    
    // 1. 读取芯片ID
    spi_read_reg(BMI088_ACCEL_CHIP_ID_ADDR, &chip_id, 1);
    if(chip_id != BMI088_ACCEL_CHIP_ID) {
        return BMI088_ERR_CHIP_ID;
    }
    
    // 2. 软复位
    spi_write_reg(BMI088_ACCEL_SOFTRESET_ADDR, 0xB6);
    vTaskDelay(pdMS_TO_TICKS(10));
    
    // 3. 配置量程 ±24g,ODR 1600Hz
    spi_write_reg(BMI088_ACCEL_RANGE_ADDR, 0x03);  // ±24g
    spi_write_reg(BMI088_ACCEL_ODR_ADDR, 0xAC);    // 1600Hz
    
    // 4. 使能传感器
    spi_write_reg(BMI088_ACCEL_PWR_CTRL_ADDR, 0x04);
    
    return BMI088_OK;
}

// 读取加速度数据
void bmi088_accel_read(float *accel_x, float *accel_y, float *accel_z) {
    uint8_t buf[6];
    int16_t raw[3];
    
    // 连续读取6个字节(X、Y、Z各2字节)
    spi_read_reg(BMI088_ACCEL_X_LSB_ADDR, buf, 6);
    
    // 合并为16位有符号数
    raw[0] = (int16_t)(buf[1] << 8 | buf[0]);
    raw[1] = (int16_t)(buf[3] << 8 | buf[2]);
    raw[2] = (int16_t)(buf[5] << 8 | buf[4]);
    
    // 转换为物理单位(m/s²),±24g量程下 LSB = 0.001464 g
    *accel_x = raw[0] * 0.001464f * 9.80665f;
    *accel_y = raw[1] * 0.001464f * 9.80665f;
    *accel_z = raw[2] * 0.001464f * 9.80665f;
}

4.3 数据同步与时间戳

这是数据采集里最容易被忽视、但后果最严重的问题。你想想看,IMU数据是1000Hz,GPS是10Hz,气压计是100Hz。如果每个数据的时间基准不一样,融合出来的姿态和位置就是错的。

我见过一个案例:某团队做无人机测绘,拍的照片和GPS时间戳差了200ms。结果生成的3D模型全是歪的。嗯,这就是时间不同步的代价。

4.3.1 时间戳的生成策略

在RTOS环境下,时间戳通常用系统滴答定时器(SysTick)生成。每个数据读取时,记录当前的tick值,换算成微秒或毫秒。

我推荐的做法是:

  • 统一时间基准:所有传感器都用同一个定时器计数。
  • 在中断里打时间戳:数据就绪中断触发时,立刻记录时间,而不是等任务处理时再记。
  • 时间戳与数据绑定:把时间戳和传感器数据打包成一个结构体,一起传递。
// 带时间戳的IMU数据结构体
typedef struct {
    uint64_t timestamp_us;  // 微秒级时间戳
    float accel_x;
    float accel_y;
    float accel_z;
    float gyro_x;
    float gyro_y;
    float gyro_z;
} imu_data_ts_t;

// SPI中断服务函数(假设数据就绪引脚触发中断)
void EXTI_IRQHandler(void) {
    uint64_t now = get_system_us();  // 立即记录时间
    imu_data_ts_t data;
    data.timestamp_us = now;
    
    // 读取传感器数据
    bmi088_accel_read(&data.accel_x, &data.accel_y, &data.accel_z);
    bmi088_gyro_read(&data.gyro_x, &data.gyro_y, &data.gyro_z);
    
    // 放入队列
    xQueueSendFromISR(imuQueue, &data, NULL);
}

经验之谈: 我习惯用64位微秒时间戳。32位毫秒时间戳在49天后会溢出,无人机飞不了那么久,但万一你跑个长航时测试呢?64位微秒可以跑到几十万年,省心。

4.3.2 多传感器时间对齐

不同传感器的数据速率不一样,怎么对齐?我常用的方法是:

  1. 插值法:以最高频率的传感器(通常是IMU)为基准,对其他传感器数据做线性插值,算出它在同一时刻的值。
  2. 最近邻法:找到时间戳最接近的那个数据,直接拿来用。简单,但精度稍差。
  3. 硬件同步:用MCU的定时器输出PWM,同时触发多个传感器的数据采集。这是最准的,但硬件上要支持。

我个人偏好插值法。精度够,实现也不复杂。你想想看,GPS是10Hz,两个数据之间隔了100ms。IMU在这100ms里跑了100个数据。用IMU的时间戳做基准,对GPS位置做线性插值,就能得到每个IMU时刻对应的位置。

4.3.3 时间同步的常见坑

我曾经踩过的坑:

  • 中断优先级:时间戳记录的中断优先级必须最高。否则,高优先级中断嵌套时,时间戳会偏大。
  • 定时器溢出:32位定时器在72MHz下,每59秒溢出一次。必须做溢出处理,否则时间戳会回绕。
  • 任务调度延迟:不要在任务里打时间戳。任务可能被高优先级任务抢占,时间戳就不准了。

4.4 本章知识体系总览

下面这张图,把本章的核心逻辑串起来了。你可以看到,RTOS是骨架,传感器驱动是血肉,时间戳是神经——三者缺一不可。

数据采集软件框架总览 数据采集软件框架总览 实时操作系统 (RTOS) 任务调度 · 中断管理 · 资源同步 IMU 驱动 SPI/I2C · 初始化 · 读取 GPS 驱动 UART · NMEA解析 · 位置 气压计驱动 I2C · 温度补偿 · 高度 数据同步与时间戳 统一基准 · 中断打戳 · 插值对齐 统一数据帧输出 RTOS提供调度保障 → 驱动层采集原始数据 → 时间戳层统一对齐 → 输出可用数据帧

好了,这一章的内容就这些。RTOS怎么搭、驱动怎么写、时间戳怎么打,都是实打实的工程经验。你把这些搞明白了,数据采集的软件框架就算立住了。