Marshal Complex Structures and String Arrays in C

Interoperability between managed .NET code and unmanaged C/C++ libraries requires precise control over memory layouts and data representation. This guide covers how to marshal complex nested structures, fixed and variable string arrays, and custom unmanaged pointers using C#'s Platform Invoke (P/Invoke) layer. By properly configuring attributes such as StructLayoutAttribute and MarshalAsAttribute, you can pass rich data models seamlessly across the native boundary without memory corruption or memory leaks.

Marshaling Complex Structures

Unmanaged memory layouts must align exactly with managed structures. When dealing with complex structures containing primitive types, fixed-size buffers, or nested structures, you must explicitly declare how the Common Language Runtime (CLR) positions fields in memory.

1. Struct Layout Configuration

Use the [StructLayout] attribute to define field alignment. In most C-compatible ABIs, LayoutKind.Sequential is used:

using System;
using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct NativeChild
{
    public int Id;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
    public string Name;
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct NativeParent
{
    public int Version;
    public NativeChild Details; // Embedded nested structure
    public IntPtr OptionalData;  // Pointer to external memory/payload
}
  • LayoutKind.Sequential: Orders fields in physical memory in the order they appear in the source code.
  • Pack: Can be set (e.g., Pack = 4 or Pack = 8) if the native library was compiled with specific compiler packing pragmas.
  • MarshalAs(UnmanagedType.ByValTStr, SizeConst = N): Inlines an array of characters directly inside the struct rather than treating the string as a pointer (char*).

Marshaling String Arrays (string[])

An array of strings in C# is an array of object references. In native C/C++, a string array is typically represented as an array of pointers (char** or wchar_t**).

Passing a String Array to Unmanaged Code (In)

To pass an array of strings to a function expecting const char**:

[DllImport("NativeLib.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
public static extern void ProcessStrings(
    [In, MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.LPStr)] 
    string[] strings, 
    int count
);
  • UnmanagedType.LPArray: Instructs the marshaler to pack the array as a contiguous native array of pointers.
  • ArraySubType = UnmanagedType.LPStr: Tells the marshaler that each pointer in the array points to a null-terminated ANSI C-string (char*). For Unicode, change CharSet to CharSet.Unicode and ArraySubType to UnmanagedType.LPWStr.

Marshaling Structures Containing String Arrays

When a structure contains an array of strings (e.g., char** items), the CLR cannot automatically determine the array length at runtime. You must handle this scenario using manual pointer allocation via IntPtr.

Example Native Definition

typedef struct {
    int count;
    char** items;
} BatchData;

Managed Representation and Manual Allocation

In C#, define the pointer as an IntPtr:

[StructLayout(LayoutKind.Sequential)]
public struct BatchData
{
    public int Count;
    public IntPtr Items; // Represents char**
}

To populate and send this structure to unmanaged code:

public static void SendBatch(string[] managedStrings)
{
    int count = managedStrings.Length;
    IntPtr[] stringPointers = new IntPtr[count];

    try
    {
        // 1. Allocate native memory for each string (char*)
        for (int i = 0; i < count; i++)
        {
            stringPointers[i] = Marshal.StringToHGlobalAnsi(managedStrings[i]);
        }

        // 2. Allocate native memory for the array of pointers (char**)
        int pointerSize = IntPtr.Size;
        IntPtr nativeArrayPtr = Marshal.AllocHGlobal(pointerSize * count);

        try
        {
            // 3. Copy pointer array to unmanaged buffer
            Marshal.Copy(stringPointers, 0, nativeArrayPtr, count);

            // 4. Construct the managed structure
            BatchData batch = new BatchData
            {
                Count = count,
                Items = nativeArrayPtr
            };

            // 5. Call the native API
            NativeMethods.ProcessBatch(ref batch);
        }
        finally
        {
            // Free the pointer array memory
            Marshal.FreeHGlobal(nativeArrayPtr);
        }
    }
    finally
    {
        // Free individual allocated strings
        for (int i = 0; i < count; i++)
        {
            if (stringPointers[i] != IntPtr.Zero)
            {
                Marshal.FreeHGlobal(stringPointers[i]);
            }
        }
    }
}

Receiving Dynamic Structures and String Arrays from Native Code

When native code allocates a structure containing dynamic arrays and returns it to C#, the managed side must deconstruct the pointers.

[DllImport("NativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern void GetBatchData(out BatchData data);

[DllImport("NativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern void FreeBatchData(ref BatchData data);

public static string[] ReadBatchData()
{
    GetBatchData(out BatchData data);
    string[] result = new string[data.Count];

    if (data.Count > 0 && data.Items != IntPtr.Zero)
    {
        IntPtr[] nativePointers = new IntPtr[data.Count];
        Marshal.Copy(data.Items, nativePointers, 0, data.Count);

        for (int i = 0; i < data.Count; i++)
        {
            result[i] = Marshal.PtrToStringAnsi(nativePointers[i]);
        }
    }

    // Call native cleanup function to let the native allocator free memory
    FreeBatchData(ref data);

    return result;
}

Key Guidelines

  • Match Architecture: Ensure that pointer arithmetic respects target platform architecture. IntPtr.Size is 4 bytes on x86 and 8 bytes on x64.
  • Respect Ownership: If unmanaged code allocates memory, use a corresponding native exported function to release it, rather than calling managed Marshal.FreeHGlobal or Marshal.FreeCoTaskMem, unless the native function explicitly used malloc or CoTaskMemAlloc.
  • String Encodings: Verify whether the native code expects ANSI (UTF-8 on modern Linux/macOS runtimes or local code page on Windows) or wide characters (UTF-16 on Windows). Keep CharSet and MarshalAs configurations aligned with the native compiler settings.