文章目录
概述
嵌入式系统中使用串口通过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串口之间的时钟偏差。