
在我接触过的项目里写“日志函数”的水平能直接看出一个开发者对工程的认真程度。我接手过一个老服务代码里到处都是裸的 printf没有时间、没有级别、没有出处某个凌晨线上数据出问题我打开日志文件一看全是意义不明的数字那一刻的无力感到现在还记得。后来我给自己立了条规矩凡是我经手的 C 或 C# 项目第一件事就是把日志函数搭扎实。这篇文章就把我分别在 C 和 C# 里写日志函数的完整过程摊开来讲从最基础的 printf 风格写法到带时间戳、级别、调用位置、线程安全的完整版本再到编码、性能、死锁这些只有真写过才懂的坑。适合刚接触 C 或 C# 的读者也适合想把项目里散落的打印整理成正规日志的同学。如果你在做上位机开发、游戏工具链或者嵌入式边缘服务这套思路同样可以直接搬。两套代码我都会给全抄走改改就能用。1. 先把需求想明白日志函数和打印语句差在哪很多人写日志函数第一步就是抄个 printf 包一层。但“能输出”和“能用来排查问题”是两回事。1.1 打印语句是调试期工具日志是运行期证据打个比方printf 像你装修现场临时拉的电灯哪儿看不清就照哪儿装修完就该拆日志像小区里的监控摄像头平时不惹眼但出了事你是靠它回放才知道几点几分谁干了什么。差别具体到工程上有三个地方。第一打印语句的输出目标是临时的日志的输出目标要是持久的。printf 往控制台一打就没了程序一关事故现场就没了日志至少应该能落到文件里将来回溯问题才有材料。第二打印语句没有统一格式每个人写得都不一样日志的每一行都应该像一个结构化的登记表时间、级别、位置、内容一眼扫过去就能抓住重点。第三打印语句是“给正在调试的人看的”日志是“给未来的自己和其他同事看的”。你写的时候可能觉得“这里我一看就懂”但三个月后回来看没有上下文的数字就是天书。1.2 一个称职的日志函数至少包含五个要素我列一个表这是我每次搭日志基础设施时对照检查的清单要素作用没有会怎样时间戳还原事发时间线只知道出错不知道先后日志级别区分 debug/info/warn/error可以过滤日志量爆炸重要信息被淹没调用位置文件 行号 函数直接定位到代码出处满文件搜字符串找代码线程上下文多线程程序里区分哪个任务在跑并发问题没法跟踪输出开关可以在正式环境关掉低级别日志性能开销和磁盘占用失控这五个要素不是“锦上添花”是“缺一不可”。尤其调用位置我看过太多自研日志只有时间和内容出了事在几万行代码里 grep 关键词痛苦程度不亚于大海捞针。所以下面的实现里C 用宏、C# 用特性来支持这一项这是本篇文章最核心的两个技术点。顺带回答一个常见疑问C 不是有 spdlogC# 不是有 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__代表调用时传入的那一堆参数。如果你的编译器支持 C20还可以考虑用__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_guardstd::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_guardstd::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;这种写法是 C11 之后标准保证线程安全的局部静态变量初始化多线程第一次调用Instance()时不会出现两个线程各构造一份的问题不需要二次加锁干净利落。同一个互斥锁保护文件句柄和写入。fputs不是线程安全的两个线程同时写同一个FILE*会造成行间穿插甚至数据损坏。这里把写入过程整体锁住保证每条日志是一整行完整落盘。fflush 是把双刃剑。加了 fflush 是为了防止进程崩溃时日志还在缓冲区里没出来代价是每次写都触发系统调用性能差一些。实际项目里我一般会对 Error 级别强制刷盘Info 和 Debug 交给缓冲频率根据日志量自己调。2.4 C20 有更优雅的选择std::source_location看到这里对宏深恶痛绝的朋友可以喘口气了。C20 引入了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)这种格式化传参C20 还得配合std::format用或者干脆继续用宏。我目前的代码里还是宏为主只有当项目整体迁移到 C20 并且全队都用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::mapint, int{{1, 2}}.size());预处理器的词法分析只认圆括号不把逗号当参数分隔符的是“被圆括号包住的逗号”。上面这行里{{1, 2}}的逗号在外面没有任何圆括号保护std::mapint, int里int, int的逗号同理预处理器会直接把这些拆成多个参数宏直接报错。这个坑卡住过不少从 C 转过来用模板的开发者。而 Caller Info 是编译器按语言语法处理的逗号、模板、泛型、Lambda 都影响不了它这是语言机制带来的优势。反过来说宏也有宏的好处它不依赖任何语法细节只要能文本替换就行C98 时代就能用。C# 的 Caller Info 要求 .NET 4.5 及以上版本如果还有项目停留在老版本框架这条路就走不通只能用Environment.StackTrace那种笨办法性能和可读性都差一截。4.2 使用体验和性能的差异我做一个表格直接对比维度CC#获取调用位置的机制宏文本替换Caller Info 编译期注入额外依赖标准库即可.NET 标准库格式化的安全性格式化符不匹配会崩溃强类型运行期相对安全线程安全自己加 std::mutexlock 语法糖编译期信息获取宏特性编译器实现性能上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, ccsUTF-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.EnabledDebug 版本默认开Release 版本默认关。这个开关配合 5.2 里的 try-catch能保证日志系统任何异常都不影响业务。private static volatile bool _enabled true; public static bool Enabled { get _enabled; set _enabled value; }注意volatile多线程环境下一个线程修改_enabled另一个线程正在读要避免读到旧值。C 那边同理可以用std::atomicbool或者给 SetLevel 也加锁。6.2 按天切分文件避免日志无限膨胀长期跑的服务一个 app.log 会涨到几个 GB打开都费劲grep 更是灾难。简单方案是文件名带日期日期变化时重开文件。在 Write 里加一个当日日期的判断即可if (_currentDate ! DateTime.Today) { ReopenFileForToday(); }C 版也用同样的思路每次 Write 前用time(nullptr)拿当前时间和上一次记录的日期比较跨天就fclose再fopen新文件。真正的生产级还要考虑日志压缩、保留天数、按大小切分但这些都可以在这个基础上慢慢加思路是相通的。6.3 从自研到成熟轮子知道底线在哪最后说句实在话我写自研日志函数目的是理解原理、解决“不能用第三方库”的场景以及面试的时候不被问倒。但到了大型项目我还是会换用专门的库——C 用 spdlogC# 用 NLog 或 Serilog。它们解决了自研很难做好的事异步写入、结构化字段、日志链路追踪、按大小和日期双维度轮转、从配置中心动态更新级别等等。但我仍然建议每个写 C 或 C# 的开发者都亲手写一遍日志函数。不是因为“面试会考”而是这个小小的函数能把可变参数、预处理宏、编译器特性、线程安全、IO 性能这些基础功完整过一遍是性价比极高的练手项目。我在实际带人的时候也会把“写一个日志函数”作为第一个任务因为它能很快暴露一个新人编程习惯上的问题——缓冲区越界、锁粒度太大、异常不处理全都在这一两百行里现形。把这件事做扎实了后面再去接任何日志框架你都会带着“这工具为什么这么设计”的眼光去看收获完全不一样。