简介:这是一份面向C++与MFC初学者及需要补齐动态链接库基础的开发者的入门示例包,围绕MFC DLL的创建、导出与在客户端程序中调用展开,适合课程实验、自学练手或作为小型项目的参考模板。压缩包共61个文件,约4.79MB,包含h与cpp源码、dsp与dsw工程配置、rc与res资源脚本、dll与lib编译产物,以及obj、pdb、ilk等调试中间文件,既可阅读实现,又能直接对照工程结构。包内以库工程和调用端对话框工程相互配合,展示导出声明、模块定义文件及接口使用关系,日期处理类等小例子便于快速理解扩展MFC DLL的组织方式。已有407人学习,适合希望从可运行示例入手、建立MFC DLL开发与调用整体认识的读者。
1. 一份 MFC DLL 从“能跑”到“敢交给别人用”的距离
某图像工具项目从单文档 MFC 程序起步,算法、参数对话框和绘图代码全挤在一个 EXE 里,第二个项目要复用同一套参数界面时只能整份复制,改一处要同步三处。有人提议把复用部分抽成一个 MFC DLL(动态链接库),主程序只留菜单和框架。问题随即冒出来:抽成常规 DLL 还是扩展 MFC DLL?导出的对话框为什么弹出来是主程序的?CString传给 DLL 为什么会崩?这篇按“选型、工程骨架、接口设计、状态切换、避坑排查、验收测试矩阵”的顺序,把扩展 MFC DLL 的开发和使用过程完整走一遍,适合正在做 MFC 毕设、课程设计,或者要给已有产品拆模块的人照着改。
2. 工程结构设计:三类 DLL 的边界与一个 EXE 加两个 DLL 的骨架
2.1 先判类型:常规 DLL、扩展 DLL、纯资源 DLL 的边界
MFC 工程向导里那几个 DLL 选项,名字很像,能做的事差得很远。判断标准其实只有一条:接口边界上会不会出现 MFC 对象。只要会出现CDialog派生类、CString、CRect、CDC*这类东西,就只能走扩展 MFC DLL;接口全是HRESULT、int、BYTE*、HWND,常规 DLL 更省心;只有图标、对话框模板、字符串表,没有代码,那就纯资源 DLL。
| 类型 | MFC 链接方式 | 能导出的东西 | 典型用途 | 对调用方的限制 |
|---|---|---|---|---|
| 常规 DLL(共享 MFC) | 动态链接 MFC,定义_AFXDLL | C 函数、非 MFC 类;内部可以用 MFC | 算法模块、带少量对话框的功能包 | 调用方也要有同一套 MFC 运行时 |
| 常规 DLL(静态 MFC) | 静态链接 MFC | C 函数、非 MFC 类 | 给非 MFC 程序调用的功能包 | 体积大,边界上绝不能出现 MFC 对象 |
| 扩展 MFC DLL | 必须动态链接 MFC,定义_AFXDLL和_AFXEXT | CObject派生类、AFX_EXT_CLASS标记的类 | 复用对话框、文档/视图类、绘图模块 | 只能被 MFC 程序或另一个扩展 DLL 加载 |
| 纯资源 DLL | 不需要 MFC | 资源,没有导出函数 | 多语言资源、皮肤、图标库 | 用LoadLibrary+ 资源 API,或切资源句柄 |
一个常见的误判是“我接口里只是按值传了个CString,不算导出 MFC 类吧”。按值传也是传,字符串的缓冲区由哪一个堆分配、由哪一个模块释放,编译器不会替你检查。只要 MFC 类型跨过 DLL 边界,扩展 DLL 就是唯一稳妥的选择,而且 EXE 和 DLL 必须使用同一套共享 MFC、同一个运行库配置。
反过来,如果调用方是控制台程序、C# 的 P/Invoke,或者一个根本没用 MFC 的第三方宿主,扩展 DLL 直接行不通:AfxInitExtensionModule在初始化阶段就会失败,因为进程里没有可用的 MFC 模块状态。这种情况下只能退到常规 DLL,老老实实把接口做成 C 风格。
2.2 骨架:一个 EXE 加两个 DLL 的目录与属性页设置
把两类 DLL 都放进同一个解决方案,能提前暴露混用问题。目录按“接口头单独一层、模块各占一个目录、产物统一输出”的方式切:
MfcDllDemo/ ├─ include/ │ ├─ image_api.h // 纯 C 接口,EXE 和常规 DLL 共用 │ └─ shape_ext.h // 扩展 DLL 的类声明,只给 MFC 调用方 ├─ src/ │ ├─ App/ // MFC 主程序,生成 .exe │ ├─ ImageCore/ // 常规 MFC DLL:图像处理,C 接口 │ └─ ShapeExt/ // 扩展 MFC DLL:参数对话框 + CObject 派生类 ├─ bin/ │ ├─ Debug/ │ └─ Release/ └─ MfcDllDemo.sln接口头单独放include/,是为了让 EXE 工程和 DLL 工程引用同一份声明。DLL 工程里绝不要 include 主程序的pch.h,两个模块各带一份预编译头,编译器选项稍有出入就是一批重定义错误,排查起来很折腾。
属性页要逐项统一,下面这张表是整条链路能稳定工作的前提:
| 属性页项 | 取值 | 原因 |
|---|---|---|
| 常规 → 使用 MFC | 在共享 DLL 中使用 MFC | 扩展 DLL 的硬性要求;EXE 和常规 DLL 跟着统一,避免一个进程里跑两套 MFC |
| C/C++ → 代码生成 → 运行库 | 多线程调试 DLL (/MDd) 或多线程 DLL (/MD) | 与共享 MFC 配套,整条链共用同一个 CRT 堆 |
| C/C++ → 预处理器 | _AFXDLL;扩展 DLL 再加_AFXEXT;DLL 自身加IMAGECORE_EXPORTS这类宏 | AFX_EXT_CLASS才能正确展开成__declspec(dllexport) |
| C/C++ → 语言 → 字符集 | 全部 Unicode | 跨模块传TCHAR系列字符串的前提 |
| 链接器 → 常规 → 输出文件 | $(SolutionDir)bin\$(Configuration)\$(TargetName)$(TargetExt) | EXE 和 DLL 落在同一目录,调试时不会误加载旧版本 |
| 调试 → 工作目录 | $(SolutionDir)bin\$(Configuration) | 直接 F5 就能跑,不用手工拷贝 DLL |
_AFXEXT这一项最容易被忽略。头文件里的AFX_EXT_CLASS是按_AFXEXT是否定义展开的:DLL 工程定义了它,类声明就是__declspec(dllexport);EXE 工程不定义,同一份头文件展开成__declspec(dllimport)。如果 DLL 工程漏了_AFXEXT,编译期不报错,链接期会出现一堆“无法解析的外部符号”,而且符号名恰恰是你自己刚写的那几个函数,第一次遇到会怀疑是不是少加了源文件。
输出目录统一这件事也值得单独说。默认设置下,Debug 和 Release 的 DLL 分别躺在各自目录,主程序却可能加载到系统路径里的旧版本。调试时改了代码、断点不生效、行为跟源码对不上,九成是加载了另一个目录的 DLL。用Process Explorer或调试器的“模块”窗口看一眼实际加载路径,比反复重编快得多。
2.3 依赖方向:接口头是唯一约定
依赖关系画出来是一条单向线:EXE 依赖接口头,接口头对应一个.lib,.lib在链接期指向 DLL。DLL 不 include 任何来自 EXE 工程的头文件,也不反向调用 EXE 里的函数。这条线一旦破了,模块就再也拆不出去——你把 DLL 拷给别的项目,它还要回头找原来那个 EXE 里的符号。
接口头本身也要克制。image_api.h里只放HRESULT、HWND、BYTE*、int这类基础类型,不放CString、不放std::vector、不放任何模板。模板和 STL 容器跨 DLL 边界是另一个层面的问题:两边的编译器版本、运行库配置、迭代器调试级别只要有一项不同,布局就不一样,而编译器不会在边界上做任何检查。
如果 EXE 侧有一个结构体要传给 DLL,把它挪到include/下面,两边引用同一份定义。我一般还会在结构体第一个字段放int nStructSize,由调用方填sizeof,DLL 入口先校验大小。后续版本给结构体加字段时,老 DLL 拿到新结构体不会越界读,新 DLL 拿到老结构体也能识别出是老版本,比直接看字段值猜版本可靠得多。
3. 接口设计:C 接口、导出类与工厂函数的三种写法
3.1 能走 C 接口就别导出 C++ 类
图像处理这类模块,接口天然适合 C 风格:输入一块内存,输出一块内存,返回值表示成功失败。下面这份头文件是常规 DLL 的典型形态,extern "C"关掉名字修饰,__stdcall固定调用约定,HRESULT统一错误码:
// include/image_api.h #pragma once #include <windows.h> #ifdef IMAGECORE_EXPORTS #define IMAGECORE_API __declspec(dllexport) #else #define IMAGECORE_API __declspec(dllimport) #endif #ifdef __cplusplus extern "C" { #endif // 所有导出函数的第一个参数统一带结构体大小,便于后续扩展 typedef struct tagImageRunParam { int nStructSize; // 调用方填 sizeof(IMAGE_RUN_PARAM) HWND hWndParent; // 用于弹出 DLL 内部进度条,可为 NULL const BYTE* pSrcBits; // 源图数据,由调用方持有 int nWidth; int nHeight; int nChannels; // 1 或 3 BYTE* pDstBits; // 目标缓冲区,由调用方分配 int nDstCapacity; // 目标缓冲区字节数,DLL 负责校验 } IMAGE_RUN_PARAM; IMAGECORE_API HRESULT __stdcall ImageOp_Init(void); IMAGECORE_API HRESULT __stdcall ImageOp_Run(const IMAGE_RUN_PARAM* pParam); IMAGECORE_API void __stdcall ImageOp_Cleanup(void); IMAGECORE_API const char* __stdcall ImageOp_GetVersion(void); #ifdef __cplusplus } #endif这段代码里有三个设计决定值得说明。第一,缓冲区由调用方分配、DLL 只负责填充,所有权始终在一边,不会出现“DLL 里 new、EXE 里 delete”的跨堆操作。第二,nDstCapacity由 DLL 校验,缓冲区不够就返回E_OUTOFMEMORY或自定义的HRESULT,而不是直接写越界。第三,ImageOp_GetVersion返回静态字符串,宿主加载后先调一次,把版本号写进日志,现场出问题时第一眼就能确认加载的是哪个版本。
错误码不要只用0和-1。HRESULT的MAKE_HRESULT宏或者自定义的0x8004xxxx区段都行,关键是每个失败分支有独立值。真到了排查阶段,“返回 -1”和“返回 0x80040003 表示目标缓冲区不足”完全不是一个效率。
3.2 必须导出 MFC 类时:虚析构、工厂函数、头文件保持极简
参数对话框这类东西绕不开 MFC 类。扩展 DLL 导出CObject派生类是标准做法,但有三条规矩:基类析构必须是虚的;对象只能由 DLL 内部的工厂创建、由 DLL 内部的工厂销毁;头文件里不写任何有实际逻辑的内联函数。
// include/shape_ext.h #pragma once #include <afxwin.h> // AFX_EXT_CLASS 在 DLL 工程里展开为 dllexport,在 EXE 工程里展开为 dllimport class AFX_EXT_CLASS CShapeBase : public CObject { DECLARE_DYNAMIC(CShapeBase) public: CShapeBase(); virtual ~CShapeBase(); // 必须虚析构,否则 delete 基类指针不会走派生析构 virtual double GetArea() const = 0; virtual void Draw(CDC* pDC, const CRect& rcClient) const = 0; virtual BOOL EditParam(CWnd* pParent); // 内部弹 DLL 自己的对话框 }; // 工厂:new 和 delete 都发生在 DLL 内部,堆始终是同一个 AFX_EXT_CLASS CShapeBase* CreateShape(int nType); AFX_EXT_CLASS void DestroyShape(CShapeBase* pShape);DECLARE_DYNAMIC和IMPLEMENT_DYNAMIC这一对宏提供运行时类型信息,序列化和IsKindOf都依赖它;如果类要放进CObList或者参与文档序列化,还得补DECLARE_SERIAL。这些宏在扩展 DLL 里能正常工作,前提就是CDynLinkLibrary已经把 DLL 的运行时类信息挂到了模块状态链上,下一章会讲。
EditParam这种“在 DLL 内部弹自己的对话框”的写法,比“把对话框指针传出来让 EXE 弹”稳得多。对话框资源和它的DoModal代码在同一个模块里,资源句柄天然正确;一旦让 EXE 去弹 DLL 的对话框,就得处理资源切换,多一个失败点。
头文件里不写内联实现,是因为内联函数会在调用方展开,等于把 DLL 的实现细节编进了 EXE。DLL 改了实现但 EXE 没重编,两边行为就不一致,而这种不一致在 Debug 下往往看不出来。构造函数、析构函数、虚函数全部放到.cpp里,头文件只留声明,最省事。
3.3 导出表自检:dumpbin 看名字、看依赖
DLL 编出来先别急着写调用代码,用dumpbin把导出表和依赖表各看一遍,能省掉后面一半的调试时间:
# 看导出函数:确认没有 C++ 修饰名、函数数量对得上 dumpbin /exports bin\Release\ImageCore.dll # 看依赖:确认 MFC 运行时和 CRT 版本符合预期 dumpbin /dependents bin\Release\ImageCore.dll/exports的输出里,C 风格接口应该只有一个干净的名字,比如ImageOp_Run;如果看到?ImageOp_Run@@YGJPAU...这种,说明extern "C"漏了或者被包进了命名空间,GetProcAddress按短名字去找必然返回NULL。/dependents的输出里应该能看到mfc140u.dll、msvcp140.dll、vcruntime140.dll这一组;如果扩展 DLL 的依赖里出现了静态 MFC 库,说明属性页里“使用 MFC”选错了,后面会遇到资源状态和内存分配的一系列问题。
4. 加载与状态切换:EXE 侧怎么调、DLL 侧怎么切资源
4.1 隐式链接和显式 LoadLibrary 的适用面
隐式链接就是在工程里加.lib、把头文件 include 进来,程序启动时由加载器解析导入表。写法最省事,缺点是 DLL 缺失时进程直接起不来,错误信息只有系统弹窗;而且 DLL 一旦加载就常驻,没有“用完卸载”的余地。
显式加载用LoadLibrary+GetProcAddress,适合插件化、可选功能、需要拿到明确错误码的场景。下面这段是宿主侧的典型写法:
// App 侧的动态加载封装 typedef HRESULT (__stdcall *PFN_ImageOp_Run)(const IMAGE_RUN_PARAM*); HMODULE LoadImageCore() { // 用绝对路径 + LOAD_WITH_ALTERED_SEARCH_PATH,让 DLL 在自己所在目录找依赖 TCHAR szPath[MAX_PATH] = { 0 }; ::GetModuleFileName(NULL, szPath, MAX_PATH); ::PathRemoveFileSpec(szPath); ::PathAppend(szPath, _T("ImageCore.dll")); HMODULE hDll = ::LoadLibraryEx(szPath, NULL, LOAD_WITH_ALTERED_SEARCH_PATH); if (hDll == NULL) { // 126 找不到模块;193 架构不符;14001 并行配置错误 TRACE(_T("LoadLibraryEx failed, path=%s, err=%lu\n"), szPath, ::GetLastError()); } return hDll; }LOAD_WITH_ALTERED_SEARCH_PATH只在传入绝对路径时生效,所以先把GetModuleFileName和PathRemoveFileSpec走一遍。少了这一步,DLL 的依赖项会按当前工作目录、系统目录的顺序去找,调试器里工作目录和资源管理器里双击的目录往往不是同一个,表现就是“我这儿能跑,换台机器就加载失败”。
GetLastError的三个常见值值得记住:126是找不到模块本身或它的某个依赖;193是位数不匹配,32 位进程加载 64 位 DLL;14001是并行配置错误,通常是 VC 运行库没装对。把这几个值打进日志,现场反馈“加载失败”时能直接定位到方向。
GetProcAddress找名字也有坑。extern "C"加__stdcall之后,导出名会变成_ImageOp_Run@12这种带参数栈字节数的形式。两个解决办法:一是用.def文件给导出名起别名,二是直接按修饰名去取。.def更干净:
LIBRARY ImageCore EXPORTS ImageOp_Run=_ImageOp_Run@12 ImageOp_Init=_ImageOp_Init@0.def文件加到 DLL 工程后,导出表里就是不带修饰的短名字,GetProcAddress(hDll, "ImageOp_Run")直接命中。注意.def里列出的函数要和头文件里的声明一一对应,漏一个就是运行时NULL,编译期不会有任何提示。
4.2 常规 DLL 的状态切换:AFX_MANAGE_STATE 不是可选项
常规 DLL 里只要碰了资源——弹对话框、加载字符串、取图标——导出函数的第一行就必须切模块状态:
extern "C" __declspec(dllexport) BOOL ShowParamDlg(CWnd* pParent) { // 切到本 DLL 的模块状态,后续资源查找才会从本 DLL 开始 AFX_MANAGE_STATE(AfxGetStaticModuleState()); CParamDlg dlg(pParent); return dlg.DoModal() == IDOK; }不写这一行的后果很具体:DoModal按资源 ID 去找对话框模板,查找顺序从当前模块状态开始,而当前状态还是宿主的,于是宿主 EXE 里恰好同 ID 的对话框被弹了出来。如果宿主里没有同 ID 资源,就是断言失败或者弹出一个空的、尺寸不对的窗口。更隐蔽的情况是字符串资源:DLL 里LoadString(IDS_ERR_XXX)取到的是宿主的字符串表,日志里打出来的错误信息跟代码完全对不上。
AFX_MANAGE_STATE是个栈上对象,函数返回时自动恢复原状态,所以它必须写在函数体最外层,不能写在某个if分支里。同一个函数里多次切换没有意义,一次就够。如果导出函数会被频繁调用,比如每帧调用一次,状态切换本身开销很小,但要注意别在循环内部反复构造。
扩展 DLL 的情况略有不同。DllMain里new CDynLinkLibrary之后,DLL 的资源已经挂在模块状态链上,MFC 查找资源时会沿着这条链走,所以多数情况下不需要显式写AFX_MANAGE_STATE。但如果一个进程里加载了多个扩展 DLL,而且它们用了相同的资源 ID,查找顺序就取决于加载顺序,这时候在导出函数里显式切一下状态,结果才是确定的。
4.3 扩展 DLL 的初始化:DllMain、CDynLinkLibrary 与卸载
扩展 DLL 的DllMain是整份代码里最不能随手改的地方,向导生成的那几行基本就是正确形态:
// ShapeExt.cpp #include "pch.h" #include "framework.h" #include "shape_ext.h" static AFX_EXTENSION_MODULE g_shapeExtDll = { NULL, NULL }; extern "C" int APIENTRY DllMain(HINSTANCE hInstance, DWORD dwReason, LPVOID lpReserved) { UNREFERENCED_PARAMETER(lpReserved); if (dwReason == DLL_PROCESS_ATTACH) { // 初始化扩展模块状态,失败必须返回 0,让加载方拿到失败结果 if (!AfxInitExtensionModule(g_shapeExtDll, hInstance)) return 0; // 把本 DLL 的运行时类信息和资源挂到模块状态链上 new CDynLinkLibrary(g_shapeExtDll); } else if (dwReason == DLL_PROCESS_DETACH) { // 卸载前摘掉链上的节点,避免宿主退出时访问已释放的模块状态 AfxTermExtensionModule(g_shapeExtDll); } return 1; }AfxInitExtensionModule做的是保存HINSTANCE、初始化本模块的AFX_MODULE_STATE、准备运行时类列表。它失败通常意味着进程里的 MFC 没初始化好,或者这个 DLL 被一个非 MFC 程序加载了,返回值必须原样传出去,不能吞掉。
new CDynLinkLibrary(g_shapeExtDll)这一句没有保存返回值,看着像内存泄漏,其实是故意的:这个对象会把自己挂到模块状态链上,由 MFC 在卸载时统一管理。它的作用是把本 DLL 的CRuntimeClass注册进去,这样 EXE 侧用DECLARE_DYNAMIC的IsKindOf、动态创建、序列化才能跨模块工作。
DllMain里还有两条不能碰:不要在DLL_PROCESS_ATTACH里创建窗口、启动线程、等待锁;也不要在里面调用AfxWinInit或者任何依赖消息循环的东西。DllMain持有加载器锁,里面做重活很容易死锁,而且死锁现场往往表现为整个进程卡在启动界面,看不出跟 DLL 有关。
5. 避坑与排查:扩展 MFC DLL 最容易翻车的五个点
5.1 跨模块传 CString 与 delete:堆不匹配的断言
现象:Debug 版本运行到某处突然断言,提示堆损坏或者_CrtIsValidHeapPointer失败;Release 版本则表现为随机崩溃,崩溃点跟代码逻辑对不上。
原因:EXE 和 DLL 用了不同的 CRT。一边/MDd、一边/MTd,各自有独立的堆,EXE 里new出来的CString传到 DLL 里被析构,释放动作发生在另一个堆上。扩展 DLL 虽然强制共享 MFC,但如果 EXE 侧不小心选了静态 MFC,照样会出这个问题。
解决:整条链路的“使用 MFC”和“运行库”两项必须一致,扩展 DLL 只能是“在共享 DLL 中使用 MFC”加/MD或/MDd。接口设计上尽量不跨边界传CString、std::string、std::vector;必须传字符串就传const TCHAR*加长度,缓冲区由调用方持有。真要传对象,用工厂函数在 DLL 内创建、在 DLL 内销毁,EXE 侧只持指针。
5.2 对话框弹成了主程序的:资源句柄没切
现象:调用 DLL 导出的ShowParamDlg,弹出来的却是主程序里那个“关于”对话框;或者窗口能弹出来,但控件文字是空的、尺寸明显不对。
原因:常规 DLL 的导出函数没有写AFX_MANAGE_STATE(AfxGetStaticModuleState()),资源查找从宿主模块开始,恰好撞上了同 ID 的模板。扩展 DLL 里如果有多个 DLL 共用资源 ID,加载顺序不同也会出现同样的现象。
解决:常规 DLL 的每个导出函数第一行加状态切换;扩展 DLL 检查各模块的资源 ID 段是否重叠,IDD_、IDS_、IDR_按模块分段编号。排查时在DoModal之前打印AfxGetResourceHandle()的值,和AfxGetInstanceHandle()对比,不一致就说明状态没切对。
5.3 找不到导出函数:GetProcAddress 返回 NULL
现象:LoadLibrary成功,GetProcAddress返回NULL,GetLastError是127(找不到指定过程)。
原因:导出名被 C++ 修饰了,或者__stdcall加了@参数字节数后缀,而代码里按短名字去取;也可能是函数声明了但没加导出宏,压根没进导出表。
解决:dumpbin /exports看一眼真实导出名,然后决定用.def起别名还是按修饰名取。更稳妥的做法是统一用.def文件管理导出表,头文件里只留声明,导出名和实现解耦。每次加新接口后重新跑一遍dumpbin /exports,把导出列表和头文件对一遍,这个习惯能挡掉大部分“本地能跑、换台机器就找不到函数”的问题。
5.4 Debug 与 Release 混装:一加载就断言
现象:Debug 版 EXE 加载 Release 版 DLL,程序启动阶段就断言,提示_AFXDLL相关或者调试堆相关;反过来 Release 版 EXE 加载 Debug 版 DLL,可能直接崩溃在 MFC 初始化里。
原因:Debug 和 Release 的 MFC 库是两套,调试版带额外的堆管理字段和断言逻辑,对象布局都不一样。混装等于让两个二进制按不同结构体解释同一块内存。
解决:输出目录按配置分到bin\Debug和bin\Release,EXE 和 DLL 永远同配置同目录。发布前用dumpbin /dependents检查一遍,确认依赖的是mfc140u.dll而不是mfc140ud.dll。CI 或者打包脚本里加一条检查,比事后靠人肉记忆可靠。
5.5 退出时崩溃:对象比 DLL 活得久
现象:主程序关闭时崩溃,调用栈落在AfxTermExtensionModule或者某个CObject的析构里;显式FreeLibrary之后立刻访问 DLL 创建的对象,也会崩在同样位置。
原因:EXE 侧还持有 DLL 创建的CWnd、CObject派生对象或者回调指针,DLL 已经卸载,虚表指针指向的代码段不在了。另一种情况是DllMain的DLL_PROCESS_DETACH里做了依赖其他模块的清理动作,退出顺序不由自己控制。
解决:宿主在退出前显式销毁所有 DLL 创建的对象,把“销毁”放在窗口关闭消息里,而不是指望析构顺序。显式加载的场景,FreeLibrary之前先把所有函数指针置空、所有对象释放。扩展 DLL 的清理只做AfxTermExtensionModule,不要在里面 delete 全局对象,全局对象的生命周期交给 MFC 的模块状态管理。
6. 验收与答辩:用一张测试矩阵把 DLL 方案跑成可复现的结论
验收阶段最怕“我这儿能跑”。把下面这张矩阵走完,才算能交付:
| 用例 | 操作 | 预期结果 |
|---|---|---|
| 隐式链接启动 | 正常启动 EXE | 主界面出现,DLL 版本号打进日志 |
| 显式加载 | 菜单触发LoadLibrary | 返回非空句柄,GetProcAddress全部命中 |
本文还有配套的精品资源,点击获取