4. 数据流设计:从外设到内存的环形缓冲区实现与指针管理
SPI 双机通信中,数据怎么从外设寄存器安全地搬到内存里?这是个大问题。我早期做项目时,直接让 DMA 往一个固定数组里写数据,结果 CPU 读一半,DMA 又写进来了——数据全乱套。后来我才意识到,环形缓冲区才是解决这个问题的标准答案。
说白了,环形缓冲区就是一个首尾相连的数组。它让 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 来计算,这样即使指针回绕也不会出错。
4.3 数据流全景图
下面这张图展示了从 SPI 外设到环形缓冲区的完整数据路径。我画这张图时特意把 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 配置了循环模式,它会自动回绕到缓冲区开头继续写。这时候写指针的更新逻辑要小心。我建议用 普通模式 + 手动重载,这样每次中断都能精确控制写指针的位置。
__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——丢弃新数据。因为覆盖旧数据会导致数据乱序,调试起来非常痛苦。溢出计数器可以帮你评估缓冲区是否够用。
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 区域,省去维护步骤。