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

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

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 the mood sours quickly. In this article, using a minimal Windows + C++ example, we look at when this setup really shines and what API shapes hold up well. The thinking is nearly the same on Linux / macOS, but the code examples assume a Windows DLL.

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++
  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

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.

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 is the protagonist and C# is called as a component. That direction is exactly the opposite of P/Invoke and C++/CLI.

3. Architecture Diagram

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

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.

4.3. The Publish Command

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.

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.

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

It is calmer to 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.

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.

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 less accident-prone.

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.

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#.

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.

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.

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.

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