简介:《精通MFC》配套光盘源代码面向已具备C++基础、希望系统掌握Windows平台MFC开发的中高级读者,覆盖从面向对象建模、窗口与消息机制到文档视图、GDI与GDI+绘图、进程线程、动态链接库、COM组件及.NET托管扩展的完整知识链路。压缩包为rar格式,共1114个文件、约8.01MB,其中279个h头文件与229个cpp源文件构成各章示例的主体实现,56个vcproj工程文件与37个sln解决方案便于直接用Visual Studio打开调试,另有50个rc、47个rc2资源脚本及ico、bmp图像素材支撑界面示例,def、idl、tlb等文件则对应DLL导出与COM接口定义。已有514人学习下载。逐章对照可复现消息映射、对话框数据交换、文档序列化、打印预览、多线程同步与进程间通信等关键代码,工程化目录也便于按主题检索与二次改造,适合作为案头参考与动手实践的配套素材。
1. 光盘里的 MFC 源代码:一套能编译的 Windows 桌面程序样本
被派去接手一个跑了十来年的工控上位机,界面全是对话框,按钮靠自绘,数据和界面之间靠 DDX 来回倒腾,这种时候翻教程远不如手边有一份能直接编译的 MFC 源代码管用。这套「精通MFC」配套光盘里的示例工程就是这种材料:按章节铺开的对话框程序、文档视图、GDI 绘图、串口收发、多线程与进程间通信,每个示例都是独立可编译的工程,而不是书页里撕下来的片段。它适合两类人,一类是被安排维护老 Windows 桌面项目的开发者,另一类是想弄明白 MFC 的消息泵、运行时类型识别、消息映射表这些机制到底怎么转起来的人。下面按目录骨架、消息链路、编译跑通、踩坑排查、二次开发的顺序,把这堆代码拆开看一遍。
2. 目录与工程骨架:从 .dsp 到 .vcxproj,先弄清每个文件是谁
光盘解压出来第一眼是懵的:几十个文件夹,命名从 Chapter02 排到 Chapter16,中间还夹着 Demo、Sample、Tools 这类名字。别急着找入口,先花十分钟做一次文件盘点。判断一套 MFC 源码值不值得深挖,看三个指标就够了——可独立编译的工程有多少个、公共头文件被复用的次数、资源脚本里对话框模板的数量。这三个数字基本决定了它是「一章一个玩具」还是「有一个能撑起真实项目的小框架」。多数配套源码属于前者,但公共目录里往往藏着几个写得相当扎实的封装,比如自绘按钮、串口类、日志类,这些才是二次开发真正的起点。
2.1 光盘目录的三层结构:章节工程、公共代码、资源
典型的布局是三层。最外层按章节切分,每章一个目录,里面放该章所有示例,示例之间互不依赖,删掉一个不影响别的编译。中间层是公共代码,名字常见的有 Common、Include、Lib、ClassLib,放的是跨章节复用的类,很多示例工程会用相对路径..\Common去 include 它,一旦你移动目录位置就会集体报找不到头文件。最内层是资源,散落在各个工程自己的 res 目录里,图标、位图、对话框模板、字符串表都在其中,也是后面 ID 冲突的高发区。
| 文件 / 目录 | 常见命名 | 作用 | 处理优先级 |
|---|---|---|---|
| 章节工程目录 | Chapter03、Chap04_Dlg | 每个目录一个可独立编译示例 | 先挑 3 个有代表性的 |
| 工程文件 | .dsp、.vcproj、*.vcxproj | 编译器与链接器配置 | 决定升级路线 |
| 资源脚本 | *.rc、resource.h | 对话框模板、图标、字符串表 | ID 冲突高发区 |
| 公共代码 | Common、Include、Lib | 自绘控件、串口/网络封装 | 二次开发最值钱的部分 |
| 预编译头 | stdafx.h、pch.h | 集中 include 与宏开关 | 字符集设置常藏在这里 |
盘点这一步用几条命令就能做完,比在资源管理器里一层层点开快得多:
# 1) 摸清体量:这套源代码里有多少 .cpp / .h find . -type f \( -iname "*.cpp" -o -iname "*.h" \) | wc -l # 2) 数工程文件,一个工程基本对应书里一个可运行示例 find . -type f \( -iname "*.dsp" -o -iname "*.vcproj" -o -iname "*.vcxproj" \) -print # 3) 找出所有资源脚本,后面排查 ID 冲突全靠它 find . -type f -iname "*.rc" -print # 4) 看哪些头文件被引用最多,被引用最多的通常就是事实上的“公共库” grep -rho --include=*.cpp '#include "[^"]*\.h"' . | sort | uniq -c | sort -rn | head -n 20参数上要注意几个细节。-type f限定只找普通文件,避免把目录名也算进去;-iname而不是-name,是因为光盘里的源码经常出现.CPP、.H这种大写后缀,区分大小写会漏掉一半;\( ... \)这对括号必须转义,否则会被 shell 当成子 shell 语法处理,-o是逻辑或,不分组的话优先级会出错。第 4 条里grep -r递归、-h隐藏文件名、-o只输出匹配到的那一小段,三者组合起来才能让uniq -c统计出真实的引用次数,少一个参数结果就是废的。跑完这四条,你手上就有一张粗略的依赖地图了。
提示:盘点阶段不要急着改任何文件。光盘里的源码大多带只读属性,Windows 下直接复制出来先用
attrib -R /S去掉只读,否则后面 IDE 升级工程时会提示保存失败。
2.2 工程文件的三代形态与升级路线
MFC 配套源码的工程文件形态直接暴露了它的年代。.dsp+.dsw是 VC6 时代的产物,.vcproj+.sln对应 VS2002 到 VS2008,.vcxproj+.sln是 VS2010 之后。三种形态在新版 IDE 里的待遇完全不同:.vcxproj基本能直接打开,.vcproj会触发一次单向升级,.dsp则要看 IDE 版本,很多新版直接不给升。升级本身不难,难的是升级会把工程文件整个重写,你原来那些自定义的包含路径、预处理器定义、链接依赖全部要重新核一遍。
我一般走两条路线,视示例大小而定。文件少的示例(三五个 cpp),直接在 IDE 里新建一个空的白板 MFC 工程,把源文件和资源手动加进去,配置项自己重设一遍,干净且心里有数。文件多的、依赖公共库的示例,才用「打开旧工程 → 允许重定向 → 逐项核对配置」这条路,因为手抄几十个源文件列表太容易漏。无论哪条路,动之前先把原始目录整份复制一份留底,这是我踩过坑之后雷打不动的习惯。
重定向完成后,打开.vcxproj检查三个关键项。这三个值任何一个不对,编译期就会给你一堆看不懂的错误:
<!-- 老工程重定向后,先确认这三处没被改成不存在的值 --> <PropertyGroup Label="Configuration"> <PlatformToolset>v143</PlatformToolset> <!-- 装的是哪版工具集就写哪版,写高了直接报 MSB8020 --> <CharacterSet>Unicode</CharacterSet> <!-- 改成 MultiByte 只是权宜之计,后面会说为什么 --> <UseOfMfc>Dynamic</UseOfMfc> <!-- Dynamic 要随程序带 mfc140u.dll,Static 则体积翻倍 --> </PropertyGroup>PlatformToolset必须和本机实际安装的生成工具版本对齐,写一个没装的版本号,IDE 会报找不到工具集;CharacterSet是后面字符集问题的总开关,改成 MultiByte 能让一批老代码立刻编过,但等于把技术债往后推;UseOfMfc决定是静态链接还是动态链接 MFC 库,动态链接的程序拷到没装运行库的机器上会直接起不来,静态链接则单个 exe 体积大几 MB。做现场部署的工具,我一般选静态,省得挨个机器装运行库。
2.3 最小可运行骨架:theApp、InitInstance 与消息循环
把任意一个对话框示例的入口文件打开,形状几乎一模一样。理解了这个骨架,再看别的示例就只是往里填内容:
// 典型的 MFC 示例工程入口,几乎每个章节示例都是这个形状 #include "stdafx.h" // 预编译头,必须排在所有 include 之前 #include "MainApp.h" #include "MainDlg.h" CMainApp theApp; // 全局唯一应用对象,在 WinMain 之前就已完成构造 BOOL CMainApp::InitInstance() { // 老工程常见写法:把配置写进注册表,路径由产品名决定 SetRegistryKey(_T("SampleSoft")); // 换成自己的名字,否则多个示例会共用同一处注册表位置 INITCOMMONCONTROLSEX icc = { sizeof(icc), ICC_WIN95_CLASSES }; InitCommonControlsEx(&icc); // 不初始化,列表控件、进度条可能创建失败 CMainDlg dlg; m_pMainWnd = &dlg; // 主窗口指针交给框架,消息循环才有退出依据 INT_PTR nResponse = dlg.DoModal(); // 模态对话框自带消息循环 if (nResponse == IDOK) { /* 确定分支 */ } return FALSE; // 对话框程序必须返回 FALSE,否则框架会再进一次消息循环 }逐点说清楚。theApp是全局对象,它的构造发生在main之前,MFC 的WinMain是库里的,靠这个全局对象把控制权接到你的InitInstance上,所以它必须有且只有一个,重名会报重复定义。SetRegistryKey影响CWinApp::GetProfileString一族函数的读写位置,不改的话你测试两个示例时配置会互相污染,这是新手最常遇到的「玄学」问题之一。INITCOMMONCONTROLSEX结构体两个成员分别是大小和要初始化的控件类别,大小字段忘了填会导致调用直接失败。
最容易被忽略的是最后的return FALSE。基于对话框的程序,DoModal返回时窗口已经销毁,此时若返回 TRUE,框架会继续进入CWinThread::Run的消息循环,而此时已无窗口可收消息,表现就是进程不退出或者退出时崩一下。同理,m_pMainWnd必须在DoModal之前赋值,否则框架在Run里判断主窗口为空会直接结束进程。
3. 消息映射链路:一次鼠标点击从 AfxWndProc 走到你的处理函数
MFC 最劝退的地方就是「明明没写 switch-case,消息怎么就跑到我的函数里了」。这套机制在示例代码里随处可见,BEGIN_MESSAGE_MAP和ON_BN_CLICKED几乎每个 cpp 都有,但书上的图往往讲得太抽象。把链路拆成四段就好理解了:系统把消息投递到线程消息队列,CWinThread::PumpMessage取出消息并先给PreTranslateMessage一次拦截机会,然后调::DispatchMessage交给窗口过程,MFC 注册的窗口过程是AfxWndProc,它把 HWND 反查成 CWnd 指针,转进CWnd::WindowProc,再进OnWndMsg做消息映射查找。查找失败才走DefWindowProc。这条链路上任何一段理解错了,调试时就会找不到北。
3.1 消息映射表的三段式结构与查找过程
宏展开之后其实就是一个静态数组。头文件里DECLARE_MESSAGE_MAP()声明两个静态成员:一个是_messageEntries数组,一个是messageMap结构(内含指向基类映射表的指针)。cpp 里BEGIN_MESSAGE_MAP/END_MESSAGE_MAP负责定义这个数组,中间的每个ON_XXX就是往数组里塞一条AFX_MSGMAP_ENTRY记录。
// ---- 头文件:声明处理函数 ---- class CMainDlg : public CDialog { DECLARE_MESSAGE_MAP() // 展开后声明静态映射表与基类指针 public: afx_msg void OnBnClickedOk(); // afx_msg 只是标记,不参与编译 afx_msg void OnPaint(); afx_msg LRESULT OnUserData(WPARAM wParam, LPARAM lParam); // 自定义消息 }; // ---- 实现文件:填充映射表 ---- BEGIN_MESSAGE_MAP(CMainDlg, CDialog) // 第二个参数是基类,本级查不到就往基类走 ON_BN_CLICKED(IDOK, &CMainDlg::OnBnClickedOk) ON_WM_PAINT() // 系统消息用约定名 OnPaint,宏内部已绑定 ON_MESSAGE(WM_USER + 100, &CMainDlg::OnUserData) END_MESSAGE_MAP()一条AFX_MSGMAP_ENTRY有六个字段:nMessage(消息值)、nCode(通知码,比如 BN_CLICKED)、nID(控件 ID 下限)、nLastID(控件 ID 上限)、nSig(函数签名类型)、pfn(函数指针)。查找是线性的,逐条比对消息值,再判断 ID 是否落在[nID, nLastID]区间内,最后按nSig把pfn强转成正确签名的成员函数指针去调用。所以ON_COMMAND_RANGE能用一条记录覆盖一段 ID,靠的就是这个区间匹配;也所以映射表越长,查找越慢——几百条的表在WM_MOUSEMOVE这种高频消息上是有可测量开销的,别什么都往映射表里塞。
宏名和函数名的对应关系是新手最容易卡的:ON_WM_PAINT()不带参数,因为它内部已经写死了OnPaint这个名字和WM_PAINT这个消息;而ON_BN_CLICKED要你显式给 ID 和函数地址。同一个消息在同一个类里只能映射一次,重复映射不会报编译错,只会在运行期表现为「只有一个函数被调用」,很隐蔽。
3.2 一条消息从进入到返回的完整链路
拿一次按钮点击走一遍。用户点击 → 系统投递WM_COMMAND,wParam高位是通知码BN_CLICKED、低位是控件 ID,lParam是按钮句柄 →CWinThread::PumpMessage取出 →PreTranslateMessage里IsDialogMessage会处理 Tab 键和快捷键(这一步很重要,对话框里的回车 Esc 默认按钮就是在这里被转成IDOK/IDCANCEL的)→::DispatchMessage→AfxWndProc→CWnd::WindowProc→OnWndMsg在映射表里找到ON_BN_CLICKED(IDOK, ...)这条 → 调用OnBnClickedOk→ 若返回 FALSE 且是WM_COMMAND,还会继续往父窗口冒泡。
理解PreTranslateMessage这一环,能解决一大批怪问题。比如你想让编辑框里按回车不关闭对话框,正确做法是重写PreTranslateMessage拦下WM_KEYDOWN的 VK_RETURN,而不是在OnOK里做判断;再比如程序里加了键盘钩子却不起作用,多半是被IsDialogMessage提前消费掉了。
3.3 自定义消息与线程间通信
示例里但凡涉及工作线程,几乎都会用WM_USER + N往主窗口投消息。这段代码值得抄下来,但有两个参数必须弄明白:
// 工作线程把进度丢回 UI 线程 UINT CWorkerThread::Run() { // 关键:工作线程不要直接碰控件,一律先投递消息 ::PostMessage(m_hWndOwner, WM_USER + 100, (WPARAM)nProgress, (LPARAM)pBuffer); return 0; } // UI 线程的处理函数,签名固定为 LRESULT(WPARAM, LPARAM) LRESULT CMainDlg::OnUserData(WPARAM wParam, LPARAM lParam) { int nProgress = (int)wParam; // 小整数直接塞 wParam BYTE* pData = reinterpret_cast<BYTE*>(lParam); // 指针塞 lParam if (nProgress >= 100 && pData) { // 处理完记得释放,谁分配谁释放,这里约定由 UI 线程释放 delete[] pData; } return 0; }PostMessage是异步投递,消息进队列就返回,线程不会被阻塞;SendMessage是同步,会一直等到目标窗口处理完。工作线程里用SendMessage发给主窗口,一旦主窗口此时正在等这个线程结束,就是标准的死锁现场,程序直接卡死没有任何输出。WM_USER的取值范围是 0 到 0x7FFF,留给应用自己用,但要注意别和控件通知码、WM_APP(0x8000 起)混用。lParam传指针时必须保证接收方使用期间指针有效,最常见的翻车是工作线程用栈上的局部数组传过去,函数一返回内存就没了,UI 线程拿到的是野指针。
注意:
ON_MESSAGE映射的处理函数返回类型必须是LRESULT,写成void或BOOL编译能过,运行期签名不匹配会导致返回值错乱甚至栈不平衡。
4. 把示例跑起来:工具链、字符集、资源三处卡点逐个拆
一套十几年前的源码,在新机器上第一次点「生成」,大概率是满屏红。别慌,错误看着多,真正的原因通常只有三类:没装 MFC 组件、字符集不匹配、资源或运行库对不上。这三类按顺序解决,能覆盖九成以上的编译失败。剩下的零星错误基本是老语法在新编译器下不兼容,逐个改就行,不需要推倒重来。
4.1 工具链:MFC 组件、平台工具集与命令行验证
第一件事是确认 MFC 到底装没装。新版 Visual Studio 的 C++ 工作负载默认不勾 MFC,缺了它的典型症状是无法打开包括文件: "afxwin.h"。打开安装程序,在单个组件里勾上对应版本的「适用于最新 v143 生成工具的 C++ MFC」,装完重启 IDE。这一步不做,后面所有操作都是白费。
装好之后,用命令行编译一次,比在 IDE 里点生成更容易看清错误顺序:
# 用 MSBuild 单独编译一个示例,先把它的错误跑出来 msbuild Chapter05\Chapter05.sln /p:Configuration=Debug /p:Platform=Win32 /m /v:m/p:Configuration和/p:Platform指定配置和平台,老工程很多只有 Win32 配置,直接指定 x64 会报找不到配置;/m允许并行编译,多工程解决方案能快不少;/v:m把输出压到 minimal,只显示警告和错误,避免上千行编译日志淹没有效信息。如果解决方案里有多个工程互相依赖,/t:Build默认会按依赖顺序来,不需要手动排序。
还有一个常被忽略的点:老工程的Platform多半只有 Win32。强行加 x64 配置编出来的程序,运行期错误会非常难查,因为源码里到处是long转指针、int存句柄这类写法,32 位下恰好没问题,64 位下高位被截断。要上 64 位,得先把这些地方一处一处过一遍,不能指望改个平台就完事。
4.2 字符集:Unicode 与 MBCS 的批量处理
这是这类源码最集中的雷区。VC6 时代的代码默认多字节字符集,char、strcpy、sprintf满天飞,工程一旦切到 Unicode,CString::Format、SetWindowText、CreateFile全部报类型不匹配,一次能炸出几百个错误。
处理思路有两条。权宜之计是把工程属性里的字符集改回「使用多字节字符集」,一分钟编过,代价是这套代码永远停留在老 API 上,且新版 MFC 对 MBCS 的支持在逐步收紧。正经做法是保留 Unicode,用TCHAR家族宏做一层过渡,把改动限制在接口边界上:
// 统一走 TCHAR 家族,让同一份代码在两种字符集下都能编 CString sPath; sPath.Format(_T("%s\\config.ini"), (LPCTSTR)strDir); // 格式串和参数都必须是 TCHAR 系 TCHAR szBuf[MAX_PATH] = { 0 }; _tcscpy_s(szBuf, MAX_PATH, (LPCTSTR)sPath); // 用 _tcscpy_s 而不是 strcpy // 必须和只认 char* 的第三方库打交道时,在边界处一次性转换 #ifdef _UNICODE #include <atlconv.h> USES_CONVERSION; // 声明转换用的栈上缓冲区 const char* pszAnsi = T2A((LPCTSTR)sPath); // 只在调用点转换,不要写进循环 #endif几个参数和坑点说明白。_T()宏在 Unicode 配置下把字面量变成L"...",非 Unicode 下保持"...",是整套过渡的核心;_tcscpy_s、_stprintf_s这些带_s的安全版本比老函数多了缓冲区大小参数,能挡掉一批溢出;USES_CONVERSION和T2A这类转换宏会在当前栈帧上分配缓冲区,所以绝对不能放在循环体里反复调用,栈会涨得很快,正确用法是在函数入口声明一次、转换一次、用完就走。真要高频转换,用WideCharToMultiByte自己管缓冲区更稳妥。
改造过程建议配一张对照表按顺序替换,比盲改效率高得多:
| 老写法 | Unicode 下的问题 | 替换写法 |
|---|---|---|
char buf[256] | 与LPCTSTR参数不兼容 | TCHAR buf[256] |
strcpy/strcat | 只接受char* | _tcscpy_s/_tcscat_s |
sprintf | 同上,且无长度约束 | _stprintf_s |
CString::Format("%s", p) | 格式串与参数类型错位 | _T("%s")+(LPCTSTR)p |
atoi/itoa | 宽字符版本缺失 | _ttoi/_itot_s |
4.3 资源与运行期依赖:rc、manifest 与 DLL
编译过了不代表能跑。资源层面最常见的是把多个示例合并进一个工程时,resource.h里的IDD_XXX、IDC_XXX撞号,症状是对话框打开后控件错位或者干脆弹出另一个窗口。原因是.rc文件和resource.h是两套需要同步的东西,手工合并时只改了.rc没改头文件,或者两个文件的编号起点重叠。稳妥做法是合并前先把各工程的 ID 段整体平移,留出空档。
运行期依赖还有两个点。一是 MFC 动态链接时,目标机器上必须有对应版本的mfc140u.dll和 VC 运行库,缺了的表现是双击没反应或者弹「应用程序无法正常启动 (0xc000007b)」,位数不匹配也是这个错误码。二是视觉样式,老工程的.rc里如果没有引入comctl32.lib和对应的 manifest 依赖,跑起来是 Windows 经典灰按钮,跟书上的截图对不上。在stdafx.h里加一句#pragma comment(linker, "/manifestdependency:...")或者在工程属性里开「启用视觉样式」即可。
5. 避坑与排查:十几年前的 MFC 代码在新机器上的五类翻车
这套代码在一个已经跑通的示例上做修改,和从零把它编译起来,遇到的坑完全不是一回事。下面五条是我在不同机器上反复撞见的,每条都按现象、原因、解决三段记,照着排查能省掉大量瞎试的时间。
5.1 编译期三类高频报错
第一条:无法打开包括文件: "afxwin.h"或MSB8041: 此项目需要 MFC 库。现象是解决方案里所有工程一起报错,一个都编不过。原因是当前 VS 安装没有勾选 MFC 组件,新版安装程序默认不含。解决方法是打开安装程序 → 修改 → 单个组件 → 勾上对应工具集的「C++ MFC」,装完重启 IDE。注意工具集版本要对齐,装了 v142 的 MFC 但工程用 v143,一样报错。
第二条:error C2664: 无法将参数 1 从 "const char [N]" 转换为 "LPCTSTR"。现象是集中在SetWindowText、Create、Format这些接口上,一次几百条。原因是工程字符集是 Unicode,而代码里全是窄字符字面量。解决方法是按 4.2 的思路统一走_T()和TCHAR,或者临时把工程字符集改为多字节。要提醒的是,改了字符集只是把编译错误藏起来,运行期如果有中文字符串拼接,还是可能出乱码。
第三条:LNK2005: xxx 已经在 xxx.obj 中定义或LNK2019: 无法解析的外部符号。现象是编译全过,链接阶段炸。原因九成是运行时库配置不一致,比如静态库工程用了/MT,主工程用了/MD,或者有的 cpp 里_AFXDLL宏有、有的没有。解决方法是把所有工程的「C/C++ → 代码生成 → 运行时库」调成同一个值,静态链接 MFC 时用/MT,动态时用/MD,且_AFXDLL要与UseOfMfc设置保持一致。链接错误是最烦的一类,报错信息指向的往往不是真正的问题源头,要从第一个LNK2005往前找。
5.2 运行期两类静默故障
第四条:调试版一跑就弹出 Debug Assertion Failed,指向afxwin1.inl或wincore.cpp的某一行。现象是程序启动瞬间中断,断言内容多半和窗口创建、资源加载有关。原因通常是资源 ID 对不上——.rc里的对话框模板编号和代码里CDialog构造参数传的 ID 不一致,或者.rc根本没被加进工程。解决方法是双击断言打开调用栈,看DoModal里传的 ID 值,再打开resource.h核对这个编号对应的是哪个模板;如果编号存在但resource.h里没有定义,说明.rc没参与编译,去工程里把资源文件加进去。
第五条:程序双击后一闪而过,或者进程还在但窗口不见。现象是没有任何提示,任务管理器里能看到进程,几秒后消失。原因是InitInstance里在返回之前就崩了,或者对话框Create失败但代码没检查返回值直接往下走。解决方法是先在InitInstance第一行写日志、最后一行写日志,确认崩溃发生在哪一步;Create和DoModal的返回值一律要判断,失败时用GetLastError()打印错误码。找不到线索就挂上调试器,在「异常」设置里勾选 Win32 异常第一次机会中断,让崩溃现场停下来。
5.3 排查手段:跟踪输出与兜底日志
MFC 自带的跟踪输出比printf好用得多,只是很多人不知道开关在哪。在InitInstance开头打开afxTraceFlags,配合调试器的输出窗口就能看到框架内部的动作:
// 在 InitInstance 最前面打开 MFC 自身的跟踪输出 #ifdef _DEBUG afxTraceFlags |= traceAppMsg | traceDlls; // traceAppMsg 看消息流向,traceDlls 看模块加载 #endif // 兜底异常过滤:程序“静默消失”时至少留下一条线索 static LONG WINAPI CrashFilter(EXCEPTION_POINTERS* pEx) { // 此处只做最小动作:格式化 + 落盘,不弹窗、不碰 UI、不分配大对象 TCHAR szBuf[512]; _stprintf_s(szBuf, _T("code=0x%08X addr=%p\r\n"), pEx->ExceptionRecord->ExceptionCode, // 异常码,0xC0000005 是访问冲突 pEx->ExceptionRecord->ExceptionAddress); // 出错指令地址,配合 PDB 定位到函数 HANDLE h = ::CreateFile(_T("crash.log"), GENERIC_WRITE, FILE_SHARE_READ, nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (h != INVALID_HANDLE_VALUE) { ::SetFilePointer(h, 0, nullptr, FILE_END); DWORD dwWritten = 0; ::WriteFile(h, szBuf, (DWORD)(_tcslen(szBuf) * sizeof(TCHAR)), &dwWritten, nullptr); ::CloseHandle(h); } return EXCEPTION_EXECUTE_HANDLER; // 吞掉异常并让进程退出,避免反复弹窗 }afxTraceFlags是全局标志位,traceAppMsg把窗口消息的收发打出来,traceDlls打出模块加载卸载,排查「缺哪个 DLL」时非常直接。CrashFilter里有两个纪律必须守:一是不调用任何复杂 MFC 对象和 UI,异常上下文里堆栈已经不可信,CString的构造函数都可能二次崩溃;二是用WriteFile而不是CStdioFile,避免依赖 CRT 状态。ExceptionCode里0xC0000005是访问冲突,0xC0000094是整数除零,0xC00000FD是栈溢出,看到这几个基本能定性。ExceptionAddress要配合生成的.pdb和.map才能还原到函数名,所以发布调试版时记得把这两样一起留着,别只留个 exe。
提示:
CrashFilter要用SetUnhandledExceptionFilter注册,并且在main/InitInstance最早期注册,晚了就漏掉启动阶段的崩溃。
6. 二次开发:把示例里的零件拆成自己能复用的模块
光盘里那几百个示例,一个个通读是最低效的做法。真正值钱的是那些不依赖界面、能单独拎出来的零件——配置读写、串口收发、日志、字符串处理、目录遍历。我的做法是给每个候选类画三条边界:它依赖了哪些 MFC 类、它有没有直接引用控件或窗口句柄、它的错误处理是返回值还是抛异常。三条里有一条越界,就先改造再抽,别硬抽。比如示例里的配置类往往直接绑在对话框上,先把它对CDialog的引用砍掉,只留CString和CStdioFile,就能在控制台工程里直接复用。
// 抽取后的典型形态:不依赖任何窗口类,只依赖 CStdioFile class CIniProfile { public: explicit CIniProfile(LPCTSTR lpszPath); // 只记路径,不做 IO bool GetString(LPCTSTR lpszSection, LPCTSTR lpszKey, CString& strOut, LPCTSTR lpszDefault = _T("")); bool SetString(LPCTSTR lpszSection, LPCTSTR lpszKey, LPCTSTR lpszValue); private: CString m_strPath; // 不缓存文件内容,避免多实例不同步 };参数设计上有两个取舍。GetString用输出参数加返回值,而不是直接返回CString,是为了区分「读到空串」和「键不存在」这两种情况,老代码里这两者混在一起,出了问题根本查不出来。lpszDefault给默认值,调用点就能少写一堆判断。不缓存文件内容是有意为之,这类工具通常同时被几个模块读写,缓存反而制造不一致。
验证方法我固定用三步:先写一个不涉及界面的控制台测试工程,把抽出来的类跑一遍增删改查;再让原来的对话框示例改用新类,行为必须和改之前完全一致;最后在 Release 配置下再跑一遍,因为很多老代码在 Debug 下有断言兜底、在 Release 下才暴露未初始化变量的问题。第二步最容易偷懒跳过,结果就是抽出来的东西在原工程里反而跑不通。
说个我自己的教训。早些年我图快,直接在一个示例工程上改,改到一半发现原来的功能坏了,又没有版本记录,只能把光盘重新解压一遍对照,白白耗掉一个下午。从那以后我每次动这类老源码之前,都强制走一遍:整份复制留底、建个本地仓库先提交一次原始状态、每改完一个编译单元就提交一次。这套动作花不了两分钟,但它是唯一可靠的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取