4、数据采集框架:DMA+双缓冲机制、环形缓冲区设计、时间戳管理、数据帧封装

各位同学,咱们今天聊点硬核的。

传感器数据采集,说白了就是让MCU把外部物理世界的信号,变成内存里的一串数字。但这里有个坑——如果每次来一个数据你就去读一次,CPU就被绑死了,啥也干不了。我早年做车载项目时,就吃过这个亏。当时用轮询方式读一个IMU,结果CPU占用率飙到70%,CAN报文都差点丢包。

嗯,从那以后,我彻底转向了DMA+缓冲区的方案。今天就把这套框架掰开揉碎讲给你听。

4.1 DMA+双缓冲机制:让CPU喘口气

DMA(直接存储器访问)是什么?简单说,就是数据搬运工。它不占用CPU,自己把外设的数据搬到内存里。你想想看,如果每次ADC转换完,CPU都要去读结果寄存器,那得多累?

双缓冲机制,就是准备两个缓冲区。DMA在往缓冲区A写数据时,CPU可以安心处理缓冲区B里的数据。等A写满了,DMA自动切到B,CPU反过来处理A。这样交替进行,数据流就不会断。

核心要点:双缓冲解决了「数据生产」和「数据消费」的速度匹配问题。生产者和消费者各干各的,互不干扰。

我在项目中遇到过一个问题:如果缓冲区大小设置不当,DMA切换太频繁,反而会增加中断开销。我的建议是——缓冲区大小至少是单次数据量的4倍以上。比如你的ADC采样率是1kHz,每次采样2字节,那每个缓冲区至少8字节,这样DMA切换频率就降下来了。

配置DMA时,有几个关键参数要注意:

  • 传输方向:外设到内存(传感器数据采集基本都是这个方向)
  • 循环模式:开启,这样DMA会自动重新开始
  • 数据宽度:跟外设寄存器宽度一致,别搞错
  • 优先级:如果多个DMA通道,传感器数据采集的优先级设高一点

我的小技巧:在STM32上,我习惯用DMA的半传输完成中断和传输完成中断。半传输完成时,表示缓冲区A满了;传输完成时,表示缓冲区B满了。这样CPU就知道该处理哪个缓冲区了。

4.2 环形缓冲区设计:无锁的数据管道

双缓冲虽然好,但只有两个缓冲区,灵活性不够。如果数据生产速度忽快忽慢,或者CPU处理不过来,数据就可能丢失。

环形缓冲区(Ring Buffer)就是来解决这个问题的。它本质上是一个固定大小的数组,加上两个指针——读指针和写指针。写指针追着读指针跑,读指针追着写指针跑。只要缓冲区没满,数据就不会丢。

我常用的环形缓冲区结构体长这样:

typedef struct {
    uint8_t *buffer;      // 数据缓冲区
    uint32_t size;        // 缓冲区大小
    volatile uint32_t head;  // 写指针(生产者用)
    volatile uint32_t tail;  // 读指针(消费者用)
} ring_buffer_t;

为什么head和tail要加volatile?因为生产者和消费者可能在不同中断上下文中访问它们。不加volatile,编译器优化后可能读到旧值,那就乱套了。

环形缓冲区的核心操作就两个:

  1. 写入数据:检查缓冲区是否已满,没满就写,然后移动head指针
  2. 读取数据:检查缓冲区是否为空,不为空就读,然后移动tail指针

注意:环形缓冲区不是线程安全的!如果生产者和消费者在不同中断优先级中运行,需要加临界区保护。我一般用关中断的方式,因为时间很短,不影响实时性。

我曾经在一个项目中,因为环形缓冲区大小设置太小,导致高频传感器数据频繁覆盖。后来我把缓冲区大小设为采样率的10倍,问题就解决了。你想想看,缓冲区大小 = 最大数据延迟时间 × 数据速率,这个公式很实用。

4.3 时间戳管理:让数据有「时间感」

传感器数据如果没有时间戳,那就是一堆没意义的数字。尤其是在车载场景下,你需要知道每个数据是在什么时刻采集的,这样才能做数据融合、时间对齐。

时间戳管理,说白了就是给每个数据包打上一个时间标签。这个标签通常来自MCU的定时器或RTC。

我推荐的做法是:

  • 使用一个32位或64位的自由运行定时器,精度至少1微秒
  • 在DMA传输完成中断中,读取当前定时器值,作为时间戳
  • 将时间戳和数据一起存入缓冲区

为什么要在DMA中断里读时间戳?因为这时候数据刚刚采集完,时间最准确。如果你等CPU处理完其他事再读,时间已经偏了。

关键点:时间戳的精度取决于定时器的时钟源。如果使用内部RC振荡器,温漂可能很大。我建议使用外部晶振驱动的定时器,精度更高。

时间戳的格式,我习惯用结构体封装:

typedef struct {
    uint32_t seconds;     // 秒
    uint32_t microseconds; // 微秒
} timestamp_t;

这样既能表示绝对时间,又能方便计算时间差。嗯,这里要注意——如果定时器溢出,需要处理回绕问题。我一般用64位计数器,或者定期同步RTC时间。

4.4 数据帧封装:标准化你的数据

数据采集完了,时间戳也打上了,接下来就是封装成数据帧。为什么需要封装?因为上层应用(比如CAN通信、日志记录、上位机解析)需要统一的格式来解析数据。

数据帧封装,说白了就是定义一套协议。我常用的数据帧格式如下:

字段 长度(字节) 说明
帧头 2 固定值,如0xAA55,用于帧同步
数据长度 2 有效数据的字节数
传感器ID 1 标识是哪个传感器
时间戳 8 秒+微秒
有效数据 N 传感器原始数据
校验和 1 简单异或校验,或CRC
帧尾 2 固定值,如0x0D0A

这个格式看起来简单,但很实用。帧头和帧尾用于帧同步,即使数据流中有干扰,也能重新找到帧边界。校验和用于检测数据是否在传输过程中出错。

我的经验:在车载项目中,我一般用CRC16而不是简单的异或校验。因为车载环境电磁干扰大,异或校验的检错能力不够。虽然CRC16多花几个CPU周期,但值得。

封装数据帧的代码也很直接:

void pack_data_frame(uint8_t *frame, uint8_t sensor_id, 
                     timestamp_t *ts, uint8_t *data, uint16_t len) {
    uint16_t index = 0;
    // 帧头
    frame[index++] = 0xAA;
    frame[index++] = 0x55;
    // 数据长度
    frame[index++] = (len >> 8) & 0xFF;
    frame[index++] = len & 0xFF;
    // 传感器ID
    frame[index++] = sensor_id;
    // 时间戳
    memcpy(&frame[index], ts, sizeof(timestamp_t));
    index += sizeof(timestamp_t);
    // 有效数据
    memcpy(&frame[index], data, len);
    index += len;
    // 校验和(这里用异或)
    uint8_t checksum = 0;
    for(uint16_t i = 0; i < index; i++) {
        checksum ^= frame[i];
    }
    frame[index++] = checksum;
    // 帧尾
    frame[index++] = 0x0D;
    frame[index++] = 0x0A;
}

你看,整个数据采集框架就是这样串起来的:DMA负责搬运数据,双缓冲或环形缓冲区负责缓存,时间戳给数据赋予时间属性,数据帧封装让数据变得规范。这套框架我用了好多年,从最初的STM32F1到现在的H7系列,基本没变过。

最后说一句——框架是死的,应用是活的。你完全可以根据自己的需求调整缓冲区大小、时间戳精度、帧格式。但核心思想不变:让数据流顺畅、可靠、可追溯。

下一章,咱们聊聊如何用这套框架实际采集一个加速度传感器,并做简单的滤波处理。