4. 串口打印调试(UART)

串口打印,说白了就是嵌入式世界的“printf”。你想想看,一个单片机跑起来,里面到底发生了什么?寄存器配对了没有?中断进来了没有?状态机走到哪一步了?没有串口打印,你只能靠猜。我做了这么多年固件,可以负责任地告诉你:一个没有串口调试接口的裸机项目,就像在黑夜里开车还不开灯。

这一章,我们就来把UART调试接口彻底搞透。从初始化到printf重定向,从环形缓冲区到日志分级,一步到位。

4.1 UART初始化——别让波特率坑了你

UART初始化其实不复杂,但有几个细节容易翻车。我个人习惯把初始化拆成三步:

  1. 时钟使能——GPIO时钟和USART时钟都要开
  2. GPIO配置——TX推挽复用,RX浮空输入或上拉输入
  3. USART配置——波特率、数据位、停止位、校验位

这里我贴一段我常用的初始化代码,STM32F103为例:

void UART_Init(void)
{
    // 1. 使能时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

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

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

    // 3. USART配置
    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);
}
我曾经踩过的坑:波特率设置不对,打印出来全是乱码。后来发现是外部晶振频率和库函数默认值不一致。记住:检查HSE_VALUE宏定义,确保它和你板子上的晶振一致。

4.2 printf重定向——把printf“骗”到串口上

裸机环境下,printf默认输出到标准输出,也就是显示器。但我们没有显示器,只有串口。怎么办?重定向!

说白了,就是告诉编译器:“你别管printf往哪写,我来接管底层输出函数。”在ARM MDK环境下,重写fputc函数即可:

int fputc(int ch, FILE *f)
{
    // 等待发送寄存器空
    while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
    USART_SendData(USART1, (uint8_t)ch);
    return ch;
}

然后在工程设置里勾选“Use MicroLIB”,这样printf就能通过串口输出了。

小提示:如果你用GCC工具链,重定向的是_write函数。不同编译器入口不同,但思路完全一样。

4.3 环形缓冲区——别让打印拖慢你的系统

直接调用printf有个大问题:阻塞。每次打印都要等串口发送完一个字节,如果打印量大,CPU就被卡死了。我见过一个项目,就因为调试打印太多,导致中断响应超时,整个系统跑飞。

解决方案就是环形缓冲区。原理很简单:

  • 写数据时,只往缓冲区里写,不直接操作串口
  • 读数据时,由中断或定时器从缓冲区取数据发送

这样,printf瞬间返回,CPU继续干正事。

下面是一个轻量级环形缓冲区的实现:

#define RING_BUF_SIZE 256

typedef struct {
    uint8_t buffer[RING_BUF_SIZE];
    volatile uint16_t head;
    volatile uint16_t tail;
} RingBuf_t;

RingBuf_t txRing;

// 写一个字节到环形缓冲区
int RingBuf_Put(RingBuf_t *rb, uint8_t data)
{
    uint16_t next = (rb->head + 1) % RING_BUF_SIZE;
    if (next == rb->tail) {
        return -1;  // 缓冲区满
    }
    rb->buffer[rb->head] = data;
    rb->head = next;
    return 0;
}

// 从环形缓冲区读一个字节
int RingBuf_Get(RingBuf_t *rb, uint8_t *data)
{
    if (rb->head == rb->tail) {
        return -1;  // 缓冲区空
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
    return 0;
}

然后在USART发送完成中断里,调用RingBuf_Get取出数据发送:

void USART1_IRQHandler(void)
{
    if (USART_GetITStatus(USART1, USART_IT_TXE) != RESET) {
        uint8_t data;
        if (RingBuf_Get(&txRing, &data) == 0) {
            USART_SendData(USART1, data);
        } else {
            USART_ITConfig(USART1, USART_IT_TXE, DISABLE);
        }
    }
}
核心要点:环形缓冲区用“头尾指针”实现先进先出。头指针指向写位置,尾指针指向读位置。当head == tail时,缓冲区为空;当(head+1)%SIZE == tail时,缓冲区满。注意:实际可用空间是SIZE-1,因为要区分空和满。

4.4 日志分级——让调试信息不再“一锅粥”

项目大了以后,调试信息会爆炸。什么“进入函数A”、“变量x=123”、“错误:传感器无响应”……全混在一起,根本看不清。这时候就需要日志分级。

我一般分四个等级:

等级 宏定义 颜色(终端) 使用场景
ERROR LOG_ERROR 红色 系统无法继续运行的致命错误
WARN LOG_WARN 黄色 不影响运行但需要注意的异常
INFO LOG_INFO 绿色 关键状态变化、初始化完成等
DEBUG LOG_DEBUG 白色 详细调试信息,发布时关闭

实现方式很简单,用宏控制:

#define LOG_LEVEL  LOG_LEVEL_DEBUG

#define LOG_ERROR(fmt, ...)  \
    do { printf("[ERROR] " fmt "\r\n", ##__VA_ARGS__); } while(0)

#define LOG_WARN(fmt, ...)   \
    do { printf("[WARN]  " fmt "\r\n", ##__VA_ARGS__); } while(0)

#define LOG_INFO(fmt, ...)   \
    do { printf("[INFO]  " fmt "\r\n", ##__VA_ARGS__); } while(0)

#if LOG_LEVEL >= LOG_LEVEL_DEBUG
#define LOG_DEBUG(fmt, ...)  \
    do { printf("[DEBUG] " fmt "\r\n", ##__VA_ARGS__); } while(0)
#else
#define LOG_DEBUG(fmt, ...)  // 发布时直接空定义
#endif

使用时:

LOG_INFO("系统初始化完成,主频 %d MHz", 72);
LOG_WARN("传感器读取超时,重试第 %d 次", retry);
LOG_ERROR("Flash写入失败,地址: 0x%08X", addr);
我的习惯:开发阶段LOG_LEVEL开到DEBUG,发布前改成INFO或WARN。DEBUG信息全部编译掉,不占ROM也不占CPU。

4.5 本章知识体系

下面这张图把本章的核心逻辑串起来了:

UART调试接口知识体系 UART调试接口知识体系 UART调试接口 UART初始化 printf重定向 环形缓冲区 日志分级 时钟使能 GPIO配置 USART配置 fputc重写 MicroLIB 头尾指针 中断发送 ERROR/WARN INFO/DEBUG 核心目标:非阻塞、可分级、低开销的串口调试方案 适用于裸机环境下的故障定位与运行状态监控

4.6 避坑指南

最后,分享几个我这些年踩过的坑:

  • 波特率误差:系统时钟不准时,波特率会偏移。115200以下还好,超过1Mbps时一定要用示波器量一下实际波形。
  • 环形缓冲区溢出:打印太快,中断来不及发送。我一般把缓冲区设到512字节以上,同时控制单次打印长度。
  • 中断优先级:USART中断优先级不能太低,否则会被其他高优先级中断长时间阻塞,导致丢数据。
  • 日志等级宏:发布前记得把LOG_LEVEL调到INFO或WARN,否则DEBUG信息会拖慢系统。