C# P/Invoke: Calling C++ DLLs (DllImport to LibraryImport)

A detailed guide on calling native C/C++ DLLs from C# using P/Invoke, covering traditional DllImport, modern LibraryImport, and struct marshalling.

In .NET development, it’s often necessary to interact with native code libraries written in C/C++. Platform Invoke (P/Invoke) is a powerful feature provided by .NET that allows managed code (like C#) to call unmanaged functions, such as those in a DLL.

This guide covers the two primary P/Invoke methods and demonstrates how to handle complex data structures.

Method 1: Traditional [DllImport]

[DllImport] has been the classic way to perform P/Invoke since the .NET Framework era. It uses an attribute to declare a reference to a function within a DLL.

1
2
3
// Use the DllImport attribute to specify the DLL name and function entry point
[DllImport("ZrqHRV.dll", CallingConvention = CallingConvention.StdCall, EntryPoint = "func")]
public static extern int func(int devtype);

Method 2: Modern [LibraryImport] (for .NET 7+)

Starting with .NET 7, the recommended approach is to use the [LibraryImport] attribute. It leverages a source generator to create marshalling code at compile time, which offers better performance and an improved debugging experience compared to the runtime generation of [DllImport].

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// The new, more performant method in .NET 7+
[LibraryImport("nativelib", EntryPoint = "to_lower", StringMarshalling = StringMarshalling.Utf16)]
internal static partial string ToLower(string str);

// Use with MarshalAs for finer-grained marshalling control
[LibraryImport("nativelib", EntryPoint = "to_lower")]
[return: MarshalAs(UnmanagedType.LPWStr)]
internal static partial string ToLowerWithMarshalAs([MarshalAs(UnmanagedType.LPWStr)] string str);

// Specify a calling convention
[LibraryImport("nativelib", EntryPoint = "to_lower", StringMarshalling = StringMarshalling.Utf16)]
[UnmanagedCallConv(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvStdcall) })]
internal static partial string ToLowerWithCallConv(string str);

Data Marshalling: Defining Structs

When you need to pass complex data, you typically define a C# struct that matches the memory layout of the C/C++ struct.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Use StructLayout to ensure memory layout compatibility with C/C++
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct ParamsIn
{
    public int deviceId; // Device ID
    public int packageSn; // Package serial number

    // Use MarshalAs to define a fixed-size array
    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 200)]
    public int[] ecgList; // ECG waveform, 200 points

    public int ecgFlag; // ECG lead alarm flag: 0 for normal, 1 for off
    public int ecgFs; // ECG sample rate

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 25)]
    public int[] chData;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 25)]
    public int[] abData;

    public int rspFlag;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 25)]
    public int[] xList; // X-axis data

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 25)]
    public int[] yList; // Y-axis data

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 25)]
    public int[] zList; // Z-axis data
}

Practical Call: Passing Struct Pointers and Parsing Results

The following code demonstrates how to pass a managed struct instance to an unmanaged function that expects a pointer.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 1. Initialize the managed struct
ParamsIn paramsIn = new()
{
    ecgList = new int[200],
    deviceId = 1,
    packageSn = 123,
    ecgFlag = 0,
    ecgFs = 200,
};

// 2. Allocate unmanaged memory for input and output parameters
IntPtr pntIn = Marshal.AllocHGlobal(Marshal.SizeOf(paramsIn));
IntPtr pntOut = Marshal.AllocHGlobal(Marshal.SizeOf<ParamsOut>());

try
{
    // 3. Copy the managed struct data to the unmanaged memory block
    Marshal.StructureToPtr(paramsIn, pntIn, false);

    // 4. Call the unmanaged function, passing the pointers
    // Assuming the function signature is: bool ProcessSignal(nint paramsIn, nint paramsOut)
    bool success = NativeLibrary.ProcessSignal(pntIn, pntOut);

    if (success)
    {
        // 5. Copy the result from unmanaged memory back to a new managed struct
        ParamsOut? paramsOut = Marshal.PtrToStructure<ParamsOut>(pntOut);
        // ... process the data in paramsOut here ...
    }
}
finally
{
    // 6. Always free the allocated unmanaged memory to prevent memory leaks
    Marshal.FreeHGlobal(pntIn);
    Marshal.FreeHGlobal(pntOut);
}

Important: Memory allocated with Marshal.AllocHGlobal must be manually freed using Marshal.FreeHGlobal. It is highly recommended to place the deallocation code in a finally block to ensure it executes even if an exception occurs.


Built with Hugo
Theme Stack designed by Jimmy