Jans的BLOG
Jans的BLOG

作者:Scott Currie
Microsoft Corporation

2003 年 3 月

摘要:透过 Visual C++® .NET 和 Visual C++ .NET 2003 编译程序建立且使用混合 DLL (原生和 Microsoft Intermediate Language (MSIL) DLL 的组合) 的应用程序,在某些状况下加载时可能会遇到死结的情形。本文详细说明问题所在,并阐述在下一版 Visual C++ .NET 编译程序和 Common Language Runtime (Runtime) 中预期的解决方案,并提供使用 Visual C++ .NET 2002 和 Visual C++ .NET 2003 工具组解决问题的相关信息。所有使用 Visual C++ .NET 编译程序选项建置 DLL 的开发人员都应该阅读此文章。(打印共 6 页)

目录

简介
混合 DLL
DllMain 限制
Managed 程序代码及 DllMain
Common Language Runtime 加载器及 DllMain
长期解决方案建议
使用现有工具进行准备
结论

简介

本文详细说明了混合 DLL 的加载问题。如果使用者在 Visual C++ .NET 2002 或 Visual C++ .NET 2003 使用混合 DLL,可能会因此问题而受影响。此份白皮书说明了 Managed 程序代码/机器码模型,并阐述混合 DLL 是在什么情况下产生的。本文除了叙述 DllMain 限制,即有关在 DLL 内部进入点合法作业的规则,还说明在 DIMain 内部执行的 Managed 程序代码可能违反 DIMain 限制的状况。本文另外还探讨了目前混合 DLL 加载算法所引起的特定问题,并阐述新的混合 DLL 加载算法,预期将在下一版的编译程序和 Runtime 推出,并将修正该加载问题。最后,本白皮书提供使用 Visual C++ .NET 2002 和 Visual C++ .NET 2003 工具来解决问题的相关信息。

混合 DLL

在探究混合 DLL 加载问题背后的技术性原因之前,必须先了解混合 DLL 和他们是在什么状况下产生的,这点很重要。Visual C++ .NET 编译程序可同时产生机器码 (例如 x86 机器指令) 和 Managed 程序代码 (MSIL)。该编译程序或使用者可以决定要在每一函式上产生哪一种程序代码类型。也就是说,单一 DLL 或 EXE 可同时包含部份机器码函式和部份 Managed 程序代码函式。

原生影像,不管是 DLL 或 EXE,指的是所有函式都以机器码实作的影像。这是 Visual C++ .NET 之前的 Visual C++ 编译程序唯一能够产生的影像类型。原生影像仍是 C++ 用户最常用的影像类型。它们是由 OS 加载器加载,并且直接在硬件上透过部份 OS 操作执行。原生影像仍继续采它们固定的方式操作。本文中的信息并不适用于原生影像。

纯 MSIL 影像,不管是 DLL 或 EXE,指的是所有函式都以 MSIL 实作的影像。Visual C# .NET 和 Visual Basic .NET 编译程序只能产生纯 MSIL 影像。再者,在某些情况下使用 Visual C++ .NET 编译程序有可能产生纯 MSIL 影像。有关使用 Visual C++ .NET 编译程序加上 Runtime 编译程序选项来建构纯影像的详细信息和指南,请参阅 Visual Studio .NET 2003 文件中的「Producing Verifiable Components with Managed Extensions for C++」。纯 MSIL 影像是由 Common Language Runtime 加载器所加载,且在 Runtime 之上执行。纯 MSIL 影像并不会受混合 DLL 加载问题的影响。本文中的信息并不适用于纯 MSIL 影像。

混合影像,不管是 DLL 或 EXE,指的是至少有一个函式是以机器码,而且至少有一个函式是以 MSIL 实作的影像。当使用 Visual C++ .NET 2002 和 Visual C++ .NET 2003 中的 Common Language Runtime 编译程序选项进行编译时,通常会产生混合影像。由于 OS 并不了解 Managed 程序代码,而 Runtime 也不了解机器码,因此 OS 和 Runtime 必须一起合作加载并执行混合影像。这种互操作会引致一些复杂的状况,而且也是混合 DLL 加载问题的源头。

注意 虽然混合 EXE 有某些限制,但它们仍不受混合 DLL 加载问题的影响。本文并不适用于混合 EXE。本文仅适用于混合 DLL。

DllMain 限制

DllMain 进入点函式旨在执行简单的初始化和终止工作。进行简单初始化和终止以外的操作可能会引起死结和循环相依。这项限制的起因是 DIMain 进入点函式执行时,会对 OS 加载器使用锁定机制。这个机制可确保 DLL 内部的程序代码在初始化 DLL 之前无法执行。再者,OS 加载器锁定可避免多线程或进程尝试同时载入 DLL,因为这个动作可能会破坏在加载过程中用到的全局数据结构。为了协助用户防止他们的 DIMain 函式发生问题,MSDN 链接库记录在 DLL 进入点内部安全执行的可行及不可行操作方式。如需详细信息,请参阅 DllMain (英文)。

下列是特别视为可在 DIMain 函数内部安全执行的操作:

· 初始化静态项目和全局项目。

· 呼叫 Kernel32.dll 中的函式。这项作业绝对安全,因为 Kernel32.dll 必须在 DIMain 被呼叫时加载。

· 建立同步对象,如重要的区段和 Mutex。如需详细信息,请参阅 Synchronization Objects (英文)。

· 存取线程区域储存区 (TLS)。

下列是特别被视为在大部份情况下于 DIMain 函数内部不安全的操作:

· 直接或间接呼叫 LoadLibraryLoadLibraryExFreeLibrary 函式。

· 呼叫登录函式。

· 呼叫位于 Kernel32.dll 以外的汇入函式。

· 与其他线程或进程通讯。

系统并未强制使用 DIlMain 函式的规则。OS 反而会尝试侦测死结或相关性循环,并在违反规则及发生问题时终止进程。通常,OS 不会侦测死结,而进程会停止响应。

Managed 程序代码及 DllMain

在 DIMain 内部执行 Manged 程序代码绝对是不安全的做法。这表示 DIMain 以 MSIL 实作并不安全,DIMain 直接或间接呼叫以 MSIL 实作的函式也不安全。如果 Managed 程序代码是在 DIMain 内部执行,很有可能发生死结。

有几种情况是 Runtime 必须执行未经 DIMain 允许的操作,以确保语意的正确无误。下列两个章节提供这些情况的特定范例。

存取未载入的型别

Managed 程序代码并不需要明确地加载模块或组件。Runtime 会私下进行控制。每当存取型别时,Runtime 都会进行检查以了解包含该类型的组件是否已加载执行的 AppDomain 中。如果已经加载,则会依指定采用该型别。如果未加载,则 Runtime 会在包含该预期的类型的 DLL 组件上自动呼叫 LoadLibrary。一旦组件经加载且初始化后,就可以使用预期的型别。在大部份情况下,这项自动化的链接库加载动作提供了蛮大的方便。然而在 DIMain 内部执行 Managed 程序代码时,自动化链接库加载功能很有可能会违反 DIMain 规则。

垃圾收集

Runtime 提供了一种垃圾收集的机制,可消除程序设计人员手动管理内存的累赘。优先垃圾收集在很多种状况下都会在载入混合 DLL 时导致死结的情形。例如,当储存 OS 加载器锁定的线程处于优先 GC 模式时,触发垃圾收集将会暂停该线程。如果有任何其他线程是在合作模式执行,而且尝试接受 OS 加载器锁定,则会导致死结的结果。再者,垃圾收集行程本身也会在某些情况下尝试取得 OS 加载器锁定,例如当垃圾收集行程在页面建立写入-监看保护,或监视 OS 内存加载时。如果在 DIMain 期间执行,这也肯定会导致死结。还有其他含有多垃圾收集行程线程的多处理器系统的案例也有 DIMain 期间的死结问题。一般来说,在 DIMain 期间或在储存 OS 加载器锁定的任何时候叫用垃圾收集行程都不安全。

Common Language Runtime 加载器及 DllMain

不幸地,在目前的实作下,上述的状况有时候可能会在载入混合 DLL 时发生在 DIMain 内部。混合影像为了强制 Managed 程序代码和机器码一并执行而采用的加载算法会制造一种状况,让未与 /noentry 选项链接的混合 DLL 可以在 OS 加载器锁定的情况下,于 DLL 进入点内部执行不安全的操作。再者,即使 DLL 已与 /noentry 选项链接,Common Language Runtime 1.0 和 1.1 版也可能发生死结。与 /noentry 选项链接的 DLL 在下一版的 Runtime 应该不会发生死结。

使用 Visual C++ .NET 编译程序来编译 DLL 时 (使用 /clr 而非 /noentry),一定会产生 Unmanaged DIMain 进入点。由编译程序所产生的此进入点将会呼叫 Runtime 启始程序代码 (Startup Code),这会加载 MSCOREE.dll 和 MSCORWks.dll。如果采用 C Runtime (这是常见情况),则 Runtime 启始程序代码接着会呼叫 DllMainCRTStartup。DllMainCRTStartup 会初始化用户程序代码和 CRT 静态项目和全局项目。之后,DllMainCRTStartup 会呼叫使用者提供的 DllMain,如果存在的话。在混合 DLL 中,使用者提供的 DIMain 通常会编译成 MSIL,这表示 Managed 程序代码将在加载器锁定的情况下执行,导致如本文「存取未加载的型别」和「垃圾收集」章节所述的死结可能性。即使使用者提供的 DllMain 以机器码实作 (例如在原始档中被 #pragma unmanaged 包围),Managed 程序代码仍确定会在此进程中执行,因为部份在加载程序期间所呼叫的 Stub 和 Thunk 是以 Managed 程序代码实作的。

因此,由于加载器在 Common Language Runtime 1.0 和 1.1 版操作的方式,使得死结虽然很少出现在练习中,却永远都有可能伴随混合 DLL 发生。最糟的是,刚好可在大部份系统上操作的混合 DLL 在系统承受负荷时、影像签署时 (因为安全性功能需要更多的 Managed 程序代码在组件加载期间执行)、系统安装拦截程序时、或 Runtime 的行为因 Service Pack 或新版本而改变时,特别可能会开始死结的状况。总而言之,这是所有混合 DLL 都必须处理的严重问题。

此外,Common Language Runtime 加载器 1.0 和 1.1 版的实作也可能遭遇死结状况,甚至在 /noentry 链接器选项已指定的情形下也一样,诸如当 Unmanaged DLL 汇出或 Unmanaged VTable Fixups (VTFixups) 属于混合 DLL 一部份的情况。这些问题应该会在下一版的 Runtime 中修正,但是目前并没有什么办法可以完全去除 Visual C++ .NET 2002 或 Visual C++ .NET 2003 中 Common Language Runtime 1.0 和 1.1 版上与 Unmanaged 汇出和 Unmanaged VTFixups 相关的死结风险。在下一版的 Runtime 执行相同的影像时应该不会再发生这些特定的死结风险。因此,Microsoft 建议除非遇到死结状况,否则没有必要将 Unmanaged 导出和 Unmanaged VTFixups 从影像中移除。

同样也请注意由于组件使用 .NET 组件签署技术进行签署时,会执行较大量的程序代码,所以签署混合 DLL 时,所有上述案例发生死结的机率也随之增加。

长期解决方案建议

不幸地,因 Common Language Runtime、CRT、Visual C++ 编译程序和链接器所需变更的幅度和深度关系,使得 Microsoft 无法完全在 Visual C++ .NET 2003 版中修正问题。在 Visual C++ .NET 2003 中修正此问题可能会导致其他核心 Managed 功能不稳定。因此,Visual C++ 小组针对大多数利用 Visual C++ .NET 2002 和 Visual C++ .NET 2003 建置混合 DLL 的情况,发展出修正此问题的解决方案,并且可在下一版的 Common Language Runtime 执行,不过需要程序设计人员变更部份程序代码。下段阐述处理所有混合 DLL 加载问题并预期实作在下一版的 Visual C++ .NET (Visual Studio .NET 2003 之后) 和下一版 Common Language Runtime (1.1 版之后) 的解决方案。

尤其是 Common Language Runtime 中已加入新的加载时间事件,可在一个模块加载应用程序域时发出讯号。这个新事件跟原生的 DLL_PROCESS_ATTACH 事件很类似。加载模块时,Common Language Runtime 会检查该模块在全局范围中是否有一个 .cctor 方法。全局 .cctor 是 Managed 模块初始化表达式C 这个初始化表达式是在原生 DIMain 之后 (换句话说,在加载器锁定之外),但在任何 Managed 程序代码执行或从该模块存取 Managed 数据之前执行的。模块 .cctor 的语意跟该些类别 .cctors 非常类似,而且在《ECMA C# and Common Language Infrastructure Standards》(英文) 中有所定义。

这种解决方案的目标在于分开 Managed 和原生初始化,只让必须在 OS 加载器锁定下执行的核心原生部份确实在该处执行。Managed 初始化和非核心原生初始化会在模块初始化表达式之内、加载器锁定之外,但在使用模块之前执行。请注意,模块 .cctor 会被带到 ECMA 委员会前进行标准化,即使 Visual C++ 将很有可能是唯一在后期 Visual Studio .NET 2003 版本支持它的 Microsoft 语言。

除了提供 Managed 模块初始化表达式机制,来修正新编译影像的加载器锁定问题之外,这个解决方案另外还提供一些检查工具,用以防止 Common Language Runtime 执行可能使用旧式工具建立的不安全影像。下一版的 Common Language Runtime 将有能力马上终止尝试在 OS 加载器锁定之内执行 Managed 程序代码的 DLL。这种模式可透过一个提供足够信息来修正问题的决定性失败来取代非决定性以及不安全的行为。如果应用程序开发人员使用 Visual C++ .NET 2003 工具无法处理该问题,或无法取得下一版的编译程序,会有一个全进程兼容性模式可用来允许不安全混合 DLL 的执行。兼容性和不安全终止模式均可透过应用程序的 XML 组态文件来启用。再者,需要执行不安全混合 DLL 的应用程序也可以使用应用程序的 XML 组态文件,来指定要在其上执行的 Runtime 版本 (例如 1.1 版)。

使用现有工具进行准备

Microsoft 准备了一篇知识库文件,完整说明为确保使用 Visual C++ .NET 2003 工具组建立的混合 DLL 可与修正的 Runtime 安全执行而必须采取的步骤。该知识库文件可至 http://support.microsoft.com/?id=814472 上浏览。简单来说,该解决方案需要程序设计人员使用 /noentry 来连结所有的混合 DLL,并在适当的时候手动初始化 DLL。并提供 Helper 函式来简化手动初始化的程序。请注意,知识库文件会阐述使用 Visual Studio .NET 2002 和 Visual Studio .NET 2003 工具建立混合 DLL 所需的步骤,而这在下一版的 Common Language Runtime 应该也能安全执行。在 1.0 和 1.1 版的 Common Language Runtime 上,即使完全遵守这些指示,仍然有小小的机会可能发生死结。更进一步的 KB 文件将在产品推出的时候发行,其中会描述因应一些其余死结可能情况的解决方法。如需详细信息,请参阅知识库文件。

结论

Visual C++ 和 Common Language Runtime 小组在决定重新面对此问题时,针对混合 (Managed 和原生) DLL 载入和初始化,做出了工程方面的选择。基于混合 DLL 加载问题的因素,这些选择造成一般混合 DLL 可靠性的负面影响。本文探讨了部份构成混合 DLL 加载问题的技术核心重点。Visual C++ .NET 2003 产品已经确认并记录此加载问题的适当解决方法。此解决方法将提高 1.0 和 1.1 版的 Common Language Runtime 中混合 DLL 的安全性。再者,此解决方法将使得混合 DLL 在下一版的 Common Language Runtime 上具备十足安全性 (例如,没有死结的风险)。如果无法使用 Visual C++ .NET 2002 和 Visual C++ .NET 2003 工具组来实作建议的解决方法,可能会在下一版的 Common Language Runtime 导致应用程序失败。下一版的 Visual C++ .NET 和 Common Language Runtime 预计会包含此问题的完整修正档,而且一般来说将不需要进行任何使用者程序代码变更。

posted on 2007-07-18 10:37  Jans  阅读(675)  评论(0编辑  收藏  举报