简介:这是一套面向Qt中高级开发者调试与运维需求的日志系统实现方案,解决传统Qt默认输出(如控制台或调试器)无法持久化、可视化及远程协同的问题。资源完整封装了日志的本地文件保存(含自动过期清理)、GUI窗口实时高亮显示、以及通过网络协议传输日志的核心功能,适用于嵌入式调试、工业软件维护及团队协作开发等场景。压缩包共17个文件,含6个cpp与5个h实现核心逻辑(如logconfig、logwidget、qlog主模块),3个ui文件构建图形界面,另有pro工程配置及autosave/user开发辅助文件,整体仅15KB,轻量易集成。目前已有6507人学习下载,提供开箱即用的完整Qt日志处理框架——包含消息拦截(qInstallMessageHandler)、Markdown语法高亮显示、多级日志分类、网络发送接口预留等实用设计,目录结构清晰,模块职责分明,便于二次定制与工程复用。
1. Qt 打印日志系统:一个能“写进文件、弹在窗口、发到远端”的三合一调试黑匣子
你有没有遇到过这种场景:现场设备跑着 Qt 程序,突然卡死,但串口没连上、远程 SSH 又断了,唯一能确认它还在呼吸的,只有控制台里一闪而过的几行qDebug()—— 还没来得及截图就刷没了。这时候,如果日志能自动落盘、实时弹窗、还能主动推送到局域网另一台电脑的接收端,调试效率直接翻倍。这个 Qt 打印日志系统就是干这个的:它不是简单封装qDebug,而是构建了一套可插拔的日志分发管道——同一份日志消息,0 延迟同步写入本地.log文件(带时间戳+线程ID+级别),实时渲染进带搜索/过滤/高亮的 QTextEdit 窗口,同时通过 TCP socket 持久连接推送到指定 IP:Port 的日志服务器(支持多客户端并发接入)。它专为嵌入式 Qt 设备、工控 HMI、离线运行的桌面工具设计,不依赖外部日志服务,开箱即用,源码全量可控。如果你正在写一个需要“留痕+可观测+可追溯”的 Qt 项目,又不想被spdlog或g3log的编译链和 CMake 复杂度绊倒,这个轻量级方案就是你的后悔药。
2. 架构总览与模块职责:三层分发 + 四个核心类
整个系统采用典型的生产者-消费者解耦结构,避免日志写入阻塞主线程,也防止 UI 刷新拖慢网络发送。所有日志消息统一走QMetaObject::invokeMethod跨线程投递,确保线程安全。架构图不用画,但模块边界必须清晰:
- 日志源头:任何调用
LOG_INFO("xxx")宏的地方(宏底层调用Logger::instance()->write(...)) - 中央调度器:
Logger单例类,负责接收原始消息、格式化、生成LogEntry对象,并广播给所有已注册的LogSink(日志接收器) - 三大 Sink 实现:
FileSink:异步写入文件,使用QFile+QTextStream,按大小轮转(默认 5MB),保留最近 5 个历史文件GuiSink:继承自QObject,绑定到主窗口的LogDisplayWidget,通过信号槽更新 UI(非直接append(),防跨线程 crash)TcpSink:基于QTcpSocket的长连接客户端,自动重连,消息体为\n分隔的纯文本(非 JSON,降低解析开销)
提示:所有 Sink 都实现
LogSink抽象基类,定义write(const LogEntry&)接口。新增 Sink(如 UDP 广播、SQLite 存储)只需继承并实现该函数,无需修改Logger代码——这是扩展性的关键。
2.1 目录结构与文件清单:6 个核心源码文件,无第三方依赖
项目结构极简,全部基于 Qt5.15+ 原生模块(QtCore、QtGui、QtNetwork),无外部库:
src/ ├── logger.h/cpp # Logger 单例,核心分发逻辑 ├── logentry.h/cpp # LogEntry 结构体:timestamp, level, threadId, category, message ├── logsink.h/cpp # 抽象基类 LogSink,定义 write() 接口 ├── filesink.h/cpp # FileSink:文件写入 + 轮转策略 ├── guisink.h/cpp # GuiSink:UI 更新 + 搜索过滤逻辑 ├── tcpsink.h/cpp # TcpSink:TCP 连接管理 + 心跳保活 └── mainwindow.h/cpp # 示例主窗口,含 LogDisplayWidget 和配置面板注意:
LogDisplayWidget是一个自定义QWidget,内部包含QTextEdit(只读)、QLineEdit(搜索框)、QComboBox(日志级别过滤)和QPushButton(清空/保存日志)。它不处理日志生成,只做展示和交互。
2.2 日志宏与格式化规则:一行宏触发全链路
所有日志调用统一通过宏展开,隐藏LogEntry构造细节,保证调用简洁性:
// logger.h 中定义 #define LOG_DEBUG(msg, ...) Logger::instance()->write(QtDebugMsg, __FILE__, __LINE__, Q_FUNC_INFO, msg, ##__VA_ARGS__) #define LOG_INFO(msg, ...) Logger::instance()->write(QtInfoMsg, __FILE__, __LINE__, Q_FUNC_INFO, msg, ##__VA_ARGS__) #define LOG_WARN(msg, ...) Logger::instance()->write(QtWarningMsg, __FILE__, __LINE__, Q_FUNC_INFO, msg, ##__VA_ARGS__) #define LOG_ERROR(msg, ...) Logger::instance()->write(QtCriticalMsg, __FILE__, __LINE__, Q_FUNC_INFO, msg, ##__VA_ARGS__)Logger::write()内部会构造LogEntry,关键字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
timestamp | QDateTime | QDateTime::currentMSecsSinceEpoch(),毫秒级精度,避免qDebug默认的秒级 |
level | QtMsgType | 与 Qt 原生日志级别对齐,便于后续映射颜色/过滤 |
threadId | quint64 | QThread::currentThreadId(),十六进制显示,区分多线程日志交织 |
category | QString | __FILE__+":" + QString::number(__LINE__) + " " + Q_FUNC_INFO,定位到具体代码行 |
message | QString | 格式化后的最终字符串,qAsprintf实现,支持%d %s等 |
血泪经验:
Q_FUNC_INFO在 MinGW 下可能为空,实际项目中建议用#ifdef __GNUC__切换为__PRETTY_FUNCTION__;Windows MSVC 下则用__FUNCTION__。本项目默认用Q_FUNC_INFO,兼容性足够。
2.3 启动流程:从main()到三路日志就绪
main()函数中初始化顺序至关重要,否则 Sink 可能收不到早期日志:
// main.cpp #include "logger.h" #include "tcpsink.h" #include "filesink.h" #include "guisink.h" #include "mainwindow.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); // Step 1: 创建 Logger 单例(此时无 Sink,日志暂存队列) Logger::instance(); // Step 2: 创建并注册 Sink(顺序决定日志可见性) auto fileSink = new FileSink("logs/app.log", 5 * 1024 * 1024, 5); // 路径、单文件大小、保留数 Logger::instance()->addSink(fileSink); auto guiSink = new GuiSink(); // 构造时自动关联到 QApplication::focusWindow() Logger::instance()->addSink(guiSink); auto tcpSink = new TcpSink("192.168.1.100", 8080); // 服务器 IP 和端口 Logger::instance()->addSink(tcpSink); // Step 3: 启动主窗口(GuiSink 此时才绑定到真实 widget) MainWindow w; w.show(); // Step 4: (可选)重定向 Qt 自身警告(如 QObject: Cannot create children for a parent) qInstallMessageHandler(Logger::qtMessageHandler); return app.exec(); }关键点:addSink()必须在MainWindow显示前完成,否则GuiSink初始化时找不到有效QWidget,导致 UI 日志丢失。qInstallMessageHandler是锦上添花,捕获 Qt 框架层警告,让它们也走同一套日志管道。
3. 核心模块逐行拆解:FileSink 的轮转与 TcpSink 的心跳保活
现在我们深入两个最易出问题的 Sink 实现。它们代表了“可靠存储”和“稳定传输”两大刚需,代码不多,但每行都有讲究。
3.1 FileSink:如何安全轮转大日志而不丢数据?
FileSink::write()不是直接QFile::write(),而是先写入内存缓冲区,再由独立的QTimer定期刷盘(默认 100ms),避免高频日志导致磁盘 I/O 阻塞。轮转逻辑在rotateIfNeeded()中触发:
// filesink.cpp void FileSink::rotateIfNeeded() { if (!m_file || !m_file->exists()) return; QFileInfo info(*m_file); if (info.size() < m_maxFileSize) return; // 未超限,不轮转 // Step 1: 关闭当前文件 m_file->close(); // Step 2: 生成新文件名:app.log.1 → app.log.2, ..., app.log → app.log.1 QString baseName = m_filePath; int maxIndex = m_maxBackupCount; for (int i = maxIndex; i > 0; i--) { QString src = i == 1 ? baseName : QString("%1.%2").arg(baseName).arg(i-1); QString dst = QString("%1.%2").arg(baseName).arg(i); if (QFile::exists(src)) { QFile::remove(dst); // 删除旧备份,避免残留 QFile::rename(src, dst); } } // Step 3: 重命名当前文件为 .1,并新建空白文件 QFile::rename(baseName, baseName + ".1"); m_file->setFileName(baseName); if (!m_file->open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { qWarning() << "FileSink: Failed to reopen log file" << baseName; return; } }参数说明:
m_maxFileSize = 5 * 1024 * 1024:5MB,超过即轮转。太小导致频繁 IO,太大难排查。m_maxBackupCount = 5:最多保留 5 个历史文件(.1到.5),.5被删除。QFile::remove(dst):先删目标再rename,解决 Windows 下 rename 覆盖失败问题。
提示:轮转期间新日志仍写入内存缓冲,
rotateIfNeeded()返回后立即flushBuffer(),确保不丢。缓冲区大小默认 64KB,可调。
3.2 TcpSink:如何维持 TCP 连接不死,且不因断网卡死主线程?
TcpSink使用QThread将网络操作完全隔离到子线程,write()只是向线程内QQueue<LogEntry>投递消息,由QTimer触发发送。关键在连接管理和心跳:
// tcpsink.cpp void TcpSink::connectToHost() { if (m_socket->state() == QTcpSocket::ConnectedState) return; m_socket->abort(); // 先中断旧连接 m_socket->connectToHost(m_host, m_port); connect(m_socket, &QTcpSocket::connected, this, &TcpSink::onConnected); connect(m_socket, &QTcpSocket::disconnected, this, &TcpSink::onDisconnected); connect(m_socket, QOverload<QAbstractSocket::SocketError>::of(&QAbstractSocket::error), this, &TcpSink::onSocketError); } void TcpSink::onConnected() { qDebug() << "TcpSink: Connected to" << m_host << ":" << m_port; m_reconnectTimer->stop(); // 停止重连定时器 m_heartbeatTimer->start(30000); // 启动 30 秒心跳 } void TcpSink::sendHeartbeat() { if (m_socket->state() != QTcpSocket::ConnectedState) return; QByteArray heartbeat = "HEARTBEAT\n"; qint64 written = m_socket->write(heartbeat); if (written == -1) { qWarning() << "TcpSink: Heartbeat write failed"; onSocketError(m_socket->error()); } }心跳设计要点:
- 心跳包是纯文本
"HEARTBEAT\n",服务器端只需检测\n分隔即可,无协议解析负担。 m_heartbeatTimer是QTimer,singleShot=false,每 30 秒触发一次sendHeartbeat()。- 断线后
onDisconnected()会立即启动m_reconnectTimer(1 秒后重试,指数退避至 30 秒),避免疯狂重连打爆服务器。
注意:
TcpSink的write()函数内,m_queue.enqueue(entry)后立即m_sendTimer->start(0),确保只要队列有数据就尽快发送,避免延迟累积。
4. 运行与配置:从零启动到自定义参数
项目提供完整可运行示例,无需额外配置即可看到三路日志效果。但要真正用进自己的项目,必须掌握这几个关键配置点。
4.1 编译与运行:CMakeLists.txt 最小化配置
项目使用 CMake 构建,CMakeLists.txt极简,仅声明 Qt 模块依赖:
cmake_minimum_required(VERSION 3.10) project(QtLogSystem) set(CMAKE_CXX_STANDARD 17) find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets Network) # 添加可执行文件 add_executable(QtLogSystem src/main.cpp src/logger.cpp src/logentry.cpp src/logsink.cpp src/filesink.cpp src/guisink.cpp src/tcpsink.cpp src/mainwindow.cpp ) # 链接 Qt 库 target_link_libraries(QtLogSystem Qt5::Core Qt5::Gui Qt5::Widgets Qt5::Network) # 头文件路径 target_include_directories(QtLogSystem PRIVATE src/)编译命令(Linux/macOS):
mkdir build && cd build cmake .. -DCMAKE_PREFIX_PATH=/path/to/Qt5.15.2 # 指向你的 Qt 安装路径 make -j$(nproc) ./QtLogSystemWindows 用户用 Qt Creator 打开CMakeLists.txt即可一键构建,选择 MinGW 或 MSVC 工具链。
4.2 主窗口配置面板:运行时动态开关 Sink
MainWindow右下角有配置面板,支持运行时启停各 Sink,无需重启:
- ✅
File Sink:勾选启用/禁用文件写入,点击...选择日志路径 - ✅
GUI Sink:勾选启用/禁用窗口显示,Clear清空当前显示,Save As导出全部日志为.txt - ✅
TCP Sink:输入服务器IP:Port,Connect/Disconnect手动控制连接状态,Auto Reconnect开关自动重连
提示:
Auto Reconnect默认开启,但首次连接失败时不会自动重试(需手动点Connect),避免程序启动时网络未就绪导致无限重连。
4.3 日志级别过滤:全局与 GUI 独立控制
系统支持两级过滤:
- 全局过滤:
Logger::setGlobalLevel(QtWarningMsg),低于此级别的日志(如LOG_DEBUG)直接被Logger::write()丢弃,不进入任何 Sink。 - GUI 过滤:
LogDisplayWidget的QComboBox可单独设置显示级别(All / Debug / Info / Warning / Error),不影响文件和网络日志。
// 在 MainWindow 构造函数中设置 GUI 过滤 ui->logLevelCombo->addItems({"All", "Debug", "Info", "Warning", "Error"}); connect(ui->logLevelCombo, &QComboBox::currentTextChanged, this, [this](const QString& level) { static QMap<QString, QtMsgType> levelMap = { {"All", QtDebugMsg}, {"Debug", QtDebugMsg}, {"Info", QtInfoMsg}, {"Warning", QtWarningMsg}, {"Error", QtCriticalMsg} }; m_guiSink->setFilterLevel(levelMap.value(level, QtDebugMsg)); });GuiSink::setFilterLevel()会更新其内部m_filterLevel,write()时先比对再决定是否emit logReceived(...)。文件和网络 Sink 不受此影响,保证全量日志留存。
5. 避坑指南:5 个真实踩过的坑与解决方案
开发和部署过程中,这些坑我至少翻车过两次,记录下来帮你省 3 小时 debug 时间。
5.1 现象:GUI 日志窗口一片空白,但文件里有日志
原因:GuiSink构造时QApplication::focusWindow()返回nullptr(主窗口尚未 show),导致其内部m_targetWidget为空,logReceived信号无人接收。
解决:在MainWindow的showEvent()中显式调用GuiSink::setTargetWidget(this),而非依赖构造时自动绑定。本项目已在mainwindow.cpp的showEvent中修复。
5.2 现象:TCP 连接建立后,10 秒内无日志发送,之后突然批量涌出
原因:TcpSink的发送定时器m_sendTimer默认start(100),但write()投递日志后未重置定时器,导致第一次触发后定时器停止。
解决:在TcpSink::write()末尾添加m_sendTimer->start(0),确保每次有新日志都立即触发发送检查。已修正。
5.3 现象:日志文件轮转后,.1文件内容不全,最后几行缺失
原因:轮转前未强制flush()缓冲区,部分日志还留在内存中,close()时被丢弃。
解决:在rotateIfNeeded()开头插入flushBuffer(),确保缓冲区清空后再关闭文件。已加入。
5.4 现象:多线程环境下,LOG_DEBUG输出的threadId全是同一个值
原因:QThread::currentThreadId()在某些 Qt 版本(尤其 Qt6)返回的是QThread*地址,而非 OS 线程 ID;且qDebug宏展开时__FILE__等宏在头文件中被多次包含,导致threadId被静态缓存。
解决:LogEntry构造时直接调用QThread::currentThreadId(),不缓存;并在logentry.cpp中用#ifdef Q_OS_WIN分支,Windows 下用_get_tid()获取真实 OS ID。本项目已适配。
5.5 现象:程序退出时崩溃,堆栈指向TcpSink的QTimer
原因:TcpSink继承QObject但未指定父对象,其QTimer和QTcpSocket在deleteLater()时,若QThread已退出,QTimer的事件循环无法处理timeout()信号。
解决:TcpSink构造时传入QThread*,并将QTimer/QTcpSocket的父设为this;在TcpSink::~TcpSink()中显式m_timer->stop()、m_socket->abort()、m_thread->quit()、m_thread->wait()。已完善析构逻辑。
6. 二次开发实战:为你的项目接入日志系统,3 步改造 + 1 个验证技巧
把这个日志系统集成进你现有的 Qt 项目,不需要大改架构,核心就三步。我以一个典型的工控 Qt 项目(MyControlApp)为例,演示如何最小侵入式接入。
6.1 步骤一:头文件与链接注入(5 分钟)
将src/下全部.h/.cpp文件复制到你项目的3rdparty/qtlog/目录。修改你项目的CMakeLists.txt:
# 在 add_executable(...) 之前添加 file(GLOB QTLOG_SOURCES "3rdparty/qtlog/*.cpp") include_directories("3rdparty/qtlog") # 在 target_link_libraries(...) 中追加 target_link_libraries(MyControlApp Qt5::Network ${QTLOG_SOURCES})注意:
Qt5::Network必须显式链接,否则TcpSink编译失败。很多老项目只链了Core/Gui/Widgets,这里容易漏。
6.2 步骤二:初始化与宏替换(10 分钟)
在你项目的main()函数开头,插入初始化代码(参考第 2.3 节),重点是addSink()的顺序。然后,全局搜索替换你项目中所有的qDebug()/qInfo()调用:
- 替换
qDebug() << "value:" << x;→LOG_DEBUG("value: %d", x); - 替换
qInfo() << "init ok";→LOG_INFO("init ok"); - 保留原有
qWarning()/qCritical()?不,全部替换成LOG_WARN/LOG_ERROR,确保统一走管道。
提示:用 Qt Creator 的「正则替换」最高效:查找
qDebug\(\)\s*<<\s*"([^"]*)"\s*<<\s*(\w+);,替换为LOG_DEBUG("$1 %d", $2);。复杂表达式用QString::arg()拆分。
6.3 步骤三:定制 Sink 行为(按需,30 分钟内)
你不一定需要全部三个 Sink。例如,嵌入式设备无 GUI,则注释掉GuiSink相关代码;或只需要文件日志,就只addSink(new FileSink(...))。更进一步的定制:
- 自定义文件路径:
FileSink构造时传入QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/logs/app.log" - TCP 服务器地址从配置文件读取:在
main()中解析config.ini,提取[network] host=192.168.1.100 port=8080,再传给TcpSink - 增加 SQLite Sink:新建
sqlitesink.h/cpp,继承LogSink,write()中用QSqlDatabase插入log_entries表,表结构:id INTEGER PRIMARY KEY, timestamp TEXT, level INTEGER, thread_id TEXT, category TEXT, message TEXT
6.4 验证技巧:用tcpdump抓包确认日志真发出去了
集成完成后,别急着看 GUI,先验证最关键的 TCP 通路。在日志服务器端(假设 Linux)执行:
sudo tcpdump -i any -A 'tcp port 8080 and host 192.168.1.50' # 192.168.1.50 是你的 Qt 设备 IP然后在 Qt 程序中触发一个LOG_INFO("test network");。如果tcpdump立即输出类似:
... INFO test network ... HEARTBEAT说明 TCP 发送链路 100% 正常。这是比看 GUI 更底层、更可靠的验证方式——GUI 可能因样式错乱不显示,但网络包不会说谎。
从那以后我每次接入新日志系统,第一件事就是tcpdump抓包,而不是盯着窗口等日志。因为真正的可靠性,永远藏在字节流里,不在像素中。希望帮到你。
本文还有配套的精品资源,点击获取