在我接触过的项目里,写“日志函数”的水平,能直接看出一个开发者对工程的认真程度。我接手过一个老服务,代码里到处都是裸的 printf,没有时间、没有级别、没有出处,某个凌晨线上数据出问题,我打开日志文件一看,全是意义不明的数字,那一刻的无力感到现在还记得。后来我给自己立了条规矩:凡是我经手的 C++ 或 C# 项目,第一件事就是把日志函数搭扎实。
这篇文章就把我分别在 C++ 和 C# 里写日志函数的完整过程摊开来讲:从最基础的 printf 风格写法,到带时间戳、级别、调用位置、线程安全的完整版本,再到编码、性能、死锁这些只有真写过才懂的坑。适合刚接触 C++ 或 C# 的读者,也适合想把项目里散落的打印整理成正规日志的同学。如果你在做上位机开发、游戏工具链或者嵌入式边缘服务,这套思路同样可以直接搬。两套代码我都会给全,抄走改改就能用。
1. 先把需求想明白:日志函数和打印语句差在哪
很多人写日志函数,第一步就是抄个 printf 包一层。但“能输出”和“能用来排查问题”是两回事。
1.1 打印语句是调试期工具,日志是运行期证据
打个比方:printf 像你装修现场临时拉的电灯,哪儿看不清就照哪儿,装修完就该拆;日志像小区里的监控摄像头,平时不惹眼,但出了事,你是靠它回放才知道几点几分谁干了什么。
差别具体到工程上,有三个地方。
第一,打印语句的输出目标是临时的,日志的输出目标要是持久的。printf 往控制台一打就没了,程序一关,事故现场就没了;日志至少应该能落到文件里,将来回溯问题才有材料。
第二,打印语句没有统一格式,每个人写得都不一样;日志的每一行都应该像一个结构化的登记表,时间、级别、位置、内容,一眼扫过去就能抓住重点。
第三,打印语句是“给正在调试的人看的”,日志是“给未来的自己和其他同事看的”。你写的时候可能觉得“这里我一看就懂”,但三个月后回来看,没有上下文的数字就是天书。
1.2 一个称职的日志函数,至少包含五个要素
我列一个表,这是我每次搭日志基础设施时对照检查的清单:
| 要素 | 作用 | 没有会怎样 |
|---|---|---|
| 时间戳 | 还原事发时间线 | 只知道出错,不知道先后 |
| 日志级别 | 区分 debug/info/warn/error,可以过滤 | 日志量爆炸,重要信息被淹没 |
| 调用位置(文件 + 行号 + 函数) | 直接定位到代码出处 | 满文件搜字符串找代码 |
| 线程上下文 | 多线程程序里区分哪个任务在跑 | 并发问题没法跟踪 |
| 输出开关 | 可以在正式环境关掉低级别日志 | 性能开销和磁盘占用失控 |
这五个要素不是“锦上添花”,是“缺一不可”。尤其调用位置,我看过太多自研日志只有时间和内容,出了事在几万行代码里 grep 关键词,痛苦程度不亚于大海捞针。所以下面的实现里,C++ 用宏、C# 用特性来支持这一项,这是本篇文章最核心的两个技术点。
顺带回答一个常见疑问:C++ 不是有 spdlog,C# 不是有 NLog 吗,为什么还要自己写?有三个场景让我觉得自研依然有意义:一是嵌入式、老平台、RTL 环境不允许引第三方库;二是你手头项目只需要 50 行日志代码,引一个重型库里划不来;三是我始终认为,能把日志函数写到能上生产的程度,说明可变参数、预处理、线程同步这些基础功是过关的。面试时让候选人手写日志函数,也几乎是必考项目,所以这篇文章不亏。
2. C++ 实现:可变参数 + 宏 + 锁,三件套
先给结论:C++ 里写一个能用的日志函数,绕不开三个东西——可变参数、宏、互斥锁。下面我按从简到繁的顺序拆开讲。
2.1 先用可变参数实现“像 printf 一样传参”
在 C++ 里写日志,最舒服的调用方式显然是LOG_INFO("用户 %d 登录", userId),带格式化。这依赖 C 语言留下来的可变参数机制:va_list、va_start、va_end。
看代码:
#include <cstdio> #include <cstdarg> void WriteLog(const char* fmt, ...) { va_list args; va_start(args, fmt); char buffer[2048]; vsnprintf(buffer, sizeof(buffer), fmt, args); va_end(args); printf("%s\n", buffer); }这里的关键函数是vsnprintf:和 printf 同源的格式化输出,但把格式化结果写到字符串缓冲区里。为什么用vsnprintf而不是老掉牙的sprintf?因为后者不限定缓冲区大小,遇到长消息直接写穿,栈被毁掉,程序在哪里崩都查不出来。vsnprintf的n就是“最大写入长度”,缓冲区 2048,它就最多写 2047 个字符再加'\0',宁可截断不可越界。这是第一步,别省这个 n。
2.2 用宏把文件、行号、函数名“钉”进日志
可变参数解决了格式化问题,但调用位置怎么拿?C++ 标准里没有 C# 那样的 Caller Info,能依赖的是预处理器的三个魔法符号:
__FILE__:当前源文件路径__LINE__:当前行号__FUNCTION__:当前函数名
问题是,这三个符号必须写在调用日志的那一行才有意义。如果直接写在 WriteLog 内部,它拿到的是 WriteLog 自己的文件路径和行号,每条日志都一样,等于白搭。所以必须用宏把它们展开到调用现场:
#define LOG_INFO(...) WriteLog(__FILE__, __LINE__, __FUNCTION__, __VA_ARGS__)展开之后,__FILE__、__LINE__、__FUNCTION__就变成了调用点上写死的字符串和数字。这是“编译期信息”的关键——不是运行时去查栈,而是编译器在处理到这一行的时候直接把源代码位置替换进去,零运行时开销。
注意:宏定义里的
...和__VA_ARGS__是 C 的可变参数宏写法,__VA_ARGS__代表调用时传入的那一堆参数。如果你的编译器支持 C++20,还可以考虑用__VA_OPT__处理某些逗号边角问题,这个后面说坑的时候再展开。
2.3 完整版:时间戳、级别、线程安全、文件输出
有了上面的基础,我把完整实现贴出来。这是一个能在生产环境直接跑的最小版本:
#pragma once #include <cstdarg> #include <cstdio> #include <cstring> #include <ctime> #include <mutex> #include <string> enum class LogLevel { Debug = 0, Info, Warn, Error }; const char* LevelToString(LogLevel level) { switch (level) { case LogLevel::Debug: return "DEBUG"; case LogLevel::Info: return "INFO"; case LogLevel::Warn: return "WARN"; case LogLevel::Error: return "ERROR"; default: return "UNKNOWN"; } } class Logger { public: static Logger& Instance() { static Logger logger; return logger; } void SetLevel(LogLevel level) { m_level = level; } void OpenFile(const std::string& path) { std::lock_guard<std::mutex> lock(m_mutex); if (m_file) { fclose(m_file); } m_file = fopen(path.c_str(), "a"); } void Write(LogLevel level, const char* file, int line, const char* func, const char* fmt, ...) { // 级别过滤必须在格式化之前做,否则白费力气 if (level < m_level) { return; } va_list args; va_start(args, fmt); char message[2048]; vsnprintf(message, sizeof(message), fmt, args); va_end(args); time_t now = time(nullptr); struct tm tmv; #ifdef _WIN32 localtime_s(&tmv, &now); #else localtime_r(&now, &tmv); #endif char timebuf[32]; strftime(timebuf, sizeof(timebuf), "%Y-%m-%d %H:%M:%S", &tmv); // 只取文件名,不用带一大串路径 const char* shortName = file; if (const char* p = strrchr(file, '/')) { shortName = p + 1; } if (const char* p = strrchr(shortName, '\\')) { shortName = p + 1; } char output[4096]; snprintf(output, sizeof(output), "[%s][%s][%s:%d][%s] %s\n", timebuf, LevelToString(level), shortName, line, func, message); std::lock_guard<std::mutex> lock(m_mutex); if (m_file) { fputs(output, m_file); fflush(m_file); } else { fputs(output, stdout); } } private: Logger() = default; ~Logger() { if (m_file) { fclose(m_file); } } Logger(const Logger&) = delete; Logger& operator=(const Logger&) = delete; std::mutex m_mutex; LogLevel m_level = LogLevel::Info; FILE* m_file = nullptr; }; #define LOG_DEBUG(...) \ Logger::Instance().Write(LogLevel::Debug, __FILE__, __LINE__, __FUNCTION__, __VA_ARGS__) #define LOG_INFO(...) \ Logger::Instance().Write(LogLevel::Info, __FILE__, __LINE__, __FUNCTION__, __VA_ARGS__) #define LOG_WARN(...) \ Logger::Instance().Write(LogLevel::Warn, __FILE__, __LINE__, __FUNCTION__, __VA_ARGS__) #define LOG_ERROR(...) \ Logger::Instance().Write(LogLevel::Error, __FILE__, __LINE__, __FUNCTION__, __VA_ARGS__)调用方式:
#include "Log.h" int main() { Logger::Instance().SetLevel(LogLevel::Info); Logger::Instance().OpenFile("app.log"); int userId = 10086; LOG_INFO("用户 %d 请求登录", userId); LOG_DEBUG("请求参数: name=%s, retry=%d", "alice", 3); LOG_ERROR("数据库连接失败: %s", "timeout"); return 0; }有几个地方值得停下来解释。
级别过滤放在最前面。如果日志级别设成 Warning,那 Debug 和 Info 的消息根本不需要拼时间戳、不需要格式化、不需要抢锁,return掉就行。别小看这个顺序,在高频日志的场景下,这一条判断能省下大半性能开销。
单例 + Meyers Singleton。static Logger logger;这种写法是 C++11 之后标准保证线程安全的局部静态变量初始化,多线程第一次调用Instance()时,不会出现两个线程各构造一份的问题,不需要二次加锁,干净利落。
同一个互斥锁保护文件句柄和写入。fputs不是线程安全的,两个线程同时写同一个FILE*会造成行间穿插甚至数据损坏。这里把写入过程整体锁住,保证每条日志是一整行完整落盘。
fflush 是把双刃剑。加了 fflush 是为了防止进程崩溃时日志还在缓冲区里没出来,代价是每次写都触发系统调用,性能差一些。实际项目里我一般会对 Error 级别强制刷盘,Info 和 Debug 交给缓冲,频率根据日志量自己调。
2.4 C++20 有更优雅的选择:std::source_location
看到这里,对宏深恶痛绝的朋友可以喘口气了。C++20 引入了std::source_location,它能把宏干的事用类型安全的接口做掉:
#include <source_location> void Info(const std::string& message, const std::source_location& loc = std::source_location::current()) { std::cout << loc.file_name() << ":" << loc.line() << " " << loc.function_name() << " " << message << "\n"; }秘密在默认参数std::source_location::current():当调用方没有显式传这个参数时,编译器会在调用点生成一个包含当前源码位置的 source_location 对象,自动绑到形参上。C# 的 Caller Info 思路和它一模一样,只是 C++ 把它放在了标准库里。
不过要泼一盆冷水:std::source_location和 C 风格的可变参数列表放在一起很别扭。你要是想既拿到调用位置、又支持LOG_INFO("用户 %d", userId)这种格式化传参,C++20 还得配合std::format用,或者干脆继续用宏。我目前的代码里还是宏为主,只有当项目整体迁移到 C++20 并且全队都用std::format的写法时,才考虑彻底去掉宏。
3. C# 实现:用 Caller Info 特性拿走调用现场
C# 这边没有预处理宏,但 .NET 提供了一组编译器注入的特性,效果和宏殊途同归。
3.1 三个特性拿到文件、行号、成员名
核心是System.Runtime.CompilerServices命名空间里的三个特性:
[CallerFilePath]:调用方的源文件路径[CallerLineNumber]:调用方的行号[CallerMemberName]:调用方的成员名(方法名、属性名)
它们的用法是给“可选参数”打标记,调用方不传,编译器自动填:
using System; using System.IO; using System.Runtime.CompilerServices; public static class Logger { public static void Info(string message, [CallerFilePath] string file = "", [CallerLineNumber] int line = 0, [CallerMemberName] string member = "") { Console.WriteLine($"[{Path.GetFileName(file)}:{line}][{member}] {message}"); } }调用方只需要写一行:
Logger.Info("用户登录成功");编译后的效果等同于编译器替你在这一行调用了Logger.Info("用户登录成功", "Program.cs", 15, "Main")。注意:这一切发生在编译期,不是运行时用反射去查调用栈,所以性能和可维护性都比 StackTrace 反射方案好得多,这也是我强烈推荐用 Caller Info 而不是Environment.StackTrace的原因。
顺带提醒一个细节:[CallerMemberName]在属性访问器里也有效,取到的是属性名而不是底层get_PropertyName这类 CLR 方法名。这意味着做 INotifyPropertyChanged 这类通知机制时,可以直接拿它当属性名参数,少写很多魔法字符串。
3.2 线程安全的写入与文件落盘
实际的完整版是这个样子:
using System; using System.IO; using System.Runtime.CompilerServices; using System.Text; public enum LogLevel { Debug = 0, Info, Warn, Error } public static class Logger { private static readonly object SyncRoot = new object(); private static StreamWriter _writer; private static volatile LogLevel _level = LogLevel.Info; public static LogLevel Level { get => _level; set => _level = value; } public static void OpenFile(string path) { lock (SyncRoot) { _writer?.Dispose(); _writer = new StreamWriter(path, append: true, new UTF8Encoding(false)) { AutoFlush = true }; } } public static void Debug(string message, [CallerFilePath] string file = "", [CallerLineNumber] int line = 0, [CallerMemberName] string member = "") => Write(LogLevel.Debug, message, file, line, member); public static void Info(string message, [CallerFilePath] string file = "", [CallerLineNumber] int line = 0, [CallerMemberName] string member = "") => Write(LogLevel.Info, message, file, line, member); public static void Warn(string message, [CallerFilePath] string file = "", [CallerLineNumber] int line = 0, [CallerMemberName] string member = "") => Write(LogLevel.Warn, message, file, line, member); public static void Error(string message, [CallerFilePath] string file = "", [CallerLineNumber] int line = 0, [CallerMemberName] string member = "") => Write(LogLevel.Error, message, file, line, member); private static void Write(LogLevel level, string message, string file, int line, string member) { if (level < _level) { return; } string record = string.Concat( "[", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"), "][", level, "][", Path.GetFileName(file), ":", line.ToString(), "][", member, "] ", message); lock (SyncRoot) { if (_writer != null) { _writer.WriteLine(record); } else { Console.WriteLine(record); } } } }使用:
class Program { static void Main() { Logger.Level = LogLevel.Info; Logger.OpenFile("app.log"); int userId = 10086; Logger.Info($"用户 {userId} 请求登录"); Logger.Error($"数据库连接失败: {"timeout"}"); Console.ReadKey(); } }这里我特意用了string.Concat而不是字符串插值$"..."。不是说插值不好,而是这个函数本身并不需要可读性很强的模板,Concat 能少做一轮字符串格式化的开销。虽然单次量级不明显,但日志库是要给全项目背书的,能省一点是一点。
3.3 一个隐藏限制:params 可变参数和 Caller Info 不能共存
写到这里,很多 C# 新手会问:为什么不支持这么写——
Logger.Info("用户 {0} 登录失败,原因 {1}", userId, reason);这就是“看起来很简单,做起来很别扭”的典型。CallerMemberName这些特性要求参数带默认值,而params object[] args必须是参数列表里最后一个,两者放在同一个方法签名里是矛盾的。你说那我写两个重载?
public static void Info(string format, params object[] args); public static void Info(string format, object arg0, [CallerFilePath] string file = "", ...);结果更糟:当你恰好传一个参数时,编译器倾向于选第二个(非 params 重载优先级高),调用位置能拿到;当你传两个以上的参数时,只能匹配到第一个,调用位置又丢了。同一个日志调用,一会儿有出处、一会儿没出处,排查时更加混乱。
我的建议是:别在这个接口上死磕,直接用字符串插值。
Logger.Info($"用户 {userId} 登录失败,原因 {reason}");插值本身就是一次string.Format,格式化的活儿在调用方做掉了,Logger 这边只需要一个接收完整字符串的方法签名,Caller Info 干干净净拿到调用位置。这也是 NLog、Serilog 之外的轻量自研方案里最常见的形态。
4. 同样是在调日志,两种语言差在哪里
两边代码都写完了,放在一起比较一下,能发现很多语言设计层面的有意思的东西。
4.1 宏替换与编译器注入:机制对比
C++ 的__FILE__、__LINE__是预处理阶段的文本替换,C# 的[CallerFilePath]是编译阶段编译器向可选参数注入属性值。机制不同,后果也不同。
宏是“文本层面”的替换,所以它有时候很笨。比如在宏参数里出现逗号就会出问题。设想这种代码:
LOG_INFO("数据大小 %d", std::map<int, int>{{1, 2}}.size());预处理器的词法分析只认圆括号,不把逗号当参数分隔符的是“被圆括号包住的逗号”。上面这行里{{1, 2}}的逗号在外面没有任何圆括号保护,std::map<int, int>里<int, int>的逗号同理,预处理器会直接把这些拆成多个参数,宏直接报错。这个坑卡住过不少从 C 转过来用模板的开发者。
而 Caller Info 是编译器按语言语法处理的,逗号、模板、泛型、Lambda 都影响不了它,这是语言机制带来的优势。
反过来说,宏也有宏的好处:它不依赖任何语法细节,只要能文本替换就行,C++98 时代就能用。C# 的 Caller Info 要求 .NET 4.5 及以上版本,如果还有项目停留在老版本框架,这条路就走不通,只能用Environment.StackTrace那种笨办法,性能和可读性都差一截。
4.2 使用体验和性能的差异
我做一个表格直接对比:
| 维度 | C++ | C# |
|---|---|---|
| 获取调用位置的机制 | 宏文本替换 | Caller Info 编译期注入 |
| 额外依赖 | 标准库即可 | .NET 标准库 |
| 格式化的安全性 | 格式化符不匹配会崩溃 | 强类型,运行期相对安全 |
| 线程安全 | 自己加 std::mutex | lock 语法糖 |
| 编译期信息获取 | 宏 | 特性+编译器实现 |
性能上,C++ 版本因为做了“先判断级别、再格式化”的顺序,日志量大的时候优势明显。C# 这边同样顺序,但有一个 C++ 没有的坑:字符串插值是在调用方完成的,哪怕 Logger 内部先判断了级别,$"..."已经在进入方法之前构造好了。你如果真在乎性能,就得在外面套一层:
if (Logger.Level <= LogLevel.Debug) { Logger.Debug($"复杂对象={JsonSerializer.Serialize(bigData)}"); }否则 Debug 级别的日志在正式环境虽然不会写出,格式化成本却一分不少。这是 C# 自研日志最容易忽视、也是性能差距最明显的地方。我在公司内部 Code Review 时,看到类似的高成本插值日志,都会建议加这条 if。
5. 上线前必须绕开的几个坑(这部分我踩过)
下面这些坑,有些是我自己在大流量项目里踩出来的,有些是帮同事排障排出来的。每一条都用一句话先说结论,再给背景。
5.1 中文乱码:Windows 控制台和文件编码
C++ 在 Windows 下跑中文日志,最经典的问题是:源码保存成 UTF-8,文件输出也是 UTF-8,但控制台按 GBK 解码,打出来全是乱码。反过来,如果你fopen的时候没指定编码,默认按系统的 ANSI 码页写文件,程序拷到 Linux 上,文件内容又成了乱码。
我的处理方式分两种情况。控制台调试用,在 main 开头加一行:
SetConsoleOutputCP(CP_UTF8);需要包含<windows.h>。文件输出用,Windows 下可以用 MSVC 的扩展语法:
fopen("app.log", "a, ccs=UTF-8");C# 这边一般不会碰到控制台乱码,因为 .NET 的 Console 默认按系统码页,你在Main开头设置Console.OutputEncoding = Encoding.UTF8即可。文件写入则用new UTF8Encoding(false)明确写 UTF-8 无 BOM——注意那个false,如果你写的是new UTF8Encoding(true),文件头会带三个字节的 BOM,很多日志采集工具对 BOM 处理不好,会在每行第一个字段前面多出看不见的字符。这是我真实返工过的一个细节。
5.2 日志函数把程序弄崩的三种方式
第一种,C++ 的格式化符写错。LOG_INFO("%d", name)这种,如果name不是整型,vsnprintf 会按整型去读对象内存,结果是未定义行为,轻则打印垃圾,重则直接崩。C 风格的格式化天生没有类型检查,这是硬伤。两条路:要么写日志时格外小心,要么给项目开-Wformat编译选项让编译器帮你查,再要么直接用std::format这类类型安全的格式化方案。
第二种,在信号处理函数里打日志。C++ 里接 SIGSEGV 或 SIGABRT 时,有人习惯在信号处理函数里写一行错误日志。问题是信号处理函数可能打断正在持锁打印的代码,而你的 Logger 内部也加了std::mutex,一个持锁的信号处理函数再去抢同一把锁,直接死锁。信号处理函数里能做的事非常有限,带锁的写文件就是典型的不安全操作。我的结论是:不要在信号处理函数里调日志函数,不要在析构函数里调它,也不要在静态对象析构之后调它。
第三种,C# 的日志写出本身抛异常。磁盘满了、文件被别的程序占用、权限不对,StreamWriter 都会抛异常。如果 Write 方法没有 catch,主程序也就跟着崩了。日志系统的基本要求是:它自己永远不能成为事故的源头。所以我在 Write 方法的锁内部包了一层 try-catch,捕获后至少保证程序主体不受影响。
5.3 高频日志与磁盘 IO 的性能取舍
AutoFlush = true 在 C# 端、fflush 在 C++ 端,都是双刃剑。保证“进程崩溃时日志不丢”的同时,把每次写日志都变成了同步的系统调用。我自己测过一个接口,每秒打几百条 Info 级别的日志,AutoFlush 开着的时候,里面有将近三成时间耗在等磁盘完成写操作上。
如果日志量上来了,我的方案是分级刷盘:Error 必须立刻刷盘,Info 和 Debug 走缓冲区,另外开一个后台线程每隔几百毫秒做一次 Flush。这个改动不复杂,但能把日志对业务接口的延迟影响降一个数量级。自研日志写到这里,已经比很多脚手架里随便糊的版本强太多了。
6. 几个让自研日志更好用的后手
最后聊点锦上添花的,都是我在项目里用过的成熟思路,代码量都不大,但作用很明显。
6.1 日志级别过滤与调试期开关
除了 SetLevel 之外,我习惯再加一个全局开关。比如 C# 端设置一个Logger.Enabled,Debug 版本默认开,Release 版本默认关。这个开关配合 5.2 里的 try-catch,能保证日志系统任何异常都不影响业务。
private static volatile bool _enabled = true; public static bool Enabled { get => _enabled; set => _enabled = value; }注意volatile:多线程环境下,一个线程修改_enabled,另一个线程正在读,要避免读到旧值。C++ 那边同理,可以用std::atomic<bool>,或者给 SetLevel 也加锁。
6.2 按天切分文件,避免日志无限膨胀
长期跑的服务,一个 app.log 会涨到几个 GB,打开都费劲,grep 更是灾难。简单方案是:文件名带日期,日期变化时重开文件。在 Write 里加一个当日日期的判断即可:
if (_currentDate != DateTime.Today) { ReopenFileForToday(); }C++ 版也用同样的思路:每次 Write 前用time(nullptr)拿当前时间,和上一次记录的日期比较,跨天就fclose再fopen新文件。真正的生产级还要考虑日志压缩、保留天数、按大小切分,但这些都可以在这个基础上慢慢加,思路是相通的。
6.3 从自研到成熟轮子,知道底线在哪
最后说句实在话:我写自研日志函数,目的是理解原理、解决“不能用第三方库”的场景,以及面试的时候不被问倒。但到了大型项目,我还是会换用专门的库——C++ 用 spdlog,C# 用 NLog 或 Serilog。它们解决了自研很难做好的事:异步写入、结构化字段、日志链路追踪、按大小和日期双维度轮转、从配置中心动态更新级别等等。
但我仍然建议每个写 C++ 或 C# 的开发者,都亲手写一遍日志函数。不是因为“面试会考”,而是这个小小的函数,能把可变参数、预处理宏、编译器特性、线程安全、IO 性能这些基础功完整过一遍,是性价比极高的练手项目。我在实际带人的时候,也会把“写一个日志函数”作为第一个任务,因为它能很快暴露一个新人编程习惯上的问题——缓冲区越界、锁粒度太大、异常不处理,全都在这一两百行里现形。把这件事做扎实了,后面再去接任何日志框架,你都会带着“这工具为什么这么设计”的眼光去看,收获完全不一样。