嵌入式/STM32串口printf打印字符串,多出来的数据是哪里来的?

文章目录

概述

嵌入式系统中使用串口通过USB-RS232向PC输出日志,常见的情况是有数据丢失,但是菜鸟竟然会遇到数据变多的情况。

使用STM32F103进行串口实验,串口2工作于中断发送、串口3工作于轮询接收、串口1重定向于printf输出。在某个实验阶段,存在以下异常现象:PC终端上接收到的日志内容比预期要有规律的多出来一些。我一时半会无法确定以上异常的本质是串口2多发送了?串口3多接收了?串口1多打印了?最终问题被定位在, printf %s 的实参字符串内存区没有以 '\0' 结尾,该错误在多种巧合之下,表现出来以上异常。

@HISTORY

202405 简单的问题,往往解决起来并不轻松。在很多时候,某个具体的问题其本身并不复杂,但是该问题出现的场景相对比较复杂,这就可能需要我们花费很长的时间,不断尝试变换问题分析路径,不端试错之后才能接近问题事实,并进而发现其实是一个简单的错误。本文关注的是分析过程和分析方法,而不是关注问题本身。此外,对于一些基础编程习惯的不遵守,往往是制造难以排查的简单问题的源头,比如本文提及的,使用 printf 打印字符串数据内存区域时,没有严格以 '\0' 结尾。

补充,需要额外提醒的是,无论串口数据出现了怎样的异常,首先要想到的是监控SR状态寄存器。

转载请标明出处,https://blog.csdn.net/quguanxin/category_9647199.html

问题场景

彼时的实验中,使用串口2中断模式发送,窗口3轮询模式接收。发送和接收数据缓冲区如下,

cpp 复制代码
//串口2中断发送/串口3轮询接收
#if SWITCH_USART2_IT_SEND_USART3_LP_RECV
//定义串口3接收数据区
uint8_t dtbuff_usart3_recv [100];
//定义串口2中断发送缓冲区
uint8_t dtbuff_usart2_it_send[32];
//串口2发送计数
static int count_usart2_sending = 0;
#endif

//构建待发送数据 //恒定每消息27字节(方便调试)
int build_sending_data(uint8_t *dtbuff_sending, int usartNoSend, int usartNoRecv, int *count_sending) {
    volatile int len = sprintf((char*)dtbuff_sending, "u%d send u%d recv, n %04d..\r\n", usartNoSend, usartNoRecv, (*count_sending)++);
    if (len >= 32)  //上述不安全构建过程可能导致不可预知问题/如printf卡死
        printf("bulid error..\r\n");
    //printf("product:%s", dtbuff_sending);
    return len;
    //串口3工作在轮询收发模式、串口2工作在中断收发模式
}

在mian函数中触发一次USART2的中断发送过程,之后由USART2中断发送完成函数触发继续发送,

c 复制代码
//串口2中断发送完成
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) {   
    //虽然实验这么搞,但实际一般不如此用
    if(huart->Instance == USART2) {
        #if SWITCH_USART2_IT_SEND_USART3_LP_RECV
        //构建待发送数据
        volatile int len = build_sending_data(dtbuff_usart2_it_send, 2, 3, &count_usart2_sending);
        if (count_usart2_sending >= 200) {
            LED1(1);  return;
            //count_usart2_sending==200时,dtbuff_中被写入的是199,此buff没有被发送送出去
        }
        //统计已发送数据
        debug_total_usart2_sended += len;
            
        //继续usart2的中断数据发送
        volatile HAL_StatusTypeDef usart2_send_State = HAL_UART_Transmit_IT(huart, dtbuff_usart2_it_send, len);
        if (HAL_OK != usart2_send_State) {
            printf("Failure Usart2 SendState:%d\r\n", usart2_send_State);
        }
        #endif
        //禁止调用HAL_Delay延时函数 //也要禁止空操作延时 /∵ 无论何种延时都会因为在中断服务中执行而抢占CPU
    }
    else {
        printf("undefined usart TxCpltCallback!\r\n");
    }
}

在mian函数的while循环中,完成USART3的轮询接收过程,接收缓冲区100字节,并在接收完成后调用printf打印字符串,

c 复制代码
int main(void) {
  HAL_Init();

  SystemClock_Config();
  MX_GPIO_Init();
  MX_USART1_UART_Init();
  MX_USART2_UART_Init();
  MX_USART3_UART_Init();
  
  //开启串口2中断发送
  #if SWITCH_USART2_IT_SEND_USART3_LP_RECV
  //构建待发送数据
  volatile int len = build_sending_data(dtbuff_usart2_it_send, 2, 3, &count_usart2_sending);
  //统计已发送数据
  debug_total_usart2_sended += len;
  //统计USART2发送数据
  HAL_UART_Transmit_IT(&huart2, dtbuff_usart2_it_send, len);
  #endif
  
  while (1)  {
    //这个判断没啥鸟用,因为HAL_UART_Receive无论正常或异常结束都会重新置READY
    //HAL_UART_StateTypeDef HAL_UART_GetState()
    
    //直接检查底层状态寄存器
    if (1 == __HAL_UART_GET_FLAG(&huart3, UART_FLAG_ORE)) {
        printf("%%ORE%%");
        //直接输出整个状态寄存器的值
        uint32_t value_sr = (&huart3)->Instance->SR;
        //以16进制输出状态寄存器的值
        printf("---Usart3 SR:0x%x--\r\n", value_sr);
    }
    
    //清空接收数据缓冲区
    memset(dtbuff_usart3_recv, '\0', sizeof(dtbuff_usart3_recv));
    //串口3轮询接收(串口2中断发送)/本函数是阻塞的哈
    volatile HAL_StatusTypeDef u3RecvState = HAL_UART_Receive(&huart3, dtbuff_usart3_recv, sizeof(dtbuff_usart3_recv), 1000);
    if (HAL_OK != u3RecvState) {
        //printf("Failure Usart3 RecvState:%d\r\n", u3RecvState);
    }
    if (u3RecvState == HAL_TIMEOUT) {
        // 计算实际接收字节数 = 目标长度 - 剩余未接收计数
        debug_total_usart3_recved += (sizeof(dtbuff_usart3_recv) - huart3.RxXferCount);
    }
    else {
        debug_total_usart3_recved += sizeof(dtbuff_usart3_recv);
    }
    //
    #if 1
    printf("%s", dtbuff_usart3_recv);
    #else //尝试绕过printf
    HAL_UART_Transmit(&huart1, (uint8_t *)dtbuff_usart3_recv, sizeof(dtbuff_usart3_recv), HAL_MAX_DELAY);
    #endif
  } //while ..
}

放大个别异常现象

先说几句废话,感觉若有所得,但实际上可能什么都不是吧。我们都知道,如果要进行一个物理的、化学的、社会的实验,通常因变量不能太多,否则实验就会很复杂,实验过程复杂,实验结果不稳定、不可控。软件排查问题,也似乎要遵守这一规则,我们可能需要减少或放大某些方面的环境因素,以放大实验现象,从而更快的定为问题。

实验开始的时候,我串口2和3工作于9600,而串口1工作于115200,此时实验现象是,

此时的实验现象就比较复杂:

首先,SR:0xe8(ORE位==1)代表USART接收过程存在数据溢出情况。

另外,每当USART接收缓冲区满(红色标记处),即接收完成,都会出现xxx0004... xxx0008... 等重复数据。

对于ORE 数据溢出错误,这个不难分析。其原因在于:

printf是耗时的,这会阻塞 HAL_UART_Receive(&huart3, ... 过程。printf 会调用 fputc 函数实现,而 fputc 本质是 基于串口1 的轮询式发送过程。在物理层次上,串口1的异步发送过程是依赖于波特率发生器的;在软件层次上,会阻塞循环着不断检查相关寄存器标志位的状态。

ORE溢出错误,导致部分字节数据丢失,我是想明白的。想不明白的是,为啥会有多出来的数据呢,还这么规律。我想进一步探寻这个规律,我首先要做的是,想办法关闭上述ORE异常对输出结果的影响。方法也很多:

1、降低USART2中断发送频率。基于上述中断发送模式的实现,这其实不好实现,难点在于,你不太好在HAL_UART_TxCpltCallback 中增加任何形式的延时操作,在其他文章中,我们谈过这个问题。因为 HAL_UART_TxCpltCallback 在中断服务函数中调用,执行优先级较高,会抢占CPU计算资源,即,减少 main-while 中USART3 轮询接收操作的执行机会,从而加剧接收数据溢出丢失的问题。

2、使用定时器中断服务函数,执行串口数据发送过程,放弃使用 HAL_UART_TxCpltCallback 中的发送循环。这是可行的,但我不想这么干,因为 验证中断发送完成接收函数是我的目的之一。

3、这是最简单的方案,就是提升USART1异步发送的串口波特率,这自然会提升printf的耗时。我们可以具体计算出来,USART1的波特率在达到什么水准后,就不会影响USART3的轮序接收过程。

我使用了方案3,我们先不计算USART1不影响USART3接收过程的最低的波特率,我们直接上最高波特率 2000000bit/s

重新编译,消除了ORE异常对输出结果的影响,

如上,程序的异常现象或者说规律就清晰多了。每当USART3的100B大小的轮询接收缓冲区满,最后一条目标打印内容(是因为缓冲区边界被断开的那条目标内容、也是目标发送内容)将被重复。至此,为啥有重复数据,尚不清楚。别急,慢慢分析。

从哪个层次开始多的?

我们从PC侧串口终端里,看见数据有规律的被多出来。这是怎么导致的呢?可能的原因:

1、USART2中断发送阶段,由于异常导致某条内容被重发发送?

2、USART3轮序接收阶段,由于缓冲区溢出,或者底层HAL处理?导致某段数据被重复接收。

3、USART1打印输出阶段,发送异常?导致重复输出。

4、pfintf 到 fputc 函数调用的中间阶段,有缓冲区?导致了部分数据重复。

5、上位机的PS232串口线或PC串口驱动,有异常?导致了部分数据重复。

一个有经验的开发者,可能很快便从理论上排除好几条原因。但是对于一个新手,每种可能似乎都是真的可能的,我就是那个新手。彼时我不具备一些串口较底层的分析能力,我只能从上层代码中逐点去试错去验证。

统计收发字节数

为什么要统计收发字节数呢?

通过前边的代码测试,USART3的接收过程也没有提示丢失数据,因此我首先怀疑是USART发送端导致出现了重复数据。另外,虽然我没有检测到USART2和USART3的SR寄存器有异常标志职位,但由于对USART-HAL层的实现逻辑并不十分清楚,心始终是悬着的。还是得实际测试下才放心,顺便比较下,USART发送字节数、USART3接收字节数、PC端接收字节数。

cpp 复制代码
//统计USART2中断发送字节数
static unsigned int debug_total_usart2_sended = 0;
//统计USART3轮序接收字节数
static unsigned int debug_total_usart3_recved = 0;

要特备注意的是,我自定义的 build_sending_data 函数,

c 复制代码
//构建待发送数据
int build_sending_data(uint8_t *dtbuff_sending, int usartNoSend, int usartNoRecv, int *count_sending) {
    volatile int len = sprintf((char*)dtbuff_sending, "u%d send u%d recv, n %04d..\r\n", usartNoSend, usartNoRecv, (*count_sending)++);
    if (len >= 32)  //上述不安全构建过程可能导致不可预知问题/如printf卡死
        printf("bulid error..\r\n");
    //printf("product:%s", dtbuff_sending);
    return len;
    //串口3工作在轮询收发模式、串口2工作在中断收发模式
}

在上述函数调用处,

c 复制代码
 //构建待发送数据
 volatile int len = build_sending_data(dtbuff_usart2_it_send, 2, 3, &count_usart2_sending);
 if (count_usart2_sending >= 200) {
     LED1(1); return;
     //count_usart2_sending==200时,dtbuff_中被写入的是199,此buff没有被发送送出去
 }
 //统计已发送数据
 debug_total_usart2_sended += len;

当count_usart2_sending == 200时,(++是后++,是先赋值后加加哦)dtbuff_usart2_it_send实际为,

bash 复制代码
u2 send u3 recv, n 0199..

而此时就return啦,即199这条是没有被发送的,即最后被发出去的是0198,这与PC段的接收显示是吻合的。

按照上述代码配置,实际发送数据为 199 * 27 = 5373 字节,我们在Keil实时监控结果为,

通过上述实时监控,可以看到,寻轮接收到的字节数与发送字节数完成相等。也就是说明,USART2发送和USART3接收字节数相等。

异常数据的规律

但是我们在PC串口终端中收到的数据总量是 6831 字节,6831 - 5373 = 1458 字节。

1458 / 27 = 54 条,哪里来的54呢?

5373 / 100 = 53 ...73

即寻轮接收过程,一共执行了54次,每次执行都多出27个字节,即一次发送数据。

我们重新审视下异常数据,

上图中,以,前两次打印结果为例来分析,灰色部分正好是轮询接收缓冲区大小的100字节。第一次100字节的缓冲区满了后,

u2 send u3 recv, n -/-0003... 上述数据被隔开了。左图红框中的 0003... 是第二次调用 HAL_UART_Receive 时的接收数据。分析到这里,我们的问题具体了许多,以左图为例, 灰色100字节块后边的 u2 send u3 recv, n 0003... 哪里来的,

打印完成100字节后,又打印了 27个字节(一个完整发送包),才又开始USART3的接收过程。多出来的数据是???

USART1发送字节数

在usart.c文件中,我们实现了printf重定向输出,如下。并增加了实际发送字节数统计,

cpp 复制代码
//统计发送字节数
unsigned int cnt_fput = 0;
//使用printf重定向输出
int fputc(int ch, FILE *f) {
    HAL_StatusTypeDef state = HAL_UART_Transmit(&huart1,(uint8_t *)&ch, 1, HAL_MAX_DELAY);
    if (HAL_OK != state) {
        //可能会返回Busy,从而导致字符丢失? /高波特率下从未被触发
        LED0_TOGGLE();
    }
    cnt_fput++;
    //
    return ch;
}

fputc 每次只发送一个字节,我们可以看看,它被调用了多少次。调试结果让我哈哈,这么搞?

在应用层,我们输入给printf的总字节数是5373,但是fputc函数却被调用了6804次,这是为什么呢?

怀疑C库有缓冲异常?

如果这么怀疑,那让我们的实验过程,绕靠C库的printf和fputc函数不就行了。我们自定义一个变参打印函数,

c 复制代码
void DebugPrint(const char *msg, uint32_t len) {
    HAL_UART_Transmit(&huart1, (uint8_t *)msg, len, HAL_MAX_DELAY);
    if (HAL_OK != state) {
        LED1(1);
    }
}

当我尝试绕开printf/fputc机制时,上述重复打印的现象没有了,只是存在轻微的字节丢失,一段日志如下,

本文重点关注,数据重复现象。但我还是忍不住的去看看,这里为啥会有少量丢失。上述代码执行过程中,通过Watch窗口的监视debug_total_usart2_sended 和 debug_total_usart3_recved 都是 5373 正常字节数,不多不少。据此分析,上述丢字节现象的最可能的原因还是在 USART1上,我们在后续章节会继续探讨,这里先到这。让我们回到主问题上:

直接使用串口发送HAL_UART_Transmit,不会出现重复数据的问题。如此说来,问题就在printf或fputc上了。而基于前文的实验,我们也早就证明了 printf 函数我们只传递给了它 5373字节,但是它的底层 fputc 却被调用了 6831次。分析到这,问题就被锁定出来了,就是printf的问题,要么是函数实现有问题,要么是该函数调用使用有问题。

printf函数实现原理

难道C标准库中有什么数据缓冲区?或者有什么其他不为人知的实现错误,被我触发啦!哈哈,想多了。我之前有篇尘封好几年的更老的文章(未发布),我还分析过Linux中的printf的实现过程。翻出其中的一段来,品味下fprintf的实现机制,尤其是%s字符输出,

cpp 复制代码
//对外接口
int printf(const char *format, ...);
//在printf中通过va_list解析调用
printf_core(const char *format, va_list arg_list) { 
   ...
   // 根据格式说明符处理不同类型
   switch (spec.type) {
       case 's': {
           //字符串处理
           char *str = va_arg(arg_list, char*);
           total_chars += process_string(buffer, &buf_index, str, spec);
           break;
       }
       ...
}
cpp 复制代码
int process_string(char *buffer, int *buf_index, char *str, FormatSpec spec) {
    int chars_written = 0;
    int len = 0;
    char *original_str = str;  // 保存原始指针
	
	#if 0
    // ==== 关键点1:strlen()如何检测'\0' ==== //手动实现strlen逻辑(标准库通常用汇编优化)
    while (*str != '\0') {  // 关键:逐字节检测结束符
        len++;
        str++;
    }
    str = original_str;     // 重置指针到字符串开头
    #else
    len = strlen(str);
    #endif

    //输出字符串内容
    while (len--) {
        ..
        //调用fputc函数
        flush_buffer(buffer, buf_index);
        ...
    }
    ...

C字符串, 缓冲区要有+'\0' 缓冲区要有+'\0' 缓冲区要有+'\0' 重要的事情说3遍。

啊,啊,啊! 真是要命,拍自己两个耳光,然后我就不想往下写了。但我还是有疑问。是,我的接收缓冲区没有加C字符串的技术符,printf的执行过程,越界访问啦,但是被越界的字符竟然是一些合理的字符,不是乱码啊?200包数据发送结束后,这块内存状态如下,

如上,printf 确实没有运行错,100字节后,内存数据的确是规整的27字节数据,之后是 00 结束符号。

确定问题并修正

错误归错误,我新的问题在于,溢出访问是多么危险的事情,HAL不太可能意外给我填充上述区域!!那?只能是我字节搞的。

我给接收过程设置的数据缓冲区大小是100字节,其定义为 uint8_t dtbuff_usart3_recv [100]; 为啥地址上100字节以外也被赋值了呢?

操作 HAL_UART_Receive(&huart3, dtbuff_usart3_recv, sizeof(dtbuff_usart3_recv), 1000); 干不出这事情来的,我确信。

无巧不成书

接下来我们重点关注,dtbuff_usart3_recv的一举一动。在首次调用HAL_UART_Receive前,看dtbuff_usart3_recv内存情况,

如上图,dtbuff_usart3_recv 这100字节空间之后,是 u2 send u3 recv, n 0000...

而 HAL_UART_Receive 第一次执行完成后,dtbuff_usart3_recv 这100字节空间之后,是 u2 send u3 recv, n 0003...

恍然大悟啦。我靠,这块区域不就是 dtbuff_usart2_it_send[32] 变量的内存区域地址吗!0x20000084 + 4 == 0x200000848

差点疯了。这么个错误,诓骗了自己大半天。

正确的代码

cpp 复制代码
//定义串口3接收数据区
#define USART3_RECV_SIZE   100
//+1是为了存放C串结束符号
uint8_t dtbuff_usart3_recv [USART3_RECV_SIZE+1];
//定义串口2中断发送缓冲区
uint8_t dtbuff_usart2_it_send[32];
//串口2发送计数
static int count_usart2_sending = 0;

int main(void) {
	...
  //开启串口2中断发送/串口3轮询接收
  #if SWITCH_USART2_IT_SEND_USART3_LP_RECV
  //初始化串口3接收数据缓冲区
  dtbuff_usart3_recv[sizeof(dtbuff_usart3_recv)-1] = '\0';
  //构建待发送数据
  volatile int len = build_sending_data(dtbuff_usart2_it_send, 2, 3, &count_usart2_sending);
  //统计已发送数据
  debug_total_usart2_sended += len;
  //统计USART2发送数据
  HAL_UART_Transmit_IT(&huart2, dtbuff_usart2_it_send, len);
  #endif
  while (1)  {
        //清空接收数据缓冲区/101字节
	    memset(dtbuff_usart3_recv, '\0', sizeof(dtbuff_usart3_recv));
	    //串口3轮询接收(串口2中断发送)/本函数是阻塞的哈
	    volatile HAL_StatusTypeDef u3RecvState = HAL_UART_Receive(&huart3, dtbuff_usart3_recv, USART3_RECV_SIZE, 1000);
	    if (HAL_OK != u3RecvState) {
	        //printf("Failure Usart3 RecvState:%d\r\n", u3RecvState);
	    }
	    //usart3实际接收到的字节数
	    unsigned int usart3_recv_real = 0;
	    #if 0
	    if (u3RecvState == HAL_TIMEOUT) {
	        // 计算实际接收字节数 = 目标长度 - 剩余未接收计数
	        debug_total_usart3_recved += (USART3_RECV_SIZE - huart3.RxXferCount);
	    }
	    else {
	        debug_total_usart3_recved += USART3_RECV_SIZE;
	    }
	    #else //等效实现
	    usart3_recv_real = strlen((char*)dtbuff_usart3_recv);
	    #endif
	    //累加USART3接收到的总字节数
	    debug_total_usart3_recved += usart3_recv_real;
	
	    #if 0 //可以等效于直接串口输出
	    printf("%s", dtbuff_usart3_recv);
	    #else  
	    if (usart3_recv_real > 0 )
	    DebugPrint((const char *)dtbuff_usart3_recv, usart3_recv_real);
	    #endif
	    
	  }
  }

遗留问题

在前面的<怀疑C库有缓冲异常>小节中,我们还有个遗留的问题,直接调用串口1的循环发送函数,而绕过fputc函数时,出现了少量的丢数据。后来在理论和实践的双重加持下,证明这是错觉,这与是否绕过printf没有关系。实验中我使用的是2000000bit/s的高波特率,使用修正的USART3数据接收缓冲区,无论是使用printf还是使用DebugPrint直接发送过程,都会有极少字节丢失的情况偶发。以下就是printf实测,

后续我使用DebugPrint直接打印进行了部分实验,已完成和确定的是,我们的串口3接收了5373个完整的字节,即传递给HAL层的,

HAL_UART_Transmit(&huart1, ...

以上函数的实参数据字节数,是多不少的,

PC终端接收显示偶尔的丢失1-3个字节,大多时候是1个字节都不丢失的。我此时尚不确定,该异常的具体原因,只是猜测问题的最大可能点还是在 USART1上。我会在后续文章中继续深入分析。后续实验还是要注重放大关注的实验现象,减少实验因变量。如,

使用定时器单独测试串口1,避免串口3接收丢失,或串口2发送丢失的可能性。

降低串口1的波特率,试图放大丢失现象。(不过,非高速下,问题不一定复现哈。那样的话就真得考虑时钟偏差了,可能某个周期上的高电平持续太短,没有被检测到,或者上位机的USB驱动运行有波动...)

实验中的高波特率之下,丢失现象为随机丢失,其中较为可能的原因之一是,USART1和PC-USB串口之间的时钟偏差。

相关推荐
药9555 小时前
STM32开发(中断模式:外部中断、串口中断)
stm32·单片机·嵌入式硬件
兆龙电子单片机设计6 小时前
【STM32项目开源】STM32单片机厨房安全监测系统
stm32·单片机·物联网·开源·自动化
谁刺我心7 小时前
STM32环境配置keil5【保姆级】
stm32·keil5
一枝小雨15 小时前
【DMA】深入解析DMA控制器架构与运作原理
stm32·单片机·嵌入式硬件·系统架构·dma·嵌入式·arm
翰霖努力成为专家19 小时前
STM32,新手学习
stm32·嵌入式硬件·学习
华清远见IT开放实验室21 小时前
华清远见携STM32全矩阵产品及创新机器狗亮相2025 STM32研讨会,共启嵌入式技术探索新程
linux·人工智能·stm32·单片机·嵌入式硬件·虚拟仿真
LeenixP1 天前
STM32H750xx【QSPI】轮询方式读写GD25Q64E
c语言·stm32·嵌入式硬件·cubemx·stm32h7·keilmdk
小莞尔1 天前
【51单片机】【protues仿真】基于51单片机心形流水灯系统
c语言·stm32·单片机·嵌入式硬件·51单片机
沐欣工作室_lvyiyi1 天前
基于单片机的老年人身体健康蓝牙监测手环(论文+源码)
stm32·单片机·嵌入式硬件·毕业设计·老年人监测