由于嵌入式系统的资源受限性,在大多数项目中都可以找到循环缓冲区数据结构。
循环缓冲区(也称为环形缓冲区)是固定大小的缓冲区,就好像内存在本质上是连续的和循环的一样。随着内存的生成和消耗,数据不需要重新洗牌——而是调整头/尾指针。添加数据时,头指针前进。当数据被消费时,尾指针前进。如果到达缓冲区的末尾,指针将简单地环绕到开头。
循环缓冲区通常用作固定大小的队列。固定大小有利于嵌入式系统,因为开发人员经常尝试使用静态数据存储方法而不是动态分配。
循环缓冲区对于数据生产和消费以不同速率发生的情况也是有用的结构:最新数据始终可用。如果消费者跟不上生产,陈旧的数据将被更新的数据覆盖。通过使用循环缓冲区,我们可以确保我们始终使用最新的数据。
C实现
使用封装
由于我们正在创建一个循环缓冲区库,我们希望确保用户使用我们的库 API 而不是直接修改结构。我们还希望将实现包含在我们的库中,以便我们可以根据需要更改它,而无需最终用户更新他们的代码。用户不需要知道我们结构的任何细节,只需要知道它存在即可。
在我们的库头文件中,我们将声明结构:
// Opaque circular buffer structure
typedef struct circular_buf_t circular_buf_t;
我们不希望用户直接使用circular_buf_t指针,因为他们可能会觉得他们可以解引用circular_buf_t指针。我们将创建一个他们可以使用的句柄类型。
我们句柄的最简单方法是将typedef cbuf_handle_t作为指向循环缓冲区的指针。这将防止我们需要在我们的函数实现中转换指针。
// Handle type, the way users interact with the API
typedef circular_buf_t* cbuf_handle_t;
另一种方法是使句柄成为uintptr_t
or void*
值。在我们的接口内部,我们将处理到适当指针类型的转换。我们对用户隐藏循环缓冲区类型,与数据交互的唯一方法是通过句柄。
我们将坚持使用简单的句柄实现,以保持我们的示例代码简单明了。
API设计
首先,我们应该考虑用户将如何与循环缓冲区交互:
- 他们需要用缓冲区buffer和buffer大小初始化循环缓冲区容器
- 他们需要销毁一个循环缓冲容器
- 他们需要重置循环缓冲容器
- 他们需要能够将数据添加到缓冲区
- 他们需要能够从缓冲区中获取下一个值
- 他们需要知道缓冲区是满的还是空的
- 他们需要知道缓冲区中当前元素的数量
- 他们需要知道缓冲区的最大容量
使用上面的列表API,组合成一个库。用户将使用我们在初始化期间创建的不透明句柄类型与循环缓冲区库进行交互。
我选择了uint8_t作为此实现中的基础数据类型。您可以使用任何您喜欢的特定类型——只要注意适当地处理底层缓冲区和字节数。
/// Pass in a storage buffer and size
/// Returns a circular buffer handle
cbuf_handle_t circular_buf_init(uint8_t* buffer, size_t size);
/// Free a circular buffer structure.
/// Does not free data buffer; owner is responsible for that
void circular_buf_free(cbuf_handle_t me);
/// Reset the circular buffer to empty, head == tail
void circular_buf_reset(cbuf_handle_t me);
/// Put version 1 continues to add data if the buffer is full
/// Old data is overwritten
void circular_buf_put(cbuf_handle_t me, uint8_t data);
/// Put Version 2 rejects new data if the buffer is full
/// Returns 0 on success, -1 if buffer is full
int circular_buf_put2(cbuf_handle_t me, uint8_t data);
/// Retrieve a value from the buffer
/// Returns 0 on success, -1 if the buffer is empty
int circular_buf_get(cbuf_handle_t me, uint8_t * data);
/// Returns true if the buffer is empty
bool circular_buf_empty(cbuf_handle_t me);
/// Returns true if the buffer is full
bool circular_buf_full(cbuf_handle_t me);
/// Returns the maximum capacity of the buffer
size_t circular_buf_capacity(cbuf_handle_t me);
/// Returns the current number of elements in the buffer
size_t circular_buf_size(cbuf_handle_t me);
确定缓冲区是否已满
在我们继续之前,我们应该花点时间讨论一下我们将使用什么方法来确定缓冲区是满的还是空的。
循环缓冲区的“满”和“空”情况看起来都一样:head指针tail相等。有两种方法可以区分满的和空的:
- “浪费”缓冲区中的一个槽:
- 满状态是head + 1 == tail
- 空状态是head == tail
- 使用bool标志和附加逻辑来区分状态::
- 满状态是full
- 空状态是(head == tail) && !full
我们还应该考虑线程安全。通过使用单个空单元格来检测“满”情况,我们可以在没有锁的情况下支持单个生产者和单个消费者(只要put不get修改相同的变量)。队列是线程安全的,因为生产者只会修改head索引,而消费者只会修改tail索引。使用full标志会产生互斥的要求。这是因为full标志由生产者和消费者共享。
下面的实现使用了bool标志。使用标志需要get和put例程中的附加逻辑来更新标志。我还将描述如何对不使用full标志的单个生产者/消费者进行修改。
圆形缓冲容器类型
现在我们已经掌握了我们需要支持的操作,我们可以设计我们的循环缓冲容器。
我们使用容器结构来管理缓冲区的状态。为了保留封装,容器结构是在我们的库.c文件中定义的,而不是在标头中。
我们需要跟踪:
- 底层数据缓冲区
- 缓冲区的最大大小
- 当前的“header”位置(添加元素时增加)
a- 指示缓冲区是否已满的标志
// The hidden definition of our circular buffer structure
struct circular_buf_t {
uint8_t * buffer;
size_t head;
size_t tail;
size_t max; //of the buffer
bool full;
};
现在我们的容器设计好了,我们准备实现库函数了。
实现
需要注意的一个重要细节是我们的每个 API 都需要一个已初始化的缓冲区句柄。我们将利用断言以“按合同设计”风格执行我们的 API 要求,而不是在我们的代码中乱扔条件语句。
如果接口使用不当,程序会立即失败,而不需要用户检查和处理错误代码。
例如:
circular_buf_reset(NULL);
生产:
=== C Circular Buffer Check ===
Assertion failed: (me), function circular_buf_reset, file ../../circular_buffer.c, line 35.
Abort trap: 6
另一个重要的注意事项是下面显示的实现不是线程安全的。底层循环缓冲库没有加锁。
初始化和重置
让我们从头开始:初始化循环缓冲区。我们的 API 让客户提供底层缓冲区和缓冲区大小,我们向他们返回一个循环缓冲区句柄。我们希望我们的用户提供缓冲区的原因是这允许静态分配缓冲区。如果我们的 API 在后台创建缓冲区,我们将需要使用动态内存分配,这在嵌入式系统程序中通常是不允许的。
我们需要在库中提供一个循环缓冲区结构实例,以便我们可以返回一个指针给用户。malloc为了简单起见,我使用了。不能使用动态内存的系统只需要修改函数init以使用不同的方法,例如从预先分配的循环缓冲区结构的静态池中分配。
另一种方法是打破封装,允许用户在库外静态声明循环缓冲区容器结构。在这种情况下,circular_buf_init需要更新为采用结构指针。我们还可以让我们的init函数在堆栈上创建一个容器结构并将其全部返回。但是,由于封装被破坏,用户将能够在不使用库例程的情况下修改结构。如果你想保留封装,你需要使用指针而不是具体的结构实例。
// User provides struct
void circular_buf_init(circular_buf_t* me, uint8_t* buffer,
size_t size);
// Return a concrete struct
circular_buf_t circular_buf_init(uint8_t* buffer, size_t size);
// Return a pointer to a struct instance - preferred approach
cbuf_handle_t circular_buf_init(uint8_t* buffer, size_t size);
我们将返回一个在库内部分配的结构的句柄。创建容器后,我们需要填充值并调用reset
。在我们从init
返回之前,我们确保缓冲容器已创建为空状态。
cbuf_handle_t circular_buf_init(uint8_t* buffer, size_t size)
{
assert(buffer && size);
cbuf_handle_t cbuf = malloc(sizeof(circular_buf_t));
assert(cbuf);
cbuf->buffer = buffer;
cbuf->max = size;
circular_buf_reset(cbuf);
assert(circular_buf_empty(cbuf));
return cbuf;
}
重置函数的目的是将缓冲区置于“空”状态,这需要更新head、tail和full:
void circular_buf_reset(cbuf_handle_t me)
{
assert(me);
me->head = 0;
me->tail = 0;
me->full = false;
}
由于我们有一个创建循环缓冲容器的方法,我们需要一个等效的方法来销毁容器。在这种情况下,我们调用free
作用于cbuf_handle_t容器。我们不会尝试释放底层缓冲区,因为我们不拥有它。
void circular_buf_free(cbuf_handle_t me)
{
assert(me);
free(me);
}
状态检查
接下来,我们将实现与缓冲容器状态相关的函数。
缓冲容器满的功能是最容易实现的,因为我们有一个代表状态的标志:
bool circular_buf_full(cbuf_handle_t me)
{
assert(me);
return me->full;
}
由于我们有full区分满或空状态的标志,我们将标志与head == tail
检查结合起来:
bool circular_buf_empty(cbuf_handle_t me)
{
assert(me);
return (!me->full && (me->head == me->tail));
}
我们缓冲区的容量是在初始化期间提供的,因此我们只需将该值返回给用户:
size_t circular_buf_capacity(cbuf_handle_t me)
{
assert(me);
return me->max;
}
计算缓冲区中的元素数量是一个比我预期的更棘手的问题。许多建议的大小计算都使用模数,但我在测试时遇到了奇怪的极端情况。我选择使用条件语句进行简化计算。
如果缓冲区已满,我们就知道我们的容量已达到最大值。如果head大于或等于tail,我们只需将这两个值相减即可得到我们的大小。如果tail大于head,我们需要用抵消差值max以获得正确的大小。
size_t circular_buf_size(cbuf_handle_t me)
{
assert(me);
size_t size = me->max;
if(!me->full)
{
if(me->head >= me->tail)
{
size = (me->head - me->tail);
}
else
{
size = (me->max + me->head - me->tail);
}
}
return size;
}
添加和删除数据
在循环缓冲区中添加和删除数据需要操作head和tail指针。当向缓冲区添加数据时,我们在当前head位置插入新值,然后我们递增head。当我们从缓冲区中移除数据时,我们检索当前指针的值tail,然后递增tail。
然而,将数据添加到缓冲区需要更多的考虑。如果缓冲区是full,我们需要递增我们的tail指针以及head。我们还需要检查插入值是否会触发full条件。
我们将实现该put函数的两个版本,因此让我们将指针递增逻辑提取到辅助函数中。如果我们的缓冲区已经满了,我们就前进tail。我们总是递增head。指针递增后,我们通过检查head == tail
是否填充标志full。
请注意下面模运算符 ( %) 的使用。当达到最大大小时,模数将导致head和tail值重置为 0。这确保head和tail始终是基础数据缓冲区的有效索引。
static void advance_pointer(cbuf_handle_t me)
{
assert(me);
if(me->full)
{
me->tail = (me->tail + 1) % me->max;
}
me->head = (me->head + 1) % me->max;
me->full = (me->head == me->tail);
}
我们可以使用条件逻辑来减少指令总数:
if (++(me->head) == me->max)
{
me->head = 0;
}
现在,advance_pointer将如下所示:
static void advance_pointer(cbuf_handle_t me)
{
assert(me);
if(me->full)
{
if (++(me->tail) == me->max)
{
me->tail = 0;
}
}
if (++(me->head) == me->max)
{
me->head = 0;
}
me->full = (me->head == me->tail);
}
我们可以创建一个类似的辅助函数,在从缓冲区中删除值时调用该辅助函数。当我们删除一个值时,full标志被设置为false,并且尾指针被递增。
static void retreat_pointer(cbuf_handle_t me)
{
assert(me);
me->full = false;
if (++(me->tail) == me->max)
{
me->tail = 0;
}
}
我们将创建该put函数的两个版本。第一个版本将一个值插入缓冲区并递增指针。如果缓冲区已满,最旧的值将被覆盖。这是循环缓冲区的标准用例
void circular_buf_put(cbuf_handle_t me, uint8_t data)
{
assert(me && me->buffer);
me->buffer[me->head] = data;
advance_pointer(me);
}
如果缓冲区已满,该put
函数的第二个版本将返回一个错误。这是出于演示目的而提供的,但我们不会在我们的系统中使用此变体。
int circular_buf_put2(cbuf_handle_t me, uint8_t data)
{
int r = -1;
assert(me && me->buffer);
if(!circular_buf_full(me))
{
me->buffer[me->head] = data;
advance_pointer(me);
r = 0;
}
return r;
}
要从缓冲区中删除数据,我们访问tail
处的值,然后更新tail指针。如果缓冲区为空,我们不返回值或修改指针。相反,我们向用户返回一个错误。
int circular_buf_get(cbuf_handle_t me, uint8_t * data)
{
assert(me && data && me->buffer);
int r = -1;
if(!circular_buf_empty(me))
{
*data = me->buffer[me->tail];
retreat_pointer(me);
r = 0;
}
return r;
}
这就完成了我们的循环缓冲区库的实现。
用法
使用该库时,客户端负责创建底层数据缓冲区到circular_buf_init,并返回一个cbuf_handle_t:
uint8_t * buffer = malloc(EXAMPLE_BUFFER_SIZE * sizeof(uint8_t));
cbuf_handle_t me = circular_buf_init(buffer,
EXAMPLE_BUFFER_SIZE);
该句柄用于与所有剩余的库函数交互:
bool full = circular_buf_full(me);
bool empty = circular_buf_empty(me);
printf("Current buffer size: %zu\n", circular_buf_size(me);
完成后不要忘记释放底层数据缓冲区和容器:
free(buffer);
circular_buf_free(me);
删除full标志的修改
如果你想放弃full标志,你会检查head是tail后面的一个位置以确定缓冲区是否已满:
bool circular_buf_full(circular_buf_t* me)
{
// We determine "full" case by head being one position behind the tail
// Note that this means we are wasting one space in the buffer
return ((me->head + 1) % me->size) == me->tail;
}
现在,如果我们想避免模运算,我们可以使用条件逻辑来代替:
bool circular_buf_full(circular_buf_t* me)
{
// We need to handle the wraparound case
size_t head = me->head + 1;
if(head == me->max)
{
head = 0;
}
return head == me->tail;
}
空的情况就是下面这样,并且head和tail是相同的:
bool circular_buf_empty(circular_buf_t* me)
{
// We define empty as head == tail
return (me->head == me->tail);
}
从缓冲区获取数据时,我们将递增tai指针,必要时回绕:
int circular_buf_get(circular_buf_t * me, uint8_t * data)
{
int r = -1;
if(me && data && !circular_buf_empty(me))
{
*data = me->buffer[me->tail];
me->tail = (me->tail + 1) % me->size;
r = 0;
}
return r;
}
将数据添加到缓冲区时,我们将存储数据并递增头指针,必要时回绕:
int circular_buf_put(circular_buf_t * me, uint8_t data)
{
int r = -1;
if(me && !circular_buf_full(me))
{
me->buffer[me->head] = data;
me->head = (me->head + 1) % me->size;
r = 0;
}
return r;
}