第四章 升级日志系统:从DEBUG到Flash持久化

日志系统,说白了就是固件的「黑匣子」。

我刚开始做固件时,总觉得加日志是浪费时间。直到有一次,产品在客户现场死机,我连死在哪都不知道——那种感觉,真叫一个抓瞎。从那以后,我每写一个模块,第一件事就是把日志框架搭好。

这一章,咱们就聊聊怎么搭一个靠谱的日志系统。从等级定义、环形缓冲区,到Flash持久化,一步步来。

4.1 日志等级:别什么都往串口扔

很多新手喜欢用printf到处打信息。结果呢?调试信息、正常状态、错误警告全混在一起。你想想看,几千行日志里找一条关键错误,跟大海捞针似的。

我个人习惯把日志分成四个等级:

等级 宏定义 颜色(串口终端) 使用场景
DEBUG LOG_LEVEL_DEBUG 灰色 开发阶段详细流程跟踪,发布版关闭
INFO LOG_LEVEL_INFO 白色 系统启动、状态切换、关键数据上报
WARN LOG_LEVEL_WARN 黄色 非致命异常、重试、降级处理
ERROR LOG_LEVEL_ERROR 红色 致命错误、断言失败、硬件异常
我的习惯:DEBUG日志只在开发版打开,发布版通过宏开关一键关闭。INFO/WARN/ERROR保留,但ERROR必须带上下文——光说「错误」没用,得告诉我哪个模块、什么状态、错误码是多少。

代码实现很简单,一个宏就搞定:

#define LOG_LEVEL  LOG_LEVEL_INFO

#define LOG_DEBUG(fmt, ...)  \
    do { if (LOG_LEVEL <= LOG_LEVEL_DEBUG) \
        log_output("DEBUG", fmt, ##__VA_ARGS__); \
    } while(0)

#define LOG_INFO(fmt, ...)   \
    do { if (LOG_LEVEL <= LOG_LEVEL_INFO) \
        log_output("INFO",  fmt, ##__VA_ARGS__); \
    } while(0)

#define LOG_WARN(fmt, ...)   \
    do { if (LOG_LEVEL <= LOG_LEVEL_WARN) \
        log_output("WARN",  fmt, ##__VA_ARGS__); \
    } while(0)

#define LOG_ERROR(fmt, ...)  \
    do { if (LOG_LEVEL <= LOG_LEVEL_ERROR) \
        log_output("ERROR", fmt, ##__VA_ARGS__); \
    } while(0)
注意:do { ... } while(0)这个写法不是闲的。它能保证宏在if-else分支里不出错。我曾经见过同事因为没加这个,日志宏把整个逻辑搞崩了。

4.2 环形缓冲区:轻量级日志缓存

直接往Flash写日志?太慢了。而且Flash有擦写寿命限制,动不动就写,芯片很快就废了。

所以我们需要一个中间层——环形缓冲区。说白了就是一个固定大小的数组,写满了从头覆盖。它有两个指针:写指针和读指针。

环形缓冲区结构图 0 1 2 3 4 5 6 7 写指针 (最新日志) 读指针 (最旧日志) 说明:写指针追上读指针时,覆盖最旧数据 读指针永远指向最旧的有效数据

核心数据结构就两个东西:一个数组,两个索引。

typedef struct {
    uint8_t  *buffer;      // 缓冲区基地址
    uint32_t size;         // 缓冲区大小(必须是2的幂)
    uint32_t write_idx;    // 写索引
    uint32_t read_idx;     // 读索引
} ring_buffer_t;

为什么size必须是2的幂?因为可以用位运算代替取模,快很多。

// 写入一个字节
int ring_buf_put(ring_buffer_t *rb, uint8_t data) {
    uint32_t next = (rb->write_idx + 1) & (rb->size - 1);
    
    // 缓冲区满?覆盖最旧数据
    if (next == rb->read_idx) {
        rb->read_idx = (rb->read_idx + 1) & (rb->size - 1);
    }
    
    rb->buffer[rb->write_idx] = data;
    rb->write_idx = next;
    return 0;
}

// 读取一个字节
int ring_buf_get(ring_buffer_t *rb, uint8_t *data) {
    if (rb->read_idx == rb->write_idx) {
        return -1;  // 空
    }
    
    *data = rb->buffer[rb->read_idx];
    rb->read_idx = (rb->read_idx + 1) & (rb->size - 1);
    return 0;
}
关键点:环形缓冲区「满」和「空」的判断容易混淆。我习惯让缓冲区始终留一个空位——当write_idx + 1 == read_idx时视为满。这样read_idx == write_idx就一定是空,不会打架。

4.3 日志持久化:把关键信息写进Flash

环形缓冲区里的日志,掉电就没了。真正要排查问题,得把日志存到Flash里。

但Flash写入有几个坑:

  • 写入前必须擦除——Flash只能从1写0,不能从0写1
  • 擦除以扇区为单位——最小擦除单位通常是4KB
  • 擦写寿命有限——一般10万次,频繁写很快就报废

我的做法是:

  1. 在Flash里划出一块区域,专门存日志
  2. 日志按「页」组织,每页固定大小(比如256字节)
  3. 写满一页才刷一次Flash,减少擦写次数
  4. 扇区写满了,擦除最旧的扇区

看个简单的日志页结构:

#define LOG_PAGE_SIZE    256
#define LOG_SECTOR_SIZE  4096
#define LOG_MAGIC        0xA5A5

typedef struct {
    uint16_t magic;       // 魔数,校验页是否有效
    uint16_t length;      // 本页有效数据长度
    uint32_t timestamp;   // 时间戳
    uint8_t  data[LOG_PAGE_SIZE - 8];  // 日志内容
} log_page_t;

写入流程:

void flash_log_write(const uint8_t *log_data, uint16_t len) {
    static log_page_t page;
    static uint16_t offset = 0;
    
    // 第一页,初始化
    if (offset == 0) {
        page.magic = LOG_MAGIC;
        page.timestamp = get_sys_tick();
    }
    
    // 拷贝数据到当前页
    uint16_t copy_len = (len > (sizeof(page.data) - offset)) ?
                        (sizeof(page.data) - offset) : len;
    memcpy(&page.data[offset], log_data, copy_len);
    offset += copy_len;
    
    // 页满了?刷入Flash
    if (offset >= sizeof(page.data)) {
        page.length = offset;
        flash_erase_and_write(&page, sizeof(page));
        offset = 0;
    }
}
我曾经踩过的坑:直接往Flash里写结构体,结果因为字节对齐问题,数据全乱了。记住:写入Flash前,一定要做字节对齐处理,或者用__packed属性。另外,时间戳别用RTC的绝对时间——万一RTC没初始化呢?我改用系统上电后的tick计数,至少能知道日志的先后顺序。

4.4 日志读取:从Flash里捞数据

存进去还得能读出来。读取逻辑相对简单:

  • 遍历Flash扇区,找到魔数正确的页
  • 按页读取,拼成完整的日志流
  • 通过串口或文件系统导出
void flash_log_read_all(void) {
    uint32_t addr = LOG_FLASH_BASE;
    log_page_t page;
    
    while (addr < LOG_FLASH_BASE + LOG_FLASH_SIZE) {
        flash_read(addr, (uint8_t *)&page, sizeof(page));
        
        if (page.magic != LOG_MAGIC) {
            addr += LOG_SECTOR_SIZE;
            continue;  // 跳过无效扇区
        }
        
        // 输出日志
        uart_send(page.data, page.length);
        addr += LOG_PAGE_SIZE;
    }
}
小技巧:调试阶段,我习惯在Flash日志里加一个「滚码」——每次写入递增。这样即使时间戳丢了,也能通过滚码判断日志顺序。这个习惯帮我解决过好几次时间同步失败的问题。

4.5 本章小结

日志系统看着简单,但要做好真不容易。总结几个要点:

  • 等级分明——DEBUG/INFO/WARN/ERROR各司其职,别混用
  • 环形缓冲——轻量、高效,适合实时缓存
  • Flash持久化——按页写入、按扇区擦除,注意寿命和字节对齐
  • 可追溯——每条日志带上时间戳或滚码,方便排查