【STM32 】把 printf 搬到串口上 —— C 标准 IO 函数重定向与多文件工程搭建(实战串口电灯)


【STM32 】把 printf 搬到串口上 ------ C 标准 IO 函数重定向与多文件工程搭建


1. 原理:为什么重写两个函数就够了

标准 C 库的底层 IO 是有"钩子"的:

  • printf 底层是通过 fputc 向显示器一次写入一个字符;
  • scanf / fgets 底层是通过 fgetc 从键盘一次读取一个字符。

所以重定向的思路非常简单:重写 fputcfgetc 这两个底层函数,把"写显示器 / 读键盘"换成"串口发送 / 串口接收",上层所有标准 IO 函数就自动跟着走了,一行都不用改。

顺带一提:嵌入式板卡种类繁多,具体串口、引脚都不一样,所以"根据具体情况重新适配"是嵌入式开发的常态------这正是本课的核心。


2. 硬件原理:USB 转串口(CH340K)

开发板上的串口电路使用了一颗 CH340K(USB 转 USART)芯片,等效于一个"USB 转 UART TTL"模块,板上已经集成好了,无需外接转换模块。

为什么需要它?

现在的电脑(尤其是笔记本)几乎没有原生串口接口,而 MCU(如 STM32)只认识 TTL 串口电平信号,二者无法直接对话,所以需要一个"翻译官"------CH340K 负责在 USB 协议和串口电平之间互转。

连接关系:

  • 电脑 USB 口 ↔ CH340K ↔ STM32(USART1)
  • TX 与 RX 交叉连接(CH340K 的 TX 接 MCU 的 RX,CH340K 的 RX 接 MCU 的 TX)
  • GND 共地

对软件应用来讲,电脑上看到的是串口(虚拟 COM 口),而实际物理链路是 USB------这是为了解决电脑没有标配串口的问题。


补充:DAP 和 SWD 是什么关系?

课件里用了一个很形象的比喻:

  • DAP(Debug Access Port,调试访问端口)是一个物理仿真器硬件;
  • SWD(Serial Wire Debug,串行线调试)是一种调试通信协议。

比喻:DAP = 考试时坐在座位上的同学本人;

SWD = 用来传递纸条的通道 + 约定好的手势暗号。

重要提醒:在 CubeMX 中如果 Debug 接口配置错误(如选了 No Debug),烧录完程序后可能无法再通过调试器连接 STM32------第一次烧录能成功,后续烧录就会失败。


3. CubeMX 工程配置(五步)

步骤 配置位置 选择/填写 作用
System Core → SYS → Debug Serial Wire 保留 SWD 调试口,避免二次烧录失败
System Core → RCC HSE 选 Crystal/Ceramic Resonator 使用板上 8MHz 外部晶振
Clock Configuration PLL 源 HSE 8MHz,倍频 x9 → 72MHz 系统主频跑到最大 72MHz
Connectivity → USART1 Asynchronous 异步模式 打开串口,自动配置 PA9=TX、PA10=RX
GPIO 配置 LED4(推挽输出) 后面验证用(引脚以自己板子原理图为准)

时钟树关键数值:

SYSCLK = 72MHz,HCLK(AHB /1)= 72MHz,

PCLK1(APB1 /2)= 36MHz,PCLK2(APB2 /1)= 72MHz。


USART1 参数详解

参数 配置值 含义
Mode Asynchronous 异步通信(最核心的 UART 模式)
Hardware Flow Control Disable 不使用硬件流控,只走 Rx/Tx
Baud Rate 115200 常见波特率
Word Length 9 Bits (including Parity) 8 位数据 + 1 位校验
Parity Odd 奇校验
Stop Bits 1 1 位停止位
Data Direction Receive and Transmit 收发模式(也可只收/只发)
Over Sampling 16 Samples 过采样,USART 接收器识别输入信号的技术

使能串口后,CubeMX 会自动配置对应引脚(PA9 → USART1_TX,PA10 → USART1_RX),这就是"使能外设即自动配脚"的体现。

最后在 Project Manager 中填写工程名(如 USART1_test),Toolchain/IDE 选 MDK-ARM V5.32,点击 GENERATE CODE 生成工程。


4. 重定向代码实现

4.1 勾选 MicroLIB

在 Keil 中:Options for Target → Target → 勾选 Use MicroLIB。

  • MicroLIB 是面向嵌入式系统的精简版 C 运行库,体积小、效率高;
  • 不勾选时,printf 默认走"半主机模式"(Semihosting)输出到调试器,串口上什么都看不到;
  • 这个库已经和 Keil 深度捆绑,在 Keil 安装目录下看不到库本体,但能看到对应的 C 头文件。

4.2 重写 fputc / fgetc

cpp 复制代码
/* 串口重定向:printf 底层调用 */
int fputc(int ch, FILE *f)
{
    uint8_t data = (uint8_t)ch;

    if (HAL_UART_Transmit(&huart1, &data, 1, HAL_MAX_DELAY) == HAL_OK) {
        return ch;      // 发送成功,返回字符
    } else {
        return EOF;     // 发送失败
    }
}

/* 串口重定向:scanf / fgets 底层调用 */
int fgetc(FILE *f)
{
    uint8_t ch = 0;

    while (1) {
        if (HAL_UART_Receive(&huart1, &ch, 1, HAL_MAX_DELAY) != HAL_OK) {
            return EOF;
        }
        // 忽略 '\r',只保留 '\n'(串口助手发送常带 \r\n)
        if (ch != '\r') {
            return (int)ch;
        }
    }
}

逐行解读:

  • fputc:把 printf 送来的每个字符转成 uint8_t,通过 HAL_UART_Transmit 从串口发出一个字节;成功返回原字符(标准库期望的行为),失败返回 EOF
  • fgetc:死循环等待串口收到一个字节;上位机发送的数据通常以 \r\n 结尾,这里忽略 \r、只保留 \n,这样 fgets 才能正确按行工作。

4.3 主循环验证(echo 例程)

cpp 复制代码
while (1)
{
    char buffer[64];
    fgets(buffer, 63, stdin);                 // 从串口读一行
    printf("echo# %s\n", buffer);             // 原样回显给上位机
    HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_8);    // 翻转 LED 做指示
}

烧录后,打开串口助手(SSCOM / XCOM 等),波特率选 115200,发什么就能看到 MCU 回显 echo# ...,同时开发板 LED 翻转------说明 printf / fgets 已经成功"搬家"到串口。


4.4 编译验证

课件实测编译结果:

cpp 复制代码
compiling BSP_Usart_Redir.c...
linking...
Program Size: Code=4662 RO-data=378 RW-data=20 ZI-data=1108
"Usart_Redir_test\Usart_Redir_test.axf" - 0 Error(s), 0 Warning(s).
Build Time Elapsed: 00:00:02

0 错误 0 警告,程序体积也很小。


5. 多文件模块化工程

每次都把重定向代码写在 main.c 里太乱,而且下一个工程想复用还得复制粘贴。正确的做法是做成 BSP(Board Support Package,板级支持包)驱动模块,独立成目录,随用随引。

5.1 目录结构

复制代码
BSP_Drivers/
├── inc/                  ← 头文件目录
│   └── BSP_Usart_Redir.h
└── src/                  ← 源文件目录
    └── BSP_Usart_Redir.c

5.2 头文件 BSP_Usart_Redir.h

cpp 复制代码
#ifndef __BSP_USART_REDIR_H     // 防止头文件被重复包含
#define __BSP_USART_REDIR_H

#ifdef __cplusplus
extern "C" {
#endif

#include <stdio.h>
#include "stm32f1xx_hal.h"

extern UART_HandleTypeDef *ghuart;              // 全局串口句柄
void BSP_Usart_Init(UART_HandleTypeDef *huart); // 把串口句柄注入本模块

#ifdef __cplusplus
}
#endif

#endif

几个关键点:

  • #ifndef / #define / #endif:防止头文件重复包含的标准写法;
  • stm32f1xx_hal.h:HAL 库的总入口头文件,包含所有外设驱动、类型定义和核心功能,包含它一个就能用整个 HAL 库;
  • extern "C":告诉 C++ 编译器,这里面的函数按 C 语言规则编译和链接。原因是 C++ 编译器会对函数名"改名"(如 func 变成 Z4funcv),而 C 编译器不改名;加上 extern "C" 就是让 C++ 编译器"别改名、按 C 的方式找函数",否则链接时会因名字不匹配而报错(HAL 库里也有这个,是因为用户可能用 C++ 开发);
  • extern UART_HandleTypeDef *ghuart:模块内部的全局串口句柄,通过 BSP_Usart_Init 注入,这样模块不依赖具体哪个串口。

5.3 源文件 BSP_Usart_Redir.c

cpp 复制代码
#include "BSP_Usart_Redir.h"

UART_HandleTypeDef *ghuart = NULL;

void BSP_Usart_Init(UART_HandleTypeDef *huart)
{
    if (huart == NULL)
        return;
    ghuart = huart;
}

int fputc(int ch, FILE *f)
{
    if (ghuart != NULL) {
        uint8_t data = (uint8_t)ch;
        if (HAL_UART_Transmit(ghuart, &data, 1, HAL_MAX_DELAY) == HAL_OK)
            return ch;
    }
    return EOF;
}

int fgetc(FILE *f)
{
    uint8_t ch = 0;
    while (1) {
        if (HAL_UART_Receive(ghuart, &ch, 1, HAL_MAX_DELAY) != HAL_OK)
            return EOF;
        if (ch != '\r')        // 忽略 \r,只保留 \n
            return (int)ch;
    }
}

相比 4.2 的版本,这里不再写死 &huart1,而是用指针 ghuart------换串口只需在 main 里改一行 BSP_Usart_Init(&huart2),通用性大幅提升。


5.4 在 Keil 中引入模块(四步)

① 新建组:

右键工程名 → Add Group...,命名随意(如 Common_BSP_Drivers)。组只是一个逻辑概念,用于组织文件,不影响编译结果。

② 添加源文件:

右键新组 → Add Existing Files to Group...,选中 BSP_Usart_Redir.c 添加。只添加 .c 源文件,头文件不进工程(编译就出现了)

③ 添加头文件搜索路径:

Options for Target → C/C++ (AC6) → Include Paths,添加..\BSP_Drivers\inc

为什么必须加路径?

C 头文件在预处理阶段要做"头文件展开",本质是把头文件内容拷贝进源文件;

要拷贝,编译器就必须先找到这个头文件,要找到就必须有搜索路径。

踩坑提示:

添加完路径后多确认几次(多点几下 OK),Keil 有偶发 bug------点了 OK 实际没保存。

④ 编译:

可以看到 compiling BSP_Usart_Redir.c...,说明源文件已被正确编译进工程,0 Error 0 Warning。


6. 进阶:用串口控制 LED

有了重定向和模块化基础,把 LED 也做成 BSP 模块,然后通过串口指令控制 LED 闪烁。

6.1 BSP_LED.h

cpp 复制代码
#ifndef __BSP_LED_H
#define __BSP_LED_H

#ifdef __cplusplus
extern "C" {
#endif

#include <stdbool.h>
#include "stm32f1xx_hal.h"

typedef enum
{
    LED1 = 1,
    LED2,
    LED3,
    LED4,
    ALL
} LED_t;

void BSP_LED_On(LED_t led);
void BSP_LED_Off(LED_t led);
void LED_Blink(LED_t led);

#ifdef __cplusplus
}
#endif

#endif

6.2 BSP_LED.c

cpp 复制代码
#include "BSP_LED.h"

// BSP:Board Support Package(板级支持包)
// flag: true -> 点亮,false -> 熄灭(开发板 LED 低电平点亮)
static void BSP_LED_Control(LED_t led, bool flag)
{
    GPIO_PinState state = flag ? GPIO_PIN_RESET : GPIO_PIN_SET;
    switch (led)
    {
    case LED1:
        HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, state);
        break;
    case LED2:
        HAL_GPIO_WritePin(GPIOF, GPIO_PIN_9, state);
        break;
    case LED3:
        HAL_GPIO_WritePin(GPIOF, GPIO_PIN_10, state);
        break;
    case LED4:
        HAL_GPIO_WritePin(GPIOF, GPIO_PIN_11, state);
        break;
    default:
        break;
    }
}

void BSP_LED_On(LED_t led)  { BSP_LED_Control(led, true); }
void BSP_LED_Off(LED_t led) { BSP_LED_Control(led, false); }

void LED_Blink(LED_t led)
{
    if (led == ALL)
    {
        LED_t leds[] = {LED1, LED2, LED3, LED4};
        int leds_num = sizeof(leds) / sizeof(leds[0]);
        int cnt = 3;
        do
        {
            for (int i = 0; i < leds_num; i++)
                BSP_LED_On(leds[i]);
            HAL_Delay(500);
            for (int i = 0; i < leds_num; i++)
                BSP_LED_Off(leds[i]);
            HAL_Delay(500);
        } while (cnt--);          // 全部灯闪 3 轮
    }
    else
    {
        for (int i = 0; i < 3; i++)   // 单灯闪 3 次
        {
            BSP_LED_On(led);
            HAL_Delay(500);
            BSP_LED_Off(led);
            HAL_Delay(500);
        }
    }
}

注意 flag=true(点亮)时写的是 GPIO_PIN_RESET------开发板 LED 是低电平点亮,具体极性以自己板子的原理图为准。


6.3 main 中串口控制

cpp 复制代码
#include "BSP_Usart_Redir.h"
#include "BSP_LED.h"
#include <string.h>

char who[32];

int main(void)
{
    /* HAL_Init / SystemClock_Config / MX_GPIO_Init / MX_USART1_UART_Init 由 CubeMX 生成 */
    BSP_Usart_Init(&huart1);   // 注入串口句柄

    while (1)
    {
        printf("开始闪灯控制...\n");
        Usart_Ctrl_LED_Blink();
    }
}

void Usart_Ctrl_LED_Blink(void)
{
    fgets(who, 31, stdin);     // 从串口读一行指令

    if (strncasecmp("led1", who, strlen("led1")) == 0)
        LED_Blink(LED1);
    // 可继续扩展 led2 / led3 / led4 / all 分支
}

上位机输入 led1 回车,开发板 LED1 就会闪烁 3 次------一套"串口指令控制外设"的最小框架就搭好了。


7. 踩坑与经验总结

现象 原因 解决办法
第一次能烧录,之后烧录失败 CubeMX 的 Debug 没选 Serial Wire,调试口被禁用/复用 配置时选 Serial Wire
printf 在串口上没输出 没勾选 MicroLIB(走了半主机模式) Options → Target → Use MicroLIB
编译报 fatal error: BSP_Usart_Redir.h: No such file 头文件搜索路径没加 C/C++ → Include Paths 添加 ..\BSP_Drivers\inc
链接报 undefined symbol / 重定向无效 组里只加了头文件、漏加 .c 源文件 Add Existing Files 添加 .c
串口助手收不到/乱码 波特率或校验位与代码不一致 统一为 115200、奇校验、9 位数据

8. 小结

  • 重定向的本质:printf → fputcscanf/fgets → fgetc,重写这两个底层函数,标准 IO 就"搬家"到了串口;
  • 硬件前提:电脑没有原生串口,靠 CH340K 做 USB 与 TTL 串口之间的"翻译官";
  • 工程前提:勾选 MicroLIB,避开半主机模式;
  • 模块化:BSP 驱动(inc 头文件 + src 源文件)独立成目录,Keil 里建组、加 .c、配 Include Paths,即可被任意工程复用;
  • 成果:从此所有例程都可以 printf 调试、scanf 获取数据、串口控制外设------这也是后续所有章节示例的公共底座。****。

相关推荐
Dawson Zhu1 小时前
Agent 记忆调用的上下文感知:从“能召回“到“敢开口“的决策框架
人工智能·语言模型·架构·aigc·agi
wuminyu1 小时前
Kafka利用sendfile与Page Cache实现高性能传输剖析
java·linux·c语言·jvm·c++
流浪0011 小时前
大厂 C/C++ 2023–2025 面试答案题汇总
开发语言·c++
Yanjun2i1 小时前
Agent学习记录六:Tool 类 + Tool Registry
开发语言·python·学习
一条泥憨鱼1 小时前
【从0开始学习计算机网络】| TIME_WAIT 为什么是 2MSL,CLOSE_WAIT / TIME_WAIT 堆积怎么排查
服务器·开发语言·网络·计算机网络·网络安全
(Charon)1 小时前
【C++】 定时器入门:从定时任务到 epoll 驱动
开发语言·c++
FW-Linker1 小时前
多卡聚合的数据包级并行传输原理在广电直播场景中详解
网络·5g·架构·智能路由器
亮_一个嵌入式新手1 小时前
C语言Day21
c语言·开发语言
kcuwu.1 小时前
第 1 课 · Hello, World 与一个 Go 程序的诞生
开发语言·后端·golang