PInvoke 字符串封送的高级主题






4.87/5 (17投票s)
探讨PInvoke封送字符串的一些微妙的、不同的方法。
引言
通过DllImportAttribute
使用的.NET平台调用工具,是一种与非托管DLL交互的强大而简单的机制。然而,在处理字符串缓冲区所有权责任与非托管代码时,存在许多重要的细微之处。本文除了默认的MarshalAs(UnmanagedType.LPStr)
之外,还涵盖了一些其他选项。
背景
因此,您已经完全投入到.NET中,但您仍然拥有大量要利用的现有本地代码DLL。平台调用提供了一种封装这些本地DLL的机制,并轻松封送一些常见类型的参数,但这并不适用于所有情况。特别是,一些字符串封送到本地代码是复杂的,必须使用不同的技术来处理。
- 如果本地代码在调用后希望拥有该字符串怎么办?
- 如果您想封送UTF8字符串而不是ANSI/ASCII字符串怎么办?
- 如果该参数实际上是一个out缓冲区,目标将写入该缓冲区怎么办?
这些只是与本地代码DLL交互时存在的一些现实情况。您将了解到使用PInvoke处理任何这些情况都很容易,但它们各自截然不同,需要不同的代码。
使用代码
我们在这里不讨论PInvoke的基本概念。为此,我们建议您查阅其他地方提供的许多优秀教程之一。相反,我们将考虑使用PInvoke与声明为以下内容的本地代码DLL入口点交互的几种不同方法:
void my_function(char *data);
此C代码可能与我们在字符指针数据上的几种可能合同。以下是一些合同。在所有情况下,我们都假定字符串是null终止的ANSI/ASCII字符串。
data
在调用期间可能是只读的,并且永远不会被本地代码存储data
在调用期间可能会被修改,并且永远不会被本地代码存储data
可能会被本地代码采纳为自己的,本地代码期望稍后释放它
如果您熟悉PInvoke教程,您应该知道如何处理上面#1的情况。我们只需声明入口点并指定内置的LPStr
封送器。
[DllImport("mydllname")]
extern static unsafe void my_function( [MarshalAs(UnmanagedType.LPStr)] string data)
属性装饰器很简单,并且自动处理上面#1的情况。调用之前,内置封送器会为null终止字符串分配一个固定位置的缓冲区,并将托管字符串的ANSI/ASCII兼容版本复制到缓冲区中。调用之后,封送器会自动释放缓冲区,确保不发生内存泄漏。
那么我们如何处理上面的#2,其中数据可能会在调用期间被修改?默认封送器不考虑调用后缓冲区的 contents,那么我们如何看到修改呢?快速阅读文档表明,我们可以将[Out]
属性添加到封送器上,但真正的挑战在于幕后。
如果本地调用只是修改几个字符,同时保持长度不变,那么我们只需要在之后将内容复制回托管代码。然而,如果它要在字符串末尾添加更多数据,我们的缓冲区就不足以容纳更多数据了!它会覆盖内存中其他随机的内容。
本地代码如何知道我们的缓冲区有多大?非托管代码接口就是如此复杂。也许该函数会接受一个额外的参数,即字符串缓冲区的最大长度,以便它可以小心地不覆盖缓冲区末尾。也许合同只是期望字符串缓冲区上写入的最大数据量已知为4000个字符。虽然这些合同可能看起来很混乱,但这就是非托管接口的现实。我们将假定后一种情况。
我们的非托管合同现在是
void my_function(char *data);
// data should point to a char[4000] that we can write to during the call
为了满足此调用,我们需要托管代码分配一个4000个字符的缓冲区,然后在之后复制内容。如果我们知道字符串是ANSI字符串,则以下代码将实现所需的结果
private class imp {
[DllImport("mydllname")]
private extern static unsafe my_function(IntPtr data);
}
public unsafe void my_function(out string data) {
IntPtr buffer = (IntPtr)stackalloc byte[4000];
imp.my_function(buffer);
data = Marshal.PtrToStringAnsi(buffer);
}
stackalloc
为栈上的分配腾出空间,栈上的分配保证在调用期间保持固定和可用。Marshal.PtrToStringAnsi()
会自动将null终止的ANSI/ASCII字符缓冲区转换为托管字符串,并在此过程中分配托管字符串。
如果字符串参数需要同时传递到函数中和从函数中传出,那么一个新的封送桩也可以处理这种情况。
private class imp {
[DllImport("mydllname")]
private extern static unsafe my_function(IntPtr data);
}
public unsafe void my_function(ref string data) {
// allocate room on the stack
IntPtr buffer = (IntPtr)stackalloc byte[4000];
// convert the managed string into an ASCII byte[]
byte[] data_buf = Encoding.ASCII.GetBytes(data);
// check for out-of-bounds
if (data_buf.Length > (4000-1)) {
throw new Exception("input too large for fixed size buffer");
}
// .. then copy the bytes
Marshal.Copy(data_buf, 0, buffer, data_buf.Length);
Marshal.WriteByte(buffer + data_buf.Length, 0); // terminating null
imp.my_function(buffer);
// after the call, marshal the bytes back out
data = Marshal.PtrToStringAnsi(buffer);
}
然而,现在情况#3呢?我们不能让本地代码拥有栈上的数据结构,因为它在调用后就不存在了!我们需要分配可以被其拥有的内存。此外,如果我们要发送的字符串是UTF8字符串,而不是ANSI/ASCII字符串怎么办?没有自动的UTF8字符串封送转换器,所以我们需要做更多的工作。让我们考虑以下本地调用
void my_function(char *data);
// data points to a heap allocated UTF8 string which will be adopted by
// the my_function DLL. It will be freed later by the DLL when it's no
// longer needed.
这种情况带来了一些额外的复杂性,因为典型的系统有许多分配器。第一个重要任务是确定本地DLL使用的分配器,因为使用相同的分配器分配内存很重要,如果本地代码要释放它。如果您可以控制本地DLL的构建,一种安全的方法是从本地DLL中导出一个显式的my_malloc
和my_free
,它使用与它相同的分配器,确保您的.NET运行时可以使用这些入口点始终访问相同的分配器。然而,如果您知道DLL使用标准的全局Windows分配器,那么您可以使用Marshal.AllocHGlobal
和Marshal.FreeHGlobal
。
private class imp {
[DllImport("mydllname")]
private extern static unsafe my_function(IntPtr data);
}
public unsafe void my_function(string data) {
IntPtr buffer = IntPtr.Zero;
string data;
try {
// remember the byte[] is not null terminated
byte[] strbuf = Encoding.UTF8.GetBytes(data);
// .. so add one more byte for the null termination
buffer = Marshal.AllocHGlobal(strbuf.Length + 1);
// .. then copy the bytes
Marshal.Copy(strbuf, 0, buffer, strbuf.Length);
Marshal.WriteByte(buffer + strbuf.Length, 0); // terminating null
} catch (Exception e) {
// be sure to free the buffer if it was allocated
if (buffer != IntPtr.Zero) {
Marshal.FreeHGlobal(buffer);
}
}
// call the function with our buffer
imp.my_function(buffer);
}
这部分代码比预期的要多!然而,如果您逐节遵循它,您会发现它满足了本地代码合同。它将我们的托管字符串转换为UTF8 byte[];
。然后,它分配一个足够大的本地代码缓冲区来容纳此数组加上末尾的null终止符。它将字节从数组复制出来,然后在本地缓冲区的末尾写入一个null字符。
我将再次提出分配器的问题,因为这至关重要。如果本地代码使用的分配器与AllocHGlobal
使用的分配器不同,那么当它尝试释放此内存时,就会发生糟糕的事情。如果幸运的话,程序在测试时会崩溃。如果您不幸运,内存将被静默损坏。我们在.NET下可以访问的另一个分配器是CoTaskMemAlloc
。然而,对于任何期望内存所有权跨越此类边界的DLL来说,最安全的方法是显式提供自己的my_alloc
和my_free
入口点。在这种情况下,您将使用这些入口点来分配和释放上面的缓冲区,而不是AllocHGlobal
和FreeHGlobal
。
构建自定义封送器
我知道你在想什么,因为我也在想。我如何使用简单的装饰器语法来创建我自己的封送器,而不是所有这些包装器代码?幸运的是,平台调用提供了一种方法来实现这一点,即编写自定义封送类。下面是一个自定义封送类的示例,它的功能与LPStr
封送器完全相同,只是它转换为UTF8而不是ANSI字符串。
public class UTF8Marshaler : ICustomMarshaler {
static UTF8Marshaler static_instance;
public IntPtr MarshalManagedToNative(object managedObj) {
if (managedObj == null)
return IntPtr.Zero;
if (!(managedObj is string))
throw new MarshalDirectiveException(
"UTF8Marshaler must be used on a string.");
// not null terminated
byte[] strbuf = Encoding.UTF8.GetBytes((string)managedObj);
IntPtr buffer = Marshal.AllocHGlobal(strbuf.Length + 1);
Marshal.Copy(strbuf, 0, buffer, strbuf.Length);
// write the terminating null
Marshal.WriteByte(buffer + strbuf.Length, 0);
return buffer;
}
public unsafe object MarshalNativeToManaged(IntPtr pNativeData) {
byte* walk = (byte*)pNativeData;
// find the end of the string
while (*walk != 0) {
walk++;
}
int length = (int)(walk - (byte*)pNativeData);
// should not be null terminated
byte[] strbuf = new byte[length];
// skip the trailing null
Marshal.Copy((IntPtr)pNativeData, strbuf, 0, length);
string data = Encoding.UTF8.GetString(strbuf);
return data;
}
public void CleanUpNativeData(IntPtr pNativeData) {
Marshal.FreeHGlobal(pNativeData);
}
public void CleanUpManagedData(object managedObj) {
}
public int GetNativeDataSize() {
return -1;
}
public static ICustomMarshaler GetInstance(string cookie) {
if (static_instance == null) {
return static_instance = new UTF8Marshaler();
}
return static_instance;
}
}
使用这个自定义封送器就像使用简单的内置LPStr
封送器一样容易。对于我们的入口点,装饰器看起来像这样
[DllImport("mydllname")]
extern static void my_function(
[MarshalAs(UnmanagedType.CustomMarshaler,
MarshalTypeRef=typeof(UTF8Marshaler))]
string data);
特殊注意事项
自定义封送器实例使用GetInstance()
方法检索,以便能够进行通用优化,即封送器完全是静态的,不需要为每个参数重新分配新值。如果您希望为特定的封送实例维护状态,则从GetInstance()
返回类的 新实例非常重要。
上面的代码总是将字符串缓冲区分配到堆上,但是对于您知道大小合理的字符串,将其分配到栈上可能是有利的。一种方法是创建一个单独的自定义封送器,该封送器始终使用栈,并为其命名一个明显的名字,如UTF8StackMarshaller
。另一种可能性是返回一个实际实例从get-instance,然后存储实例内的状态,该状态知道数据是封送到栈还是堆,可能基于字符串的大小。
历史
- 初始版本:2010年12月21日。