分类: C/C++
2008-04-23 21:56:50
C At Work 专栏...
持续化更新的视状态,在DLL中使用托管扩展
原著:Paul DiLascia
翻译:
下载源代码: (253KB)
原文出处:
这个月是我的专栏11周年纪念以及新标题:“C At Work”的开幕式。同时我们还新增了一个新的双月专栏:“”,这个专栏由我的伙伴,C 大师级人物之一—— Stan Lipman 主持。Stan 将更多地涉及纯粹的 C /CLI
语言方面(他会告诉你更多这方面的东西),而我将继续一如既往地编写 C 日常应用以及现实中的MFC(和目前的托管扩展)Windows
编程方面的文章。Stan 的新专栏描绘的是公认的当今最为活跃的主流编程语言之一的 Microsoft C (译者:即微软的 C ),将在这个栏目中为我们的读者提供更多的 C 内容。
我选择“C At Work”这个标题是因为它有两层含义:第一个意思是“工作中的 C ”(C on-the-job),指在工作中使用
C 的人们。而对我来说的另一个含义,也是更重要的含义是“让 C 工作”(Putting C Work)——也就是说让 C
为你做事。这才是我的专栏主旨之所在,这一点是不会改变的。唯一正式的变化是在名字中不会再有“Q&A”,但我保留偶尔写一些我认为重要而且有趣和值得的不是直接来自读者提问的
C 主题和技术的权利。此外,我打算专栏还是保持 Q&A 风格,所以说不管怎样,尽管给我提问就是了。
我怀着极大的兴趣阅读了你在2004年11月关于如何持续化不同用户会话“打开”对话框视图状态的专栏文章,我觉得有一个问题。你的 CListViewShellWnd (对话框中的 m_wndListViewShell)在用户进入另一个文件夹时会被销毁。所以当你关闭对话框时,它不会存储当前的视图模式,而是用户进入另一个文件夹时所处的视图模式。用什么方法解决这个问题?
void CListViewShellWnd::OnDestroy() { m_lastViewMode = GetViewMode(); // 记住当前视图模式 }
当对话框被销毁时,其对象(CPersistOpenDlg)便在构造函数中将 m_lastViewMode 写入用户配置文件。过程就是如此——它是一种做这类事情的常规方式。但是正像 John 发现的那样,“打开”对话框在用户关闭它时并没有销毁外壳视图;它每次都是在用户转到另一个文件夹时销毁的。要改变文件清单的显示,唯一的方法是去全部从头再建立一次。(现在你知道为什么“打开”对话框后,进入另一个文件夹是如此之慢了)。为了了解文件夹视图的销毁,你只要添加一个 TRACE 即可:
void CListViewShellWnd::OnDestroy() { TRACE(_T("CListViewShellWnd::OnDestroy\n")); ... }
现在运行“打开”对话框并切换文件夹。已经足够了,看一下文件夹视图便知了。CListViewShellWnd
忠实地保存了其视图模式,但当用户改变文件夹时,“打开”对话框销毁了它的 SHELLDLL_DefView,从而导致 MFC (在 CWnd::OnNcDestroy
中)对之进行自动反子类化(unsubclass)并将 m_hWnd 置为NULL。此后“打开”对话框创建新的 SHELLDLL_DefView,然而此时 CPersistOpenDlg
再也看不到这个 SHELLDLL_DefView,所以当 CPersistOpenDlg
将视图模式写入用户配置文件时,它写入的是用户改变文件夹之前的老数据。
幸运的是,这个 Bug 不属于那种难以再现的Bug。修复 CPersistOpenDlg 也不难,稍微想一想便可以解决。显而易见的事情是必须捕获 OnFolderChange
和重新子类化文件夹视图。重新进行子类化是正确的思路,但为什么要在 OnFolderChange 里做呢?不管什么时候,只要你在 Windows
中发现意想不到的事情,扪心自问一下:还有什么地方会出错?如果“打开对话框”在用户切换文件夹时销毁其自身文件夹视图,那么你怎么知道在其它时候不会销毁呢?比如在万圣节的午夜钟声敲响时,或者
Red Sox 在月蚀期间赢得世界棒球大赛的时候?为了找到问题的解决办法,我们要深入分析问题的根源,而不是只看表面现象。
CPersistOpenDlg 已经有一个私有消息 MYWM_POSTINIT 来初始化对话框。当对话框第一次启动时,CPersistOpenDlg
在 OnInitDialog 中将该消息发(POST)给自己,处理例程子类化该文件夹视图。当文件夹视图被销毁时,我只要再发一次 MYWM_POSTINIT
即可:
void CListViewShellWnd::OnDestroy() { m_lastViewMode = GetViewMode(); // 与以前一样,没有变化 m_pDialog->PostMessage(MYWM_POSTINIT); // 重新发送初始化消息 }
现在,如果“打开”对话框处于任何原因决定要销毁其文件夹视图(切换文件夹或者Red Sox),CListViewShellWnd
都会发送初始化消息,从而使得我的对话框重新子类化此新的文件夹视图。很聪明,是不是?这里是 Post MYWM_POSTINIT 消息,而不是
Send,这一点很重要,所以“打开”对话框可以在 CPersistOpenDlg::OnPostInit 子类化它之前创建新的文件夹视图。之所以在 CListViewShellWnd::OnDestroy
中修复此问题,是要保证能够抓住所有“打开”对话框可能销毁其文件夹视图的地方,并且还有一个好处是不需要任何新的事件处理例程。绝对不要画蛇添足,以免遭受隐晦
bug 的困扰。
聪明的读者可能已经注意到我在代码中加了一个数据成员,m_pDialog,它指向“父”CPersistOpenDlg 对象。向视图的父窗口(GetParent)发
YUWM_POSTINIT 消息是较为干净的做法(因为它不需要添加数据成员),但在这里行不通,因为在此情况很特殊,CPersistOpenDlg
实际上是真正对话框的子窗口。所以我加了一个在构造函数中初始化的回头指针(back pointer)。
我还必须对 CPersistOpenDlg::OnPostInit
作一下些细微的修改。原来的处理程序不仅子类化文件夹视图,而且还初始化用户配置文件中的视图模式。对话框第一次启动时我是需要这样做的——但我不想每次用户切换文件夹时将视图模式重置成保存的状态。所以我需要一个方法来区分第一次和后继的初始化。既然我没有将
WPARAM 用于其它目的,那就用它吧。下面来修改代码,OnInitDialog 在 WPARAM 为 TRUE 时,则发送
MYWM_POSTINIT,而 CListViewShellWnd::OnDestroy 在 WPARAM 为 FALSE 时发送。新的
OnPostInit 程序代码参见 Figure 1。
最后,有些人可能会问——当“打开”对话框因为彻底关闭而销毁文件夹视图时会是一种什么情况呢?从
ListViewShellWnd::OnDestroy 里发出的 MYWM_POSTINIT 消息会怎么样呢?什么事都不会发生。CListViewShellWnd::OnDestroy
将消息发到一个空无的地方(void where),就像智慧之树倒在森林里一样,悄然无声,因为没有人倾听。整个消息队列随着对话框的销毁而销声匿迹。
我真是个好心人,重新编写了 CPersistOpenDlg 实现,修复了本文所描述的 bug。具体细节请下载源代码。
我有一个 MFC 动态链接库(DLL),我想用托管扩展来调用
.NET Framework 中的类。在编译时连接器报出一个警告 warning LNK4243:“DLL 包含用 /clr 编译的对象,不能用 /NOENTRY
链接;映像文件可能无法正常运行。”我不太明白其含义,因此将它忽略——可我的程序在运行的时候垮掉了。难道在 DLL 中不能使用托管扩展吗?
class Bobble { public: Bobble() { /* create */ } ~Bobble() { /* destroy */ } };
假设你的 Bobble DLL 定义了一个全局静态实例:
Bobble MyBobble;
MyBobble 是一个全局对象,所有的函数都可以使用它,就像 MFC 中的 theApp。不知何故,编译器现在必须安排应用程序调用 bobble.dll 中的任何函数之前先调用 Bobble 构造函数。这在 C 语言中是绝不会有的事,在 C 中静态初始化的唯一方法如下:
int GlobalVal=0;
也就是说,将原始数据类型初始化为一个常量值,编译器会进行自身管理。但现在的初始化需要调用运行时执行的函数,而不是在编译时。在 C 中,你甚至可以编写下面这样的代码通过某个函数来初始化一个整型:
UINT WM_MYFOOMSG = RegisterWindowMessage("MYFOOMSG");
那么谁来调用这些函数?何时调用?答案是:启动代码(startup code)在调用 DllMain 之前。你也许认为一切都是从 DllMain 开始的,但在此之前,C 运行时 DLL 启动代码调用了你的构造函数。如果你深入 crtdll.c 文件看看 CRT DLL 的初始化序列(这个文件位于\VS.NET\VC7\crt\src目录),你会发现下面的函数:
BOOL WINAPI _DllMainCRTStartup(...) { if ( /* DLL_PROCESS_ATTACH */ ) { _CRT_INIT(...); // initialize CRT including ctors DllMain(...); } ... }
我对所发生的重要细节进行了简化:_DllMainCRTStartup 调用另一个 crtdll 函数,_CRT_INIT,然后调用 DllMain。_CRT_INIT 初始化 C 运行时,然后调用你的所有静态对象的构造函数。_CRT_INIT 是如何知道调用哪个构造函数的呢?是编译器产生了一个列表。所以当应用程序调用你的 C DLL 时,其加载顺序如下:
如果你使用 MFC,你甚至都不知道 DllMain 的存在,因为 MFC 为你提供了这个入口。在 MFC 中,一切都是从 CWinApp::InitInstance
开始的。而且是唯一的一个入口,MFC 用它自己的 DllMain 实现对它的调用。
好了,现在让我们言归正传,讨论 .NET 和托管扩展。你的 MFC DLL 运行得很好,你甚至都不知道它还有一个 DllMain,现在你想调用框架。所以你
#include
为了回答这个问题,我得解释一下另一个迷惑人的问题。DllMain 运行于 DLL
生命期一个重要的节骨眼上,正好是在它的加载过程当中。你无法实施在 DllMain 中想做的任何事情。尤其是调用其它
DLLs,因为它们都还没有被加载。调用其它 Dlls 需要首先加载它们,而这种加载要通过整个 DLL 加载循环链实现,你的 DLL
此时正在加载过程中。你知道“死循环,堆栈溢出吗?”它甚至限制应用像 user32,shell32 这样的系统 DLLs 和 COM
对象。许多程序员沮丧地发现他们的 DLL 从 DllMain中调用 MessageBox 时崩溃,希望在调试期间显示一些信息。唯一个可以保证加载并从
DllMain 中安全调用的DLL是 kernel32.dll。(有关在 DllMain 中能做什么和不能做什么的更多信息参见
)。
根据以上的解释,你可能会猜测托管 DLL 哪里不对劲。只要你使用 /clr 开关,你的 DLL 就成了托管的 DLL。初始化一个托管 DLL
需要在 DllMain 期间运行不安全代码。编译器现在产生的是微软中间语言(MSIL),不是本地机器指令。只有微软的人确切知道内幕以及系统在遇到第一个
MSIL 指令时要调用多少个 DLLs。不管发生什么,你都能断定系统除了调用内核外,还有更多的调用。因此默认情况下,托管 DLLs 不会与 C
运行时库 msvcrt.lib 链接。它们没有 _DllMainCRTStartup,并且不会调用 DllMain——即便它存在。换句话说,为了防止在
DLL 初始化期间调用不安全代码,微软的人直接规定托管 DLLs 将是一个 /NOENTRY DLLs。/NOENTRY DLL 即是一个没有
入口点的 DLL。如果你的 DLL 本身不需要初始化或终止例程,它便不需要 DllMain,因此你可以使用 /NOENTRY 选项。这样的 DLLs
不能有需要初始化的静态对象;只能输出函数。
由于 MFC 到处都有需要初始化的不可或缺的静态对象。那是不是就意味着不能在混合模式的 DLL中使用 MFC
了呢?当然不是!微软的老大提供了一种方案。参见前面提到的“Converting Managed
Extensions”一文,稍微不同的是根据你是否使用 LoadLibrary 或输入库以及你是编写常规 DLL 还是 COM 对象。
既然你问的是关于 MFC 的问题,我就描述一下使用引入库的 C /MFC DLLs
该怎么做,这是一种更常见的情况。基本思路很简单:首先在链接选项中添加 /NOENTRY,将你的 DLL 建立成一个 NOENTRY
DLL。这样使得链接器不至于报出警告。为了初始化对象,你必须编写自己的
初始化/终止处理函数(initialization/termination)并输出它们。任何使用你的 DLL
的应用程序在调用DLL中的函数之前都必须先调用你的初始化函数,同样,在结束时必须调用终止函数。
感觉这是个很好的计划,但是如何实现初始化/终止函数呢?幸运的是微软的老大提供了一个文件,其中包含了这两个函数的实现,从而使事情变得简单化;你只要包含该文件并调用这两个函数即可:
#include <_vcclrit.h> BOOL __declspec(dllexport) MyDllInit() { return __crt_dll_initialize(); } BOOL__declspec(dllexport) MyDllTerm() { return __crt_dll_terminate(); }
其实并不难,是吗?如果你打开 _vcclrit.h 文件,看看过去那些粗糙的多线程处理代码,你会发现 __crt_dll_initialize 所做的工作无外乎调用 _DllMainCRTStartup(DLL_ PROCESS_ ATTACH)。同样,__crt_dll_terminate 则调用 _DllMainCRTStartup (DLL_PROCESS_DETACH)。如果你用 C 编写代码,你不会留意丢失的 Boolean 返回码,你可以写一个自动初始化类,用其构造函数/析构函数封装 __crt_dll_xxx 调用,因此,应用程序只要在全局范围的某个地方创建一个自动初始化类对象的实例即可:
CMyLibInit initlib; // in main app
注意 CMyLibInit 只能适用于某个 exe 使用 DLL
的情况;如果你的客户端是另外一个DLL,你试图想在该DLL的全局范围内实例化 CMyLibInit 的话,在加载器锁定的情况下,__crt_dll_initialize
会被再次调用,从而潜在地触发同样的你正试图避免的 DLL-死锁问题。为了令从另外一个本地 DLL 初始化混合
DLL,你必须找到一个合适的地方来调用混合 DLL 的初始化/终止函数——例如,如果本地DLL是 COM 对象,那么可以在 DllGetClassObject/DllCanUnloadNow
中调用。否则你还需要从本地DLL导出另外一层初始化/终止函数,而本地DLL则调用构造函数或者加载器锁定情况下的 DllMain。
Figure 3 展示了我写的一个使用 CMyLibInit 的 DLL。ManLib 导出一个函数:FormatRunningProcesses,它调用
.NET 框架中的进程类,输出一个以 CString 格式化的运行进程清单,测试程序调用 FormatRunningProcesses
并在消息框中显示此清单,参见 Figure 4:
Figure 4 LibTest
我用 TRACE 诊断机制对 ManLib 进行了跟踪以帮助你了解哪个函数获得调用。Figure 5
是例子程序运行画面,显示了事件顺序。你可以将此输出与
Figure 3 中的 TRACE 输出进行比较以更好地理解调用顺序。如果你自己实现类似 CManLibInit
的机制,记住:在调用 __crt_dll_initialize 之前 (或者在调用 __crt_dll_terminate
之后)不能做任何事情。我刚开始写 ManLib 时,不加思索地将 TRACE 放在最前面,就像下面这样:
CManLibInit::CManLibInit() { TRACE(...); // Oops! __crt_dll_initialize(); }
结果我的程序崩溃了,原因是 MFC 的跟踪机制还没有被初始化。而这正是 __crt_dll_initialize 要做的事情。
Figure 5 TraceWin
还有一件事情需要提及。MSDN 库文章中描述的解决方案告诉你在项目设置菜单中将 msycrt.lib 添加到链接库中并将 __DllMainCRTStartup@12(托管名)设置成强制符号引用——“Force
Symbol References”。如果你一开始就用使用一个 MFC DLL,便不需要这些步骤,因为 MFC 已经链接你需要的东西。你只要将 /NOENTRY
添加到链接器选项,编写自己的初始化/终止函数并在应用程序中调用它们即可。相关文章参见“Visual
C 2005 中混合代码的初始化”。
编程愉快!