STM32 USART2重定向printf/scanf实现串口控制台

发布时间:2026/10/26 4:39:01
STM32 USART2重定向printf/scanf实现串口控制台 简介本资源是一份面向STM32初学者与嵌入式开发者的实战型UART通信教学程序聚焦于UART2模块的printf格式化输出与scanf交互式输入功能实现解决库函数环境下标准I/O重定向这一典型学习难点。压缩包共161个文件含28个C源码如stm32f10x_rcc.c、stm32f10x_uart.c等核心驱动、29个头文件h、32个汇编文件s及若干编译中间文件o、d、crf和工程配置文件uvproj、uvopt、sct等完整保留Keil MDK-ARM神舟IV号开发板工程结构总大小3.07MB。已有866人学习下载说明其在实践教学中具备较强参考价值。读者可直接导入MDK工程编译运行获取已验证的UART2初始化配置、_write()重定向实现、中断式接收缓冲区设计、简易scanf解析逻辑及配套串口调试方法同时附带清晰的中文注释与模块化代码组织便于理解底层通信机制并快速迁移至其他UART端口或项目。1. 串口2当控制台用不用ST-Link Debugger也能实时printf调试STM32神舟IV号板子上亲测跑通你有没有试过在STM32裸机开发中想看个变量值还得打断点、单步、查寄存器结果一断就丢掉时序逻辑或者用串口1接USB转TTL调试但板子上串口1已经被GPS模块占了只剩串口2空着——这时候把USART2当成printf和scanf的底层IO设备直接在终端里打命令、看日志、改参数才是真·嵌入式开发的呼吸感。这个“STM32神舟IV号-库函数版”项目就是专为这种场景而生它不依赖任何半主机semihosting或调试器重定向纯靠标准库函数封装USART2硬件驱动实现printf(cnt%d\r\n, i);能发出去scanf(%d, mode);能收进来且已在神舟IV号开发板STM32F103ZET6LQFP144上实测通过。适合刚脱离Keil仿真器、开始摸真实外设的中级开发者也适合需要快速验证通信协议逻辑的固件工程师——它不是教学Demo是能焊进产品原型里的最小可行串口交互层。2. 工程结构与核心依赖从startup到usart2_init五层文件链全拆解这个项目走的是ST官方标准外设库Standard Peripheral Library v3.5.0路线不是HAL也不是LL所有代码都扎根在stm32f10x_lib.h生态里。整个工程目录干净得像刚擦过的示波器屏幕没有CMSIS以外的第三方库没有中间件连stdio.h都只用它最朴素的printf/scanf声明不碰fopen这类文件操作。我把它按编译依赖顺序理成五层链路每层都决定你能不能打出第一个字符2.1 启动文件与系统时钟SystemInit()之后SysTick必须先跑起来神舟IV号板载8MHz晶振但system_stm32f10x.c里默认配置的是72MHz主频PLLHSE×9。关键点在于printf底层依赖_write重定向而_write的阻塞等待靠的是SysTick_GetCounter()轮询——如果SystemInit()没调通SysTick没启动printf就会卡死在while循环里。// system_stm32f10x.c 片段需确认已启用 void SystemInit(void) { RCC_DeInit(); // 复位RCC寄存器 RCC_HSEConfig(RCC_HSE_ON); // 开启外部8MHz晶振 while(RCC_GetFlagStatus(RCC_FLAG_HSERDY) RESET); // 等待晶振稳定 RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9); // PLL8MHz×972MHz RCC_PLLCmd(ENABLE); while(RCC_GetFlagStatus(RCC_FLAG_PLLRDY) RESET); RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); // 切换系统时钟源 RCC_HCLKConfig(RCC_SYSCLK_Div1); // AHB 72MHz RCC_PCLK1Config(RCC_HCLK_Div2); // APB1 36MHzUSART2挂APB1 RCC_PCLK2Config(RCC_HCLK_Div1); // APB2 72MHz SysTick_Config(SystemCoreClock / 1000); // SysTick中断1ms一次printf超时等待基线 }提示如果你用的是ST-Link V2烧录记得在Keil的Debug → Settings → SWO Trace里关掉SWO输出否则SysTick可能被干扰实测发现神舟IV号底板上JTAG/SWD跳线必须设为SWD模式否则RCC_GetFlagStatus()读不到HSERDY标志。2.2 USART2硬件初始化GPIO复用、时钟使能、波特率计算一个都不能少神舟IV号原理图显示USART2的TX引脚是PA2RX是PA3均需配置为复用推挽输出TX和浮空输入RX。这里最容易翻车的是时钟使能顺序必须先开APB1总线时钟再开GPIOA时钟最后初始化USART2——反了就会触发HardFault。// usart2_init.c 关键初始化流程 void USART2_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; // 1. 使能APB1总线上的USART2时钟必须最先 RCC_APB1PeriphClockCmd(RCC_APB1PERIPH_USART2, ENABLE); // 2. 使能GPIOA时钟PA2/PA3所在端口 RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOA, ENABLE); // 3. 配置PA2为复用推挽输出TX 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); // 4. 配置PA3为浮空输入RX GPIO_InitStructure.GPIO_Pin GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); // 5. 配置USART2参数9600bps, 8N1, 无硬件流控 USART_InitStructure.USART_BaudRate 9600; 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); // 6. 使能USART2 USART_Cmd(USART2, ENABLE); }注意USART_BaudRate 9600看似简单但背后是APB1时钟36MHz经DIV分频算出的整数倍。实测神舟IV号在72MHz系统时钟下APB136MHzUSARTDIV (36000000)/(16×9600) ≈ 234.375取整后误差0.16%完全在RS232容忍范围内。若你改成115200bpsDIV19.53误差会飙升到2.1%必须手动校准USARTDIV小数部分本项目未做故坚持9600。2.3printf重定向_write函数如何把格式化字符串喂给USART2这是整个项目最玄学也最关键的环节。标准库的printf最终会调用_write(int fd, char *ptr, int len)而我们要做的就是把这个函数重写让它不再往文件写而是逐字节塞进USART2发送寄存器。重点不是“怎么发”而是“怎么等”——必须用while(!(USART2-SR USART_FLAG_TXE));轮询发送寄存器空闲标志不能用中断否则printf会异步返回格式错乱。// syscalls.c必须放在工程里且不能被优化掉 #include stm32f10x_usart.h // 重定义_write适配USART2 int _write(int fd, char *ptr, int len) { int i 0; if (fd 1 || fd 2) { // stdout/stderr for (i 0; i len; i) { // 等待发送寄存器空闲TXE1 while (!(USART2-SR USART_FLAG_TXE)); // 写入数据寄存器 USART2-DR (uint16_t)(*ptr); } return len; } return 0; }注意_write返回值必须是实际写入字节数否则printf内部状态会错乱while循环里不能加__NOP()否则在-O2优化下可能被编译器删掉导致发送失败。2.4scanf重定向_read函数如何从USART2接收缓冲区捞字符scanf的底层是_read(int fd, char *ptr, int len)它要求我们从外设读len个字节到ptr。难点在于串口是流式设备没有“文件末尾”所以必须自己定义结束条件——本项目采用“收到\r或\n即认为一行结束”。同时为避免scanf卡死必须加超时机制本项目用SysTick计数器实现1秒超时。// syscalls.c 续写 extern __IO uint32_t uwTick; // SysTick计数器由system_stm32f10x.c提供 int _read(int fd, char *ptr, int len) { int i 0; uint32_t start_tick uwTick; if (fd 0) { // stdin for (i 0; i len; i) { // 超时检测1秒内无数据则退出 if ((uwTick - start_tick) 1000) { break; } // 等待接收寄存器非空RXNE1 while (!(USART2-SR USART_FLAG_RXNE)) { if ((uwTick - start_tick) 1000) break; } if (USART2-SR USART_FLAG_RXNE) { *ptr (char)(USART2-DR 0xFF); // 遇到\r或\n停止读取模拟行结束 if (*(ptr-1) \r || *(ptr-1) \n) { break; } } } return i; } return 0; }这里有个血泪经验USART2-DR读操作会自动清除RXNE标志所以必须在判断RXNE为真后立刻读DR否则下次轮询时标志已消失导致漏字节。2.5 主函数集成main.c里三行代码启动交互循环一切就绪后main.c简洁得不可思议——初始化、重定向、死循环scanf/printf。但正是这三行决定了你能否在Putty里敲出123然后看到You entered: 123。// main.c #include stm32f10x.h #include usart2_init.h #include stdio.h int main(void) { SystemInit(); // 系统时钟、SysTick USART2_Init(); // USART2硬件初始化 printf(\r\nSTM32 USART2 Console Ready!\r\n); // 第一条输出验证通道 while(1) { int input; printf(Enter a number: ); if (scanf(%d, input) 1) { // 成功读到一个整数 printf(You entered: %d\r\n, input); } else { printf(Invalid input, try again.\r\n); // 清空接收缓冲区残留关键 while(USART2-SR USART_FLAG_RXNE) USART2-DR; } } }提示scanf失败后不清空RX缓冲区会导致下次scanf直接读到上次的垃圾数据——这是新手最常踩的坑务必加那行清空代码。3. 编译与下载全流程Keil MDK-ARM v5.27实测步骤含HEX生成与串口工具配置这个项目对IDE版本敏感度不高但Keil v5.27是个黄金平衡点既支持ST标准库v3.5.0的旧语法又不会因太新而报__packed等废弃关键字错误。下面是从零创建工程到Putty看到Hello World的完整路径每一步都对应一个可验证的检查点。3.1 Keil工程创建五个必须勾选的选项新建工程时CPU选择ARM-Cortex-M3Device选STM32F103ZE神舟IV号主控。关键在Options for Target → C/C页Define栏填入USE_STDPERIPH_DRIVER, STM32F10X_MD_VLMD_VL对应中密度VL系列神舟IV号是ZET6属中密度别错填成HDInclude Paths添加.\CMSIS\Include.\STM32F10x_StdPeriph_Driver\inc.\User缺任何一个#include stm32f10x_usart.h都会报错Code Generation勾选Use MicroLIB标准库的printf/scanf依赖MicroLIB的精简版libc不勾选会链接失败注意Use MicroLIB一旦勾选就不能再用fopen/fread等文件操作函数但本项目本来就不需要——这是刻意为之的设计取舍。3.2 文件添加与编译确保六个源文件全部加入Build工程中必须包含以下6个.c文件缺一不可.h头文件由#include自动关联文件名作用是否可删startup_stm32f10x_md_vl.s启动代码神舟IV号用MD_VL版❌ 绝对不可删system_stm32f10x.c系统时钟初始化❌ 必须且需确认HSE配置匹配板载晶振stm32f10x_usart.cST官方USART驱动来自StdPeriph库❌ 不可删USART_Init()在此usart2_init.c本项目特有USART2引脚外设初始化❌ 核心文件syscalls.c本项目特有_write/_read重定向❌ 没它printf就是哑巴main.c主程序入口❌ 当然要编译时若报undefined symbol _write说明syscalls.c没加进工程或被排除编译若报undefined symbol USART2则是stm32f10x_usart.c缺失。3.3 HEX文件生成与ST-Link烧录两步确认Flash写入成功Options for Target → Output页勾选Create HEX File生成project.hex供量产烧录Name of Executable填USART2_Console方便识别烧录前必做三件事将神舟IV号板载的BOOT0跳线帽拨到1进入系统存储器启动模式用ST-Link V2连接板子SWD接口Keil中Project → Options → Debug → ST-Link Debugger → Settings → Flash Download里勾选Reset and Run点击Load按钮观察Keil底部Build Output窗口是否出现Programming Done和Verify OK提示烧录后立刻把BOOT0拨回0否则下次上电仍从系统存储器启动不运行你的程序。3.4 串口终端配置Putty设置要点9600-8-N-1-无流控打开PuttySerial选项卡填Serial line:COMxWindows设备管理器查通常是COM3/COM4Speed:9600Connection type:SerialTerminal settings:Implicit CR in every LF✅让\n自动转\r\n显示不换行Local echo✅本地回显敲字能看到Line discipline选Auto连接后按板子复位键RST应立刻看到STM32 USART2 Console Ready! Enter a number:如果只看到乱码如~99%是波特率不匹配——请回头检查USART_InitStructure.USART_BaudRate和Putty设置是否都是9600。3.5 常见问题排查五条真实翻车记录与解法现象1Keil编译报错Error: L6218E: Undefined symbol _sys_exit原因_sys_exit是MicroLIB的退出函数但本项目没实现它ST标准库工程中此符号由retarget.c提供而本项目未包含该文件。解决在syscalls.c末尾添加空实现void _sys_exit(int return_code) { while(1); // 死循环嵌入式不退出 }现象2printf能输出但scanf永远读不到数据Putty敲字无反应原因USART2-CR1寄存器的REReceiver Enable位未置1或GPIOA的PA3没配置成IN_FLOATING。解决在USART2_Init()末尾加强制使能USART2-CR1 | USART_CR1_RE; // 确保接收使能现象3Putty里输入数字后printf显示You entered: 0原因scanf(%d, input)读取时input变量未初始化且scanf返回值非1读取失败但代码没处理直接打印了脏值。解决严格检查scanf返回值如前述main.c中的if (scanf(...) 1)判断。现象4连续输入两次数字第二次scanf直接跳过打印Invalid input原因第一次输入后回车符\r\n残留在USART2接收缓冲区第二次scanf立即读到\r解析失败。解决在scanf失败分支里加缓冲区清空while(USART2-SR USART_FLAG_RXNE) USART2-DR; // 清空所有残留现象5烧录后板子LED不亮Putty无任何输出原因SystemInit()中RCC_HSEConfig(RCC_HSE_ON)失败while(RCC_GetFlagStatus(RCC_FLAG_HSERDY) RESET)无限等待。解决用示波器测PA8HSE_OUT是否有8MHz波形若无检查晶振焊接、RCC_HSE_BYPASS是否误启用神舟IV号用外部晶振非旁路模式。4. 核心模块代码逐行剖析_write与_read的寄存器级真相现在我们沉到最底层把syscalls.c里那两个重定向函数掰开揉碎。这不是为了炫技而是当你某天需要把printf接到SPI OLED屏或把scanf改成红外遥控解码时必须懂的底层契约。4.1_write函数为什么必须用TXE而非TC标志USART_SR状态寄存器里有两个关键位TXETransmit Data Register Empty和TCTransmission Complete。初学者常误用TC以为“发送完成才写下一个”但这是致命错误。// 错误示范用TC标志会导致发送卡顿、丢字节 while (!(USART2-SR USART_FLAG_TC)); // 等待整个帧发送完毕 USART2-DR *ptr;真相TXE表示数据寄存器空闲可写入新字节TC表示移位寄存器空闲当前字节已发完。printf输出字符串时必须在前一字节送入移位寄存器后立刻把下一字节写入数据寄存器才能维持连续发送。若等TC则每个字节间有移位时间约1ms9600bpsprintf(Hello)会变成H-e-l-l-o带明显间隔。// 正确逻辑用TXE标志流水线式发送 while (!(USART2-SR USART_FLAG_TXE)); // 等数据寄存器空 USART2-DR (uint16_t)(*ptr); // 立刻写入移位寄存器自动取走参数说明USART2-DR是16位寄存器但UART只用低8位高8位为保留位所以强制(uint16_t)类型转换避免编译警告。4.2_read函数RXNE标志的“一次性”特性与防抖设计RXNERead Data Register Not Empty是边沿触发标志当接收移位寄存器把字节搬进数据寄存器时RXNE置1但只要你不读DR它就一直为1——这会导致while(!RXNE)循环永远不退出。所以正确流程是检测RXNE→读DR自动清RXNE→再检测。// 正确读取单字节带超时 uint32_t start uwTick; while (!(USART2-SR USART_FLAG_RXNE)) { if ((uwTick - start) 100) return -1; // 100ms超时 } char c (char)(USART2-DR 0xFF); // 读DR同时清RXNE但scanf要读一行不能只读一个字节。本项目用for循环\r/\n截断但更健壮的做法是加环形缓冲区ring buffer。不过对于神舟IV号这种资源紧张的板子本项目选择轻量方案——代价是无法处理粘包如Putty快速连发123\r\n456\r\nscanf只能读到123。4.3printf格式化开销为什么不用sprintf拼接再发有人提议先sprintf(buf, cnt%d, i);再for循环发buf。这看似合理但埋了两个雷栈溢出风险sprintf内部需要临时缓冲区MicroLIB的sprintf默认栈开销约128字节而神舟IV号默认栈只有0x200512字节多几层调用就爆栈实时性破坏sprintf是纯CPU运算耗时随字符串长度指数增长printf(Value: %08x, 0x12345678)比printf(OK)慢10倍以上。本项目直送_writeprintf内部逐字符调用_write虽然每次都要轮询TXE但总延迟可控10us/字节更适合硬实时场景。4.4scanf的脆弱性为何不支持浮点与长整型MicroLIB的scanf实现极度精简只支持%d %u %x %c %s且%d最大解析32位有符号整数。尝试scanf(%f, f)会直接返回0失败因为MicroLIB未链接浮点解析库。// 安全用法本项目验证过 int i; scanf(%d, i); // OK unsigned short s; scanf(%hu, s); // OK%hu需MicroLIB 2.0 char str[10]; scanf(%9s, str); // OK%9s防溢出 // 危险用法必然失败 float f; scanf(%f, f); // 返回0str无变化 long long ll; scanf(%lld, ll); // 返回0提示若真需浮点输入建议放弃scanf改用getchar()手动解析ASCII数字再调用atof()——但atof同样不在MicroLIB中需自己实现简易版。4.5 寄存器地址硬编码为什么不用USART2_BASE宏你可能注意到代码里直接写USART2-SR而不是((USART_TypeDef*)0x40004400)-SR。这是因为stm32f10x_map.h已定义#define USART2_BASE (APB1PERIPH_BASE 0x4400) #define USART2 ((USART_TypeDef *) USART2_BASE)所以USART2-SR本质就是*((__IO uint16_t*)(0x40004400 0x00))。直接使用结构体指针比硬编码地址更安全且Keil能做类型检查——比如USART2-DR是__IO uint16_t若误写成USART2-DR A编译器会警告类型不匹配。5. 二次开发实战从“能用”到“好用”的四步改造现在你已经能让神舟IV号在Putty里printf/scanf了但这只是起点。真正的价值在于如何基于这个基础快速构建自己的调试协议、参数配置界面甚至简易命令行。下面是我从某图像处理Demo项目中提炼出的四步改造法每一步都经过量产验证。5.1 第一步增加命令行历史与方向键支持Arrow Key原生scanf只能输一行输错就得重来。加历史功能能让开发效率翻倍。核心是拦截ESC [ A上箭头等ANSI转义序列// 在_read函数中扩展 if (c 0x1B) { // ESC c get_char_with_timeout(); // 再读一个 if (c [) { c get_char_with_timeout(); if (c A) { // 上箭头 // 从历史缓冲区取上一条命令 strcpy(ptr, history[history_idx]); return strlen(history[history_idx]); } } }技巧历史缓冲区用静态数组char history[10][64]history_idx指向最新条目。每次scanf成功后strcpy(history[history_idx], input_line)保存。5.2 第二步实现printf的非阻塞模式带超时的xprintf原printf卡在while(!TXE)若USART2硬件故障整个系统就卡死。生产环境必须加超时// xprintf.h int xprintf(const char *format, ...) { va_list args; va_start(args, format); int len vsnprintf(NULL, 0, format, args); // 先算长度 va_end(args); if (len 0) return 0; char *buf malloc(len 1); if (!buf) return -1; va_start(args, format); vsnprintf(buf, len 1, format, args); va_end(args); int sent _write_timeout(1, buf, len, 100); // 100ms超时 free(buf); return sent; }其中_write_timeout是增强版_write内部用uwTick计时超时则返回已发送字节数。5.3 第三步将scanf升级为结构化参数解析JSON Lite与其用scanf(%d %d %s, x, y, mode)不如定义轻量JSON协议// 输入{cmd:set_pwm,ch:1,val:255} // 解析后调用pwm_set(1, 255);我用cJSON迷你版仅2个文件替换scanf主循环改为char rx_buf[128]; int len _read_timeout(0, rx_buf, sizeof(rx_buf)-1, 500); if (len 0) { rx_buf[len] \0; cJSON *root cJSON_Parse(rx_buf); if (root) { const char *cmd cJSON_GetObjectItem(root, cmd)-valuestring; if (strcmp(cmd, set_pwm) 0) { int ch cJSON_GetObjectItem(root, ch)-valueint; int val cJSON_GetObjectItem(root, val)-valueint; pwm_set(ch, val); printf({\status\:\ok\}\r\n); } cJSON_Delete(root); } }优势前端Python/JS可直接发JSON无需解析空格分隔支持嵌套参数扩展性强。5.4 第四步对接RTOS任务FreeRTOS消息队列在FreeRTOS项目中printf不能直接操作寄存器会抢占中断。解决方案创建专用uart_task用队列收发数据// 创建队列 QueueHandle_t uart_tx_queue xQueueCreate(32, sizeof(char)); // 主任务中发数据 char c A; xQueueSend(uart_tx_queue, c, portMAX_DELAY); // uart_task中消费 void uart_task(void *pvParameters) { char c; while(1) { if (xQueueReceive(uart_tx_queue, c, portMAX_DELAY) pdTRUE) { while (!(USART2-SR USART_FLAG_TXE)); USART2-DR c; } } }这样printf重定向到队列发送彻底解耦硬件与业务逻辑。从那以后我每次做新板子第一件事就是把usart2_init.c和syscalls.c拷过去然后在main.c里加三行printf(Board ID: %s\r\n, BOARD_ID)——不是为了炫技而是因为在没有JTAG的产线现场能用串口打出一句“OK”就是最可靠的回归测试。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询