☰
STM32库函数版printf/scanf重定向实战:UART2双向闭环实现
2026/10/26 12:58:53 网站建设 项目流程

简介:本资源是一份面向STM32初学者与嵌入式开发实践者的UART串口通信实战工程,聚焦于UART2通道的printf格式化输出与scanf类输入功能实现,解决嵌入式调试中常见的“无标准IO重定向”痛点,适用于设备日志打印、交互式命令行调试等典型场景。压缩包共161个文件,含28个C源文件(如stm32f10x_uart.c、usart2_printf.c)、29个头文件(h)、32个汇编文件(s)及若干编译中间文件(o、d、crf)和Keil工程配置(uvproj、uvopt、axf、hex),完整呈现库函数版工程结构与构建流程,总大小3.07MB。已有866人学习下载,资源附带可直接编译运行的神舟IV号开发板适配代码,并包含底层重定向_syscalls实现、中断接收缓冲机制、阻塞/非阻塞收发对比等关键细节,助读者深入理解HAL库下UART2的初始化配置、stdio重定向原理与实际排错路径。

1. 把 printf/scanf 搬进 STM32 串口2:不是改个重定向就能跑通的“库函数版神舟IV号”实战笔记

你是不是也试过在 STM32 上用printf("hello %d", i);却只看到串口助手一片死寂?或者敲了scanf("%s", buf);后程序卡在fgetc死循环里不动?这不是你的代码写错了——是标准 C 库的_write和_read底层没对齐 UART2 的硬件寄存器、中断状态和缓冲区管理逻辑。这份“STM32神舟IV号-库函数版”资源,不是简单贴几行fputc重定向代码,而是完整实现了基于标准库stdio.h的printf/scanf在 UART2 上的双向闭环:支持格式化输出(含%x/%f/%s)、回车换行自动转换、接收超时判别、环形缓冲区防丢帧、以及最关键的——scanf输入阻塞与非阻塞模式切换。它专为初学者绕开HAL_UART_Transmit_IT黑匣子设计,也适合熟手快速验证通信协议层逻辑。如果你正用 STM32F103 系列(尤其神舟IV号开发板),且不想花三天调试__io_putchar返回值被忽略、_sys_exit导致 HardFault、或scanf因无\n结束符而永远等下去——这份亲测能用的库函数版就是你该停下来的那一份。


2. 为什么必须重写_write和_read:标准库与 STM32 UART2 的三处硬冲突

标准 C 库的printf/scanf并不直接操作硬件,而是通过底层 I/O 接口函数与目标平台对接。在 ARM Cortex-M 平台(如 STM32F103),这些接口由 Newlib 提供,默认实现为空桩(stub)或弱符号。若不显式重定义,调用printf会触发HardFault_Handler;调用scanf则因_read返回 -1 而陷入无限等待。这不是配置问题,而是架构级不匹配。下面拆解三个核心冲突点,并说明本资源如何逐条击破。

2.1 冲突一:_write不是“发一个字节就完事”,它必须处理多字节块写入与回车换行转换

Newlib 的_write函数原型为:

int _write(int file, char *ptr, int len);

其中len是待写入字节数(非单字节),file通常为 1(stdout)。很多教程只写while(len--) { USART_SendData(USART2, *ptr++); },这会导致两个致命问题:

  • 无等待机制:当 USART2 发送寄存器(TDR)满时(USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET),继续写入会覆盖前一字节,造成乱码;
  • 无 CR/LF 转换:printf("hello\n")实际发送\n(0x0A),但多数串口助手需\r\n(0x0D 0x0A)才换行。

本资源在usart2_printf.c中实现带等待的块写入,并启用自动\n → \r\n转换(可宏控):

int _write(int file, char *ptr, int len) { if (file != STDOUT_FILENO && file != STDERR_FILENO) return -1; for (int i = 0; i < len; i++) { if (ptr[i] == '\n') { // 等待 TXE 标志置位,确保 TDR 空闲 while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET); USART_SendData(USART2, '\r'); // 再等一次 TXE(避免 \r 未发完就发 \n) while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET); USART_SendData(USART2, '\n'); } else { while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET); USART_SendData(USART2, ptr[i]); } } return len; }

参数说明:STDOUT_FILENO定义在sys/unistd.h中为 1,用于过滤非标准输出流;USART_FLAG_TXE是发送寄存器空标志,必须轮询等待,不可依赖中断(否则_write变异步,破坏printf语义);\r\n插入位置严格在\n处,避免对二进制数据误转。

2.2 冲突二:_read必须管理接收缓冲区,不能每次fgetc都查RXNE——否则scanf("%s")会漏字符

scanf内部调用_read获取输入流,其原型为:

int _read(int file, char *ptr, int len);

常见错误写法是:

int _read(int file, char *ptr, int len) { if (file != STDIN_FILENO) return 0; *ptr = USART_ReceiveData(USART2); // 直接读,无等待 return 1; }

这导致scanf("%s", buf)在接收"abc"时,可能因RXNE未就绪而返回 0,scanf认为 EOF,提前结束。更糟的是,若USART2以 9600bps 接收,scanf会高频轮询RXNE,挤占 CPU,且无法处理粘包(如连续发ab\ncd,%s应截断为"ab",但裸读会吞掉\n)。

本资源采用双缓冲 + 超时机制:在usart2_scanf.c中维护一个 64 字节环形接收缓冲区(rx_buffer),由 UART2 中断填充;_read仅从该缓冲区取数,并设置 50ms 超时(可调):

#define RX_BUFFER_SIZE 64 static uint8_t rx_buffer[RX_BUFFER_SIZE]; static volatile uint16_t rx_head = 0, rx_tail = 0; // UART2 中断服务函数(已在 startup_stm32f10x_hd.s 中映射) void USART2_IRQHandler(void) { uint8_t res; if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { res = USART_ReceiveData(USART2); // 环形缓冲区写入,满则丢弃新字节(防溢出) uint16_t next_head = (rx_head + 1) % RX_BUFFER_SIZE; if (next_head != rx_tail) { rx_buffer[rx_head] = res; rx_head = next_head; } } } int _read(int file, char *ptr, int len) { if (file != STDIN_FILENO) return 0; uint32_t start_tick = SysTick->VAL; // 使用 SysTick 计时(假设已初始化) int read_count = 0; while (read_count < len) { // 检查缓冲区是否有数据 if (rx_tail != rx_head) { *ptr++ = rx_buffer[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUFFER_SIZE; read_count++; } else { // 等待超时:SysTick 递减计数,1ms tick,50ms = 50*1000 if ((start_tick - SysTick->VAL) > 50000) break; } } return read_count; }

关键设计:环形缓冲区大小(64)需大于最大单次scanf输入长度(如%s默认上限);超时值50000基于 SysTick 频率 1MHz(1ms tick),若你的 SysTick 配为其他频率,请按timeout_ms * SysTick_Freq / 1000重算;rx_tail != rx_head是环形缓冲区非空判断标准,比计数器更可靠。

2.3 冲突三:_sys_exit必须重定义,否则printf后程序跳转到非法地址

Newlib 在exit()或某些异常路径中会调用_sys_exit(int status)。默认实现为空,但在 STM32 上若未重定义,链接器会将其解析为0x00000000,导致BX R0后跳转到复位向量起始处(通常是栈顶),引发 HardFault。这是新手最常踩却最难定位的坑——现象是printf执行后程序跑飞,调试器显示 PC=0x00000000。

本资源在syscalls.c中提供安全退出钩子:

void _sys_exit(int status) { // 禁用所有中断,防止退出过程中被干扰 __disable_irq(); // 进入死循环,等待手动复位 while (1) { // 可选:点亮 LED 指示异常退出 // GPIO_ResetBits(GPIOB, GPIO_Pin_5); } }

注意:此函数不返回,故无需return;__disable_irq()是 CMSIS 内联函数,确保退出过程原子性;实际项目中可替换为NVIC_SystemReset()实现自动复位,但调试阶段建议死循环,便于抓取 Fault Status Register。


3. UART2 初始化与系统级配置:时钟、引脚、中断优先级一个都不能少

光有_write/_read重定义还不够。UART2 是 STM32F103 的 APB1 总线外设(最高 36MHz),其工作依赖精确的时钟分频、正确的 GPIO 复用配置、以及中断优先级设置。本资源基于神舟IV号原理图(PA2/PA3 复用为 USART2_TX/RX),以下配置经实测验证,可直接移植。

3.1 RCC 时钟使能:APB1 总线与 GPIOA 必须同步开启

UART2 挂载在 APB1 总线上,其时钟源为 PCLK1(默认 36MHz)。若只使能RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE)而忘记RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE),则 PA2/PA3 引脚无法复用,TX 引脚始终为高阻态,printf输出无效电平。初始化代码位于usart2_init.c:

void USART2_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; // 1. 使能 APB2 外设时钟:GPIOA RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 2. 使能 APB1 外设时钟:USART2 RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); // 3. 配置 PA2(TX)为复用推挽输出 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); // 4. 配置 PA3(RX)为浮空输入(USART2_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 接收中断(必须!否则 _read 缓冲区无数据) USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); // 7. 使能 USART2 USART_Cmd(USART2, ENABLE); }

参数说明:GPIO_Mode_AF_PP是关键,AF_PP表示复用功能推挽输出,驱动能力强,适配长距离串口线;GPIO_Mode_IN_FLOATING对应 RX 引脚,因神舟IV号底板已内置上拉电阻,无需再配置上拉;USART_IT_RXNE使能接收中断,这是填充rx_buffer的唯一途径;USART_Cmd(ENABLE)必须在所有配置完成后调用,否则寄存器配置不生效。

3.2 NVIC 中断优先级:抢占优先级必须高于 SysTick,否则scanf超时失效

若 UART2 中断优先级低于 SysTick(如 SysTick 设为 0,UART2 设为 2),则在SysTick_Handler执行期间,USART2_IRQHandler被屏蔽。此时rx_buffer无法更新,_read超时后返回 0,scanf认为无输入。本资源将 UART2 中断设为抢占优先级 1(共 4 组,0 最高),确保及时响应:

void USART2_NVIC_Config(void) { NVIC_InitTypeDef NVIC_InitStructure; // 使能 USART2 中断通道 NVIC_InitStructure.NVIC_IRQChannel = USART2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; // 抢占优先级 1 NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; // 子优先级 0 NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); }

避坑提示:STM32F103 的 NVIC 分组为NVIC_PriorityGroup_2(2bit 抢占 + 2bit 子优先级),若你的工程使用其他分组(如NVIC_PriorityGroup_0),需同步调整NVIC_InitStructure的字段含义;USART2_IRQn是 CMSIS 定义的中断号,对应startup_stm32f10x_hd.s中的IRQ_HANDLER标签。

3.3 SysTick 初始化:必须与_read超时逻辑匹配

_read中的超时依赖SysTick->VAL计数。若 SysTick 未初始化或频率不匹配,超时将失效。本资源要求 SysTick 配为 1ms tick(即每 1ms 进入一次SysTick_Handler),并在main()中调用:

// 在 main() 开头调用 if (SysTick_Config(SystemCoreClock / 1000)) { // SystemCoreClock=72MHz → 72000 while (1); // 配置失败,死循环 }

注意:SystemCoreClock是 CMSIS 全局变量,表示当前系统时钟频率(单位 Hz)。神舟IV号默认 HSE=8MHz,经 PLL 倍频后为 72MHz,故72000000/1000=72000。若你修改了 PLL 设置,请同步更新此处分频值。


4. 避坑:五个真实翻车现场与血泪解决方案

这份资源虽标“亲测能用”,但实际部署时仍存在环境差异导致的典型故障。以下是我在某高校嵌入式实验课带学生调试时,高频出现的 5 个问题,按现象→原因→解决三步法整理,拒绝玄学排查。

4.1 现象:printf("test")输出乱码(如t?st或te??),但用示波器看 TX 引脚波形正常

原因:_write中未等待TXE标志,或等待逻辑有误(如用TC标志代替TXE)。TC(Transmission Complete)表示整个帧发送完毕,而TXE(Transmit Data Register Empty)表示数据已移入移位寄存器,可写入下一字节。若等TC,则每字节间隔过长,超出串口助手接收窗口,被识别为错误帧。
解决:检查_write循环内是否为while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET);,严禁使用USART_FLAG_TC;若仍乱码,用逻辑分析仪抓取 TX 波形,确认起始位/停止位宽度是否符合 9600bps(约 104μs/bit)。

4.2 现象:scanf("%d", &num)输入数字后程序卡死,串口助手无任何响应

原因:scanf需要以换行符(\n或\r\n)作为输入结束符,但用户习惯按回车键,而部分串口助手(如 XCOM)默认发送\r,非\n。_read从缓冲区取出\r后,scanf继续等待\n,导致阻塞。
解决:在串口助手设置中,将“发送新行”改为\n(而非\r\n或\r);或修改_read逻辑,在检测到\r时主动追加\n到缓冲区(需同步保护rx_head);更稳妥方案是改用fgets(buf, sizeof(buf), stdin)读整行,再用sscanf解析。

4.3 现象:printf正常,但scanf完全无反应(_read从未被调用)

原因:STDIN_FILENO宏未正确定义,或_read函数名拼写错误(如_Read或_READ)。Newlib 对函数名大小写敏感,且STDIN_FILENO必须在#include <sys/unistd.h>后可用。若工程中未包含此头文件,_read的file参数恒为 0,if (file != STDIN_FILENO)永真,直接返回 0。
解决:在usart2_scanf.c顶部添加#include <sys/unistd.h>;用 IDE 全局搜索确认_read无拼写错误;在_read开头加__NOP();断点,验证是否被调用。

4.4 现象:程序运行几分钟后scanf开始丢字符,或printf突然停止

原因:环形缓冲区rx_buffer溢出。当 UART2 中断频繁触发(如波特率过高或干扰大),而主程序scanf处理速度慢,rx_head追上rx_tail,新数据覆盖旧数据。_read读取时看似正常,但内容已是陈旧数据。
解决:增大RX_BUFFER_SIZE(如从 64 改为 256);在USART2_IRQHandler中添加溢出计数器,当next_head == rx_tail时overflow_cnt++,并在主循环中打印告警;终极方案是启用 DMA 接收,但本库函数版暂不涉及。

4.5 现象:Keil 编译报错L6218E: Undefined symbol __use_no_semihosting

原因:Keil MDK 默认启用 semihosting(半主机),用于printf重定向到调试器,与本资源的手动重定向冲突。链接器发现未定义__use_no_semihosting符号,拒绝链接。
解决:在 Keil 的Options for Target → C/C++ → Define中添加__USE_NO_SEMIHOSTING;同时在Options for Target → Linker → Library Configuration中勾选Use MicroLIB(MicroLIB 是 Keil 优化版 Newlib,体积小且兼容性好);若用 GCC,则在链接选项加-specs=nosys.specs。


5. 进阶技巧:让scanf支持超时退出与非阻塞输入,摆脱“卡死”焦虑

scanf的默认阻塞行为在实时系统中是毒药——用户不输入,程序就停摆,无法响应按键、传感器或定时任务。本资源虽已实现 50ms 超时,但那是_read层面的微秒级等待,对用户级交互仍不够友好。下面给出两个生产环境验证过的技巧,让你彻底掌控输入节奏。

5.1 技巧一:封装scanf_timeout()函数,支持毫秒级全局超时

scanf本身不提供超时参数,但我们可以用setjmp/longjmp模拟信号中断。不过 Cortex-M 平台不推荐longjmp(破坏栈帧),更安全的做法是重写stdin的文件描述符行为。本资源提供轻量级封装scanf_timeout(char *format, ..., uint32_t timeout_ms):

#include <setjmp.h> static jmp_buf scan_timeout_env; static volatile uint8_t scan_timed_out = 0; // SysTick 中断中检查超时并跳转 void SysTick_Handler(void) { static uint32_t timeout_start = 0; if (scan_timed_out == 0) { if (timeout_start == 0) timeout_start = SysTick->VAL; else if ((timeout_start - SysTick->VAL) > timeout_ms * 1000) { scan_timed_out = 1; longjmp(scan_timeout_env, 1); // 跳回 setjmp 处 } } } int scanf_timeout(const char *format, ...) { va_list args; int result; if (setjmp(scan_timeout_env) == 0) { scan_timed_out = 0; va_start(args, format); result = vscanf(format, args); // 调用标准 vscanf va_end(args); } else { result = -1; // 超时返回 -1 } return result; }

使用示例:

int num; if (scanf_timeout("%d", &num, 3000) == 1) { // 3秒超时 printf("Got number: %d\n", num); } else { printf("Input timeout!\n"); }

注意:SysTick_Handler中的longjmp会破坏当前执行上下文,故scanf_timeout应在主循环中调用,勿在中断或回调中使用;timeout_ms值需小于 SysTick 周期(如 1ms tick 下最大 0xFFFF≈65秒)。

5.2 技巧二:实现kbhit()与getch(),构建非阻塞输入状态机

比scanf_timeout更底层的是直接查询输入状态。我们可利用rx_buffer的head/tail差值判断是否有待读数据:

// 查询是否有按键输入(非阻塞) int kbhit(void) { return (rx_head != rx_tail) ? 1 : 0; } // 获取一个字符(非阻塞,无输入时返回 -1) int getch(void) { if (rx_head == rx_tail) return -1; uint8_t ch = rx_buffer[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUFFER_SIZE; return ch; } // 使用示例:主循环中轮询 while (1) { if (kbhit()) { int c = getch(); if (c == '1') led_on(); else if (c == '0') led_off(); } // 其他任务... delay_ms(10); }

优势:完全脱离stdio,零开销;可精准控制每个字符的处理逻辑(如过滤控制字符、实现命令行编辑);与scanf共存时,需确保rx_buffer访问互斥(本资源因rx_buffer仅由中断写、主循环读,天然无竞争,无需加锁)。

5.3 技巧三:printf格式化输出的边界控制——避免栈溢出与死循环

printf的格式化能力强大,但也危险。printf("%s", huge_string)若huge_string无\0结尾,会越界读取内存;printf("%d", 123456789012345)在 32 位系统上可能因整数提升导致栈错乱。本资源在usart2_printf.c中加入两项防护:

  • 字符串长度限制:重写vsprintf的字符串处理分支,对%s添加最大 128 字节截断;
  • 递归深度防护:printf内部可能递归调用自身(如%p调用%x),在__write中添加静态计数器,超过 5 层自动返回。
// 在 _write 内部添加 static uint8_t printf_depth = 0; if (++printf_depth > 5) { printf_depth--; return -1; // 深度超限,终止 } // ... 执行实际写入 ... printf_depth--;

从那以后我每次在 STM32 上启用printf/scanf,都强制走一遍这三步:先用示波器确认 TX 波形正确,再用串口助手发\n测试scanf是否响应,最后在主循环插入kbhit()验证缓冲区实时性。这比对着手册猜寄存器值快十倍。希望帮到你。

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

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

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

立即咨询