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,编译器优化后可能读到旧值,那就乱套了。
环形缓冲区的核心操作就两个:
- 写入数据:检查缓冲区是否已满,没满就写,然后移动head指针
- 读取数据:检查缓冲区是否为空,不为空就读,然后移动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系列,基本没变过。
最后说一句——框架是死的,应用是活的。你完全可以根据自己的需求调整缓冲区大小、时间戳精度、帧格式。但核心思想不变:让数据流顺畅、可靠、可追溯。
下一章,咱们聊聊如何用这套框架实际采集一个加速度传感器,并做简单的滤波处理。