为什么需要 printf 重定向?

在单片机开发中,调试信息输出至关重要。虽然可以使用 UART 直接发送字符串,但 printf 提供了格式化输出的强大能力,如整数、浮点、十六进制等,能极大简化日志记录。然而,标准 C 库的 printf 默认输出到 stdout,而单片机没有操作系统,需要手动将 stdout 重定向到 UART 外设。

原理剖析:printf 的底层机制

printf 是标准 C 库函数,它内部调用 fputc(或 putc)来输出单个字符。在桌面系统中,fputc 由操作系统实现;在嵌入式环境中,我们需要自己实现 fputc,将字符发送到 UART 的发送寄存器。这就是重定向的核心。

对于浮点支持,标准 C 库(如 newlib)默认不启用浮点格式化,以节省代码空间。需要手动链接浮点库(如 -u _printf_float)或使用微库(MicroLib)来启用。

配置步骤:以 STM32 为例

1. 初始化 UART

首先,确保 UART 外设已正确初始化,波特率、数据位等设置无误。以下以 STM32F103 的 USART1 为例:

// USART1 初始化,波特率 115200
void USART1_Init(void) {
    GPIO_InitTypeDef GPIO_InitStructure;
    USART_InitTypeDef USART_InitStructure;

    // 使能时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE);

    // TX (PA9) 复用推挽输出
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    // RX (PA10) 浮空输入
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    // USART 配置
    USART_InitStructure.USART_BaudRate = 115200;
    USART_InitStructure.USART_WordLength = USART_WordLength_8b;
    USART_InitStructure.USART_StopBits = USART_StopBits_1;
    USART_InitStructure.USART_Parity = USART_Parity_No;
    USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
    USART_InitStructure.USART_Mode = USART_Mode_RX | USART_Mode_TX;
    USART_Init(USART1, &USART_InitStructure);

    USART_Cmd(USART1, ENABLE);
}

2. 实现 fputc 重定向

在 C 文件中添加以下代码,将 printf 输出重定向到 USART1:

#include <stdio.h>

// 重定向 printf 到 USART1
int fputc(int ch, FILE *f) {
    // 等待发送缓冲区空
    while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
    USART_SendData(USART1, (uint8_t)ch);
    return ch;
}

注意:如果使用 Keil MDK,需要勾选 "Use MicroLIB" 以支持重定向。若使用 GCC,可能需要额外处理。

3. 启用浮点支持

  • Keil MDK:在 Options for Target -> Target 标签页,勾选 "Use MicroLIB",然后编译时添加 --printf_float 选项(或在代码中声明 #pragma import(__use_no_semihosting) 并实现 _sys_exit)。更简单的方法是使用 MicroLIB,它默认支持浮点。
  • GCC (arm-none-eabi):在链接时添加 -u _printf_float-u _scanf_float 选项,例如:arm-none-eabi-gcc -u _printf_float -o output.elf ...

4. 完整示例代码

以下是一个完整的示例,包含初始化、重定向和浮点输出:

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

// 初始化 USART1
void USART1_Init(void) {
    // ... 如上所示 ...
}

// 重定向 printf
int fputc(int ch, FILE *f) {
    while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
    USART_SendData(USART1, (uint8_t)ch);
    return ch;
}

int main(void) {
    float temperature = 25.36;
    int count = 42;

    USART1_Init();

    printf("System started!\n");
    printf("Count: %d, Temperature: %.2f°C\n", count, temperature);

    while (1) {
        // 主循环
    }
}

注意事项与常见问题

  • 缓冲区问题:printf 默认使用行缓冲,但在嵌入式环境中,可能没有实现缓冲。如果输出不完整,可以尝试在 fputc 中直接发送,或使用 setvbuf 设置无缓冲。
  • 浮点支持代价:启用浮点 printf 会增加代码大小(约 10-20KB),在资源受限的 MCU 上需谨慎。如果不需要浮点,建议使用整数格式化或自定义转换。
  • 重定向冲突:如果同时使用 printfsprintf,确保重定向只影响 stdout,不影响其他流。
  • 中断安全:如果在中断中调用 printf,需确保 UART 发送不被打断,否则可能丢失数据。建议使用环形缓冲区。
  • 硬件流控:如果使用 RTS/CTS,需在初始化时配置,否则可能通信异常。

进阶技巧:使用环形缓冲区

对于高频日志输出,直接阻塞式发送会浪费 CPU。可以改用中断或 DMA + 环形缓冲区,实现非阻塞 printf。例如,使用一个 FIFO,fputc 将字符放入缓冲区,UART 中断负责发送。这样 printf 几乎不占用 CPU 时间。

// 简单环形缓冲区示例(伪代码)
#define BUF_SIZE 256
volatile uint8_t buf[BUF_SIZE];
volatile uint16_t head = 0, tail = 0;

int fputc(int ch, FILE *f) {
    uint16_t next = (head + 1) % BUF_SIZE;
    while (next == tail); // 等待空间
    buf[head] = ch;
    head = next;
    // 触发 UART 中断发送
    USART_ITConfig(USART1, USART_IT_TXE, ENABLE);
    return ch;
}

总结

printf 重定向是嵌入式调试的基石。通过实现 fputc 并启用浮点支持,开发者可以轻松输出格式化的调试信息。本文提供了完整的配置步骤和代码示例,并指出了常见陷阱。掌握这一技术,将显著提升开发效率。