65.9K
CodeProject 正在变化。 阅读更多。
Home

P/Invoke Jujitsu: A Data Kata

starIconstarIconstarIconstarIconstarIcon

5.00/5 (7投票s)

2020年7月11日

MIT

14分钟阅读

viewsIcon

6427

downloadIcon

93

利用数据的内存布局,让你的 P/Invoke 代码更易于访问和维护

引言

一旦你克服了学习曲线,P/Invoke 就变得非常强大。如果你没有在指针操作语言中磨练过,它可能会让你望而生畏,但它只是你可用的另一种资源。归根结底,它就是为了帮助你,而 .NET 提供的 P/Invoke 功能是深入、灵活且易于使用的。

一旦你学会了进行一些基本的 P/Invoke 操作,你面临的下一个最大障碍就是理解如何利用它来构建你一直在做的事情,因为没有一个单一的指南可以按顺序展示如何“升级”你的使用能力。然而,探索它就是解锁其潜力并理解背后机制的关键,所以如果你计划编写依赖于它的代码,这一点非常重要。你理解得越多,最终你的代码就会越好。

本文旨在让你掌握一些处理数据时更灵活的中间级技能。我们不会像上一篇 P/Invoke Jujitsu 教程中那样使用 Marshal 类,但我们会深入探索数据的内存布局,并将其映射到不同的托管类型。

了解你的数据

32 位数据是什么?简单来说,它是一个 int。换个说法,它是一个 float。再换个说法,它是一个包含 4 个字节的 array。再换个说法,它是一个包含 4 个 byte 字段的 struct,或者是一个包含 2 个 short 字段的 struct,或者是一个包含 1 个 short 字段和 2 个 byte 字段的 struct。它可能是以上任何一种,取决于你看待它的方式。

仅仅知道有多少数据是不够的,你还必须知道它是什么。这就是为什么我们有类型,以便我们能够区分这 32 位是应该被解释为 intfloat,还是如上所述的其他类型。

理解这一切的关键之一是:类型不是“持有”数据的某物。它仅仅“引用”数据。类型仅仅是数据的一个概念性部分。它并非真正属于数据本身。数据最纯粹的形式就是那些位,就像构成 int 的 32 位一样。我们的编程语言和运行时为我们对原始数据施加了类型。类型是访问数据的表象和接口,而不是数据本身。它是分离的。理解不是行动。类型是抽象的,而位是具体的。如果我们去掉数据中所有的类型,只看它的原始形式,它就是平坦的位流。这基本上就是 P/Invoke 所处理的内容。

数据本身什么也不是。是我们与数据互动的方式定义了它。我们的类型为我们与数据的操作提供了基础。它们赋予数据形式和形状,否则它就只是一片(有时)已知长度的位海。它的一切都取决于你如何使用它来定义它。

为什么费心要知道这些?简单来说,因为我们要利用它。在处理 P/Invoke 代码时,撕掉数据的一个表象并用另一个替换它可能非常有用,而理解如何做到这一点将让你更深入地了解 P/Invoke 和你的数据是如何工作的。

正如我所说,P/Invoke 处理的是你数据的位。它不太关心反映这些位的类型。它相信你告诉它的任何类型,并且会愉快地让你欺骗它。欺骗编组器,或者至少糊弄它,是完全没问题的,我们自己也会这样做,只是不要被抓到!我会在过程中解释,很快就会讲到。

编组方法调用

有时,你只需要调用一个非托管平台方法,这时就需要编组一个方法调用。要做到这一点,你需要告诉 .NET 接收调用的方法在哪里。DllImportAttribute 涵盖了这一点。我们将使用 winmm.dll 的 MIDI 功能(属于 Win32 Multimedia API)来演示本文的概念。所有调用都接收自 winmm.dll

我们需要做的第一件事是找出我们要调用的方法长什么样子。与 .NET 程序集不同,非托管 DLL 无法告诉我们其类型和方法长什么样子。我们必须查阅(在此例中)微软的文档和/或头文件。如果没有这些,我们也可以使用 pinvoke.net 作为资源,但请注意,那里的内容是众包的,经常包含非最优甚至错误的定义。不过,对于获取常量和标志值来说,它是一个很好的网站,因为这些通常不在微软的文档中。

这部分是一个拥有 C 和尤其是 Win32 C 开发背景的优势,但我会尽量引导你,即使你没有这个背景。如果你没有 C 背景,最好从 pinvoke.net 下载 P/Invoke 定义,然后根据需要进行验证和修改。但不要跳过最后一步,因为正如我所说,该网站上的 P/Invoke 定义通常非最优甚至错误。

我们将从 midiOutClose() 函数开始,因为它参数最少。这是微软提供的 C 定义:

MMRESULT midiOutClose( HMIDIOUT hmo );

我们需要翻译所有参数和返回值,总共需要进行两次翻译。从左到右,第一个是 MMRESULT。如果你不熟悉微软的习惯,可能会想深入查找头文件或 Google,但它是一个 32 位整数,微软用它来报告调用的状态——是否出错。他们对他们的大多数非托管调用都是这样做的。我再进一步告诉你,这个整数用于信号成功(值为零)或失败(值为非零),并且有一个与它对应的错误代码枚举。然而,我们不需要这些常量值来编写这段代码。事实上,即使是专业代码,我们也不需要它们,因为有更好的方法来获取友好的错误消息,而不是在这里使用常量值。我知道这些是因为我之前用过这个 API,所以我才能告诉你。如果我不知道,我可能不得不搜索 C 或 C# 的示例代码。最后,我们将使用 int 来表示 MMRESULT

警告:这仅在 intMMRESULT 大小完全相同时才有效!请始终匹配方法参数和返回值的尺寸! 我不在乎你是否在需要 int 的地方使用 float,因为它们都是 32 位。编组器也不在乎。然而,如果你在需要 int(32 位)的地方使用 short(16 位)或 long(64 位),你最好还是人为地硬编码一个未处理的异常来崩溃你的应用程序,省去一些调试的痛苦。发生的情况是,它会破坏“调用堆栈”,这是编组器用于将调用传输到非托管代码并从中传输回来的内存。这会不可挽回地损坏你的应用程序。最好的情况是你每次都立即崩溃。最坏的情况是你“有时”崩溃,稍晚一点。花时间确保方法返回值和参数正确。你的应用程序的完整性完全取决于此。P/Invoke 可以让你非常轻松地做危险的事情。

该参数的类型是 HMIDIOUT,这是一个 32 位的“句柄”,微软用它来表示“指向我们未公开内容的指针”——这没关系,我们不需要知道句柄指向的内容的文档。每当你获得一个句柄时,你都可以使用 IntPtr。理论上,你可以使用 32 位值,如 int,但使用 IntPtr 是个好习惯。但请记住,IntPtr 的大小取决于你平台的字长,但通常如果你需要让你的应用程序同时支持 32 位和 64 位,你仍然需要两套不同的 P/Invoke 声明和一些条件编译块。在本练习中,我们将专注于 32 位 P/Invoke。大多数时候,你在 .NET 中开发的应用程序的 IntPtr 大小都是 32 位。

将所有这些放在一起,这就是我们上面方法的 P/Invoke 签名:

[DllImport("winmm.dll")]
static extern int midiOutClose(IntPtr hmo);

这还不算太糟糕,但这就是我们为什么首先处理它的原因。midiOutOpen() 函数更复杂。

MMRESULT midiOutOpen(
    LPHMIDIOUT phmo, 
    UINT uDeviceID, 
    DWORD_PTR dwCallback, 
    DWORD_PTR dwInstance, 
    DWORD fdwOpen
);

请务必打开此函数的 文档。从左到右,从上到下,我们已经知道 MMRESULT 是一个 int

下一个是 LPHMIDIOUT。我们从前面知道 HMIDIOUT 本身就是 IntPtr。如果你熟悉匈牙利命名法,你会知道在某物前面加上“LP”意味着它是后面内容的指针。在这种情况下,它是 HMIDIOUT 句柄的指针,而 HMIDIOUT 本身又是另一个指针。文档还表明这是一个输出值。这可以解释为什么它是一个指向某个东西的指针LPHMIDIOUT),而不是简单地指向本身(HMIDIOUT)。这是 C 语言中说“我需要按引用传递它或传递一个输出值”的方式。在 C# 中,知道 HMIDIOUTIntPtr,我们可以使用 ref IntPtr phmo 按引用传递,这效果很好。唯一的问题是,编组器也会传递输入值以及输出值。为了告诉 C# 和编组器我们不关心输入值,我们用 out 替换 ref,留下 out IntPtr phmo。如果你不确定何时使用哪一个,请使用 ref,因为它在 out 可以使用的场景下也能工作,而 outref 需要的场景下则无法工作。

接下来,如果我们查找微软的 Win32 头文件,我们可以发现 UINT 是一个 32 位的无符号 int。除非我需要无符号范围用于无符号值,否则我通常会为这些使用 int,我们这里也一样,所以留下 int uDeviceId

下一个参数是类型为 DWORD_PTR 的指针,文档说明它指向的内容取决于 fdwOpen 的值。我们在这里躲过了一劫,因为我们的代码不需要这个参数。我们可以传递一个 null 指针,我们将通过将参数声明为 IntPtr 来表示,并从代码中传递 IntPtr.Zero,所以留下 IntPtr dwCallback

下一个参数也是类型为 DWORD_PTR 的指针,文档说这是一个用户定义的值,与函数一起传递,用于回调机制。我们也不会使用这个。我们将将其声明为 IntPtr,以便与 DWORD_PTR 的内存占用匹配,但我们将从代码中传递 IntPtr.Zero,所以留下 IntPtr dwInstance

最后,但同样重要的是,我们有一个 DWORD,它是一个 32 位无符号整数。同样,我们倾向于在 C# 中使用有符号值,编组器也不在乎,所以我们将这个声明为 int fdwOpen

最后,将所有这些放在一起,我们就得到了:

[DllImport("winmm.dll")]
static extern int midiOutOpen(out IntPtr phmo, int uDeviceId, 
                              IntPtr dwCallback, IntPtr dwInstance, int fdwOpen);

为 Void* 赋予形式

正如我之前所说,数据在其原始形式中没有类型——它只是一个位流。有时这些位有不同的包装。考虑以下 P/Invoke 方法声明:

[DllImport("winmm.dll")]
static extern int midiOutShortMsg(IntPtr hmo, int dwMsg);

这里,显而易见的问题是消息是什么?这里它用 int dwMsg 表示,但这个 int 代表什么?就我们目前所知,它基本上是一个 32 位的不透明位流。这并没有告诉我们太多。

查看 文档,它告诉我们消息被“打包”到一个 int 中,其中每个字节代表消息的不同部分:我们有一个“状态字节”,两个“数据字节”,外加一个未使用字节,总共 4 个字节,即 32 位。

好的,所以我们可以这样将消息打包到一个 int 中:

var msg = (data2 << 16) + (data1 << 8) + status;

这不是很清楚。也许我们可以做得更好。与其将此视为打包到一个 32 位 int 中的 24 位值,不如将其分解为四个 8 位字段,其中一个字段是保留的?

这就是我们与 P/Invoke 玩得开心的地方,并真正让它为我们服务而不是与我们作对

[StructLayout(LayoutKind.Sequential)]
struct MidiMsg
{
    public byte Status;
    public byte Data1;
    public byte Data2;
    byte Reserved;
}

结构是从低字节到高字节布局的,大小为一个 32 位整数。回想一下,我之前说过,只要大小相同,我们就可以欺骗编组器?让我们通过创建 midiOutShortMsg() 的另一个声明来实现这一点:

[DllImport("winmm.dll")]
static extern int midiOutShortMsg(IntPtr hmo, MidiMsg dwMsg);

在这里,我们用 MidiMsg struct 替换了 DWORD 字段(32 位),而不是 int。它们都是 32 位,所以是可行的。它只是意味着我们在代码中以不同的方式处理这些位。最终结果是一样的。编组器并不在意 MidiMsg 不是 int。它只看到一个 32 位的位流,它必须在调用的两端之间传递,无论它以何种形式出现。

现在,与其

var msg = (data2 << 16) + (data1 << 8) + status;

我们可以这样做:

var msg = default(MidiMsg);
msg.Status = status;
msg.Data1 = data1;
msg.Data2 = data2;

然后,无论哪种方式,我们都可以调用 midiOutMsg(handle, msg);

你可能仍然不知道状态、data1 和 data2 是什么,因为没有人告诉你,但至少后者让我们更清楚地设置消息字段。状态、data1 和 data2 特定于 MIDI 协议,我在 此链接中对此进行了介绍。

假设我们有时需要这个 msg 作为 int,有时作为 MidiMsg struct。一个选择是更改我们的 struct 定义:

[StructLayout(LayoutKind.Explicit)]
struct MidiMsg2
{
    [FieldOffset(0)] public int Packed;
    [FieldOffset(0)] public byte Status;
    [FieldOffset(1)] public byte Data1;
    [FieldOffset(2)] public byte Data2;
    [FieldOffset(3)] byte Reserved;
}

这仍然是 32 位。我们改变了 struct 的布局方式,所以我们将 MidiMsg2 的每个字段单独放置在其位流中。请注意,我们的 int 字段位于数据开头,并且也占用 32 位,就像 struct 的其余部分一样。这创建了一种“C union”,其中 Packed 字段引用与 struct 其余部分相同的内存位置。它只是以不同的方式反映其中的数据。设置它会影响其他 4 个字段,反之亦然。

使用上面的 MidiMsg2,我们之前的 int midiOutShortMsg(IntPtr, int) 可以正常工作,所以我们不需要再声明一个,尽管我们可以。我们只需将 msg.Packed 传递给我们已经声明的 midiOutShortMsg()。这只会将我们的数据作为单个 32 位整数值获取。

关于 MarshalAs?

你可以通过将 MarshalAsAttribute 应用于 P/Invoke 方法参数和/或返回值来调整编组行为。但是,你几乎永远不需要它,如果你发现自己在使用它,通常是在自找麻烦。这不是因为它是“高级内容”——我们在这里接触的就是高级内容。不,这是因为编组器在没有提示的情况下非常擅长编组你提供给它的内容。如果你必须给它提示,你很可能试图编组一个它无法处理的数据类型。使用 MarshalAsAttribute,大多数情况下,是你在某处弄错了的警告。我们这里不会介绍它,因为有一个主要例外,就是我们何时想使用它,但这通常出现在 COM 接口上,而我们这次不涉及。

现在从形式到运动

现在我们有了概念和数据结构,让我们将它们付诸实践:

IntPtr handle;
// 0 = success
if(0==midiOutOpen(out handle, 0, IntPtr.Zero, IntPtr.Zero, 0))
{
    // Below we use two different methods
    // of calling midiOutShortMsg()

    // The reason this works is not because the C function we're calling
    // has multiple overloads - it doesn't. No, the reason it works is because
    // the raw bytes we're passing to the function are the same either way.

    // no matter what, midiOutShortMsg() sees two 32-bit parameters passed into it 
    // and returns 32 bits out of it through the return value.
    // The value of the second parameter however, can vary depending on how we 
    // want it to map to memory. In one way, we chose a 4 byte struct (4x8=32-bits)
    // in the other, we chose an int (32-bits). The only thing that changes is how 
    // *we're* looking at or  modifying the data, not the data itself - it is the 
    // same whether or not the data is mapped to a 4 byte int or whether it 
    // is mapped to a 4 byte struct - it's just 32-bits of data!

    // first we'll use the structure method
    // of calling midiOutShortMsg():
    var m = default(MidiMsg);
    m.Status = 0x90; // note on
    m.Data1 = 0x3C; // middle C
    m.Data2 = 0x7F; // max velocity
    midiOutShortMsg(handle, m);

    // Now we'll use the int method of 
    // calling midiOutShortMsg():

    // if we had done so above it would have been:
    // midiOutShortMsg(handle, 0x007F3C90); 

    // note on middle E, max velocity: (data1 = 0x40)
    midiOutShortMsg(handle, 0x007F4090);

    // alternate to above is
    //m = default(MidiMsg);
    //m.Status = 0x90; // note on
    //m.Data1 = 0x40; // middle E
    //m.Data2 = 0x7F; // max velocity
    //midiOutShortMsg(handle, m);

    // note on middle G, max velocity: (data1 = 0x43)
    var m2 = default(MidiMsg2);
    m2.Status = 0x90; // note on
    m2.Data1 = 0x43; // middle G
    m2.Data2 = 0x7F; // max velocity
    // use the "Packed" field to get
    // the above as a 32-bit int
    midiOutShortMsg(handle, m2.Packed);

    // alternate to above is
    // midiOutShortMsg(handle, 0x007F4390);

    Console.Error.WriteLine("Press any key to exit...");
    Console.ReadKey();
                
    midiOutClose(handle);
}

这将以最大敲击力度,在第一个可用的 MIDI 输出控制器(通常是你的计算机声卡的波表合成器)上输出一个 C 大调和弦,根音为中央 C。因此,你应该能在你的电脑扬声器上听到一个和弦的输出。

这样,我们就以几种不同的方式调用了同一个 midiOutShortMsg() 方法,但每种方式都使用了相同的基础 32 位位流,无论我们是用 struct 还是用 int 创建它。

现在你已经看到了如何通过使用不同的底层类型来表示相同的内存空间来处理你的数据,并在此过程中提高了 P/Invoke 代码的灵活性和可读性。

历史

  • 2020年7月11日 - 首次提交
© . All rights reserved.