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

PInvoke 字符串封送的高级主题

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.87/5 (17投票s)

2010 年 12 月 21 日

CPOL

7分钟阅读

viewsIcon

68527

探讨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字符串。

  1. data在调用期间可能是只读的,并且永远不会被本地代码存储
  2. data在调用期间可能会被修改,并且永远不会被本地代码存储
  3. 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_mallocmy_free,它使用与它相同的分配器,确保您的.NET运行时可以使用这些入口点始终访问相同的分配器。然而,如果您知道DLL使用标准的全局Windows分配器,那么您可以使用Marshal.AllocHGlobalMarshal.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_allocmy_free入口点。在这种情况下,您将使用这些入口点来分配和释放上面的缓冲区,而不是AllocHGlobalFreeHGlobal

构建自定义封送器

我知道你在想什么,因为我也在想。我如何使用简单的装饰器语法来创建我自己的封送器,而不是所有这些包装器代码?幸运的是,平台调用提供了一种方法来实现这一点,即编写自定义封送类。下面是一个自定义封送类的示例,它的功能与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日。
© . All rights reserved.