2、设备端日志采集架构:嵌入式日志采集原理、日志缓冲区设计、日志级别动态配置

好,咱们进入设备端日志采集的核心部分。

说实话,很多做物联网的同学,一开始都只关注云平台怎么存、怎么展示日志。但真正出问题的时候,你才发现——设备端压根没把日志发上来,或者发上来的全是垃圾信息。嗯,这就是架构没设计好。

我个人习惯,在设计日志系统时,先想清楚三个问题:怎么采?采了放哪?什么时候发? 这节我们就围绕这三个问题展开。

2.1 嵌入式日志采集原理

嵌入式设备不像服务器,它资源有限。CPU主频可能只有几十兆赫,RAM可能只有几十KB。所以日志采集不能「大张旗鼓」,得「精打细算」。

说白了,日志采集就是把程序运行过程中的关键信息,按照一定格式记录下来。但这里有个坑——你记录的动作本身,不能影响程序的正常运行。

我在项目中遇到过这样的情况:一个传感器采集程序,本来跑得好好的。加了日志打印后,采集周期从100ms变成了150ms。为什么?因为printf是阻塞的,它要等串口发完才返回。

所以,嵌入式日志采集的核心原则是:采集动作要轻量,不能阻塞主流程

日志采集的典型流程:

  1. 程序运行到关键节点,调用日志接口
  2. 日志接口格式化信息(时间戳、级别、内容)
  3. 将格式化后的日志写入缓冲区
  4. 后台任务或中断将缓冲区内容发送出去

这里有个细节:时间戳怎么取?我建议用系统滴答时钟(SysTick)或者RTC。千万别用毫秒级的延时去读时间,那会拖慢系统。

2.2 日志缓冲区设计

缓冲区设计,是日志系统的灵魂。你想想看,设备可能一秒产生几十条日志,但网络传输可能一秒只能发几条。没有缓冲区,日志就丢了。

我常用的缓冲区设计有三种:

类型 特点 适用场景
环形缓冲区 固定大小,新数据覆盖旧数据 内存紧张,允许丢旧日志
链式缓冲区 动态分配,可扩展 内存较充裕,日志量波动大
双缓冲区 一个写、一个读,交替使用 多任务环境,避免读写冲突

我个人最推荐环形缓冲区。为什么?因为它实现简单,没有动态内存分配,不会产生碎片。我曾经在一个STM32F103的项目里,只用2KB的环形缓冲区,就撑住了每秒200条日志的写入。

来看一个环形缓冲区的核心代码:

// 环形缓冲区结构体
typedef struct {
    uint8_t *buffer;    // 缓冲区起始地址
    uint32_t size;      // 缓冲区大小
    uint32_t head;      // 写指针
    uint32_t tail;      // 读指针
} ring_buffer_t;

// 写入日志
int ring_buffer_write(ring_buffer_t *rb, uint8_t *data, uint32_t len) {
    uint32_t i;
    for (i = 0; i < len; i++) {
        // 如果缓冲区满了,覆盖最旧的数据
        if ((rb->head + 1) % rb->size == rb->tail) {
            rb->tail = (rb->tail + 1) % rb->size;  // 丢弃最旧数据
        }
        rb->buffer[rb->head] = data[i];
        rb->head = (rb->head + 1) % rb->size;
    }
    return len;
}

避坑指南: 我曾经犯过一个错误——环形缓冲区的大小设成了2的幂次方,但取模运算用了%操作符。后来发现编译器优化后,%操作比位运算慢很多。改成 head & (size - 1) 后,性能提升了30%。记住:缓冲区大小一定要是2的幂次方。

2.3 日志级别动态配置

日志级别,说白了就是给日志分个三六九等。常见的级别有:

  • ERROR:致命错误,必须上报
  • WARN:警告,需要关注
  • INFO:常规信息,日常监控
  • DEBUG:调试信息,开发用
  • TRACE:详细跟踪,极少用

但这里有个关键问题:日志级别不能写死在代码里。你想想看,设备部署到现场后,出了问题,你想看DEBUG级别的日志,但代码里只开了INFO。怎么办?总不能派人去现场烧录固件吧。

所以,日志级别必须支持动态配置。我常用的方案有两种:

  1. 通过云平台下发配置:设备收到MQTT消息后,修改本地的日志级别变量
  2. 通过串口AT指令:现场调试时,用串口工具直接修改

来看一个动态配置的实现:

// 全局日志级别,默认只输出ERROR和WARN
static uint8_t g_log_level = LOG_LEVEL_WARN;

// 设置日志级别(可从云平台或串口调用)
void log_set_level(uint8_t level) {
    if (level > LOG_LEVEL_TRACE) {
        level = LOG_LEVEL_TRACE;
    }
    g_log_level = level;
    log_info("日志级别已更新为: %d", level);
}

// 日志输出接口
void log_output(uint8_t level, const char *fmt, ...) {
    if (level > g_log_level) {
        return;  // 级别不够,直接丢弃
    }
    // 格式化并写入缓冲区
    // ...
}

注意: 动态配置日志级别时,要考虑线程安全。如果主任务在写日志,另一个任务突然改了级别,可能会出问题。我建议用原子操作或者关中断的方式来保护 g_log_level 变量。

另外,我还想提一个经验:日志级别不要设太多。我见过有人搞了8个级别,结果开发人员自己都分不清该用哪个。我个人习惯只用4个:ERROR、WARN、INFO、DEBUG。TRACE级别我基本不用,因为日志量太大,会把缓冲区撑爆。

嗯,到这里,设备端日志采集的核心架构就讲完了。总结一下:

  • 采集要轻量,不能阻塞主流程
  • 缓冲区推荐环形缓冲区,大小设为2的幂次方
  • 日志级别必须支持动态配置,且要考虑线程安全

下一节,我们会讲日志的传输协议设计。到时候再聊怎么把日志高效地发到云平台。