日志输出优化:异步日志与DMA传输、日志压缩与过滤、日志标签化与彩色输出

日志这东西,很多人觉得不就是printf吗?有什么好讲的。我以前也这么想,直到在一个电机驱动项目里,因为日志打印占用了太多CPU时间,导致控制周期抖动,电机直接啸叫了。那次之后我才明白——日志输出如果不优化,它就不是调试工具,而是Bug制造机。

这一节,我们专门聊怎么让日志又快、又省、又好看。说白了,就是三个方向:异步化解决性能问题,压缩过滤解决存储问题,标签彩色化解决可读性问题。

1. 异步日志:别让打印拖累主逻辑

传统的同步日志,每写一条都要等外设把数据送出去。串口115200波特率,一个字符大概87μs,一条100字节的日志就是8.7ms。你想想看,如果控制周期只有1ms,这一条日志就能让系统崩掉。

异步日志的核心思路很简单:生产者把日志扔进缓冲区,消费者(后台任务或中断)负责输出。主线程只管写,不管发。

关键指标对比:

特性 同步日志 异步日志
单条耗时 ~8ms(115200bps) ~1μs(仅入队)
对实时性影响 严重阻塞 几乎无影响
缓冲区溢出风险 低(直接输出) 高(需设计丢帧策略)
实现复杂度 低 中

实现上,我习惯用环形缓冲区(Ring Buffer)加一个低优先级任务。主任务写日志时只做一次memcpy,然后发信号量通知输出任务。输出任务在空闲时批量发送。

// 异步日志核心结构
typedef struct {
    uint8_t *buffer;      // 环形缓冲区
    uint32_t head;        // 写指针
    uint32_t tail;        // 读指针
    uint32_t size;        // 缓冲区大小
    SemaphoreHandle_t sem; // 通知信号量
} async_log_t;

// 生产者——主任务调用
void async_log_write(async_log_t *log, const uint8_t *data, uint32_t len) {
    // 检查剩余空间,不够则丢弃
    uint32_t available = (log->tail - log->head - 1) & (log->size - 1);
    if (available < len) return;  // 丢帧,不阻塞
    
    // 拷贝数据到环形缓冲区
    for (uint32_t i = 0; i < len; i++) {
        log->buffer[log->head] = data[i];
        log->head = (log->head + 1) & (log->size - 1);
    }
    // 通知消费者
    xSemaphoreGive(log->sem);
}

// 消费者——低优先级任务
void async_log_task(void *param) {
    async_log_t *log = (async_log_t *)param;
    uint8_t out_buf[128];
    while (1) {
        xSemaphoreTake(log->sem, portMAX_DELAY);
        // 批量取出并发送
        while (log->head != log->tail) {
            out_buf[0] = log->buffer[log->tail];
            log->tail = (log->tail + 1) & (log->size - 1);
            // 通过DMA或中断发送
            uart_send_byte(out_buf[0]);
        }
    }
}

我的经验:缓冲区大小设为2的幂次,用位与运算代替取模,能省不少指令。我曾经在Cortex-M0上跑,取模操作要40多个周期,改成位与只要2个周期。

2. DMA传输:让CPU彻底解放

异步日志解决了阻塞问题,但CPU还是要一条一条往外搬数据。这时候DMA就派上用场了。DMA传输,说白了就是给CPU请了个搬运工——你把数据地址和长度告诉它,它自己搬,搬完了给你发个中断。

我建议把日志输出和DMA深度绑定:日志消费者任务只负责把数据整理成块,然后启动DMA传输。传输期间CPU可以干别的事。

// DMA传输日志块
void dma_log_send(async_log_t *log, uint32_t len) {
    // 等待上一次DMA传输完成
    while (DMA_GetFlagStatus(DMA1_FLAG_TC1) == RESET);
    
    // 配置DMA:从环形缓冲区读指针位置开始传输
    DMA_Cmd(DMA1_Channel1, DISABLE);
    DMA_SetCurrDataCounter(DMA1_Channel1, len);
    DMA_MemoryBaseAddress(DMA1_Channel1, (uint32_t)&log->buffer[log->tail]);
    DMA_Cmd(DMA1_Channel1, ENABLE);
    
    // 更新读指针(DMA传输完成后在中断里做)
}

注意:DMA传输期间,环形缓冲区的读指针区域不能被覆盖。我踩过这个坑——DMA还没传完,新日志就把数据冲掉了。解决方案是:DMA传输期间锁定读指针区域,或者用双缓冲机制。

3. 日志压缩与过滤:别让日志淹死你

嵌入式设备存储空间有限,日志动不动就几MB,根本存不下。而且大部分日志其实没用——你只需要关键信息。

压缩方面,我推荐两种轻量级方法:

  • 行程编码(RLE):连续重复的字符(比如空格、0x00)压缩成"重复次数+字符"。适合日志中大量重复的格式化输出。
  • 差分编码:只记录变化的部分。比如传感器数据,每次只记录差值,能省70%以上空间。

过滤方面,我习惯在编译期和运行期做两级过滤:

过滤层级 方法 优点 缺点
编译期 宏开关 #ifdef LOG_LEVEL >= 3 零运行时开销 需要重新编译
运行期 全局日志级别变量 + 条件判断 可动态调整 有分支开销
模块级 每个模块独立使能/禁用 精细控制 配置复杂
// 编译期+运行期双重过滤
#define LOG_LEVEL_ERROR 0
#define LOG_LEVEL_WARN  1
#define LOG_LEVEL_INFO  2
#define LOG_LEVEL_DEBUG 3

// 编译期开关:不满足级别直接消失
#define LOG_PRINT(level, fmt, ...) \
    do { \
        if ((level) <= g_log_current_level) { \
            log_output(level, fmt, ##__VA_ARGS__); \
        } \
    } while(0)

// 模块级过滤:每个模块有自己的掩码
typedef struct {
    uint32_t module_id;
    uint32_t enable_mask;  // bit0=ERROR, bit1=WARN, ...
} log_module_t;

int log_module_enabled(log_module_t *mod, int level) {
    return (mod->enable_mask & (1 << level)) != 0;
}

我曾经在一个电池管理项目里,日志量太大导致Flash被写穿。后来加了差分压缩和运行期过滤,日志量从每天2MB降到200KB,Flash寿命延长了10倍。

4. 日志标签化与彩色输出:一眼看出问题在哪

你有没有这种经历——串口终端刷屏,满屏白字,根本分不清哪条是错误、哪条是调试信息?反正我有。后来我引入了日志标签化和彩色输出,效率提升不止一倍。

标签化就是给每条日志打上"身份证":模块名、函数名、行号、时间戳、严重级别。这样即使日志混在一起,也能快速定位来源。

// 标签化日志宏
#define LOG_TAG(mod, level, fmt, ...) \
    log_tagged(mod, level, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

void log_tagged(const char *module, int level, 
                const char *file, int line, 
                const char *fmt, ...) {
    // 输出格式:[模块][级别][文件:行号] 内容
    printf("[%s][%s][%s:%d] ", 
           module, 
           level_str[level], 
           strrchr(file, '/') ? strrchr(file, '/')+1 : file,
           line);
    va_list args;
    va_start(args, fmt);
    vprintf(fmt, args);
    va_end(args);
    printf("\r\n");
}

彩色输出利用终端转义序列,不同级别不同颜色。我常用的配色方案:

级别 颜色 转义码 适用场景
ERROR 红色 \033[31m 致命错误,立即处理
WARN 黄色 \033[33m 异常但不致命
INFO 绿色 \033[32m 关键状态变化
DEBUG 蓝色 \033[34m 调试细节
TRACE 灰色 \033[37m 函数调用跟踪
// 彩色输出实现
#define COLOR_RED   "\033[31m"
#define COLOR_GRN   "\033[32m"
#define COLOR_YEL   "\033[33m"
#define COLOR_BLU   "\033[34m"
#define COLOR_GRY   "\033[37m"
#define COLOR_RST   "\033[0m"

void log_color(int level, const char *fmt, ...) {
    const char *color;
    switch(level) {
        case LOG_ERROR: color = COLOR_RED; break;
        case LOG_WARN:  color = COLOR_YEL; break;
        case LOG_INFO:  color = COLOR_GRN; break;
        case LOG_DEBUG: color = COLOR_BLU; break;
        default:        color = COLOR_GRY; break;
    }
    printf("%s", color);
    va_list args;
    va_start(args, fmt);
    vprintf(fmt, args);
    va_end(args);
    printf("%s\r\n", COLOR_RST);
}

注意:彩色转义码只在支持ANSI的终端有效(如PuTTY、SecureCRT)。如果日志要写入文件,记得去掉颜色码,否则文件里全是乱码。我一般加一个编译开关,终端版带颜色,文件版纯文本。

5. 整体架构:把这些串起来

上面讲了异步、DMA、压缩、过滤、标签、彩色。它们不是孤立的,应该组合成一个完整的日志系统。下面这张图是我在项目中常用的架构:

日志系统整体架构 应用代码 中断服务 任务/线程 日志过滤层(编译期 + 运行期 + 模块级) 标签化 + 彩色格式化 环形缓冲区(异步队列,支持压缩) DMA传输 → 串口/网络 文件存储(压缩后) 说明:日志从应用层产生,经过过滤→格式化→缓冲→输出,全程不阻塞主逻辑

这个架构的核心思想就一句话:应用层只管扔日志,后面的事交给专门的模块去处理。过滤、格式化、缓冲、传输,每一层各司其职,互不干扰。

总结一下优化效果:

  • 异步 + DMA:日志输出对CPU占用从30%降到1%以下
  • 压缩 + 过滤:存储空间节省60%~80%
  • 标签 + 彩色:问题定位时间缩短50%以上

嗯,这些就是我这些年做日志优化的核心经验。你想想看,如果你的系统现在还在用printf裸奔,不妨试试这套方案。不用全上,先从异步日志开始,效果立竿见影。