注:未经作者允许,转载请注明出处。
一、环形数组的引入
在C语言单片机编程过程中,经常使用串口接收数据,采用的方式一般有两种,第一种是轮循模式接收,这种方式往往需要消耗大量的时间检测接收的数据缓存(数据),有数据的时候就执行,没有的话就退出。此外,由于串口接收数据的时刻是无法预测的,所以即使检测到数据,有时候也会由于数据丢失造成错误。还有一种是中断,中断接收响应快,但是对于不定长数据就很难办了。那么对于以上情况,本次介绍一下环形数组的存储方式,使用串口中断接收+环形数组,能够很好的接收不定长数据,达到事半功倍的效果。
二、什么是环形数组
顾名思义,环形数组就是一个类似于圆环的数组。一般我们经常采用数组进行数据存储,试想一下,如果将数组的首尾相连组成一个环,将读数据的起始地址和写数据的起始地址都映射到该环的某一个的位置,并指明一个正方向,采用先进先出机制,每次数据存储地址都随数据存入而增长,直到数组的尾部,然后再回到数组的首部,每次读数据也类似的变化,那么,每次读地址到写地址之间的数据地址,就是最新的缓存数据地址。
例如上图,将大小为MAXN的数组首尾相连接后,以顺时针为正方向,定义tail为尾,以head为首,head是当前写入数据的首地址,tail就是当前写入数据的尾地址,同样也是读数据的首地址,数据从tail以顺时针向head方向读取,head就是读数据的末地址。那么从数组下标 7–>(MAXN-1)–> 1 的数据就是最新数据。当进行数据读取时,只需读取要以顺时针从tail所在的下标开始进行读数据,直到head下标所在的数据,就是目前最新的数据。
那么既然是一个环,那肯定会有head的地址与tail的地址相重合的时候,那么这时候怎么办?这时就存在两种情况:1、缓存区数据为空;2、缓存区数据满,首尾相连了,不能进行数据存储了。这时候如何判断这两种情况?那么就引入标志位read_flag和write_flag,采用这两个标志位记录从下标(MAXN-1)到下标0的跨界标志,每当从数组的(MAXN-1)跨界到下标0,就将read_flag或者writa_flag变换一次。那么如果存在条件:1、head和tail地址相等,且read_flag == write_flag,则数组为空;2、head和tail地址相等,且read_flag != write_flag,则数组为full。为空时,不需要进行读操作,为full时,如果不想覆盖掉之前的数据,就需要禁止对缓存区写操作,如果不介意丢数据,则可以进行写(当然一般不会这样,数据丢了还有啥意义)。
三、C语言实现环形数组
注:以下函数均来自RT-thread官网>>RT-Thread Nano>>移植控制台/FinSH>>示例代码。
3.1、先建一个环,把数据存进去
struct rt_ringbuffer
{
// rt_uint8_t是unsigned char 类型
//rt_uint16_t 是unsigned int 类型
rt_uint8_t *buffer_ptr; //指向数组的指针
rt_uint16_t read_mirror : 1; //读 跨界标志
rt_uint16_t read_index : 15; //读地址下标
rt_uint16_t write_mirror : 1; //写 跨界标志
rt_uint16_t write_index : 15; //写地址下标
rt_int16_t buffer_size; //数组大小
};
#define UART_RX_BUF_LEN 16
rt_uint8_t uart_rx_buf[UART_RX_BUF_LEN] = {0};
struct rt_ringbuffer uart_rxcb; /* 定义一个 ringbuffer cb */
static UART_HandleTypeDef UartHandle;
void rt_ringbuffer_init(struct rt_ringbuffer *rb,
rt_uint8_t *pool,
rt_int16_t size)
{
RT_ASSERT(rb != RT_NULL);
RT_ASSERT(size > 0);
/* initialize read and write index */
rb->read_mirror = rb->read_index = 0;
rb->write_mirror = rb->write_index = 0;
/* set buffer pool and size */
rb->buffer_ptr = pool;
rb->buffer_size = RT_ALIGN_DOWN(size, RT_ALIGN_SIZE);
}
/* 初始化串口,中断方式 */
static int uart_init(void)
{
/* 初始化串口接收 ringbuffer */
rt_ringbuffer_init(&uart_rxcb, uart_rx_buf, UART_RX_BUF_LEN);
/* 初始化串口接收数据的信号量 */
rt_sem_init(&(shell_rx_sem), "shell_rx", 0, 0);
/* 初始化串口参数,如波特率、停止位等等 */
UartHandle.Instance = USART2;
UartHandle.Init.BaudRate = 115200;
UartHandle.Init.HwFlowCtl = UART_HWCONTROL_NONE;
UartHandle.Init.Mode = UART_MODE_TX_RX;
UartHandle.Init.OverSampling = UART_OVERSAMPLING_16;
UartHandle.Init.WordLength = UART_WORDLENGTH_8B;
UartHandle.Init.StopBits = UART_STOPBITS_1;
UartHandle.Init.Parity = UART_PARITY_NONE;
/* 初始化串口引脚等 */
if (HAL_UART_Init(&UartHandle) != HAL_OK)
{
while (1);
}
/* 中断配置 */
__HAL_UART_ENABLE_IT(&UartHandle, UART_IT_RXNE);
HAL_NVIC_EnableIRQ(USART2_IRQn);
HAL_NVIC_SetPriority(USART2_IRQn, 3, 3);
return 0;
}
为了采用C语言实现环形数组存储,先定义一个结构体,然后定义一个缓存数组,最后把参数进行初始化。接下来先看中断函数,如下:
/* uart 中断 */
void USART2_IRQHandler(void)
{
int ch = -1;
rt_base_t level;
/* enter interrupt */
rt_interrupt_enter(); //在中断中一定要调用这对函数,进入中断
if ((__HAL_UART_GET_FLAG(&(UartHandle), UART_FLAG_RXNE) != RESET) &&
(__HAL_UART_GET_IT_SOURCE(&(UartHandle), UART_IT_RXNE) != RESET))
{
while (1)
{
ch = -1;
if (__HAL_UART_GET_FLAG(&(UartHandle), UART_FLAG_RXNE) != RESET)
{
ch = UartHandle.Instance->DR & 0xff;
}
if (ch == -1)
{
break;
}
/* 读取到数据,将数据存入 ringbuffer */
rt_ringbuffer_putchar(&uart_rxcb, ch);
}
rt_sem_release(&shell_rx_sem);
}
/* leave interrupt */
rt_interrupt_leave(); //在中断中一定要调用这对函数,离开中断
}
由于该中断服务函数用于RT-thread操作系统中的,所以rt_interrupt_enter();rt_interrupt_leave();是标记进入和退出临界函数标志,可以忽略。
其实中断函数中就是对接收的数据进行存储,主要的函数是rt_ringbuffer_putchar(&uart_rxcb, ch);
其函数源码如下:
/**
* put a character into ring buffer
*/
rt_size_t rt_ringbuffer_putchar(struct rt_ringbuffer *rb, const rt_uint8_t ch)
{
RT_ASSERT(rb != RT_NULL);
/*检查剩余空间 */
if (!rt_ringbuffer_space_len(rb))
return 0;
rb->buffer_ptr[rb->write_index] = ch;
/* 检查是不是到达数组的最后一个下标 */
if (rb->write_index == rb->buffer_size-1)
{
/*如果是最后一个下标,就转换一下标志*/
rb->write_mirror = ~rb->write_mirror;
/*写如数组的下标回到数组的0下标*/
rb->write_index = 0;
}
else
{
/*对写的数组加1,用于下次存储*/
rb->write_index++;
}
return 1;
}
对于这段源码, rt_ringbuffer_space_len()函数是检查是否还有存储空间,暂时先不管。然后就是把数据对数组进行赋值,其中
rb->write_index在每次存储一个字节数据时,都会增加,毕竟在一个环上循环,在这里就是一个写入到数组位置的标记。不过在增加之前,还检查了写入的是不是数组的尾部,在数组最后一个字节空间写入数据后,下次存储就是下一个环,所以应该跳转到数组的首部。
通过以上函数,环形数组数据存储就实现了,再回首看一下rt_ringbuffer_space_len()函数如何检查剩余空间的,源码如下:
#define rt_ringbuffer_space_len(rb) ((rb)->buffer_size - rt_ringbuffer_data_len(rb))
rt_inline enum rt_ringbuffer_state rt_ringbuffer_status(struct rt_ringbuffer *rb)
{
if (rb->read_index == rb->write_index)
{
if (rb->read_mirror == rb->write_mirror)
return RT_RINGBUFFER_EMPTY;
else
return RT_RINGBUFFER_FULL;
}
return RT_RINGBUFFER_HALFFULL;
}
/**
* get the size of data in rb
*/
rt_size_t rt_ringbuffer_data_len(struct rt_ringbuffer *rb)
{
switch (rt_ringbuffer_status(rb))
{
case RT_RINGBUFFER_EMPTY:
return 0;
case RT_RINGBUFFER_FULL:
return rb->buffer_size;
case RT_RINGBUFFER_HALFFULL:
default:
if (rb->write_index > rb->read_index)
return rb->write_index - rb->read_index;
else
return rb->buffer_size - (rb->read_index - rb->write_index);
};
}
从源码可以看出,rt_ringbuffer_space_len()函数就是一个宏,该宏调用了rt_ringbuffer_data_len()函数,该函数在内部调用rt_ringbuffer_status()函数对数组做了计算,从源码中可以看到,通过检查rb->read_index 和 rb->write_index两个参数(其中rb->read_index 会随着读函数变化,稍后会讲),检查数据缓存是full还是null,或者两者都不是,最终返回剩余空间大小。
通过以上函数的调用,最终把数据以一个环形的方式放入存储空间。
3.2、还是那个环,把数据读出来
既然数据已经存入到环形数组里面了,那么如何将数据从环形数组中读出?调用读数据的函数是应用函数,该应用函数如下:
char rt_hw_console_getchar(void)
{
char ch = 0;
/* 从 ringbuffer 中拿出数据 */
while (rt_ringbuffer_getchar(&uart_rxcb, (rt_uint8_t *)&ch) != 1)
{
rt_sem_take(&shell_rx_sem, RT_WAITING_FOREVER);
}
return ch;
}
这个应用函数有什么用暂时不管,本次就看是如何把数据读出来的。由函数可知,内部调用了rt_ringbuffer_getchar函数来读数据,进入该函数源码:
/**
* get a character from a ringbuffer
*/
rt_size_t rt_ringbuffer_getchar(struct rt_ringbuffer *rb, rt_uint8_t *ch)
{
RT_ASSERT(rb != RT_NULL);
/* ringbuffer is empty */
if (!rt_ringbuffer_data_len(rb))
return 0;
/* put character */
*ch = rb->buffer_ptr[rb->read_index];
if (rb->read_index == rb->buffer_size-1)
{
rb->read_mirror = ~rb->read_mirror;
rb->read_index = 0;
}
else
{
rb->read_index++;
}
return 1;
}
在该函数中,也是首先检查缓存是否是空的,空的就返回,如果不是空的,就返回当前读到的数据,顺便检查一下数据缓存空间的状态,对读的地址加1,用于下次读数据使用。
总结以上可以知道,环形数组就是一边存数据,一边读数据,一旦数据读出后,那么下次读的地址就变化了,其实也就是释放数组空间,用于下次存储。
以上就是对环形存储缓存的代码实现介绍,具体源码请看RT-thread源码。
四、扩展
环形数组的存在,使我们能够采用串口中断方式接收不定长的数据,但是还是有弊端的,比如什么时候接收完?正在读取时又有新数据到来怎么办?接收大数据时,内存不够怎么办?
然而,即便在难的事情也难不住我们伟大的程序员,在操作系统中,经常使用环形对列操作,有兴趣的话可以学习研究一下。开源共享、共同进步。