简介:本资源是一个面向VC6 MFC开发者的轻量级C++日志记录工具包,专为解决传统Windows桌面应用中调试信息难追踪、运行状态难复现的问题而设计。适用于初学者理解日志机制,也便于中高级开发者快速集成基础日志功能到遗留MFC项目中。压缩包为RAR格式,共2个文件(1个头文件Log.h定义CLog类接口,1个源文件log.cpp实现时间戳格式化、文件追加写入及多等级日志输出),整体仅2KB,即下即用,无依赖项。已有218人学习下载,说明其在老版本VC开发场景中具备实际参考价值。读者可直接将该日志类嵌入现有MFC工程,快速启用DEBUG/INFO/ERROR分级打印,支持自动创建vc_log.txt日志文件、带精确时间戳与消息前缀的格式化输出,并掌握文件指针管理、CString字符串拼接及异常抛出等典型VC6编程实践。
1. VC6 MFC 下轻量级日志类:不依赖第三方、无运行时库冲突、直接嵌入老项目就能用的 log 实现
在维护一个运行了十五年以上的 VC6 MFC 工业控制软件时,我遇到过最棘手的问题不是算法逻辑,而是「出问题时根本不知道哪一行代码执行到了、变量值是多少、线程卡在哪」。当时项目已停更多年,不能引入任何新 DLL(比如 log4cplus 或 Boost.Log),连std::string都不敢用——因为 VC6 的 STL 实现不完整且与 MFC 的CString混用极易崩溃。这时候,一个仅含Log.h和log.cpp两个文件、编译即用、输出带时间戳+等级+文件行号、日志自动追加到文本文件的 CLog 类,就成了救命稻草。它不是现代 C++ 日志框架,而是一套专为 VC6 MFC 环境打磨的「手术刀式日志工具」:没有宏定义污染全局命名空间,不强制要求异常处理,不依赖 ATL 或 WTL,甚至能在CWinApp::InitInstance()之前就安全初始化。适合所有仍在维护 VC6/VC7.1 项目的工程师——尤其是那些被客户要求「必须在 Windows 2000 上跑通」、「不允许安装任何额外运行库」的硬性场景。
2. CLog 类设计原理与 VC6 兼容性取舍:为什么不用 std::ofstream、不封装 CTime::GetCurrentTime()
2.1 为何坚持使用 FILE* + fprintf 而非 CStdioFile 或 std::ofstream
VC6 的CStdioFile在多线程写入时存在隐式临界区竞争(其内部缓冲未加锁),而std::ofstream在 VC6 中默认不支持宽字符流(std::wofstream根本不可用),且构造/析构过程会触发_setmbcp()等区域设置调用,在某些嵌入式 WinCE 子系统中引发断言失败。CLog直接采用 CRT 的fopen/fprintf/fflush组合,原因有三:
fopen(_T("vc_log.txt"), "a+t")中的"a+t"模式确保:每次写入自动定位到文件末尾(a),同时允许读写(t表示文本模式,兼容 DOS/Windows 换行符\r\n);fprintf是原子写入(单次调用内不会被线程打断),配合fflush强制刷盘,避免程序异常退出时日志丢失;- 所有字符串操作基于 MFC
CString,天然支持 Unicode(通过_UNICODE宏切换)和 ANSI 双编译模式,无需手动WideCharToMultiByte转码。
提示:若需在纯 Win32 SDK 项目(无 MFC)中复用该类,只需将
CString替换为TCHAR[]数组 +_tcscpy_s,并移除对AfxThrowFileException的调用,改用GetLastError()判断错误。
2.2 时间戳格式化为何绕过 CTime::Format,而用 GetLocalTime + wsprintf
CTime::Format在 VC6 中存在两个致命缺陷:一是内部调用GetDateFormat/GetTimeFormat时若系统区域设置为非英语(如中文简体),会导致格式化字符串包含全角冒号:或空格,破坏日志解析;二是其构造函数CTime(time_t)在跨时区或夏令时切换期间可能返回错误时间。CLog改用 Win32 APIGetLocalTime获取SYSTEMTIME结构体,再通过wsprintf手动拼接:
void CLog::FormatTime(CString& strTime) { SYSTEMTIME st; GetLocalTime(&st); TCHAR szBuf[64]; wsprintf(szBuf, _T("%04d-%02d-%02d %02d:%02d:%02d"), st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond); strTime = szBuf; }此实现完全规避区域设置影响,且wsprintf在 VC6 中稳定支持%02d等格式符(_stprintf在某些 SP6 补丁版本中存在栈溢出风险)。注意:szBuf必须声明为TCHAR类型而非char,否则 Unicode 编译下wsprintf会写入宽字符导致后续fprintf输出乱码。
2.3 日志等级参数为何用 LPCTSTR 而非枚举类型
WriteLog(LPCTSTR pszLogLevel, LPCTSTR pszMessage)的第一个参数设计为字符串而非enum LOG_LEVEL { DEBUG, INFO, WARNING, ERROR },是为兼容 VC6 的预处理器限制。VC6 不支持enum class,普通enum在头文件中定义后,若多个.cpp文件包含Log.h,链接时易出现LNK2005: xxx already defined错误。而字符串字面量_T("DEBUG")在编译期被合并为同一地址,无重复定义风险。更重要的是,这种设计允许动态注入等级名——例如在调试 Release 版本时,通过读取注册表键值HKEY_LOCAL_MACHINE\SOFTWARE\MyApp\LogLevel返回"VERBOSE",直接传入即可,无需修改枚举定义。
3. 完整可编译代码实现与关键参数配置说明
3.1 Log.h 头文件:声明类接口与线程安全控制宏
// Log.h #pragma once #include <afx.h> // MFC core components #include <stdio.h> // CRT file I/O #include <windows.h> // GetLocalTime class CLog { public: CLog(); ~CLog(); // 主日志写入接口:pszLogLevel 为等级字符串(如 _T("ERROR")),pszMessage 为日志内容 void WriteLog(LPCTSTR pszLogLevel, LPCTSTR pszMessage); // 带文件名和行号的增强版写入(推荐在实际业务代码中使用) void WriteLogEx(LPCTSTR pszLogLevel, LPCTSTR pszMessage, LPCTSTR pszFile, int nLine); // 设置日志文件路径(默认为当前目录 vc_log.txt) void SetLogFilePath(LPCTSTR pszPath); // 清空现有日志文件(慎用,生产环境建议关闭) void ClearLogFile(); protected: void FormatTime(CString& strTime); void OpenLogFile(); void CloseLogFile(); private: CString m_strLogPath; // 日志文件完整路径 FILE* m_pFile; // 文件句柄,线程不安全,需外部同步 CRITICAL_SECTION m_cs; // VC6 不支持 std::mutex,用 Win32 CRITICAL_SECTION 实现线程保护 };注意:
CRITICAL_SECTION是 VC6 下唯一可靠的用户态同步原语。InitializeCriticalSection和DeleteCriticalSection必须在构造/析构中成对调用,否则会导致内存泄漏或崩溃。此处未使用CCriticalSection(MFC 封装类),因其在 VC6 中部分成员函数未导出。
3.2 log.cpp 实现文件:含线程保护、路径解析与错误降级策略
// log.cpp #include "Log.h" #include <tchar.h> #include <shlwapi.h> // PathFindFileName #pragma comment(lib, "shlwapi.lib") CLog::CLog() : m_pFile(NULL), m_strLogPath(_T("vc_log.txt")) { InitializeCriticalSection(&m_cs); } CLog::~CLog() { CloseLogFile(); DeleteCriticalSection(&m_cs); } void CLog::SetLogFilePath(LPCTSTR pszPath) { if (pszPath && *pszPath) { m_strLogPath = pszPath; } } void CLog::ClearLogFile() { CloseLogFile(); _tremove(m_strLogPath); OpenLogFile(); // 重新打开即创建空文件 } void CLog::OpenLogFile() { EnterCriticalSection(&m_cs); if (m_pFile == NULL) { // 尝试创建日志目录(若路径含子目录) TCHAR szDir[MAX_PATH] = {0}; _tcscpy_s(szDir, m_strLogPath); PathRemoveFileSpec(szDir); if (*szDir && !PathIsDirectory(szDir)) { CreateDirectory(szDir, NULL); // 忽略返回值,即使失败也继续 } m_pFile = _tfopen(m_strLogPath, _T("a+t")); if (m_pFile == NULL) { // 降级策略:若指定路径不可写,尝试写入临时目录 TCHAR szTemp[MAX_PATH] = {0}; GetTempPath(MAX_PATH, szTemp); _tcscat_s(szTemp, _T("vc_log_fallback.txt")); m_pFile = _tfopen(szTemp, _T("a+t")); if (m_pFile) { // 记录降级事件到控制台(仅调试时可见) _tprintf(_T("[FALLBACK] Log redirected to %s\n"), szTemp); } } } LeaveCriticalSection(&m_cs); } void CLog::CloseLogFile() { EnterCriticalSection(&m_cs); if (m_pFile) { fclose(m_pFile); m_pFile = NULL; } LeaveCriticalSection(&m_cs); } void CLog::FormatTime(CString& strTime) { SYSTEMTIME st; GetLocalTime(&st); TCHAR szBuf[64]; wsprintf(szBuf, _T("%04d-%02d-%02d %02d:%02d:%02d"), st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond); strTime = szBuf; } void CLog::WriteLog(LPCTSTR pszLogLevel, LPCTSTR pszMessage) { if (!pszLogLevel || !pszMessage) return; EnterCriticalSection(&m_cs); OpenLogFile(); if (m_pFile == NULL) { LeaveCriticalSection(&m_cs); return; } CString strTime; FormatTime(strTime); CString strOutput; strOutput.Format(_T("[%s] [%s]: %s"), strTime, pszLogLevel, pszMessage); // 使用 _ftprintf_s(VC6 SP6+ 支持)替代 fprintf,防止格式化字符串攻击 _ftprintf_s(m_pFile, _T("%s\n"), strOutput); fflush(m_pFile); LeaveCriticalSection(&m_cs); } void CLog::WriteLogEx(LPCTSTR pszLogLevel, LPCTSTR pszMessage, LPCTSTR pszFile, int nLine) { if (!pszLogLevel || !pszMessage || !pszFile) return; EnterCriticalSection(&m_cs); OpenLogFile(); if (m_pFile == NULL) { LeaveCriticalSection(&m_cs); return; } CString strTime; FormatTime(strTime); // 提取文件名(不含路径),提升日志可读性 LPCTSTR pszFileName = PathFindFileName(pszFile); CString strOutput; strOutput.Format(_T("[%s] [%s] [%s(%d)]: %s"), strTime, pszLogLevel, pszFileName, nLine, pszMessage); _ftprintf_s(m_pFile, _T("%s\n"), strOutput); fflush(m_pFile); LeaveCriticalSection(&m_cs); }关键参数说明与可调项:
| 参数/行为 | 默认值 | 修改方式 | 影响说明 |
|---|---|---|---|
m_strLogPath | "vc_log.txt" | 调用SetLogFilePath(_T("D:\\logs\\app.log")) | 支持绝对路径、相对路径;若含不存在的目录,类自动创建 |
| 日志打开模式 | "a+t" | 修改OpenLogFile()中fopen第二个参数 | "a+"支持读写,"t"强制文本模式(避免二进制写入换行符错误) |
| 线程保护粒度 | 整个WriteLog调用 | 无法细粒度到单行日志,因fprintf+fflush需原子性 | 高频写入场景下可能成为性能瓶颈,但保证日志行不交叉 |
| 降级路径 | GetTempPath()+"vc_log_fallback.txt" | 修改ClearLogFile()中的GetTempPath调用 | 当主日志路径因权限/磁盘满失败时,自动转向系统临时目录 |
3.3 在 MFC 应用中集成:从 CWinApp 到对话框控件的全链路调用
在CWinApp派生类(如CMyApp)中声明全局日志对象,并在InitInstance()开头初始化:
// MyApp.h class CMyApp : public CWinApp { public: virtual BOOL InitInstance(); static CLog g_Log; // 声明为 static,确保单例 }; // MyApp.cpp CLog CMyApp::g_Log; // 定义 BOOL CMyApp::InitInstance() { // 在任何 MFC 对象创建前启用日志 CMyApp::g_Log.SetLogFilePath(_T("C:\\MyApp\\logs\\runtime.log")); // 记录启动事件 CMyApp::g_Log.WriteLog(_T("INFO"), _T("Application starting...")); // 后续正常 MFC 初始化... return CWinApp::InitInstance(); }在对话框类中使用WriteLogEx自动注入文件名和行号:
// MyDialog.cpp void CMyDialog::OnBnClickedOk() { // 使用宏封装,避免手动传参错误 #define LOG_DEBUG(msg) CMyApp::g_Log.WriteLogEx(_T("DEBUG"), _T(msg), _T(__FILE__), __LINE__) #define LOG_ERROR(msg) CMyApp::g_Log.WriteLogEx(_T("ERROR"), _T(msg), _T(__FILE__), __LINE__) LOG_DEBUG(_T("User clicked OK button")); if (!DoSomethingCritical()) { LOG_ERROR(_T("DoSomethingCritical failed!")); AfxMessageBox(_T("Operation failed.")); } }提示:
__FILE__在 VC6 中返回完整路径(如D:\project\src\MyDialog.cpp),PathFindFileName自动截取为MyDialog.cpp,大幅缩短日志长度,便于快速定位。
4. 生产环境部署技巧与典型故障排查表
4.1 日志文件权限与磁盘满应对:三步隔离法
VC6 MFC 程序常以低权限用户(如Guest或受限服务账户)运行,导致日志写入失败。采用以下三步隔离策略:
- 路径解耦:不在程序安装目录写日志(通常受 UAC 保护),改用
%ALLUSERSPROFILE%\MyApp\Logs(所有用户可写)或%LOCALAPPDATA%\MyApp\Logs(当前用户专用); - 大小监控:在
WriteLog写入前检查文件尺寸,超 10MB 自动重命名归档:
void CLog::CheckLogSizeAndRotate() { if (m_pFile == NULL) return; fseek(m_pFile, 0, SEEK_END); long nSize = ftell(m_pFile); if (nSize > 10 * 1024 * 1024) // 10MB { CString strArchive = m_strLogPath; strArchive += _T(".old"); _trename(m_strLogPath, strArchive); fclose(m_pFile); m_pFile = NULL; } }- 静默降级:当
fopen失败且降级路径也失败时,改用OutputDebugString输出到 Visual Studio 输出窗口(仅调试版有效),确保开发阶段不丢日志。
4.2 常见故障与对应排查命令/日志特征
| 故障现象 | 日志文件表现 | 排查命令(Windows 命令行) | 根本原因与修复 |
|---|---|---|---|
| 日志文件为空,但程序正常运行 | 文件存在但 0 字节 | dir /q vc_log.txt查看所有者;icacls vc_log.txt /grant Users:F授予写入权 | 进程以高权限启动,日志文件被创建为管理员所有,后续低权限实例无法写入 |
日志内容乱码(如? ? ? ?) | 文件用记事本打开显示方块 | chcp查看当前代码页;type vc_log.txt | findstr /c:"["测试是否能匹配[ | VC6 默认 ANSI 编码,若系统区域设置为 UTF-8,需在项目属性 → 配置属性 → 常规 → 字符集 → 选择「使用多字节字符集」 |
同一时刻多行日志内容混杂(如[INFO] [DEBUG]出现在同一行) | 日志行首缺失[或时间戳错位 | debugview捕获OutputDebugString输出,确认是否线程未加锁 | EnterCriticalSection/LeaveCriticalSection未配对,或CloseLogFile被多线程并发调用 |
WriteLogEx输出的文件名显示为完整路径而非文件名 | 日志中出现D:\project\src\MyDialog.cpp(123) | dumpbin /headers log.obj | findstr "CRT"确认链接的 CRT 版本 | shlwapi.lib未正确链接,PathFindFileName解析失败,回退到原始__FILE__ |
4.3 性能压测下的日志优化:禁用时间戳与异步缓冲开关
在高频数据采集场景(如每秒 1000+ 条日志),GetLocalTime+wsprintf成为瓶颈。此时可关闭时间戳,改用单调递增计数器:
// 在 Log.h 中添加 static volatile LONG g_nLogCounter = 0; // 在 WriteLog 中替换 FormatTime 调用: // CString strTime; FormatTime(strTime); → CString strTime; strTime.Format(_T("%ld"), InterlockedIncrement(&g_nLogCounter));若需更高性能,可启用内存缓冲(需自行管理缓冲区溢出):
// 添加缓冲区成员 TCHAR m_szBuffer[4096]; int m_nBufferPos; // WriteLog 中改为: if (m_nBufferPos + strOutput.GetLength() < sizeof(m_szBuffer)/sizeof(TCHAR)) { _tcscat_s(m_szBuffer + m_nBufferPos, sizeof(m_szBuffer)/sizeof(TCHAR) - m_nBufferPos, strOutput); _tcscat_s(m_szBuffer + m_nBufferPos, sizeof(m_szBuffer)/sizeof(TCHAR) - m_nBufferPos, _T("\n")); m_nBufferPos += strOutput.GetLength() + 1; } else { // 缓冲区满,刷入文件 _ftprintf_s(m_pFile, _T("%s"), m_szBuffer); fflush(m_pFile); m_nBufferPos = 0; _tcscpy_s(m_szBuffer, _T("")); }注意:此缓冲方案牺牲了实时性,日志可能延迟数秒才落盘,仅适用于可接受短暂延迟的监控场景。
本文还有配套的精品资源,点击获取