基于 freeRTOS 的消息队列、多任务和使用DMA、空闲中断、缓冲区彻底解决串口接收数据丢包拆包粘包的问题
基于 freeRTOS 的消息队列、多任务和使用DMA、空闲中断、缓冲区彻底解决串口接收数据丢包拆包粘包的问题
使用freeRTOS的唯一硬性要求是内存要足够大,可以随便造。系统总堆太大或太小都有可能卡死,这个问题以后再深究。
1. 架构设计
串口空闲中断处理函数:当串口空闲中断发生时,负责将串口接收DMA中的数据发送至串口消息队列,然后等待下一次空闲中断的产生。
主任务:在 freeRTOS 初始化时创建,负责创建其它的任务以及消息队列,并且以一定频率实时向上位机发送数据。
数据处理任务:负责接收来自串口消息队列的数据,以正确的通讯协议区分并解析每个数据帧,并且把解析完成的数据发送至LED消息队列。
LED控制任务:负责接收LED消息队列的数据,并控制灯带颜色。
由不同的任务负责不同的功能,LED控制任务无需关注串口数据接收和解析的实现,串口接收和解析也无需关注LED控制任务的实现,解除了各个功能的耦合。
2. 串口空闲中断的实现
串口空闲中断可以标志一次数据传输的结束,相比一字节一中断,空闲中断只有在接收完一帧时才会产生中断,减少了频繁中断产生的系统开销。
首先需要在相对应的串口中断回调函数当中判断空闲中断是否产生,并调用空闲中断回调函数。
void USART2_IRQHandler(void)
{
if(__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE)) // 判断空闲中断标志位
{
HAL_UART_IdleCpltCallback(&huart2); // 调用空闲中断回调函数
}
HAL_UART_IRQHandler(&huart2);
}
然后重写空闲中断回调函数:
空闲中断回调函数在接收到数据之后,把数据原封不动的写入到g_xQueueUsart队列,并重启DMA接收。整个过程快进快出,在中断内执行的程序尽量不要占用太长时间。
xHigherPriorityTaskWoken设置为true时,无论接收消息的任务有没有轮到被调度,都会在发送消息时立即调度一次该任务,时效性会更好。
void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart)
{
if(huart->Instance == USART2)
{
// 获取接收到的数据长度
rx_len = BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart2_rx);
if (rx_len)
{
__HAL_UART_CLEAR_IDLEFLAG(&huart2); // 清除 IDLE 标志
DataPacket datapacket;
datapacket.length = rx_len;
memcpy(datapacket.data, rx_buffer, rx_len);
BaseType_t xHigherPriorityTaskWoken = pdTRUE;
if (xQueueSendFromISR(g_xQueueUsart, &datapacket, &xHigherPriorityTaskWoken) != pdPASS)
{
// 发送失败
}
Stop_DMA_Receive();
// 重新启动 DMA 接收
Start_DMA_Receive();
rx_len = 0;
}
}
}
3. 数据处理任务的实现
数据处理任务的逻辑比较复杂,需要处理数据帧丢包、粘包和拆包的问题。
粘包:是指两个或多个数据包粘连在一起作为一个单一的数据包被接收方接收的现象。这通常发生在发送方连续快速发送多个数据包时,接收方可能在一次读取操作中接收到多个数据包,导致原本应该分开处理的数据包被当作一个整体处理。
拆包:是指一个数据包被拆分成多个部分进行传输,接收方需要分多次接收完整的数据包。
为了解决这些问题,首先把从g_xQueueUsart队列读到的数据一箩筐的装进缓冲区,然后再区分每个数据帧,再把解析成功的数据帧发送给g_xQueueLeds队列,解析失败的的数据帧则丢弃。
丢包的问题最好解决,一帧的数据不完整直接丢弃即可。
如果发生粘包,需要把完整的数据帧分离出来,同时保留还没有接收完成的数据帧。
如果发生拆包,需要等到完整的数据帧接收完再处理。
void usart_process_task(void *params)
{
DataPacket dataPacket = {0};
SerialPortProtocol receiveProtocol = {0XFF, 0X00, 0X0D};
while (1)
{
if (xQueueReceive(g_xQueueUsart, &dataPacket, portMAX_DELAY) == pdPASS) // 线程阻塞等待缓冲区的数据
{
memcpy(buffer + buffer_len, dataPacket.data, dataPacket.length); // 数据放入缓冲区
buffer_len += dataPacket.length; // 增加缓冲区长度
// 处理缓冲区
while (buffer_len > 0)
{
if (buffer[0] != 0XFF) // 如果帧开头不是FF就丢掉
{
memcpy(buffer, buffer + 1, buffer_len - 1); // 缓冲区整体向前移1位
buffer_len--;
} else if (buffer_len >= sizeof(SerialPortProtocol))
{
// 尝试解码
if (try_decode(&receiveProtocol, buffer, sizeof(SerialPortProtocol)) == sizeof(SerialPortProtocol))
{
memcpy(buffer, buffer + sizeof(SerialPortProtocol), buffer_len - sizeof(SerialPortProtocol));
buffer_len -= sizeof(SerialPortProtocol);
// 发送数据给LDE任务
if (xQueueSend(g_xQueueLeds, &receiveProtocol, NULL) != pdPASS)
{
// 发送失败
}
} else {
// 解码失败时需要把这个完整的数据帧破坏掉,则不满足buffer[0] == 0XFF的条件,整个数据帧都会被删除
memcpy(buffer, buffer + 1, buffer_len - 1); // 缓冲区整体向前移1位
buffer_len--;
}
} else {
// buffer[0] == 0XFF并且长度小于一个数据帧,说明这一帧没有接收完成,需要停止处理,等待下一次接收
break;
}
}
}
}
}
4. LED控制任务的实现
LED控制任务根本不需要再关注串口数据解析的实现,也不需要关注灯带驱动的实现。只需要接收相关参数调用驱动即可,代码不放了。
5. freeRTOS 定时器的应用
使用 freeRTOS vTaskDelayUntil可以在循环内精准控制每个循环的执行时间,可以很方便的控制串口发送频率和灯带呼吸灯的周期。
6. 总结
引入了freeRTOS不但解决了现有问题,而且并没有随着代码量的庞大而增加系统开销。相反,freeRTOS的多任务调度充分利用了单片机的硬件性能,如果不增加延时,呼吸灯的频率都比之前快了很多。裸机程序即使不增加呼吸灯的延时,呼吸灯周期仍然很长,只能通过增加步进距离加快频率。