How to Call C/C++ DLLs in C# Using P/Invoke

Platform Invoke (P/Invoke) is a bridge provided by the .NET runtime that allows managed C# applications to call unmanaged functions implemented inside dynamic link libraries (DLLs) written in C or C++. This guide outlines the core mechanics of P/Invoke, detailing how to export native functions, declare them in C# using the DllImport attribute, marshal data types between managed and unmanaged memory, and avoid common runtime pitfalls.

1. Exporting the C/C++ Function

Before C# can invoke a native function, the C/C++ compiler must export the function without name mangling. Standard C++ compilers mangle function names to support overloading, which breaks the standard symbol lookup used by P/Invoke. To prevent this, export functions using extern "C" and the __declspec(dllexport) attribute.

// NativeLib.cpp
#define EXPORT_API extern "C" __declspec(dllexport)

EXPORT_API int Add(int a, int b) 
{
    return a + b;
}

EXPORT_API void PrintMessage(const char* message) 
{
    printf("%s\n", message);
}

Compile this code into a DLL (e.g., NativeLib.dll).

2. Setting Up P/Invoke in C

To consume the exported functions in C#, reference the System.Runtime.InteropServices namespace and use the [DllImport] attribute alongside a static extern method declaration.

using System;
using System.Runtime.InteropServices;

class Program
{
    // Define the DLL name and calling convention
    [DllImport("NativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
    public static extern int Add(int a, int b);

    [DllImport("NativeLib.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
    public static extern void PrintMessage(string message);

    static void Main()
    {
        int sum = Add(10, 25);
        Console.WriteLine($"Result from C++: {sum}");

        PrintMessage("Hello from managed C#!");
    }
}

Place the compiled NativeLib.dll in the same output directory as the C# executable (e.g., bin/Debug/net8.0/), or ensure its directory is included in the system's PATH.

3. Data Marshaling

The CLR's P/Invoke marshaler handles conversions between managed and unmanaged representations of data. While primitive types transfer automatically, complex types require specific mapping.

Primitive Types

Primitive types like int, float, double, and byte share the same representation in memory (blittable types) and require no special configuration:

  • int (C++) \(\rightarrow\) int (C#)
  • double (C++) \(\rightarrow\) double (C#)
  • bool (C++) \(\rightarrow\) [MarshalAs(UnmanagedType.I1)] bool (C#)

Strings

Strings in .NET are immutable Unicode sequences (UTF-16), while C/C++ usually expects null-terminated char* (ANSI/UTF-8) or wchar_t* (UTF-16). Use the CharSet property in [DllImport] to define how strings are converted:

  • CharSet.Ansi: Marshals C# string to const char*.
  • CharSet.Unicode: Marshals C# string to const wchar_t*.

Passing by Reference (Pointers)

To pass parameters as pointers to allow the unmanaged function to modify their values, use the ref or out keywords in C#.

C++:

EXPORT_API void GetDimensions(int* width, int* height) 
{
    *width = 1920;
    *height = 1080;
}

C#:

[DllImport("NativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern void GetDimensions(out int width, out int height);

// Usage:
GetDimensions(out int w, out int h);

Structs

To pass custom structures, match the memory layout explicitly using the [StructLayout] attribute.

C++:

struct Vector3 {
    float x;
    float y;
    float z;
};

EXPORT_API void ProcessVector(Vector3* vec) {
    vec->x *= 2;
}

C#:

[StructLayout(LayoutKind.Sequential)]
public struct Vector3
{
    public float X;
    public float Y;
    public float Z;
}

[DllImport("NativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern void ProcessVector(ref Vector3 vec);

4. Key Configuration Rules

  • Calling Conventions: Default C++ functions use __cdecl, while Windows API functions typically use __stdcall. Mismatches result in a PInvokeStackImbalance runtime exception. Always explicitly match the convention in the attribute via CallingConvention = CallingConvention.Cdecl or CallingConvention.StdCall.
  • Platform Architecture: Ensure target architectures match. A 64-bit C# application (x64) cannot load a 32-bit (x86) native DLL. Set the C# project configuration to match the native library build target.