STM32 USART 详解(八):C I/O 串口重定向原理与实现

目录

🎬 云泽Q个人主页
🔥 专栏传送入口 : 《C语言》《数据结构》《C++》《Linux》《蓝桥杯系列》《笔试算法》《AI赋能》《STM32》《Python

⛺️遇见安然遇见你,不负代码不负卿~


前言

大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~

一、串口重定向C I/O接口的核心原理与实现

1.1 为什么要做重定向

我们在前面已经完成了串口的第一个主题------基本读写。但光能发能收还不够,我们在实际开发中,肯定需要借助串口来支持各种调试工作,甚至要用串口来进行对应的逻辑控制。

问题在于:用基本的串口读写接口,操作起来特别不方便。每次发个数据,你得组装发送缓冲、指定长度、调用发送函数,接收同理。这种"裸操作"在调试阶段效率很低。

于是我们就有了第二个主题------重定向C I/O接口

你可能在以前学C语言时用过printfscanffgets这些函数。我们要做的,就是把这些标准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去输出。这就是为什么我们说fputcprintf的底层函数。

再看另一种情况:

c 复制代码
printf("%d", 12345);

这里直接传了一个常量12345,效果和上面一样------先格式化成5个字符,再逐个通过fputc输出到字符设备。

核心结论 :不管你怎么调用printf,它最终都会落到fputc这个函数上。所以我们只要改写fputc的底层实现,让它走USART(串口)而不是走默认的显示器设备,printf的输出就自然被重定向到串口了。

2.2 scanf的底层机制

scanfprintf的逆过程------它是一个进行格式化输入的函数

c 复制代码
int a = 0;
scanf("%d", &a);

当你在上位机(串口助手)输入"12345"并回车后,实际发生的事情是:

  • 步骤a :底层通过fgetc函数,一个字符一个字符地 获取输入,即依次读到'1''2''3''4''5'这5个字符。
  • 步骤bscanf内部再把这些字符转成int类型 ,最终把整数12345写入变量a所在的内存(占用4字节)。

核心结论 :和printf依赖fputc一样,scanf的底层依赖的是fgetc。所以我们只要改写fgetc,让它从串口读取字符,scanf就能自然地从串口获取输入。

同理,fgets的底层也是fgetc。一旦fgetc被重定向到串口,scanffgets就同时获得了从串口读取数据的能力。

三、配置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接口,允许我们通过重写fputcfgetc来接管标准I/O的输入输出通道。

如果不勾选MicroLIB,即便你重写了fputcfgetcprintf也不会走你的自定义函数,而是走默认的半主机模式,程序会直接卡住。

四、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,上层函数(如scanffgets)都能正确识别行结束。

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,直接返回该字符。

这个逻辑的精妙之处在于:

  1. 普通字符ch != '\r'成立,直接返回,保持不变。
  2. \r\n组合 :先读到\r,条件不成立,继续循环;再读到\nch != '\r'成立,返回\n。相当于把\r\n合并成一个\n
  3. 单独的\nch != '\r'成立,直接返回\n,保持不变。
  4. 单独的\r:条件不成立,继续循环等待下一个字符。

最终效果:无论上位机发什么换行组合,fgetc统一向上层返回\n作为行结束符。

5.3 为什么fgets和scanf会自动获得串口输入能力

前面已经讲过,scanffgets底层都调用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)只关心"格式化"这件事,真正把数据搬上搬下硬件通道的脏活累活,全部交给fputcfgetc。我们只要接管这两个底层函数,所有格式化I/O就自然拥有了串口通信能力。


结语

相关推荐
ThornArmor2 小时前
重铸1996|肉体渲染:关节的裂缝:分段模型积木拼装与未成熟的 RSP 矩阵骨骼动画形变
c语言·汇编·c++·python·硬件架构·游戏机
T1mzhou2 小时前
ARM64 Linux 6.10 内核驱动(12):dd.c:probe/ remove、延迟 probe
linux·服务器·c语言·arm开发·arm
Doraemomo3 小时前
IMX6ULL裸机开发——系统主频和时钟配置
arm开发·嵌入式硬件·makefile·imx6ull
白色的北极熊3 小时前
c语言 scanf 输入流有空格,逗号 说明
c语言
Logic1013 小时前
C语言/数据结构位运算题解:异或XOR找出货船中的“独特载货量“——只出现一次的数字
c语言·数据结构·数组·位运算·时间复杂度·算法题·异或性质
深圳市恒锐丰科技杨生4 小时前
EG1192H 零功耗使能集成上管非同步降压 DC‑DC|屹晶 EGmicro
嵌入式硬件·硬件工程
星栖与芯5 小时前
LiteOS-M 切换汇编逐行图解(1):汇编是什么·寄存器与栈
汇编·stm32·嵌入式硬件·harmonyos·鸿蒙系统
xx~t6 小时前
嵌入式——单片机2
嵌入式硬件·学习·51单片机·串口·蜂鸣器
JNX_SEMI7 小时前
OC5138T 8V~100V超宽输入降压恒流驱动:覆盖12V汽车至72V电动车全场景
单片机·嵌入式硬件