Calling a C# Native AOT DLL from C/C++
· Go Komura · 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
- The Conclusion First (In One Line)
- Choosing the Right Bridge
- Architecture Diagram
- Minimal Setup
- 4.1. The C# Project
- 4.2. The Exported C# Code
- 4.3. The Publish Command
- 4.4. Calling It from C++
- 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
- Cases Where It Fits
- Cases Where It Still Doesn’t Fit
- Pitfalls
- Summary
- References
1. The Conclusion First (In One Line)
- If you want to call C# logic from C/C++ in-process, Native AOT +
UnmanagedCallersOnlyis a very strong option. - However, what gets exported is strictly a C function entry point. This is not a world where you expose
stringorList<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
flowchart LR
Cpp["C / C++ application"] -->|cdecl function calls| Dll["C# DLL published with Native AOT"]
Dll --> Exports["Exports marked UnmanagedCallersOnly"]
Exports --> Core["C# business logic"]
Exports --> Store["Handle 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_thandle - 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.
stringobjectList<T>TaskSpan<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
UnmanagedCallersOnlymust bestatic. - 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
- Complete sample code for this article (C# library, C++ caller example, unit tests) - komurasoft-blog-samples (GitHub)
- Native code interop with Native AOT - Microsoft Learn
- Building native libraries - Microsoft Learn
- Native AOT deployment - Microsoft Learn
- UnmanagedCallersOnlyAttribute Class - Microsoft Learn
- UnmanagedCallersOnlyAttribute.CallConvs Field - Microsoft Learn
- C# compiler breaking changes: ref / ref readonly / in / out are not allowed on methods attributed with UnmanagedCallersOnly
- Building Native Libraries with NativeAOT - dotnet/samples
- Why a C++/CLI Wrapper Is a Strong Choice for Using Native DLLs from C# - KomuraSoft Blog
- How to Call a 64-bit DLL from a 32-bit App - A Case Study Where a COM Bridge Helps - KomuraSoft Blog
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Windows App Outsourcing and Contract Development: What to Sort Out Before You Ask
Before commissioning Windows app outsourcing or contract development, here is how to sort out existing software modification, device inte...
A Checklist for Safely Handling Child Processes in Windows Apps
Safely handling child processes in Windows apps depends less on the launch API and more on designing process tree ownership and shutdown ...
Serial Communication App Pitfalls - Through Reconnection and Log Design
The serial communication app pitfalls you want to avoid in device integration and instrument control, organized from a practical perspect...
Shared Memory Pitfalls and Practical Best Practices
The pitfalls of using shared memory in production, and a design approach that lowers the accident rate by covering synchronization, visib...
Pre-Migration Checklist for Moving from .NET Framework to .NET
A practical checklist of what to verify before migrating from .NET Framework to .NET: project types, unsupported technologies, NuGet depe...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
32-bit / 64-bit Interoperability
Topic page for 32-bit / 64-bit interoperability, native boundaries, and related Windows design decisions.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
Implementing the boundary between C# and C/C++ is exactly the kind of design and implementation work we cover under Windows application development.
Legacy Asset Reuse & Migration Support
Building a bridge between existing native assets and .NET also fits well with our legacy asset reuse and migration support.
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.
Public links