日志系统设计:日志级别、环形缓冲区与输出策略
日志系统,说白了就是嵌入式系统的“黑匣子”。
我做了十几年嵌入式开发,最怕的就是现场出问题却拿不到有效信息。没有日志,你就像蒙着眼睛修车。所以这一章,咱们把日志系统从头到尾捋一遍。
日志级别定义
日志级别不是拍脑袋定的。我见过不少团队,上来就定义七八个级别,结果没人记得住。我个人习惯,四个级别就够了:
| 级别 | 数值 | 含义 | 使用场景 |
|---|---|---|---|
| ERROR | 0 | 致命错误 | 系统无法继续运行,必须人工介入 |
| WARN | 1 | 警告 | 异常但可恢复,比如通信超时重试 |
| INFO | 2 | 关键信息 | 状态切换、任务启动/停止、参数配置 |
| DEBUG | 3 | 调试信息 | 变量值、函数入口/出口、循环计数 |
级别定义用枚举就行,简单直接:
typedef enum {
LOG_LEVEL_ERROR = 0,
LOG_LEVEL_WARN = 1,
LOG_LEVEL_INFO = 2,
LOG_LEVEL_DEBUG = 3
} log_level_t;
#define LOG_PRINT(level, fmt, ...) \
do { \
if ((level) <= g_log_threshold) { \
log_output(level, fmt, ##__VA_ARGS__); \
} \
} while(0)
注意那个 g_log_threshold,运行时可以动态调整。我在项目中遇到过,现场调试时把阈值从 ERROR 降到 DEBUG,瞬间抓到那个间歇性崩溃的 bug。
环形缓冲区实现
为什么要用环形缓冲区?直接打印不行吗?
不行。串口打印是阻塞的,一条日志等几十毫秒,实时性全毁了。环形缓冲区是生产者-消费者模型:中断或任务里只管往缓冲区写,后台慢慢往外吐。
核心结构体长这样:
#define LOG_BUF_SIZE 4096
typedef struct {
uint8_t buffer[LOG_BUF_SIZE];
uint32_t head;
uint32_t tail;
uint32_t count;
} ring_buf_t;
写入和读取的逻辑,我直接给代码:
int ring_buf_write(ring_buf_t *rb, const uint8_t *data, uint32_t len) {
uint32_t i;
for (i = 0; i < len; i++) {
if (rb->count >= LOG_BUF_SIZE) {
return -1; // 缓冲区满了,丢弃
}
rb->buffer[rb->head] = data[i];
rb->head = (rb->head + 1) % LOG_BUF_SIZE;
rb->count++;
}
return (int)len;
}
int ring_buf_read(ring_buf_t *rb, uint8_t *data, uint32_t len) {
uint32_t i;
for (i = 0; i < len; i++) {
if (rb->count == 0) {
return (int)i; // 没数据了
}
data[i] = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % LOG_BUF_SIZE;
rb->count--;
}
return (int)len;
}
缓冲区大小怎么定?我一般按 2 的幂次来,比如 1024、2048、4096。这样取模可以用位运算,快很多:
#define LOG_BUF_MASK (LOG_BUF_SIZE - 1)
rb->head = (rb->head + 1) & LOG_BUF_MASK;
日志输出策略
输出方式选哪个?看你的硬件资源和调试需求。我列个对比表:
| 方式 | 速度 | 硬件需求 | 优点 | 缺点 |
|---|---|---|---|---|
| UART | 慢(115200bps) | 一个串口 | 通用,几乎任何板子都有 | 速度慢,占用引脚 |
| SWO | 快(可达 2Mbps) | SWO 引脚 + 调试器 | 不占额外引脚,速度较快 | 需要调试器支持,只能输出文本 |
| SEGGER RTT | 极快(可达 10MB/s) | J-Link 调试器 | 速度最快,双向通信 | 需要 J-Link,调试器不能断开 |
我个人最常用的是 RTT。为什么?快。你想想看,UART 打印一条 100 字节的日志要将近 10ms,RTT 只要几微秒。对于实时性要求高的系统,这差距是致命的。
但 RTT 有个问题:调试器必须一直连着。现场部署时,我一般切回 UART,或者把日志写到 Flash 里,等回来再读。
时间戳打点
没有时间戳的日志,就像没有日期的日记——你只知道发生了什么,不知道什么时候发生的。
时间戳怎么打?我推荐用系统滴答时钟(SysTick)。一般 RTOS 都会提供一个 1ms 的 tick,直接拿来用:
uint32_t get_timestamp_ms(void) {
return xTaskGetTickCount(); // FreeRTOS 示例
}
如果精度要求更高,可以用硬件定时器。比如 Cortex-M 的 DWT 周期计数器,可以做到 CPU 周期级精度:
uint32_t get_timestamp_cycle(void) {
return DWT->CYCCNT;
}
日志格式我习惯这样:
[时间戳][级别][模块] 消息内容
[1234567][ERROR][CAN] 总线关闭,尝试恢复...
这样一眼就能看出问题发生的时间点和模块。
整体架构
下面这张图,是我项目里用的日志系统架构:
这个架构的好处是分层清晰。应用层只管调用 LOG_PRINT,不用管底层怎么输出。想换输出方式?改底层策略就行,上层代码一行不动。