嵌入式/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串口之间的时钟偏差。

相关推荐
切糕师学AI5 小时前
STM32是什么?
stm32·单片机·嵌入式硬件
普中科技7 小时前
【普中STM32F1xx开发攻略--标准库版】-- 第 12 章 STM32 时钟系统
stm32·单片机·嵌入式硬件·arm·时钟树·普中科技
齐落山大勇10 小时前
STM32的ADC(遥杆的控制)
stm32·单片机·嵌入式硬件
huaijin62210 小时前
ESP32在arduino环境下的离线安装 -- 理论上多个版本都有效
stm32·单片机·嵌入式硬件
云山工作室15 小时前
基于单片机的牧场奶牛养殖系统设计(论文+源码)
stm32·单片机·嵌入式硬件·毕业设计·毕设
CosimaLi1 天前
STM32F10x硬件I2C
stm32·单片机·嵌入式硬件
GilgameshJSS1 天前
STM32H743-ARM例程36-DNS
c语言·arm开发·stm32·单片机·嵌入式硬件
Jie_jiejiayou2 天前
STM32F10xxx启动模式配置与ISP一键下载
stm32·isp·烧录模式
GilgameshJSS2 天前
STM32H743-ARM例程33-TOUCH
c语言·arm开发·stm32·单片机·嵌入式硬件
hazy1k2 天前
51单片机基础-继电器实验
stm32·单片机·嵌入式硬件·51单片机·1024程序员节