实战项目一:基于消息队列的日志收集系统

日志收集,听起来好像挺简单?不就是把printf改一改嘛。但真要在嵌入式设备上搞一套靠谱的日志系统,坑多着呢。我记得刚入行那会儿,在一个工业控制器项目里,就因为日志打印把实时任务给拖垮了——串口输出占用了太多CPU时间,导致控制周期超时。从那以后,我就再也不敢小看日志这件事了。

今天这个项目,咱们就用消息队列来搭一套轻量级的日志收集系统。说白了,就是把日志的生产和消费拆开,让日志输出不再阻塞主业务逻辑。

系统整体架构

先看看我们要搭什么。整个系统分三层:

  • 日志生产者:各个任务模块,随时产生日志消息
  • 消息队列:一个固定大小的环形缓冲区,暂存日志
  • 日志消费者:一个后台任务,从队列里取日志,写到串口或SD卡

我习惯把这种模式叫做"异步日志"。你想想看,如果每个任务都直接写串口,那串口锁一争,整个系统都得卡住。用队列一隔,生产者只管往里扔,消费者慢慢往外吐,互不干扰。

核心设计原则:日志收集不能影响主任务的实时性。消息队列就是那道"缓冲墙"。

基于消息队列的日志收集系统架构 日志生产者 任务A (传感器) 任务B (通信) 任务C (控制) ...更多模块 入队 消息队列 [日志条目] 头指针→ [日志条目] [日志条目] [日志条目] 尾指针→ 环形缓冲区 (固定大小) 出队 日志消费者 后台任务 (低优先级) 格式化 + 时间戳 输出到串口/SD卡 可配置输出目标

消息队列的核心实现

队列用环形缓冲区实现,这是嵌入式里最经典的做法。为什么不用链表?因为动态分配内存容易产生碎片,在资源受限的MCU上是大忌。我见过一个项目,就是因为malloc/free用太多,最后堆碎了,系统莫名其妙死机。

来看代码:

/* 日志消息结构体 */
typedef struct {
    uint32_t timestamp;    /* 系统滴答时间戳 */
    uint8_t  level;        /* 日志级别:DEBUG, INFO, WARN, ERROR */
    uint8_t  module_id;    /* 模块ID */
    char     message[64];  /* 日志内容 */
} log_msg_t;

/* 环形队列结构体 */
typedef struct {
    log_msg_t buffer[LOG_QUEUE_SIZE];
    volatile uint32_t head;  /* 生产者写入位置 */
    volatile uint32_t tail;  /* 消费者读取位置 */
} log_queue_t;

/* 入队函数 */
int log_enqueue(log_queue_t *q, const log_msg_t *msg) {
    uint32_t next_head = (q->head + 1) % LOG_QUEUE_SIZE;
    
    /* 队列满?直接丢弃,不阻塞 */
    if (next_head == q->tail) {
        return -1;  /* 丢日志,但绝不卡任务 */
    }
    
    q->buffer[q->head] = *msg;
    q->head = next_head;
    return 0;
}

/* 出队函数 */
int log_dequeue(log_queue_t *q, log_msg_t *msg) {
    if (q->head == q->tail) {
        return -1;  /* 队列空 */
    }
    
    *msg = q->buffer[q->tail];
    q->tail = (q->tail + 1) % LOG_QUEUE_SIZE;
    return 0;
}

我的经验:队列大小选2的幂次方,比如64、128、256。这样取模运算可以用位与(&)代替,快很多。在Cortex-M上,一条指令就搞定。

日志级别的设计

日志级别不能太少,也不能太多。我一般用这四级:

级别 枚举值 使用场景
DEBUG 0 开发调试,发布时关掉
INFO 1 正常运行信息,比如"系统启动"
WARN 2 异常但可恢复,比如"通信超时,重试"
ERROR 3 严重错误,比如"传感器无响应"

每个模块在编译时就可以设置自己的日志级别。比如通信模块可以开DEBUG,其他模块只开INFO以上。这样既灵活又省空间。

消费者任务的设计

消费者任务跑在后台,优先级设低一些。它的工作很简单:循环从队列取日志,然后输出。

void log_consumer_task(void *param) {
    log_msg_t msg;
    
    while (1) {
        if (log_dequeue(&g_log_queue, &msg) == 0) {
            /* 格式化输出 */
            char output[128];
            snprintf(output, sizeof(output),
                     "[%lu][%s][mod:%d] %s\n",
                     msg.timestamp,
                     level_to_string(msg.level),
                     msg.module_id,
                     msg.message);
            
            /* 写到串口 */
            uart_send_string(output);
            
            /* 也可以同时写到SD卡,看配置 */
            if (g_log_to_sd) {
                sd_card_append("log.txt", output);
            }
        } else {
            /* 队列空,休眠一会儿 */
            os_delay_ms(10);
        }
    }
}

注意:snprintf格式化比较耗时,但因为是后台任务,不影响主任务。不过如果日志量太大,格式化也可能成为瓶颈。我曾在某个项目中,每秒产生上千条日志,结果后台任务CPU占用飙到30%。后来加了"日志聚合"——相同日志1秒内只输出一次。

避坑指南

做这个项目,有几个坑我踩过,你注意一下:

  • 队列溢出处理:队列满了怎么办?我的做法是直接丢弃新日志。日志可以丢,但系统不能卡。你想想看,如果因为写日志导致控制任务超时,那才是大问题。
  • 中断中写日志:中断服务函数里也可以调用log_enqueue,但要注意——中断里不能调用可能阻塞的函数。我们的入队函数是纯轮询,没有锁,所以安全。
  • 时间戳精度:用系统滴答定时器,精度到毫秒就够了。别用RTC,那玩意儿读一次要好几毫秒。
  • 日志内容长度:固定64字节够不够?我一般建议128字节。太长浪费内存,太短信息不全。可以根据实际需求调整。

完整的使用示例

最后,看看怎么用这套系统:

/* 初始化 */
log_queue_init(&g_log_queue);
os_task_create(log_consumer_task, "log", 512, NULL, 2);

/* 在任务中写日志 */
void sensor_task(void *param) {
    while (1) {
        int32_t value = read_sensor();
        
        if (value < 0) {
            LOG_ERROR("SENSOR", "读取失败, err=%d", value);
        } else {
            LOG_INFO("SENSOR", "当前值: %d", value);
        }
        
        os_delay_ms(100);
    }
}

这里的LOG_ERROR、LOG_INFO是宏,内部封装了时间戳获取和入队操作。用宏的好处是,可以在DEBUG级别下直接不编译日志代码,省空间。

嗯,这套系统我用了好几年,从STM32到ESP32,从FreeRTOS到裸机,稍微改改就能跑。核心思想就一个:日志是后台的事,别让前台等。你把这个想明白了,日志系统就成功了一大半。


公众号:蓝海资料掘金营,微信deep3321