串口通信:printf 重定向超详细教程(STM32 实战版)

0 阅读9分钟

目录

前言

一、printf 重定向核心原理

1.1 printf 的底层调用链

1.2 核心重定向函数:fputc

1.3 关键编译选项:关闭半主机模式

二、前期准备:串口初始化

三、实战 :标准库(STM32_StdPeriph_Lib)实现 printf 重定向

3.1 步骤 1:完成 USART1 初始化(标准库)

3.2 步骤 2:重写 fputc 函数(核心)

3.3 步骤 3:关闭半主机模式(MDK-ARM/Keil 编译器)

3.4 步骤 4:测试 printf 重定向

3.5 编译配置:启用 MicroLIB(可选,优化编译)

四、总结


前言

在 STM32 等嵌入式开发中,printf是最常用的调试手段之一 —— 通过串口将调试信息打印到电脑串口助手,能快速定位程序问题、查看变量状态。但嵌入式 MCU 的标准库中,printf默认指向主机标准输出(如电脑终端),并非串口,因此需要通过重定向底层输出函数,让printf的内容通过串口发送,这一过程就是printf重定向。

一、printf 重定向核心原理

要理解重定向,首先要理清printf的底层调用链路,这是实现重定向的基础,核心围绕C 标准库底层 I/O 函数展开。

1.1 printf 的底层调用链

printf是 C 标准库(stdio.h)中的格式化输出函数,其本身不负责具体的 “数据发送”,而是完成格式化字符串处理后,调用底层的字符输出函数将数据写入指定设备。完整调用链:

printf(格式化字符串) → vfprintf(格式化处理核心) → fputc(单个字符输出) → 底层硬件输出接口(如串口)

1.2 核心重定向函数:fputc

fputc是 C 标准库的底层字符输出函数,原型如下:

int fputc(int ch, FILE *f);

  • 功能:将单个字符ch写入文件流f
  • 参数:ch为要输出的字符(int 型兼容 ASCII 码),f为文件流(如标准输出stdout)
  • 返回值:成功返回写入的字符,失败返回EOF(-1)

默认的fputc实现针对主机标准输出,嵌入式中无此设备,因此我们需要重新实现 fputc 函数(函数名、参数、返回值必须与标准一致,否则无法被printf调用),在新的实现中,将字符ch通过串口发送函数写入串口数据寄存器,完成重定向。

1.3 关键编译选项:关闭半主机模式

嵌入式开发中,若直接使用标准库printf而不处理,编译器会默认启用半主机模式(Semihosting) —— 半主机是一种调试技术,让 MCU 通过调试器(如 J-Link、ST-Link)借用主机的 I/O 设备(屏幕、串口)完成输入输出,这会导致两个问题:

  • 程序脱离调试器后无法运行(半主机依赖调试链路)
  • 与我们自定义的串口输出冲突,导致printf无输出

因此,必须关闭半主机模式,这是重定向成功的前提,不同编译器(MDK-ARM/Keil、GCC)的关闭方式略有差异,后续实战部分会详细说明。

二、前期准备:串口初始化

重定向的本质是让printf通过串口输出,因此必须先完成串口的底层初始化—— 配置串口的波特率、数据位、停止位、校验位,使能串口时钟和 GPIO 时钟,确保串口硬件可以正常收发数据。

串口初始化是嵌入式基础,核心配置要点:

  • 使能串口对应 GPIO 口时钟和串口外设时钟
  • 配置 GPIO 为复用推挽输出(TX 引脚)、浮空输入(RX 引脚,若无需接收可省略)
  • 配置串口初始化结构体:波特率(常用 9600/115200)、8 位数据位、1 位停止位、无校验
  • 使能串口外设

注意:以下实战部分均以 STM32F103C8T6(最常用的 STM32 入门芯片)、USART1 为例,其他芯片 / 串口可直接替换对应引脚和外设名。

三、实战 :标准库(STM32_StdPeriph_Lib)实现 printf 重定向

标准库是 STM32 早期的经典开发库,寄存器封装程度适中,适合理解硬件底层,目前仍有大量工程在使用。

3.1 步骤 1:完成 USART1 初始化(标准库)

创建串口初始化函数,配置 GPIO 和 USART1,波特率设为 115200(串口助手需对应),参考代码如下:

#include "stm32f10x.h"
#include "stdio.h"  // 必须包含,否则无法识别printf/fputc

// USART1初始化:波特率115200,8N1,仅TX(PA9),RX可选(PA10)
void USART1_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct;
    USART_InitTypeDef USART_InitStruct;
    NVIC_InitTypeDef NVIC_InitStruct;  // 若无需中断,可省略

    // 1. 使能时钟:GPIOA时钟、USART1时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE);

    // 2. 配置TX引脚(PA9):复用推挽输出
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9;
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;  // 复用推挽:串口专用输出模式
    GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStruct);

    // 可选:配置RX引脚(PA10):浮空输入
    // GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10;
    // GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    // GPIO_Init(GPIOA, &GPIO_InitStruct);

    // 3. 配置USART1参数:115200-8N1
    USART_InitStruct.USART_BaudRate = 115200;  // 波特率,与串口助手一致
    USART_InitStruct.USART_WordLength = USART_WordLength_8b;  // 8位数据位
    USART_InitStruct.USART_StopBits = USART_StopBits_1;        // 1位停止位
    USART_InitStruct.USART_Parity = USART_Parity_No;          // 无校验
    USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None; // 无硬件流控
    USART_InitStruct.USART_Mode = USART_Mode_Tx;  // 仅发送模式,若需接收加USART_Mode_Rx
    USART_Init(USART1, &USART_InitStruct);

    // 可选:使能串口中断(若需接收中断)
    // USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);  // 使能接收非空中断
    // NVIC_InitStruct.NVIC_IRQChannel = USART1_IRQn;
    // NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1;
    // NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0;
    // NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
    // NVIC_Init(&NVIC_InitStruct);

    // 4. 使能USART1外设
    USART_Cmd(USART1, ENABLE);
}

3.2 步骤 2:重写 fputc 函数(核心)

按照标准fputc的原型,重新实现函数,将字符通过USART_SendData发送到串口,必须等待串口发送完成(避免数据丢失):

// 重写fputc函数:将字符通过USART1发送,实现printf重定向
int fputc(int ch, FILE *f)
{
    // 1. 等待串口数据寄存器为空(TXE位:发送寄存器空,可写入新数据)
    while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
    
    // 2. 将字符写入串口数据寄存器,完成发送
    USART_SendData(USART1, (uint8_t)ch);
    
    // 3. 处理换行:printf的\n是换行,无回车,串口助手需\r\n才会换行+回车
    if (ch == '\n')
    {
        while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
        USART_SendData(USART1, '\r');
    }
    
    return ch;  // 按标准返回写入的字符
}

关键细节

  • 必须等待USART_FLAG_TXE置 1(发送寄存器空),否则连续发送会覆盖未发送的数据,导致乱码;
  • printf\n仅为换行(New Line),无回车(Carriage Return),而串口助手(如串口调试助手、SecureCRT)默认需要\r\n才会同时换行 + 回车,因此手动补充\r,避免打印内容只换行不回车。

3.3 步骤 3:关闭半主机模式(MDK-ARM/Keil 编译器)

标准库开发主要使用 MDK-ARM/Keil 编译器,关闭半主机模式有两种方法,任选其一即可,推荐方法 1(代码实现,无需修改编译器配置,移植性更强)。

方法 1:代码实现(推荐,跨工程可直接移植

在代码中添加半主机模式关闭的宏定义和函数,直接放在fputc函数下方即可:

// 关闭半主机模式:MDK-ARM/Keil编译器专用
#pragma import(__use_no_semihosting)

// 半主机模式所需的底层函数,关闭后需定义,避免链接错误
struct __FILE
{
    int handle;
    // 其他成员可省略
};
FILE __stdout;  // 定义标准输出流,避免printf链接错误

// 半主机模式的_exit函数,关闭后需重定义
void _exit(int x)
{
    x = x;  // 空实现,避免警告
}

// 半主机模式的fgetc函数,若无需scanf,可空实现
int fgetc(FILE *f)
{
    return 0;
}

// 半主机模式的_sys_open函数,关闭后需重定义
int _sys_open(const char *name, int mode, int access)
{
    return 0;
}

方法 2:编译器配置实现(需手动修改,适合单工程)

  1. 打开 Keil 工程,点击Magic Wand(魔法棒)→TargetCode Generation
  2. 取消勾选Use MicroLIB(若已勾选),然后在Misc Controls中添加编译选项:--no_semihosting
  3. 点击OK保存配置,重新编译即可。

3.4 步骤 4:测试 printf 重定向

main函数中初始化 USART1,然后直接调用printf即可,代码示例:

int main(void)
{
    // 系统初始化(标准库必加,配置系统时钟,如72MHz)
    SystemInit();
    // 初始化USART1
    USART1_Init();
    
    // 测试printf重定向
    printf("STM32标准库 printf重定向成功!\r\n");
    printf("波特率:115200 8N1\r\n");
    printf("当前数值:num = %d, float = %.2f\r\n", 100, 3.14f);
    
    while (1)
    {
        // 循环打印测试
        printf("循环打印:%d\r\n", i++);
        delay_ms(1000);  // 需自行实现延时函数,如SysTick延时
    }
}

3.5 编译配置:启用 MicroLIB(可选,优化编译)

MDK-ARM/Keil 编译器中,标准 C 库(stdio.h)体积较大,嵌入式中可使用MicroLIB(微库)—— 专为嵌入式优化的轻量级 C 库,体积小、运行效率高,完美支持printf/fputc

启用方法:

  1. 点击 Keil 魔法棒→TargetCode Generation
  2. 勾选Use MicroLIB,点击OK
  3. 重新编译工程(启用后可省略部分半主机关闭代码,更简洁)

四、总结

printf重定向虽是小技术,却体现了嵌入式开发中 “将通用工具适配到特定环境” 的核心思想。掌握这一技术,不仅能让调试工作事半功倍,更能加深对C语言标准库、编译链接过程和硬件底层操作的理解,是每位嵌入式工程师成长道路上的必修课