3. 日志系统架构:日志级别定义、日志存储机制、日志轮转策略
日志系统,说白了就是智能音箱的「黑匣子」。
我做了这么多年嵌入式,最怕的就是产品出了问题,结果日志一塌糊涂——要么没记,要么记太多把存储撑爆了。嗯,今天咱们就把日志系统的三个核心问题讲透:记什么、记在哪、记满了怎么办。
3.1 日志级别定义:别什么都往里塞
很多新手工程师喜欢把所有信息都打上 printf,结果线上产品日志里全是「WiFi连接成功」「音量调到50%」这种废话。真正出故障时,关键信息反而被淹没了。
我个人习惯,把日志分成5个级别。你想想看,这就像交通信号灯——不同颜色代表不同紧急程度。
| 级别 | 名称 | 含义 | 线上是否开启 |
|---|---|---|---|
| 0 | FATAL | 致命错误,系统无法继续运行 | ✅ 必须开启 |
| 1 | ERROR | 功能异常,但系统可降级运行 | ✅ 必须开启 |
| 2 | WARN | 潜在风险,需要关注 | ✅ 建议开启 |
| 3 | INFO | 关键流程节点记录 | ✅ 建议开启 |
| 4 | DEBUG | 调试信息,仅开发阶段使用 | ❌ 关闭 |
核心原则:线上产品只开 FATAL、ERROR、WARN 和少量 INFO。DEBUG 日志只在开发板或远程调试时临时开启。
我在项目中遇到过一件事:某次智能音箱半夜频繁重启,线上日志全是 INFO 级别的「心跳包正常」。后来我强制把日志级别降到 DEBUG,才发现是某个传感器驱动在特定条件下触发了看门狗复位。你看,级别设错了,排查方向就全偏了。
代码实现上,我一般这样定义:
typedef enum {
LOG_LEVEL_FATAL = 0,
LOG_LEVEL_ERROR = 1,
LOG_LEVEL_WARN = 2,
LOG_LEVEL_INFO = 3,
LOG_LEVEL_DEBUG = 4
} log_level_t;
#define LOG_PRINT(level, fmt, ...) \
do { \
if (level <= g_log_threshold) { \
log_output(level, __FILE__, __LINE__, fmt, ##__VA_ARGS__); \
} \
} while(0)
这里有个小技巧:g_log_threshold 可以在运行时通过 AT 指令或云端配置动态修改。线上出问题时,远程把阈值降到 DEBUG,就能抓到现场信息。排查完再改回去,不影响性能。
避坑指南:我曾经在某个项目里把 DEBUG 日志的宏定义写成了 printf,结果线上版本忘记关掉,导致每秒输出几百条日志,Flash 芯片一个月就写报废了。所以一定要用宏开关控制,编译时就把 DEBUG 日志彻底去掉。
3.2 日志存储机制:写在哪儿是个学问
智能音箱不像服务器有海量硬盘。它的存储资源非常有限——可能只有 16MB 的 SPI Flash,还要分给固件、配置、语音数据。日志能占多少?我一般只给 1~2MB。
存储方案主要有三种,我按推荐程度排个序:
- 环形缓冲区(Ring Buffer):固定大小,写满后覆盖最旧的数据。适合记录最近一段时间的日志。
- 文件系统 + 日志文件:使用 LittleFS 或 SPIFFS 这类轻量文件系统,按文件管理日志。
- 直接写 Flash 扇区:绕过文件系统,直接操作 Flash 地址。性能高,但管理麻烦。
我个人最常用的是「环形缓冲区 + 定期转储」的组合方案。说白了就是:内存里维护一个环形缓冲区,实时写入日志;当缓冲区快满时,一次性刷到 Flash 里保存。
代码结构大概这样:
#define LOG_BUF_SIZE (16 * 1024) // 16KB 环形缓冲区
#define LOG_FLASH_SECTOR (0x1F0000) // Flash 地址
static uint8_t log_buf[LOG_BUF_SIZE];
static uint32_t write_index = 0;
void log_write(const char *msg) {
uint32_t len = strlen(msg);
// 环形写入
for (uint32_t i = 0; i < len; i++) {
log_buf[(write_index + i) % LOG_BUF_SIZE] = msg[i];
}
write_index = (write_index + len) % LOG_BUF_SIZE;
}
void log_flush_to_flash(void) {
// 将环形缓冲区内容写入 Flash 指定扇区
flash_erase(LOG_FLASH_SECTOR);
flash_write(LOG_FLASH_SECTOR, log_buf, LOG_BUF_SIZE);
}
注意:Flash 写入有寿命限制,一般 10 万次擦写。如果每 10 秒刷一次日志,两年就报废了。所以刷写频率要控制好——我一般设置成每小时一次,或者日志量达到 80% 时才触发。
3.3 日志轮转策略:别让日志撑死系统
日志轮转,说白了就是「旧的不去,新的不来」。
我记得有一次,客户反馈音箱用了一个月后变得特别卡。查了半天,发现是日志文件没有轮转,一直追加写入,把 Flash 空间占满了,导致文件系统无法分配新空间。
从那以后,我设计日志轮转时一定会考虑三个参数:
- 最大文件数:比如保留最近 3 个日志文件
- 单个文件大小:比如每个文件不超过 512KB
- 轮转触发条件:写满就切,或者按时间切
具体实现上,我推荐「固定数量 + 覆盖最旧」的策略:
#define MAX_LOG_FILES 3
#define LOG_FILE_SIZE (512 * 1024) // 512KB
void log_rotate(void) {
// 检查当前文件是否超过大小限制
uint32_t file_size = get_file_size("log_0.txt");
if (file_size < LOG_FILE_SIZE) {
return; // 没满,继续写
}
// 文件满了,执行轮转
// log_2.txt -> 删除
// log_1.txt -> 重命名为 log_2.txt
// log_0.txt -> 重命名为 log_1.txt
// 新建 log_0.txt
remove("log_2.txt");
rename("log_1.txt", "log_2.txt");
rename("log_0.txt", "log_1.txt");
create_new("log_0.txt");
}
关键点:轮转操作本身也要记录日志!我曾经遇到过轮转过程中突然断电,导致文件系统损坏。后来我加了一个「轮转标志位」——开始轮转前写一个标记,轮转完成后清除。下次启动时检查这个标记,如果发现异常,就重建日志系统。
另外,我建议在日志文件头部加一个固定格式的元信息:
[HEADER]
version=1.0
device_id=SN20240315001
boot_count=128
timestamp=1710567890
[END_HEADER]
2024-03-16 10:30:01 [INFO] 系统启动完成
2024-03-16 10:30:02 [WARN] 麦克风阵列校准偏差较大
...
这样做的好处是,即使日志文件被轮转覆盖了,你也能从元信息里知道这个文件对应哪个设备、哪次启动。我在远程排查问题时,经常靠这个快速定位。
一个小建议:日志轮转的时间点最好选在系统空闲时。比如凌晨 3 点,用户都睡了,音箱处于待机状态。这时候做轮转,既不影响用户体验,也能保证操作安全。
好了,日志系统的三个核心问题就讲到这里。总结一下:
- 级别要分清楚,线上只开必要级别
- 存储用环形缓冲区 + Flash 定期转储
- 轮转策略要防写满、防丢失、防断电
下一章咱们聊聊「日志采集与上报机制」——怎么把设备端的日志安全、高效地传到云端。到时候我会分享一个我踩过的坑,关于 MQTT 传输日志时丢数据的血泪史。