日志系统搭建:日志等级定义、日志格式规范、环形缓冲区实现、日志输出接口封装
日志系统,说白了就是嵌入式系统的「黑匣子」。我做了这么多年嵌入式测试,遇到过太多现场复现不了的bug。没有日志,你就像蒙着眼睛找针。这一章,咱们就把这个黑匣子搭起来。
日志等级定义
日志等级不是拍脑袋定的。我见过不少团队,日志等级搞得比操作系统优先级还复杂,结果现场一跑,全是DEBUG信息,关键错误反而被淹没了。
我个人习惯,嵌入式系统用5级就够了:
| 等级 | 宏定义 | 数值 | 使用场景 |
|---|---|---|---|
| ERROR | LOG_LEVEL_ERROR | 1 | 系统无法继续运行的致命错误 |
| WARN | LOG_LEVEL_WARN | 2 | 不影响运行但需要关注的异常 |
| INFO | LOG_LEVEL_INFO | 3 | 系统关键状态变化、初始化完成等 |
| DEBUG | LOG_LEVEL_DEBUG | 4 | 开发调试阶段的详细流程信息 |
| TRACE | LOG_LEVEL_TRACE | 5 | 函数进出、变量值等最细粒度信息 |
我的经验:发布版本默认只开ERROR和WARN。INFO在需要现场分析时动态打开。DEBUG和TRACE只在开发阶段用。这样既保证性能,又保留现场诊断能力。
日志格式规范
格式不统一,日志分析工具就没法用。我踩过这个坑——有一次现场出了问题,三个工程师写的日志格式都不一样,解析脚本写了三天。
我建议的格式模板:
[时间戳][等级][模块:函数:行号] 日志内容
具体到代码实现:
#define LOG_FORMAT "[%s][%s][%s:%s:%d] %s\r\n"
// 示例输出:
// [2024-01-15 14:30:25.123][ERROR][CAN:CanSend:128] 总线离线,重试第3次
这里有几个要点:
- 时间戳:必须带毫秒。我遇到过两次日志时间一模一样的情况,后来才发现是没加毫秒,根本没法还原时序。
- 模块名:用缩写,比如CAN、UART、FATFS。太长会浪费环形缓冲区空间。
- 行号:用__LINE__宏。别嫌麻烦,出问题的时候,行号能帮你省半天时间。
环形缓冲区实现
为什么用环形缓冲区?嵌入式系统写日志不能阻塞主流程。你想想看,如果每次写日志都去刷Flash,系统响应就完蛋了。
环形缓冲区的核心结构:
typedef struct {
uint8_t *buffer; // 缓冲区基地址
uint32_t size; // 缓冲区大小
volatile uint32_t head; // 写指针
volatile uint32_t tail; // 读指针
} RingBuffer_t;
写操作的核心逻辑:
int32_t RingBuf_Write(RingBuffer_t *rb, const uint8_t *data, uint32_t len) {
uint32_t next_head;
for (uint32_t i = 0; i < len; i++) {
next_head = (rb->head + 1) % rb->size;
// 缓冲区满,覆盖最旧数据
if (next_head == rb->tail) {
rb->tail = (rb->tail + 1) % rb->size;
}
rb->buffer[rb->head] = data[i];
rb->head = next_head;
}
return len;
}
注意:head和tail必须用volatile修饰。我曾经在一个多任务系统里忘了加volatile,结果编译器优化后,写指针一直不更新,日志全丢了。查了两天才找到原因。
缓冲区大小怎么定?我一般按这个公式算:
- 系统峰值日志产生速率 × 最大容忍延迟时间 × 1.5(安全系数)
- 比如:每秒最多产生2KB日志,后台任务每100ms刷一次,那就是 2KB × 0.1s × 1.5 = 300字节。实际取整到512字节或1KB。
下面这张图展示了环形缓冲区的工作流程:
日志输出接口封装
接口封装的核心思想:上层调用者不需要知道日志最终写到哪。是串口、文件、还是网络?那是底层的事。
我常用的接口设计:
// 日志初始化
int Log_Init(LogOutputFunc output, uint32_t buf_size);
// 日志写入(对外接口)
void Log_Write(uint8_t level, const char *module,
const char *func, int line,
const char *fmt, ...);
// 日志刷出(后台任务调用)
int Log_Flush(void);
// 日志等级动态设置
void Log_SetLevel(uint8_t level);
具体实现时,我习惯用可变参数宏封装:
#define LOG_ERROR(mod, fmt, ...) \
Log_Write(LOG_LEVEL_ERROR, mod, __FUNCTION__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_INFO(mod, fmt, ...) \
if(g_log_level >= LOG_LEVEL_INFO) \
Log_Write(LOG_LEVEL_INFO, mod, __FUNCTION__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_DEBUG(mod, fmt, ...) \
if(g_log_level >= LOG_LEVEL_DEBUG) \
Log_Write(LOG_LEVEL_DEBUG, mod, __FUNCTION__, __LINE__, fmt, ##__VA_ARGS__)
关键设计点:
- DEBUG和TRACE等级加条件判断,避免不必要的函数调用开销
- Log_Write内部只做格式化+写入环形缓冲区,不做IO操作
- Log_Flush由低优先级后台任务调用,负责实际输出
输出函数注册机制也很重要。我做过一个项目,调试时用串口输出,量产时改成SPI Flash存储。接口不变,只换注册函数:
// 串口输出
int Uart_Output(const uint8_t *data, uint32_t len) {
return HAL_UART_Transmit(&huart1, data, len, 100);
}
// Flash存储输出
int Flash_Output(const uint8_t *data, uint32_t len) {
return Flash_Write_Log(data, len);
}
// 初始化时选择
Log_Init(Uart_Output, 1024); // 调试版
// Log_Init(Flash_Output, 2048); // 量产版
我曾经踩过的坑:在中断服务函数里直接调用Log_Write。中断里用printf或者复杂格式化,轻则系统卡死,重则栈溢出。正确的做法是:中断里只设标志位,由任务上下文去调用日志接口。