Linux->日志的实现
目录
本文说明
一:日志的核心功能
二:实现日志
1:日志等级
2:时间的获取
3:pid
4:文件名+代码行数
5:日志的内容
①:无可变参数前
②:可变参数的介绍
③:可变参数宏
④:可变参数格式化函数
6:日志初步代码及效果
①:LogMessage.hpp
三:优化日志
1:省略掉__FILE__ 和 __LINE__
2:对多语句宏的优化
3:加锁
4:打印到文件或者显示器
四:日志总代码:
1:LogMessage
2:main.cc
五:使用日志
1:向屏幕上打印
2:向文件中写入
本文说明
日志作为一个在Linux中后期被频繁使用的东西,并且我们常常使用的就是我们自己实现的日志,在实现日志的过程中,有很多值得学习的知识点,所以把日志单独整理出来作为一篇博客~~
一:日志的核心功能
我们实现的日志,打印效果如下:
二:实现日志
1:日志等级
// 日志等级
enum Level
{DEBUG = 0,INFO,WARNING,ERROR,FATAL
};std::string LevelToString(int level)
{switch (level){case DEBUG:return "Debug";case INFO:return "Info";case WARNING:return "Warning";case ERROR:return "Error";case FATAL:return "Fatal";default:return "Unknown";}
}
解释:一个枚举类型搞定日志等级,然后利用LevelToString接口把枚举常量变成对应的字符串返回,因为我们打印日志的时候,需要以字符串“DdBug”这种形式打印才能表示日志等级
2:时间的获取
std::string GetTimeString()
{// 获取当前时间的时间戳(从1970-01-01 00:00:00开始的秒数)time_t curr_time = time(nullptr);// 将时间戳转换为本地时间的tm结构体// tm结构体包含年、月、日、时、分、秒等字段struct tm *format_time = localtime(&curr_time);// 检查时间转换是否成功if (format_time == nullptr)return "None";// 缓冲区用于存储格式化后的时间字符串char time_buffer[1024];// 格式化时间字符串:年-月-日 时:分:秒snprintf(time_buffer, sizeof(time_buffer), "%d-%02d-%02d %02d:%02d:%02d",format_time->tm_year + 1900, // tm_year: 从1900年开始的年数,需要加1900format_time->tm_mon + 1, // tm_mon: 月份范围0-11,需要加1得到实际月份format_time->tm_mday, // tm_mday: 月中的日期(1-31)format_time->tm_hour, // tm_hour: 小时(0-23)format_time->tm_min, // tm_min: 分钟(0-59)format_time->tm_sec); // tm_sec: 秒(0-60,60表示闰秒)return time_buffer; // 返回格式化后的时间字符串
}
解释:
①:使用time函数获取时间戳
②:把获取到的时间戳传给localtime接口,localtime接口会返回一个指向 struct tm类型 结构体的指针
③: struct tm类型结构体中的成员变量存储的就是年,月,日,且有多种选择,所以我们规定拼接后的字符串的格式即可
注:tm_year是从1900开始计数的,所以想要得到日历上的年份,需要加1900;同理,tm_mon范围是0-11,要想得到日历上的月,则需要加1
④:最后把拼接好的字符串存储进事先开辟好的字符串中,然后返回
3:pid
pid_t selfid = getpid();
解释:利用接口getpid即可
4:文件名+代码行数
文件名:_FILE__代码行数:__LINE__
解释:__FILE__
和 __LINE__
是C/C++中的预定义宏,它们在编译时被自动替换为相应的值。在哪个文件的哪一行被使用,就会返回当前文件名和当前行数
5:日志的内容
①:无可变参数前
日志最重要的信息就是后面用户设置的内容,通过该内容,才知道当前打印的日志发生了什么,是发生了除零错误,段错误....一目了然
我们的日志函数为LogMessage,目前如下:
void LogMessage(std::string filename, int line, int level)
{std::string levelstr = LevelToString(level);//获取日志等级std::string timestr = GetTimeString();//获取时间pid_t selfid = getpid();//获取PIDchar buffer[1024];std::cout << "[" + timestr + "]" + "[" + levelstr + "]" +"[" + std::to_string(selfid) + "]" +"[" + filename + "]" + "[" + std::to_string(line) + "]\n " ;
}
main中这样调用该函数:
LogMessage(__FILE__,__LINE__, FATAL);
效果如下:
解释:所以目前的实现,根本没有什么意义,因为我们不知道为什么要打印这个日志,所以可变参数是必要的,这样我们才能在调用LogMessage的地方打印出我们想要的信息!
②:可变参数的介绍
所以我们现在参数中添加上可变参数:
void LogMessage(std::string filename, int line, int level, const char *format, ...)
注:任何使用可变参数的函数,都至少要有一个其余参数,并且写在可变参数的左面,这是规定!如果没有除开可变参数的参数则编译时就会报错!
格式一般如下:
[固定参数1] [固定参数2] ... [最后一个固定参数] [可变参数1] [可变参数2] ...
因为:
任何形参在代码被编译的时候都会存储在栈上,所以我们最后一个固定参数的地址必定在栈上紧挨着可变参数的起始地址,所以我们可以通过最后一个固定参数的地址来找到可变参数的地址!
Q:为什么不能直接根据可变参数得到其的首地址?
A:编译器在编译时无法确定可变参数的信息!不知道调用处的具体参数!所以必须有至少一个固定的参数才可以使用可变参数!
所以我们通过最后一个固定参数得到了可变参数起始地址,则我们就可以按照可变参数的打印格式,去一个一个的进行强转!而我们自己即使知道了也做不到,需要系统提供的接口才可以!
③:可变参数宏
我们需要用到的接口其实是宏:
宏 | 功能 | 说明 |
---|---|---|
va_list | 声明可变参数指针 | 实际上是typedef 定义的类型 |
va_start | 初始化可变参数列表 | 让指针指向第一个可变参数 |
va_arg | 获取下一个参数 | 读取参数并移动指针 |
va_end | 清理可变参数列表 | 结束可变参数访问 |
使用接口的例子:
#include <iostream>
#include <cstdarg>
using namespace std;// 默认传递进来的参数都是整数
void Test(int num, ...)
{va_list arg;va_start(arg, num);int data = va_arg(arg, int);std::cout << "data: " << data << std::endl;va_end(arg); // arg = NULL
}int main()
{Test(1, 100);return 0;
}
运行结果:
代码解释:
①:首先定义一个va_list类型(void*类型)的变量arg
②:然后再用va_start函数,va_start(arg,num) ,其作用是让 arg 指针跳过num的地址去找到第一个可变参数在内存中的位置
③:再用va_arg(arg,int),现在既然arg指向了可变参数,我们就需要从arg开始按照想要的类型去拿取可变参数了,因为我们自己知道是整形,所以使用按照int类型去拿去
④:最后va_end(arg);负责把arg指针清空,置为NULL
这就是C语言拿去可变参数的原理,但是设计者考虑到直接把原理的接口给用户使用,用户会编写代码麻烦,所以给我们提供了新的接口,新接口的操作更加简单,集成了上面的部分宏功能!
④:可变参数格式化函数
系统给我们提供了vsprintf和vsnprintf这种接口,让我们操作更简单
vsprintf:
函数原型
int vsprintf(char* str, const char* format, va_list arg);
参数说明:
-
str
:目标字符串缓冲区,用于存储格式化后的结果 -
format
:格式字符串(与printf
的格式相同) -
arg
:已初始化的va_list
变量
函数功能:
-
从
arg
数据包中取出第一个数据 -
查看
format
说明书,知道这个数据应该放在哪里、以什么格式显示 -
将格式化后的内容写入
str
目的地 -
重复直到所有数据都处理完毕
-
⚠️ 危险:不管
str
目的地够不够大,硬往里写!
vsnprintf:
函数原型:
int vsnprintf(char* str, size_t size, const char* format, va_list arg);
参数说明:
-
str
:目标字符串缓冲区 -
size
:缓冲区的最大容量(包括结尾的 null 字符) -
format
:格式字符串 -
arg
:已初始化的va_list
变量
函数功能:
-
从
arg
数据包中取出数据 -
按照
format
说明书格式化数据 -
每写一个字符都检查:"还没超过
size
限制吧?" -
如果快满了,就停止写入,保证不溢出
-
✅ 安全:绝对不写超出缓冲区!
对比总结
特性 | vsprintf | vsnprintf |
---|---|---|
安全性 | 不安全,可能缓冲区溢出 | 安全,有长度检查 |
参数 | (str, format, arg) | (str, size, format, arg) |
缓冲区保护 | 无 | 有,最多写 size-1 字符 |
返回值 | 实际写入字符数 | 应该写入的字符数(可能大于size) |
推荐程度 | ⚠️ 不推荐使用 | ✅ 推荐使用 |
所以我们使用vsnprintf到例子中:
#include <iostream>
#include <cstdarg>
using namespace std;// 默认传递进来的参数都是整数
void Test(const char *format, ...)
{char buffer1[10]; // 只有10字节的小缓冲区va_list args;va_start(args, format);// vsnprintf 的工作:vsnprintf(buffer1, sizeof(buffer1), format, args);cout << buffer1 << endl;va_end(args);
}int main()
{Test("%d %c", 100, 'A');return 0;
}
运行结果:
解释:
①:我们用vsnprintf,仍需要定义定义一个va_list类型的变量和使用va_start和va_end,vsnprintf只是帮我们省去做最核心的一步
②:可以看到我们的Test的参数没有之前的num了,因为我们的"%d %c"也是参数,其既指定了可变参数的打印格式,又作为了va_start的参数,让args能够找到可变参数的起始地址
③:因为vsnprintf和vsprintf这种函数的最后一个参数是
已初始化的 va_list
变量,其是从这个参数得到了可变参数的起始位置,所以设计者设计的时候,就已经把vsnprintf和vsprintf接口和va_开头的接口绑定起来了,你要想使用vsnprintf和vsprintf接口,则必须和va_开头的接口一起使用才行,不然你永远无法获取可变参数的首地址!所以vsnprintf
是给"造轮子"的人用的!
图示如下:
// 错误尝试:没有va_start,args指向随机内存
va_list args;
vsnprintf(buffer, size, format, args); // 灾难!// 正确做法:必须先获取有效的手柄
va_list args;
va_start(args, format); // 获取有效的手柄
vsnprintf(buffer, size, format, args); // 使用手柄
va_end(args); // 归还手柄
所以vsnprintf和vsprintf接口一般用于参数是可变参数的函数内部!
6:日志初步代码及效果
#include "LogMessage.hpp"
using namespace std;int main()
{LogMessage(DEBUG, __FILE__, __LINE__, "%s", "当前程序正常");LogMessage(WARNING, __FILE__, __LINE__, "%s", "当前有警告");LogMessage(FATAL, __FILE__, __LINE__, "%s", "出现了严重错误");return 0;
}
①:LogMessage.hpp
#pragma once#include <iostream> //C++必备头文件
#include <cstdio> //snprintf
#include <string> //std::string
#include <ctime> //time
#include <cstdarg> //va_接口
#include <sys/types.h> //getpid
#include <unistd.h> //getpid// 日志等级
enum Level
{DEBUG = 0,INFO,WARNING,ERROR,FATAL
};// 日志等级转字符串--->字符串才能表示等级的意义 0123意义不清晰
std::string LevelToString(int level)
{switch (level){case DEBUG:return "Debug";case INFO:return "Info";case WARNING:return "Warning";case ERROR:return "Error";case FATAL:return "Fatal";default:return "Unknown";}
}// 获取当前时间的字符串
// 时间格式包含多个字符 所以干脆糅合成一个字符串
std::string GetTimeString()
{// 获取当前时间的时间戳(从1970-01-01 00:00:00开始的秒数)time_t curr_time = time(nullptr);// 将时间戳转换为本地时间的tm结构体// tm结构体包含年、月、日、时、分、秒等字段struct tm *format_time = localtime(&curr_time);// 检查时间转换是否成功if (format_time == nullptr)return "None";// 缓冲区用于存储格式化后的时间字符串char time_buffer[1024];// 格式化时间字符串:年-月-日 时:分:秒snprintf(time_buffer, sizeof(time_buffer), "%d-%02d-%02d %02d:%02d:%02d",format_time->tm_year + 1900, // tm_year: 从1900年开始的年数,需要加1900format_time->tm_mon + 1, // tm_mon: 月份范围0-11,需要加1得到实际月份format_time->tm_mday, // tm_mday: 月中的日期(1-31)format_time->tm_hour, // tm_hour: 小时(0-23)format_time->tm_min, // tm_min: 分钟(0-59)format_time->tm_sec); // tm_sec: 秒(0-60,60表示闰秒)return time_buffer; // 返回格式化后的时间字符串
}// 日志函数-->打印出日志
// 格式:时间 + 等级 + PID + 文件名 + 代码行数 + 可变参数
void LogMessage(int level, std::string filename, int line, const char *format, ...)
{std::string levelstr = LevelToString(level); // 得到等级字符串std::string timestr = GetTimeString(); // 得到时间字符串pid_t selfid = getpid(); // 得到PID// 使用va_接口+vsnprintf得到用户想要的可变参数的字符串 存储与buffer中char buffer[1024];va_list arg;va_start(arg, format);vsnprintf(buffer, sizeof(buffer), format, arg);va_end(arg);// 打印格式为时间 + 等级 + PID + 文件名 + 代码行数 + 可变参数 的日志信息std::cout << "[" + timestr + "]" + "[" + levelstr + "]" +"[" + std::to_string(selfid) + "]" +"[" + filename + "]" + "[" + std::to_string(line) + "] " + buffer<< std::endl;
}
日志到这里也就够用了,但是呢,还有很多可以优化的地方,让日志使用起来更加简单
三:优化日志
1:省略掉__FILE__ 和 __LINE__
我们使用日志的时候,不想手动的写__FILE__ 和 __LINE__,所以我们可以定义宏,但是含有可变参数的函数去进行宏定义,有一些值得注意的地方
#define LOG(level, format, ...) LogMessage(__FILE__, __LINE__, level, format, ##__VA_ARGS__)
解释:
①:C99和C++才支持宏带可变参数
②:省略掉__FILE__ 和 __LINE__很简单,宏定义即可
③:可变参数必须由##__VA_ARGS__接收,其中
__VA_ARGS__
表示可变参数,而##可以
处理科班出身是空参数的特殊情况
此时我们调用日志函数如下:
int main()
{LOG(DEBUG, "%s", "当前程序正常");LOG(WARNING, "%s", "当前有警告");LOG(FATAL, "%s", "出现了严重错误");return 0;
}
2:对多语句宏的优化
对上面的宏定义进行进一步优化,套上do while(0)循环,只执行一次,然后退出:
// C99新特性__VA_ARGS__
#define LOG(level, format, ...) \do \{ \LogMessage(__FILE__, __LINE__, level, format, ##__VA_ARGS__); \} while (0)
解释:使用 do...while(0)
的好处:
-
✅ 避免语法错误:在控制语句中安全使用
-
✅ 保证完整性:宏中的所有语句都会执行
-
✅ 分号友好:支持正常的函数调用语法
-
✅ 无性能损失:编译器会优化掉循环
-
✅ 代码美观:保持一致的代码风格
这是一个经过验证的最佳实践,几乎所有高质量的C/C++代码库都会这样编写多语句宏!
3:加锁
我们打印日志的函数可能会被多线程并发调用,造成打印混乱,所以我们需要对该函数加锁,让该函数称为线程安全的函数!
①:头文件
#include <thread> //锁
#include <mutex> //锁
②:定义全局锁
std::mutex g_mutex; // 声明全局互斥锁
③:在打印cout前加锁
// 日志函数-->打印出日志
// 格式:时间 + 等级 + PID + 文件名 + 代码行数 + 可变参数
void LogMessage(int level, std::string filename, int line, const char *format, ...)
{std::string levelstr = LevelToString(level); // 得到等级字符串std::string timestr = GetTimeString(); // 得到时间字符串pid_t selfid = getpid(); // 得到PID// 使用va_接口+vsnprintf得到用户想要的可变参数的字符串 存储与buffer中char buffer[1024];va_list arg;va_start(arg, format);vsnprintf(buffer, sizeof(buffer), format, arg);va_end(arg);std::lock_guard<std::mutex> lock(g_mutex); // 引入C++的RAII的锁 保护打印功能// 打印格式为时间 + 等级 + PID + 文件名 + 代码行数 + 可变参数 的日志信息std::cout << "[" + timestr + "]" + "[" + levelstr + "]" +"[" + std::to_string(selfid) + "]" +"[" + filename + "]" + "[" + std::to_string(line) + "] " + buffer<< std::endl;
}
解释:在cout前加锁,比整个函数前加锁更好,粒度更细,效率更高
4:打印到文件或者显示器
日志不一定只能打印到屏幕上,有时候我们需要把日志放进文件中,所以我们实现这个功能!
所以用户选择是保存到文件还是打印到屏幕上,所以在LogMessage.hpp中定义一个bool类型的变量,其表示是否保存的意思,默认false不保存打印到屏幕上!
所以我们的LogMessage函数需要新增一个bool类型的变量,在函数体内对bool类型判断,若是false则直接使用cout打印到屏幕上,反之打印到同级目录的log.txt中!
全局定义:
bool gIsSave = false; // 定义一个bool类型 用来判断打印到屏幕还是保存到文件
const std::string logname = "log.txt"; // 保存日志信息的文件名字
所以我们的日志信息需要先保存起来,而不是直接打印!
日志函数如下:
// 日志函数-->打印出日志
// 格式:时间 + 等级 + PID + 文件名 + 代码行数 + 可变参数
void LogMessage(int level, std::string filename, int line, bool issave, const char *format, ...)
{std::string levelstr = LevelToString(level); // 得到等级字符串std::string timestr = GetTimeString(); // 得到时间字符串pid_t selfid = getpid(); // 得到PID// 使用va_接口+vsnprintf得到用户想要的可变参数的字符串 存储与buffer中char buffer[1024];va_list arg;va_start(arg, format);vsnprintf(buffer, sizeof(buffer), format, arg);va_end(arg);std::lock_guard<std::mutex> lock(g_mutex); // 引入C++的RAII的锁 保护打印功能// 打印格式为时间 + 等级 + PID + 文件名 + 代码行数 + 可变参数 的日志信息std::string message = "[" + timestr + "]" + "[" + levelstr + "]" +"[" + std::to_string(selfid) + "]" +"[" + filename + "]" + "[" + std::to_string(line) + "] " + buffer;if (!issave){std::cout << message;}else{SaveFile(logname, message);}
}
所以我们对于LogMessage函数的宏定义也需要改变一下:
// 宏定义 省略掉__FILE__ 和 __LINE__
#define LOG(level, format, ...) \do \{ \LogMessage(level, __FILE__, __LINE__, gIsSave, format, ##__VA_ARGS__); \} while (0)
所以目前我们的gIsSave默认是fasle的,我们需要提供两个接口,然后用户选择日志是打印到屏幕还是保存到文件中,我们直接把接口定义为宏即可,其实只是对一个单语句进行宏定义罢了,让用户感觉在使用接口一样的感觉:
// 用户调用则意味着保存到文件
#define EnableScreen() (gIsSave = false)// 用户调用则意味着打印到屏幕
#define EnableFile() (gIsSave = true)
最后就是我们的将日志写进文件的函数:
void SaveFile(const std::string &filename, const std::string &message)
{std::ofstream out(filename, std::ios::app);if (!out.is_open()){return;}out << message;out.close();
}
解释:这个函数在LogMessage函数内部判断gIsSave为true之后,则会把日志信息字符串message传给此SaveFile函数,让其写进我们全局定义的文件"log.txt"中
四:日志总代码:
1:LogMessage
#pragma once#include <iostream> //C++必备头文件
#include <cstdio> //snprintf
#include <string> //std::string
#include <ctime> //time
#include <cstdarg> //va_接口
#include <sys/types.h> //getpid
#include <unistd.h> //getpid
#include <thread> //锁
#include <mutex> //锁
#include <fstream> //C++的文件操作std::mutex g_mutex; // 定义全局互斥锁
bool gIsSave = false; // 定义一个bool类型 用来判断打印到屏幕还是保存到文件
const std::string logname = "log.txt"; // 保存日志信息的文件名字// 日志等级
enum Level
{DEBUG = 0,INFO,WARNING,ERROR,FATAL
};// 将日志写进文件的函数
void SaveFile(const std::string &filename, const std::string &message)
{std::ofstream out(filename, std::ios::app);if (!out.is_open()){return;}out << message << std::endl;out.close();
}// 日志等级转字符串--->字符串才能表示等级的意义 0123意义不清晰
std::string LevelToString(int level)
{switch (level){case DEBUG:return "Debug";case INFO:return "Info";case WARNING:return "Warning";case ERROR:return "Error";case FATAL:return "Fatal";default:return "Unknown";}
}// 获取当前时间的字符串
// 时间格式包含多个字符 所以干脆糅合成一个字符串
std::string GetTimeString()
{// 获取当前时间的时间戳(从1970-01-01 00:00:00开始的秒数)time_t curr_time = time(nullptr);// 将时间戳转换为本地时间的tm结构体// tm结构体包含年、月、日、时、分、秒等字段struct tm *format_time = localtime(&curr_time);// 检查时间转换是否成功if (format_time == nullptr)return "None";// 缓冲区用于存储格式化后的时间字符串char time_buffer[1024];// 格式化时间字符串:年-月-日 时:分:秒snprintf(time_buffer, sizeof(time_buffer), "%d-%02d-%02d %02d:%02d:%02d",format_time->tm_year + 1900, // tm_year: 从1900年开始的年数,需要加1900format_time->tm_mon + 1, // tm_mon: 月份范围0-11,需要加1得到实际月份format_time->tm_mday, // tm_mday: 月中的日期(1-31)format_time->tm_hour, // tm_hour: 小时(0-23)format_time->tm_min, // tm_min: 分钟(0-59)format_time->tm_sec); // tm_sec: 秒(0-60,60表示闰秒)return time_buffer; // 返回格式化后的时间字符串
}// 日志函数-->打印出日志
// 格式:时间 + 等级 + PID + 文件名 + 代码行数 + 可变参数
void LogMessage(int level, std::string filename, int line, bool issave, const char *format, ...)
{std::string levelstr = LevelToString(level); // 得到等级字符串std::string timestr = GetTimeString(); // 得到时间字符串pid_t selfid = getpid(); // 得到PID// 使用va_接口+vsnprintf得到用户想要的可变参数的字符串 存储与buffer中char buffer[1024];va_list arg;va_start(arg, format);vsnprintf(buffer, sizeof(buffer), format, arg);va_end(arg);std::lock_guard<std::mutex> lock(g_mutex); // 引入C++的RAII的锁 保护打印功能// 保存格式为时间 + 等级 + PID + 文件名 + 代码行数 + 可变参数 的日志信息 到message中std::string message = "[" + timestr + "]" + "[" + levelstr + "]" +"[" + std::to_string(selfid) + "]" +"[" + filename + "]" + "[" + std::to_string(line) + "] " + buffer;// 打印到屏幕if (!issave){std::cout << message << std::endl;}// 保存进文件else{SaveFile(logname, message);}
}// 宏定义 省略掉__FILE__ 和 __LINE__
#define LOG(level, format, ...) \do \{ \LogMessage(level, __FILE__, __LINE__, gIsSave, format, ##__VA_ARGS__); \} while (0)// 用户调用则意味着保存到文件
#define EnableScreen() (gIsSave = false)// 用户调用则意味着打印到屏幕
#define EnableFile() (gIsSave = true)
2:main.cc
#include "Log.hpp"using namespace std;int main()
{//EnableScreen();//EnableFile();LOG(DEBUG, "%s", "当前程序正常");LOG(WARNING, "%s", "当前有警告");LOG(FATAL, "%s", "出现了严重错误");return 0;
}
五:使用日志
1:向屏幕上打印
#include "Log.hpp"using namespace std;int main()
{EnableScreen();// EnableFile();LOG(DEBUG, "%s", "当前程序正常");LOG(WARNING, "%s", "当前有警告");LOG(FATAL, "%s", "出现了严重错误");return 0;
}
2:向文件中写入
#include "Log.hpp"using namespace std;int main()
{//EnableScreen();EnableFile();LOG(DEBUG, "%s", "当前程序正常");LOG(WARNING, "%s", "当前有警告");LOG(FATAL, "%s", "出现了严重错误");return 0;
}