文章目录
- UART 通信丢失数据的常见原因总结
- 串口(UART)数据丢失 Bug 的复现
- 引入环形队列解决数据丢失问题
- 总结
在嵌入式系统和物联网(IoT)设备中,串行通信是一种非常普遍且重要的数据传输方式。无论是通过 UART、RS-232 还是其他串行接口,串口通信因其简单可靠而被广泛应用于各种场景,从简单的传感器读取到复杂的控制系统。然而,尽管串口通信具有诸多优点,但在实际应用中,开发者们常常会遇到一个令人头疼的问题——数据丢失。
数据丢失不仅可能导致信息不完整,还可能引发系统错误或导致关键操作失败。想象一下,在工业自动化控制中,如果某个指令未能正确传递,可能会造成生产流程的中断甚至安全问题;或者在一个医疗设备中,如果监测数据出现丢失,可能会对病人的健康状况判断产生严重的影响。因此,理解并解决串口接收时的数据丢失问题对于确保系统的稳定性和可靠性至关重要。
本文是我以往工作中遇到串口数据丢失的问题时,解决问题过程的总结归纳。文章将基于 STM32 HAL 库和环形队列,来解决其中一种最常见的 UART 数据丢失的问题,以帮助开发者减少或避免此类问题的发生。
UART 通信丢失数据的常见原因总结
在使用 UART 进行通信时,数据丢失是一个常见的问题。以下是一些导致 UART 接收数据时丢失数据的常见原因:
-
波特率不匹配:
这是 UART 丢失数据的众多原因中,最好解决的一种情况,只要重新配置设备的波特率即可。
-
缓冲区溢出:
接收方的缓冲区太小,来不及处理的数据被覆盖,或是缓冲区溢出,超出缓冲区存储空间部分的数据没被保存。
-
硬件中断或干扰:
外部电磁干扰、电源波动等可能导致信号失真。这属于硬件问题,通常使用屏蔽电缆,确保良好的接地,远离强电磁场源,使用滤波器或稳压器来减少电源波动的影响。
-
软件错误:
接收代码中的 bug,如未正确读取缓冲区数据、错误处理不当等。这属于工作失误,好好反省一下自己,然后仔细检查和测试接收代码,确保数据读取和处理逻辑正确无误。
-
流控机制失效:
硬件流控(RTS/CTS)或软件流控(XON/XOFF)没有被正确配置或实现。同第一条和第四条一样,工作失误,重新确认流控机制是否启用,确保流控信号线连接正确。
-
信号完整性问题:
信号在线缆上传输过程中衰减或受到噪声影响。硬件问题,找硬件工程师解决,通常的解决办法是使用高质量的电缆,缩短线缆长度,使用终端电阻或信号放大器来改善信号质量。
-
超时设置不合理:
接收超时时间设置得太短,可能会误认为没有数据到达而关闭接收操作。可以通过多次 debug,根据实际应用情况合理设置超时时间,确保有足够的时间来接收数据。实在不会,直接设置成最大值。
以上众多原因中,除了比较基础的配置问题导致,还有就是硬件问题。那么,排除硬件问题和一些非常低级的错误,个人觉得,对于初学者或者刚刚入行的菜鸟程序员来说,需要花一些时间解决的就是第二条,接下来将复现 UART 接收数据时,丢失数据的现象。以及用环形缓冲存储区解决问题的过程。
串口(UART)数据丢失 Bug 的复现
本次复现是基于 STM32F103 单片机,复现 Bug 之前,先来了解一下 STM32 有多少种 UART 的编程方式。根据 STM32F103 的 UART 硬件框架,STM32F103 有三种不同编程方法:
- 查询方式:
- 发送数据:先把准备发送的数据写入 TDR(Transmit Data Register),然后判断 TDR 是否空,为空则返回。
- 接收数据:先判断 RDR(Receive Data Register)是否为非空,如果为非空状态,则读取 RDR 数据。
- 中断方式:
- 发送数据:先启用 TXE(Transmit buffer empty)中断,然后在 TXE 中断处理函数中,从程序的发送 buffer 里取出一个字节的数据,写入 TDR。等再次触发 TXE 中断时,再从发送 buffer 里取出下一个数据写入 TDR,如此循环,直到发送 buffer 中的数据全部发送完毕。最后禁用 TXE 中断。
- 接收数据:先启用 RXNE(Receive buffer not empty)中断,当 UART 接收器接收到一个数据时,会立刻触发中断。在中断程序中,会读取 RDR 的数据并存入接收 buffer 中。最后禁用 RXNE 中断。如果需要处理串口接收数据的话,直接读取接收 buffer 的数据即可。
- DMA 方式:
- 发送数据:DMA 从 SRAM 得到数据,写入 UART 的 TDR。
- 接收数据:DMA 从 UART 的 RDR 读取数据,并写入 SRAM。
- 以上两个动作,任意一个完成都会触发 DMA 中断,可以作为完成标志。
对于以上三种编程方式,使用的函数如下表:
查询方式 | 中断方式 | DMA 方式 | |
---|---|---|---|
发送 | HAL_UART_Transmit | HAL_UART_Transmit_IT HAL_UART_TxCpltCallback | HAL_UART_Transmit_DMA HAL_UART_TxHalfCpltCallback HAL_UART_TxCpltCallback |
接收 | HAL_UART_Receive | HAL_UART_Receive_IT HAL_UART_RxCpltCallback | HAL_UART_Receive_DMA HAL_UART_RxHalfCpltCallback HAL_UART_RxCpltCallback |
错误 | HAL_UART_ErrorCallback | HAL_UART_ErrorCallback |
工作中最常用的还是中断方式和 DMA 方式,不过,使用中断方式编程的过程中,软件处理不得当,一样会出现接收数据丢失的风险,本文也为这种编程方式重点阐述如何解决这种风险。当然,使用 DMA 方式编程,基本不当心数据会丢失(只要空间足够大),再配合空闲中断(Idle Interrupt)是一种在数据传输过程中优化资源利用和提高系统响应性的技术。这种组合可以确保数据能够高效且可靠地传输。所以 DMA 方式不是本文的讨论重点。
[!NOTE]
其实有些应用场合并不适用 DMA 方式编程,DMA 方式是一次性将单次的接收到的数据写入 SRAM, 其实这样做的效率并不高,而且可能接收到无效的数据。例如,在一些私有协议中,对数据帧都会规定帧头和帧尾的格式,通常做法是一边接收数据一边解析数据,如果发现帧头是错的,就抛弃本次接收的数据。
本次复现 Bug 所用的程序是将接收到的数据再发送出去,项目初始化由 STM32CubeMX 生成,组态过程不做展示,串口基本设置如下:
void MX_USART1_UART_Init(void)
{
huart1.Init.BaudRate = 115200; // 设置波特率为 115200bps
huart1.Init.WordLength = UART_WORDLENGTH_8B; // 设置数据位为 8 位
huart1.Init.StopBits = UART_STOPBITS_1; // 设置停止位为 1 位
huart1.Init.Parity = UART_PARITY_NONE; // 设置无校验位
huart1.Init.Mode = UART_MODE_TX_RX; // 设置模式为发送和接收(全双工)
huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; // 禁用硬件流控
huart1.Init.OverSampling = UART_OVERSAMPLING_16; // 设置过采样率为16倍
if (HAL_UART_Init(&huart1) != HAL_OK) {
Error_Handler();
}
}
先使用一个字节作为串口接收数据的缓冲存储区,程序不展示全部代码,只展示人为输入的代码,如下:
uint8_t c;
volatile uint8_t RxCplt = 0;
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
RxCplt = 1;
HAL_UART_Receive_IT(&huart1, &c, 1);
}
}
int main(void)
{
const uint8_t *str = "Grayson Zheng\r\n";
...
HAL_UART_Transmit(&huart1, str, strlen(str), HAL_MAX_DELAY); // 先测试一下串口功能
HAL_UART_Receive_IT(&huart1, &c, 1); //启动接收中断
while (1) {
/* 如果接收缓冲区接受到了数据,就将缓冲区内容发送出来 */
if (RxCplt) {
RxCplt = 0;
HAL_UART_Transmit(&huart1, (const uint8_t *)&c, 1, HAL_MAX_DELAY);
}
}
}
以 RxCplt
作为数据接收的标志,当 RxCplt
为 1 时,说明触发了串口接收中断,就可以将接收到的数据回传,同时将 RxCplt
再次置为 0,等待下一个数据。
执行结果如下图,对于单个数据和单次少量的数据,可以做到完整的数据回传,但是单次数据量较大时,就会有数据丢失的风险。
此时,绝大多数人能想到的解决办法就是把接收缓冲存储区变大,使当次可接收到的数据更多,于是就有如下的代码修改方案:
#define BUFFER_SIZE 16
uint8_t c = 0;
uint8_t RxBuffer[BUFFER_SIZE] = {0};
uint8_t RxIndex = 0;
volatile uint8_t RxCplt = 0;
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
if (RxIndex < BUFFER_SIZE)
RxBuffer[RxIndex++] = c;
else
RxIndex = 0;
RxCplt = 1;
HAL_UART_Receive_IT(&huart1, &c, 1);
}
}
void ProcessReceivedData(UART_HandleTypeDef *huart)
{
for (uint8_t i = 0; i < RxIndex; i++)
HAL_UART_Transmit(huart, (const uint8_t *)&RxBuffer[i], 1, HAL_MAX_DELAY);
memset(RxBuffer, 0, BUFFER_SIZE);
RxIndex = 0;
}
int main(void)
{
/* USER CODE BEGIN 1 */
const uint8_t *str = "Grayson Zheng\r\n";
...
HAL_UART_Transmit(&huart1, str, strlen((const char *)str), HAL_MAX_DELAY);
HAL_UART_Receive_IT(&huart1, &c, 1);
while (1) {
if (RxCplt) {
RxCplt = 0;
ProcessReceivedData(&huart1);
}
}
该方案添加了一个 16 字节的接收缓冲存储区,将接收到的每个字节都依次写入接收缓冲存储区,再通过 ProcessReceivedData
函数回传数据。效果如下图:
为接收更多字节,我们把接收缓冲存储区扩大到 128 字节。
不过,这种做法显然治标不治本。虽然更大的缓冲存储区确实可以接收单次以内较大数量的数据,但是如果单次接收到的数据依然大于缓冲存储区的最大长度,还是会丢失数据。
[!NOTE]
通常情况下,128KB 的缓冲存储区是可以解决大部分项目的需求,但是不排除有些特殊的项目需求,接收的数据量不确定。例如,需要实现 Modbus-RTU 协议的项目,当需要读取外设连续的存储器的数值时,就可能有大量数据要接收。而且这个过程,是边接收边解析数据,并不是一次性接收完后,再进行解析。
因此更适用于这种情况的解决方法就是把缓冲存储区设计成循环缓冲区(环形队列)。
引入环形队列解决数据丢失问题
关于环形队列的介绍,在我之前的博客《【数据结构】环形队列(循环队列)学习笔记总结》做过详细的总结。没接触过或不熟悉环形队列的小伙伴,建议先移步阅读一下此博客。该博客中提供的环形队列的库由我自己编写,会直接引用到本次的解决方案中,该库可能不适用于所有应用场景,也欢迎各位高手指正。
首相把整个库复制到项目中,为了区分 STM32CubeMX 生成的库文件,我在项目根目录下新建了 Lib
文件(个人做项目的习惯)。
[!NOTE]
我个人习惯用 VSCode 做项目,对于使用 Keil 做项目的小伙伴,这里简单的教一下怎么在 Keil 中添加文件夹。已经会的小伙伴请跳过这段。
先点击 ”魔术棒“,在弹窗中选择 ”C/C++“ 选项卡,再点击 ”Include Paths“ 输入框最右边的三个点的按钮。
点击插入,再点击三个点的按钮。
找到要添加的文件夹的路径,选择文件夹后点击 ”选择文件夹“。
添加进来后,按顺序点击 ”OK“ 关掉弹窗。
点击 ”品“ 字形的图标,弹窗中点击 ”Groups“ 的插入按钮,在下面的空白框输入刚刚添加进来的文件夹的名字(要保证一致,不能写错,字母大小写有区别),然后再点击右下角的 ”Add files……“。
再次出现弹窗,在文件类型中选择 ”All files“,把要添加进来的文件,挨个双击添加,这里不会提示是否已经添加,所以添加完毕后直接关掉弹窗。
这里可以看到已经添加进来,直接点 ”OK“。
在项目树上也可以看到。
先在主代码文件上加载头文件:
#include "circular_queue.h"
再定义一个临时接收变量 c
,一个接收缓冲存储区 RxBuffer
,长度为 16 个字节,用宏定义 BUFFER_SIZE
指代,方便后期修改长度。此时的接收缓冲存储区在逻辑上还不是环形缓冲存储区,需要用到 circular_queue_t
结构体进行管理,所以定义一个 RxQueue
的结构体变量。标志位 RxCplt
,用于标记接收中断是否完成。
#define BUFFER_SIZE 16
uint8_t c = 0;
uint8_t RxBuffer[BUFFER_SIZE] = {0};
circular_queue_t RxQueue = {0};
volatile uint8_t RxCplt = 0;
在 main
函数中调用环形缓冲存储区初始化函数:
circular_queue_init(&RxQueue, RxBuffer, BUFFER_SIZE);
如果接收到数据,把数据写入环形缓冲存储区就是入队操作,需要调用入队函数,这个动作在串口接收中断的回调函数中完成:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
if (!circular_queue_is_full(&RxQueue)) // 判断队列是否满了
circular_queue_enqueue(&RxQueue, c); // 将接收到的数据入队
RxCplt = 1;
HAL_UART_Receive_IT(&huart1, &c, 1);
}
}
把环形缓冲存储区的数据取出是出队操作,需要调用出队函数:
void ProcessReceivedData(UART_HandleTypeDef *huart)
{
while (!circular_queue_is_empty(&RxQueue)) { // 判断队列是否为空
uint8_t data;
if (circular_queue_dequeue(&RxQueue, &data)) // 将数据取出
HAL_UART_Transmit(huart, (const uint8_t *)&data, 1, HAL_MAX_DELAY);
}
}
主体代码如下(已删去不必要的注释和与主题关联性不大的代码片段):
#include "circular_queue.h"
#define BUFFER_SIZE 16
uint8_t c = 0;
uint8_t RxBuffer[BUFFER_SIZE] = {0};
circular_queue_t RxQueue = {0};
volatile uint8_t RxCplt = 0;
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
if (!circular_queue_is_full(&RxQueue))
circular_queue_enqueue(&RxQueue, c); // 将接收到的数据入队
RxCplt = 1;
HAL_UART_Receive_IT(&huart1, &c, 1);
}
}
void ProcessReceivedData(UART_HandleTypeDef *huart)
{
while (!circular_queue_is_empty(&RxQueue)) {
uint8_t data;
if (circular_queue_dequeue(&RxQueue, &data))
HAL_UART_Transmit(huart, (const uint8_t *)&data, 1, HAL_MAX_DELAY);
}
}
int main(void)
{
const uint8_t *str = "Grayson Zheng\r\n";
/* 其他初始化代码在此处省略 */
circular_queue_init(&RxQueue, RxBuffer, BUFFER_SIZE); // 初始化环形缓冲存储区
HAL_UART_Transmit(&huart1, str, strlen((const char *)str), HAL_MAX_DELAY); // 测试串口发送
HAL_UART_Receive_IT(&huart1, &c, 1); // 打开串口接收中断
while (1) {
if (RxCplt) {
RxCplt = 0;
ProcessReceivedData(&huart1);
}
}
}
测试结果如下图所示,对单个数据、单次少量数据、单次最大数据、和单次数倍于最大量做了测试,均没有出现数据丢失的现象。
但是连续多出大数量后,数据出现混乱的现象:
原因在于 RxCplt
这个标志响应不及时导致,解决方案就是不再通过 RxCplt
判断,而是通过环形队列剩余的数量判断,代码如下:
#include "circular_queue.h"
#define BUFFER_SIZE 16
uint8_t c = 0;
uint8_t RxBuffer[BUFFER_SIZE] = {0};
circular_queue_t RxQueue = {0};
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
if (!circular_queue_is_full(&RxQueue))
circular_queue_enqueue(&RxQueue, c); // 将接收到的数据入队
HAL_UART_Receive_IT(&huart1, &c, 1);
}
}
void ProcessReceivedData(UART_HandleTypeDef *huart)
{
while (!circular_queue_is_empty(&RxQueue)) {
uint8_t data;
if (circular_queue_dequeue(&RxQueue, &data))
HAL_UART_Transmit(huart, (const uint8_t *)&data, 1, HAL_MAX_DELAY);
}
}
int main(void)
{
const uint8_t *str = "Grayson Zheng\r\n";
/* 其他初始化代码在此处省略 */
circular_queue_init(&RxQueue, RxBuffer, BUFFER_SIZE);
HAL_UART_Transmit(&huart1, str, strlen((const char *)str), HAL_MAX_DELAY);
HAL_UART_Receive_IT(&huart1, &c, 1);
while (1) {
if (RxQueue.count) // 判断环形队列中的元素个数
ProcessReceivedData(&huart1);
}
}
测试结果如下图,数据不再丢失,回传也准确无误。
总结
对于本次解决串口接收丢失数据的改进措施就是使用了环形队列库来管理接收数据,确保数据不会丢失。在中断回调函数中直接操作全局变量 c
和标志位 RxCplt
是一种常见做法,随后也放弃了 RxCplt
,用环形队列管理结构体的成员变量 count
取代,这样可以提高代码的可维护性和减少潜在的竞态条件,也是更安全的方法。通过这些改进,代码变得更加健壮和高效,能够更好地处理连续的数据流。各位读者也可以根据实际需求进一步调整缓冲区大小和其他参数。
当然,这里还有一个改进建议,就是在中断回调函数中,对于队列已满的情况没有做特殊处理,建议读者根据自己的需求,增加了对队列溢出的处理,并可以在需要时添加更多的错误处理机制。
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
if (!circular_queue_is_full(&rxQueue)) {
circular_queue_enqueue(&rxQueue, c); // 将接收到的数据入队
} else {
// 队列已满,丢弃新数据
// 这里可以根据需要添加日志或警告
}
RxCplt = 1;
HAL_UART_Receive_IT(&huart1, &c, 1);
}
}