STM32标准库USART2串口printf/scanf重定向完整指南
简介一套基于STM32神舟IV号开发板的库函数版UART2串口通信工程专注实现格式化输出与格式化输入功能适合学习STM32串口通信或需要串口调试例程的嵌入式开发者。压缩包共161个文件大小约3.07MB以C源码、头文件、MDK工程文件为主体还包含汇编启动文件、编译链接生成的axf与hex等产物以及说明文档、内存映射文件和清理脚本。已有864人学习使用程序实测可用。配套文档详细讲解了波特率、数据位、停止位、校验位等初始化参数设置演示了通过重定向输出函数将打印内容发送至串口并利用接收中断与缓冲区解析输入数据的完整流程提供常见错误排查思路。通过阅读该工程可以掌握重定向标准输出、处理串口中断回调等关键技能目录结构清晰便于对照源码理解库函数调用也可作为工程模板二次开发。 搞嵌入式开发这些年我越来越觉得串口printf是调试程序最顺手的一件工具。代码跑没跑对、数据进了哪个分支、传感器读到了什么值一个printf全都能给你交代清楚。前阵子在神舟IV号板子上整理一个通信模块的项目USART1被业务协议占用了我只好把调试打印挪到USART2上顺手把printf输出和scanf输入的重定向做成了可直接复用的模板实测一次通过。这篇就把完整配置思路、库函数代码和整个排查过程整理出来给正在折腾STM32标准库串口的朋友做个参考。文章针对的是标准外设库版本也就是大家常说的3.5库函数版本不是HAL库思路和代码可以直接抄进自己的工程。1. 项目思路为什么把USART2改造成调试串口1.1 串口调试的应用场景与USART2的定位在STM32F103ZET6这种大容量芯片上USART外设有好几个但很多时候大家只用了USART1。原因很简单开发板出厂默认把USB转串口接到了USART1插上USB线就能下载和打印久而久之就形成了思维定式。实际产品里这种“一个串口干到底”的做法是要吃亏的如果USART1既要做Modbus通信、又要输出调试日志调试信息会混进协议帧里轻则协议解析失败重则线上设备直接被发疯的日志流量干扰。所以我习惯把“业务通信”和“调试日志”分开业务串口保持干净调试串口专门接一个USB转TTL到PC。USART2在引脚资源上很独立PA2做发送、PA3做接收不跟主流的JTAG/SWD调试口和I2C/SPI外设抢位置用来当调试串口非常合适。这里有一个容易被忽视的点如果你板载的USB转串口和USART1绑死那么USART2的调试信息没法直接通过板上USB口看到需要外接一个3.3V电平的USB转TTL模块。这也是很多人说“我明明配置了USART2串口助手却什么都收不到”的第一嫌疑原因。先查物理连接再查软件配置这个顺序在串口调试中永远成立。1.2 printf/scanf重定向的基本原理很多初学者把printf重定向当成一个“魔法操作”觉得勾个MicroLIB、写个fputc就能用但不知道为什么。其实原理非常简单。我们平时在电脑上写C语言时printf会把格式化后的字符串逐个字符输出到屏幕这个“逐字符输出”的底层函数在标准库中就是fputcscanf从键盘读取输入底层逐个字符读入的函数是fgetc。标准库只是定义了这两个函数的接口具体实现由运行环境决定。在PC上运行环境是操作系统从显示器/键盘读写在STM32裸机上没有操作系统标准库的默认实现会用半主机模式把字符送往调试器而不是送到串口外设。所以我们要做的事情就一句话改写fputc和fgetc让它们操作USART2的发送和接收寄存器。printf格式化出来的每一个字符最终都会通过我们的fputc塞进USART2的数据寄存器DRscanf需要读取的每一个字符都会通过fgetc从USART2的接收寄存器里取。只要这两个函数改对了printf和scanf就能在嵌入式世界里正常“联网”。2. USART2的GPIO与串口配置库函数版2.1 时钟配置USART2挂在APB1别写错总线使用标准外设库配置串口第一步是开启外设时钟。这里有一个高频雷区USART1挂在APB2总线上而USART2和USART3挂在APB1总线上。我见过不止一个朋友的代码里写的是“RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART2, ENABLE)”编译能过但串口死活不工作。因为标准库的RCC函数会严格检查参数用错总线的宏虽然在有些库版本里能编译通过但时钟根本没有被正确开启。正确的写法如下RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // GPIOA在APB2 RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); // USART2在APB1这两句缺一不可。GPIOA的时钟不开PA2和PA3就是“死引脚”USART2的时钟不开后面的所有寄存器操作都是空谈。另外提一句这样配置后USART2的波特率发生器会自动从APB1的36MHz时钟分频库函数的USART_Init里已经封装好了不需要你手动计算分频系数。为什么要强调总线因为APB1的最高频率是36MHzAPB2是72MHz如果以后想把USART2的波特率拉到很高比如4.5Mbps以上APB1的36MHz时钟会成为上限瓶颈。常规的115200、921600调试波特率完全不受影响。2.2 GPIO引脚与USART结构体初始化USART2的默认引脚是PA2TX和PA3RX不需要重映射也不需要开AFIO时钟。PA2作为TX必须配置成复用推挽输出GPIO_Mode_AF_PP这样才能交给USART外设驱动引脚PA3作为RX配置成浮空输入GPIO_Mode_IN_FLOATING即可。初始化代码GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; 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_Cmd(USART2, ENABLE);这段代码里有两个细节值得说。一是GPIO_Speed复用推挽输出时我习惯统一设成50MHz这不会带来额外功耗却能保证在较高波特率下信号边缘足够陡峭。二是USART_Mode如果你只想发不想收可以只写USART_Mode_TX但既然我们要测试scanf就必须把RX和TX都打开。USART_Cmd最后启动串口这一步漏掉相当于写了半天的代码白写。3. 核心实现printf输出与scanf输入的完整重定向3.1 fputc重定向把printf“接到”USART2fputc的重定向是整个printf功能的核心。标准库的printf每次要输出一个字符时就会调用一次fputc。我们要让这个字符从USART2的发送数据寄存器发出去代码非常简单int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) RESET); USART_SendData(USART2, (uint8_t)ch); return ch; }第一行等待TXE标志置位TXE是“发送数据寄存器空”的意思表示DR寄存器已经被清空可以写入下一个字节。为什么要等因为USART发送的速度远低于CPU执行速度如果不等上一个字节真正被硬件发送出去就往DR里塞新数据就会发生数据覆盖输出就会出现丢字或乱码。第二行把要输出的字符塞进USART2的DR寄存器硬件会自动完成移位、加起始位/停止位、按波特率一位一位发出去。最后return ch是给标准库一个交代表示“这个字符我处理好了”。这里我故意用TXE而不是TC。TC是“整帧发送完成”它要等移位寄存器里最后一位也发完速度上会比TXE慢半拍连续输出大段日志时两者没有肉眼可见差别但用TXE在连续打印时的吞吐量会更高。等到哪天要做RS485自动换向需要在最后一字节彻底发送完再拉断发送使能时你再换成TC判断也不迟。3.2 fgetc重定向scanf的输入源头fgetc是scanf的底层读取函数。每当scanf需要读一个字符就会调用fgetc我们的实现就是阻塞等待USART2的接收寄存器有数据然后把读到的字节返回给标准库int fgetc(FILE *f) { while (USART_GetFlagStatus(USART2, USART_FLAG_RXNE) RESET); return (int)USART_ReceiveData(USART2); }RXNE是“接收数据寄存器非空”标志只要串口硬件从RX引脚收到了一个完整的字节硬件就会自动把数据搬进DR寄存器并把RXNE置位。fgetc就在这里等这个标志等到了就用USART_ReceiveData把DR里的数据读出来。注意USART_ReceiveData返回的是uint16_t我们强转成int返回因为fgetc的接口规定要返回int。这种轮询式fgetc有个特点是阻塞的。如果一直没数据进来程序会一直卡在这个while循环里。这在纯printf/scanf的调试场景下问题不大因为等待输入本来就是人工操作但在深度嵌入式系统里如果主循环里有其他任务需要处理这种阻塞方式就不太合适了。更工程化的做法是改成中断接收加环形缓冲区然后fgetc从缓冲区取数据。3.3 MicroLIB为什么必须勾选Use MicroLIB在Keil MDK里操作printf重定向还有一个关键步骤容易翻车不勾选MicroLIB时标准C库的printf默认走“半主机模式”。半主机模式是ARM调试器提供的一套机制程序里执行BKPT 0xAB指令把字符交给调试主机处理。问题在于很多人的工程里根本没有启用调试器的串口窗口于是程序跑到printf就停住了表现为代码卡死或HardFault。解决办法有两个。最推荐的做法是在Keil的Options for Target - Target页面找到Code Generation一栏勾选Use MicroLIB用微库来代替标准C库。MicroLIB对半主机模式的处理非常轻量你只要重写fputc和fgetc就能直接工作而且代码体积会缩小不少。第二个办法是不用MicroLIB继续使用标准C库但要在代码里禁用半主机模式并实现底层文件操作相关的函数#pragma import(__use_no_semihosting) struct __FILE { int handle; }; FILE __stdout; void _sys_exit(int x) { x x; } int fputc(int ch, FILE *f) { /* 同上 */ }两种都可以但我个人在裸机工程里一律选MicroLIB。够快、够小、重定向省事。唯一要注意的是如果工程里大量使用浮点格式化打印比如printf(%f)MicroLIB的浮点支持不如标准库完整此时建议改用标准库加重定向并把调试串口选项都配好。4. 完整工程代码与实操测试4.1 main函数完整代码与解析把上面几块拼起来就是一个能直接编译运行的完整工程。我这份代码在神舟IV号板子上实测通过编译环境是Keil MDK5加标准外设库3.5。完整代码如下#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_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(\r\n USART2 printf/scanf demo \r\n); while (1) { printf(\r\n请输入一个整数: ); if (scanf(%d, num) 1) { printf(收到整数: %d\r\n, num); if (num 0) { printf(测试结束\r\n); break; } } else { printf(输入格式错误请重新输入\r\n); while (getchar() ! \n); } } while (1); }这份代码把scanf的返回值判断加上了这是很多demo里没有的。scanf成功解析数字时会返回1如果用户在串口助手里随手敲了个字母scanf会返回0说明解析失败输入流里残留的那个字母如果不及时清掉下一次scanf还会失败形成死循环。所以else分支里的while(getchar()!\n)就是干这个的把缓冲区里一直到换行符之前的垃圾字符全部消费掉。测试数字0是为了让程序能主动退出循环方便观察完整流程。4.2 硬件接线与串口助手测试流程软件写好了连接不能马虎。神舟IV号板载的USB转串口一般固定接到了USART1所以要测USART2手边需要一个USB转TTL模块CH340、CP2102、FT232都行。接线原则是交叉连接USB转TTL模块的TX接板子的PA3USART2_RX模块的RX接板子的PA2USART2_TX然后两端GND一定要共地。不共地时信号电平没有参考点收发会极其不稳定这是新手最容易忽略的一环。打开任意一款串口助手软件XCOM、SSCOM、PuTTY都可以。关键参数是波特率115200数据位8停止位1无校验。然后勾选“发送新行”让软件每次发送都在末尾自动补上回车换行。这一步很重要因为我们的scanf读取整数后会等一个换行符作为一次输入的结束标记。上电后串口助手应该立刻显示启动信息接着输入一个数字比如123点击发送程序会回显“收到整数: 123”。输入0则退出循环打印测试结束。整个过程如果一路顺畅说明USART2的收发通路、printf重定向、scanf重定向全部工作正常。我实测时用的是115200波特率和XCOM配合从输入到回显基本无延迟。5. 常见问题与排查实录5.1 程序能下载但串口没输出这个问题排在串口调试问题榜第一名。排查顺序从物理层到软件层第一检查USB转TTL模块的TX有没有接到PA3、RX有没有接到PA2很多朋友被“同名直连”的习惯误导把TX接TX、RX接RX这就废了第二检查GND有没有共地第三确认上位机选中的COM口号正确波特率和代码里一致第四确认程序真的运行到了printf可以在USART2_Config之前加一个LED翻转看LED有没有反应最后才去检查是不是没有勾选MicroLIB或者fputc没有写对。我用逻辑分析仪看过PA2的波形正常情况下printf执行期间应该有连续的TTL方波脉冲。如果你手头有逻辑分析仪或示波器直接钩PA2看波形判断效率会高很多。没有仪器就只能按上面的顺序逐个排除。另外如果设备管理器里USB转TTL芯片显示感叹号比如FT232R/FT231X这类需要去对应厂商官网下载匹配的驱动装好再继续测试。5.2 printf中文乱码中文乱码的原因九成是源代码编码和串口助手解码不一致。Keil旧版本默认把C文件存成ANSI本地代码页中文Windows下就是GBK而新版本或某些编辑器默认存成UTF-8。如果代码里写的是printf(温度: %d\r\n, temp)上位机串口助手却用UTF-8解码GBK字节流出来的就是乱码。解决办法也很粗暴要么把Keil编辑器的编码设置成UTF-8串口助手也选UTF-8要么保持ANSI串口助手选GB2312/GBK。调试阶段我其实更建议一律用英文输出比如printf(Temperature: %d\r\n, temp)彻底绕开编码问题也方便后续接数据可视化工具做解析。产品阶段再根据需求切换中文显示。5.3 error: no stm32 target found下载失败这个报错虽然不在串口范围内但我在折腾这个工程时遇到过估计很多人也遇到过。它来自ST-Link相关的下载工具意思是找不到STM32目标芯片。排查步骤先看Debugger里选择的烧录器是不是你手里那个型号ST-Link就选ST-LinkJ-Link就选J-Link再查ST-Link和板的接线SWDIO、SWCLK、GND三根线必须接对最好再补一根3.3V电源线减少电压不稳的概率然后确认板子确实上电了按一下复位键再试。一个容易忽略的情况是如果之前下载过程序把SWD引脚复用成普通GPIO甚至关闭了调试功能芯片会拒绝调试器连接。这时可以拉高BOOT0到1重新上电用ST-Link Utility之类的工具整片擦除再把BOOT0拉回0一般就能恢复了。驱动方面ST-Link的驱动和ST-Link Utility版本太旧也会导致连接不稳定更新到新版本会有奇效。5.4 scanf输入格式错误导致死循环前面main函数里我们写了scanf返回值判断这是实际调试中血泪教训总结出来的。如果你写的是最简单的demo比如直接scanf(%d, num)然后printf(%d\r\n, num)串口助手里输了个abcscanf马上返回0num保持原值不变但abc这三个字符全部留在标准库的输入缓冲区里。下一次循环scanf继续读取第一个字符a依然不是数字解析继续失败于是程序就表现为不管你怎么发都会有乱回显或者卡死。另外还要小心scanf里读%c的情况。如果之前执行过scanf(%d, num)用户按了回车输入缓冲区里还留着换行符紧接着再用scanf(%c, cmd)你很有可能会直接读到一个换行符而不是想要的操作码。处理办法是在读%c之前手动清空缓冲区或者干脆用getchar自己搭一个字符命令解析器。工程化一点的做法是写一个read_int函数用getchar逐字符拼成字符串再用sscanf转换这样能彻底摆脱缓冲区残留的坑int read_int(int *val) { char buf[16] {0}; int i 0; int ch; while (1) { ch getchar(); if (ch \r || ch \n) break; if (i (int)sizeof(buf) - 1) buf[i] (char)ch; } if (i 0) return 0; return sscanf(buf, %d, val) 1 ? 1 : 0; }做到这个程度输入解析的稳定性已经远超demo水平了嵌入式项目里控制台交互基本够用。最后分享一个我在实际调试中养成的习惯把USART2这种调试串口做成纯输出模式时我会把波特率固定成115200因为115200在1%误差范围内能被APB1的36MHz时钟精确分频而且上位机软件普遍响应快。等你把printf/scanf重定向跑通了下一步可以试着把fputc改成多串口分发比如通过一个全局变量指定当前输出串口这样一套打印框架就能同时服务USART2和USART3调试效率会再上一个台阶。我在这个方案上已经稳定跑了好几个项目遇到串口问题也基本能一眼定位。希望这篇能帮你在神舟IV号或者其他STM32板子上少走几步弯路。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →