第一个日志程序:从printf到串口输出

说实话,很多嵌入式新手一开始都会问:“调试信息怎么打印出来?”

答案很简单——printf。但问题来了,在单片机里,printf默认是往哪里打印的?标准输出?你的板子上可没有显示器啊。

所以,我们得把printf的输出“拐”到串口上去。这就是今天要干的事。

1. 为什么是printf?

我刚开始做嵌入式那会儿,调试全靠LED闪烁。一个灯闪两下代表变量A超了,闪三下代表变量B错了……后来项目复杂了,这种“灯语”根本不够用。

printf的好处很明显:

  • 格式化输出:直接打印变量值、字符串、十六进制,省去手动拼数据
  • 可读性强:日志里能加时间戳、函数名,一眼看出问题在哪
  • 移植方便:只要重定向底层输出函数,代码几乎不用改
我的习惯:项目初期就把printf调通,后面调试能省一半时间。别等到出bug了再临时加打印,那时候往往已经晚了。

2. 重定向printf到串口

说白了,printf最终会调用一个底层函数来输出字符。在C标准库中,这个函数通常是fputc(或putchar)。我们只需要把这个函数“劫持”掉,让它把字符发到串口就行了。

2.1 硬件准备

你需要一块开发板,串口已经初始化好。以STM32为例,串口初始化代码大致如下:

// 串口初始化(以USART1为例)
void USART1_Init(void)
{
    // 使能时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

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

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

    // 配置USART1
    USART_InitTypeDef usart;
    usart.USART_BaudRate = 115200;
    usart.USART_WordLength = USART_WordLength_8b;
    usart.USART_StopBits = USART_StopBits_1;
    usart.USART_Parity = USART_Parity_No;
    usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
    usart.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
    USART_Init(USART1, &usart);

    // 使能串口
    USART_Cmd(USART1, ENABLE);
}

2.2 重定向fputc

接下来是关键一步。在Keil或IAR环境下,重定向fputc

// 重定向printf到串口1
int fputc(int ch, FILE *f)
{
    // 等待发送缓冲区空
    while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
    // 发送一个字节
    USART_SendData(USART1, (uint8_t)ch);
    return ch;
}
我曾经踩过的坑:在GCC环境下(比如STM32CubeIDE),重定向方式略有不同。需要实现_write函数,而不是fputc。如果你用的是GCC,记得查一下对应的重定向方法,否则printf死活不输出。

2.3 测试一下

好了,现在可以写个简单的测试程序:

#include <stdio.h>

int main(void)
{
    USART1_Init();  // 初始化串口

    printf("Hello, Embedded World!\r\n");
    printf("System Clock: %d MHz\r\n", 72);
    printf("Log Level: %s\r\n", "DEBUG");

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

打开串口助手,波特率设成115200,你应该能看到三行输出。嗯,就是这么简单。

3. 实现简单的日志输出

光能打印还不够,我们得让日志有“层次感”。我习惯把日志分成几个级别:

级别 宏定义 说明
调试 LOG_DEBUG 开发阶段用,发布时关掉
信息 LOG_INFO 正常运行时的重要信息
警告 LOG_WARN 可能有问题,但还能继续跑
错误 LOG_ERROR 出错了,需要处理

基于这个分级,我们可以封装几个宏:

#define LOG_DEBUG(fmt, ...)  printf("[DEBUG] " fmt "\r\n", ##__VA_ARGS__)
#define LOG_INFO(fmt, ...)   printf("[INFO]  " fmt "\r\n", ##__VA_ARGS__)
#define LOG_WARN(fmt, ...)   printf("[WARN]  " fmt "\r\n", ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...)  printf("[ERROR] " fmt "\r\n", ##__VA_ARGS__)

使用起来非常直观:

int temperature = 25;
LOG_INFO("Current temperature: %d°C", temperature);

if (temperature > 80)
{
    LOG_WARN("Temperature too high!");
}

if (temperature > 100)
{
    LOG_ERROR("Overheat! Shutting down.");
}
你想想看:如果不用日志分级,所有打印混在一起,调试时满屏都是信息,根本分不清哪些是警告、哪些是错误。有了分级,一眼就能定位问题。

4. 核心逻辑流程图

下面这张图展示了从printf到串口输出的完整流程:

printf到串口输出流程图 应用程序调用 printf() C标准库 fputc() 重定向后的串口发送 串口硬件发送到PC 关键点 • 重定向fputc • 串口初始化 • 波特率匹配 常见问题 • 串口没输出? • 乱码?检查波特率 • 卡死?检查中断

5. 避坑指南

  • 乱码问题:串口波特率、数据位、停止位必须和PC端一致。我遇到过好几次,板子设的115200,串口助手设的9600,出来全是乱码。
  • 输出卡死:如果printf里用了浮点数(比如%f),而你的编译器没有开启浮点支持,程序可能会卡死。解决办法:要么开启浮点支持,要么避免在日志里用浮点。
  • 缓冲区溢出printf内部有缓冲区,如果连续大量打印,缓冲区可能满。实时性要求高的场合,建议用puts或直接写串口寄存器。
我的小技巧:在调试阶段,我会在日志里加上时间戳。比如用系统滴答定时器获取毫秒数,然后printf("[%lu] ", tick)。这样就能知道每条日志发生的精确时间,对排查时序问题特别有用。

好了,到这里你已经学会了如何用printf打印日志,并且把它重定向到串口。说白了,就是三步:初始化串口、重定向fputc、封装日志宏。下次调试的时候,别再只靠LED闪烁了,试试这个办法,你会爱上它的。


专注资料整理