6、可变参数日志:使用stdarg.h实现类似printf的可变参数日志接口
说实话,日志系统做到这一步,基本功能都有了——时间戳、等级过滤、文件行号定位。但有个问题一直让我不太舒服:每次打印日志都得拼字符串,像这样:
LOG_INFO("温度: ");
LOG_INFO("%d", temperature);
LOG_INFO(" 度, 湿度: ");
LOG_INFO("%d", humidity);
你想想看,这多别扭?一条完整的信息被拆成好几段,日志文件里看着也乱。我当时在做一个数据采集项目,日志量特别大,这种写法直接导致日志可读性暴跌。后来我实在忍不了了,决定用C语言的可变参数机制,搞一个类似printf的日志接口。
6.1 可变参数的核心:stdarg.h
C标准库里的stdarg.h,说白了就是帮我们处理「参数个数不确定」的情况。它提供了几个宏:
- va_list:声明一个变量,用来遍历参数列表
- va_start:初始化va_list,告诉它从哪个参数开始取
- va_arg:取出下一个参数,需要指定类型
- va_end:清理工作,必须调用
嗯,这里要注意:va_start的第二个参数,必须是最后一个固定参数的名字。这个搞错了,程序直接崩。
6.2 第一个可变参数日志接口
我们先写一个最基础的版本:
#include <stdio.h>
#include <stdarg.h>
#include <time.h>
void log_printf(const char *level, const char *fmt, ...) {
va_list args;
time_t now;
struct tm *tm_info;
char time_buf[20];
// 获取当前时间
time(&now);
tm_info = localtime(&now);
strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", tm_info);
// 打印时间戳和等级
printf("[%s] [%s] ", time_buf, level);
// 处理可变参数
va_start(args, fmt);
vprintf(fmt, args);
va_end(args);
// 换行
printf("\n");
}
// 使用示例
int main() {
int temperature = 25;
int humidity = 60;
log_printf("INFO", "传感器数据: 温度=%d°C, 湿度=%d%%",
temperature, humidity);
return 0;
}
这里的关键是vprintf——它是printf的可变参数版本,直接接受一个va_list。我个人习惯用vprintf而不是自己解析格式字符串,省心又安全。
6.3 整合到日志系统中
光有打印还不够,得把等级过滤、文件行号这些功能都加进去。我在项目中是这样做的:
#include <stdio.h>
#include <stdarg.h>
#include <time.h>
typedef enum {
LOG_DEBUG,
LOG_INFO,
LOG_WARN,
LOG_ERROR
} log_level_t;
// 全局日志等级,低于这个等级的不输出
log_level_t g_log_level = LOG_DEBUG;
const char* level_to_string(log_level_t level) {
switch(level) {
case LOG_DEBUG: return "DEBUG";
case LOG_INFO: return "INFO";
case LOG_WARN: return "WARN";
case LOG_ERROR: return "ERROR";
default: return "UNKNOWN";
}
}
void log_message(log_level_t level, const char *file,
int line, const char *fmt, ...) {
if (level < g_log_level) {
return; // 等级不够,直接跳过
}
va_list args;
time_t now;
struct tm *tm_info;
char time_buf[20];
time(&now);
tm_info = localtime(&now);
strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", tm_info);
// 输出到标准错误,方便重定向
fprintf(stderr, "[%s] [%s] [%s:%d] ",
time_buf, level_to_string(level), file, line);
va_start(args, fmt);
vfprintf(stderr, fmt, args);
va_end(args);
fprintf(stderr, "\n");
}
// 为了方便使用,定义宏
#define LOG_DEBUG(fmt, ...) \
log_message(LOG_DEBUG, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_INFO(fmt, ...) \
log_message(LOG_INFO, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_WARN(fmt, ...) \
log_message(LOG_WARN, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...) \
log_message(LOG_ERROR, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
注意宏定义里的##__VA_ARGS__——这是GCC的一个扩展,作用是:如果可变参数为空,自动去掉前面的逗号。比如你写LOG_INFO("hello"),展开后不会变成log_message(..., "hello", ),而是log_message(..., "hello")。
##__VA_ARGS__是GCC/Clang的扩展,MSVC不支持。如果你需要跨平台,可以用__VA_ARGS__,但空参数时会有逗号问题。我一般在嵌入式Linux上用GCC,所以没太纠结这个。
6.4 格式化缓冲区:避免频繁IO
直接调用vfprintf每次都会触发IO操作,在嵌入式系统里这是个大坑。我曾经在一个每秒打印几百条日志的项目里,发现CPU大部分时间都耗在IO上了。
解决方案是:先把格式化后的字符串存到缓冲区,然后一次性写入。用vsnprintf可以实现:
#define LOG_BUF_SIZE 256
void log_message_buffered(log_level_t level, const char *file,
int line, const char *fmt, ...) {
if (level < g_log_level) {
return;
}
char buf[LOG_BUF_SIZE];
va_list args;
int len;
int offset = 0;
// 先写时间戳和等级
time_t now = time(NULL);
struct tm *tm_info = localtime(&now);
offset = strftime(buf, sizeof(buf), "[%Y-%m-%d %H:%M:%S] ", tm_info);
// 写等级和位置
offset += snprintf(buf + offset, sizeof(buf) - offset,
"[%s] [%s:%d] ",
level_to_string(level), file, line);
// 写用户消息
va_start(args, fmt);
len = vsnprintf(buf + offset, sizeof(buf) - offset, fmt, args);
va_end(args);
if (len >= sizeof(buf) - offset) {
// 缓冲区不够,截断处理
buf[sizeof(buf) - 1] = '\0';
}
// 一次性写入
fputs(buf, stderr);
fputc('\n', stderr);
}
6.5 可变参数日志的完整流程图
下面这张图展示了整个可变参数日志的调用流程:
6.6 避坑指南
可变参数看着简单,用起来坑不少。我踩过的几个:
- va_start和va_end必须成对出现:漏掉va_end会导致栈损坏,尤其是在ARM Cortex-M这类平台上,后果很严重。
- 不要多次调用va_start:如果你需要遍历参数列表两次,得先va_end再va_start。我见过有人想先算长度再格式化,结果va_arg取完一遍就没了。
- 类型安全:va_arg需要你手动指定类型,传错了不会报错,但结果完全不对。比如你传了一个float,却用va_arg(args, int)去取,拿到的就是垃圾值。
- 缓冲区溢出:vsnprintf比sprintf安全,但别忘了检查返回值。我曾经因为没检查,日志把栈给写穿了,查了好几天才找到原因。
核心要点:可变参数日志的核心就三步——va_start初始化、vprintf/vsnprintf格式化、va_end清理。加上等级过滤和缓冲区,就是一个生产可用的日志接口。别搞复杂了,够用就行。
好了,可变参数这块就聊到这儿。下一节我们聊聊日志的异步输出——在中断里打日志?那又是另一个故事了。
公众号:蓝海资料掘金营,微信deep3321