STM32串口printf重定向与scanf输入实战:USART2完整指南
2026/9/8 3:37:46 网站建设 项目流程

简介:神舟四号开发板上的STM32串口通信程序,基于标准库函数实现了UART2的Printf输出和scanf输入,面向正在学习串口通信、希望掌握标准库重定向以及中断接收处理机制的嵌入式开发者。程序压缩包共一百六十一个文件,包括汇编源文件、头文件、C源文件等程序源码,以及编译生成的HEX、AXF、MAP等工程文件,同时附带说明文档和清理脚本,整体体积约三点零七兆字节。目前已有八百六十四人学习下载。程序亲测可用,通过将标准输入输出重定向到第二个串口,并利用接收中断完成数据缓冲与解析,完整演示了从引脚配置、串口初始化、波特率与校验位设置,到数据收发、错误处理以及调试工具配合使用的全过程,支持全双工通信,可灵活配置常用串口参数。这套资源还提供详细的讲解文档和可复用的工程模板,适合对照神舟四号开发板动手验证,也有助于读者在此基础上扩展实现更复杂的串口通信功能。 做STM32串口调试的时候,我见过太多人在printf重定向这一步卡到怀疑人生——网上代码拷下来烧进去,串口助手就是不出字;好不容易printf能用了,scanf又直接卡死。这篇东西基于神舟IV号(STM32F103ZET6库函数版)实测通过,把UART2的printf输出和scanf输入完整走一遍,从原理到代码到串口助手设置全讲清楚。适合刚学STM32、正在做串口交互或者准备写命令行菜单的读者参考,保证你照着敲完就能跑。

1. 先搞明白:为什么默认的printf和scanf在STM32上纯粹是“哑巴”

很多初学者第一次碰这个需求时,都会在main里顺手写一句printf("Hello World\r\n"),然后发现串口助手上一片寂静,于是开始怀疑是串口没初始化,还是硬件坏了。其实根子出在stdio库和单片机之间的“断连”上。

1.1 标准C库的输入输出到底走的是哪条路

在PC上写C程序时,printf会把字符通过一个叫stdout的流送到屏幕,scanf从stdin流里读键盘输入。这两条流的底层,是操作系统帮你把“文件描述符”和键盘、显示器关联好了。可STM32上压根没有操作系统管这摊事,标准库里的printf内部最终会调用fputc往外吐字节,scanf内部调用fgetc从外部取字节——问题是,这两个函数默认不知道你的串口在哪。

所以“重定向”的本质就是:我们自己实现fputc和fgetc,让printf输出的每个字符都走串口发送寄存器,让scanf需要的每个字符都从串口接收寄存器拿。听起来简单,但里面有个巨大的暗坑,就是半主机模式。

1.2 半主机模式:程序莫名卡死的头号嫌疑犯

ARM的C库在调试环境下,默认会把fputc、fgetc这类底层函数交给“半主机模式”去处理,也就是通过调试器把输入输出重定向到开发电脑的IDE终端上。你如果在开发板上直接跑这个逻辑,程序执行到printf时,会跳到一个BKPT 0xAB断点指令上等调试器响应,没有调试器连接,它就死等,表现就是运行卡死、现象诡异。

对付半主机有两条路:一是在Keil里勾选Use MicroLIB,微库默认关闭半主机,是绝大多数人走的捷径;二是自己实现_sys_exit等半主机相关函数,把依赖彻底掐断。我的结论是:做串口控制台、命令行交互这种场景,直接用微库最省心,坑最少。

2. 神舟IV号上USART2的硬件连接与初始化配置

标题既然点名了UART串口2,神舟IV号这块板子默认就是把板载CH340 USB转串口接到了USART2上。所以第一步不是写代码,而是确认硬件通路,不然后面一切白搭。

2.1 引脚和跳线帽:最容易忽略的物理链路

USART2对应的引脚是PA2(TX)和PA3(RX)。神舟IV号板载的CH340芯片与STM32之间一般有跳线帽或拨码开关控制连接位置,你要确保跳线帽短接在USART2这一侧。不同批次的丝印可能标的是COM2或者UART2,操作之前看一眼板子背面的原理图最稳妥。

另外要注意CH340那边是TTL电平,直接连STM32没问题,但别把电脑串口线乱插到RS232接口上,电平不匹配会烧芯片。用USB线连板子上的USB转串口口就行,电脑会识别出一个COM口,设备管理器里看到CH340或者对应驱动正常就算通路没问题。

2.2 时钟树决定串口能不能跑起来

STM32F103ZET6里,USART2是挂在APB1总线上的,而GPIOA挂在APB2上。这意味着初始化时要分清是哪条总线:

  • GPIOA时钟:RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE)
  • USART2时钟:RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE)

很多人的USART2怎么都调不通,就是把USART2的时钟用APB2开,结果寄存器写进去根本没反应。APB1的最高频率是36MHz,USART2挂在APB1上,所以你计算波特率分频时用的总线时钟是36MHz,不像USART1挂在APB2上是72MHz。库函数封装好了这些细节,但你不理解时钟树,写到其他型号或者自己算分频时就容易出乱子。

2.3 GPIO复用配置和串口参数

PA2和PA3要配置成复用推挽输出和浮空输入:

GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); // TX PA2 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); // RX PA3 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitTypeDef USART_InitStructure; 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); USART_Cmd(USART2, ENABLE);

通信参数就定为115200、8位数据、1位停止位、无校验、无流控,这是串口助手最通用的设置,也是我实测最稳的组合。注意USART_Cmd这一步别漏,漏了你前面全白写。

3. 重定向fputc:让printf开口的两条路线

printf能不能输出,拼的就是fputc写得够不够干脆。

3.1 微库路线:Keil里打个勾,代码最简单

用微库的前提是你在Keil的Options for Target里,Target页的Code Generation分组下勾选了Use MicroLIB。然后实现一个fputc:

int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET); USART_SendData(USART2, (uint8_t)ch); return ch; }

逻辑只有三步:等上次发送完、把字符丢进发送寄存器、返回原值。TXE标志位表示发送数据寄存器空,只有空了才能写入下一个字节,否则会把上一个还没发完的数据覆盖掉。这个等待是关键,不加的话快速连续printf可能出现丢字、乱码。

3.2 标准库路线:关掉半主机才敢上

如果你就是不想用微库,想在标准库环境下跑,那就必须实现几个半主机相关的函数,否则程序跑进printf直接HardFault。标准的做法是:

#pragma import(__use_no_semihosting) struct __FILE { int handle; }; FILE __stdout; void _sys_exit(int x) { x = x; } void _ttywrch(int ch) { ch = ch; } int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET); USART_SendData(USART2, (uint8_t)ch); return ch; }

这一套东西里__use_no_semihosting是核心,告诉链接器我不要半主机支持,遇到相关调用你自己消化掉。相比之下微库路线省去了这些繁琐定义,但微库也有一些限制,比如对某些C99特性的支持弱一点,在STM32这种资源敏感的场景下完全够用。我的经验是:用微库,把精力花在功能上,别跟底层较劲。

3.3 printf的换行问题:为什么你的输出总是顶格

重定向完成后,第一句测试建议写printf("Hello STM32\r\n"),注意后面带的是\r\n,不是裸的\n。串口助手上,换行需要回车\r把光标拉回行首,再换行\n。只发\n的话,很多串口助手显示时不会自动回到行首,输出会变成一行叠着往右顶,看起来就像没有换行一样。这个细节能避免你反复怀疑代码有问题。

4. 重定向fgetc:scanf能正常工作的分水岭

printf搞定了,以为scanf照葫芦画瓢就行?天真。scanf比printf多了一层“输入解析”和“回显”的逻辑,坑更集中在这里。

4.1 fgetc的实现:读一个字符,顺手回显

int fgetc(FILE *f) { uint8_t ch; while (USART_GetFlagStatus(USART2, USART_FLAG_RXNE) == RESET); ch = (uint8_t)USART_ReceiveData(USART2); while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET); USART_SendData(USART2, ch); return (int)ch; }

和fputc反着来:等RXNE标志位置位,也就是串口收到了一个字节,然后从接收寄存器取出来,再把它原样发送回去。这个“回显”不是闲得没事干,是因为串口的输入输出是独立的物理通道,你不主动发送回去,串口助手上就看不到自己敲了什么,输入体验极其别扭。回显做好之后,串口助手上的表现就是你发送什么,它就原样显示什么,跟本地终端一模一样。

4.2 scanf的阻塞等待和“发送新行”设置

scanf是阻塞式的,它会一直等在fgetc里,直到拿到满足格式要求的字符。比如scanf("%d", &num)会先跳过空白字符,然后收集连续的数字字符,碰到非数字字符才停下来。

这里有个常见的翻车现场:串口助手发送“123”过去,scanf拿到1、2、3之后就一直在等第4个字符,因为%s、%d这类格式需要“结束标记”才能返回。解决办法是串口助手里勾上“发送新行”,让它在数据末尾自动补\r\n,scanf拿到回车就知道输入结束了。不同串口助手这个选项名字不一样,XCOM里叫“发送新行”,sscom里叫“加回车换行”,本质都是同一个东西。

4.3 %c和%s的特殊情况:回车、缓冲、缓冲区溢出

用scanf("%c", &ch)接收单个字符时,回车符\r也可能被当成一个字符存进去。如果你发送的结尾带了\r\n,那scanf读完目标字符后,回车符还留在接收FIFO里,下一个scanf会直接读到这个残留的\r,表现为跳过输入、表现错乱。处理办法是在调用%c之前自己清空接收缓存,或者在fgetc里主动忽略掉\r\n,把回车换行滤掉:

if (ch == '\r' || ch == '\n') ch = 0;

但这么干会影响scanf对数字输入的结束判断,需要根据实际场景取舍。我在命令行菜单里一般不用%c,全部用字符串+解析的方式,干净利落。

%s还有缓冲区溢出的问题,scanf("%s", buf)不管你buf多大,它都往死里写。安全写法是限制宽度:

char buf[32]; scanf("%31s", buf);

这样最多读入31个字符,留一个位置给结束符。单片机资源本来就紧张,这块地方值得多花两分钟写严谨。

5. 完整可运行代码:从初始化到命令行交互一气呵成

把上面这些组合起来,我给出一份亲测通过的完整示例。开发环境是Keil MDK,标准外设库,勾选MicroLIB,芯片为STM32F103ZET6,串口用USART2。

#include "stm32f10x.h" #include <stdio.h> #include <string.h> void USART2_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); 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); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); 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); USART_Cmd(USART2, ENABLE); } int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET); USART_SendData(USART2, (uint8_t)ch); return ch; } int fgetc(FILE *f) { uint8_t ch; while (USART_GetFlagStatus(USART2, USART_FLAG_RXNE) == RESET); ch = (uint8_t)USART_ReceiveData(USART2); while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET); USART_SendData(USART2, ch); return (int)ch; } int main(void) { int num; char name[32]; USART2_Init(); printf("STM32 UART2 printf/scanf demo\r\n"); printf("请输入一个整数: "); scanf("%d", &num); printf("\r\n你输入的整数是: %d\r\n", num); printf("请输入你的名字(最多31个字符): "); scanf("%31s", name); printf("\r\n你好, %s!\r\n", name); while (1) { } }

注意scanf("%d", &num)之后,我在printf前面加了\r\n,是因为scanf在读数字时,'\r'会被当成结束字符消费掉,但'\n'很可能残留在FIFO里,如果不先换一行,接下来的printf输出会跟输入内容挤在同一行。实测中发现这个细节能避免很多看着像格式混乱的问题。

串口助手这边这么设置:波特率115200,8N1,打开串口,发送区勾上“发送新行”。操作顺序是:先给板子通电,打开串口助手,然后按一下板子上的复位键,让程序从头跑一遍,这样你能完整看到printf的输出序列和scanf的等待状态。

6. 踩坑实录:从卡死到中文乱码的病根排查

这份代码能跑通,不代表你不会在扩展时踩坑。把我在神舟IV号上实际遇到的高频问题列一下,按出现的可能性排序,供你排查时对照。

现象根因解决
printf后程序死循环没有勾选MicroLIB,半主机模式未关闭勾选Use MicroLIB,或实现__use_no_semihosting相关函数
串口助手无任何输出USART2时钟错用APB2改成RCC_APB1PeriphClockCmd
输出乱码或中文乱码波特率不匹配 / Keil源文件编码与串口助手编码不一致统一115200,源文件用UTF-8编码,串口助手对应设为UTF-8
scanf卡住不返回串口助手未勾“发送新行”勾选发送新行,让数据以\r\n结尾
输入字符重复显示两次fgetc里做了回显,串口助手又开了本地回显关掉串口助手中的本地回显选项
scanf读数字读到一半不对发送的数据里混入了不是数字的字符确认发送内容,避免中文输入法或多余空格

6.1 中文乱码这个老大难

STM32串口输出中文乱码,九成是编码不一致造成的。Keil MDK老版本默认源码是ANSI编码(在简体中文Windows下就是GBK),而很多串口助手默认按UTF-8解码;或者反过来,源码是UTF-8,串口助手按GBK解码,都会乱码。

处理思路很直接:让源文件编码和串口助手解码保持一致。我的习惯是Keil里把源文件保存成UTF-8格式(Edit -> Configuration -> Encoding选UTF-8),串口助手用SSCOM或者Vofa这类支持UTF-8的工具。还有一个土办法,就是printf里的中文字符串全部用英文,彻底绕开编码问题。

6.2 从USART2换到其他串口的改法

如果你用的是别的开发板,想把代码移植到USART1,只需要改三处:一是RCC时钟,USART1挂在APB2上,要改成RCC_APB2PeriphClockCmd;二是GPIO引脚,USART1的TX是PA9、RX是PA10;三是函数里所有USART2改成USART1。其他逻辑不用动,重定向代码是跟着串口句柄走的。

6.3 中断接收和scanf的取舍

串口项目玩深了之后,你会发现scanf这种阻塞式输入在带操作系统或者带中断处理的工程里并不好用。我在状态机或者复杂交互的固件里,更常用USART接收中断+环形缓冲区,配合解析函数来替代scanf。但初学者从scanf入手理解串口输入的概念是完全正确的路径,它把“从物理层拿字节”和“格式解析”两个层次解耦得很清楚,弄懂scanf的阻塞机制,后续理解DMA加空闲中断的接收方案会轻松得多。

最后再分享一个实际调试的小技巧:在写交互程序前,先把printf输出一个系统启动时间戳或者一段固定测试文字,确认输出链路完全稳定之后,再往上面叠加scanf逻辑。串口调试本质上是减少变量,一次只验证一个环节,出问题定位起来才快。这套代码我在神舟IV号上裸机跑通过,你换个F103系列的板子,只要引脚和时钟对应改好,同样能跑。

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

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

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

立即咨询