☰
STM32 USART2重定向printf与scanf实战避坑指南
2026/10/15 17:04:21 网站建设 项目流程

简介:基于意法半导体32位微控制器库函数版的串口二通信示例工程,面向需要掌握串口打印与输入解析能力的嵌入式开发者。工程围绕神舟四号开发板,完整演示了打印函数重定向到串口二、以及输入函数经串口接收数据的中断处理流程,同时覆盖波特率、数据位、停止位、奇偶校验等串口参数配置,并给出了底层库函数调用示例,适合对照程序逐行学习。资源包共有一百六十一个文件,压缩包大小为三点零七兆字节,主体为源文件,包括三十二个汇编文件、二十九个头文件、二十八个C语言文件,另有集成开发环境工程配置、编译中间产物及说明文档,整体目录结构清晰,可直接导入工程查看运行。目前已有八百六十六人学习下载,对入门串口通信、理解标准输入输出重定向机制具有直接参考价值。工程代码经过验证、注释较为完整,能够帮助读者快速搭建串口调试环境,掌握串口初始化、中断收发、数据缓冲等关键环节,并用于排查实际项目中串口通信的常见问题。

1. 串口2的Printf和scanf:为什么翻车率这么高

多数人第一次在STM32上重定向printf,选的是串口1,因为网上例程铺天盖地,照抄就能过。等你把同样的套路改到串口2,事情就变味了:要么printf完全没输出,要么scanf一跑到就卡死,甚至打开调试器发现程序死在某个系统调用上。这个标题讲的就是在神舟IV号开发板、标准外设库(库函数版)的条件下,把USART2的printf输出和scanf输入彻底跑通。你会发现真正的坑不在fputc本身,而是时钟总线、引脚复用、微库开关和串口助手的换行设置。能解决什么问题:调试信息从串口1换到串口2,串口助手交互输入数字和字符串,不再被玄学问题挡住。适合谁:正在用库函数版做开发、想给串口2加标准输入输出能力的人。

2. 先把USART2的底子打好:时钟、引脚、结构体配置

2.1 神舟IV号上USART2的硬件连接:PA2/PA3和板载串口的关系

USART2默认复用引脚是PA2(TX)和PA3(RX)。这句话看起来简单,实际很多人栽在引脚上:有的板子把USART2引到了RS232电平的DB9接口上,要在跳线帽或排针位置确认正确的串口通路;另一些板子直接把USART2接到板载USB转串口芯片,此时只能看到USB口,不要找串口2插针。我拿到神舟IV号这类开发板时,习惯先看板子背面丝印,找到标注UART2或者USART2的丝印区域,再顺着走线判断PA2、PA3是否连通到某个排针、跳线或USB转串口芯片上。

接线时有三个细节值得注意。第一,这是TTL电平的串口,不能直接接RS232设备,也不要把PA2/PA3接到24V之类的电平上,否则板子必烧。第二,如果外接USB转TTL模块,模块的RX接板子的TX(PA2),模块的TX接板子的RX(PA3),并且地线必须共地。第三,神舟IV号上其他串口(比如串口1)可能占用了PA9/PA10,串口2复用PA2/PA3,这两个引脚如果同时被板载Flash、LCD等器件占用,需要看原理图上的跳线断开冲突。常见的做法是跳线帽断开LCD的片选和数据线,只为串口留下通路。

动手前建议做一个最小验证:板子不上电,用万用表二极管档测PA2和PA3对GND是否正常,无短路就说明硬件通路大概率没有问题。接着把USB转TTL模块接好,打开串口助手,波特率先随便设,用手触摸PA2附近,看串口助手有没有噪声波形。这个步骤能帮你区分是硬件接线问题还是软件配置问题,省去后面一大半排错时间。硬件就绪后,下面配置库函数就有一个明确的检查点了。

2.2 库函数初始化三分步:RCC时钟、GPIO复用、USART结构体

用标准外设库配置USART2,代码主角其实是三段:时钟使能、GPIO初始化和USART初始化。时钟部分最容易出问题,因为USART2和USART1不在同一条总线上——USART1挂在APB2(72MHz),USART2挂在APB1(36MHz)。神舟IV号这种典型的8MHz外部晶振方案,系统时钟72MHz时,APB1预分频系数默认是2,所以PCLK1=36MHz。标准外设库在处理波特率时会自己读取RCC时钟树,你不需要手工算BRR寄存器,但前提是时钟使能写对了总线。

下面这段初始化函数是我在模拟项目X里反复用的,直接按顺序调用即可。

void USART2_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; // 1. 使能USART2和GPIOA的时钟 // USART2在APB1总线上,必须走RCC_APB1PeriphClockCmd RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 2. 配置PA2为USART2_TX:推挽复用输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); // 3. 配置PA3为USART2_RX:浮空输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); // 4. USART2参数:115200-8-N-1,收发都开 USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_RX | USART_Mode_TX; USART_Init(USART2, &USART_InitStructure); // 5. 使能串口 USART_Cmd(USART2, ENABLE); }

这段代码里有几个参数值得按你的板子和需求改动。GPIO_Speed_50MHz是常用的最高速度等级,低速也能工作,但APB2外设的GPIO翻转速率需要匹配,我一般统一用50MHz,不在这上面省。GPIO_Mode_AF_PP必须和GPIO_Mode_IN_FLOATING成对出现:TX引脚要能推挽驱动总线,所以是复用推挽输出;RX引脚由对方芯片驱动,配置成浮空输入最直观,不加上拉也不影响3.3V TTL电平的接收。USART_Mode_RX | USART_Mode_TX是两个都用时标准写法,如果你只做printf输出,可以去掉RX,但后面scanf输入还需要,所以收发都开。波特率115200是调试默认值,如果接的模块或者蓝牙设备要求9600,直接改这个数字就行,库函数会自动重新算BRR。

除了这段配置,主函数里不需要额外开启USART2中断。printf和scanf用的是查询方式,也就是fputc/fgetc里的while等待标志位,和中断无关。如果你之前跑过中断接收例程,记得把USART_ITConfig(RXNE中断)的调用注释掉,否则中断服务函数没写时程序会进Default_Handler,表现也是卡死。这一点新手经常忽略,我会在后面的避坑章节再展开。

2.3 初始化完成后怎么验证:回环测试和示波器观察

配置代码写完,不要直接上printf,先做回环测试。回环测试是把TX和RX短接,让串口自发自收,验证USART2底层的收发通路、时钟和波特率都没问题。做法:用一根杜邦线把PA2和PA3短接,然后在主函数里发一个固定字节,读回并判断是否相同。

uint8_t test_byte = 0xA5; USART_SendData(USART2, test_byte); // 等待数据发送完成:TC标志表示移位寄存器腾空,字节已经出现在TX引脚上 while (USART_GetFlagStatus(USART2, USART_FLAG_TC) == RESET); // 等待接收完成并读回 while (USART_GetFlagStatus(USART2, USART_FLAG_RXNE) == RESET); uint8_t received_byte = USART_ReceiveData(USART2); if (received_byte == test_byte) { // 回环通过,说明USART2硬件通路正常 }

这个测试里我特意用了TC标志而不是TXE标志。TXE只表示数据寄存器空了,数据可能还在移位寄存器里没有发完;TC表示发送移位寄存器真正腾空。回环测试里短接的物理跳线很近,TX引脚发出一字节后RX瞬间收到,但如果用TXE,紧接着立刻发下一个字节时可能把前一个字节冲掉。debug串口场景下影响不大,但养成用TC的习惯能少踩很多坑。回环通过后,把短接杜邦线拿掉,再连USB转TTL模块到电脑,就可以进入printf重定向环节了。

如果回环测试收不到数据,优先检查两件事:一是时钟使能是否写成了APB2,二是PA2/PA3是否真的短接到位。现象上,时钟写错时波特率会整体偏移,示波器看TX引脚有波形但周期不对;引脚没短接时TX有波形,RX完全无信号。用示波器或者逻辑分析仪在PA2上测量是最快的判断手段,没有示波器就老老实实检查接线。这个阶段确认越仔细,后续printf和scanf出问题时的排查范围就越小。

3. 重定向Printf到串口2:fputc、微库和一段能抄的代码

3.1 重定向原理:C库的printf最终调用了什么

printf本身不认识串口,它做的事情是把格式化字符串转换成一串字符,然后逐个字节丢给底层输出函数。在Keil MDK的C运行库里,这个底层输出函数是fputc。C库对fputc有个默认实现,默认实现面向调试器半主机(semihosting)环境,也就是说它不往串口写,而是通过调试端口把字符送给IDE。所以如果你不做任何重定向,用串口助手永远看不到printf的输出,程序还可能卡死在一条BKPT指令上等调试器响应。

重定向的本质很简单:让fputc不再使用C库的默认实现,而是把每个字符通过USART2的发送寄存器发出去。函数原型是下面这样:

int fputc(int ch, FILE *f);

注意函数名必须是fputc,参数和返回值类型不能改。ch就是printf格式化后的每一个字符,返回值C库会用于错误检查,你直接返回ch即可。f参数指向当前输出流,重定向单串口时可以完全忽略。库函数版工程和HAL版工程在这个函数上几乎一样,区别只在于发字节时用库函数还是直接写寄存器,我后面给的是标准外设库的写法。

在Keil MDK里,让fputc重定向生效有一个前置条件:工程必须勾选Use MicroLIB微库。这是很多人printf突然失效的隐性原因之一,和串口几其实没关系,只是换到串口2时更容易让人误以为是引脚问题,从而绕远路排查。还有一个更容易被忽略的现象:如果工程没勾选微库,printf不一定会报编译错误,但运行时会卡死或没有任何输出。原因就是标准C库的半主机输出路径没有走fputc,或者走了但被调试器拦截。所以下一步先检查这个开关。

3.2 Keil工程里打开微库:这一步不做后面全是玄学

在Keil MDK中,点击Options for Target,进入Target标签页,勾选Use MicroLIB,点OK。就这么简单。勾选微库后,C库体积大幅缩小,printf等函数不再依赖半主机,fputc重定向生效,运行裸机工程时也不会因为堆初始化问题死机。代价是浮点格式化能力被裁剪,printf里不能用%f,这个后面会说替代方案。

我这个习惯是在一次串口调试翻车后养成的:当时一个工程从复制过来的例程里继承了标准库配置,fputc写得完全正确,但printf一执行就卡死,调试器停在BKPT指令上,查了半小时才发现是微库没勾。从那以后我新建工程的第一件事就是勾微库。如果你遇到"printf无输出但程序没死"的情况,第一反应也应该是确认这个开关,而不是去改fputc。顺带一提,勾选微库后,scanf的处理逻辑也会变化,字符输入缓冲比标准库更简化,这对串口2的scanf其实是好事,至少没有那么多隐藏缓冲逻辑要考虑。

3.3 fputc重定向代码与参数说明

微库勾好,直接加下面这段代码到任意一个.c文件里。我一般会放到串口驱动同一个文件中,紧跟初始化函数后面,这样串口相关的东西都在一处,维护方便。

#include <stdio.h> int fputc(int ch, FILE *f) { // 等待发送数据寄存器为空(TXE=1),确保上一个字节已经移入移位寄存器 while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET); // 把字符写入发送寄存器,USART外设会自动移位发出 USART_SendData(USART2, (uint8_t)ch); // 按C库约定返回原字符 return ch; }

这里最关键的是TXE标志的while等待。USART2的发送数据寄存器只有8位宽,你连续调用fputc发送多个字符时,如果上一个字符还没从数据寄存器移走,USART_SendData的写入会覆盖它,造成丢字符。有的例程用while(USART_GetFlagStatus(USART2, USART_FLAG_TC) == RESET),标准是TC标志,但fputc场景下TXE足够,因为printf的字符是连续流,TXE表示可以写入下一个字符,如果非要等TC,每次切换寄存器会明显拉低打印速度。我实测在115200波特率下,TXE模式printf全速输出不丢字符,但如果你对单字节精确控制有要求,用TC更稳。这个选择按场景来,我的建议是输出大段日志时用TXE,单字节精确控制时用TC。

重定向写好后,主函数里测试,确认串口2能打印。测试代码:

int main(void) { USART2_Config(); printf("USART2 printf test\r\n"); printf("System clock test: %lu\r\n", (unsigned long)SystemCoreClock); while (1); }

串口助手选115200-8-N-1,应该看到两行输出。如果看到乱码或第一行前几个字符丢了,重点检查波特率匹配和APB1时钟分频。这里%lu对应unsigned long,是为了避免在微库下使用%u时输出异常,微库对某些格式符的处理和标准库有差异,我习惯统一加类型转换,能省不少事。

3.4 多串口下临时切printf输出目标的做法

开发后期经常有这样的需求:调试串口是串口1,外接的传感器模块在串口2,两个口都想要printf调试。直接在fputc里写死USART2显然不灵活,所以我用一个可切换的静态变量保存当前printf目标串口,用库函数指针或者一个简单的switch来路由字符。代码不长,但能让你在几个串口之间切换时不用反复改源码:

static USART_TypeDef *s_printf_port = USART2; void Printf_SetPort(USART_TypeDef *uart) { if (uart == USART1 || uart == USART2 || uart == USART3) { s_printf_port = uart; } } int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(s_printf_port, USART_FLAG_TXE) == RESET); USART_SendData(s_printf_port, (uint8_t)ch); return ch; }

使用时,先初始化要切换的串口,然后调用Printf_SetPort(USART1)让printf临时改到串口1输出,用完再切回USART2。需要注意:切换前确保目标串口已经初始化,否则while会在一个没有使能的串口上死等。另外如果是调试串口和业务串口之间频繁切换,我建议还是各写各的发送函数,printf只留一个口,否则日志和业务数据混在一起,串口助手里看到的是一锅粥。这个函数的价值在于调试早期快速验证不同外设通路,不是给产品代码用的。

4. scanf输入到串口2:fgetc重定向与回车缓冲的账

4.1 fgetc重定向代码:让scanf能读串口

printf解决了,接下来是scanf。scanf在C库中通过fgetc从输入流逐个取字符,和fputc方向正好相反,fgetc需要等待RXNE标志置位,表示一个字节已经从RX引脚完整接收。重定向代码同样简短:

#include <stdio.h> int fgetc(FILE *f) { // 等待接收数据寄存器非空(RXNE=1),说明一字节已经从RX引脚完整接收 while (USART_GetFlagStatus(USART2, USART_FLAG_RXNE) == RESET); // 读数据寄存器,硬件自动清除RXNE标志 return (int)(USART_ReceiveData(USART2) & 0xFF); }

注意USART_ReceiveData返回的是uint16_t,上电或噪声可能产生无意义高位数据,所以我做了& 0xFF取低8位。返回类型是int,C库的getc约定用int表示字符,不能用uint8_t,否则老版本编译器的fgetc函数签名不匹配。这段代码加好后,main函数里就可以用scanf从串口助手收数据了。测试:

int num; printf("Please input an integer:\r\n"); scanf("%d", &num); printf("Get: %d\r\n", num);

串口助手里输入123并勾选"发送新行"或手动输入123按回车,程序会打印Get: 123。如果你在串口助手发送设置里没有追加回车换行,程序会卡在scanf上,场面就像死机。这里先说结论:使用scanf做串口输入,串口助手的换行设置和scanf的格式控制必须匹配,这是后续所有避坑讨论的前提。

4.2 scanf("%d")读数字为什么卡死:回车和缓冲区的账

很多人的scanf卡死不是fgetc写错了,而是被scanf的读取逻辑困住了。以scanf("%d", &num)为例,它的内部逻辑是:调用fgetc读一个字符,如果是空白字符(空格、\r、\n、\t)就跳过继续读;如果读到数字字符,就开始累加形成整数;一旦读到非数字字符,把整数返回。问题出在"一旦读到非数字字符"这个条件上。如果串口助手发送123后不追加任何字符,scanf读到字符1、2、3,然后继续阻塞在fgetc上等待下一个字符来判断输入结束。下一个字符可能永远不会来,于是在没有回车换行的情况下,scanf看起来就是死机。

解决方式是让串口助手每次发送都带一个明确的结束符。多数串口助手软件里勾选"发送新行"会附加\r\n,这样scanf读到123后遇到\r,知道数字结束了,返回成功。另一个问题是残留字符:数字被%d消费后,\r\n还留在C库的输入缓冲里。下一次调用scanf("%d")时,它会先跳过这些空白字符,所以不影响。但如果你是临时用字符接收接口读数据,或者scanf里用了%c格式,残留的换行就会被当成下一个字符读走。这个细节是新手最常见的问题:第一次输入数字成功,第二次用scanf("%c")去读字符,得到的永远是上次残留的回车。

解决残留字符的一个可靠惯例是:每次scanf前先清一次接收缓冲。清缓冲实际上是把串口数据寄存器里可能残留的字节全部读走。

void USART2_FlushRx(void) { while (USART_GetFlagStatus(USART2, USART_FLAG_RXNE) != RESET) { (void)USART_ReceiveData(USART2); // 读走并丢弃 } }

这个函数在串口调试里的出镜率比想象中高,凡是涉及scanf的地方,前一行加一句USART2_FlushRx();,可有效减少"清不掉旧输入"的玄学问题。注意这里的清空只清硬件数据寄存器里已经收到的字节,如果用户输入了一半还没发到串口,它当然清不到,所以更精确的时机是printf提示用户输入之后、scanf之前。

还有一个很多人没意识到的坑:微库下scanf遇到非数字字符串时的行为。你发abc给scanf("%d"),scanf会读取a,发现不是数字,不消费这个字符并返回0,不执行后续赋值。程序不会死,但变量没被修改,如果你没有检查scanf返回值,后面用这个未初始化变量做逻辑,就会得出莫名其妙的结论。所以好习惯是检查返回值:

if (scanf("%d", &num) == 1) { printf("Get: %d\r\n", num); } else { printf("Input error\r\n"); }

检查返回值是标准做法,在桌面C里大家都会做,到了STM32的串口上却经常被省略。主要原因还是例程太玩具,默认输入一定合法。实际串口环境噪声多、用户误触多,scanf返回0的情况高频出现,这个检查能让你少排查很多逻辑bug。

4.3 用字符方式替代scanf:sprintf配对数据解析

讲清楚上面的坑后,我再给出一个更稳的替代方案。如果你已经有串口接收中断或者主循环轮询,不建议在主逻辑里用scanf,因为它阻塞且格式耦合。更常见做法是一个字节一个字节收,拼到缓冲区里,直到收到\r或\n就组成一行,然后用字符串解析函数把数字提取出来。这样即使串口助手不发送换行,也不会卡死主循环;如果超过时间没收到结尾,还能设置超时放弃。

一个极简的按行解析流程:

#include <stdlib.h> char rec_line[32]; uint8_t rec_len = 0; void USART2_PollInput(void) { // 非阻塞方式查看RXNE,有数据才处理 if (USART_GetFlagStatus(USART2, USART_FLAG_RXNE) != RESET) { char ch = (char)(USART_ReceiveData(USART2) & 0xFF); if (ch == '\r' || ch == '\n') { if (rec_len > 0) { rec_line[rec_len] = '\0'; long val = strtol(rec_line, NULL, 10); printf("Parsed: %ld\r\n", val); rec_len = 0; } } else { if (rec_len < (sizeof(rec_line) - 1)) { rec_line[rec_len++] = ch; } } } }

这段代码放在while(1)里循环调用,每次只处理一个字符。strtol是stdlib.h里的字符串转整数函数,遇到空字符串返回0,遇到"123abc"返回123并把剩余位置指出来。相比scanf,它的优势一是非阻塞,不会在等一个永远不来的字符时拖死主逻辑;二是溢出可控,rec_line有长度限制,不会让用户输了一大串字符撑爆缓冲区。如果你要解析两个整数,可以用sscanf(rec_line, "%d,%d", &a, &b),但sscanf也是库函数,用之前记得rec_line必须以'\0'结尾。

把scanf和字符解析放在一起看,结论是:验证硬件时,scanf+fgetc最直观,一个回车就能看到结果;做实际功能时,用轮询或中断收字节、拼行、解析。不要因为printf在串口上表现不错就惯性用scanf,它在你只想快速验证的时候才是趁手的工具。

5. 串口2重定向的避坑现场:5个能速查的踩坑记录

重定向printf和scanf本身不难,难的是各种环境的组合问题。下面5条记录是按照我调试串口2时踩过的真实坑汇总的,每一条我都先说现象再给原因和解决办法,方便你对照排查。

5.1 现象:printf输出乱码

现象:串口助手能收到数据,但内容是花字符、问号,或者每隔几个字符就插入一个错字节。

原因:最常见是波特率不匹配。USART2挂在APB1上,如果APB1时钟被改成了非36MHz值,库函数算出的波特率也会变;另一种情况是串口助手实际波特率和代码不一致。少数情况是TX引脚复用模式配错,把AF_PP写成了GPIO_Mode_Out_PP,波形边沿变差导致误码。

解决:先用示波器或逻辑分析仪看TX引脚的单字节波形,数一下1位的时间是否等于1/115200秒。如果波形周期明显不对,查系统时钟配置里APB1分频系数;如果波形对但串口助手乱码,把波特率从115200改成9600重试,同时检查串口助手的数据位、停止位和校验位是否都是8-N-1。乱码还有一个隐蔽来源:USB转串口芯片质量差,在115200下波形畸变,降到9600就正常,这种属于硬件瓶颈,不是代码问题。

5.2 现象:scanf一执行就卡死

现象:printf正常,程序跑到scanf("%d", &num)这一行后不再往下走,串口助手发数字、字母都没有反应。

原因:分两种。一是串口助手没有发送换行,scanf在等待一个非数字字符来结束解析,如上章分析;二是fgetc里的while等待RXNE死循环了,同时Rx引脚没有收到有效数据。如果是后者,多半是串口助手的发送脚没有接到PA3,或者USB转TTL模块的TX线没连上。

解决:先用串口助手的"手动发送"功能,勾选"发送新行"后发送123,观察是否有输出。如果没输出,用4.2里的USART2_FlushRx清理后重试。还不行就回头做2.3的回环测试,确认RX引脚在硬件层面能收到数据。我见过一个很刁钻的案例:PA3被板上其他外设拉低,fgetc的RXNE标志永远置不起来,回环测试也失效,最后是断开跳线帽解决的。

5.3 现象:串口1能打印,串口2完全无输出

现象:同一个工程,把串口1的初始化整体改成USART2,串口1输出正常,串口2串口助手什么都收不到。

原因:代码里漏改总线时钟。USART1使用RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE),USART2必须用RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE)。漏掉这行时USART2没有时钟,寄存器写进去也是无效,但编译不会报错。另一个原因是GPIO引脚没有跟着改:串口1用PA9/PA10,串口2用PA2/PA3,很多人只改了串口号,GPIO初始化的Pin还在PA9/PA10上。

解决:对照2.2的代码逐行检查。重点看两行:RCC_APB1PeriphClockCmd里是不是USART2,GPIO_InitStructure.GPIO_Pin是不是GPIO_Pin_2和GPIO_Pin_3。如果这两处都对,再用回环测试确认引脚通路。这里出问题一般都在3分钟内查完,比乱码好排查。

5.4 现象:打开微库后程序跑飞

现象:勾选Use MicroLIB后,原本不勾微库、printf偶尔能用的程序直接HardFault,调试器定位到某个库函数或sprintf调用点。

原因:微库裁剪了很多C库功能,浮点格式化首当其冲。代码里如果有printf("%.2f", val)这类写法,微库不支持,执行到浮点格式化会跑飞。另一个原因是中断里调用printf导致重入:微库对fputc的重入保护比标准库弱,主循环在printf的过程中来了串口中断,中断里又printf,两个上下文同时操作USART2的数据寄存器,会出现未定义行为。

解决:第一,所有格式化输出里不用浮点格式符,把浮点转成整数部分和小数部分分别打印,例如val = 3.14159,打印"3.14"可以拆成整数3和小数1415两个整数。第二,中断服务函数里只做标志位和缓冲区操作,printf放到主循环。如果必须快速输出,写一个独立的中断发送函数,用TXE中断或DMA,不经过printf。这个约束在裸机工程里是铁律,省掉以后很多诡异死机。

5.5 现象:板子复位的瞬间串口收到垃圾数据

现象:每次给板子上电或按下复位键,串口助手都会自动收到一个0x00或0xFF的字节,有时后面还跟着一个0x55,刚好卡在scanf输入之前,导致第一次scanf读到垃圾值。

原因:上电瞬间MCU的引脚电平还没有稳定,RX引脚如果处于浮空状态,可能被旁边走线的噪声触发一个伪起始位,USART误判为一帧数据;也可能是复位时USB转串口芯片产生的电平毛刺。如果是scanf场景,这个垃圾字节会被fgetc读走,然后scanf收到0x00当作输入结束,程序逻辑错乱。

解决:初始化USART2并开启接收后,加一段延时让引脚电平稳定,再清一次RXNE标志和接收缓冲。实用的代码放在USART2_Config的末尾:

USART_Cmd(USART2, ENABLE); delay_ms(50); // 给引脚电平稳定时间 USART2_FlushRx(); // 丢弃上电噪声

如果接收中断已经开启,垃圾字节会触发中断,需要确保中断服务函数里对噪声字符做了过滤。最简单的过滤器是只接受可打印字符和\r\n,其他一律丢弃。这个习惯能防止scanf和后续解析逻辑被上电噪声带跑。另外,如果观察到的垃圾字节是0xFF,检查一下TX线的默认电平,也可能和引脚外部的上拉电阻有关。

上面5条覆盖了我在USART2上遇到的大部分问题。如果你遇到的现象不在其中,一个经验性的排查路径是:先回环验证硬件,再单独验证printf,再单独验证scanf,最后组合。不要两个功能一起调试,否则分不清是发送还是接收的问题。回环测试永远是隔离问题的最快手段,这比反复查寄存器更快。

6. 从阻塞scanf升级:中断接收+缓冲解析的调试方案

6.1 一个能用的USART2中断接收框架

当你的串口2既要做printf调试又要接收命令时,scanf的阻塞模式就不太够用了。原因很简单:scanf执行期间主循环是死的,系统无法响应其他事件。我建议把输入改成中断+环形缓冲,printf保留原样。先使能USART2的RXNE中断,并在NVIC里使能USART2中断通道:

NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel = USART2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure);

然后在主程序里设置环形缓冲区,写中断服务函数:

#define RING_BUF_SIZE 64 static uint8_t ring_buf[RING_BUF_SIZE]; static volatile uint16_t ring_head = 0; static volatile uint16_t ring_tail = 0; void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { uint8_t ch = (uint8_t)USART_ReceiveData(USART2); uint16_t next = (ring_head + 1) % RING_BUF_SIZE; if (next != ring_tail) { ring_buf[ring_head] = ch; ring_head = next; } USART_ClearITPendingBit(USART2, USART_IT_RXNE); } }

主循环里用下面这段把字节取出来做行解析,第4章那套按行解析逻辑可以直接挂在这里,把USART2_PollInput里的RXNE检查改成从环形缓冲取字符:

while (ring_head != ring_tail) { char ch = (char)ring_buf[ring_tail]; ring_tail = (ring_tail + 1) % RING_BUF_SIZE; // 回显、拼行、解析,都在这里做 }

这个框架比scanf硬等RXNE灵活得多:接收一帧数据不会阻塞主循环,缓冲区快满时丢弃最旧数据也不会崩。中断服务函数里尤其不要调用printf,理由在前面已经说过了,重入问题不是玄学,是微库的fputc根本不防重入。

6.2 格式化输出和调试信息的几个顺手技巧

最终工作中真正省时间的是几个输出技巧。第一,用调试开关宏让printf可以一键关闭:

#define DEBUG_ENABLE 1 #if DEBUG_ENABLE #define DBG_PRINT(fmt, ...) printf(fmt, ##__VA_ARGS__) #else #define DBG_PRINT(fmt, ...) #endif

这个宏在AC5编译器下直接可用,DBG_PRINT("temp=%d", temp)展开成printf,把DEBUG_ENABLE改成0后所有调试输出全部消失,不用逐个注释。第二,十六进制打印用%02X,宽度补零让日志对齐。第三,时间戳用tick计数,在每条日志前面打上当前系统运行毫秒数,配合循环缓冲区能定位很多时序问题。这三个技巧用顺手后,printf在串口2上的价值会比scanf大一个量级。

最后说一个我自己的习惯:在串口2上吃过scanf卡死的亏之后,我定了一条规矩——验证硬件时可以用scanf,但凡是会跑超过一周的代码,输入一律用中断接收+行解析,printf只在确定不会重入的地方用。这条规矩帮我避开了很多"偶尔死机、复现不了"的坑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询