百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术分析 > 正文

c# 10 教程:24 本机和 COM 互操作性

liebian365 2024-11-18 14:20 2 浏览 0 评论


本章介绍如何与本机(非托管)动态链接库 (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 (#34;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.tv_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);    // 0

n.Channel = 10;
n.Note = 100;
n.Velocity = 50;
Console.WriteLine (n.PackedMsg);    // 3302410

n.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 d

data->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 memory
Value is 124
Letter is !
11th Number is 987.5

// Second program:

Value is 123
Letter is X
11th Number is 1.45
Updated 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) => #34;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.vbs
Dim obj
Set 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++标头。

相关推荐

快递查询教程,批量查询物流,一键管理快递

作为商家,每天需要查询许许多多的快递单号,面对不同的快递公司,有没有简单一点的物流查询方法呢?小编的回答当然是有的,下面随小编一起来试试这个新技巧。需要哪些工具?安装一个快递批量查询高手快递单号怎么快...

一键自动查询所有快递的物流信息 支持圆通、韵达等多家快递

对于各位商家来说拥有一个好的快递软件,能够有效的提高自己的工作效率,在管理快递单号的时候都需要对单号进行表格整理,那怎么样能够快速的查询所有单号信息,并自动生成表格呢?1、其实方法很简单,我们不需要一...

快递查询单号查询,怎么查物流到哪了

输入单号怎么查快递到哪里去了呢?今天小编给大家分享一个新的技巧,它支持多家快递,一次能查询多个单号物流,还可对查询到的物流进行分析、筛选以及导出,下面一起来试试。需要哪些工具?安装一个快递批量查询高手...

3分钟查询物流,教你一键批量查询全部物流信息

很多朋友在问,如何在短时间内把单号的物流信息查询出来,查询完成后筛选已签收件、筛选未签收件,今天小编就分享一款物流查询神器,感兴趣的朋友接着往下看。第一步,运行【快递批量查询高手】在主界面中点击【添...

快递单号查询,一次性查询全部物流信息

现在各种快递的查询方式,各有各的好,各有各的劣,总的来说,还是有比较方便的。今天小编就给大家分享一个新的技巧,支持多家快递,一次能查询多个单号的物流,还能对查询到的物流进行分析、筛选以及导出,下面一起...

快递查询工具,批量查询多个快递快递单号的物流状态、签收时间

最近有朋友在问,怎么快速查询单号的物流信息呢?除了官网,还有没有更简单的方法呢?小编的回答当然是有的,下面一起来看看。需要哪些工具?安装一个快递批量查询高手多个京东的快递单号怎么快速查询?进入快递批量...

快递查询软件,自动识别查询快递单号查询方法

当你拥有多个快递单号的时候,该如何快速查询物流信息?比如单号没有快递公司时,又该如何自动识别再去查询呢?不知道如何操作的宝贝们,下面随小编一起来试试。需要哪些工具?安装一个快递批量查询高手快递单号若干...

教你怎样查询快递查询单号并保存物流信息

商家发货,快递揽收后,一般会直接手动复制到官网上一个个查询物流,那么久而久之,就会觉得查询变得特别繁琐,今天小编给大家分享一个新的技巧,下面一起来试试。教程之前,我们来预览一下用快递批量查询高手...

简单几步骤查询所有快递物流信息

在高峰期订单量大的时候,可能需要一双手当十双手去查询快递物流,但是由于逐一去查询,效率极低,追踪困难。那么今天小编给大家分享一个新的技巧,一次能查询多个快递单号的物流,下面一起来学习一下,希望能给大家...

物流单号查询,如何查询快递信息,按最后更新时间搜索需要的单号

最近有很多朋友在问,如何通过快递单号查询物流信息,并按最后更新时间搜索出需要的单号呢?下面随小编一起来试试吧。需要哪些工具?安装一个快递批量查询高手快递单号若干怎么快速查询?运行【快递批量查询高手】...

连续保存新单号功能解析,导入单号查询并自动识别批量查快递信息

快递查询已经成为我们日常生活中不可或缺的一部分。然而,面对海量的快递单号,如何高效、准确地查询每一个快递的物流信息,成为了许多人头疼的问题。幸运的是,随着科技的进步,一款名为“快递批量查询高手”的软件...

快递查询教程,快递单号查询,筛选更新量为1的单号

最近有很多朋友在问,怎么快速查询快递单号的物流,并筛选出更新量为1的单号呢?今天小编给大家分享一个新方法,一起来试试吧。需要哪些工具?安装一个快递批量查询高手多个快递单号怎么快速查询?运行【快递批量查...

掌握批量查询快递动态的技巧,一键查找无信息记录的两种方法解析

在快节奏的商业环境中,高效的物流查询是确保业务顺畅运行的关键。作为快递查询达人,我深知时间的宝贵,因此,今天我将向大家介绍一款强大的工具——快递批量查询高手软件。这款软件能够帮助你批量查询快递动态,一...

从复杂到简单的单号查询,一键清除单号中的符号并批量查快递信息

在繁忙的商务与日常生活中,快递查询已成为不可或缺的一环。然而,面对海量的单号,逐一查询不仅耗时费力,还容易出错。现在,有了快递批量查询高手软件,一切变得简单明了。只需一键,即可搞定单号查询,一键处理单...

物流单号查询,在哪里查询快递

如果在快递单号多的情况,你还在一个个复制粘贴到官网上手动查询,是一件非常麻烦的事情。于是乎今天小编给大家分享一个新的技巧,下面一起来试试。需要哪些工具?安装一个快递批量查询高手快递单号怎么快速查询?...

取消回复欢迎 发表评论: