CLR 及其互操作工作原理






1.78/5 (8投票s)
对 CLR 及其在互操作中使用某些基类的解释。
引言
本文旨在解释 CLR 的一些功能,作为理解互操作基础的依据。本文将引用一些关键的系统符号组件,以解释 System.Runtime.InteropServices
命名空间如何包含用于使 C# 程序能够调用原始 C DLL 中包含的本机系统函数的方法。此功能称为 P/Invoke(平台调用)。
公共语言运行时 (CLR) 实际上是一个包含在 DLL 中的经典 COM 服务器。它作为 .NET Framework 的核心组件运行。CLR 实例是 Common Language Infrastructure (CLI) 规范的实现,它在有界公共类型系统的边界内执行代码。.NET 语言(如 C# 或 VB.NET)是一种编译器目标为 CLR 的语言,其代码发出元数据和 IL 代码。多个源代码文件的编译会生成一个名为可移植可执行文件 (Portable Executable) 的托管模块。程序集是部署单元,派生自组合的托管模块。此模块系统反映了 .NET Framework 的优势,因为可移植可执行文件基于 UNIX 创立的通用对象文件格式 (COFF)。
它可以在多个平台上与其它语言集成。与 Java 虚拟机类似,CLR 是一个虚拟执行环境。也就是说,CLR 实际上是一个系统程序,而 .NET Framework 的体系结构是一个底层基础设施,它为使用 CLR 的一些核心服务的严格托管编程平台提供了环境。然后,CLR 执行服务,其中包括(但不限于):使用基于标准 Windows 内存机制的垃圾回收 (GC) 内存堆进行自动内存管理;用于控制托管库和程序的发现、加载、布局和分析的元数据和模块约定;丰富的异常子系统,使程序能够以结构化的方式通信和响应故障;具有安全检查和代码访问安全性的类型验证。
话虽如此,CLR 与 .NET Framework 的某些基类结合使用时,允许托管代码调用定义在非托管代码中的非托管函数。此外,CLR 还允许与非托管代码和遗留代码进行互操作。此功能称为平台调用,或 P/Invoke。理解此服务可以帮助开发人员更深入地理解 .NET 和 COM 互操作。即时编译 (Just-In-Time compiling) 涉及将托管代码编译为非托管代码,这在某种意义上定义了 CLR 的物理性质:物理上,CLR 是包含复杂算法的 DLL 集合,这些算法通过调用各种 Win32 和 COM API 与 Windows 进行交互。
托管程序本质上是 Windows DLL,其代码在 Windows 加载可执行序列的 part 中引导至 CLR。将 CLR 加载到 Windows 进程中将说明几个 Windows DLL,其中每个 CLR 版本都随附两个 DLL:mscorsvr.dll 和 mscorwks.dll。这两个 DLL 都不是 .NET 程序集,因此不包含元数据和 IL 代码。执行一个或多个 .NET 可执行文件的每个进程都将包含其中之一。
Mscorsvr.dll 包含专门为多处理机优化的 CLR 版本(svr 表示服务器)。Mscorwks.dll 包含专门为单处理机优化的 CLR 版本(wks 表示工作站)。Mscorlib.dll 是一个程序集,是 .NET Framework 的另一个主要组件。作为一个程序集,它包含 .NET Framework 中每个基类型(类)的实现,因此被称为类库。
CLR 的加载是必须由进程本身处理的过程,涉及一个称为运行时主机 (runtime host) 的实体。因此,运行时主机(非托管应用程序)中必须存在一些非托管代码,因为 CLR 将处理托管代码。这些非托管代码负责加载 CLR、配置它,然后将进程内的当前线程转移到托管代码中。一旦加载了 CLR,托管运行时的非托管应用程序必须负责其他任务,例如处理未捕获的异常。这是一个重要功能,因为运行时主机可以捕获异常,但必须有一种方法来处理该异常。
有几种常用的运行时主机(在 .NET Framework 文档中称为非托管应用程序):Internet Explorer、控制台和 WinForms 主机。此处要强调的一点是,从一开始就需要互操作性才能加载 CLR。
一旦 CLR 已加载并配置为运行时执行,运行时主机就是启动已执行的程序集并将其加载到默认应用程序域 (Appdomain) 中的位置。类似于轻量级进程,应用程序域充当隔离单元,以防止与进程内的其他可执行文件发生冲突。Windows 进程上下文切换将涉及预取和映像加载,其中共享 DLL 会加载到进程的地址空间中。每个进程至少有一个执行线程,应用程序的代码和数据会被加载到内存映射文件中,以便应用程序执行。然后,进程是一个抽象单元,充当运行应用程序的资源的容器。进程内的执行线程是具体定义的代码指令。
之前我写过 CLR 是一个经典的 COM 服务器。微软将此组件设计为一个包含在 DLL 中的 COM 服务器,因此使用额外的底层代码编写,以遵守严格的标识规则并自行注册到系统注册表中。相同的内容会被加载到 Windows 进程中,该进程必须有非托管代码才能加载和配置它,以便将当前线程转移到托管代码。平台调用 (Platform Invoke) 是一项服务,它使托管代码能够调用 DLL 中实现的非托管函数。请记住,有系统 DLL 和应用程序 DLL。P/Invoke 会定位导出的函数并根据需要跨互操作边界进行参数(整数、字符串、结构、数组等)的封送处理。
平台调用概述
平台调用是一项服务,它使托管代码能够调用 DLL 中实现的非托管函数,例如 Win32 API 中的函数(注意 Windows 内部)。它会定位导出的函数,并根据需要跨互操作边界进行参数(整数、字符串、数组、结构等)的封送处理。允许您使用 P/Invoke 机制的类位于 System.Runtime.InteropServices
命名空间中。要从 C# 程序调用本机 DLL 中的函数,我们必须首先在 C# 类中声明该函数
- 该函数的声明必须用
System.Runtime.InteropServices
命名空间下的DllImport
属性标记,该属性指定 DLL 的名称。 - 在方法声明中使用
static
和extern
关键字。 - 为方法使用与 DLL 中相同的名称。
- 为每个参数命名。
P/Invoke 的一个基本用途是允许 .NET 组件与 Win32 API 进行交互。Win32 API 中一些常用的 DLL 是
DLL | 内容说明 |
---|---|
GDI32.dll |
图形设备接口函数,用于设备输出,例如绘图和字体管理。 |
Kernel32.dll |
用于内存管理和资源处理的低级操作系统函数。 |
User32.dll |
用于消息处理、计时器、菜单和通信的 Windows 管理函数。 |
如果您使用 Visual Studio 工具中包含的 dumpbin.exe 工具,可以识别和定位 DLL 中的函数
C:\Program Files\Visual Studio 8\bin> dumbin.exe -exports C:\Windows\System32\kernel32.dll
然后使用 '>' 操作符将此命令重定向到文本文件:> kernel.txt。您很可能会发现有 1027 个函数。其中之一是 Beep()
函数。以下是在 .NET Framework 控制台上应执行的代码
using System;
using System.Runtime.InteropServices;
class Program {
[DllImport("Kernel32.dll")]
public static extern bool Beep( uint iFreq, uint iDuration );
static void Main() {
bool b = Beep(100, 100);
}
}
在提示符下,键入 type con > Beep.cs
您将看到一个没有提示符的空格。
C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727> type con > Beep.cs
将上面的代码复制并粘贴到控制台空格中,然后按 Control-Z,再按 Enter 键。
现在编译
C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727> csc.exe /r:System.dll Beep.cs
C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727> Beep.exe
然后听系统蜂鸣声。
当平台调用调用非托管函数时,它会执行以下操作序列
- 定位函数所在的 DLL
- 将 DLL 加载到内存中
- 定位内存中函数的地址(包含在 DLL 中)
- 将参数压入堆栈,根据需要进行数据封送处理
- 将控制权转移给非托管函数
考虑 C# 中标准的“Hello, World!”
using System;
public class MainApp {
public static void Main() {
Console.WriteLine("Hello,World!");
}
}
如果遵循 System
类的分层结构,则代码更准确的写法是
using System;
public class MainApp {
static public void Main(System.String[]args ) {
System.Console.WriteLine("Hello, World!");
}
}
要使用 P/Invoke 将字符串传递给本机方法,您必须使用 System.String
类型。接受字符串的原生函数都存在两种编码版本:一种是 ANSI,后缀为 A;另一种是 UNICODE(或 2 字节 UNICODE 的变体,如 UTF-8),后缀为 W。因此,识别函数包括函数名和 DLL 名称。例如,指定 User32.dll 中包含的 MessageBox
函数会标识函数(MessageBox
)及其容器位置(User32.dll)。然而,P/Invoke 的典型用法是允许 .NET 组件与 Win32 API 进行交互。MessageBoxA
是 MessageBox
函数的 ANSI 入口点;MessageBoxW
是 Unicode 版本的入口点。注意:所有 COM 组件都要求为 Unicode,有时需要对编码进行翻译。为了在一字节和两字节编码之间进行选择,DllImport
属性提供了一个名为 CharSet
的参数,它可以接受 Auto
、Unicode
和 ANSI
的值。下面的示例显示了在调用 MessageBox()
函数时如何传递字符串
using System;
// must reference to gain access to the P/Invoke types
using System.Runtime.InteropServices;
class Program {
// the Win32 MessageBox() function resides in user32.dll
[DllImport("user32.dll", CharSet = CharSet.Auto)]
public static extern int MessageBox( System.IntPtr hWnd,
string text, string caption, uint type );
static void Main() {
// insert some managed data
MessageBox( System.IntPtr.Zero, "hello", "caption text", 0 );
}
}
重申一下,调用 C 风格 DLL 的过程首先是使用 C# 的 static
和 extern
关键字声明要调用的函数。请注意,在声明 C 函数原型时,您必须以托管数据类型的形式列出返回类型、函数名称和参数。这称为类型转换,是互操作性的前提。kernel32.dll 中 Beep()
的原型是
BOOL Beep(DWORDdwFreq, DWORD dwDuration);
要调用 Beep()
,您必须将 Win32 的 BOOL
类型转换为 .NET 的 bool
,并将 Win32 的 DWORD
转换为 .NET 类型:32 位双字、无符号整数、无符号长整数都是转换为 System32.Int32
.NET 类型的。C# 的等价类型是 uint
。下表用于使用 C# 进行类型转换
Win32 类型 | .NET 类型/ C# 等价类型 |
---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|