4. 数据流设计:从外设到内存的环形缓冲区实现与指针管理

SPI 双机通信中,数据怎么从外设寄存器安全地搬到内存里?这是个大问题。我早期做项目时,直接让 DMA 往一个固定数组里写数据,结果 CPU 读一半,DMA 又写进来了——数据全乱套。后来我才意识到,环形缓冲区才是解决这个问题的标准答案。

说白了,环形缓冲区就是一个首尾相连的数组。它让 DMA 可以无休止地往里写,CPU 可以随时从里面读,两者互不干扰。今天我们就来拆解这个设计。

核心思路: 用两个指针(写指针、读指针)管理一个固定大小的内存块。写指针由 DMA 更新,读指针由 CPU 更新。当指针到达数组末尾时,自动回绕到开头。

4.1 环形缓冲区的数据结构

先看一个我常用的环形缓冲区结构体定义。嗯,这里要注意,我把控制块和数据缓冲区分开存放,这样方便做内存对齐优化。

/* 环形缓冲区控制块 */
typedef struct {
    uint8_t *buffer;        /* 数据缓冲区指针 */
    uint32_t size;          /* 缓冲区大小(必须是2的幂) */
    volatile uint32_t write_index;  /* DMA写指针 */
    volatile uint32_t read_index;   /* CPU读指针 */
    uint32_t mask;          /* 取模掩码 = size - 1 */
} ringbuf_t;

/* 初始化函数 */
void ringbuf_init(ringbuf_t *rb, uint8_t *buf, uint32_t size) {
    rb->buffer = buf;
    rb->size = size;
    rb->mask = size - 1;
    rb->write_index = 0;
    rb->read_index = 0;
}

为什么 size 必须是 2 的幂?因为可以用位运算代替取模,速度更快。我测试过,在 Cortex-M4 上,index & mask 比 index % size 快了将近 10 个时钟周期。DMA 中断里每一微秒都很宝贵。

4.2 双指针的工作机制

环形缓冲区里有两个指针,它们各自扮演不同角色:

指针 谁更新 更新时机 作用
写指针 DMA 传输完成中断 每收到一帧数据后 标记新数据写入的位置
读指针 CPU 应用程序 处理完一帧数据后 标记已读取的位置

这两个指针的差值,就是缓冲区中未处理的数据量。我习惯用 (write_index - read_index) & mask 来计算,这样即使指针回绕也不会出错。

我的经验: 写指针一定要加 volatile 修饰。有一次我忘了加,编译器把 write_index 优化到了寄存器里,DMA 中断改了内存值,CPU 却一直读旧值,数据丢了半个多小时才查出来。

4.3 数据流全景图

下面这张图展示了从 SPI 外设到环形缓冲区的完整数据路径。我画这张图时特意把 DMA 和 CPU 的访问区域分开了,这样更容易理解谁负责哪一段。

SPI 数据流:外设 → 环形缓冲区 SPI 外设 DR 寄存器 DMA 控制器 自动搬运 环形缓冲区 写指针 读指针 DMA 请求 DMA 写入 CPU 应用程序 读取并处理 CPU 读取数据 关键原则 DMA 只操作写指针 CPU 只操作读指针 两者永不冲突

4.4 写指针管理:DMA 中断处理

DMA 每传输完一帧数据,就会触发中断。我在中断里只做一件事:更新写指针。千万别在中断里做数据解析,那会阻塞后续的 DMA 传输。

/* DMA 传输完成中断服务函数 */
void DMA_SPI_RX_IRQHandler(void) {
    uint32_t frame_len = 64;  /* 每帧固定64字节 */
    
    /* 更新写指针,自动回绕 */
    rb->write_index = (rb->write_index + frame_len) & rb->mask;
    
    /* 清除中断标志 */
    DMA_ClearITPendingBit(DMA1_Stream0, DMA_IT_TCIF0);
    
    /* 通知CPU有数据到达(比如释放信号量) */
    osSemaphoreRelease(rx_semaphore);
}

这里有个坑:如果 DMA 配置了循环模式,它会自动回绕到缓冲区开头继续写。这时候写指针的更新逻辑要小心。我建议用 普通模式 + 手动重载,这样每次中断都能精确控制写指针的位置。

我曾经踩过的坑: 有一次 DMA 传输速度太快,中断还没处理完,下一个中断又来了。写指针被连续更新两次,中间丢了一帧数据。解决方案是在中断里用 __disable_irq() 保护指针更新,或者改用无锁的原子操作。

4.5 读指针管理:CPU 数据消费

CPU 这边,应用程序从环形缓冲区读取数据时,需要检查是否有新数据到达。我一般用轮询或信号量两种方式:

  • 轮询方式: 在主循环里不断检查 write_index != read_index,适合低延迟场景。
  • 信号量方式: DMA 中断释放信号量,CPU 任务阻塞等待,适合多任务系统。
/* CPU 读取一帧数据 */
uint32_t ringbuf_read(ringbuf_t *rb, uint8_t *out, uint32_t len) {
    uint32_t available = (rb->write_index - rb->read_index) & rb->mask;
    
    if (available < len) {
        return 0;  /* 数据不足 */
    }
    
    /* 从读指针位置拷贝数据 */
    uint32_t offset = rb->read_index;
    for (uint32_t i = 0; i < len; i++) {
        out[i] = rb->buffer[(offset + i) & rb->mask];
    }
    
    /* 更新读指针 */
    rb->read_index = (rb->read_index + len) & rb->mask;
    
    return len;
}

你想想看,这里为什么不用 memcpy?因为缓冲区是环形的,数据可能跨越末尾回绕到开头。用 memcpy 需要分两段拷贝,反而更麻烦。我习惯用循环逐字节拷贝,虽然慢一点,但逻辑清晰,不容易出错。

4.6 缓冲区大小的选择策略

缓冲区设多大?设小了容易溢出,设大了浪费内存。我总结了一个经验公式:

场景 推荐大小 依据
低速传感器(< 1 Mbps) 256 字节 CPU 处理速度远快于接收速度
中速数据流(1~10 Mbps) 1024 字节 需容纳 2~3 帧数据
高速突发传输(> 10 Mbps) 4096 字节或更大 防止 DMA 写穿 CPU 读

我个人习惯再加一个安全余量:缓冲区大小 = 最大帧长 × 4。这样即使 CPU 被高优先级中断阻塞一段时间,缓冲区也不会溢出。

4.7 无锁设计的注意事项

环形缓冲区最大的优势就是可以做到无锁访问。DMA 只写 write_index,CPU 只读 write_index、只写 read_index。两者没有共享的读写变量,所以不需要互斥锁。

但有一个边界情况要注意:当缓冲区满时,DMA 继续写入会覆盖未读数据。我通常这样处理:

/* DMA 中断中检查是否溢出 */
void DMA_SPI_RX_IRQHandler(void) {
    uint32_t next_write = (rb->write_index + frame_len) & rb->mask;
    
    /* 如果写指针追上读指针,说明缓冲区满了 */
    if (next_write == rb->read_index) {
        overflow_counter++;  /* 溢出计数,用于调试 */
        /* 策略1:丢弃新数据(推荐) */
        return;
        /* 策略2:覆盖旧数据(慎用) */
        // rb->read_index = (rb->read_index + frame_len) & rb->mask;
    }
    
    rb->write_index = next_write;
}

我建议用策略1——丢弃新数据。因为覆盖旧数据会导致数据乱序,调试起来非常痛苦。溢出计数器可以帮你评估缓冲区是否够用。

调试小技巧: 在调试阶段,把 write_index 和 read_index 通过串口打印出来,观察它们的差值变化。如果差值经常接近缓冲区大小,说明缓冲区设小了,需要加大。

4.8 性能调优:缓存一致性问题

如果你用的是带 Cache 的 MCU(比如 STM32H7 系列),DMA 直接写内存,CPU 读的可能是 Cache 里的旧数据。这时候需要做缓存维护:

/* DMA 中断中,在更新写指针之前 */
SCB_InvalidateDCache_by_Addr(
    (uint32_t *)&rb->buffer[rb->write_index],
    frame_len
);

/* CPU 读取之前,同样要失效 Cache */
SCB_InvalidateDCache_by_Addr(
    (uint32_t *)&rb->buffer[rb->read_index],
    len
);

嗯,这里要注意,失效 Cache 的开销不小。我测试过,每次失效大约消耗 2~5 微秒。如果帧率很高,可以考虑把环形缓冲区放在 非 Cache 的 SRAM 区域,省去维护步骤。