一、宏与日志系统:从基础到实战
日志系统,说白了就是给程序装个「黑匣子」。我做了十几年嵌入式开发,最怕的就是产品在现场出问题,又没法接调试器。这时候,一个好的日志系统就是救命稻草。
今天我们来聊聊怎么用C语言的宏,打造一个轻量级日志库。别小看这个主题,它把可变参数宏、预处理指令、条件编译这些知识点全串起来了。
1.1 可变参数宏:__VA_ARGS__ 的妙用
先看个基础。标准C99引入了可变参数宏,语法很简单:
#define LOG(format, ...) printf("[LOG] " format, ##__VA_ARGS__)
这里有个细节——##。它的作用是:当可变参数为空时,删除前面的逗号。我刚开始用的时候没加这个,结果每次调用LOG("hello")都会编译报错,因为多了一个逗号。
核心要点:
__VA_ARGS__代表省略号对应的参数列表##__VA_ARGS__是GCC扩展,解决空参数时的逗号问题- 标准C99写法是
__VA_OPT__(, ),但嵌入式编译器支持度不一
举个例子:
LOG("温度: %.1f°C", 25.3); // 输出: [LOG] 温度: 25.3°C
LOG("系统启动"); // 输出: [LOG] 系统启动
1.2 日志级别控制:该输出什么,我说了算
实际项目中,日志不能全量输出。调试阶段要详细,发布阶段只要错误。这就需要用宏来控制级别。
我一般这样定义:
#define LOG_LEVEL_NONE 0
#define LOG_LEVEL_ERROR 1
#define LOG_LEVEL_WARN 2
#define LOG_LEVEL_INFO 3
#define LOG_LEVEL_DEBUG 4
// 当前日志级别,可以在编译时指定
#ifndef CURRENT_LOG_LEVEL
#define CURRENT_LOG_LEVEL LOG_LEVEL_DEBUG
#endif
#define LOG_ERROR(format, ...) \
do { \
if (CURRENT_LOG_LEVEL >= LOG_LEVEL_ERROR) \
printf("[ERROR] " format, ##__VA_ARGS__); \
} while(0)
#define LOG_WARN(format, ...) \
do { \
if (CURRENT_LOG_LEVEL >= LOG_LEVEL_WARN) \
printf("[WARN] " format, ##__VA_ARGS__); \
} while(0)
#define LOG_INFO(format, ...) \
do { \
if (CURRENT_LOG_LEVEL >= LOG_LEVEL_INFO) \
printf("[INFO] " format, ##__VA_ARGS__); \
} while(0)
#define LOG_DEBUG(format, ...) \
do { \
if (CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG) \
printf("[DEBUG] " format, ##__VA_ARGS__); \
} while(0)
我的习惯:发布版本把 CURRENT_LOG_LEVEL 设为 LOG_LEVEL_ERROR,这样只有错误日志会输出。调试版本设为 LOG_LEVEL_DEBUG,所有信息都看得到。
1.3 文件行号自动注入:__FILE__ 和 __LINE__
光有日志内容还不够。出问题了你得知道是哪个文件、哪行代码打的日志。C语言预定义了两个宏:__FILE__ 和 __LINE__。
把它们加进去:
#define LOG_ERROR(format, ...) \
do { \
if (CURRENT_LOG_LEVEL >= LOG_LEVEL_ERROR) \
printf("[ERROR] [%s:%d] " format, __FILE__, __LINE__, ##__VA_ARGS__); \
} while(0)
输出效果:
[ERROR] [main.c:42] 传感器读取失败,错误码: 0x03
嗯,这里要注意:__FILE__ 是完整路径,嵌入式项目里路径可能很长。我一般用 __FILE__ 的最后一个文件名部分,或者用 __BASE_FILE__(如果编译器支持)。
1.4 实战:轻量级日志库
好了,我们把上面的知识点整合起来,写一个完整的日志库。这个库我用了好几年,在多个STM32和ESP32项目上验证过。
先看头文件 log.h:
#ifndef __LOG_H__
#define __LOG_H__
#include <stdio.h>
#include <time.h>
// 日志级别
typedef enum {
LOG_LEVEL_NONE = 0,
LOG_LEVEL_ERROR = 1,
LOG_LEVEL_WARN = 2,
LOG_LEVEL_INFO = 3,
LOG_LEVEL_DEBUG = 4
} log_level_t;
// 默认日志级别
#ifndef LOG_LEVEL
#define LOG_LEVEL LOG_LEVEL_DEBUG
#endif
// 获取文件名(去掉路径)
#define __FILENAME__ (strrchr(__FILE__, '/') ? strrchr(__FILE__, '/') + 1 : __FILE__)
// 核心日志宏
#define LOG(level, tag, format, ...) \
do { \
if (level <= LOG_LEVEL) { \
printf("[%s] [%s] [%s:%d] " format "\r\n", \
log_level_str(level), \
tag, \
__FILENAME__, \
__LINE__, \
##__VA_ARGS__); \
} \
} while(0)
// 便捷宏
#define LOGE(tag, format, ...) LOG(LOG_LEVEL_ERROR, tag, format, ##__VA_ARGS__)
#define LOGW(tag, format, ...) LOG(LOG_LEVEL_WARN, tag, format, ##__VA_ARGS__)
#define LOGI(tag, format, ...) LOG(LOG_LEVEL_INFO, tag, format, ##__VA_ARGS__)
#define LOGD(tag, format, ...) LOG(LOG_LEVEL_DEBUG, tag, format, ##__VA_ARGS__)
// 级别转字符串
static inline const char* log_level_str(log_level_t level) {
switch(level) {
case LOG_LEVEL_ERROR: return "ERROR";
case LOG_LEVEL_WARN: return "WARN ";
case LOG_LEVEL_INFO: return "INFO ";
case LOG_LEVEL_DEBUG: return "DEBUG";
default: return "UNKN ";
}
}
#endif // __LOG_H__
使用示例:
#include "log.h"
#define TAG "MAIN"
int main() {
LOGE(TAG, "系统启动失败,错误码: %d", -1);
LOGW(TAG, "电池电量低: %.1f%%", 15.2);
LOGI(TAG, "WiFi已连接,IP: %s", "192.168.1.100");
LOGD(TAG, "当前堆栈使用: %u bytes", 2048);
return 0;
}
输出效果:
[ERROR] [MAIN] [main.c:5] 系统启动失败,错误码: -1
[WARN ] [MAIN] [main.c:6] 电池电量低: 15.2%
[INFO ] [MAIN] [main.c:7] WiFi已连接,IP: 192.168.1.100
[DEBUG] [MAIN] [main.c:8] 当前堆栈使用: 2048 bytes
避坑指南:我曾经在某个项目里把 do { ... } while(0) 写成了 { ... },结果在 if-else 语句里用日志宏时,else 匹配出了问题。记住,do-while(0) 是宏定义的标准写法,能保证宏展开后语法正确。
1.5 知识体系总览
下面这张图把本章的核心知识点串起来了:
1.6 进阶技巧:性能与资源考量
嵌入式环境资源有限,日志系统不能太「重」。我总结了几条经验:
| 问题 | 解决方案 | 说明 |
|---|---|---|
| 字符串占用ROM | 使用 #if 编译时裁剪 |
不用的日志级别,连字符串都不编译进去 |
| printf 太慢 | 改用 fputs 或自定义输出 |
printf 解析格式串开销大,批量日志时明显 |
| 日志缓冲区溢出 | 环形缓冲区 + 中断安全 | 我遇到过日志把任务栈撑爆的情况 |
| 时间戳精度 | 使用系统滴答计数器 | 不用RTC,用 HAL_GetTick() 或 clock() |
我的建议:别在中断服务函数里直接调用日志宏。中断里应该只做标记,把日志输出放到任务级处理。否则一旦日志输出阻塞,整个系统就挂了。
好了,这一章的内容就到这里。日志系统看似简单,但用好宏定义能让它既灵活又高效。下一章我们聊聊条件编译的更多玩法,到时候见。
公众号:蓝海资料掘金营,微信deep3321