目录
- 前言
- [一、串口重定向C I/O接口的核心原理与实现](#一、串口重定向C I/O接口的核心原理与实现)
-
- [1.1 为什么要做重定向](#1.1 为什么要做重定向)
- 二、printf与scanf的底层机制剖析
-
- [2.1 printf到底做了什么](#2.1 printf到底做了什么)
- [2.2 scanf的底层机制](#2.2 scanf的底层机制)
- 三、配置MicroLIB
- 四、fputc函数的重定向实现
-
- [4.1 函数原型](#4.1 函数原型)
- [4.2 完整实现代码](#4.2 完整实现代码)
- [4.3 主程序中的测试代码](#4.3 主程序中的测试代码)
- 五、fgetc函数的重定向实现
-
- [5.1 换行符问题的背景](#5.1 换行符问题的背景)
- [5.2 完整实现代码](#5.2 完整实现代码)
- [5.3 为什么fgets和scanf会自动获得串口输入能力](#5.3 为什么fgets和scanf会自动获得串口输入能力)
- 六、完整测试与验证
-
- [6.1 测试程序完整代码](#6.1 测试程序完整代码)
- [6.2 串口助手中的测试过程](#6.2 串口助手中的测试过程)
- 七、重定向的完整数据流总结
- 结语


🎬 云泽Q :个人主页
🔥 专栏传送入口 : 《C语言》《数据结构》《C++》《Linux》《蓝桥杯系列》《笔试算法》《AI赋能》《STM32》《Python》
⛺️遇见安然遇见你,不负代码不负卿~
前言
大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~
一、串口重定向C I/O接口的核心原理与实现
1.1 为什么要做重定向
我们在前面已经完成了串口的第一个主题------基本读写。但光能发能收还不够,我们在实际开发中,肯定需要借助串口来支持各种调试工作,甚至要用串口来进行对应的逻辑控制。
问题在于:用基本的串口读写接口,操作起来特别不方便。每次发个数据,你得组装发送缓冲、指定长度、调用发送函数,接收同理。这种"裸操作"在调试阶段效率很低。
于是我们就有了第二个主题------重定向C I/O接口。
你可能在以前学C语言时用过printf、scanf、fgets这些函数。我们要做的,就是把这些标准I/O函数的底层"偷梁换柱",让它们走串口通道。这样一来,以后编码时直接写printf就能基于串口做格式化输出,写scanf就能从串口读取数据,开发体验会好很多。
二、printf与scanf的底层机制剖析

2.1 printf到底做了什么
printf是一个进行格式化输出的函数,这句话很关键。我们来看一个具体的例子:
c
int a = 12345;
printf("%d", a);
变量a在内存中是一个4字节的int类型数据 。但printf输出的并不是这4个字节,而是输出5个独立的字符 :'1'、'2'、'3'、'4'、'5'。
整个过程分为两步:
- 格式化的过程 :把数值
12345拆解成字符序列'1' '2' '3' '4' '5'。 - 输出的过程 :调用
fputc()函数,向字符设备一次写一个字符。
也就是说,printf内部先把数值按格式要求转换成字符序列,然后逐个字符地交给底层的fputc去输出。这就是为什么我们说fputc是printf的底层函数。
再看另一种情况:
c
printf("%d", 12345);
这里直接传了一个常量12345,效果和上面一样------先格式化成5个字符,再逐个通过fputc输出到字符设备。
核心结论 :不管你怎么调用printf,它最终都会落到fputc这个函数上。所以我们只要改写fputc的底层实现,让它走USART(串口)而不是走默认的显示器设备,printf的输出就自然被重定向到串口了。
2.2 scanf的底层机制
scanf是printf的逆过程------它是一个进行格式化输入的函数。
c
int a = 0;
scanf("%d", &a);
当你在上位机(串口助手)输入"12345"并回车后,实际发生的事情是:
- 步骤a :底层通过
fgetc函数,一个字符一个字符地 获取输入,即依次读到'1'、'2'、'3'、'4'、'5'这5个字符。 - 步骤b :
scanf内部再把这些字符转成int类型 ,最终把整数12345写入变量a所在的内存(占用4字节)。
核心结论 :和printf依赖fputc一样,scanf的底层依赖的是fgetc。所以我们只要改写fgetc,让它从串口读取字符,scanf就能自然地从串口获取输入。
同理,fgets的底层也是fgetc。一旦fgetc被重定向到串口,scanf和fgets就同时获得了从串口读取数据的能力。
三、配置MicroLIB
在Keil中实现标准I/O重定向,有一个必须做的前置配置 ------勾选MicroLIB。

打开Keil的"Options for Target"对话框,切换到Target 选项卡,在"Code Generation"区域勾选 Use MicroLIB,然后点击OK保存。
Options for Target 'Usart_Redir_test'
├── Device
├── Target ← 切换到这个标签页
│ └── Code Generation
│ └── ☑ Use MicroLIB ← 勾选这里
为什么必须勾选?
MicroLIB是Keil提供的一个精简版C标准库,专门为嵌入式环境设计。标准C库的I/O系统默认指向半主机模式(semihosting),在没有操作系统和调试器的裸机环境下会卡死。而MicroLIB提供了更灵活的底层I/O接口,允许我们通过重写fputc和fgetc来接管标准I/O的输入输出通道。
如果不勾选MicroLIB,即便你重写了fputc和fgetc,printf也不会走你的自定义函数,而是走默认的半主机模式,程序会直接卡住。
四、fputc函数的重定向实现
4.1 函数原型
fputc的标准函数原型定义在stdio.h中:
c
int fputc(int ch, FILE *fp);
ch:要输出的字符(以int类型传入)。fp:文件指针,指向输出流(如stdout)。
在我们的嵌入式环境中,fp参数实际上用不到,因为所有输出都走串口。但函数签名必须和标准库保持一致,所以参数不能去掉。为了消除编译器对未使用参数的警告,我们使用UNUSED宏来处理:
c
#define UNUSED(X) (void)X /* To avoid gcc/g++ warnings */
4.2 完整实现代码
c
/* USER CODE BEGIN 4 */
int fputc(int ch, FILE *fp)
{
UNUSED(fp);
if(HAL_UART_Transmit(&huart1, (uint8_t*)&ch, 1, HAL_MAX_DELAY) == HAL_OK)
{
return ch;
}
return EOF;
}
/* USER CODE END 4 */
逐行解析:
UNUSED(fp):显式告知编译器fp参数不会使用,消除编译警告。HAL_UART_Transmit(&huart1, (uint8_t*)&ch, 1, HAL_MAX_DELAY):调用HAL库的串口发送函数,将字符ch通过USART1发送出去。参数含义:&huart1:串口1的句柄。(uint8_t*)&ch:将字符的地址强转为uint8_t*类型。1:发送1个字节。HAL_MAX_DELAY:阻塞等待发送完成,超时时间为最大值。
- 发送成功则返回原字符值
ch,失败则返回EOF(即 -1)。返回值为int类型的设计,是为了同时支持 0~255 的正常字符值和 -1 的错误标识。
EOF的定义为:
c
#define EOF (-1)
4.3 主程序中的测试代码
c
/* Includes */
#include "main.h"
/* Private includes */
#include <stdio.h>
/* USER CODE BEGIN 2 */
char inbuffer[32];
printf("Test begin...\n");
/* USER CODE END 2 */
/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
// 测试printf格式化输出
printf("hello : %d, hello %.3lf, hello: %s", 123, 3.14, "STM32F103");
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_8);
// HAL_Delay(1000);
}
/* USER CODE END WHILE */
这段代码展示了格式化输出的威力------一次printf调用,就能同时输出整数(123)、浮点数(3.140)和字符串(STM32F103),全部通过串口打印出来。
在COMTO串口助手中,你会看到如下输出:
<= Test begin...
<= hello : 123, hello 3.140, hello: STM32F103
<= hello : 123, hello 3.140, hello: STM32F103
<= hello : 123, hello 3.140, hello: STM32F103
...
灯也跟着闪烁,说明每次循环都执行了一次printf和一次GPIO翻转。
五、fgetc函数的重定向实现
5.1 换行符问题的背景
在做串口输入重定向之前,必须解决一个回车换行符的统一问题。
不同的上位机工具、不同的操作系统,发送的换行符可能不同:
- Windows系统通常发送
\r\n(回车+换行,即CR+LF)。 - Linux/Unix系统发送
\n(换行)。 - 有些工具只发送
\r(回车)。
我们的串口接收端需要做的是:**忽略\r,只保留\n**作为行结束标志。这样无论上位机发的是\r、\n还是\r\n,上层函数(如scanf、fgets)都能正确识别行结束。
5.2 完整实现代码
c
int fgetc(FILE *fp)
{
UNUSED(fp);
uint8_t ch;
while(1)
{
if(HAL_UART_Receive(&huart1, &ch, 1, HAL_MAX_DELAY) != HAL_OK)
{
return EOF;
}
// 读取成功了一个字符
if(ch != '\r')
{
return (int)ch;
}
}
}
逐行解析:
while(1):无限循环,因为串口接收是阻塞式的------没有数据到来时,就一直等。HAL_UART_Receive(&huart1, &ch, 1, HAL_MAX_DELAY):从串口1接收1个字节。如果没有数据,就阻塞等待;接收到数据后存入ch。- 如果接收失败(返回值不等于
HAL_OK),返回EOF。 - 关键逻辑 ------换行符处理:
- 如果
ch == '\r',不做任何处理,直接进入下一轮循环(继续读取下一个字符)。 - 如果
ch != '\r',说明读到了普通字符或\n,直接返回该字符。
- 如果
这个逻辑的精妙之处在于:
- 普通字符 :
ch != '\r'成立,直接返回,保持不变。 \r\n组合 :先读到\r,条件不成立,继续循环;再读到\n,ch != '\r'成立,返回\n。相当于把\r\n合并成一个\n。- 单独的
\n:ch != '\r'成立,直接返回\n,保持不变。 - 单独的
\r:条件不成立,继续循环等待下一个字符。
最终效果:无论上位机发什么换行组合,fgetc统一向上层返回\n作为行结束符。
5.3 为什么fgets和scanf会自动获得串口输入能力
前面已经讲过,scanf和fgets底层都调用fgetc来获取字符。一旦我们重写了fgetc,让它从串口读取,那么:
scanf("%d", &a)→ 内部调用fgetc逐个读字符 → 从串口读到'1'、'2'、'3'... → 转成整数存入a。fgets(inbuffer, 32, stdin)→ 内部调用fgetc逐个读字符 → 从串口读到字符串 → 存入inbuffer。
这就是重定向的威力------改一个底层函数,所有依赖它的高层函数自动生效。
六、完整测试与验证

6.1 测试程序完整代码
c
/* Includes */
#include "main.h"
/* Private includes */
#include <stdio.h>
/* USER CODE BEGIN 2 */
char inbuffer[32];
printf("Test begin...\n");
/* USER CODE END 2 */
/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
// v2版本:scanf测试
int a = 0;
scanf("%d", &a);
printf("ehco->%d\n", a);
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_8);
}
/* USER CODE END WHILE */
6.2 串口助手中的测试过程
打开COMTO串口助手,配置参数:
- 端口:COM6
- 波特率:115200
- 数据字节:8
- 奇偶校验:Odd
- 停止位:1
- 流控:None
测试一:printf格式化打印
烧写程序后,串口助手自动收到打印:
<= Test begin...
<= hello : 123, hello 3.140, hello: STM32F103
<= hello : 123, hello 3.140, hello: STM32F103
...
printf重定向成功。
测试二:scanf整数输入
在发送区输入123456,勾选<CRLF>(自动添加回车换行),点击发送:
=> 123456
<= ehco->123456
程序通过scanf从串口读到了整数123456,然后通过printf原样打印回来。注意:输入后一定要按回车 ,因为scanf("%d")需要遇到非数字字符(如\n)才会结束解析。
测试三:scanf整数溢出
输入一个超大的数值666677778888:
=> 666677778888
<= ehco->957848008
这个数值超出了32位int的范围(最大值约2147483647),所以发生了溢出,scanf解析出来的结果是957848008。这是正常的溢出行为,不是bug。
测试四:字符串输入(fgets)
如果你想测试字符串输入,可以使用fgets:
c
fgets(inbuffer, 31, stdin);
printf("ehco->%s\n", inbuffer);
在串口助手中输入hello world回车:
=> hello world
<= ehco->hello world
fgets能正确读取包含空格的完整字符串,并且灯会跟着闪烁一次,说明读取完成。
七、重定向的完整数据流总结
┌─────────────────────────────────────────────────────┐
│ 用户代码层 │
│ printf("%d", 12345) scanf("%d", &a) │
│ fgets(buf, 32, stdin) │
└──────────────────┬──────────────────┬───────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────┐
│ 标准I/O库层 │
│ printf → 格式化为字符序列 → 调用 fputc() │
│ scanf → 调用 fgetc()获取字符 → 格式化为数值 │
│ fgets → 调用 fgetc()获取字符 → 存入buffer │
└──────────────────┬──────────────────┬───────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────┐
│ 我们的重定向层(关键!) │
│ fputc() → HAL_UART_Transmit() 发送一个字符 │
│ fgetc() → HAL_UART_Receive() 接收一个字符 │
└──────────────────┬──────────────────┬───────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────┐
│ 硬件层(USART1) │
│ TX引脚发送数据 RX引脚接收数据 │
└─────────────────────────────────────────────────────┘
一句话总结 :标准I/O函数(printf/scanf/fgets)只关心"格式化"这件事,真正把数据搬上搬下硬件通道的脏活累活,全部交给fputc和fgetc。我们只要接管这两个底层函数,所有格式化I/O就自然拥有了串口通信能力。
结语
