由于嵌入式系统的资源有限性,循环缓冲区数据结构体(Circular Buffer Data Structures)被大量的使用。
循环缓冲区(也称为环形缓冲区)是固定大小的缓冲区,工作原理就像内存是连续的且可循环的一样。在生成和使用内存时,不需将原来的数据全部重新清理掉,只要调整head/tail 指针即可。当添加数据时,head 指针前进。当使用数据时,tail 指针向前移动。当到达缓冲区的尾部时,指针又回到缓冲区的起始位置。
目录:
- 为什么使用循环缓冲区
- C 实例
- 使用封装
- API设计
- 确认缓冲区是否已满
- 循环缓冲区容器类型
- 实例
- 使用
为什么使用循环缓冲区?
循环缓冲区通常用作固定大小的队列。固定大小的队列对于嵌入式系统的开发非常友好,因为开发人员通常会尝试使用静态数据存储的方法而不是动态分配。
循环缓冲区对于数据写入和读出以不同速率发生的情况也是非常有用的结构:最新数据始终可用。如果读取数据的速度跟不上写入数据的速度,旧的数据将被新写入的数据覆盖。通过使用循环缓冲区,能够保证我们始终使用最新的数据。
有关其他的用例,请查看Embedded.com上的Ring Buffer Basics。
C实例
我们将使用C语言来开始实现,我们将会碰到一些设计上的挑战。
使用封装
我们将创建一个Circular Buffer库,来避免直接操作结构体。
在我们的库文件头部,前置声明结构体:
// Opaque circular buffer structure typedef struct CIRCULAR_BUFFER_T circular_buf_t;
我们不希望用户直接操作 circular_buf_t 结构体,因为他们可能会觉得可以取消对值的引用。取而代之我们创建一个句柄类型来给用户使用。
最简单的方法是将cbuf_handle_t定义为一个指向circular buffer的指针。这会避免我们在函数中进行强制转换指针。
// Handle type, the way users interact with the API typedef circular_buf_t* cbuf_handle_t;
另一种方法是使句柄为uintptr_t或void *值。在程序内,我们将句柄转换为适当的指针类型。保证circular buffer类型对用户隐藏,与数据交互的唯一方法是通过句柄。
我们坚持简单的句柄实现,来使代码简单明了。
API Design
首先,我们应该思考用户如何与循环缓冲区交互:
- 用户需要使用一个 buffer 和 size 来初始化循环缓冲区容器
- 用户需要销毁循环缓冲区容器
- 用户需要 reset 循环缓冲区容器
- 用户需要能够从缓冲区取出下一个值
- 用户需要知道缓冲区是满还是空
- 用户需要知道当前缓冲区元素的数量
- 用户需要知道缓冲区的最大容量
使用这个列表,我们能够合并一个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 cbuf); /// Reset the circular buffer to empty, head == tail void circular_buf_reset(cbuf_handle_t cbuf); /// Put version 1 continues to add data if the buffer is full /// Old data is overwritten void circular_buf_put(cbuf_handle_t cbuf, 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 cbuf, 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 cbuf, uint8_t * data); /// Returns true if the buffer is empty bool circular_buf_empty(cbuf_handle_t cbuf); /// Returns true if the buffer is full bool circular_buf_full(cbuf_handle_t cbuf); /// Returns the maximum capacity of the buffer size_t circular_buf_capacity(cbuf_handle_t cbuf); /// Returns the current number of elements in the buffer size_t circular_buf_size(cbuf_handle_t cbuf);
确认缓冲区是否已满
在继续之前,我们应该花费一点时间去讨论一个方法去确认缓冲的空满。
循环缓冲区的 “full” 和 “empty” 看起来是相同的:head 和 tail 指针是相等的。有两种方法区分 full 和 empty:
浪费缓冲区中的一个数据槽:
- Full:tail + 1 == head
- Empty:head == tail
使用一个bool标志位和其他逻辑来区分:
- Full:full
- Empty:(head == tail) && (!full)
与其浪费一个数据槽,下面方法使用了bool标志位。使用标志位的方法要求在 get 和 put 函数中使用其他逻辑来更新标志。
缓冲区容器类型
现在我们已经确定了需要支持的操作,可以开始设计循环缓冲区容器了。
我们使用容器结构体来管理缓冲区状态。为了保留封装,容器结构体定义在library.c文件中,而不是头文件中。
我们需要跟踪以下信息:
- 基础数据缓冲区
- 缓冲区的最大范围
- “head”指针的当前位置(添加元素时增加)
- “tail”指针的当前位置(读取元素后增加)
- 一个标志位来指示缓冲区是否已满
// 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都需要一个初始化缓冲区的句柄。我们不使用条件语句来填充我们的代码,而是使用断言以“Design by Contract”样式来强制执行我们的API要求。
这样如果程序处理不当,将直接终止程序。
初始化和复位
init 函数:初始化循环缓冲区。我们的API是用户提供底层 buffer 和 buffer size,API返回一个 circular buffer 句柄。
我们需要在库端创建一个循环缓冲区容器。为了简单起见,我使用了 malloc 函数。不能使用动态内存的系统只需修改 init 函数来使用其他方法实现创建目的。例如从循环缓冲区的静态池中分配。
另一种方法是破坏封装,允许用户静态声明循环缓冲区容器结构。在这种情况下,circular_buf_init 需要更新来采用结构指针,或者初始化能够在堆栈上创建一个容器结构体并返回它。但是,由于封装被破坏,用户将无需使用例程就能修改结构体。
所以我们使用第一种方法。
// User provides struct void circular_buf_init(circular_buf_t* cbuf, uint8_t* buffer, size_t size); // Return a struct circular_buf_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; }
reset 函数:目的是将缓冲区置为 “空” 状态,需要更新 head,tail 和 full 。
void circular_buf_reset(cbuf_handle_t cbuf) { assert(cbuf); cbuf->head = 0; cbuf->tail = 0; cbuf->full = false; }
当我们有了一个创建循环缓冲区容器的方法,同样的我们也需要一个能够销毁容器的等效方法。我们可以调用 free 函数来释放容器。但不要尝试释放底层缓冲区,释放容器指针就好,因为根据我们的初始化方法,我们不需要也不能理会底层缓冲区。
void circular_buf_free(cbuf_handle_t cbuf) { assert(cbuf); free(cbuf); }
状态检查
接下来,我们将实现与缓冲区容器状态相关的函数部分。
full 函数:很容易实现,因为我们已经有一个标志位来表示满状态了:
bool circular_buf_full(cbuf_handle_t cbuf) { assert(cbuf); return cbuf->full; }
empty 函数:因为我们已经有 full 标志位来区分空满状态了,我们只需将 full 标志位和“head == tail”的检查结果合并处理。
bool circular_buf_empty(cbuf_handle_t cbuf) { assert(cbuf); return (!cbuf->full && (cbuf->head == cbuf->tail)); }
capacity 函数:由于在初始化阶段就已经设定了缓冲区的容量大小,所以只需要返回这个值即可:
size_t circular_buf_capacity(cbuf_handle_t cbuf) { assert(cbuf); return cbuf->max; }
预期计算缓冲区元素的数量是一个棘手的问题,许多人建议使用除法来计算,但在测试的时候遇到了许多奇怪的情况。所以我选择了条件语句进行简化运算。
关于缓冲区的元素数量有以下三种情况:
① 缓冲区状态是 full ,我们就知道当前的容量已经达到了最大;
② head >= tail,只需将两个值相减就可以得出大小;
③ tail > head,我们需要用最大值来抵消差值,才能得到正确的大小;
size_t circular_buf_size(cbuf_handle_t cbuf) { assert(cbuf); size_t size = cbuf->max; if(!cbuf->full) { if(cbuf->head >= cbuf->tail) { size = (cbuf->head - cbuf->tail); } else { size = (cbuf->max + cbuf->head - cbuf->tail); } } return size; }
添加和删除数据
有了这些功能之后,是时候开始深入研究了:从队列中添加和删除数据。
从循环缓冲区添加和删除数据需要操纵 head 和 tail 指针。当向缓冲区添加数据时,我们将新的数据插入当前 head 指针所在的位置,然后将 head 指针向前移一位。当从缓冲区删除数据时,我们从当前 tail 指针的位置取出数据,然后将 tail 指针向前移一位。
但是,向缓冲区添加数据时需要更多考虑。如果缓冲区是满状态,我们需要同时移动 tail 和 head 指针。我们还需要检查插入数据是否会出发 full 条件。
我们将实现两个版本的 put 函数,因此,让我们将指针前进函数提起到一个辅助函数中。如果缓冲区已满,移动 tail 指针。我们每次都向前移动一位 head 指针。当指针移动之后,我们通过检查 “head == tail” 的结果来判断是否填充 full 标志位。
注意下面使用的除法(%)运算符。当填充的数据达到最大值时,除法运算将导致 head 和 tail 指针重置为0。这样确保head 和 tail 指针始终是底层数据缓冲区的有效索引。
static void advance_pointer(cbuf_handle_t cbuf) { assert(cbuf); if(cbuf->full) { cbuf->tail = (cbuf->tail + 1) % cbuf->max; } cbuf->head = (cbuf->head + 1) % cbuf->max; cbuf->full = (cbuf->head == cbuf->tail); }
我们可以写一个类似的辅助函数当从缓冲区删除数据时调用。当删除数据时,full 标志置为 flase ,tail 指针向前移一位。
static void retreat_pointer(cbuf_handle_t cbuf) { assert(cbuf); cbuf->full = false; cbuf->tail = (cbuf->tail + 1) % cbuf->max; }
我们将创建两个版本的 put 函数。第一个版本向缓冲区插入数据并向前移动指针。如果缓冲区已满,旧数据将会被覆盖。这是循环缓冲区的标准使用案例。
void circular_buf_put(cbuf_handle_t cbuf, uint8_t data) { assert(cbuf && cbuf->buffer); cbuf->buffer[cbuf->head] = data; advance_pointer(cbuf); }
第二个版本如果缓冲区已满 put 函数将返回 error。这里只提供一个示范样例,在我们的系统中并没有使用这个变体。
int circular_buf_put2(cbuf_handle_t cbuf, uint8_t data) { int r = -1; assert(cbuf && cbuf->buffer); if(!circular_buf_full(cbuf)) { cbuf->buffer[cbuf->head] = data; advance_pointer(cbuf); r = 0; } return r; }
从缓冲区删除数据,我们取出 tail 指针位置的值并更新 tail 指针。如果缓冲区是空的,我们不返回数据或者修改指针值。相反,我们返回 error 给用户。
int circular_buf_get(cbuf_handle_t cbuf, uint8_t * data) { assert(cbuf && data && cbuf->buffer); int r = -1; if(!circular_buf_empty(cbuf)) { *data = cbuf->buffer[cbuf->tail]; retreat_pointer(cbuf); r = 0; } return r; }
这样就完成了循环缓冲区库的实现。
使用
在使用这个库时,用户负责创建 circular_buf_init 的底层数据缓冲区,将会返回 cbuf_handle_t :
uint8_t * buffer = malloc(EXAMPLE_BUFFER_SIZE * sizeof(uint8_t)); cbuf_handle_t cbuf = circular_buf_init(buffer, EXAMPLE_BUFFER_SIZE);
该处理用于和其他剩余的所有库函数交互:
bool full = circular_buf_full(cbuf); bool empty = circular_buf_empty(cbuf); printf("Current buffer size: %zu ", circular_buf_size(cbuf);
当处理完之后不要忘记 free 底层数据缓冲区和容器:
free(buffer); circular_buf_free(cbuf);