【STM32 】把 printf 搬到串口上 ------ C 标准 IO 函数重定向与多文件工程搭建
1. 原理:为什么重写两个函数就够了
标准 C 库的底层 IO 是有"钩子"的:
printf底层是通过fputc向显示器一次写入一个字符;scanf/fgets底层是通过fgetc从键盘一次读取一个字符。所以重定向的思路非常简单:重写
fputc和fgetc这两个底层函数,把"写显示器 / 读键盘"换成"串口发送 / 串口接收",上层所有标准 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 例程)
cppwhile (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 编译验证
课件实测编译结果:
cppcompiling 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:020 错误 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 → fputc、scanf/fgets → fgetc,重写这两个底层函数,标准 IO 就"搬家"到了串口;- 硬件前提:电脑没有原生串口,靠 CH340K 做 USB 与 TTL 串口之间的"翻译官";
- 工程前提:勾选 MicroLIB,避开半主机模式;
- 模块化:BSP 驱动(inc 头文件 + src 源文件)独立成目录,Keil 里建组、加 .c、配 Include Paths,即可被任意工程复用;
- 成果:从此所有例程都可以
printf调试、scanf获取数据、串口控制外设------这也是后续所有章节示例的公共底座。****。


