Calling a C# Native AOT DLL from C/C++

· Updated: · · C#, .NET, Native AOT, C++, Windows Development, Native Interop

Revision history (1 updates, last updated Sep 1, 2026)

A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.

Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614483)
First published
Cite this article(DOI: 10.5281/zenodo.21614482)

This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.

Go Komura (2026). Calling a C# Native AOT DLL from C/C++. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614482 https://comcomponent.com/en/blog/2026/03/12/003-csharp-native-aot-native-dll-from-c-cpp/

DOI (latest version)
10.5281/zenodo.21614482
DOI (this version)
10.5281/zenodo.22217136

In the previous post, Why a C++/CLI Wrapper Is a Strong Choice for Using Native DLLs from C#, we looked at the boundary when calling C++ from C#. This time we flip the direction: calling C# from C/C++.

Sometimes you want to call logic written in C# from an existing C/C++ application - but P/Invoke goes the wrong way, and bringing in C++/CLI or COM feels like overkill. This comes up especially when you want to keep the native application itself intact and move only pieces like decision logic, string processing, configuration interpretation, or calculation rules over to C#.

COM can bridge this too, but here we take a more in-process, more DLL-like approach. With .NET Native AOT, you can publish a class library as a native shared library and expose methods marked with UnmanagedCallersOnly as C entry points. In other words, you can use C# as “the native DLL being called.”

That said, not everything can simply cross the boundary as is. Leak string, List<T>, exceptions, or ownership across the boundary and things get ugly fast. In this article, using a minimal Windows + C++ example, we look at when this setup really pays off and what API shapes hold up well. The thinking is nearly the same on Linux / macOS, but the code examples assume a Windows DLL.

The difference in direction from the previous articleThe previous article covered the boundary for calling a native DLL from C#, while this one flips the direction and covers calling a C# native DLL published with Native AOT in-process from a C/C++ application.Previous: C# calls C++The C++/CLI wrapper storyThis time: C/C++ calls C#Publish C# as a DLL with Native AOTUnmanagedCallersOnly is the entry point

Figure 1: The direction here is the reverse of P/Invoke and C++/CLI: C# becomes the native DLL that gets called.

All the code in this article is published on GitHub as a buildable, runnable sample set (the C# library published with Native AOT, a C++ caller example, and unit tests).

csharp-native-aot-native-dll-from-c-cpp - komurasoft-blog-samples (GitHub)

Table of Contents

  1. The Conclusion First (In One Line)
  2. Choosing the Right Bridge
  3. Architecture Diagram
  4. Minimal Setup
    • 4.1. The C# Project
    • 4.2. The Exported C# Code
    • 4.3. The Publish Command
    • 4.4. Calling It from C++
    • 4.5. Checking That the Names Really Are Exported
    • 4.6. Linking Statically with an Import Library
  5. API Shapes That Don’t Break
    • 5.1. Lean Toward the C ABI
    • 5.2. Handle Strings as Pointer + Length + Buffer Capacity
    • 5.3. Never Let Exceptions Cross the Boundary
    • 5.4. Pin Down the Calling Convention
    • 5.5. Keep Export Methods Thin and Put the Logic Elsewhere
  6. Cases Where It Fits
  7. Cases Where It Still Doesn’t Fit
  8. Pitfalls
  9. Summary
  10. References

In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (22 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle

1. The Conclusion First (In One Line)

  • If you want to call C# logic from C/C++ in-process, Native AOT + UnmanagedCallersOnly is a very strong option.
  • However, what gets exported is strictly a C function entry point. This is not a world where you expose string or List<T> directly.
  • In practice, flattening things into a C API like create / destroy / operate, with explicit lifetime management and error codes, is far more stable.
  • If you want to work with C++ classes and the STL naturally, C++/CLI is a better fit; if you need registration, automation, or cross-process calls, COM is the better choice.

In short: you can use C# as the inside of a native DLL, but the boundary must be designed as a C ABI, not as .NET. If you can accept that trade, this becomes a genuinely interesting tool.

Design the boundary as a C ABIThe inside of the C# code can stay as classes and collections, but the boundary you expose to the outside must not be string or List. Flatten it into a C API like create / destroy / operate and make lifetime management and error codes explicit.The inside is ordinary C#The exposed surface is a flat C APILifetime is explicit through handlesErrors come back as codesDo not expose string or List of T

Figure 2: There is exactly one trade to accept: instead of exposing .NET as is, flatten the boundary down to a C ABI.

2. Choosing the Right Bridge

What you want to do Strong candidate Why
Call a set of C functions from C# P/Invoke The direction is straightforward and the most natural
Work with a C++ library naturally from C# C++/CLI C++ types, ownership, exceptions, std::wstring and the like are easy to absorb on the C++ side
Cross 32-bit / 64-bit or process boundaries COM / IPC An in-process DLL alone cannot cross these
Call C# logic from C/C++ as a native DLL Native AOT + UnmanagedCallersOnly You can export your own C entry points

This setup shines when the native side drives and C# is called as a component. That direction is exactly the opposite of P/Invoke and C++/CLI.

Which side drivesP/Invoke and C++/CLI run in the direction where C# drives and calls into native code, whereas the Native AOT setup in this article runs the opposite way, with the native side driving and calling C# logic as a component.calls native codecalls C# as a componentC# drivesP/Invoke or C++/CLINative code drivesExport with Native AOT

Figure 3: Pick the bridge by direction. The setup in this article runs native-first, with C# as the component.

3. Architecture Diagram

cdecl function callsC / C++ applicationC# DLL published with Native AOTExports marked UnmanagedCallersOnlyC# business logicHandle table / state management

Figure 4: From the C/C++ application, only the UnmanagedCallersOnly exports are visible as C functions.

The picture is simple. What matters is aligning the boundary with C functions. The C# internals can be classes, collections, or LINQ - it doesn’t matter - but the surface you expose to the outside stays flat.

4. Minimal Setup

Here we build a minimal example where the C++ side creates an “accumulator,” adds values into it, and finally retrieves the total. In real work this could be a decision engine, a configuration interpreter, or a simple parser. Think of it as the pattern where the native side holds a handle and calls operation functions in sequence.

4.1. The C# Project

First, set up a class library.

<!-- NativeAotSample.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <PublishAot>true</PublishAot>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>
</Project>

There are two key points.

  • Enable Native AOT publishing
  • Allow unsafe, since we use pointer arguments

The samples in this article target net8.0, but the same thinking applies to .NET 9 / 10.

4.2. The Exported C# Code

Methods marked with UnmanagedCallersOnly become the entry points visible from the native side. Here we hand out handles as integers and manage the internal state in a dictionary on the C# side.

// NativeExports.cs
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace KomuraSoft.NativeAotSample;

internal static class NativeStatus
{
    public const int Ok = 0;
    public const int InvalidArgument = -1;
    public const int InvalidHandle = -2;
    public const int UnexpectedError = -3;
}

internal sealed class Accumulator
{
    public long Total { get; private set; }

    public void Add(int value)
    {
        Total += value;
    }
}

internal static class AccumulatorStore
{
    private static readonly object s_gate = new();
    private static readonly Dictionary<nint, Accumulator> s_instances = new();
    private static long s_nextHandle = 0;

    public static int Create(out nint handle)
    {
        try
        {
            var instance = new Accumulator();
            handle = (nint)System.Threading.Interlocked.Increment(ref s_nextHandle);

            lock (s_gate)
            {
                s_instances.Add(handle, instance);
            }

            return NativeStatus.Ok;
        }
        catch
        {
            handle = 0;
            return NativeStatus.UnexpectedError;
        }
    }

    public static int Add(nint handle, int value)
    {
        try
        {
            lock (s_gate)
            {
                if (!s_instances.TryGetValue(handle, out var instance))
                {
                    return NativeStatus.InvalidHandle;
                }

                instance.Add(value);
                return NativeStatus.Ok;
            }
        }
        catch
        {
            return NativeStatus.UnexpectedError;
        }
    }

    public static int GetTotal(nint handle, out long total)
    {
        try
        {
            lock (s_gate)
            {
                if (!s_instances.TryGetValue(handle, out var instance))
                {
                    total = 0;
                    return NativeStatus.InvalidHandle;
                }

                total = instance.Total;
                return NativeStatus.Ok;
            }
        }
        catch
        {
            total = 0;
            return NativeStatus.UnexpectedError;
        }
    }

    public static int Destroy(nint handle)
    {
        try
        {
            lock (s_gate)
            {
                return s_instances.Remove(handle)
                    ? NativeStatus.Ok
                    : NativeStatus.InvalidHandle;
            }
        }
        catch
        {
            return NativeStatus.UnexpectedError;
        }
    }
}

public static unsafe class NativeExports
{
    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_create",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorCreate(nint* outHandle)
    {
        if (outHandle == null)
        {
            return NativeStatus.InvalidArgument;
        }

        var status = AccumulatorStore.Create(out var handle);
        *outHandle = handle;
        return status;
    }

    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_add",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorAdd(nint handle, int value)
    {
        return AccumulatorStore.Add(handle, value);
    }

    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_get_total",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorGetTotal(nint handle, long* outTotal)
    {
        if (outTotal == null)
        {
            return NativeStatus.InvalidArgument;
        }

        var status = AccumulatorStore.GetTotal(handle, out var total);
        *outTotal = total;
        return status;
    }

    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_destroy",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorDestroy(nint handle)
    {
        return AccumulatorStore.Destroy(handle);
    }
}

What this does is quite plain.

  • The only thing shown to the native side is an intptr_t handle
  • The actual state lives on the C# side
  • create / add / get / destroy are broken out into flat functions
  • Return values are error codes; output values come back through pointer arguments

With this shape, you can swap out the C# internals later and the C-side ABI stays remarkably stable.

A flat handle-based APIcreate hands out a handle, operation functions such as add and get take that handle, and destroy cleans up. The real state lives on the C# side, return values are error codes, and output values come back through pointer arguments.create: hand out a handleadd or get: operate with the handledestroy: clean upThe real state lives on the C# sideThe return value is an error code

Figure 5: All the native side sees is a handle and a set of operation functions. Swap the internals and the ABI stays stable.

One more note about how handles are numbered. The sample keeps the counter in a long and casts the result of Interlocked.Increment to nint. Two properties of that are worth knowing.

  • Zero is never handed out. The counter starts at 0 and Increment returns the value after adding, so the first handle is 1. That is what lets the C++ side use intptr_t handle = 0; as a marker for “nothing held yet.”
  • Truncation happens on 32-bit. nint is pointer-width, so it is 64 bits on 64-bit but 32 bits in a 32-bit build. Casting from long to nint silently drops the upper bits, so once numbering passes 2^32 the value wraps around. A workload that repeats create / destroy around the clock can, in principle, get there.

Be precise about what happens after it wraps. The duplicate-key exception from s_instances.Add(handle, instance) only helps while a handle with the same value is still alive. The normal use of this API is create followed by destroy, over and over, and a destroyed handle is already gone from the dictionary. So when numbering comes back around to the same value, there is no key in the dictionary and Add succeeds. As a result, the old handle the C side is still holding starts pointing at a completely unrelated new instance. No exception is raised and no error code is returned, so the values just quietly go wrong.

There is one more case: at exactly 2^32 the low 32 bits are all zero, so the 0 you were using as the marker for “nothing held yet” gets handed out.

So do not treat the duplicate-key check as a safety net. If 32-bit is a possibility, take one of these two routes.

  • Embed a generation number in the handle. Use the low bits for the sequence and the high bits for a generation, advancing the generation on every destroy. Even when the same sequence number comes around again, the values will not match
  • Fail permanently once the range is used up. When numbering hits its ceiling, make every subsequent create return an error. On equipment that runs continuously this means a restart is needed, but that is easier to deal with than breaking silently

In either case, keep the numbering counter itself in nint so it cannot exceed the nint width, and keep 0 out of the values you hand out.

How handle numbering breaks when it wraps around and what to do about itWhen numbering wraps around on 32-bit, Add succeeds for a value that was destroyed and removed from the dictionary, so the old handle the C side still holds points at an unrelated new instance and breaks silently. The countermeasures are embedding a generation number or failing permanently once the range is used up.Numbering wraps around on 32-bitAdd for the same value succeedsAn old handle points at a new instanceIt breaks with no exception and no error codeFix: embed a generation numberFix: fail once the range is used up

Figure 6: The duplicate-key exception is not a safety net. Wraparound fails silently, so the countermeasure has to be built into the design.

4.3. The Publish Command

One prerequisite first. Native AOT publishing needs a separate native toolchain. If you just add PublishAot and run dotnet publish, the build fails not during C# compilation but at the final native link step. That is the first hurdle.

Environment What you need
Windows Visual Studio 2022 or later. Install the Desktop development with C++ workload with all of its default components
Ubuntu 18.04 or later sudo apt-get install clang zlib1g-dev
Alpine 3.15 or later sudo apk add clang build-base zlib-dev
Fedora 39 or later / RHEL 8 or later sudo dnf install clang zlib-ng-devel zlib-ng-compat-devel zlib-devel
macOS Xcode Command Line Tools (supported from .NET 8 onward)

This article assumes Windows + C++, so in practice the message is: check first whether the Visual Studio C++ workload is installed. Errors about a linker that cannot be found, or failures around link.exe, almost always come from here.

With that in place, publish it as a shared library.

dotnet publish -r win-x64 -c Release /p:NativeLib=Shared

This produces a native DLL under bin/Release/net8.0/win-x64/publish/. For Windows it’s a .dll, for Linux a .so, and for macOS a .dylib.

The important thing is to publish per RID. A binary built for win-x64 cannot be used as if it were win-arm64, and the bitness of the caller and the DLL must match.

The first hurdle in publishingNative AOT publishing needs a separate native toolchain, and without one dotnet publish fails at the final native link step rather than during C# compilation. Publish per RID and match the bitness with the caller.noyesRun dotnet publishIs a native toolchain installedFails at the final native linkA native DLL comes out per RIDMatch the bitness with the caller

Figure 7: What fails is the link step, not the C# compilation. The first hurdle is whether the toolchain is there.

4.4. Calling It from C++

For now we set aside import libraries and call it straightforwardly with LoadLibrary / GetProcAddress. This form makes it easy to see what is exported and what signatures you should receive it with.

/* native_api.h */
#pragma once
#include <stdint.h>

enum km_status
{
    KM_STATUS_OK = 0,
    KM_STATUS_INVALID_ARGUMENT = -1,
    KM_STATUS_INVALID_HANDLE = -2,
    KM_STATUS_UNEXPECTED_ERROR = -3
};

typedef int (__cdecl *km_accumulator_create_fn)(intptr_t* out_handle);
typedef int (__cdecl *km_accumulator_add_fn)(intptr_t handle, int value);
typedef int (__cdecl *km_accumulator_get_total_fn)(intptr_t handle, int64_t* out_total);
typedef int (__cdecl *km_accumulator_destroy_fn)(intptr_t handle);
// main.cpp
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <windows.h>

#include "native_api.h"

template <typename T>
T LoadSymbol(HMODULE module, const char* name)
{
    FARPROC proc = ::GetProcAddress(module, name);
    if (proc == nullptr)
    {
        std::cerr << "GetProcAddress failed: " << name << '\n';
        std::exit(EXIT_FAILURE);
    }

    return reinterpret_cast<T>(proc);
}

int main()
{
    HMODULE module = ::LoadLibraryW(L"NativeAotSample.dll");
    if (module == nullptr)
    {
        std::cerr << "LoadLibraryW failed" << '\n';
        return EXIT_FAILURE;
    }

    auto create = LoadSymbol<km_accumulator_create_fn>(module, "km_accumulator_create");
    auto add = LoadSymbol<km_accumulator_add_fn>(module, "km_accumulator_add");
    auto getTotal = LoadSymbol<km_accumulator_get_total_fn>(module, "km_accumulator_get_total");
    auto destroy = LoadSymbol<km_accumulator_destroy_fn>(module, "km_accumulator_destroy");

    intptr_t handle = 0;
    if (create(&handle) != KM_STATUS_OK)
    {
        std::cerr << "create failed" << '\n';
        return EXIT_FAILURE;
    }

    if (add(handle, 10) != KM_STATUS_OK)
    {
        std::cerr << "add(10) failed" << '\n';
        return EXIT_FAILURE;
    }

    if (add(handle, 20) != KM_STATUS_OK)
    {
        std::cerr << "add(20) failed" << '\n';
        return EXIT_FAILURE;
    }

    std::int64_t total = 0;
    if (getTotal(handle, &total) != KM_STATUS_OK)
    {
        std::cerr << "get_total failed" << '\n';
        return EXIT_FAILURE;
    }

    std::cout << "total = " << total << '\n';

    if (destroy(handle) != KM_STATUS_OK)
    {
        std::cerr << "destroy failed" << '\n';
        return EXIT_FAILURE;
    }

    handle = 0;

    // Do not use a Native AOT shared library with unloading in mind.
    // FreeLibrary(module);

    return EXIT_SUCCESS;
}

In this example, all the C++ side sees is “a C API callable through function pointers.” The fact that the inside is written in C# barely needs to register at all.

Put the published DLL in the same folder as main.exe and run it, and since it adds 10 and 20, standard output is just this.

total = 30

If something fails along the way, std::cerr tells you which step it was. LoadLibraryW failed means the DLL was never found in the first place; GetProcAddress failed: km_accumulator_add means the DLL loaded but the export was not found. That is the split.

Isolating the problem when the call does not workIf LoadLibraryW fails the DLL was not loaded at all, so suspect the path, the bitness, or a missing dependent DLL. If GetProcAddress fails the DLL loaded but the export was not found. If both succeed the function pointer is callable.failedsucceededfailedsucceededDid LoadLibraryW succeed?The DLL was not loadedSuspect the path, the bitness, or dependent DLLsDid GetProcAddress succeed?The export was not foundCallable as a C API

Figure 8: Which step fails tells you whether the problem is loading the DLL or finding the export.

4.5. Checking That the Names Really Are Exported

When a call “doesn’t work,” the first thing to look at is whether the name really is present in the DLL. The quickest way is dumpbin from the Visual Studio Developer Command Prompt.

dumpbin /exports NativeAotSample.dll

If km_accumulator_create / km_accumulator_add / km_accumulator_get_total / km_accumulator_destroy all four appear in the name listing, the C# side published successfully. To filter by name, do this.

dumpbin /exports NativeAotSample.dll | findstr km_

If the names are not there, the problem is on the C# side; if they are there but GetProcAddress still fails, the problem is on the caller side.

When GetProcAddress returns NULL, the places to look are roughly these, in order.

  1. Does the name appear in dumpbin /exports (if not, this is a C# side issue)
  2. Does the string in EntryPoint match the string passed to GetProcAddress exactly (case is significant)
  3. Do the bitnesses of the calling EXE and the DLL match
  4. Is the method marked with UnmanagedCallersOnly static, and is it outside any generic
  5. Is the attribute written in the assembly being published (putting it in a referenced library does not surface it)

Note that if LoadLibraryW itself fails, this is not about exports at all. Suspect the DLL path, the bitness, or a missing dependent DLL first.

The order to check when GetProcAddress returns NULLCheck in order whether the name appears in the dumpbin exports listing, whether the EntryPoint string and the string you passed match exactly, whether the bitnesses match, whether the method is static and outside any generic, and whether the attribute is written in the assembly being published.not listedDoes the name appear in dumpbinDo the strings match exactlyDo the bitnesses matchIs it static and outside genericsIs the attribute in the published assemblyInvestigate it as a C# side problem

Figure 9: Checking whether the name appears in the exports first settles whether the problem is on the C# side or the caller side.

4.6. Linking Statically with an Import Library

Everything so far used the LoadLibrary / GetProcAddress approach, because it makes it easy to see what is exported and what signature to receive it with.

In real work, though, you often just want to include a header and call the functions directly. That means static loading through an import library. The steps are:

  1. If the publish output already contains an import library (.lib), link against it
  2. If it does not, prepare a .def file listing the export names and build the import library with lib.exe /def:NativeAotSample.def /out:NativeAotSample.lib /machine:x64
  3. On the header side, declare ordinary functions instead of function pointer types
/* native_api_static.h */
#pragma once
#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

int __cdecl km_accumulator_create(intptr_t* out_handle);
int __cdecl km_accumulator_add(intptr_t handle, int value);
int __cdecl km_accumulator_get_total(intptr_t handle, int64_t* out_total);
int __cdecl km_accumulator_destroy(intptr_t handle);

#ifdef __cplusplus
}
#endif

This makes the calling code considerably more straightforward. The trade-off is that if the DLL is missing, the process dies at startup, which makes it hard to run in a mode where “the app works but this one feature is unavailable.” If you want to slot it in like a plugin, staying with the LoadLibrary approach is easier to work with.

Choosing between dynamic loading and static linkingThe LoadLibrary approach loads at run time and suits plugin-style insertion, while static linking with an import library lets you include a header and call directly but kills the process at startup when the DLL is missing.slot it in like a plugincall it straight from a headerHow do you want to integrate itThe LoadLibrary approachStatic link with an import libA missing DLL kills the process at startup

Figure 10: Static linking is the more straightforward code, but dynamic loading is what lets the app run with just that one feature missing.

Note that publishing as a static library (NativeLib=Static) is not officially supported, so it is safer not to count on that route.

5. API Shapes That Don’t Break

Being able to export with Native AOT is fun, but in practice what you choose not to export matters more.

5.1. Lean Toward the C ABI

A quick definition first. The core of this article is “design the boundary as a C ABI, not as .NET,” and that ABI stands for Application Binary Interface: the contract for how compiled binaries fit together at run time. Think of it as a contract at the machine-code level rather than at the source-code level. It covers three things.

Contract What it decides What happens if you break it here
Calling convention Whether each argument is passed in a register or on the stack and how, where the return value is placed, and whether the caller or the callee restores the stack after the call Arguments shift out of place; the stack is corrupted right after the return
Type layout How many bytes each type occupies and where struct members sit (padding and alignment) Values can no longer be read from partway into a struct
Names and linking The spelling of exported function names and whether they are decorated GetProcAddress cannot find the name

cdecl and stdcall are names from the first of those three, the calling convention. C++ classes and exceptions differ across compilers in all three of these contracts, so putting them directly on the boundary does not fit together. Put the other way around: restrict yourself to C functions and primitive types and the contract is simple enough to line up reliably. “Lean toward the C ABI” means flattening the boundary down to the range where these contracts stay simple.

What the ABI contract coversAn ABI is the contract for how compiled binaries fit together at run time, and it consists of the calling convention that decides how arguments and return values are passed, the layout of types, and export names and decoration.ABI (a contract at the machine-code level)Calling conventionType layoutNames and linkingcdecl and stdcall are names from here

Figure 11: Leaning toward the C ABI means flattening the boundary down to where these three contracts stay simple.

On top of that, you will have a much easier time if you restrict the types you expose at the boundary to roughly the following from the start.

  • Primitive types like int32_t / int64_t / double
  • Structs with a fixed layout
  • Handles equivalent to intptr_t / void*
  • uint8_t* plus a length

Conversely, these are the things you should never let leak out in the first place.

  • string
  • object
  • List<T>
  • Task
  • Span<T>
  • C++ classes, std::vector, std::wstring

Try to push these across the boundary as is, and the boundary surface clouds over fast. The key is to keep C#’s internal concerns from leaking into C++, and not let too much of C++’s concerns leak into C# either.

Types to expose at the boundary and types to keep insideExpose primitive types, structs with a fixed layout, handles, and a pointer paired with a length, and keep string, object, List, Task, C++ classes, and the STL from leaking out.Types to expose at the boundaryPrimitives, fixed structs, and handlesA pointer plus a lengthTypes to keep insidestring, List of T, and TaskC++ classes and the STL

Figure 12: What matters in practice is what you choose not to export. Keep both sides’ concerns off the boundary.

To cut down on transcription mistakes, here is a mapping table as well. The signature of a method marked with UnmanagedCallersOnly can only use blittable types, so in practice it stays within this range.

C# side C / C++ side Notes
byte / sbyte uint8_t / int8_t  
short / ushort int16_t / uint16_t  
int / uint int32_t / uint32_t  
long / ulong int64_t / uint64_t C++ long is 32-bit on Windows and 64-bit under LP64 on Linux, so writing int64_t instead of long is the safe choice
nint / nuint intptr_t / uintptr_t Pointer width. 32 bits in a 32-bit build
float / double float / double  
bool Do not use Not blittable. Pass 0 / 1 as int32_t
char / string Do not use Handle strings as a pointer plus a length, as described in 5.2
T* (unsafe pointer) T* This is how output values come back
Struct with a fixed layout A struct with the same layout Member order, types, and padding must match on both sides

5.2. Handle Strings as Pointer + Length + Buffer Capacity

The moment you want to pass strings around, the temptation is to expose string directly - resist it. At a library boundary, something like the following shape is much clearer.

int km_parse_utf8(const uint8_t* text, int32_t text_len, int32_t* out_value);
int km_format_utf8(int32_t value, uint8_t* buffer, int32_t buffer_len, int32_t* out_written);

The point is to decide the encoding, the length, and who allocates the buffer up front. Since this is Windows, leaning toward UTF-16 is an option, but if you have other languages in view, UTF-8 is usually easier to work with.

5.3. Never Let Exceptions Cross the Boundary

A native function boundary is not a friendly medium for expressing exceptions. At the very least, it is safer not to design things so that managed exceptions leak directly to the caller.

In practice:

  • The return value is a status code
  • Actual data comes back through out buffers or pointer arguments
  • If needed, expose extra information via a get_last_error-style function

That keeps things manageable.

It isn’t flashy, but this kind of unglamorous design pays off later. Don’t suddenly start a wrestling match at the boundary, in other words.

An error design that keeps exceptions from crossingDo not let managed exceptions leak directly to the caller. Return a status code, deliver the actual data through out buffers or pointer arguments, and expose extra detail through a get_last_error style function when it is needed.An exception inside C#Catch it on the inside of the boundaryThe return value is a status codeActual data goes through pointer argumentsExtra detail via a get_last_error style call

Figure 13: Keep exceptions from crossing the boundary and translate them into the world of status codes and pointer arguments.

5.4. Pin Down the Calling Convention

The sample explicitly specifies CallConvCdecl. If you omit it, you get the platform’s default calling convention, but if you want to pin down headers and function pointer types, explicitly declaring it yourself is harder to get wrong.

Especially if there is any chance of dealing with x86, leaving this ambiguous will hurt later. Even if it rarely surfaces on x64, set the rule at the start.

5.5. Keep Export Methods Thin and Put the Logic Elsewhere

Methods marked with UnmanagedCallersOnly are not meant to be called directly from ordinary managed code. So if you start writing all your business logic inside them, testing becomes painful too.

In the sample as well, the actual state management lives in AccumulatorStore, and the exported NativeExports is nothing but a thin entrance. This matters a great deal.

  • Export methods: the ABI front desk
  • Internal classes: ordinary C# logic

With this division of labor, you can think about the boundary with C++ and the main C# code separately.

Keep exports thin and put the implementation elsewhereKeep methods marked UnmanagedCallersOnly as a thin ABI front desk and put state management and business logic in internal classes, so the boundary and the main code can be reasoned about separately and testing gets easier.Export method (a thin front desk)Internal class (ordinary C#)Only ABI validation and conversionTestable as ordinary C#

Figure 14: Do not start writing business logic in the exports. Splitting the front desk from the implementation makes maintenance and testing easier.

6. Cases Where It Fits

This setup clicks beautifully in scenarios like these.

  • You want to keep the existing C/C++ application as is and move only part of the business logic into C#
  • You don’t want pre-installing the .NET runtime to be a deployment prerequisite
  • You can keep the exported function surface small
  • You might eventually want to call the same C API from other languages such as Rust or Go

It pairs especially well with the structure of keeping the native app intact and writing only the easily swappable logic layer in C# - UI and device control stay in C++; decisions, calculations, and configuration rules go to C#.

The split that works wellLeave UI and device control in C++ and write only the easily swappable logic layer, such as decisions, calculations, and configuration rules, in C#, connecting the two through a small C API surface.An existing C / C++ applicationUI and device control stay in C++Decisions, calculations, and configuration rules in C#Connected through a small C API surfaceOther languages can call the same surface

Figure 15: The native side stays in charge while C# productivity goes into just the swappable logic layer.

7. Cases Where It Still Doesn’t Fit

Of course, this is not a cure-all. There are clear cases where it doesn’t fit.

  • You want to handle C++ classes, std::vector, or exceptions directly
    • In that case C++/CLI or a native-side wrapper is more natural.
  • You want to enter the world of COM registration, VBA / Office automation, or Explorer extensions
    • Think of that in COM terms instead.
  • You want to bridge 32-bit / 64-bit, or cross a process boundary
    • Not an in-process DLL - COM / IPC / a separate-process design is the sounder route.
  • You want to unload plugins later
    • Native AOT shared libraries should not be used with unloading in mind.
  • Your dependencies rely heavily on reflection or dynamic code generation
    • If AOT publish warnings appear, it is safer not to wave them away.

In the end, the dividing line is whether you can live within a C ABI. If you can’t, a different bridge is cleaner.

Cases where it does not fit and the bridge to use insteadUse C++/CLI to work with C++ classes and exceptions directly, COM for the registration and automation world, COM or IPC to cross bitness or process boundaries, and note that this setup does not fit plugins meant to be unloaded later.C++ types and exceptions as they arethe registration and automation worldcrossing bitness or processesunloading it laterWhat are you afterGo to C++/CLI or a wrapperGo to the COM contextGo to COM, IPC, or a separate processThis setup does not fit the premise

Figure 16: The dividing line is whether you can live within a C ABI. If you can’t, a different bridge is cleaner.

8. Pitfalls

Finally, here are the quietly easy-to-hit snags with Native AOT exports.

  • Methods marked with UnmanagedCallersOnly must be static.
  • They cannot be generic methods or live inside generic classes.
  • If you want a named export, add EntryPoint.
  • Avoid ref / in / out; return values through pointer arguments instead.
  • Only methods in the assembly being published get exported. Putting the attribute on methods in a referenced library does not surface them by itself.
  • The bitness of the caller and the DLL must match.
  • Publish warnings matter a lot. If AOT / trimming warnings appear, clear them first.

Each of these is a “well, of course” once you know it. But hit one without knowing, and some grim hours await.

9. Summary

When you want to call C# from C/C++, the first things that come to mind are COM, C++/CLI, or a separate process. All of those are valid options.

But if you want to slot C# logic in as an in-process native DLL, Native AOT + UnmanagedCallersOnly is a genuinely interesting choice.

Let’s list the key points one more time.

  • Don’t expose C# as is - flatten it into a C ABI
  • Make lifetime management explicit with handle-based design
  • Cross the boundary with error codes, not exceptions
  • Pin down the calling convention
  • Keep export methods thin and separate from the internal logic

None of this is flashy. But how you cut the boundary has a real impact on maintainability later. When you want to keep native assets alive while bringing C#’s productivity to just the logic layer, this setup is well worth remembering.

Five rules for a boundary that does not breakFlattening to a C ABI, handle-based lifetime management, crossing the boundary with error codes, pinning down the calling convention, and keeping exports thin and separate from the internal logic are the five points that make a boundary hard to break.A C# native DLL that does not breakFlatten to a C ABIMake lifetime explicit with handlesCross the boundary with error codesPin down the calling conventionKeep exports thin and separate from the implementation

Figure 17: The five rules from the summary. Unglamorous boundary design is what pays off most in maintainability.

10. References

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Frequently Asked Questions

Common questions about the topic of this article.

Can I call C# code from C++?
Yes. With .NET Native AOT you can publish a C# class library as a native shared library, and you can expose methods marked with UnmanagedCallersOnly as C entry points. In other words, you can use C# in-process from C/C++ as the native DLL that gets called.
What situations is this setup suited to?
It suits cases where you keep the native application itself intact and move only pieces such as decision logic, string processing, configuration interpretation, or calculation rules over to C#. The defining trait is the direction: the native side drives and C# is called as a component. Conversely, P/Invoke fits when C# calls a set of C functions, C++/CLI fits when you want to work with C++ types and ownership naturally, and COM or IPC fits when you need to cross 32-bit/64-bit or process boundaries.
What should I watch out for when designing the API?
What gets exported is strictly a C function entry point, so you must not expose string, List<T>, or exceptions at the boundary. Flatten the API into something like create / destroy / operate, make lifetime management and error codes explicit, handle strings as a pointer plus a length plus a buffer capacity, keep exceptions from crossing the boundary, and pin down the calling convention. The key point is to design the boundary as a C ABI rather than as .NET.
Is there working sample code?
Yes. The komurasoft-blog-samples repository on GitHub publishes a complete buildable and runnable sample set: the C# library published with Native AOT, a C++ caller example, and unit tests. The code examples assume a Windows DLL, but the thinking is nearly the same on Linux and macOS.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog