如何将包含可变大小数组的结构体编组到C#中?

28

如何序列化这个C++类型?

ABS_DATA结构用于将任意长度的数据块与长度信息关联。 Data 数组的声明长度为1,但实际长度由 Length 成员给出。

typedef struct abs_data {
  ABS_DWORD Length;
  ABS_BYTE Data[ABS_VARLEN];
} ABS_DATA;

我尝试了下面的代码,但它没有起作用。数据变量始终为空,但我确定其中有数据。

[System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential, CharSet = System.Runtime.InteropServices.CharSet.Ansi)]
    public struct abs_data
    {
        /// ABS_DWORD->unsigned int
        public uint Length;

        /// ABS_BYTE[1]
       [System.Runtime.InteropServices.MarshalAsAttribute(System.Runtime.InteropServices.UnmanagedType.ByValTStr, SizeConst = 1)]
        public string Data;
    }

所以,Data是一个只有1个字节的字节数组? - Jonathan Henson
你有定义 ABS_BYTE 和 ABS_VARLEN 的 typedefs 可以让我看一下吗? - Jonathan Henson
不,它通常会有几千个字节。 - Ezi
那你为什么只把它定义成1个字节长呢? - Jonathan Henson
typedef unsigned char ABS_BYTE 无符号整数类型(1字节) - Ezi
我不知道为什么有一个1在那里。我试图将它设为100000,但只有一点点信息在里面,而不是完整的数据。如果我再增加它,就会出现溢出错误。 - Ezi
5个回答

47

虽然这是个老问题,但最近我不得不自己解决它,所有现有的答案都很差,所以...

在结构体中编组可变长度数组的最佳解决方案是使用自定义编组程序。这让您控制运行时用于在托管和非托管数据之间转换的代码。不幸的是,自定义编组程序文档不全,且存在一些奇怪的限制。我将快速介绍这些限制,然后介绍解决方案。

令人恼火的是,您不能在结构或类的数组成员上使用自定义编组程序。没有文件记录或逻辑原因可以解释这种限制,编译器也不会发出警告,但在运行时会引发异常。此外,自定义编组程序必须实现一个函数int GetNativeDataSize(),显然无法准确地实现它(它不会传递对象实例来询问其大小,因此您只能根据类型进行推断,而类型当然是可变大小的!)幸运的是,此函数并不重要。我从未见过它被调用,即使它返回虚假值(MSDN的一个示例将其返回为 -1),自定义编组程序也可以正常工作。

首先,这是我认为您的本机原型可能看起来像的东西(我在这里使用P/Invoke,但它也适用于COM):

// Unmanaged C/C++ code prototype (guess)
//void DoThing (ABS_DATA *pData);

// Guess at your managed call with the "marshal one-byte ByValArray" version
//[DllImport("libname.dll")] public extern void DoThing (ref abs_data pData);

这是您可能会使用自定义编组程序的天真版本(实际上应该很有效)。稍后我会详细介绍编组程序本身...
[StructLayout(LayoutKind.Sequential)]
public struct abs_data
{
    // Don't need the length as a separate filed; managed arrays know it.
    [MarshalAs(UnmanagedType.CustomMarshaler, MarshalTypeRef=typeof(ArrayMarshaler<byte>))]
    public byte[] Data;
}

// Now you can just pass the struct but it takes arbitrary sizes!
[DllImport("libname.dll")] public extern void DoThing (ref abs_data pData);

很不幸,运行时你似乎不能将数组作为除了SafeArrayByValArray之外的任何数据结构进行编组。SafeArrays是有计数的,但它们看起来与你在这里寻找的(非常常见的)格式完全不同。所以那行不通。当然,ByValArray要求在编译时知道长度,因此也行不通(就像你遇到的那样)。但奇怪的是,你可以对数组参数使用自定义编组。这很麻烦,因为你必须在每个使用该类型的参数上放置MarshalAsAttribute,而不仅仅是在一个字段上放置它,并且让它适用于你使用包含该字段的类型的所有地方,但这就是生活。它看起来像这样:

[StructLayout(LayoutKind.Sequential)]
public struct abs_data
{
    // Don't need the length as a separate filed; managed arrays know it.
    // This isn't an array anymore; we pass an array of this instead.
    public byte Data;
}

// Now you pass an arbitrary-sized array of the struct
[DllImport("libname.dll")] public extern void DoThing (
    // Have to put this huge stupid attribute on every parameter of this type
    [MarshalAs(UnmanagedType.CustomMarshaler, MarshalTypeRef=typeof(ArrayMarshaler<abs_data>))]
    // Don't need to use "ref" anymore; arrays are ref types and pass as pointer-to
    abs_data[] pData);

在这个例子中,我保留了abs_data类型,以防您想对它进行特殊处理(构造函数、静态函数、属性、继承等)。如果您的数组元素包含复杂类型,则需要修改结构体以表示该复杂类型。然而,在这种情况下,abs_data基本上只是一个重命名的字节 - 它甚至没有“包装”字节;就本地代码而言,它更像是一个typedef - 因此您可以直接传递字节数组并跳过整个结构体:

// Actually, you can just pass an arbitrary-length byte array!
[DllImport("libname.dll")] public extern void DoThing (
    // Have to put this huge stupid attribute on every parameter of this type
    [MarshalAs(UnmanagedType.CustomMarshaler, MarshalTypeRef=typeof(ArrayMarshaler<byte>))]
    byte[] pData);

好的,现在您可以看到如何声明数组元素类型(如果需要),以及如何将数组传递给非托管函数。但是,我们仍然需要自定义的marshaler。您应该阅读“实现ICustomMarshaler接口”,但我会在此处进行介绍,并带有内联注释。请注意,我使用了一些简写约定(例如Marshal.SizeOf<T>()),这需要.NET 4.5.1或更高版本。

// The class that does the marshaling. Making it generic is not required, but
// will make it easier to use the same custom marshaler for multiple array types.
public class ArrayMarshaler<T> : ICustomMarshaler
{
    // All custom marshalers require a static factory method with this signature.
    public static ICustomMarshaler GetInstance (String cookie)
    {
        return new ArrayMarshaler<T>();
    }

    // This is the function that builds the managed type - in this case, the managed
    // array - from a pointer. You can just return null here if only sending the 
    // array as an in-parameter.
    public Object MarshalNativeToManaged (IntPtr pNativeData)
    {
        // First, sanity check...
        if (IntPtr.Zero == pNativeData) return null;
        // Start by reading the size of the array ("Length" from your ABS_DATA struct)
        int length = Marshal.ReadInt32(pNativeData);
        // Create the managed array that will be returned
        T[] array = new T[length];
        // For efficiency, only compute the element size once
        int elSiz = Marshal.SizeOf<T>();
        // Populate the array
        for (int i = 0; i < length; i++)
        {
            array[i] = Marshal.PtrToStructure<T>(pNativeData + sizeof(int) + (elSiz * i));
        }
        // Alternate method, for arrays of primitive types only:
        // Marshal.Copy(pNativeData + sizeof(int), array, 0, length);
        return array;
    }

    // This is the function that marshals your managed array to unmanaged memory.
    // If you only ever marshal the array out, not in, you can return IntPtr.Zero
    public IntPtr MarshalManagedToNative (Object ManagedObject)
    {
        if (null == ManagedObject) return IntPtr.Zero;
        T[] array = (T[])ManagedObj;
        int elSiz = Marshal.SizeOf<T>();
        // Get the total size of unmanaged memory that is needed (length + elements)
        int size = sizeof(int) + (elSiz * array.Length);
        // Allocate unmanaged space. For COM, use Marshal.AllocCoTaskMem instead.
        IntPtr ptr = Marshal.AllocHGlobal(size);
        // Write the "Length" field first
        Marshal.WriteInt32(ptr, array.Length);
        // Write the array data
        for (int i = 0; i < array.Length; i++)
        {   // Newly-allocated space has no existing object, so the last param is false
            Marshal.StructureToPtr<T>(array[i], ptr + sizeof(int) + (elSiz * i), false);
        }
        // If you're only using arrays of primitive types, you could use this instead:
        //Marshal.Copy(array, 0, ptr + sizeof(int), array.Length);
        return ptr;
    }

    // This function is called after completing the call that required marshaling to
    // unmanaged memory. You should use it to free any unmanaged memory you allocated.
    // If you never consume unmanaged memory or other resources, do nothing here.
    public void CleanUpNativeData (IntPtr pNativeData)
    {
        // Free the unmanaged memory. Use Marshal.FreeCoTaskMem if using COM.
        Marshal.FreeHGlobal(pNativeData);
    }

    // If, after marshaling from unmanaged to managed, you have anything that needs
    // to be taken care of when you're done with the object, put it here. Garbage 
    // collection will free the managed object, so I've left this function empty.
    public void CleanUpManagedData (Object ManagedObj)
    { }

    // This function is a lie. It looks like it should be impossible to get the right 
    // value - the whole problem is that the size of each array is variable! 
    // - but in practice the runtime doesn't rely on this and may not even call it.
    // The MSDN example returns -1; I'll try to be a little more realistic.
    public int GetNativeDataSize ()
    {
        return sizeof(int) + Marshal.SizeOf<T>();
    }
}

哇,这太长了!好吧,你已经看到了。我希望人们能看到这篇文章,因为有很多错误答案和误解存在...


1
感谢您出色的总结!这对我理解marshal内部有很大帮助.....而我遇到了稍微糟糕一些的情况:"struct Foo {int a; int b; /* 无关字段 */, int size, int arr[0]; }"。由于额外的a和b字段,我不能像您那样将其作为参数传递。有什么建议吗? - wsxiaoys
@wsxiaoys:我不明白问题在哪里...你可以为Foo编写一个自定义的编组器,为ab和你的“无关字段”分配额外的字节并填充它们,然后在适当的偏移处写入数组大小和值。解组也是一样的;以与上面读取大小相同的方式从本机指针中读取ab,但在适当的偏移处,然后获取大小并分配托管数组,最后填充它。Foo的托管表示可能不需要size字段,但如果需要,可以包含它。 - CBHacking
非常感谢,信息丰富且有用。不幸的是,在“core”分支5.0中似乎无法正常工作-请参见https://github.com/dotnet/runtime/issues/8271。无论如何,还是要为这个好答案点赞! - Mosè Bottacini
@MosèBottacini ,在结构字段上无法使用自定义的编组程序 - 正如我所说,编译器不会抱怨,但它会产生运行时错误 - 这就是您链接的问题所尝试的。 您仍然可以编组函数参数,就像我展示的那样,或者至少您应该能够这样做,如果您不能,请在GitHub上打开一个新的错误报告(您链接的那个不同)。 - CBHacking
@CBHacking,也许我表达得不够清楚,但我感谢您提供的出色答案。是的,我发布的链接确切地符合我的情况。无论如何,我已经重新设计了代码,使其更加“ .Net 5.0”导向,这既是好事也是坏事,但它有它的“优点”,比如更多的控制权,代价是需要编写更多的代码:现在我有了填充和提取Span<byte>的序列化和反序列化方法。 - Mosè Bottacini

7

结构体包含可变长度数组是不可能进行编组的(但可以将可变长度数组作为函数参数编组)。您需要手动读取数据:

IntPtr nativeData = ... ;
var length = Marshal.ReadUInt32 (nativeData) ;
var bytes  = new byte[length] ;

Marshal.Copy (new IntPtr ((long)nativeData + 4), bytes, 0, length) ;

2
很遗憾,在.NET 4.0框架中不存在Marshal.ReadUInt32()方法。有人能解释为什么吗? - Cameron
1
可能是因为无符号类型不符合CLS标准。使用Marshal.ReadIntNn函数并手动转换为无符号。 - Anton Tykhyy

6

如果要保存的数据不是字符串,您就不必将其存储为字符串。除非原始数据类型是char*,否则我通常不会将其编组成字符串。否则,应该使用byte[]

尝试:

[MarshalAs(UnmanagedType.ByValArray, SizeConst=[whatever your size is]]
byte[] Data;

如果您需要将其转换为字符串,请使用以下方法:
System.Text.Encoding.UTF8.GetString(your byte array here). 

显然,您需要根据需要更改编码,尽管UTF-8通常足以满足需求。
我现在明白了问题,您必须组合一个可变长度的数组。MarshalAs不允许这样做,因此数组将需要通过引用发送。
如果数组长度是可变的,则您的byte[]需要是IntPtr,因此您将使用以下代码:
IntPtr Data;

替代

[MarshalAs(UnmanagedType.ByValArray, SizeConst=[whatever your size is]]
byte[] Data;

您可以使用Marshal类访问底层数据。例如:
uint length = yourABSObject.Length;
byte[] buffer = new byte[length];

Marshal.Copy(buffer, 0, yourABSObject.Data, length);

当你完成时,你可能需要清理你的内存以避免泄漏,但我认为当你的ABSObject超出范围时,垃圾回收器会清理它。无论如何,这里是清理代码:

Marshal.FreeHGlobal(yourABSObject.Data);

3
问题在于长度基于公共的无符号整数Length,但我无法在那里填入数据。 - Ezi
2
我认为如果你直接删除无关部分,你的回答会更容易理解,而不是在其末尾添加“更新”。没有必要在问题中保留过去迭代响应的记录;编辑历史已经有了这一点,以防有人真的想知道你的原始答案。 - Rob Kennedy
3
@Jonathan:这里不需要调用Marshal.FreeHGlobal,因为没有未管理的数组需要释放。该数组是在问题中声明的未管理结构的一部分。如果需要释放整个结构,则是另一回事。无论如何,未管理指针必须通过用于首次分配它们的分配器进行释放,而有许多未管理内存分配器。 - Anton Tykhyy
@Jonathan:*(IntPtr*)&unmanaged.Data[0]是由unmanaged.Data的前4个字节(或在64位环境中为8个字节)组成的IntPtr。(IntPtr)&unmanaged.Data[0]是一个指向unmanaged.Data开头的指针。重点是,marshaler(打包器)不会以任何特殊方式处理IntPtr,它只是一个指针大小的数字。 - Anton Tykhyy
1
@Anton,我只是看不出这两个C++声明有什么主要区别。ABS_BYTE* data与引用ABS_BYTE Data[]作为Data是一样的。因此,我不明白它们为什么会被编组成不同的方式。我并不怀疑它们是不同的,我只是不明白为什么。 - Jonathan Henson
显示剩余12条评论

3

您正在尝试将一个长度为1的字符串作为byte[ABS_VARLEN]进行编组。您需要确定ABS_VARLEN常量是什么,并将数组编组为:

[MarshalAs(UnmanagedType.LPArray, SizeConst = 1024)]
public byte[] Data;

(这里的1024是一个占位符;请填写ASB_VARLEN实际值。)

3
ABS_VARLEN 意味着它的长度可能始终不同,根据公共 uint Length。 - Ezi
2
非 C 程序员似乎对这个结构体有很多疑问和误解。通常,结构体定义了一个数组,其中 1 的意思是“接下来的内存中将包含由 'Length' 成员指定的任意数量的字节,您需要遍历并解释它们。”1 并不一定意味着 1,也没有已知的可能值。它完全是在运行时根据情况确定的。 - ribram

2

在我看来,将数组固定并获取其地址更加简单高效。

假设您需要将abs_data传递给myNativeFunction(abs_data*)

public struct abs_data
{
    public uint Length;
    public IntPtr Data;
}

[DllImport("myDll.dll")]
static extern void myNativeFunction(ref abs_data data);

void CallNativeFunc(byte[] data)
{
    GCHandle pin = GCHandle.Alloc(data, GCHandleType.Pinned);

    abs_data tmp;
    tmp.Length = data.Length;
    tmp.Data = pin.AddrOfPinnedObject();

    myNativeFunction(ref tmp);

    pin.Free();
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接