第四章 升级日志系统:从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有擦写寿命限制,动不动就写,芯片很快就废了。
所以我们需要一个中间层——环形缓冲区。说白了就是一个固定大小的数组,写满了从头覆盖。它有两个指针:写指针和读指针。
核心数据结构就两个东西:一个数组,两个索引。
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万次,频繁写很快就报废
我的做法是:
- 在Flash里划出一块区域,专门存日志
- 日志按「页」组织,每页固定大小(比如256字节)
- 写满一页才刷一次Flash,减少擦写次数
- 扇区写满了,擦除最旧的扇区
看个简单的日志页结构:
#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持久化——按页写入、按扇区擦除,注意寿命和字节对齐
- 可追溯——每条日志带上时间戳或滚码,方便排查