简介:针对STM32神舟IV号开发板的UART2串口通信示例,这是一份基于库函数版工程的完整可运行程序。工程解决嵌入式开发中常见的printf输出与scanf输入重定向问题,通过将标准输入输出映射到UART2,让开发者能像在PC上一样方便地调试串口数据,适合需要掌握STM32串口通信及标准外设库用法的学习者参考。压缩包共161个文件,以C源码(28个.c)、头文件(29个.h)、汇编文件(32个.s)为核心,附带uvproj/uvopt工程配置、hex/axf可烧录固件、doc说明文档以及批处理清理脚本,整体3.07MB,目录结构清晰,便于按需取用。已有864人学习下载,资源内含可直接运行的工程和固件,并保存了调试过程文件。对照文档可深入理解UART2初始化、中断接收、字符输出重定向、波特率设置等关键环节,实际操作时还能看到串口输出并输入数据,从而快速掌握库函数版UART驱动开发要点。 相信不少刚接触STM32的朋友都有过这样的经历:点灯、按键、外部中断都玩得挺顺,结果一到“想用串口打印点调试信息”这一步就卡住了。尤其是习惯了C语言里printf、scanf的写法,到了单片机里却发现printf根本不出来数据,或者一用scanf程序就卡死。我在神舟IV号开发板上用库函数版本把这两个功能完整跑通过,实测稳定,今天把整个实现思路、代码细节和踩过的坑一次性说清楚。
这篇内容的核心就是把串口2(USART2)配置好,然后通过重定向fputc和fgetc,让C标准库的printf和scanf直接工作在串口上。适合正在学STM32标准库、想把调试效率提起来的朋友,也适合那些在串口收发上老是“差最后一步”的新手参考。
1. 项目背景与整体设计思路
1.1 为什么要在STM32上重定向printf和scanf
很多人在电脑上写C语言时,printf就是往屏幕打印,scanf就是从键盘读入,这是标准库干的事。但到了STM32上,标准库并不知道“屏幕”和“键盘”是什么东西,这时候就需要我们告诉它:把串口当成屏幕和键盘。这就是重定向的本质——让标准库的输入输出底层接口,指向我们指定的串口硬件。
有人可能会问:调试的时候直接看变量不就行了,为什么要费劲搞printf?实话说,串口打印在单片机开发里的意义非常大。程序跑飞了、某个变量的值不对、某段逻辑有没有执行到,printf打一行出来比啥都直观。而在交互式调试中,scanf能让你在运行中给单片机发指令、改参数,不用重新编译烧录,这在调PID参数或者协议解析的时候特别省时间。
神舟IV号这块板子用的是STM32F103ZET6,属于增强型系列,资源比较丰富,有5个串口。我选择串口2来做这个功能,主要是它和USB转串口芯片的连接在板子上是现成的,直接用杜邦线或者板载跳线就能跟电脑通信,不需要额外接USB转TTL模块。
1.2 库函数版本与寄存器版本、HAL库版本的取舍
现在网上STM32教程鱼龙混杂,有的是寄存器写法,有的是标准库写法,还有的是HAL库写法。神舟IV号配套的例程是标准的固件库(标准外设库),也就是常说的库函数版本。这个版本虽然官方已经不再更新,但对于学习来说,它的寄存器映射清晰、代码可读性好,比直接操作寄存器省事,又比HAL库的封装更容易理解底层原理。
至于为什么不用HAL库,不是因为HAL不好,而是神舟IV号老开发板的例程都是基于标准库写的,如果强行换HAL库,整个工程结构都要推翻重来。而且标准库的串口配置代码非常直观,GPIO初始化、串口初始化、中断配置三步走,逻辑清楚,特别适合作为理解UART工作原理的入门素材。如果你以后转到HAL库,理解了标准库这套流程,看HAL的UART_Init也就是换个函数名的事。
1.3 USART、UART的区别与串口2的硬件基础
标题里写的是UART,但STM32的串口外设全称其实叫USART(Universal Synchronous/Asynchronous Receiver/Transmitter)。多出来的这个S是指同步模式,也就是可以外接时钟线做同步通信。实际上我们用的异步串口通信,并不需要时钟线,只用TX和RX两根线。这个点很多入门教程会含糊带过,但面试的时候经常被问到。
STM32F103ZET6的USART2引脚是PA2(TX)和PA3(RX),复用推挽输出。需要留意的是,虽然芯片本身支持5V容忍,但从稳定性和电平匹配角度,还是建议按3.3V逻辑来设计,毕竟STM32是3.3V供电的器件。神舟IV号板载的USB转串口芯片一般用的是CH340或类似方案,板上已经做了电平转换,直接接线就能用。
2. 串口2的初始化配置与时钟使能细节
2.1 库函数版本初始化代码的完整骨架
串口初始化的第一步是开时钟。STM32的外设使用前必须开启对应时钟,这一点和8位单片机差别很大。串口2挂载在APB1总线上,所以要使能RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE),同时PA2和PA3是GPIOA的引脚,需要使能RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE)。注意,GPIO是挂在APB2上的,和串口的APB1不是一回事,漏掉任何一个时钟都会导致外设不工作。
然后是GPIO的配置。PA2配置为复用推挽输出(GPIO_Mode_AF_PP),PA3配置为浮空输入(GPIO_Mode_IN_FLOATING)。这里有个细节:TX引脚必须配置为复用推挽,因为要由串口外设控制引脚电平输出;RX引脚因为是接收外部信号,配置为浮空输入即可。有些人把TX误配成通用推挽输出(GPIO_Mode_Out_PP),结果数据发不出去,就是这个原因。
2.2 波特率、数据位、停止位的参数选择逻辑
USART初始化的参数结构体USART_InitTypeDef里,几个关键参数的含义要理解透彻:
USART_BaudRate:波特率,我用的是115200。这个速率和调试助手的默认设置一致,无需额外修改就能直接通信。如果要更远距离传输或者抗干扰,可以降到9600,但要保证收发双方一致。USART_WordLength:数据位长度,选择USART_WordLength_8b,即8个数据位。这是最通用的配置,ASCII字符刚好用一个字节表示。USART_StopBits:停止位,USART_StopBits_1。1位停止位是默认配置,绝大多数串口设备都支持。USART_Parity:校验位,USART_Parity_No。无校验,因为串口调试助手默认也是无校验。USART_Mode:收发模式,设置为USART_Mode_Rx | USART_Mode_Tx,同时开启接收和发送,因为我们需要printf输出,也需要scanf输入。USART_HardwareFlowControl:硬件流控,USART_HardwareFlowControl_None。不使用RTS/CTS,三线制通信(TX、RX、GND)足够。
这里插一句关于波特率的理解。115200的意思是每秒传输115200个比特位,那么传一个字节(10个bit,包含起始位和停止位)大约需要86.8微秒。如果你的程序在中断里处理的事情耗时超过这个时间,就有可能导致下一次数据到来时没有及时响应,这就是波特率不能一味求高的原因。
配置完成后,调用USART_Cmd(USART2, ENABLE)使能串口,并且开启接收中断USART_ITConfig(USART2, USART_IT_RXNE, ENABLE)。注意,如果不开启接收中断,scanf就没法在运行时接收数据,这也是有人移植了代码却发现scanf不生效的原因之一。
3. printf与scanf重定向的实现及原理
3.1 fputc和fgetc的本质:重定向的关键入口
C标准库中的printf最终会调用fputc来输出单个字符,scanf最终会调用fgetc来读取单个字符。但标准库默认的fputc和fgetc是面向PC屏幕和键盘的,到了嵌入式环境,我们必须自己实现这两个函数,把字符输出到串口、从串口读取字符。这就是“重定向”的全部秘密。
需要重写的函数如下:
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) { while (USART_GetFlagStatus(USART2, USART_FLAG_RXNE) == RESET); return (int)USART_ReceiveData(USART2); }fputc里为什么要while等待USART_FLAG_TXE?因为USART_SendData只是把数据写入发送数据寄存器,真正移位发送出去还需要时间。如果前一个字节还没发完就写入下一个字节,会造成数据覆盖,输出就会乱码或者丢字节。TXE标志位表示发送数据寄存器为空,等它置位再写入下一个数据,这样就能保证每个字节都被完整发送。
fgetc里等待的是USART_FLAG_RXNE,表示接收数据寄存器非空。也就是说,用户调用scanf的时候,如果串口没有数据进来,程序会一直停在这里等待,这就是为什么有时候使用scanf感觉程序“卡死了”——其实它是在等你往串口助手发送数据。
3.2 使用MicroLIB和KEIL工程配置的要点
在KEIL MDK环境下,默认的C标准库比较大,而且为了支持文件操作会引入半主机(Semihosting)模式。半主机模式是ARM调试器提供的一种机制,让开发板上的程序通过调试器在PC上执行输入输出操作。如果不关闭半主机模式,printf输出会走到调试器而不是串口,就会出现“程序能编译能下载,但串口就是没数据”的现象。
解决办法有两个:一是勾选KEIL魔术棒(Options for Target)里的Target标签页的“Use MicroLIB”选项,MicroLIB是ARM提供的高度精简版C库,默认不启用半主机模式,重定向fputc和fgetc后就能正常工作;二是在代码中实现_sys_exit等函数来禁用半主机模式。我推荐用MicroLIB,操作简单,生成的代码也更小。
需要补充的是,勾选了MicroLIB之后,标准输入输出函数的行为会有所简化,比如不支持浮点数格式化时需要额外配置。但如果我们只是打印整数和字符串,MicroLIB足够用了。如果你要打印浮点数,可以在C/C++标签页的Define里加上MICROLIB宏,或者在fputc内部用vsprintf手动格式化,这是后话了。
另外有个KEIL的细节值得注意:勾选MicroLIB后有时编译会报__use_no_semihosting相关的错误,根本原因是工程里某些文件因为混合编译导致库选择不一致。遇到这种情况,检查一下所有源文件是否都参与了编译,然后把Include Path和宏定义清理干净,一般就能解决。
3.3 中文乱码与字符编码问题处理
串口输出中文出现乱码,是很多人遇到过的问题。原因很简单:KEIL编辑器默认的编码方式可能和串口助手的解码方式不一致。具体来说,KEIL中某些版本默认使用ANSI(GB2312)保存文件,而串口助手通常用UTF-8解析收到的字节流,编码不一致自然乱码。
解决方案有几种:一是修改串口调试助手的解码方式为GBK/GB2312;二是在KEIL的Edit→Configuration→Editor里,把Encoding改为UTF-8后重新输入中文字符串;三是最稳妥的办法——尽量不在单片机端输出中文,调试信息一律用英文加十六进制数值。我个人推荐英文,因为中文串口调试助手在编码处理上各软件水平不一,英文能从根本上避免这类问题。
如果你的产品确实需要在显示屏或上位机显示中文,那是另一套方案,涉及字库和编码转换,这里不展开,但要知道串口输出中文乱码不是单片机的问题,而是编码协商的问题。
4. 实操验证:从编译烧录到串口收发测试
4.1 完整测试代码与接线准备
在神舟IV号上测试时,我用的测试代码逻辑很简单:上电后先打印一条提示信息,然后进入循环,等待用户通过scanf输入一个整数,再把它原样打印出来。如果程序运行正常,你会看到串口助手先显示一行提示,你输入数字后它会回显。
#include "stm32f10x.h" #include <stdio.h> void USART2_Config(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_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; 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_ITConfig(USART2, USART_IT_RXNE, ENABLE); 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) { while (USART_GetFlagStatus(USART2, USART_FLAG_RXNE) == RESET); return (int)USART_ReceiveData(USART2); } int main(void) { int num = 0; USART2_Config(); printf("USART2 printf/scanf test ready!\r\n"); while (1) { printf("Please input a number: "); scanf("%d", &num); printf("You entered: %d\r\n", num); } }接线方面,神舟IV号如果板载USB转串口和USART1相连,而我们要用USART2,就需要确认板上的跳线或者使用杜邦线将PA2、PA3连接到USB转串口芯片的RXD、TXD端。注意是交叉连接:单片机的TX接USB转串口的RX,单片机的RX接USB转串口的TX,然后GND必须共地。不一致的连接是最常见的调试失败原因,没有之一。
4.2 实际测试流程与现象记录
下载程序后打开串口调试助手,选择对应的COM口,波特率设置为115200,数据位8,停止位1,无校验位,无硬件流控。打开串口后按下开发板复位键,调试助手会立即收到一行USART2 printf/scanf test ready!。这说明TX方向已经打通。
接着在发送框输入一个数字,比如123,点击发送。程序里的scanf在等待数据,收到后会继续执行,然后打印You entered: 123。如果回显正确,说明RX方向也正常,整个收发链路完全打通。
但是有个细节必须提醒:使用串口调试助手测试scanf时,很多软件默认发送的数据末尾带着\n(换行),而scanf("%d", &num)在遇到非数字字符时会停止读取,如果你发送的是123\n,程序会读取到数字123,然后换行符残留在接收缓冲区。下一次循环执行scanf时,\n会被当作格式不匹配的字符处理,导致读取失败。这个问题的解决办法是,在串口助手里发送时选择“发送新行”时注意看是否带了CRLF,或者改进scanf的格式,比如在%d后面加上%*c跳过非数字字符。这是真正写过的人才会发现的坑。
4.3 用USART中断实现对scanf的增强(可选)
如果你觉得标准scanf在串口调试助手里用起来别扭,可以考虑用USART接收中断配合环形缓冲区,自己实现一个简单的接收解析函数。这样就不需要依赖fgetc的阻塞等待,程序可以一边干别的事一边判断有没有数据到达。
实际情况中,我在产品代码里基本都是自己写接收解析逻辑,scanf大多数时候只在学习阶段用。原因是scanf的阻塞特性在主循环里会拖慢系统响应,而且它的格式解析有时不符合嵌入式场景的简单需求。但作为学习和功能验证,把scanf跑通能帮你深刻理解标准库重定向的原理,理解了原理,以后写自己的解析函数就是轻而易举的事。
5. 常见问题与排查技巧实录
5.1 编译报错:Error: L6915E / no stm32 target found
用KEIL开发STM32时,有几种报错让人很头疼,其中一个是error: no stm32 target found! if your product embeds debug authentication。这个报错通常是调试器连接不上芯片导致的,和代码本身无关。检查顺序是:确认ST-Link或J-Link是否正确插入电脑USB口;确认接线是否正确,特别是SWDIO、SWCLK、GND、3.3V四根线;确认KEIL的Debug选项里选择的调试器型号与实际使用的一致;最后按一下开发板的复位键再尝试下载,有时候芯片卡死在某种低功耗或异常状态,复位能救回来。
如果用的是ST-Link Utility或者CUBE Programmer这类独立烧录工具,报No STM32 Target还要检查是否设置了读保护(RDP)。神舟IV号这类老开发板如果之前烧过别人的程序,可能开启了读保护,要用ST-Link Utility先解除保护再烧录。初始化全片擦除有时也能解决这类问题。
5.2 printf输出乱码或完全没有输出
没有输出的排查优先级,我建议按这个顺序来:
- 串口号对不对?设备管理器里看看COM口,特别是FT232R这类USB转串口芯片,驱动异常时在设备管理器会显示黄色感叹号。重新安装驱动即可。
- 波特率是否一致?115200的两端都要是115200,不能只改一头。
- 是否勾选MicroLIB?没勾的话
printf默认走了半主机模式。 - TX引脚是否接对?PA2是TX,要接到USB转串口的RX端。
- 复位了吗?串口助手打开后,单片机的程序可能在你打开串口前就已经跑过
printf了,错过了数据。先打开串口再按复位,保证能看到启动信息。
输出乱码的情况,除了前面说的中文编码问题,还有可能是波特率两边不一致导致的位错误。比如发送端实际是115200,接收端设置成9600,那每个字节的采样点就会错位,出来的就是一堆乱码符号。
5.3 scanf卡死、输入无效或读到错误值
scanf卡死,先确认接收中断有没有打开。虽然fgetc本身就是靠查询RXNE标志来判断有没有数据,但如果串口接收中断未使能,某些情况下RXNE标志的置位行为会异常,导致fgetc永远等不到有效数据。
输入无效或者读到的值不对,多半是发送的数据格式问题。scanf("%d", &num)要求接收缓冲区里的数据必须首先是数字字符,如果你用串口助手发送的是十六进制格式字符,比如发送0x7B,那它只会读到开头的0,然后因为后面的x不匹配而停止。scanf解析遇到不匹配字符时不会跳过它,而是把它留在缓冲区,造成后续读取接连失败。
解决办法是:利用scanf("%d%*c", &num)这种方式,%*c表示跳过一个非数字字符,可以吞掉数字后面的换行符。或者用while(getchar() != '\n');手动清空缓冲区,这个做法在PC的C语言教程里也常见,但很多人没意识到串口场景下同样适用。
5.4 芯片发热、引脚电平异常等硬件排查
如果软件配置看起来都对,但串口就是不通,硬件排查也必不可少。先用万用表确认PA2在程序运行后是否有3.3V左右的电平变化,如果PA2始终为低,说明串口根本没初始化成功,或者程序根本没跑到初始化的地方。再用示波器或逻辑分析仪看printf发送时的波形,如果波形完全平直无翻转,把问题定位在软件;如果有波形但是幅度太小,检查电平转换芯片的工作电压是否正常。
USB转TTL芯片不稳定也是常见坑,比如FT232R驱动在Win10下有时需要手动更新到最新版,CH340在某些精简系统上也需要手动安装驱动。驱动问题往往表现为“设备管理器能看到COM口但一打开就报占用或设备不存在”。遇到这种情况,把驱动卸载干净后重新安装,比在网上乱搜一圈有效得多。
6. 从调试功能到工程实践的延伸建议
把printf和scanf在串口2上跑通,只是嵌入式串口应用的第一步。我在实际项目中用这套思路做了不少扩展,有几个方向值得继续深入。
一是分段日志分级输出。在产品代码中,可以用宏控制不同调试级别的输出,比如DEBUG_INFO、DEBUG_WARN、DEBUG_ERROR,通过一个全局变量或者条件编译决定哪些信息需要打印。这样调试阶段开全量日志,发布阶段只保留错误日志,不用大改代码。
二是使用printf格式化的高级技巧。除了%d、%s这些基础格式,%x适合打印十六进制寄存器值,%p可以打印指针地址,%.2f能控制浮点输出精度。在理解串口重定向的基础上,你可以构造一个统一的日志函数log_printf(const char *file, int line, const char *fmt, ...),在打印信息前自动添加文件名、行号和时间戳,这在排查复杂逻辑问题时能节省大量时间。
三是串口协议帧设计。scanf适合人类手动输入调试,但真正和设备通信时必须用协议帧。常见的设计是帧头(如0xAA 0x55)、长度字节、数据域、校验和(累加和或CRC)。这个阶段,你已经不应该用scanf来解析数据了,而是在接收中断里把数据存入环形缓冲区,在主循环里按状态机解析完整的帧。理解了本文的重定向原理和串口底层机制,设计自己的协议栈会顺理成章。
四是直接操作寄存器理解更深层行为。库函数虽然封装好了,但如果你想知道某个寄存器位到底做了什么,可以用调试器打开外设寄存器视图,比对USART_Init执行前后CR1、CR2、BRR等寄存器的变化。尤其是BRR寄存器,它直接决定了波特率的分频系数,理解了APB1时钟频率(默认36MHz)和分频计算的关系,你就不会好奇为什么某些波特率有误差了。
从整个项目来看,串口调通的最大价值不在于“能打印了”,而在于你明白了标准库和硬件外设之间的桥梁是怎么搭起来的。以后无论是换到HAL库,还是换到别的芯片平台,核心思路都是相通的。哪怕去用ESP32、用树莓派Pico,本质上都是重新实现底层的字符输入输出函数,只是函数名和API形式不同而已。
本文还有配套的精品资源,点击获取