核心痛点:串口(或网络)数据来得太快、太乱,或者不定长。
-
用普通数组
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。
只要 head 和 tail 的读写是原子的(32位 CPU 读写 32位变量通常是原子的),就不会出大乱子。
但是:如果你的 head 和 tail 是 16 位的,而在 8 位单片机上跑,读写就需要两步指令,这时候必须关中断保护,否则会读到“半个指针”。 建议:在 ARM Cortex-M 上,使用 uint32_t 类型的索引是安全的。
7. 常见的大坑
-
分不清空和满: 如果
Head == Tail表示空,那什么时候表示满?-
如果存满了也是
Head == Tail,程序就傻了。 -
解决:牺牲一个字节的空间。当
(Head + 1) % Size == Tail时,就认为满了。虽然浪费了 1 字节,但逻辑最简单。
-
-
DMA 配合的 Cache 一致性: 如果你用 DMA 往 RingBuffer 里搬数据(自动循环模式),而你的 CPU 开启了 D-Cache(如 STM32F7/H7)。
-
现象:DMA 改了内存,但 CPU 读到的还是 Cache 里的旧数据。
-
解决:读取前必须做 Cache Invalidate 操作,或者把这块内存配置为 MPU 无缓存区 (Non-cacheable)。
-
关键点
-
Ring Buffer 是解决“生产快、消费慢”或“不定长数据”的神器。
-
单产单消模型下,在 32 位机上可以实现无锁(Lock-Free)操作。
-
大小设为 2 的幂次,可以用位与运算
&代替取模+ if,效率起飞。
/***************************************************
* 本文为作者《嵌入式开发基础与工程实践》系列文章之一。
* 关注即可订阅后续内容更新,翻阅往期信息,采用异步推送机制,无需主动轮询。
* 转发本文可视为一次网络广播,有助于更多节点接收该信息。
***************************************************/
996

被折叠的 条评论
为什么被折叠?



