4. 传感器数据采集基础:I2C/SPI接口传感器驱动开发、定时器触发采样、DMA传输数据、数据缓存与环形缓冲区设计

好,咱们进入第四章。这一章可以说是整个故障预测系统的「感官神经」。你想想看,没有准确、及时的数据,后面再牛的AI算法也是白搭。我刚开始做嵌入式AI那会儿,就吃过数据采集的亏——采样率不够,数据还丢包,模型训练出来就是个「睁眼瞎」。

这一章,我会带你搞定四个核心环节:传感器驱动(怎么跟传感器「说话」)、定时器触发(什么时候采样)、DMA传输(怎么不占CPU地把数据搬回来)、以及环形缓冲区(数据来了往哪儿放)。

4.1 I2C与SPI接口传感器驱动开发

先说接口。STM32上最常见的传感器接口就是I2C和SPI。我个人习惯是:低速、数据量小的传感器用I2C,高速、数据量大的用SPI。比如温度传感器、加速度计,I2C就够了;要是接摄像头或者高采样率的麦克风,那必须上SPI。

4.1.1 I2C驱动开发要点

I2C就两根线:SCL(时钟)和SDA(数据)。驱动开发说白了就是按照时序图,把「开始信号→设备地址→寄存器地址→数据读写→停止信号」这一套流程走通。

我在项目中遇到过一个问题:I2C总线挂死。排查了半天,发现是某个从设备没释放总线。后来我养成了一个习惯——每次通信前先发一个「停止信号」复位总线状态。

核心代码片段:I2C读取传感器数据

// 读取MPU6050加速度计数据
HAL_StatusTypeDef MPU6050_ReadAccel(MPU6050_HandleTypeDef *dev, int16_t *accel_data)
{
    uint8_t buf[6];
    uint8_t reg = MPU6050_ACCEL_XOUT_H;

    // 写寄存器地址
    HAL_I2C_Master_Transmit(&dev->hi2c, dev->addr, &reg, 1, 100);
    // 读6字节数据
    HAL_I2C_Master_Receive(&dev->hi2c, dev->addr, buf, 6, 100);

    // 组合成16位有符号数
    accel_data[0] = (int16_t)((buf[0] << 8) | buf[1]);
    accel_data[1] = (int16_t)((buf[2] << 8) | buf[3]);
    accel_data[2] = (int16_t)((buf[4] << 8) | buf[5]);

    return HAL_OK;
}

避坑指南:我曾经在I2C通信时忘了检查HAL函数的返回值,结果传感器偶尔不响应,程序就卡死了。后来我加了超时重试机制,问题解决。记住:任何通信都要加超时处理

4.1.2 SPI驱动开发要点

SPI比I2C快,但线也多:SCK、MOSI、MISO、CS(片选)。每个从设备一根CS线,所以设备多了IO口不够用。

SPI驱动开发有个关键点:时钟极性(CPOL)和时钟相位(CPHA)必须和传感器匹配。我见过太多人在这上面栽跟头——读出来的数据全是0xFF或者0x00,其实就是模式没配对。

SPI模式 CPOL CPHA 数据采样边沿
模式0 0 0 上升沿采样
模式1 0 1 下降沿采样
模式2 1 0 下降沿采样
模式3 1 1 上升沿采样

嗯,这里要注意:大多数传感器默认用模式0或模式3。你拿到数据手册后,先看时序图,确认数据是在SCK的哪个边沿采样的。

4.2 定时器触发采样

传感器驱动写好了,接下来就是「什么时候采样」的问题。你总不能在主循环里一直轮询吧?那样CPU啥也别干了。

我的做法是:用定时器产生固定频率的中断,在中断服务函数里触发采样。比如故障预测需要100Hz的采样率,那就把定时器配成10ms中断一次。

定时器配置示例(100Hz采样)

// 定时器句柄
TIM_HandleTypeDef htim2;

// 定时器初始化函数
void MX_TIM2_Init(void)
{
    TIM_ClockConfigTypeDef sClockSourceConfig = {0};
    TIM_MasterConfigTypeDef sMasterConfig = {0};

    htim2.Instance = TIM2;
    htim2.Init.Prescaler = 7200 - 1;      // 72MHz / 7200 = 10kHz
    htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
    htim2.Init.Period = 100 - 1;           // 10kHz / 100 = 100Hz
    htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
    HAL_TIM_Base_Init(&htim2);

    // 使能更新中断
    HAL_NVIC_SetPriority(TIM2_IRQn, 0, 0);
    HAL_NVIC_EnableIRQ(TIM2_IRQn);
}

// 中断服务函数
void TIM2_IRQHandler(void)
{
    HAL_TIM_IRQHandler(&htim2);
}

// 回调函数——在这里触发采样
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance == TIM2->Instance)
    {
        // 触发ADC采样或读取传感器
        Sensor_TriggerSampling();
    }
}

注意:中断服务函数里不要做耗时操作!我曾经在中断里直接调用HAL_I2C_Master_Receive,结果因为I2C通信太慢,导致中断嵌套,系统直接崩了。正确的做法是:中断里只置一个标志位,主循环里处理数据

4.3 DMA传输数据

说到DMA,说白了就是「数据搬运工」。CPU告诉DMA:「你去把传感器数据搬到内存里,搬完了告诉我一声」,然后CPU就可以去干别的事了。

我为什么强调DMA?因为故障预测系统往往需要连续采集数据,如果每次都用CPU去读,那CPU的利用率会非常低。用DMA的话,CPU可以腾出手来做FFT、特征提取这些计算密集型任务。

4.3.1 SPI+DMA传输

SPI配合DMA是最常见的组合。配置起来也不复杂:

// SPI DMA接收配置
void SPI_DMA_Receive(uint8_t *buffer, uint16_t size)
{
    // 启动DMA接收
    HAL_SPI_Receive_DMA(&hspi1, buffer, size);
}

// DMA传输完成回调
void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi)
{
    if (hspi->Instance == SPI1->Instance)
    {
        // 数据接收完成,处理数据
        ProcessSensorData();
    }
}

这里有个小技巧:使用DMA循环模式。比如你要连续采集1000个点,可以配成循环模式,DMA会自动把数据填满缓冲区,满了就从头开始覆盖。配合环形缓冲区使用,效果绝佳。

4.4 数据缓存与环形缓冲区设计

数据采集进来了,往哪儿放?直接放全局数组?那数据覆盖问题怎么处理?

我的答案是:环形缓冲区。它就像一个循环队列,有头指针和尾指针。数据从尾指针写入,从头指针读出。当缓冲区满了,新数据会覆盖最旧的数据。

环形缓冲区核心实现

// 环形缓冲区结构体
typedef struct {
    float buffer[RING_BUFFER_SIZE];
    uint16_t head;      // 读指针
    uint16_t tail;      // 写指针
    uint16_t count;     // 当前数据个数
} RingBuffer_t;

// 初始化
void RingBuffer_Init(RingBuffer_t *rb)
{
    rb->head = 0;
    rb->tail = 0;
    rb->count = 0;
}

// 写入数据
uint8_t RingBuffer_Write(RingBuffer_t *rb, float data)
{
    rb->buffer[rb->tail] = data;
    rb->tail = (rb->tail + 1) % RING_BUFFER_SIZE;

    if (rb->count < RING_BUFFER_SIZE)
    {
        rb->count++;
    }
    else
    {
        // 缓冲区满了,覆盖最旧数据,头指针也要移动
        rb->head = (rb->head + 1) % RING_BUFFER_SIZE;
    }
    return 1;
}

// 读取数据
uint8_t RingBuffer_Read(RingBuffer_t *rb, float *data)
{
    if (rb->count == 0)
        return 0;  // 缓冲区空

    *data = rb->buffer[rb->head];
    rb->head = (rb->head + 1) % RING_BUFFER_SIZE;
    rb->count--;
    return 1;
}

经验之谈:我曾经在环形缓冲区上踩过一个坑——读写指针的原子性问题。在中断里写数据,在主循环里读数据,如果不加保护,可能会出现「读到了半新半旧的数据」。我的解决方案是:在临界区操作时关中断,或者使用无锁环形缓冲区(单生产者单消费者场景)。

4.5 完整数据采集链路

好了,把上面四个环节串起来,就是一个完整的数据采集链路:

  1. 定时器产生100Hz中断
  2. 中断服务函数里触发SPI DMA传输
  3. DMA自动把传感器数据搬进环形缓冲区
  4. 主循环从环形缓冲区取出数据,进行预处理和特征提取

你想想看,整个过程中CPU几乎不参与数据搬运,效率非常高。我之前的项目里,用这套方案实现了8通道、1kHz采样率的振动信号采集,CPU占用率才不到20%。

总结一下本章要点

  • I2C适合低速传感器,注意总线挂死问题
  • SPI适合高速传感器,注意时钟模式匹配
  • 定时器触发采样,中断里只做轻量操作
  • DMA解放CPU,循环模式配合环形缓冲区
  • 环形缓冲区解决数据生产者和消费者速度不匹配问题

下一章,我们会基于这一章采集到的数据,开始做信号预处理和特征提取。到时候你就知道,好的数据采集基础有多重要了。