日志系统设计:日志级别、日志格式、异步日志、日志轮转
日志这东西,说白了就是嵌入式系统的「黑匣子」。我做了十几年嵌入式开发,遇到过太多线上问题——程序跑飞了、内存溢出了、外设莫名其妙不工作了。没有日志,你就像瞎子摸象。有了日志,你才能知道系统死前到底发生了什么。
今天我们来聊聊日志系统的四个核心设计点:日志级别、日志格式、异步日志、日志轮转。这四点做好了,你的日志系统才算真正能用、好用、耐看。
一、日志级别:别什么都往日志里塞
我记得刚入行那会儿,有个同事特别喜欢用 printf 到处打信息。结果产品上线后,串口每秒输出几百行日志,CPU 占用率直接飙到 30%。这哪是调试?这是自残。
日志级别的作用,就是让你能按需控制输出量。常见的分级是这样的:
| 级别 | 数值 | 含义 | 使用场景 |
|---|---|---|---|
| FATAL | 0 | 致命错误 | 系统无法继续运行,必须复位 |
| ERROR | 1 | 错误 | 功能异常,但系统还能跑 |
| WARN | 2 | 警告 | 潜在问题,比如内存快满了 |
| INFO | 3 | 信息 | 关键流程节点,比如初始化完成 |
| DEBUG | 4 | 调试 | 开发阶段用的详细输出 |
| TRACE | 5 | 跟踪 | 函数调用、变量变化等细粒度信息 |
我个人习惯的做法是:在代码里定义一个全局的日志级别变量。发布版本默认设为 INFO,调试版本设为 DEBUG 或 TRACE。这样线上跑的时候不会被打扰,出问题了再远程调高级别。
核心原则:日志级别越低(数值越大),输出越详细。生产环境建议用 INFO 或 WARN 级别,别开 DEBUG。
二、日志格式:统一模板,方便解析
日志格式这件事,我踩过坑。以前有个项目,每个模块的日志格式都不一样——有的带时间戳,有的不带;有的用逗号分隔,有的用空格。结果出问题后,写脚本分析日志时,光解析格式就花了两天。
所以,我建议所有日志统一成这样的格式:
[时间戳] [级别] [模块名] [文件名:行号] 消息内容
举个例子:
[2025-01-15 14:23:45.678] [ERROR] [UART] [uart_driver.c:128] 接收缓冲区溢出,丢弃数据
为什么要这么设计?
- 时间戳:精确到毫秒,方便定位时序问题
- 级别:一眼看出严重程度
- 模块名:快速定位是哪个子系统出的问题
- 文件名:行号:直接定位代码位置,省去翻代码的时间
在 C 语言里,可以用宏来实现自动填充文件名和行号:
#define LOG_ERROR(mod, fmt, ...) \
log_output(LOG_LEVEL_ERROR, mod, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
小技巧:时间戳最好用 UTC 时间,避免时区问题。如果系统没有 RTC,可以用系统启动后的相对时间(比如 ticks)。
三、异步日志:别让日志拖慢系统
同步日志的问题在于:每次写日志都要等 I/O 完成。如果日志输出到串口或文件,写一次可能要几毫秒甚至几十毫秒。在高频日志场景下,这会让系统响应变慢。
异步日志的思路很简单:把日志消息丢到一个队列里,然后由专门的日志线程或任务去处理。调用方只管往队列里塞,不用等。
我画了一张图,帮你理解这个流程:
异步日志的代码实现也不复杂。核心就是一个环形缓冲区和两个指针:写指针和读指针。
#define LOG_QUEUE_SIZE 256
typedef struct {
char buffer[LOG_QUEUE_SIZE][128]; // 日志消息缓冲区
volatile uint32_t head; // 写指针
volatile uint32_t tail; // 读指针
} log_queue_t;
// 入队(由应用线程调用)
int log_enqueue(log_queue_t *q, const char *msg) {
uint32_t next = (q->head + 1) % LOG_QUEUE_SIZE;
if (next == q->tail) {
return -1; // 队列满了,丢弃
}
strncpy(q->buffer[q->head], msg, 127);
q->head = next;
return 0;
}
// 出队(由日志线程调用)
int log_dequeue(log_queue_t *q, char *out) {
if (q->tail == q->head) {
return -1; // 队列为空
}
strncpy(out, q->buffer[q->tail], 127);
q->tail = (q->tail + 1) % LOG_QUEUE_SIZE;
return 0;
}
注意:我曾经在一个项目中,日志队列满了之后阻塞了调用线程,结果导致系统死锁。记住:日志是辅助手段,不能影响主业务。队列满了就丢,别犹豫。
四、日志轮转:别让日志撑爆存储
嵌入式设备的存储空间通常很有限。如果不做日志轮转,日志文件会一直增长,直到把 Flash 或 SD 卡写满。到时候系统可能直接挂掉。
日志轮转的常见策略有两种:
- 按大小轮转:单个日志文件达到指定大小(比如 1MB)后,自动切换到下一个文件
- 按时间轮转:每天或每小时生成一个新的日志文件
我一般两种结合用。比如:每个日志文件最大 512KB,最多保留 10 个文件。超过 10 个就覆盖最旧的那个。
实现思路是这样的:
#define LOG_FILE_MAX_SIZE (512 * 1024) // 512KB
#define LOG_FILE_MAX_COUNT 10
void log_rotate(void) {
static int current_file = 0;
static uint32_t file_size = 0;
if (file_size >= LOG_FILE_MAX_SIZE) {
current_file = (current_file + 1) % LOG_FILE_MAX_COUNT;
file_size = 0;
// 打开新的日志文件
sprintf(filename, "log_%d.txt", current_file);
// 如果文件已存在,直接覆盖
}
// 写入日志,更新 file_size
}
建议:日志文件名最好带上序号或时间戳,比如 log_001.txt、log_002.txt。这样排查问题时,能按时间顺序查看。
五、避坑指南:我踩过的那些坑
最后分享几个我亲身经历过的教训:
- 日志里不要用动态内存分配:malloc 可能失败,而且会引入不确定性。用静态缓冲区就好。
- 日志字符串要短:我见过有人把整个数据包打印出来,一次日志输出几百字节。这在低带宽串口上会卡死系统。
- 注意日志的原子性:多线程环境下,如果日志输出被中断打断,可能输出乱码。用互斥锁或关中断保护一下。
- 日志别写敏感信息:密码、密钥这些千万别打出来。我曾经见过一个产品,日志里直接打印了 WiFi 密码……
好了,日志系统的四个核心点就聊到这里。记住:好的日志系统,平时感觉不到它的存在,出问题时它就是你最得力的助手。