【第26期】环形缓冲区 (Ring Buffer):数据流大动脉

核心痛点:串口(或网络)数据来得太快、太乱,或者不定长。

  • 用普通数组 Buffer[100]?写满了怎么办?要把数据整体搬回开头吗?那 CPU 就累死了。

  • 用链表?malloc 太多会碎片化,且速度慢。

解决方案:把数组的首尾连起来,做成一个环。

1. 物理模型:衔尾蛇

想象一条蛇咬住了自己的尾巴。 我们只需要两个指针(索引):

  • Head (头/写指针):猛兽的嘴。数据来了就往这里塞,塞完头往前伸。

  • Tail (尾/读指针):猛兽的排泄口(或者叫处理端)。业务逻辑从这里把数据取走,取完尾巴也往前挪。

核心规则

  • Head 追 Tail:说明数据太快,缓冲区要满了(Overflow)。

  • Tail 追 Head:说明处理得很快,缓冲区空了。

2. C 语言实现:结构体定义

一个标准的环形缓冲区对象通常包含以下成员:

typedef struct {
    uint8_t *buffer;    // 实际的内存块
    uint32_t size;      // 缓冲区总大小 (最好是 2 的幂次)
    volatile uint32_t head; // 写索引 (ISR 会改它,必须 volatile)
    volatile uint32_t tail; // 读索引 (Task 会改它,必须 volatile)
} RingBuffer_t;

3. 写入逻辑 (Push) —— 中断里跑的代码

这是 ISR 里最常用的代码。要求极快。

// 返回 1 表示成功,0 表示满了
int RingBuffer_Push(RingBuffer_t *rb, uint8_t data) {
    uint32_t next_head = rb->head + 1;
    
    // 1. 处理回绕 (Wrap Around)
    if (next_head >= rb->size) {
        next_head = 0;
    }

    // 2. 判断满 (Full)
    // 经典策略:保留一个字节不存,用于区分“满”和“空”
    // 如果 Head 的下一步撞上了 Tail,就是满了
    if (next_head == rb->tail) {
        return 0; // 满了,丢弃数据 (或者覆盖旧数据,看策略)
    }

    // 3. 存入数据
    rb->buffer[rb->head] = data;
    
    // 4. 更新指针
    rb->head = next_head;
    return 1;
}

4. 读取逻辑 (Pop) —— 任务里跑的代码

这是 Task 里的代码。

// 返回 1 表示读到了,0 表示空了
int RingBuffer_Pop(RingBuffer_t *rb, uint8_t *data) {
    // 1. 判断空 (Empty)
    // 如果头尾重合,就是空的
    if (rb->head == rb->tail) {
        return 0; 
    }

    // 2. 取数据
    *data = rb->buffer[rb->tail];

    // 3. 更新指针 & 处理回绕
    uint32_t next_tail = rb->tail + 1;
    if (next_tail >= rb->size) {
        next_tail = 0;
    }
    rb->tail = next_tail;
    
    return 1;
}

5. 进阶技巧:取模运算的优化

在上面的代码中,我们用了 if (next >= size) next = 0; 来处理回绕。这涉及到了分支判断。 如果你对速度有极致追求(比如音频处理),或者你不想用 if,有一个位运算的黑魔法。

前提:缓冲区大小 size 必须是 2 的幂次方 (16, 32, 64, 128, 256...)。

优化写法

// 假设 size = 256 (0x100), mask = 255 (0xFF)
// 任何数 & 0xFF,结果一定在 0~255 之间,自动回绕!

rb->head = (rb->head + 1) & (rb->size - 1);

这条指令通常比 if 跳转要快,且流水线更顺畅。

6. 线程安全问题 (Lock-Free?)

这是一个经典的面试题:Ring Buffer 需要加锁吗?

答案是:如果是“单生产者 - 单消费者”模型,通常不需要加锁。

  • ISR (生产者) 只修改 head,只读取 tail

  • Task (消费者) 只修改 tail,只读取 head

只要 headtail 的读写是原子的(32位 CPU 读写 32位变量通常是原子的),就不会出大乱子。

但是:如果你的 headtail 是 16 位的,而在 8 位单片机上跑,读写就需要两步指令,这时候必须关中断保护,否则会读到“半个指针”。 建议:在 ARM Cortex-M 上,使用 uint32_t 类型的索引是安全的。

7. 常见的大坑

  1. 分不清空和满: 如果 Head == Tail 表示空,那什么时候表示满?

    • 如果存满了也是 Head == Tail,程序就傻了。

    • 解决牺牲一个字节的空间。当 (Head + 1) % Size == Tail 时,就认为满了。虽然浪费了 1 字节,但逻辑最简单。

  2. DMA 配合的 Cache 一致性: 如果你用 DMA 往 RingBuffer 里搬数据(自动循环模式),而你的 CPU 开启了 D-Cache(如 STM32F7/H7)。

    • 现象:DMA 改了内存,但 CPU 读到的还是 Cache 里的旧数据。

    • 解决:读取前必须做 Cache Invalidate 操作,或者把这块内存配置为 MPU 无缓存区 (Non-cacheable)。


关键点

  1. Ring Buffer 是解决“生产快、消费慢”或“不定长数据”的神器。

  2. 单产单消模型下,在 32 位机上可以实现无锁(Lock-Free)操作。

  3. 大小设为 2 的幂次,可以用位与运算 & 代替取模 + if,效率起飞。

/***************************************************
 * 本文为作者《嵌入式开发基础与工程实践》系列文章之一。
 * 关注即可订阅后续内容更新,翻阅往期信息,采用异步推送机制,无需主动轮询。
 * 转发本文可视为一次网络广播,有助于更多节点接收该信息。
 ***************************************************/

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值