本章介绍如何与本机(非托管)动态链接库 (DLL) 和组件对象模型 (COM) 组件集成。除非另有说明,否则本章中提到的类型存在于 System 或 System.Runtime.InteropServices 命名空间中。
调用本机 DLL是缩写,允许您访问非托管DLL(Unix上的)中的函数,结构和回调。
例如,考虑在 Windows DLL 中定义的 MessageBox 函数,如下所示:
int MessageBox (HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType);
可以通过声明同名的静态方法、应用 extern 关键字并添加 DllImport 属性来直接调用此函数:
using System;using System.Runtime.InteropServices;MessageBox (IntPtr.Zero, "Please do not press this again.", "Attention", 0);[DllImport("user32.dll")]static extern int MessageBox (IntPtr hWnd, string text, string caption, int type);
System.Windows 和 System.Windows.Forms 命名空间中的 MessageBox 类本身调用类似的非托管方法。
下面是一个 Ubuntu Linux 的 DllImport 示例:
Console.WriteLine ($"User ID: {getuid()}");[DllImport("libc")]static extern uint getuid();
CLR 包括一个封送拆收器,该封送拆收器知道如何在 .NET 类型和非托管类型之间转换参数和返回值。在 Windows 示例中,int 参数直接转换为函数所需的四字节整数,字符串参数转换为以 null 结尾的 Unicode 字符数组(以 UTF-16 编码)。IntPtr 是一种结构,旨在封装非托管句柄;它在 32 位平台上为 32 位宽,在 64 位平台上为 64 位宽。类似的翻译发生在Unix上。(从 C# 9 开始,您还可以使用 nint 类型,该类型映射到 IntPtr 。
类型和参数封送处理封送处理常见类型在非托管端,可以有多种方法来表示给定的数据类型。例如,字符串可以包含单字节 ANSI 字符或 UTF-16 Unicode 字符,并且可以是长度前缀、以 null 结尾或固定长度。使用 MarshalAs 属性,可以向 CLR 封送拆收器指定使用的变体,以便它可以提供正确的转换。下面是一个示例:
[DllImport("...")]static extern int Foo ( [MarshalAs (UnmanagedType.LPStr)] string s );
非托管类型枚举包括封送拆收器理解的所有 Win32 和 COM 类型。在这种情况下,封送拆收器被告知转换为LPStr,这是一个以空结尾的单字节ANSI字符串。
在 .NET 端,您还可以选择要使用的数据类型。例如,非托管句柄可以映射到 IntPtr 、int、uint、long 或 ulong。
注意大多数非托管句柄封装地址或指针,因此必须映射到 IntPtr 才能与 32 位和 64 位操作系统兼容。一个典型的例子是HWND。
通常,使用 Win32 和 POSIX 函数时,您会遇到一个整数参数,该参数接受一组常量,这些常量在C++头文件(如 )中定义。无需将这些常量定义为简单的 C# 常量,而是可以在枚举中定义它们。使用枚举可以使代码更整洁,并提高静态类型的安全性。我们在中提供了一个示例。
注意安装 Visual Studio Microsoft时,请确保安装C++头文件,即使您在“C++”类别中未选择任何其他文件也是如此。这是定义所有本机 Win32 常量的位置。然后,您可以通过在 Visual Studio 程序目录中搜索 来找到所有头文件。
在Unix上,POSIX标准定义了常量的名称,但是符合POSIX的Unix系统的单个实现可能会为这些常量分配不同的数值。您必须为所选操作系统使用正确的数值。同样,POSIX 为互操作调用中使用的结构定义了标准。结构中字段的顺序不是标准的固定的,Unix 实现可能会添加其他字段。定义函数和类型的C++头文件通常安装在 include 或 中。
将字符串从非托管代码接收回 .NET 需要进行一些内存管理。如果使用 StringBuilder 而不是字符串声明外部方法,则封送拆收器会自动执行此工作,如下所示:
StringBuilder s = new StringBuilder (256);GetWindowsDirectory (s, 256);Console.WriteLine (s);[DllImport("kernel32.dll")]static extern int GetWindowsDirectory (StringBuilder sb, int maxChars);
在Unix上,它的工作方式类似。以下调用 getcwd 以返回当前:
var sb = new StringBuilder (256);Console.WriteLine (getcwd (sb, sb.Capacity));[DllImport("libc")]static extern string getcwd (StringBuilder buf, int size);
尽管 StringBuilder 使用起来很方便,但它的效率有些低下,因为 CLR 必须执行额外的内存分配和复制。在性能热点中,可以通过改用 char[] 来避免此开销:
[DllImport ("kernel32.dll", CharSet = CharSet.Unicode)]static extern int GetWindowsDirectory (char[] buffer, int maxChars);
请注意,必须在 DllImport 属性中指定字符集。您还必须在调用函数后将输出字符串修剪为长度。您可以实现此目的,同时使用阵列池最小化内存分配(参见中的),如下所示:
string GetWindowsDirectory(){ var array = ArrayPool<char>.Shared.Rent (256); try { int length = GetWindowsDirectory (array, 256); return new string (array, 0, length).ToString(); } finally { ArrayPool<char>.Shared.Return (array); }}
(当然,这个例子是人为的,因为您可以通过内置的 Environment.GetFolderPath 方法获取 Windows 目录。
注意如果您不确定如何调用特定的 Win32 或 Unix 方法,如果您搜索方法名称和 ,您通常会在互联网上找到一个示例。对于Windows,站点 是一个旨在记录所有Win32签名的wiki。
封送处理类和结构有时,您需要将结构传递给非托管方法。例如,Win32 API 中的 GetSystemTime 定义如下:
void GetSystemTime (LPSYSTEMTIME lpSystemTime);
LPSYSTEMTIME符合以下C结构:
typedef struct _SYSTEMTIME { WORD wYear; WORD wMonth; WORD wDayOfWeek; WORD wDay; WORD wHour; WORD wMinute; WORD wSecond; WORD wMilliseconds;} SYSTEMTIME, *PSYSTEMTIME;
要调用 GetSystemTime ,我们必须定义一个与 C 结构匹配的 .NET 类或结构:
using System;using System.Runtime.InteropServices;[StructLayout(LayoutKind.Sequential)]class SystemTime{ public ushort Year; public ushort Month; public ushort DayOfWeek; public ushort Day; public ushort Hour; public ushort Minute; public ushort Second; public ushort Milliseconds;}
属性指示封送拆收器如何将每个字段映射到其非托管对应项。LayoutKind.Sequential 意味着我们希望字段在边界上按顺序对齐(您很快就会看到这意味着什么),就像它们在 C 结构中一样。此处的字段名称无关紧要;字段的顺序很重要。
现在我们可以调用 GetSystemTime:
SystemTime t = new SystemTime();GetSystemTime (t);Console.WriteLine (t.Year);[DllImport("kernel32.dll")]static extern void GetSystemTime (SystemTime t);
同样,在Unix上:
Console.WriteLine (GetSystemTime());static DateTime GetSystemTime(){ DateTime startOfUnixTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc); Timespec tp = new Timespec(); int success = clock_gettime (0, ref tp); if (success != 0) throw new Exception ("Error checking the time."); return startOfUnixTime.AddSeconds (tp_sec).ToLocalTime(); }[DllImport("libc")]static extern int clock_gettime (int clk_id, ref Timespec tp);[StructLayout(LayoutKind.Sequential)]struct Timespec{ public long tv_sec; /* seconds */ public long tv_nsec; /* nanoseconds */}
在 C 和 C# 中,对象中的字段位于距离该对象地址 个字节的位置。不同之处在于,在 C# 程序中,CLR 通过使用字段标记查找此偏移量来查找此偏移量;C 字段名称直接编译为偏移量。例如,在 C 中,wDay 只是一个标记,用于表示 SystemTime 实例地址加上 24 个字节的任何内容。
为了提高访问速度,每个字段都放置在字段大小的倍数的偏移处。但是,该乘数限制为最大 x 字节,其中 是。在当前实现中,默认包大小为 8 个字节,因此由一个字节后跟一个(8 字节)长的结构占用 16 个字节,并且该字节后面的 7 个字节被浪费。您可以通过 StructLayout 属性的 Pack 属性指定包装大小来减少或消除这种浪费:这将使字段与指定的倍数的偏移对齐。因此,当包大小为 1 时,刚刚描述的结构将仅占用 9 个字节。您可以指定 1、2、4、8 或 16 字节的包大小。
StructLayout 属性还允许您指定显式字段偏移量(请参阅)。
进出封送在前面的示例中,我们将 SystemTime 实现为一个类。我们可以选择一个结构 — 前提是 GetSystemTime 是使用 ref 或 out 参数声明的:
[DllImport("kernel32.dll")]static extern void GetSystemTime (out SystemTime t);
在大多数情况下,C# 的方向参数语义与外部方法的工作方式相同。按值传递参数被复制到中,C# ref 参数被复制入/复制出,C# 输出参数被复制出来。但是,具有特殊转换的类型有一些例外。例如,数组类和 StringBuilder 类在从函数中出来时需要复制,因此它们是输入/输出。有时,使用 In 和 Out 属性重写此行为很有用。例如,如果一个数组应该是只读的,则 in 修饰符指示只复制进入函数的数组,而不是从函数中出来的数组:
static extern void Foo ( [In] int[] array);调用约定
非托管方法通过堆栈和(可选)CPU 寄存器接收参数和返回值。因为有不止一种方法可以实现这一点,所以出现了许多不同的协议。这些协议称为。
CLR 目前支持三种调用约定:StdCall、Cdeccl 和 ThisCall。
默认情况下,CLR 使用调用约定(该平台的标准约定)。在Windows上,它是StdCall,在Linux x86上,它是Cdecl。
如果非托管方法不遵循此默认值,则可以显式声明其调用约定,如下所示:
[DllImport ("MyLib.dll", CallingConvention=CallingConvention.Cdecl)]static extern void SomeFunc (...)
有点误导性的命名CallingConvention.WinApi指的是平台默认值。
来自非托管代码的回调C# 还允许外部函数通过回调调用 C# 代码。有两种方法可以完成回调:
通过函数指针(来自 C# 9)通过代表为了说明这一点,我们将在 中调用以下 Windows 函数,该函数枚举所有顶级窗口句柄:
BOOL EnumWindows (WNDENUMPROC lpEnumFunc, LPARAM lParam);
WNDENUMPROC 是一个回调,它按顺序使用每个窗口的句柄触发(或直到回调返回 false)。以下是它的定义:
BOOL CALLBACK EnumWindowsProc (HWND hwnd, LPARAM lParam);使用函数指针的回调
从 C# 9 开始,当回调是静态方法时,最简单且性能最高的选项是使用。在 WNDENUMPROC 回调的情况下,我们可以使用以下函数指针:
delegate*<IntPtr, IntPtr, bool>
这表示一个接受两个 IntPtr 参数并返回布尔值的函数。然后,您可以使用 & 运算符为其提供静态方法:
using System;using System.Runtime.InteropServices;unsafe{ EnumWindows (&PrintWindow, IntPtr.Zero); [DllImport ("user32.dll")] static extern int EnumWindows ( delegate*<IntPtr, IntPtr, bool> hWnd, IntPtr lParam); static bool PrintWindow (IntPtr hWnd, IntPtr lParam) { Console.WriteLine (hWnd.ToInt64()); return true; }}
对于函数指针,回调必须是静态方法(或静态本地函数,如本例所示)。
仅限非托管呼叫者通过将非托管关键字应用于函数指针声明,并将 [UnmanagedCallersOnly] 属性应用于回调方法,可以提高性能:
using System;using System.Runtime.CompilerServices;using System.Runtime.InteropServices;unsafe{ EnumWindows (&PrintWindow, IntPtr.Zero); [DllImport ("user32.dll")] static extern int EnumWindows ( delegate* unmanaged <IntPtr, IntPtr, byte> hWnd, IntPtr lParam); [UnmanagedCallersOnly] static byte PrintWindow (IntPtr hWnd, IntPtr lParam) { Console.WriteLine (hWnd.ToInt64()); return 1; }}
此属性标记 PrintWindow 方法,以便从非托管代码调用该方法,从而允许运行时采用快捷方式。请注意,我们还将方法的返回类型从布尔值更改为字节:这是因为应用 [UnmanagedCallersOnly] 的方法只能在签名中使用 值类型。可直接封送类型是不需要任何特殊封送逻辑的类型,因为它们在托管和非托管世界中的表示方式相同。其中包括基元整型、浮点型、双精度型和仅包含可拼接类型的结构。char 类型也是可 blitable,如果结构的一部分具有指定 CharSet.Unicode 的 StructLayout 属性:
[StructLayout (LayoutKind.Sequential, CharSet=CharSet.Unicode)]非默认调用约定
默认情况下,编译器假定非托管回调遵循平台默认调用约定。如果不是这样,您可以通过 [UnmanagedCallersOnly] 属性的 CallConvs 参数显式声明其调用约定:
[UnmanagedCallersOnly (CallConvs = new[] { typeof (CallConvStdcall) })]static byte PrintWindow (IntPtr hWnd, IntPtr lParam) ...
还必须通过在非托管关键字后插入特殊修饰符来更新函数指针类型:
delegate* unmanaged[Stdcall] <IntPtr, IntPtr, byte> hWnd, IntPtr lParam);注意
编译器允许您将任何标识符(如 XYZ)放在方括号内,只要存在调用的 .NET 类型(运行时可以理解该类型,并且与您在应用 [UnmanagedCallersOnly] 属性时指定的内容匹配)。这使Microsoft将来更容易添加新的调用约定。CallConvXYZ
在本例中,我们指定了 StdCall,这是 Windows 的平台默认值(Cdecl 是 Linux x86 的默认值)。以下是当前支持的所有选项:
名字 | 非托管修饰符 | 支撑类型 |
标准呼叫 | 非托管[标准呼叫] | CallConvStdcall |
中环 | 非托管[Cdecl] | CallConvCdecl |
此调用 | 非托管[此调用] | CallConvThiscall |
非托管回调也可以通过委托完成。此方法适用于所有版本的 C#,并允许引用实例的回调。
若要继续,请首先声明一个具有与回调匹配的签名的委托类型。然后,可以将委托实例传递给外部方法:
class CallbackFun{ delegate bool EnumWindowsCallback (IntPtr hWnd, IntPtr lParam); [DllImport("user32.dll")] static extern int EnumWindows (EnumWindowsCallback hWnd, IntPtr lParam); static bool PrintWindow (IntPtr hWnd, IntPtr lParam) { Console.WriteLine (hWnd.ToInt64()); return true; } static readonly EnumWindowsCallback printWindowFunc = PrintWindow; static void Main() => EnumWindows (printWindowFunc, IntPtr.Zero);}
具有讽刺意味的是,将委托用于非托管回调是不安全的,因为很容易陷入允许在委托实例超出范围后发生回调的陷阱(此时委托有资格进行垃圾回收)。这可能会导致最糟糕的运行时异常 - 没有有用的堆栈跟踪。对于静态方法回调,可以通过将委托实例分配给只读静态字段来避免这种情况(如本例所示)。对于实例方法回调,此模式无济于事,因此您必须仔细编码,以确保在任何潜在回调期间至少维护一个对委托实例的引用。即便如此,如果非托管端存在错误(即在您告诉它不要调用回调后),您可能仍然需要处理无法追踪的异常。解决方法是为每个非托管函数定义唯一的委托类型:这有助于诊断,因为委托类型在异常中报告。
您可以通过将 [UnmanagedFunctionPointer] 属性应用于委托,从平台默认值更改回调的调用约定:
[UnmanagedFunctionPointer (CallingConvention.Cdecl)]delegate void MyCallback (int foo, short bar);模拟 C 联合
结构中的每个字段都有足够的空间来存储其数据。考虑一个包含一个 int 和一个字符的结构。int 可能从偏移量 0 开始,并保证至少四个字节。因此,字符将从至少 4 的偏移量开始。如果由于某种原因,char 从偏移量 2 开始,如果您为 char 分配了一个值,您将更改 int 的值。听起来像是混乱,不是吗?奇怪的是,C 语言支持一种称为的结构的变体,它正是这样做的。可以在 C# 中使用 LayoutKind.Explicit 和 FieldOffset 属性来模拟这种情况。
想出一个有用的情况可能具有挑战性。但是,假设您想在外部合成器上演奏音符。Windows Multimedia API 提供了一个通过 MIDI 协议执行此操作的功能:
[DllImport ("winmm.dll")]public static extern uint midiOutShortMsg (IntPtr handle, uint message);
第二个参数 消息 ,描述了要演奏的音符。问题在于构造这个 32 位无符号整数:它在内部划分为字节,代表 MIDI 通道、音符和打击速度。一种解决方案是通过按位<<、>> 和 |运算符将这些字节与 32 位“打包”消息相互转换。但是,更简单的是定义具有显式的结构:
[StructLayout (LayoutKind.Explicit)]public struct NoteMessage{ [FieldOffset(0)] public uint PackedMsg; // 4 bytes long [FieldOffset(0)] public byte Channel; // FieldOffset also at 0 [FieldOffset(1)] public byte Note; [FieldOffset(2)] public byte Velocity;}
通道、注释和速度字段故意与 32 位打包消息重叠。这允许您使用其中任何一个进行读取和写入。无需计算即可使其他字段保持同步:
NoteMessage n = new NoteMessage();Console.WriteLine (n.PackedMsg); // 0n.Channel = 10;n.Note = 100;n.Velocity = 50;Console.WriteLine (n.PackedMsg); // 3302410n.PackedMsg = 3328010;Console.WriteLine (n.Note); // 200共享内存
内存映射文件或是 Windows 中的一项功能,它允许同一台计算机上的多个进程共享数据。共享内存速度极快,与管道不同,它提供对共享数据的访问。我们在中看到了如何使用 MemoryMappedFile 类来访问内存映射文件;绕过这一点并直接调用 Win32 方法是演示 P/Invoke 的好方法。
Win32 CreateFileMapping 函数分配共享内存。您告诉它您需要多少字节以及用于标识共享的名称。然后,另一个应用程序可以通过调用具有相同名称的 OpenFileMapping 来订阅此内存。这两种方法都返回一个,您可以通过调用 MapViewOfFile 将其转换为指针。
下面是一个封装对共享内存的访问的类:
using System;using System.Runtime.InteropServices;using System.ComponentModel;public sealed class SharedMem : IDisposable{ // Here we're using enums because they're safer than constants enum FileProtection : uint // constants from winnt.h { ReadOnly = 2, ReadWrite = 4 } enum FileRights : uint // constants from WinBASE.h { Read = 4, Write = 2, ReadWrite = Read + Write } static readonly IntPtr NoFileHandle = new IntPtr (-1); [DllImport ("kernel32.dll", SetLastError = true)] static extern IntPtr CreateFileMapping (IntPtr hFile, int lpAttributes, FileProtection flProtect, uint dwMaximumSizeHigh, uint dwMaximumSizeLow, string lpName); [DllImport ("kernel32.dll", SetLastError=true)] static extern IntPtr OpenFileMapping (FileRights dwDesiredAccess, bool bInheritHandle, string lpName); [DllImport ("kernel32.dll", SetLastError = true)] static extern IntPtr MapViewOfFile (IntPtr hFileMappingObject, FileRights dwDesiredAccess, uint dwFileOffsetHigh, uint dwFileOffsetLow, uint dwNumberOfBytesToMap); [DllImport ("Kernel32.dll", SetLastError = true)] static extern bool UnmapViewOfFile (IntPtr map); [DllImport ("kernel32.dll", SetLastError = true)] static extern int CloseHandle (IntPtr hObject); IntPtr fileHandle, fileMap; public IntPtr Root => fileMap; public SharedMem (string name, bool existing, uint sizeInBytes) { if (existing) fileHandle = OpenFileMapping (FileRights.ReadWrite, false, name); else fileHandle = CreateFileMapping (NoFileHandle, 0, FileProtection.ReadWrite, 0, sizeInBytes, name); if (fileHandle == IntPtr.Zero) throw new Win32Exception(); // Obtain a read/write map for the entire file fileMap = MapViewOfFile (fileHandle, FileRights.ReadWrite, 0, 0, 0); if (fileMap == IntPtr.Zero) throw new Win32Exception(); } public void Dispose() { if (fileMap != IntPtr.Zero) UnmapViewOfFile (fileMap); if (fileHandle != IntPtr.Zero) CloseHandle (fileHandle); fileMap = fileHandle = IntPtr.Zero; }}
在此示例中,我们在使用 SetLastError 协议发出错误代码的 DllImport 方法上设置 SetLastError=true。这可确保在引发该异常时填充 Win32Exception 的错误详细信息。(它还允许您通过调用 Marshal.GetLastWin32Error 来显式查询错误。
为了演示这个类,我们需要运行两个应用程序。第一个创建共享内存,如下所示:
using (SharedMem sm = new SharedMem ("MyShare", false, 1000)){ IntPtr root = sm.Root; // I have shared memory! Console.ReadLine(); // Here's where we start a second app...}
第二个应用程序通过构造同名的 SharedMem 对象来订阅共享内存,现有参数为 true:
using (SharedMem sm = new SharedMem ("MyShare", true, 1000)){ IntPtr root = sm.Root; // I have the same shared memory! // ...}
最终结果是每个程序都有一个 IntPtr,一个指向同一非托管内存的指针。这两个应用程序现在需要以某种方式通过这个公共指针读取和写入内存。一种方法是编写一个封装所有共享数据的类,然后使用 UnmanagedMemoryStream 将数据序列化(和反序列化)到非托管内存。但是,如果有大量数据,这是低效的。想象一下,如果共享内存类有一兆字节的数据,并且只需要更新一个整数。更好的方法是将共享数据构造定义为结构,然后将其直接映射到共享内存中。我们将在下一节中讨论这个问题。
将结构映射到非托管内存可以直接将具有顺序或显式结构布局的结构映射到非托管内存。请考虑以下结构:
[StructLayout (LayoutKind.Sequential)]unsafe struct MySharedData{ public int Value; public char Letter; public fixed float Numbers [50];}
固定指令允许我们内联定义固定长度的值类型数组,这就是将我们带入不安全领域的原因。此结构中的空间以内联方式分配给 50 个浮点数。与标准 C# 数组不同,Numbers 不是对数组。如果我们运行以下内容
static unsafe void Main() => Console.WriteLine (sizeof (MySharedData));
结果为 208:50 个四字节浮点数,加上 Value 整数的四个字节,加上字母字符的两个字节。总数 206 四舍五入为 208,因为浮点数在四字节边界上对齐(四个字节是浮点数的大小)。
我们可以在不安全的上下文中演示 MySharedData,最简单的是使用堆栈分配的内存:
MySharedData d;MySharedData* data = &d; // Get the address of ddata->Value = 123;data->Letter = 'X';data->Numbers[10] = 1.45f;or:// Allocate the array on the stack:MySharedData* data = stackalloc MySharedData[1];data->Value = 123;data->Letter = 'X';data->Numbers[10] = 1.45f;
当然,我们并没有展示在托管环境中无法实现的任何内容。但是,假设我们要将 MySharedData 的实例存储在 CLR 垃圾回收器范围之外的非上。这就是指针变得非常有用的地方:
MySharedData* data = (MySharedData*) Marshal.AllocHGlobal (sizeof (MySharedData)).ToPointer();data->Value = 123;data->Letter = 'X';data->Numbers[10] = 1.45f;
Marshal.AllocHGlobal 在非托管堆上分配内存。以下是稍后释放相同内存的方法:
Marshal.FreeHGlobal (new IntPtr (data));
(忘记释放内存的结果是一个很好的老式内存泄漏。
注意从 .NET 6 开始,可以改为使用新的 NativeMemory 类来分配和释放非托管内存。NativeMemory 使用比 AllocHGlobal 更新(更好)的底层 API,还包括执行对齐分配的方法。
为了与它的名字保持一致,这里我们将MySharedData与我们在上一节中编写的SharedMem类结合使用。以下程序分配共享内存块,然后将 MySharedData 结构映射到该内存中:
static unsafe void Main(){ using (SharedMem sm = new SharedMem ("MyShare", false, (uint) sizeof (MySharedData))) { void* root = sm.Root.ToPointer(); MySharedData* data = (MySharedData*) root; data->Value = 123; data->Letter = 'X'; data->Numbers[10] = 1.45f; Console.WriteLine ("Written to shared memory"); Console.ReadLine(); Console.WriteLine ("Value is " + data->Value); Console.WriteLine ("Letter is " + data->Letter); Console.WriteLine ("11th Number is " + data->Numbers[10]); Console.ReadLine(); }}注意
您可以使用内置的 MemoryMappedFile 类而不是 SharedMem ,如下所示:
using (MemoryMappedFile mmFile = MemoryMappedFile.CreateNew ("MyShare", 1000))using (MemoryMappedViewAccessor accessor = mmFile.CreateViewAccessor()){ byte* pointer = null; accessor.SafeMemoryMappedViewHandle.AcquirePointer (ref pointer); void* root = pointer; ...}
下面是附加到同一共享内存的第二个程序,读取第一个程序写入的值(它必须在第一个程序等待 ReadLine 语句时运行,因为共享内存对象在离开其 using 语句时被释放):
static unsafe void Main(){ using (SharedMem sm = new SharedMem ("MyShare", true, (uint) sizeof (MySharedData))) { void* root = sm.Root.ToPointer(); MySharedData* data = (MySharedData*) root; Console.WriteLine ("Value is " + data->Value); Console.WriteLine ("Letter is " + data->Letter); Console.WriteLine ("11th Number is " + data->Numbers[10]); // Our turn to update values in shared memory! data->Value++; data->Letter = '!'; data->Numbers[10] = 987.5f; Console.WriteLine ("Updated shared memory"); Console.ReadLine(); }}
每个程序的输出如下所示:
// First program:Written to shared memoryValue is 124Letter is !11th Number is 987.5// Second program:Value is 123Letter is X11th Number is 1.45Updated shared memory
不要被指针吓倒:C++程序员在整个应用程序中使用它们,并且能够让一切正常工作。至少大多数时候是这样!相比之下,这种用法相当简单。
碰巧的是,我们的例子不安全 - 确切地说 - 出于另一个原因。我们没有考虑两个程序同时访问同一内存时出现的线程安全(或者更准确地说,进程安全)问题。若要在生产应用程序中使用它,我们需要将 volatile 关键字添加到 MySharedData 结构中的“值”和“字母”字段中,以防止实时 (JIT) 编译器(或 CPU 寄存器中的硬件)缓存字段。此外,随着我们与字段的交互变得不平凡,我们很可能需要通过跨进程互斥来保护它们的访问,就像我们使用 lock 语句来保护对多线程程序中字段的访问一样。我们在中详细讨论了线程安全性。
固定和固定 {...}将结构直接映射到内存的一个限制是结构只能包含非托管类型。例如,如果需要共享字符串数据,则必须改用固定字符数组。这意味着手动转换到字符串类型或从字符串类型转换。具体操作方法如下:
[StructLayout (LayoutKind.Sequential)]unsafe struct MySharedData{ ... // Allocate space for 200 chars (i.e., 400 bytes). const int MessageSize = 200; fixed char message [MessageSize]; // One would most likely put this code into a helper class: public string Message { get { fixed (char* cp = message) return new string (cp); } set { fixed (char* cp = message) { int i = 0; for (; i < value.Length && i < MessageSize - 1; i++) cp [i] = value [i]; // Add the null terminator cp [i] = '\0'; } } }}注意
没有对固定数组的引用;相反,你会得到一个指针。当您索引到固定数组时,您实际上是在执行指针算术!
第一次使用 fixed 关键字时,我们为结构中的 200 个字符分配内联空间。同一关键字(有些令人困惑)在以后在属性定义中使用时具有不同的含义。它指示 CLR 固定对象,以便如果它决定在块内执行垃圾回收,它不会在内存堆上移动基础结构(因为它的内容是通过直接内存指针迭代的)。看看我们的程序,你可能想知道MySharedData是如何在内存中移动的,因为它不是驻留在堆上,而是驻留在非托管的世界中,垃圾收集器没有管辖权。但是,编译器不知道这一点,并且托管上下文中使用MySharedData,因此它坚持添加固定关键字以使不安全的代码在托管上下文中安全。编译器确实有一点 - 以下是将MySharedData放在堆上所需的全部内容:
object obj = new MySharedData();
这将生成一个带盒的 MySharedData - 在堆上,并且有资格在垃圾回收期间进行传输。
此示例说明如何在映射到非托管内存的结构中表示字符串。对于更复杂的类型,还可以选择使用现有的序列化代码。一个限制条件是序列化数据的长度不得超过其在结构中的空间分配;否则,结果是与后续字段的意外联合。
COM 互操作性.NET 运行时为 COM 提供特殊支持,使 COM 对象能够从 .NET 使用,反之亦然。COM 仅在 Windows 上可用。
COM 的目的COM 是组件对象模型的首字母缩写,组件对象模型是与库接口的二进制标准,由 Microsoft 于 1993 年发布。发明 COM 的动机是使组件能够以独立于语言和版本容错的方式相互通信。在 COM 之前,Windows 中的方法是发布使用 C 编程语言声明结构和函数的 DLL。这种方法不仅是特定于语言的,而且也很脆弱。这种库中类型的规范与其实现密不可分:即使使用新字段更新结构也意味着破坏其规范。
COM 的美妙之处在于通过称为 的构造将类型的规范与其基础实现分开。COM 还允许在有状态上调用方法,而不是局限于简单的过程调用。
注意在某种程度上,.NET 编程模型是 COM 编程原则的演变:.NET 平台还促进了跨语言开发,并允许二进制组件在不破坏依赖于它们的应用程序的情况下发展。
COM 类型系统的基础知识COM 类型系统围绕接口旋转。COM 接口很像 .NET 接口,但它更普遍,因为 COM 类型通过接口公开其功能。例如,在 .NET 世界中,我们可以简单地声明一个类型,如下所示:
public class Foo{ public string Test() => "Hello, world";}
这种类型的消费者可以直接使用 Foo。如果我们后来更改了 Test() 的,调用程序集将不需要重新编译。在这方面,.NET 将接口与实现分开,不需要接口。我们甚至可以在不破坏调用者的情况下添加重载:
public string Test (string s) => $"Hello, world {s}";
在COM世界中,Foo通过接口公开其功能以实现相同的解耦。因此,在Foo的类型库中,将存在这样的接口:
public interface IFoo { string Test(); }
(我们通过显示 C# 接口(而不是 COM 接口)来说明这一点。然而,原理是相同的 - 尽管管道不同。
然后,呼叫者将与IFoo而不是Foo进行交互。
在添加测试的重载版本时,COM的生活比.NET更复杂。首先,我们将避免修改 IFoo 接口,因为这会破坏与以前版本的二进制兼容性(COM 的原则之一是接口一旦发布,就是的)。其次,COM 不允许方法重载。解决方案是让Foo实现:
public interface IFoo2 { string Test (string s); }
(同样,为了熟悉起见,我们已将其音译为 .NET 界面。
支持多个接口对于使 COM 库至关重要。
IUnknown和IDispatch所有 COM 接口都使用全局唯一标识符 (GUID) 进行标识。
COM 中的根接口是 IUnknown — 所有 COM 对象都必须实现它。此接口有三种方法:
地址参考释放查询接口AddRef 和 Release 用于生存期管理,因为 COM 使用引用计数而不是自动垃圾回收(COM 旨在处理非托管代码,其中自动垃圾回收不可行)。方法返回支持该接口的对象引用(如果可以)。
要启用动态编程(例如,脚本和自动化),COM 对象还可以实现 IDispatch 。这使得动态语言(如 VBScript)能够以后期绑定的方式调用 COM 对象,就像 C# 中的动态语言(尽管仅用于简单调用)。
从 C 调用 COM 组件#CLR 对 COM 的内置支持意味着您不直接使用 和 IDispatch。相反,您使用 CLR 对象,运行时通过运行时可调用包装器 (RCW) 封送对 COM 世界的调用。运行时还通过调用 AddRef 和 Release(当 .NET 对象完成时)来处理生存期管理,并处理两个世界之间的基元类型转换。例如,类型转换可确保每一端都能看到熟悉形式的整数和字符串类型。
此外,还需要有某种方法以静态类型方式访问 RCW。这是 工作。COM 互操作类型是自动生成的代理类型,用于为每个 COM 成员公开一个 .NET 成员。类型库导入程序工具 () 基于您选择的 COM 库从命令行生成 COM 互操作类型,并将其编译为 。
注意如果 COM 组件实现多个接口,则 工具将生成一个类型,其中包含来自所有接口的成员的联合。
可以在 Visual Studio 中创建 COM 互操作程序集,方法是转到“添加引用”对话框,然后从“COM”选项卡中选择一个库。例如,如果您安装了Microsoft Excel,则添加对Microsoft Excel 对象库的引用允许您与 Excel 的 COM 类进行互操作。下面是用于创建和显示工作簿,然后在该工作簿中填充单元格的 C# 代码:
using System;using Excel = Microsoft.Office.Interop.Excel;var excel = new Excel.Application();excel.Visible = true;Excel.Workbook workBook = excel.Workbooks.Add();((Excel.Range)excel.Cells[1, 1]).Font.FontStyle = "Bold";((Excel.Range)excel.Cells[1, 1]).Value2 = "Hello World";workBook.SaveAs (@"d:\temp.xlsx");注意
当前需要在应用程序中嵌入互操作类型(否则,运行时不会在运行时找到它们)。单击 Visual Studio 的解决方案资源管理器中的 COM 引用,并在“属性”窗口中将“嵌入互操作类型”属性设置为 true,或者打开 文件并添加以下行(粗体):
<ItemGroup> <COMReference Include="Microsoft.Office.Excel.dll"> ... <EmbedInteropTypes>true</EmbedInteropTypes> </COMReference></ItemGroup>
Excel.Application 类是一种 COM 互操作类型,其运行时类型为 RCW。当我们访问工作簿和单元格属性时,我们会返回更多的互操作类型。
可选参数和命名参数由于 COM API 不支持函数重载,因此具有大量参数的函数是很常见的,其中许多参数是可选的。例如,下面介绍了如何调用 Excel 工作簿的 Save 方法:
var missing = System.Reflection.Missing.Value;workBook.SaveAs (@"d:\temp.xlsx", missing, missing, missing, missing, missing, Excel.XlSaveAsAccessMode.xlNoChange, missing, missing, missing, missing, missing);
好消息是 C# 对可选参数的支持是 COM 感知的,因此我们可以这样做:
workBook.SaveAs (@"d:\temp.xlsx");
(正如我们在第中所述,可选参数由编译器“扩展”为完整的详细形式。
命名参数允许您指定其他参数,无论其位置如何:
workBook.SaveAs (@"c:\test.xlsx", Password:"foo");隐式引用参数
某些 COM API(尤其是 Word Microsoft)公开将参数声明为按引用传递的函数,无论函数是否修改参数值。这是因为不复制参数值可以感知到性能增益(性能增益可以忽略不计)。
从历史上看,从 C# 调用此类方法很笨拙,因为必须为每个参数指定 ref 关键字,这会阻止使用可选参数。例如,要打开Word文档,我们过去必须这样做:
object filename = "foo.doc";object notUsed1 = Missing.Value;object notUsed2 = Missing.Value;object notUsed3 = Missing.Value;...Open (ref filename, ref notUsed1, ref notUsed2, ref notUsed3, ...);
借助隐式 ref 参数,您可以在 COM 函数调用中省略 ref 修饰符,从而允许使用可选参数:
word.Open ("foo.doc");
需要注意的是,如果您调用的 COM 方法确实改变了参数值,则不会收到编译时或运行时错误。
索引省略 ref 修饰符的功能还有另一个好处:它使具有 ref 参数的 COM 索引器可通过普通 C# 索引器语法访问。否则将禁止这样做,因为 C# 索引器不支持 ref / out 参数。
还可以调用接受参数的 COM 属性。在下面的示例中,Foo 是一个接受整数参数的属性:
myComObject.Foo [123] = "Hello";
仍然禁止自己在 C# 中编写此类属性:类型只能在其自身(“默认”索引器)上公开索引器。因此,如果要用 C# 编写使上述语句合法的代码,Foo 需要返回另一个公开(默认)索引器的类型。
动态绑定动态绑定可以通过两种方式在调用 COM 组件时提供帮助。
第一种方法是允许在没有 COM 互操作类型的情况下访问 COM 组件。为此,请使用 COM 组件名称调用 Type.GetTypeFromProgID 以获取 COM 实例,然后从此使用动态绑定调用成员。当然,没有智能感知,编译时检查是不可能的:
Type excelAppType = Type.GetTypeFromProgID ("Excel.Application", true);dynamic excel = Activator.CreateInstance (excelAppType);excel.Visible = true;dynamic wb = excel.Workbooks.Add();excel.Cells [1, 1].Value2 = "foo";
(同样的事情也可以实现,但更笨拙,用反射而不是动态绑定。
注意此主题的变体是调用支持 IDispatch 的 COM 组件。然而,这样的组件非常罕见。
动态绑定在处理 COM 变体类型时也很有用(在较小程度上)。由于设计不佳而不是必要性的原因,COM API 函数通常充斥着这种类型,大致相当于 .NET 中的对象。如果在项目中启用“嵌入互操作类型”(稍后会详细介绍),运行时会将变量映射到动态,而不是将变量映射到对象,从而避免了强制转换的需要。例如,你可以合法地做
excel.Cells [1, 1].Font.FontStyle = "Bold";
而不是:
var range = (Excel.Range) excel.Cells [1, 1];range.Font.FontStyle = "Bold";
以这种方式工作的缺点是会丢失自动完成功能,因此您必须知道名为 Font 的属性恰好存在。因此,将结果分配给其已知的互操作类型通常更容易:
Excel.Range range = excel.Cells [1, 1];range.Font.FontStyle = "Bold";
如您所见,这比老式方法仅节省了五个字符!
变量到动态的映射是默认设置,并且是在引用上启用嵌入互操作类型的功能。
嵌入互操作类型我们之前说过,C# 通常通过通过调用 工具(直接或通过 Visual Studio)生成的互操作类型来调用 COM 组件。
过去,唯一的选择是引用互操作程序集,就像任何其他程序集一样。这可能会很麻烦,因为使用复杂的 COM 组件时,互操作程序集可能会变得非常大。例如,Microsoft Word 的小型加载项需要一个比自身大几个数量级的互操作程序集。
您可以选择嵌入所使用的部分,而不是引用互操作程序集。编译器分析程序集以精确地计算出应用程序所需的类型和成员,并直接在应用程序中嵌入(仅)这些类型和成员的定义。这避免了膨胀以及需要发送其他文件。
若要启用此功能,请在 Visual Studio 的解决方案资源管理器中选择 COM 引用,然后在“属性”窗口中将“嵌入互操作类型”设置为 true,或者按照前面所述编辑 文件(请参阅)。
类型等效性CLR 支持链接互操作类型的。这意味着,如果两个程序集分别链接到一个互操作类型,则如果这些类型包装相同的 COM 类型,则它们将被视为等效。即使它们链接到的互操作程序集是独立生成的,也是如此。
注意类型等效依赖于 System.Runtime.InteropServices 命名空间中的 TypeIdentifierAttribute 属性。编译器会在链接到互操作程序集时自动应用此属性。如果 COM 类型具有相同的 GUID,则认为它们是等效的。
向 COM 公开 C# 对象还可以使用 C# 编写可在 COM 世界中使用的类。CLR 通过称为 (CCW) 的代理使这成为可能。CCW 封送在两个世界之间进行类型(与 RCW 一样),并根据 COM 协议的要求实现 IUnknown(以及可选的 IDispatch)。CCW 通过引用计数(而不是通过 CLR 的垃圾回收器)从 COM 端进行生存期控制。
可以将任何公共类公开给 COM(作为“进程内”服务器)。为此,请首先创建一个接口,为其分配唯一的 GUID(在 Visual Studio 中,可以使用工具>),声明它对 COM 可见,然后设置接口类型:
namespace MyCom{ [ComVisible(true)] [Guid ("226E5561-C68E-4B2B-BD28-25103ABCA3B1")] // Change this GUID [InterfaceType (ComInterfaceType.InterfaceIsIUnknown)] public interface IServer { int Fibonacci(); }}
接下来,提供接口的实现,为该实现分配唯一的 GUID:
namespace MyCom{ [ComVisible(true)] [Guid ("09E01FCD-9970-4DB3-B537-0EC555967DD9")] // Change this GUID public class Server { public ulong Fibonacci (ulong whichTerm) { if (whichTerm < 1) throw new ArgumentException ("..."); ulong a = 0; ulong b = 1; for (ulong i = 0; i < whichTerm; i++) { ulong tmp = a; a = b; b = tmp + b; } return a; } }}
编辑您的 . 文件,添加以下行(粗体):
<PropertyGroup> <TargetFramework>netcoreapp3.0</TargetFramework> <EnableComHosting>true</EnableComHosting></PropertyGroup>
现在,当您生成项目时,会生成一个附加文件 ,该文件可以注册为 COM 互操作。(请记住,根据您的项目配置,文件将始终为 32 位或 64 位:在这种情况下没有“任何 CPU”这样的东西。从的命令提示符下,切换到保存 DLL 的目录并运行 。
然后,可以从大多数支持 COM 的语言使用 COM 组件。例如,可以在文本编辑器中创建此 Visual Basic 脚本,并通过在 Windows 资源管理器中双击该文件来运行它,或者像启动程序一样从命令提示符启动它:
REM Save file as ComClient.vbsDim objSet obj = CreateObject("MyCom.Server")result = obj.Fibonacci(12)Wscript.Echo result
请注意,.NET Framework 不能加载到与 .NET 5+ 或 .NET Core 相同的进程中。因此,.NET 5+ COM 服务器不能加载到 .NET Framework COM 客户端进程中,反之亦然。
启用无注册表 COM传统上,COM 将类型信息添加到注册表。无注册表 COM 使用清单文件而不是注册表来控制对象激活。若要启用此功能,请将以下行(粗体)添加到 文件:
<PropertyGroup> <TargetFramework>netcoreapp3.0</TargetFramework> <EnableComHosting>true</EnableComHosting> <EnableRegFreeCom>true</EnableRegFreeCom></PropertyGroup>
然后,您的构建将生成。
注意.NET 5+ 不支持生成 COM 类型库 (*.tlb)。您可以手动编写 IDL(接口定义语言)文件或接口中本机声明的C++标头。
本人所创作的文章,只在今日头条首发创作,不曾发布在其他平台,请勿搬运抄袭。
标题:《MineCraft联机大揭秘:正版、非正版和服务器端联机方式全解析》
随着MineCraft的热度愈加高涨,越来越多的玩家渴望与朋友们一同探险、创造。不过,MineCraft的联机方式有许多,包括正版、非正版和服务器端等多种方式。
本文将为你详细介绍这些联机方式,让你轻松畅玩MineCraft的奇妙世界。
正版联机方式
首先,让我们来探讨正版联机方式。正版玩家享有多种联机选项,其中一种是通过内网穿透网站实现的。具体步骤如下:
注册内网穿透账号: 首先,你需要前往内网穿透网站注册一个账号,并确保能够成功登录。
进入MineCraft世界: 打开MineCraft并进入你想要联机的世界。
开启局域网模式: 进入游戏后,按下ESC键,然后选择“对局域网开放”。在此步骤中,你需要填入一个端口号,推荐固定一个端口以避免每次启动都要创建新的隧道。记住这个端口号。
创建隧道: 返回到端口映射网站,选择“创建隧道”,并按照指引创建一个隧道。
获取联机地址: 创建隧道后,你会得到一个用于好友连接的地址。
随后,你的好友只需使用相同版本和模组的MineCraft客户端,在多人联机中输入这个地址,即可轻松联机。请注意,这种联机方式仅适用于正版玩家。
非正版联机
非正版玩家也可以通过端口映射来实现联机。步骤基本与正版玩家相同,但加入者可以使用外置登录方式代替正版账号。以下是详细步骤:
外置登录: 加入方可以通过外置登录的方式来代替正版账号。
具体操作可参考相关教程。
端口映射: 接下来,按照正版联机的步骤,通过端口映射来实现联机。
服务器端联机方式
现在,让我们来谈谈服务器端联机方式的优势。使用服务器端联机,你可以轻松建立自己的MineCraft服务器,并享受一年365天不间断的游戏乐趣。
服务器的搭建
首先,我们将学习如何搭建一个纯净服使用Paper服务器端为例:
下载Paper服务器端: 前往Paper官方下载网站,选择适合你的MineCraft版本的Paper核心进行下载。
创建服务器文件夹: 将下载的Paper核心文件放入一个新建的文件夹中。
修改eula.txt文件: 打开eula.txt文件,将其中的“eula=false”改为“eula=true”,保存并退出。
启动服务器: 双击Paper核心文件,等待一段时间,你会看到一些新的文件和文件夹被生成,表明服务器启动成功。
端口映射: 使用端口映射工具,将服务器的25565端口映射到公网,以便朋友们可以加入你的服务器。
插件的使用
使用服务器端联机方式,你还可以轻松添加各种插件,管理玩家权限更加清晰。只需将下载好的插件文件放入服务器的"plugins"文件夹中,然后重新启动服务器,插件就会生效。
模组的安装
如果你想在服务器上使用模组,可以通过以下步骤安装:
下载并安装合适版本的Forge或Fabric,选择"server"选项,选择服务器核心文件夹。
安装完成后,会生成一个"run.bat"文件。双击运行它,会生成一个"mods"文件夹。
将模组文件放入"mods"文件夹中,然后再次运行"run.bat"。
这样,服务器和客户端都安装了相同的模组,玩家们可以畅快地游玩。
通过本文,我们详细介绍了MineCraft的多种联机方式,包括正版、非正版和服务器端联机。