1. 为什么日志一定得用宏定义来做1.1 普通日志函数取不到真实的调用点很多 Qt 项目一开始都是这么过来的为了排查问题自己在代码里写了一个logMessage(const QString)函数内部用qDebug()打印然后所有模块都调它。这个方案跑起来确实能看但一旦程序发到客户机器上问题就来了——日志里全是这一行logMessage: user login failed你根本不知道是哪条分支、哪个函数、哪个文件打印出来的。想加文件行号那就得在每个调用点手写__FILE__和__LINE__白白写了十几次之后人就开始烦躁而且总会漏掉。解决这个问题的关键是把“获取调用点”这件事交给预处理阶段去干。宏展开发生在编译之前__FILE__、__LINE__、__func__这些预定义宏在调用点原地展开写到日志里的就是真实的调用位置。普通函数做不到这一点因为无论你从哪调用函数内部拿到的__FILE__都指向这个日志函数所在的文件行号也是固定的。1.2 宏还能顺带解决变参传递和发布剥离写日志的时候我们通常希望这样自然一点LOG_INFO(user id %d, status %s, userId, statusText);这里参数个数是不定的。用函数重载去适配所有参数组合不现实用std::function又显得绕。宏配合可变参数__VA_ARGS__可以原样转发不丢参数。宏还有另一个普通函数替代不了的好处编译期剥离。发布版本如果不想让低级别日志参与任何计算可以在一处把宏定义成((void)0)所有调用点自动变成空语句。函数做不到这一点函数代码还在参数照样会被求值额外开销也省不掉。我常用的比喻是宏就是贴在调用点上的一个摄像头编译阶段就把位置信息一次性拍进去后面谁想篡改都难普通函数是让所有调用方去前台报道前台却忘了登记你是哪个部门的。2. 一套能直接抄的宏日志实现2.1 整体设计的核心思路我先说清楚这套东西的定位它面向 Qt Widgets / Qt Quick 这类桌面客户端也适用后台服务程序目标是实现“三件套”——分级打印、调用点定位、文件输出。设计上分成三层第一层是枚举LogDebug、LogInfo、LogWarn、LogError。数值越小越细。第二层是日志类Log负责打开文件、格式化、加锁、滚动文件。第三层是宏LOG_DEBUG、LOG_INFO、LOG_WARN、LOG_ERROR统一调用Log::write()同时注入文件行号。这里有个经验日志类不要做成复杂单例用静态成员函数最简单。单例那一套生命周期管理在这个场景里纯属增加心智负担静态函数初始化顺序也更好控制。真需要全局唯一的文件句柄静态成员就够了。2.2 核心实现代码这是头文件#pragma once #include QString #include QFile #include QMutex #include QDir enum LogLevel { LogDebug 0, LogInfo 1, LogWarn 2, LogError 3, LogOff 4 }; class Log { public: static void init(const QString dirPath, const QString baseName, LogLevel level LogInfo); static void shutdown(); static void setOutputConsole(bool enabled); static bool isEnabled(LogLevel level); static void write(LogLevel level, const char *file, int line, const char *func, const QString message); static void cleanOldFiles(int keepDays); private: static void ensureFileLocked(); static void rollByDayLocked(); static void rollBySizeLocked(); static QString levelName(LogLevel level); static QFile s_file; static QMutex s_mutex; static bool s_inited; static bool s_toConsole; static QString s_dirPath; static QString s_baseName; static QString s_currentFile; static QString s_currentDate; static LogLevel s_level; static qint64 s_maxBytes; };实现文件#include Log.h #include QDateTime #include QFileInfo #include QCoreApplication #include cstdio QFile Log::s_file; QMutex Log::s_mutex; bool Log::s_inited false; bool Log::s_toConsole true; QString Log::s_dirPath QStringLiteral(.); QString Log::s_baseName QStringLiteral(app); QString Log::s_currentFile QStringLiteral(app.log); QString Log::s_currentDate; LogLevel Log::s_level LogInfo; qint64 Log::s_maxBytes 50LL * 1024 * 1024; void Log::init(const QString dirPath, const QString baseName, LogLevel level) { QMutexLocker locker(s_mutex); s_dirPath dirPath.isEmpty() ? QStringLiteral(.) : dirPath; s_baseName baseName.isEmpty() ? QStringLiteral(app) : baseName; s_level level; QDir dir(s_dirPath); s_dirPath dir.absolutePath(); if (!dir.exists()) { dir.mkpath(s_dirPath); } s_currentDate QDate::currentDate().toString(QStringLiteral(yyyy-MM-dd)); s_currentFile QStringLiteral(%1/%2_%3.log) .arg(s_dirPath, s_baseName, s_currentDate); s_file.setFileName(s_currentFile); if (s_file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { const QString head QStringLiteral(\n logger start %1 \n) .arg(QDateTime::currentDateTime() .toString(QStringLiteral(yyyy-MM-dd hh:mm:ss))); s_file.write(head.toUtf8()); s_inited true; } else { s_inited false; std::fprintf(stderr, [Log] open log file failed: %s\n, qPrintable(s_currentFile)); } } void Log::shutdown() { QMutexLocker locker(s_mutex); if (s_file.isOpen()) { s_file.write( logger end \n); s_file.flush(); s_file.close(); } s_inited false; } void Log::setOutputConsole(bool enabled) { s_toConsole enabled; } bool Log::isEnabled(LogLevel level) { return level s_level level LogOff; } QString Log::levelName(LogLevel level) { switch (level) { case LogDebug: return QStringLiteral(DEBUG); case LogInfo: return QStringLiteral(INFO ); case LogWarn: return QStringLiteral(WARN ); case LogError: return QStringLiteral(ERROR); default: return QStringLiteral(?????); } } void Log::write(LogLevel level, const char *file, int line, const char *func, const QString message) { if (!isEnabled(level)) { return; } QMutexLocker locker(s_mutex); ensureFileLocked(); rollByDayLocked(); rollBySizeLocked(); QString sourceFile QString::fromLocal8Bit(file); int slash sourceFile.lastIndexOf(QLatin1Char(/)); if (slash 0) { slash sourceFile.lastIndexOf(QLatin1Char(\\)); } if (slash 0) { sourceFile sourceFile.mid(slash 1); } const QString timeText QDateTime::currentDateTime() .toString(QStringLiteral(yyyy-MM-dd hh:mm:ss.zzz)); const QString levelText levelName(level); const QString lineText QStringLiteral(%1 [%2] %3:%4 %5 | %6\n) .arg(timeText) .arg(levelText) .arg(sourceFile) .arg(line) .arg(QString::fromLocal8Bit(func)) .arg(message); if (s_file.isOpen()) { s_file.write(lineText.toUtf8()); s_file.flush(); } if (s_toConsole) { std::fputs(lineText.toLocal8Bit().constData(), stderr); std::fflush(stderr); } } void Log::ensureFileLocked() { if (s_file.isOpen()) { return; } QDir dir(s_dirPath); if (!dir.exists()) { dir.mkpath(s_dirPath); } s_currentDate QDate::currentDate().toString(QStringLiteral(yyyy-MM-dd)); s_currentFile QStringLiteral(%1/%2_%3.log) .arg(s_dirPath, s_baseName, s_currentDate); s_file.setFileName(s_currentFile); if (s_file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { s_inited true; } } void Log::rollByDayLocked() { const QString date QDate::currentDate().toString(QStringLiteral(yyyy-MM-dd)); if (date s_currentDate) { return; } s_currentDate date; s_file.close(); s_currentFile QStringLiteral(%1/%2_%3.log) .arg(s_dirPath, s_baseName, s_currentDate); s_file.setFileName(s_currentFile); if (s_file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { s_inited true; } } void Log::rollBySizeLocked() { if (!s_file.isOpen() || s_file.size() s_maxBytes) { return; } const QString stamp QDateTime::currentDateTime() .toString(QStringLiteral(yyyyMMdd_HHmmss)); QString archived QStringLiteral(%1/%2_%3_%4.log) .arg(s_dirPath, s_baseName, s_currentDate, stamp); int seq 1; while (QFileInfo::exists(archived)) { archived QStringLiteral(%1/%2_%3_%4_%5.log) .arg(s_dirPath, s_baseName, s_currentDate, stamp) .arg(seq); seq; } s_file.close(); QFile::rename(s_currentFile, archived); s_file.setFileName(s_currentFile); if (s_file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { s_inited true; } } void Log::cleanOldFiles(int keepDays) { QMutexLocker locker(s_mutex); QDir dir(s_dirPath); QStringList filters; filters QStringLiteral(%1_*.log).arg(s_baseName); const QFileInfoList fileList dir.entryInfoList(filters, QDir::Files, QDir::Name); const QDateTime deadline QDateTime::currentDateTime().addDays(-keepDays); for (const QFileInfo info : fileList) { if (info.lastModified() deadline) { QFile::remove(info.absoluteFilePath()); } } }然后是在头文件末尾挂上的宏#define LOG_WRITE(level, ...) \ do { \ if (Log::isEnabled(level)) { \ Log::write((level), __FILE__, __LINE__, __func__, QString::asprintf(__VA_ARGS__)); \ } \ } while (0) #define LOG_DEBUG(...) LOG_WRITE(LogDebug, __VA_ARGS__) #define LOG_INFO(...) LOG_WRITE(LogInfo, __VA_ARGS__) #define LOG_WARN(...) LOG_WRITE(LogWarn, __VA_ARGS__) #define LOG_ERROR(...) LOG_WRITE(LogError, __VA_ARGS__)2.3 使用方式和实际输出效果项目启动时这样初始化一次Log::init(QStringLiteral(./logs), QStringLiteral(client)); Log::setOutputConsole(true);业务代码里直接调用LOG_INFO(user login success, id %d, userId); LOG_ERROR(request timeout, retry %d, retryCount); LOG_DEBUG(http response body %s, body.toUtf8().constData());输出文件内容大致长这样2025-01-01 10:20:30.123 [INFO ] mainwindow.cpp:124 MainWindow::onLogin | user login success, id 10001 2025-01-01 10:20:31.456 [ERROR] network.cpp:87 NetworkClient::processResponse | request timeout, retry 2每一行都有毫秒时间戳、日志级别、源文件名、行号、函数名。排查客户反馈问题时对着日志拉到出错前后几行基本就能锁定业务路径。这里有几个实操上的细节要提醒宏定义中do { } while (0)不是摆设它保证宏展开成一条完整语句别人在if/else后面调用不会挂掉分支。__func__在 MSVC 2015 之后、GCC/Clang 上都是标准支持的够用。Q_FUNC_INFO返回的是完整签名日志会很臃肿我基本不用它。QString::asprintf至少要传一个格式串所以LOG_INFO()这种空参数写法编译不过。这不算缺点反而是约束逼着你把要打的内容写清楚。3. 日志文件的滚动、清理和编码3.1 按天滚动和按大小切分怎么落地日志文件如果只写一个app.log用不了几天就会膨胀到几个 GB。宁可让它按日期拆开方便按天检索也方便删除历史日志。上面代码里文件名规则是base_yyyy-MM-dd.log。跨天的时候write()内部检测到日期变化关闭旧文件打开新文件。这个滚动不是靠定时器做的而是在日志写入路径里顺带检查。逻辑简单也不会漏掉任何一次写入。同一份文件还要防单日过大。比如某个客户机跑了一个异常任务一天打了好几百兆日志。我的默认值是单文件 50 MB超过就归档成base_2025-01-01_143523.log原位置立即重建新文件。归档前先检查同名文件是否存在避免同一秒内连续触发导致彼此覆盖。3.2 历史日志的自动清理日志只滚动不清理最后磁盘一样会满。我给Log类加了一个清理接口按文件修改时间删除指定天数之前的日志文件Log::cleanOldFiles(7);这个接口在程序启动时调用一次就够了。实际项目里我会把“日志保留天数”做成配置文件里的一个可选参数默认 7 天客户环境特殊需求再改。清理的时候要注意一点不要用文件名排序来推断新旧因为同一天的归档文件和当前文件混在一起时名字排序并不严格等于时间顺序。用lastModified()判断最稳。3.3 编码问题别再交学费了日志文件最稳妥的编码是 UTF-8 无 BOM。Windows 上老版本记事本打开无 BOM 的 UTF-8 会乱码但现代系统基本已经没这个问题。如果你服务的客户还在用旧记事本可以在初始化时往文件头写三个字节\xEF\xBB\xBF代价是 Linux 下看文件会多一个不可见字符。我这边默认不写 BOM保持跨平台干净。另外提醒一下 Qt 版本差异Qt 5 里常见写法是QTextStream::setCodec()Qt 6 直接把setCodec()移除了改成了setEncoding(QStringConverter::Utf8)。为了避免版本迁移时的麻烦我上面的实现干脆不用QTextStream写文件全部用QFile::write(lineText.toUtf8())。这样 Qt 5 和 Qt 6 都能编少踩一个坑。4. 多线程写入、控制台输出和发布版剥离4.1 加锁很简单别把事情搞复杂桌面客户端里日志往往来自多个线程界面线程、网络线程、业务工作线程。QFile本身不是线程安全的同一个文件句柄并发写一定会出问题。用QMutex包住文件写入是最直接的手段。我甚至建议把锁的范围放大一点——从“打开文件、滚动检查、格式化、写入”整个过程都锁住。原因很简单滚动逻辑要判断文件状态格式化要用时间戳这些操作交叉进行时如果线程各锁各的会产生一堆竞态条件。日志系统追求的是正确性不是纳秒级性能。真正高频率写日志每秒几千条的时候再考虑异步队列加写线程。对绝大多数 Qt 桌面应用每个线程每秒写几十条日志已经算多了加锁的代价可以忽略不计。你更需要担心的反而是日志写得太多把 UI 卡了——因为我上面的代码里write()里有一条s_file.flush()。日志量大的场景可以改成定时 flush或者去掉逐条 flush每 1 秒刷一次。4.2 发布版把 Debug 日志彻底剥掉运行时过滤只能减少写入格式化和函数调用还是在发生。如果某个LOG_DEBUG出现在高频循环里即使等级被过滤掉了参数也已经被求值性能损耗照旧。我的做法是在Log.h里给LOG_DEBUG单独留一个编译开关#ifdef QT_NO_DEBUG # undef LOG_DEBUG # define LOG_DEBUG(...) ((void)0) #endif也就是 Debug 构建完整输出Release 构建把LOG_DEBUG编译成空操作彻底消除所有 Debug 日志参数求值和函数调用。这个思路和 Qt 自带的QT_NO_DEBUG_OUTPUT一致只是换成了自己的宏。LOG_INFO、LOG_WARN、LOG_ERROR在 Release 下正常保留但初始化级别可以设成LogWarn。这样客户机上的日志只记录警告和错误既不丢关键信息也不会疯长。4.3 日志参数里不要放带副作用的表达式因为发布版可能把整个LOG_DEBUG替换成((void)0)所以有一个隐藏得很深的坑LOG_DEBUG(count %d, count);Debug 模式下count会自增Release 模式下这一行被完全剥离count根本不会执行。同一个代码库两种构建行为不一样这种 bug 特别难排查。给自己的约定是日志宏参数只能用只读表达式禁止、赋值、函数调用等有副作用的内容。真有需要在日志里计算某种状态的先在前面算好再传变量进去。5. qDebug 和自定义日志宏怎么配合5.1 qInstallMessageHandler 的适用场景很多项目已经到处是qDebug()了要全部改成自定义宏工作量不小。这时候可以考虑 Qt 自带的消息处理机制static void messageHandler(QtMsgType type, const QMessageLogContext context, const QString msg) { LogLevel level LogDebug; switch (type) { case QtDebugMsg: level LogDebug; break; case QtInfoMsg: level LogInfo; break; case QtWarningMsg: level LogWarn; break; case QtCriticalMsg: case QtFatalMsg: level LogError; break; default: break; } QString src; int line 0; if (context.file) { src QString::fromLocal8Bit(context.file); line context.line; } Log::write(level, src.toLocal8Bit().constData(), line, context.function ? context.function : , msg); } // 在 main() 里安装一次 qInstallMessageHandler(messageHandler);这样做的好处是第三方库、Qt 内部的警告也全都被统一收进日志文件。坏处是QMessageLogContext里的file、line、function在部分发布构建下可能是空的你拿不到可靠调用点。所以如果调用点信息是强需求还得回到自定义宏。5.2 我推荐的组合方式我的建议是分情况老项目、大量qDebug()先上qInstallMessageHandler立刻能拿到统一文件改动最小。新项目、或者日志格式要求高度统一直接用自定义宏不依赖任何运行时消息机制。两者混用也完全可以自定义宏写业务日志同时qInstallMessageHandler兜底把漏网之鱼收集到一起。需要注意的是Log::write()内部不要再调用qDebug()之类的接口否则会形成递归。文件和控制台输出自己用QFile和fputs完成就好。6. 踩坑记录和一点实操建议6.1 MSVC 与格式串的兼容性QString::asprintf底层依赖 C 的vsnprintf系列跨平台格式串兼容性并不完全一致。比如%lld在 MSVC 和 MinGW 下的表现有历史差异%S宽字符在不同工具链下的语义也不一样。我自己的经验是日志格式串尽量只用基本的%d、%s、%f、%u复杂拼接别硬塞进格式串改成先拼QString再通过%s传进去。宁可多写一行代码也不要为一个百分号查半天文档。Qt 的类型安全接口QString::arg()其实是更好的拼接方式只能在日志宏外面先用着因为宏里无法对参数做类型模板推导。6.2 流式日志宏的 if/else 悬挂坑有些团队喜欢流式风格把日志宏设计成#define LOG_INFO if (Log::isEnabled(LogInfo)) LogStream(LogInfo, __FILE__, __LINE__)然后调用if (loginFailed) LOG_INFO login failed; else retry();展开之后else会和宏里的if配对retry()的逻辑就全乱掉了。有人用for循环包一层去绕过它但那需要额外设计代理对象可读性也下降。我这边干脆全部采用 printf 风格宏配合do { } while (0)从根上回避这个坑。风格统一之后团队协作也没人写错。6.3 日志文件被占用、多进程互写Windows 上经常遇到日志文件被编辑器、杀毒软件占用的情况滚动改名失败是常态。我在代码里对rename失败没有做强处理原因就是它不影响继续写当前文件不会让程序崩溃。你只要记住日志文件不是给人打开看的是给程序读的。排查问题前先拷出到别的目录再打开别把源文件锁住。多进程场景更麻烦。两个实例同时写同一个日志文件即使QFile以 Append 模式打开日志行也可能交错而且flush和滚动之间没有跨进程锁。最简单粗暴的做法是初始化时把进程号拼进文件名Log::init(logDir, QStringLiteral(client_%1) .arg(QCoreApplication::applicationPid()));每个进程一个文件谁的问题看谁的文件干净利落。6.4 记得把日志目录变成可配置参数日志目录最怕写死在代码里。发布版程序在不同机器上工作目录不同一旦没权限写当前目录日志文件打不开你连“为什么打不开”都看不到。我建议初始化路径从配置、命令行参数或标准目录中取得比如QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)再在下面建一个logs子目录。如果配置漏了默认落到当前目录也可以但一定要打开失败时往stderr打一条提示。客户现场反馈“没有日志”有很大概率不是程序没打日志而是程序根本没地方写。尽早暴露这个问题能省掉好几轮无效沟通。我在实际项目里用这套宏日志跑过多个客户端最大的感受是日志系统的价值不在于代码写得多花哨而在于你拿到日志文件的那一刻能不能一眼锁定问题。宏定义日志把“谁在哪个文件的第几行说了什么”全部固化下来排查问题时基本不用再靠猜。后续如果遇到更高频的日志需求可以在现有基础上加队列和后台写线程但核心的等级控制、调用点注入、文件滚动这套骨架不用变。