P/Invoke Jujitsu:传递可变长度结构体






4.77/5 (9投票s)
如何处理.NET 封送处理程序难以处理的结构体
引言
通常,.NET 封送处理程序可以像魔法一样处理 P/Invoke 调用到 Win32 和其他平台原生库,但事情有时会变得棘手,而且无论如何,你必须知道你在做什么,即使如此。尽管封送处理程序功能强大,即使在熟练的手中,有些任务它也无法胜任,让你不得不 resorting to some less pleasant methods of getting your point across with code。虽然最好避免,但这些有时是必要的。我将介绍如何处理涉及封送带有固定或可变长度数组的 C 结构体的棘手场景。
请注意,这绝不是 MIDI API 的完整或真实世界的演示。这也不是本文的目标。本文只是展示如何使用难处理的结构体进行 P/Invoke。MIDI API 只是一个载体,仅此而已。有关 MIDI API 的全面介绍,请下载或查看 本文 中的代码。
P/Invoke 声明
我假设你已经使用过 P/Invoke,至少复制粘贴过一些 P/Invoke 代码,并且知道 `DllImportAttribute` 和 `StructLayoutAttribute` 是什么。
我们将调用 32 位 Windows MIDI 函数 `midiStreamOut()` 和相关的 `midiOutPrepareHeader()`、`midiOutUnprepareHeader()` 和 `midiOutLongMsg()` 函数,以及使用一些回调和相关的支持调用来演示这些概念。这些函数重要的是它们接受一个 `MIDIHDR` 结构体,该结构体本身包含一个固定长度的保留数据数组,在某些情况下,一个必须是 `DWORD`(4 字节)倍数的 `MIDIEVENT` 可变长度结构体数组。哇!这要求很高,但也展示了本文将如何解决的所有问题,并且通过一些改编,它也可以同样适用于 COM 互操作,尽管在那里遇到这种情况的可能性较小。
第一步是准备好我们的 C API `stdcall` 函数原型。
MMRESULT midiOutPrepareHeader(HMIDIOUT hmo, LPMIDIHDR lpMidiOutHdr, UINT cbMidiOutHdr);
MMRESULT midiOutUnprepareHeader(HMIDIOUT hmo, LPMIDIHDR pmh, UINT cbmh);
MMRESULT midiStreamOut(HMIDISTRM hms, LPMIDIHDR pmh, UINT cbmh);
MMRESULT midiOutLongMsg(HMIDIOUT hmo, LPMIDIHDR pmh, UINT cbmh);
我们可以在 Microsoft Win32 API 文档在线和 pinvoke.net 中找到其中类型的信息。我现在将从左到右、从上到下介绍它们。
- `MMRESULT` 是一个 32 位值,表示调用成功为 0,失败为某个非零错误代码。我们将它表示为 `int`。
- `HMIDIOUT` 就像任何 Win32 句柄一样,本质上是指针。因此,我们使用 `IntPtr` 来表示它。
- `LPMIDIHDR` 是一个指向 `MIDIHDR` 结构体的指针,我们还没有讲到。我们可以通过两种方式表示它。最显而易见的一种——`IntPtr`,不一定最好,因为它意味着手动将结构体复制到 `IntPtr` 指向的内存中,有时这是必要的。另一种——通常更好的选择是使用 `ref`,就像本例中的 `ref MIDIHDR`,它将 marshaling 一个指向结构体的指针——它通过引用传递结构体。这比 `IntPtr` 方法更安全、更干净、更高效——三全其美!所以,只要可以,就使用它。我们需要以两种方式调用其中一些函数,因此我们将使用两种不同的重载来声明它们的 P/Invoke 函数,一种用于 `IntPtr`,一种用于 `ref MIDIHDR`。
- 这里的 `UINT` 是 32 位无符号整数。它们仅用于传递 `MIDIHDR` 的大小,但 `Marshal.SizeOf()` 返回的是 `int` 而不是 `uint`,所以我们将使用 `int`。
- `HMIDISTRM` 是另一个句柄,所以我们再次使用 `IntPtr`。
正如我之前所说,我们将为其中一些函数提供多个重载,因此 C# 声明如下:
[DllImport("winmm.dll")]
static extern int midiOutPrepareHeader(IntPtr hmo, ref MIDIHDR lpMidiOutHdr, int cbMidiOutHdr);
[DllImport("winmm.dll")]
static extern int midiOutPrepareHeader(IntPtr hmo, IntPtr lpMidiOutHdr, int cbMidiOutHdr);
[DllImport("winmm.dll")]
static extern int midiOutUnprepareHeader(IntPtr hmo, ref MIDIHDR pmh, int cbmh);
[DllImport("winmm.dll")]
static extern int midiOutUnprepareHeader(IntPtr hmo, IntPtr pmh, int cbmh);
[DllImport("winmm.dll")]
static extern int midiStreamOut(IntPtr hms, IntPtr pmh, int cbmh);
[DllImport("winmm.dll")]
static extern int midiOutLongMsg(IntPtr hmo, ref MIDIHDR pmh, int cbmh);
现在我们可以讲讲 `struct` 了。主要的 `struct` 是 `MIDIHDR`。它可以包含在 `lpData` 成员指向的内存中分配的额外数据。有时,这些数据采用 `MIDIEVENT` 结构体数组的形式。这些结构体本身是可变长度的,但每个结构体必须是 4 字节的倍数。以下是原型:
typedef struct midihdr_tag {
LPSTR lpData;
DWORD dwBufferLength;
DWORD dwBytesRecorded;
DWORD_PTR dwUser;
DWORD dwFlags;
struct midihdr_tag *lpNext;
DWORD_PTR reserved;
DWORD dwOffset;
DWORD_PTR dwReserved[4]; // should be 8!!! for midiStreamOut()
} MIDIHDR, *LPMIDIHDR;
typedef struct {
DWORD dwDeltaTime;
DWORD dwStreamID;
DWORD dwEvent;
DWORD dwParms[];
} MIDIEVENT;
从上到下,首先是 `MIDIHDR`。
微软,以其无限的智慧,决定 `lpData` 应该是 `LPSTR` 而不是 `LPVOID`。可能有一个原因,但我不知道。将其视为 `LPVOID`,即 `IntPtr`。它永远不是 `string`。
接下来的两个成员是 `DWORD`,它们是 32 位无符号的,但我们将使用 `int`,因为这对我们来说更方便。
下一个是 the pointer,所以我们使用 `IntPtr`。
下一个是 `DWORD`,使用 `int` 或 `uint` 在这里都不太重要。我只使用 `int`。无论你使用哪种,请确保你的标志常量与类型匹配。
之后,又是 `IntPtr`。这永远不可能是 `MIDIHDR`,因为在 C# 中,结构体不能包含对其自身的引用作为成员。它必须是一个类,这超出了本文的范围。我们也不需要在代码中使用此字段。
另一个 `IntPtr`,这次是保留的。
接下来,一个偏移量。我们在这里使用 `int`。
最后,有趣的东西,一个固定长度的数组。现在不要激动。为了使 `struct` 的内存布局与 C 等价物兼容,我们必须将数组“展开”成 8 个字段,每个字段都是 `IntPtr`。谢天谢地只有 8 个,而不是 256 个!我使用 `dwReserved1`、`dwReserved2` 等。请注意,定义说的是 4。对于 `midiOutLongMsg()` 来说 4 个足够了,但对于 `midiStreamOut()` 来说不够。不过,我们不需要声明两次。我们可以声明最大的那个,并在任何地方使用它。额外的空间没关系,只要它在结构体的末尾。空间不足则不行。
现在来看 `MIDIEVENT`。
这里的前三个字段可以是 `int`。
`dwParams` 是另一回事。起初,它看起来并不复杂。它是一个 32 位无符号值的数组,没错。但是,它有多长?更重要的是,它如何影响结构体的内存占用?所有这些数据都需要紧跟在前面 3 个字段之后。没有办法进行封送!我们仍然可以使用这个结构体,但完全省略 `dwParams`。当我们需要它时,我们将手动编写它。
[StructLayout(LayoutKind.Sequential)]
private struct MIDIHDR {
public IntPtr lpData;
public int dwBufferLength;
public int dwBytesRecorded;
public IntPtr dwUser;
public int dwFlags;
public IntPtr lpNext;
public IntPtr reserved;
public int dwOffset;
public IntPtr dwReserved1;
public IntPtr dwReserved2;
public IntPtr dwReserved3;
public IntPtr dwReserved4;
public IntPtr dwReserved5;
public IntPtr dwReserved6;
public IntPtr dwReserved7;
public IntPtr dwReserved8;
}
[StructLayout(LayoutKind.Sequential)]
private struct MIDIEVENT {
public int dwDeltaTime;
public int dwStreamID;
public int dwEvent;
// DWORD dwParams[] is marshalled manually
}
太好了,这是我们的主要 P/Invoke 声明!
但是,`dwReserved1`、`dwReserved2`、`dwReserved3` 和 `dwReserved4` 等字段看起来很糟糕。确实如此。实际上,还有另一种方法,对键盘更友好,也更易于使用,但会让封送处理程序在幕后做更多工作。我们可以这样声明字段:
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)]
public IntPtr[] dwReserved;
我们没有这样做的原因如下。首先,我们自己永远不会使用这些字段,所以我们不必关心它们的外观和作用。其次,只有 8 个,而不是 256 个。我刚才是在开玩笑,我不会让你打那么多字。你以为我是什么怪物?最后,如果我们使用上面的方法,封送处理程序必须分配那个数组。我们不需要额外的开销。那是无用的。我知道让 CPU 做无用的事情在当今是可以接受的,甚至是时尚的,但让我们限制一下我们让可怜的 CPU 在这个级别上做不必要的工作,好吗?好的。哇。实际上,这个结构体在时间敏感的调用中被频繁使用。最好以一种认真的方式编写代码。这是一个多媒体应用程序,不是业务应用程序,而且你无法扩展此代码——如果它不够快,你就不能仅仅再添加一个 Web 服务器!
我们还有很多其他的支持 P/Invoke 互操作代码,但我们只会在这里介绍一些,因为其中很多都超出了在这里深入讨论的范围,而且说实话,如果你理解了上面的内容,你就会理解其余的。我们会更简要地介绍,只是为了周全。
MMRESULT midiOutGetErrorText(MMRESULT mmrError, LPSTR pszText, UINT cchText);
这将为我们从早期 P/Invoke 方法之一返回的 `MMRESULT` 提供一个比较友好的错误消息。我们这样声明:
[DllImport("winmm.dll")]
static extern int midiOutGetErrorText(int mmrError, StringBuilder pszText, int cchText);
在这里使用 `StringBuilder` 告诉封送处理程序这是一个固定长度的字符串缓冲区,将由调用者填充。我知道封送处理程序会从这个声明中收集到这一点很奇怪,但原因是在 C `stdcall` API 调用中,以这种方式填充字符串是一种常见的模式,因此 Microsoft 将此功能作为一种便利。否则,会更困难,因为我们需要手动封送。使用此方法,我们只需将 `StringBuilder` 的容量声明为 `cchText` 的值,然后将其传递。它将在方法返回时填充。
MMRESULT midiOutOpen(LPHMIDIOUT phmo, UINT uDeviceID,
DWORD_PTR dwCallback, DWORD_PTR dwInstance, DWORD fdwOpen);
这个函数打开一个 MIDI 输出设备。该调用通过第一个参数返回一个句柄,该参数是指向句柄的指针,或者在 .NET 中,是一个按引用传递的 `IntPtr`——`ref IntPtr`。但是,由于函数不关心传入参数的值,因此我们使用 `out` 而不是 `ref`。如果你不确定在这种情况下是使用 `ref` 还是 `out`,请使用 `ref`。在这种情况下,我们不使用回调指针,所以我们将其封送为 `IntPtr`。否则,在这里,我们将封送一个委托。以下是我们 C# 声明:
[DllImport("winmm.dll")]
static extern int midiOutOpen(out IntPtr phmo, int uDeviceID,
IntPtr dwCallback, IntPtr dwInstance, int fdwOpen);
下一个我们将介绍涉及回调。
MMRESULT midiStreamOpen(LPHMIDISTRM phms, LPUINT puDeviceID, DWORD cMidi, DWORD_PTR dwCallback, DWORD_PTR dwInstance, DWORD fdwOpen);
这是 C 中的回调函数原型。我们将需要将其传递给上面的 `dwCallback`。
void CALLBACK MidiOutProc( HMIDIOUT hmo, UINT wMsg, DWORD_PTR dwInstance,
DWORD_PTR dwParam1, DWORD_PTR dwParam2);
我们可以忽略上面的 CALLBACK 宏,因为它只是解析为 `stdcall`,而我们已经在使用了。创建委托的过程与创建导入的 P/Invoke 方法的过程相同。应用我们迄今为止学到的知识,得到:
[DllImport("winmm.dll")]
static extern int midiStreamOpen(out IntPtr phms, ref int puDeviceId,
int cMidi, MidiOutProc dwCallback, IntPtr dwInstance, int fdwOpen);
private delegate void MidiOutProc(IntPtr hmo, int wMsg, IntPtr dwInstance,
IntPtr dwParam1, IntPtr dwParam2);
关于我们的回调有几点:首先,对于任何回调,你*必须*确保你的委托在回调被调用期间一直存在。如果不是,你将得到可怕的错误,或者更糟的是,你的应用程序将直接关闭而没有任何错误。不要让垃圾回收器和 Win32 之间进行一场比赛。无论谁赢,*你*都会输。在这种情况下,即使在我们发送完所有数据到设备之后,我们的回调也可能被调用,所以我们必须小心地在应用程序的整个生命周期内保留我们的委托。根据你的需要,生命周期会有所不同,但请务必注意你的回调。其次,在本例中,我们的 `dwParam1` 实例将是指向 `MIDIHDR` 结构体的指针。我们可能声明了 `ref MIDIHDR dwParam`,在某些情况下这可能是明智的,但出于稍后会弄清楚的原因,我们在这里不这样做。
支持方法
现在我们有了 P/Invoke 声明,是时候将它们付诸实践了。
我们将从使用 `MIDIHDR` 的更简单的方法开始——`midiOutLongMsg()`。
static void _SendLong(IntPtr handle,byte[] data,int startIndex,int length)
{
var header = default(MIDIHDR);
var dataHandle = GCHandle.Alloc(data, GCHandleType.Pinned);
try
{
header.lpData = new IntPtr(dataHandle.AddrOfPinnedObject().ToInt64() + startIndex);
header.dwBufferLength = header.dwBytesRecorded = length;
header.dwFlags = 0;
_Check(midiOutPrepareHeader(handle, ref header, MIDIHDR_SIZE));
_Check(midiOutLongMsg(handle, ref header, MIDIHDR_SIZE));
// cheap, but we're not using a callback here:
while ((header.dwFlags & MHDR_DONE) != MHDR_DONE)
{
Thread.Sleep(1);
}
_Check(midiOutUnprepareHeader(handle, ref header, MIDIHDR_SIZE));
}
finally
{
dataHandle.Free();
}
}
在这里,我们使用 `GCHandle` 来固定我们的数据数组。然后我们构建一个 `MIDIHDR` 结构体。我们正在从 `data` 数组偏移 `startIndex`。请注意,我们没有进行边界检查。通常,在这种情况下,这一点尤其重要,因为如果不这样做,你可能会导致访问冲突,但为了示例,我省略了它以保持代码简洁。无论如何,我们设置头部中的字段,然后在调用 `midiOutLongMsg()` 之前调用 `midiOutPrepareHeader()`。对于某些 MIDI 硬件——取决于实际设备是什么——在发送音符时可能会有延迟,但我们不能在发送完成之前调用 `midiOutUnprepareHeader()` 或释放我们的固定数组。由于我们没有使用回调,我们在循环中进行了廉价的等待。大多数时候,sleep 将永远不会执行。有更干净的方法可以做到这一点,但这比看起来要好。数据字节只是 MIDI 消息字节,对应于“音符开”和“音符关”等 MIDI 操作。这些协议超出了本文的范围,但请参阅我的 MIDI 库文章 以了解更多关于它们的内容。
现在进入有趣的部分。这是我们一直以来努力的方向。我们将准备一个 `midiStreamOut()` 调用。这适合那些认为上面的内容太简单(确实如此)的人。在这里,我们将自己完成一些 .NET 封送处理程序一直在为我们做的工作。在 `MIDIHDR` 中,我们需要用指向可变长度 `MIDIEVENT` 结构体的连续数组的指针来填充 `lpData` 成员。标准封送处理程序在那里失效,所以我们将自己处理。每个 `MIDIEVENT` 实例的长度必须是 4 的倍数。据我所知,包括 `MIDIHDR` 在内的所有内存的总大小不能超过 65536 字节——即 64kb。另一个问题是,每次我们想调用 `midiStreamOut()` 时,都需要一个新的缓冲区,并且必须在发送完成后释放该缓冲区,而我们通过回调收到通知。因为我们必须自己处理 `MIDIHDR` 的分配和释放,所以我们将以与上面不同的方式使用它。
关于我们正在制作的缓冲区内存布局要注意的一点是,它是一个单一缓冲区,以 `MIDIHDR` 结构体开头,然后是 `MIDIEVENT` 结构体,所以基本上 `lpData` 成员始终指向 `MIDIHDR` 的最后一个成员之后。虽然我们不必这样做,但替代方法是进行两次单独的堆分配——一次用于 `lpData`,一次用于 `MIDIHDR` 本身。非托管堆分配相对昂贵,并且不必要地这样做会导致堆碎片,进一步降低性能。我们希望在如何使用它方面保持谨慎,并且要正确地做到这一点只需要一点前瞻性。我在下面演示了正确的技术。我们可以通过回收分配来做得更好,但这会增加显著的复杂性,我们在这里将避免。
static void _SendStream(IntPtr handle, IEnumerable<KeyValuePair<int,byte[]>> events)
{
if (null == events)
throw new ArgumentNullException("events");
if (IntPtr.Zero == handle)
throw new InvalidOperationException("The stream is closed.");
int blockSize = 0;
// we always allocate 64k for our buffer. The alternative
// would be to enumerate our events twice, the first time
// to get the total buffer size. That's undesirable
// 64kb of RAM is trivial.
IntPtr headerPointer = Marshal.AllocHGlobal(EVENTBUFFER_MAX_SIZE+MIDIHDR_SIZE);
try
{
IntPtr eventPointer = new IntPtr(headerPointer.ToInt64() + MIDIHDR_SIZE);
var ptrOfs = 0;
var hasEvents = false;
foreach (var @event in events)
{
hasEvents = true;
if(4>@event.Value.Length) // short msg <= 24 bits
{
blockSize += MIDIEVENT_SIZE;
if (EVENTBUFFER_MAX_SIZE <= blockSize)
throw new ArgumentException("There are too many events
in the event buffer - maximum size must be 64k", "events");
var se = default(MIDIEVENT);
se.dwDeltaTime = @event.Key;
se.dwStreamID = 0;
// pack 24 bits into dwEvent
se.dwEvent = ((@event.Value[2] & 0x7F) << 16) +
((@event.Value[1] & 0x7F) << 8) + @event.Value[0];
// this method is documented as "obsolete" but the alternative
// involves declaring a CopyMemory win32 API call
// and using that to copy memory pinned by a GCHandle.
// yuck. Just do this instead.
// I wish MS would give you suitable replacements when
// they deprecate something
// note that we're copying the structure to
// our current working memory location
Marshal.StructureToPtr
(se, new IntPtr(ptrOfs + eventPointer.ToInt64()), false);
// increment our pointer by the size ofa MIDIEVENT
ptrOfs += MIDIEVENT_SIZE;
}
else // probably a sysex - either way, greater than 24 bits.
{
// our MIDIEVENT is variable length but must be a multiple of
// 4 bytes. The following ensures that. dl will contain our
// modified data length, padded as necessary
var dl = @event.Value.Length;
if (0 != (dl % 4))
dl += 4 - (dl % 4);
blockSize += MIDIEVENT_SIZE + dl;
if (EVENTBUFFER_MAX_SIZE <= blockSize)
throw new ArgumentException("There are too many events
in the event buffer - maximum size must be 64k", "events");
var se = default(MIDIEVENT);
se.dwDeltaTime = @event.Key;
se.dwStreamID = 0;
se.dwEvent = MEVT_F_LONG | (@event.Value.Length);
// and once again we copy the structure
// to our current working memory location
Marshal.StructureToPtr
(se, new IntPtr(ptrOfs + eventPointer.ToInt64()), false);
ptrOfs += MIDIEVENT_SIZE; // increment our pointer
// now copy our variable length portion
Marshal.Copy(@event.Value, 0,
new IntPtr(ptrOfs + eventPointer.ToInt64()), @event.Value.Length);
// increment our pointer by the adj. variable length amount
ptrOfs += dl;
}
}
if (hasEvents)
{
var header = default(MIDIHDR);
header.lpData = eventPointer;
header.dwBufferLength = header.dwBytesRecorded = blockSize;
// copy our MIDIHDR into the buffer
Marshal.StructureToPtr(header, headerPointer, false);
_Check(midiOutPrepareHeader(handle, headerPointer, MIDIHDR_SIZE));
_Check(midiStreamOut(handle, headerPointer, MIDIHDR_SIZE));
headerPointer = IntPtr.Zero;
}
}
finally
{
// if headerPointer is non-zero and we got here, either an error occurred
// or midiStreamOut was never called so free it.
if (IntPtr.Zero != headerPointer)
Marshal.FreeHGlobal(headerPointer);
}
}
此例程处理“事件”,其中事件是时间差和某些消息字节的键值对。时间差告诉流“何时”播放消息字节数据。每个时间差都相对于前一个。时间差滴答的长度取决于速度和时间基准,我们在此不介绍如何设置。与之前一样,消息数据字节是 MIDI 字节,对应于不同的操作,如“音符开”或“音符关”。对于每个事件,都有两个主要路径之一可供选择。第一个是消息长度小于 4 字节。如果采用此路径,我们的有效 `MIDIEVENT` 结构体将只是:
typedef struct {
DWORD dwDeltaTime;
DWORD dwStreamID;
DWORD dwEvent;
DWORD dwParms[0];
} MIDIEVENT;
或者更简单地说:
typedef struct {
DWORD dwDeltaTime;
DWORD dwStreamID;
DWORD dwEvent;
} MIDIEVENT;
基本上,如果 `data.Length/@event.Value.Length` 小于 4,我们根本不需要 `dwParms`。
第二种情况更复杂。基本上,我们需要自己编写一个结构体,该结构体必须是 4 字节的倍数,所以这意味着基本上,它必须是一个所有成员都是 `DWORD` 的结构体——至少在内存布局方面。我将向你展示我的意思:
对于 `data.Length` = 4
typedef struct {
DWORD dwDeltaTime;
DWORD dwStreamID;
DWORD dwEvent;
DWORD dwParms[1];
} MIDIEVENT;
或者等价的:
typedef struct {
DWORD dwDeltaTime;
DWORD dwStreamID;
DWORD dwEvent;
DWORD dwParm1;
} MIDIEVENT;
对于 `data.Length` = 5 到 8
typedef struct {
DWORD dwDeltaTime;
DWORD dwStreamID;
DWORD dwEvent;
DWORD dwParms[2];
} MIDIEVENT;
或者等价的替代方案:
typedef struct {
DWORD dwDeltaTime;
DWORD dwStreamID;
DWORD dwEvent;
DWORD dwParm1;
DWORD dwParm2;
} MIDIEVENT;
对于 `data.Length` = 9 到 12
typedef struct {
DWORD dwDeltaTime;
DWORD dwStreamID;
DWORD dwEvent;
DWORD dwParms[3];
} MIDIEVENT;
或者等价的替代方案:
typedef struct {
DWORD dwDeltaTime;
DWORD dwStreamID;
DWORD dwEvent;
DWORD dwParm1;
DWORD dwParm2;
DWORD dwParm3;
} MIDIEVENT;
以此类推,每次需要更多字节时,就添加一个字段或数组元素。
显然,我们没有创建所有这些声明。我们理论上可以那样做,但这将是荒谬且容易出错的,而且结果将是一场维护的噩梦。
相反,我们所做的是将内存复制到缓冲区中的战略位置,这样我们首先写入 3 个成员/12 字节的 `MIDIEVENT` 基本结构体。然后,我们将指针移到该结构体末尾,并编写 `data`/@event.Value 数组,在末尾进行填充,直到它是 4 字节的倍数。我希望这有意义。
这是上面计算填充的代码。这里“dl”是数据长度的缩写。
var dl = @event.Value.Length;
if (0 != (dl % 4))
dl += 4 - (dl % 4);
如果存在更清晰的方法来执行此计算,请便,但目前这样可行。
然后,我们只是在当前位置进行复制:首先,我们执行 `MIDIEVENT` 基本结构体,然后移动指针并复制我们的 `data/@event.Value` 字节。这非常简单。
Marshal.StructureToPtr(se, new IntPtr(ptrOfs + eventPointer.ToInt64()), false);
ptrOfs += MIDIEVENT_SIZE; // increment our pointer
// now copy our variable length portion
Marshal.Copy(@event.Value, 0,
new IntPtr(ptrOfs + eventPointer.ToInt64()), @event.Value.Length);
// increment our pointer by the adj. variable length amount
ptrOfs += dl;
有几点需要注意:第一,我们正在使用 `Marshal.StructureToPtr()`,这在技术上已经过时。有一个使用 `GCHandle` 的替代方法,但它很丑陋,意味着可读性差、易出错,并且还需要另一个 P/Invoke 声明。微软没有为此方法提供合适的替代方案。如果我错了,请在评论中纠正我。第二,我们没有将填充字节清零。我们不必这样做,因为驱动程序从不读取它们。这样做只会增加代码量,从而增加出错的机会,而没有任何实际好处。最后,我们使用 `ToInt64()` 来获取可以进行算术运算的 `IntPtr` 整数值。我们不使用 `ToInt32()` 也不将其转换为 `int`,因为虽然此代码仅在 32 位上进行了测试,但我宁愿不为将来的 64 位支持制造问题。这样做不会造成损害,并使代码稍微更具未来兼容性。
Main() 代码
这是我们调用所有先前创建内容的地方。演示非常简单,足以让上述代码稍微运行一下,并给你一些反馈。我不想将焦点从封送上移开,因为那是本文的重要部分。
这不是一篇关于 MIDI 协议工作原理的文章,但我的 MIDI 库文章 涵盖了一些内容,并且在其库代码中完成了本文探讨的所有内容。
static void Main()
{
IntPtr handle=IntPtr.Zero;
try
{
// open the first MIDI port
_Check(midiOutOpen(out handle, 0, IntPtr.Zero, IntPtr.Zero, 0));
Console.Error.WriteLine("Sending middle C. Press any key to continue...");
// note ON
var data = new byte[4];
data[0] = 0x90;
data[1] = 60;
data[2] = 0x7F;
data[3] = 0;
_SendLong(handle, data, 0, data.Length);
Console.ReadKey(true);
// note OFF
data[0] = 0x80;
data[1] = 60;
data[2] = 0x7F;
data[3] = 0;
_SendLong(handle, data, 0, data.Length);
}
finally
{
if (IntPtr.Zero != handle)
_Check(midiOutClose(handle));
}
// do streaming!
var devId = 0; // use the first MIDI output device
const int NOTE_LEN = 48; // arbitrary distance between the notes, in ticks
// open the stream, specifying our callback
_Check(midiStreamOpen(out handle, ref devId, 1,
MidiOutProcHandler, IntPtr.Zero, CALLBACK_FUNCTION));
try
{
Console.Error.WriteLine("Streaming chords to output. Press any key to exit...");
// start the stream so it will play queued MIDI data
_Check(midiStreamRestart(handle));
// create a buffer of MIDI events
var midiEvents = new KeyValuePair<int, byte[]>[]
{
// first one is a sysex message i just made up
new KeyValuePair<int, byte[]>(0,new byte[] {0xF0,1,2,3,4,5,6,7,8,9,0xF7}),
// note ons
new KeyValuePair<int,byte[]>(0,new byte[] { 0x90,60,127}),
new KeyValuePair<int,byte[]>(0,new byte[] { 0x90,64,127}),
new KeyValuePair<int,byte[]>(0,new byte[] { 0x90,67,127}),
// note offs
new KeyValuePair<int,byte[]>(NOTE_LEN,new byte[] { 0x80,60,127}),
new KeyValuePair<int,byte[]>(0,new byte[] { 0x80,64,127}),
new KeyValuePair<int,byte[]>(0,new byte[] { 0x80,67,127}),
// note ons
new KeyValuePair<int,byte[]>(0,new byte[] { 0x90,62,127}),
new KeyValuePair<int,byte[]>(0,new byte[] { 0x90,65,127}),
new KeyValuePair<int,byte[]>(0,new byte[] { 0x90,69,127}),
// even more notes
new KeyValuePair<int,byte[]>(NOTE_LEN,new byte[] { 0x90,64,127}),
new KeyValuePair<int,byte[]>(0,new byte[] { 0x90,67,127}),
new KeyValuePair<int,byte[]>(0,new byte[] { 0x90,72,127}),
};
// send them
_SendStream(handle, midiEvents);
// wait for exit
Console.ReadKey();
// stop anything still playing
_Check(midiStreamStop(handle));
}
finally
{
if (IntPtr.Zero != handle)
_Check(midiStreamClose(handle));
}
}
我们在这里所做的只是发送一些消息。我编造了一个系统扩展(sysex)消息用于流播放,以测试 `_SendStream()` 中的第二条代码路径,它处理长度超过 24 位的消息。据我所知,这个系统扩展(sysex)消息没有做什么,尽管有可能连接的 MIDI 设备理论上可以识别它,到那时谁知道会发生什么?在你的声卡的 MIDI 波表合成器上,它没有任何效果。请注意,我们实际上应该使用 `midiOutShortMsg()` 而不是 `midiOutLongMsg()`,后者通常只用于无法打包到 24 位的系统扩展(sysex)消息,但这没关系。前者只是同一种东西的优化版本,并且不会演示本文中的任何原理,因此被省略了。
在这里,我们做的唯一其他事情(我不会深入介绍)是在 `_MidiOutProc()` 回调处理程序被调用时,“取消准备”然后释放我们在 `_SendStream()` 中分配和准备的 `MIDIHDR` 缓冲区。我们通过 API 将指针传递给我们。
关注点
使用 MIDI API 是一种新鲜的折磨。几乎没有关于它的文档,而且它非常挑剔。本文中提供的解决方案和临时修复——例如在 `midiOutLongMsg()` 之后在循环中休眠——即使是在 C 中使用此 API 时,也是相当标准的。如果我对任何工作方式有误,请随时在评论中提出投诉并纠正我。
历史
- 2020 年 7 月 4 日 - 初始提交