STM32串口重定向printf与scanf实现详解
简介针对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号开发板上用库函数版本把这两个功能完整跑通过实测稳定今天把整个实现思路、代码细节和踩过的坑一次性说清楚。这篇内容的核心就是把串口2USART2配置好然后通过重定向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的串口外设全称其实叫USARTUniversal Synchronous/Asynchronous Receiver/Transmitter。多出来的这个S是指同步模式也就是可以外接时钟线做同步通信。实际上我们用的异步串口通信并不需要时钟线只用TX和RX两根线。这个点很多入门教程会含糊带过但面试的时候经常被问到。STM32F103ZET6的USART2引脚是PA2TX和PA3RX复用推挽输出。需要留意的是虽然芯片本身支持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_PPPA3配置为浮空输入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中某些版本默认使用ANSIGB2312保存文件而串口助手通常用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形式不同而已。本文还有配套的精品资源点击获取