引言

调试是嵌入式开发中不可避免的环节,而串口打印作为最基础的调试方式,其重要性不言而喻。然而,很多开发者只是简单地使用printf输出信息,导致日志冗长、无层次、难以筛选。本文将带你掌握STM32打印调试的艺术,实现日志分级、格式化输出以及远程调试,让你的调试过程更加高效和优雅。

1. 基础:重定向printf到串口

在STM32上使用printf,首先需要重定向底层输出函数。以HAL库为例,我们重写fputc函数,将字符发送到USART。

#include <stdio.h>

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

同时,在Keil或IAR中启用MicroLIB,避免使用半主机模式。这样,printf就可以直接输出到串口了。

2. 日志分级:让日志井井有条

2.1 定义日志级别

日志分级是专业调试的基础。我们定义常见的日志级别:

typedef enum {
    LOG_LEVEL_ERROR = 0,  // 错误,程序无法继续运行
    LOG_LEVEL_WARN,       // 警告,可能出现问题
    LOG_LEVEL_INFO,       // 信息,正常运行状态
    LOG_LEVEL_DEBUG,      // 调试,详细运行信息
    LOG_LEVEL_VERBOSE     // 冗余,最详细的输出
} LogLevel;

2.2 实现分级宏

通过条件编译和宏控制,我们可以灵活地开启或关闭不同级别的日志。

// 设置当前允许输出的最低日志级别
#define LOG_LEVEL_CURRENT  LOG_LEVEL_DEBUG

// 日志输出宏
#define LOG_E(fmt, ...)  log_output(LOG_LEVEL_ERROR,   "E", fmt, ##__VA_ARGS__)
#define LOG_W(fmt, ...)  log_output(LOG_LEVEL_WARN,    "W", fmt, ##__VA_ARGS__)
#define LOG_I(fmt, ...)  log_output(LOG_LEVEL_INFO,    "I", fmt, ##__VA_ARGS__)
#define LOG_D(fmt, ...)  log_output(LOG_LEVEL_DEBUG,   "D", fmt, ##__VA_ARGS__)
#define LOG_V(fmt, ...)  log_output(LOG_LEVEL_VERBOSE, "V", fmt, ##__VA_ARGS__)

// 核心输出函数
void log_output(LogLevel level, const char *tag, const char *fmt, ...) {
    if (level > LOG_LEVEL_CURRENT) return;  // 过滤低级别日志

    // 添加时间戳和级别标签
    printf("[%s] ", tag);
    
    va_list args;
    va_start(args, fmt);
    vprintf(fmt, args);
    va_end(args);
    printf("\r\n");
}

使用示例:

LOG_I("系统初始化完成,版本 %d.%d", 1, 0);
LOG_D("ADC值: %d", adc_value);
LOG_E("内存分配失败!");

2.3 添加时间戳

在日志中加入时间戳,有助于分析时序问题。利用SysTick或定时器获取毫秒级时间。

uint32_t get_tick_ms(void) {
    return HAL_GetTick();  // 使用HAL库的tick
}

void log_output(LogLevel level, const char *tag, const char *fmt, ...) {
    if (level > LOG_LEVEL_CURRENT) return;
    printf("[%lu][%s] ", (unsigned long)get_tick_ms(), tag);
    // ... 其余不变
}

3. 彩色输出:让日志一目了然

在支持ANSI转义序列的终端(如SecureCRT、MobaXterm)中,我们可以为不同级别添加颜色。

#define COLOR_RED     "\033[31m"
#define COLOR_YELLOW  "\033[33m"
#define COLOR_GREEN   "\033[32m"
#define COLOR_BLUE    "\033[34m"
#define COLOR_RESET   "\033[0m"

void log_output(LogLevel level, const char *tag, const char *fmt, ...) {
    if (level > LOG_LEVEL_CURRENT) return;

    // 根据级别选择颜色
    const char *color;
    switch (level) {
        case LOG_LEVEL_ERROR:   color = COLOR_RED;    break;
        case LOG_LEVEL_WARN:    color = COLOR_YELLOW; break;
        case LOG_LEVEL_INFO:    color = COLOR_GREEN;  break;
        case LOG_LEVEL_DEBUG:   color = COLOR_BLUE;   break;
        default:                color = COLOR_RESET;  break;
    }

    printf("%s[%lu][%s] ", color, (unsigned long)get_tick_ms(), tag);
    // ... 输出内容
    printf(COLOR_RESET "\r\n");
}

4. 远程调试:通过串口命令交互

除了输出日志,我们还可以实现一个简单的远程调试接口,通过串口接收命令,动态调整日志级别、查询系统状态等。

4.1 命令解析框架

使用中断或DMA接收串口数据,解析命令。

// 命令处理函数指针类型
typedef void (*cmd_handler_t)(char *args);

typedef struct {
    const char *name;
    cmd_handler_t handler;
    const char *help;
} cmd_entry_t;

// 示例命令:设置日志级别
static void cmd_set_level(char *args) {
    int level = atoi(args);
    if (level >= LOG_LEVEL_ERROR && level <= LOG_LEVEL_VERBOSE) {
        current_log_level = level;
        printf("日志级别已设置为: %d\r\n", level);
    } else {
        printf("无效级别,范围0-4\r\n");
    }
}

// 命令表
static const cmd_entry_t cmd_table[] = {
    {"level", cmd_set_level, "设置日志级别: level <0-4>"},
    {"help",  cmd_help,      "显示帮助"},
    // 可以添加更多命令
};

// 处理接收到的命令行
void process_command(char *line) {
    // 分割命令和参数
    char *cmd = strtok(line, " ");
    char *args = strtok(NULL, "");
    
    for (int i = 0; i < sizeof(cmd_table)/sizeof(cmd_table[0]); i++) {
        if (strcmp(cmd, cmd_table[i].name) == 0) {
            cmd_table[i].handler(args);
            return;
        }
    }
    printf("未知命令: %s\r\n", cmd);
}

4.2 串口接收中断处理

在串口中断中累积字符,遇到换行符时处理命令。

#define RX_BUF_SIZE 128
static char rx_buf[RX_BUF_SIZE];
static uint8_t rx_index = 0;

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    if (huart == &huart1) {
        char ch = rx_data;
        if (ch == '\n' || ch == '\r') {
            if (rx_index > 0) {
                rx_buf[rx_index] = '\0';
                process_command(rx_buf);
                rx_index = 0;
            }
        } else {
            if (rx_index < RX_BUF_SIZE - 1) {
                rx_buf[rx_index++] = ch;
            }
        }
        // 重新开启接收中断
        HAL_UART_Receive_IT(&huart1, &rx_data, 1);
    }
}

5. 性能优化与注意事项

  • 减少阻塞:在中断或实时性要求高的代码中,避免直接使用printf,可改用非阻塞的DMA传输或环形缓冲区。
  • 日志缓冲:对于大量日志,使用环形缓冲区暂存,由后台任务或DMA发送,避免CPU长时间占用。
  • 格式字符串:使用%d%x等时注意数据类型,避免因类型不匹配导致输出错误。
  • 条件编译:在发布版本中,可以通过宏关闭所有日志,减少代码体积和运行开销。
#ifdef DEBUG_ENABLE
    #define LOG_D(...) log_output(LOG_LEVEL_DEBUG, "D", __VA_ARGS__)
#else
    #define LOG_D(...)
#endif

6. 总结

通过日志分级、彩色输出、时间戳和远程命令交互,我们构建了一套完整的STM32调试打印系统。这不仅让日志清晰易读,还能动态调整输出级别,甚至远程控制设备,极大提升了调试效率。希望本文能帮助你掌握这些技巧,让调试成为一种艺术。