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 = 4orPack = 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, changeCharSettoCharSet.UnicodeandArraySubTypetoUnmanagedType.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.Sizeis 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.FreeHGlobalorMarshal.FreeCoTaskMem, unless the native function explicitly usedmallocorCoTaskMemAlloc. - 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
CharSetandMarshalAsconfigurations aligned with the native compiler settings.