Calling C++ from Unity looks easy in the smallest example. Export a function, add DllImport in C#, and pass a few numbers across. The call works, so the boundary appears finished.

The difficult bugs arrive later. A Boolean occupies a different number of bytes than expected. A struct gains a field and its alignment changes. A native library keeps a callback after the managed delegate has been collected. An error message points to memory that was already released. Everything behaves in the Editor, then an IL2CPP build fails because ahead-of-time compilation has different constraints.

Those failures all point to a broken contract at the language boundary.

Marshalling is the work required to move values between managed C# and unmanaged native code. Some values can cross unchanged. Others need conversion, copying, pinning, allocation, or an explicit lifetime rule. A reliable Unity plug-in keeps that contract narrow enough to inspect and predictable enough to test.

Unity gameplay code
        |
        v
managed wrapper  -- validation, ownership, Unity-friendly types
        |
        v
C ABI            -- fixed widths, explicit buffers, error codes
        |
        v
C++ implementation -- classes, templates, threads, native libraries

Most of the integration work belongs in the managed wrapper and C ABI. The DllImport declaration only describes the call.

Export C, implement C++

C++ is a poor binary interface between independently built modules. Function names can be mangled, exceptions depend on compatible runtimes, class layouts are compiler-sensitive, and standard-library types such as std::string or std::vector are implementation details rather than portable wire formats. A small C-compatible API keeps those compiler-specific details inside the plug-in.

// game_native.h
#pragma once
#include <stdint.h>

#if defined(_WIN32)
    #define GAME_API extern "C" __declspec(dllexport)
#else
    #define GAME_API extern "C" __attribute__((visibility("default")))
#endif

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

// Returns the number of output points, or a negative error code.
GAME_API int32_t path_smooth(
    const NativePoint* input,
    int32_t input_count,
    NativePoint* output,
    int32_t output_capacity);

The implementation behind path_smooth can use classes, templates, SIMD code, or a third-party navigation library. None of those choices leak into C#. The exported surface uses fixed-width integers, pointers, a plain struct, and a documented return convention.

extern "C" prevents C++ name mangling. The export macro makes the symbol visible from the dynamic library. The fixed-width int32_t avoids the platform-dependent meaning of types such as long, and the caller-owned buffers keep memory ownership explicit.

Treat the exported header as a protocol definition. Once builds ship against it, changing field order or parameter meaning is an ABI change even if the C++ compiler accepts the edit.

Match the signature exactly

The corresponding Unity declaration can stay small:

using System.Runtime.InteropServices;

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

internal static class GameNative
{
    private const string Library = "game_native";

    [DllImport(Library,
        EntryPoint = "path_smooth",
        CallingConvention = CallingConvention.Cdecl,
        ExactSpelling = true)]
    internal static extern int PathSmooth(
        [In] NativePoint[] input,
        int inputCount,
        [Out] NativePoint[] output,
        int outputCapacity);
}

The declaration must describe the C function exactly: parameter order, widths, signedness, calling convention, layout, and direction.

Microsoft recommends LibraryImport for modern .NET 7-or-newer applications when source-generated interop is available. Unity projects still need to account for the scripting runtime, Unity version, selected backend, and target platforms they actually ship. DllImport remains the conservative choice across many Unity configurations because the engine’s supported toolchain matters more than general .NET fashion.

Keep all imports in one internal class. Gameplay code should call a managed wrapper, not scatter P/Invoke declarations through MonoBehaviours. That wrapper can validate lengths, reuse buffers, turn error codes into useful exceptions, and convert between Vector3 and the boundary type.

Prefer data that is already the right shape

A blittable type has the same bit-level representation on both sides of the boundary. Values such as byte, int, uint, long, float, double, pointers, and fixed-layout structs made only from blittable fields are the easiest values to pass. The runtime does not need to translate each field into another representation.

That is why NativePoint contains three float fields rather than a managed object reference. Sequential layout makes the field order explicit. The native and managed definitions still need an agreement about packing and alignment, but this shape is straightforward on the usual Unity targets.

A few innocent-looking types deserve suspicion:

  • C# bool does not have one universal native representation. A C++ bool is commonly one byte, while default P/Invoke rules can map a managed Boolean to a four-byte Windows BOOL.
  • C and C++ long change width across common platforms. C# long is always 64 bits.
  • char and wchar_t do not provide one portable text encoding across Windows, Linux, and macOS.
  • Enums need an explicitly agreed underlying integer type.
  • References, strings, arrays, and nested managed objects are not plain bytes with self-evident ownership.

A stable plug-in interface can avoid most custom attributes by using int32_t status values, uint8_t flags, fixed-width enum storage, and UTF-8 byte sequences. Explicit types remove entire categories of platform bugs.

If the native API cannot be changed and truly exposes a one-byte Boolean, say so in C#:

[return: MarshalAs(UnmanagedType.U1)]
internal static extern bool IsReady();

For a new API, returning int32_t and interpreting zero or nonzero in the managed wrapper is often even clearer.

Arrays need a length and a direction

A pointer does not carry its element count. Every array crossing a C boundary therefore needs a length, and every output buffer needs a capacity.

The path_smooth contract follows a useful pattern:

  1. C# allocates or rents the output array.
  2. C# passes the input length and output capacity.
  3. C++ never writes beyond that capacity.
  4. C++ returns the number of elements written or a negative error code.

That pattern avoids returning a native allocation whose owner is unclear. It also lets the managed side reuse arrays across frames instead of creating garbage for every call.

The convenient [In] and [Out] array signature is a good starting point for occasional or coarse-grained work. A high-frequency call may need unsafe code with pinned spans or direct pointers to remove marshaller decisions and intermediate copies. Do not start there without a measurement. A single native call processing 2,000 points is usually a better boundary than 2,000 calls processing one point each.

Crossing the boundary has fixed overhead. More importantly, conversion and copying can dominate tiny native operations. Batch work so native code does enough useful computation to justify the trip.

Strings are bytes plus an encoding contract

char* does not mean “string” with enough precision for an ABI. It means a pointer to character-sized storage. The contract must also define:

  • the encoding
  • whether the data is null-terminated or length-delimited
  • whether null is allowed
  • who allocated the memory
  • how long the pointer remains valid
  • who releases it

UTF-8 is the practical cross-platform default for a new Unity plug-in. It avoids the platform differences around wchar_t and matches Unity’s guidance for string values returned by native methods on iOS.

Input strings that native code only reads during the call can be declared with explicit UTF-8 marshalling:

[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
internal static extern int LoadRegion(
    [MarshalAs(UnmanagedType.LPUTF8Str)] string path);

Output strings deserve a stricter design. Returning const char* is safe only if the lifetime is documented and the managed side copies it before the pointer becomes invalid. Returning an allocated char* requires a matching native free function, and that free function must use the same allocator that created the memory.

A caller-owned buffer is often easier to reason about:

// Returns required bytes including the null terminator.
// Writes only when output_capacity is large enough.
GAME_API int32_t get_last_error(char* output, int32_t output_capacity);

C# can ask for the required size, rent a byte[], call again, and decode the bytes as UTF-8. There is more ceremony, but ownership is unambiguous. It also avoids relying on StringBuilder, whose marshalling can create multiple buffers and copies.

Never release native memory with Marshal.FreeHGlobal unless the native API explicitly allocated it using the matching mechanism. “Both are unmanaged memory” is not an ownership contract.

Opaque handles keep C++ objects private

Sometimes the plug-in needs long-lived native state: a pathfinding world, audio processor, compression context, or hardware connection. Passing a C++ class pointer directly and pretending C# understands the object is the wrong abstraction.

Expose an opaque handle instead:

struct NavWorld;

GAME_API NavWorld* nav_world_create(const NativePoint* vertices, int32_t count);
GAME_API int32_t nav_world_query(NavWorld* world, NativePoint from, NativePoint to);
GAME_API void nav_world_destroy(NavWorld* world);

C# sees the pointer as an IntPtr or, preferably, a SafeHandle wrapper. It cannot inspect the C++ object, and it should not try. Its responsibilities are to reject a null result, keep the handle alive during calls, and release it exactly once through nav_world_destroy.

SafeHandle is preferable to hand-written finalization because it exists specifically to model native resource ownership. An IDisposable managed façade can own the safe handle and provide Unity-friendly methods. The rest of the project never needs to see IntPtr.

The destroy function is part of the ABI, not optional cleanup. Native leaks are especially painful in the Editor because disabled domain reload settings and repeated play sessions can make lifetime mistakes appear inconsistent.

Callbacks reverse every lifetime assumption

A callback lets native code call a managed function pointer. It is useful for progress reports, log messages, asynchronous completion, and events from native middleware. It also introduces three separate constraints: signature, delegate lifetime, and thread affinity.

A Unity-compatible callback shape commonly looks like this:

using AOT;
using System;
using System.Runtime.InteropServices;

internal static class NativeCallbacks
{
    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    internal delegate void LogCallback(int level, IntPtr utf8Message);

    // Root the delegate for as long as native code may call it.
    private static readonly LogCallback Log = OnNativeLog;

    [DllImport("game_native", CallingConvention = CallingConvention.Cdecl)]
    private static extern void RegisterLogCallback(LogCallback callback);

    internal static void Register() => RegisterLogCallback(Log);

    [MonoPInvokeCallback(typeof(LogCallback))]
    private static void OnNativeLog(int level, IntPtr utf8Message)
    {
        // Copy the message, then enqueue it for the Unity main thread.
        // Do not call UnityEngine APIs if the native caller may be a worker thread.
    }
}

For AOT builds, Unity documents static callback methods marked with MonoPInvokeCallback. The exact delegate signature and calling convention still have to match the native function pointer.

The static field keeps the delegate alive after the registration call returns. Native code may store that function pointer and invoke it much later. If managed code drops its last delegate reference, the native library can call into collected state. Keep the delegate rooted until native code unregisters it and can no longer issue callbacks.

A callback also runs on whichever thread invoked it. If a native worker thread reports completion, the callback is on that worker thread. Most Unity APIs belong on the main thread, so the callback should copy the payload into managed-owned data and enqueue it. Update or another main-thread pump can consume the queue.

Do not let exceptions escape a reverse P/Invoke callback. Catch them at the managed boundary, record the failure, and return a defined status if the callback signature permits one.

Errors must cross as data, not exceptions

C++ exceptions should stop before the exported C function returns. Allowing one to unwind through managed frames is undefined territory and can terminate the process.

Catch exceptions inside the native wrapper and convert them into a stable error code:

GAME_API int32_t path_smooth(/* arguments */)
{
    try
    {
        // Validate pointers and capacities, then call the C++ implementation.
        return perform_path_smoothing(/* arguments */);
    }
    catch (const std::bad_alloc&)
    {
        return -2;
    }
    catch (...)
    {
        return -1;
    }
}

The managed façade can translate -1 into a NativePluginException, preserve -2 as an out-of-memory failure, or return a normal result object. Both sides still need to agree on what each value means.

Validation belongs on both sides. C# should reject mismatched counts before paying for a native call. C++ should still reject null pointers, negative lengths, and impossible capacities because the native boundary is a trust boundary. A bad pointer does not produce a friendly Unity exception; it can take down the player.

Editor success does not prove player success

Unity native plug-ins vary by platform. Windows loads a DLL, macOS typically uses a bundle or dynamic library, Linux uses a shared object, and iOS commonly links native source or a static library into the player. On statically linked Apple targets, the import name is commonly "__Internal" rather than a file name.

The Unity Inspector also controls which platforms and CPU architectures import a plug-in. A correct C# declaration cannot load an x86-64 library into an ARM64 player. File placement, import settings, exported symbol visibility, architecture, and dependent native libraries all belong to the deployment contract.

IL2CPP adds an AOT step, so test the actual backend early. Callback preservation, unsupported generic paths, and platform linker behavior are not exercised by an Editor session running Mono. A minimal development player that loads the plug-in and performs one call catches more useful failures than another hour staring at attributes.

Keep platform selection close to the interop class:

#if UNITY_IOS && !UNITY_EDITOR
private const string Library = "__Internal";
#else
private const string Library = "game_native";
#endif

Conditional compilation should choose the library location, not hide incompatible ABI designs. The exported function and data contract should remain the same wherever possible.

Put version and size checks in the API

Interop failures are often silent layout disagreements. Add cheap probes that make those disagreements loud:

GAME_API uint32_t interop_abi_version();
GAME_API int32_t interop_sizeof_native_point();

In a development build, the managed wrapper can compare the returned ABI version with its expected constant and compare native sizeof(NativePoint) with Marshal.SizeOf<NativePoint>(). Fail at startup with a useful message rather than corrupting a buffer during a match.

A small boundary test should cover:

  • minimum and maximum integer values used by the API
  • a struct round trip with distinctive field values
  • empty, one-element, and full-capacity arrays
  • non-ASCII UTF-8 text
  • a deliberately undersized output buffer
  • every documented error code
  • callback registration, invocation, and unregistration
  • repeated create/destroy cycles for opaque handles
  • the Editor and at least one IL2CPP development player

The test does not need a framework. A tiny scene or command-line test program that asserts these cases is enough. Boundary tests are valuable because they fail near the mismatch instead of several systems later.

Keep the boundary coarse and dull

A native plug-in earns its maintenance cost when it provides something C# cannot reasonably provide: existing middleware, platform APIs, specialized codecs, hardware integration, or measured performance in a substantial computation. Choosing C++ only because it sounds faster is weak justification. Per-object native calls from Update, marshalled strings in a hot loop, and tiny functions that bounce across the ABI can erase the expected gain. Native code also adds builds, symbols, deployment rules, crash debugging, and architecture-specific artifacts.

Start with one managed façade per native subsystem. Batch inputs. Reuse buffers. Keep exported functions few. Keep C++ objects behind handles. Make ownership visible in function names and documentation. Measure the whole call path in a player build, including conversion and copying, rather than timing only the inner native algorithm.

A good boundary is deliberately less expressive than either language. It uses plain data because plain data survives compiler changes, backend changes, and platform changes.

Check the contract before adding another export

Before adding the next native function, write its contract beside the declaration: exact widths and layout, buffer bounds, string encoding, memory ownership, error behavior, and callback lifetime or thread rules. Add one boundary test that calls it from an IL2CPP development player. If any of those rules remain unclear, the function is not ready to ship.

References