2. 日志系统设计:日志级别定义、日志格式化、日志输出接口、环形缓冲区实现
日志系统,说白了就是嵌入式系统的「黑匣子」。我在做HAL层开发的头两年,其实没太重视这东西。直到有一次,一个偶发的I2C通信异常让我在实验室熬了三个通宵——没有日志,你根本不知道系统崩溃前发生了什么。从那以后,我设计任何模块,第一件事就是先把日志系统搭好。
今天我们就来聊聊,一个靠谱的HAL层日志系统该怎么设计。我会从四个核心维度展开:日志级别、格式化、输出接口,还有环形缓冲区。
2.1 日志级别定义
日志级别不是拍脑袋定的。我个人习惯参考Linux内核和Android HAL的规范,再结合嵌入式场景做裁剪。常见的级别定义如下:
| 级别 | 数值 | 含义 | 典型场景 |
|---|---|---|---|
| FATAL | 0 | 致命错误 | 系统无法继续运行,必须复位 |
| ERROR | 1 | 错误 | 功能异常,但系统可继续 |
| WARN | 2 | 警告 | 非预期状态,不影响当前功能 |
| INFO | 3 | 信息 | 关键流程节点、状态切换 |
| DEBUG | 4 | 调试 | 开发阶段详细输出 |
| TRACE | 5 | 跟踪 | 函数调用、变量值变化 |
核心原则:发布版本只保留FATAL、ERROR、WARN三级。DEBUG和TRACE只在开发版启用。你想想看,如果量产设备每秒输出几百条调试日志,不光浪费Flash寿命,还会拖慢系统响应。
我在项目中遇到过一件事:有个同事把日志级别全开,结果设备跑了一个月,Flash写坏了。嗯,这就是没做好级别控制的代价。
2.2 日志格式化
日志格式要「机器可读,人眼可看」。我建议的格式模板是:
[时间戳][级别][模块名:行号] 消息内容
举个例子:
[2025-01-15 14:30:22.456][ERROR][I2C:128] 总线超时,重试第3次
为什么要包含行号?因为嵌入式环境里,你经常没法用GDB。有了行号,定位问题快得多。我曾经靠这个格式,十分钟就找到了一个DMA中断冲突的bug。
格式化函数实现如下:
void log_format(uint8_t level, const char *module, int line, const char *fmt, ...) {
char buffer[LOG_BUFFER_SIZE];
int len = 0;
// 1. 时间戳
uint32_t ms = get_system_ms();
len += snprintf(buffer + len, sizeof(buffer) - len,
"[%u.%03u]", ms / 1000, ms % 1000);
// 2. 级别
const char *level_str[] = {"FATAL", "ERROR", "WARN", "INFO", "DEBUG", "TRACE"};
len += snprintf(buffer + len, sizeof(buffer) - len,
"[%s]", level_str[level]);
// 3. 模块和行号
len += snprintf(buffer + len, sizeof(buffer) - len,
"[%s:%d]", module, line);
// 4. 用户消息
va_list args;
va_start(args, fmt);
len += vsnprintf(buffer + len, sizeof(buffer) - len, fmt, args);
va_end(args);
// 5. 输出
log_output(buffer, len);
}
小技巧:时间戳用系统滴答计数(ms)而不是RTC时间。原因很简单——RTC可能还没初始化,日志系统就得先跑起来。我一般用SysTick或定时器计数,精度够用,还省事。
2.3 日志输出接口
输出接口要抽象化。你不能把日志写死在UART上,万一客户要求用SPI屏显示呢?
我习惯定义一个输出接口结构体:
typedef struct {
void (*init)(void);
void (*write)(const char *data, uint16_t len);
void (*flush)(void);
} log_output_t;
然后注册不同的后端:
static log_output_t uart_output = {
.init = uart_init,
.write = uart_write,
.flush = uart_flush,
};
static log_output_t flash_output = {
.init = flash_log_init,
.write = flash_log_write,
.flush = flash_log_flush,
};
// 运行时切换
void log_set_output(log_output_t *output) {
g_current_output = output;
g_current_output->init();
}
这样做的好处是啥?你想想看,调试阶段用UART输出到PC,量产阶段切到Flash存储,甚至可以通过蓝牙远程拉取日志。接口不变,底层随便换。
注意:输出函数里不能有阻塞等待。尤其是Flash写入,动辄几十毫秒。我建议用DMA + 双缓冲,或者干脆把日志先扔进环形缓冲区,由后台任务慢慢刷。
2.4 环形缓冲区实现
环形缓冲区是日志系统的「心脏」。为什么不用链表?因为嵌入式环境里,动态内存分配是禁忌——你不知道什么时候会分配失败,也不知道碎片化有多严重。
环形缓冲区,说白了就是一个固定大小的数组,加上读写指针。核心结构如下:
#define RING_BUF_SIZE 4096
typedef struct {
uint8_t buffer[RING_BUF_SIZE];
volatile uint32_t write_idx;
volatile uint32_t read_idx;
} ring_buffer_t;
读写逻辑:
// 写入
int ring_buf_write(ring_buffer_t *rb, const uint8_t *data, uint32_t len) {
for (uint32_t i = 0; i < len; i++) {
uint32_t next = (rb->write_idx + 1) % RING_BUF_SIZE;
// 如果缓冲区满了,覆盖旧数据
if (next == rb->read_idx) {
rb->read_idx = (rb->read_idx + 1) % RING_BUF_SIZE;
}
rb->buffer[rb->write_idx] = data[i];
rb->write_idx = next;
}
return len;
}
// 读取
int ring_buf_read(ring_buffer_t *rb, uint8_t *data, uint32_t max_len) {
uint32_t count = 0;
while (rb->read_idx != rb->write_idx && count < max_len) {
data[count++] = rb->buffer[rb->read_idx];
rb->read_idx = (rb->read_idx + 1) % RING_BUF_SIZE;
}
return count;
}
关键点:读写指针都用volatile修饰。为什么?因为中断里可能会写日志,主循环里读日志。没有volatile,编译器优化后可能永远读不到新数据。我曾经在这个坑里摔过一次,排查了整整一天。
这里我画了一张环形缓冲区的工作流程图,帮你直观理解:
你看这个图,写指针一直在往前跑,读指针在后面追。当写指针快追上读指针时,说明缓冲区快满了。这时候有两种策略:
- 丢弃新日志:保证旧日志不丢失,但可能漏掉关键信息
- 覆盖旧日志:保留最新信息,但最开始的日志会丢失
我个人推荐覆盖策略。为什么?因为出问题时,你更关心「崩溃前发生了什么」,而不是「系统启动时干了什么」。
2.5 避坑指南
最后分享几个我踩过的坑:
- 中断里别用复杂格式化:snprintf这种函数可能很慢,还可能有重入问题。我一般在中断里只做简单拷贝,格式化交给后台任务。
- 注意临界区保护:如果日志系统被多个任务或中断同时调用,读写指针必须加锁。最简单的做法是关中断,但关中断时间不能太长。
- 缓冲区大小要合理:太小容易丢日志,太大浪费RAM。我一般按「系统最大日志产生速率 × 期望保留时间」来估算。比如每秒最多100条日志,每条平均50字节,想保留10秒,那就是50KB。
我的习惯:开发阶段用4KB环形缓冲区 + UART输出。量产阶段用16KB环形缓冲区 + Flash存储。Flash里再分两个区,轮流写,防止单区损坏导致所有日志丢失。
好了,日志系统的核心设计就这些。下一节我们会聊错误码的规范定义和传递机制,到时候再细说。
可可精品资料库