4. 日志与打印调试:UART打印的配置与使用、日志分级与格式化、环形缓冲区实现日志记录

调试嵌入式系统,说白了就是跟看不见摸不着的代码斗智斗勇。你没法像PC程序那样打断点、看变量,很多时候唯一的窗口就是那根串口线。我个人习惯,拿到一块新板子,第一件事就是把UART打印调通。没有打印,就像在黑屋子里修表,全靠摸。

4.1 UART打印的配置与使用

UART打印是嵌入式调试的“Hello World”,但很多人栽在细节上。我见过不少工程师,代码逻辑没问题,就是打印不出来,最后发现是波特率配错了,或者引脚复用没开。

配置UART,核心就三步:

  1. 使能时钟:别忘了给UART外设和对应的GPIO口开时钟。STM32上用__HAL_RCC_USARTx_CLK_ENABLE(),别漏了GPIO的。
  2. 配置GPIO:TX脚要配成复用推挽输出,RX脚配成浮空输入或上拉输入。具体看芯片手册,别想当然。
  3. 初始化UART:波特率、数据位、停止位、校验位。最常见的是115200 8N1。

核心要点:调试阶段,建议把UART中断优先级设高一点,避免在高负载下丢数据。我曾在电机控制项目里吃过这个亏,一启动电机,打印就断断续续,查了半天才发现是中断被抢了。

下面是一个典型的初始化代码片段,以STM32 HAL库为例:

// UART句柄定义
UART_HandleTypeDef huart1;

// UART初始化函数
void UART_Init(void)
{
    // 1. 使能时钟
    __HAL_RCC_USART1_CLK_ENABLE();
    __HAL_RCC_GPIOA_CLK_ENABLE();

    // 2. 配置GPIO:PA9-TX, PA10-RX
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin = GPIO_PIN_9;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

    GPIO_InitStruct.Pin = GPIO_PIN_10;
    GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

    // 3. 配置UART
    huart1.Instance = USART1;
    huart1.Init.BaudRate = 115200;
    huart1.Init.WordLength = UART_WORDLENGTH_8B;
    huart1.Init.StopBits = UART_STOPBITS_1;
    huart1.Init.Parity = UART_PARITY_NONE;
    huart1.Init.Mode = UART_MODE_TX_RX;
    huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;
    HAL_UART_Init(&huart1);
}

// 重定向printf到UART
int fputc(int ch, FILE *f)
{
    HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100);
    return ch;
}

小技巧:用fputc重定向printf,调试时直接写printf("value = %d\n", val);,方便又直观。但注意,printf很占资源,中断里别用。

4.2 日志分级与格式化

项目大了,打印满天飞,你根本分不清哪个是错误、哪个是警告。我建议从一开始就引入日志分级。说白了,就是给每条打印贴个标签,告诉你看的人:这条信息有多重要。

常用的分级标准:

级别 宏定义 使用场景
ERROR LOG_ERROR 系统无法继续运行的致命错误
WARN LOG_WARN 不影响运行但需要注意的异常
INFO LOG_INFO 关键状态变化,如初始化完成
DEBUG LOG_DEBUG 开发阶段的详细调试信息

实现起来很简单,用宏包装一下:

#define LOG_LEVEL 3  // 0:ERROR, 1:WARN, 2:INFO, 3:DEBUG

#define LOG_ERROR(fmt, ...) \
    do { if (LOG_LEVEL >= 0) printf("[ERROR] " fmt "\r\n", ##__VA_ARGS__); } while(0)
#define LOG_WARN(fmt, ...)  \
    do { if (LOG_LEVEL >= 1) printf("[WARN]  " fmt "\r\n", ##__VA_ARGS__); } while(0)
#define LOG_INFO(fmt, ...)  \
    do { if (LOG_LEVEL >= 2) printf("[INFO]  " fmt "\r\n", ##__VA_ARGS__); } while(0)
#define LOG_DEBUG(fmt, ...) \
    do { if (LOG_LEVEL >= 3) printf("[DEBUG] " fmt "\r\n", ##__VA_ARGS__); } while(0)

格式化方面,我习惯加上时间戳和文件名。比如:[INFO] [main.c:120] System initialized。这样出问题了,一眼就能定位到代码位置。

注意:发布版本时,记得把LOG_LEVEL设低,或者干脆关掉所有调试打印。我曾经见过一个产品,线上版本还开着DEBUG日志,结果串口被日志撑爆,导致通信卡死。

4.3 环形缓冲区实现日志记录

直接printf有个大问题:它是阻塞的。如果UART发送速度跟不上日志产生速度,系统就会被拖慢。尤其是在中断里,你总不能等它发完再出来吧?

解决方案就是环形缓冲区。说白了,就是一个固定大小的数组,加上读写指针。日志先写到缓冲区里,后台任务或者中断再慢慢往外发。这样,日志产生者不会被阻塞。

下面是一个简单的环形缓冲区实现:

#define RING_BUF_SIZE 1024

typedef struct {
    uint8_t buffer[RING_BUF_SIZE];
    volatile uint16_t head;  // 写指针
    volatile uint16_t tail;  // 读指针
} ring_buffer_t;

ring_buffer_t log_ring;

// 初始化
void ring_buffer_init(ring_buffer_t *rb)
{
    rb->head = 0;
    rb->tail = 0;
}

// 写入一个字节
int ring_buffer_put(ring_buffer_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 ring_buffer_get(ring_buffer_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;
}

// 日志写入函数(非阻塞)
void log_write(const char *str)
{
    while (*str) {
        if (ring_buffer_put(&log_ring, *str) != 0) {
            // 缓冲区满了,丢弃后续字符
            break;
        }
        str++;
    }
}

// 后台任务:从缓冲区取数据并发送
void log_send_task(void)
{
    uint8_t ch;
    while (1) {
        if (ring_buffer_get(&log_ring, &ch) == 0) {
            HAL_UART_Transmit(&huart1, &ch, 1, 10);
        } else {
            // 缓冲区空,稍等再查
            HAL_Delay(1);
        }
    }
}

关键设计:环形缓冲区里,head == tail表示空,(head + 1) % SIZE == tail表示满。这样会浪费一个字节的空间,但判断逻辑简单可靠。我见过有人用计数方式,结果在多线程环境下计数不同步,数据全乱了。

你想想看,有了环形缓冲区,中断里只管往里面塞数据,主循环里慢慢往外吐。哪怕UART波特率只有9600,也不会拖慢系统响应。

下面这张图展示了整个日志系统的数据流:

日志系统数据流图 日志系统数据流图 应用程序 调用 LOG_INFO 等宏 日志分级宏 过滤 & 格式化 环形缓冲区 非阻塞写入 后台发送任务 循环读取并发送 UART 外设 逐字节发送 串口线 说明:日志从应用产生,经过分级过滤后写入环形缓冲区,后台任务异步发送,不阻塞主流程。

经验之谈:缓冲区大小怎么定?我一般按最坏情况算:假设所有任务同时打日志,每秒产生多少字节,再乘以2作为余量。比如每秒最多1KB日志,缓冲区设2KB就够。太大浪费RAM,太小容易丢数据。

嗯,到这里,日志系统的基本框架就搭起来了。从UART配置,到分级格式化,再到环形缓冲区异步输出,每一步都有坑,但也都有解法。你动手试试,把这几块拼起来,调试效率能提升一大截。