Calling a C# Native AOT DLL from C/C++
· Updated: · Go Komura · 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.
flowchart TB
accTitle: The difference in direction from the previous article
accDescr: The 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.
prev["Previous: C# calls C++"] --> wrap["The C++/CLI wrapper story"]
now["This time: C/C++ calls C#"] --> aot["Publish C# as a DLL with Native AOT"]
aot --> entry["UnmanagedCallersOnly 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
- 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++
- 4.5. Checking That the Names Really Are Exported
- 4.6. Linking Statically with an Import Library
- 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
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 +
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.
flowchart TB
accTitle: Design the boundary as a C ABI
accDescr: The 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<T>. Flatten it into a C API like create / destroy / operate and make lifetime management and error codes explicit.
inner["The inside is ordinary C#"] --> face["The exposed surface is a flat C API"]
face --> h["Lifetime is explicit through handles"]
face --> e["Errors come back as codes"]
face -.-> ng["Do 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.
flowchart TB
accTitle: Which side drives
accDescr: P/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.
cs["C# drives"] -->|"calls native code"| n1["P/Invoke or C++/CLI"]
nat["Native code drives"] -->|"calls C# as a component"| n2["Export 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
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"]
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_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.
flowchart TB
accTitle: A flat handle-based API
accDescr: create 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["create: hand out a handle"] --> op["add or get: operate with the handle"]
op --> destroy["destroy: clean up"]
op -.-> state["The real state lives on the C# side"]
op -.-> err["The 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
Incrementreturns the value after adding, so the first handle is 1. That is what lets the C++ side useintptr_t handle = 0;as a marker for “nothing held yet.” - Truncation happens on 32-bit.
nintis pointer-width, so it is 64 bits on 64-bit but 32 bits in a 32-bit build. Casting fromlongtonintsilently 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.
flowchart TB
accTitle: How handle numbering breaks when it wraps around and what to do about it
accDescr: When 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.
wrapd["Numbering wraps around on 32-bit"] --> add["Add for the same value succeeds"]
add --> alias["An old handle points at a new instance"]
alias --> silent["It breaks with no exception and no error code"]
silent --> g1["Fix: embed a generation number"]
silent --> g2["Fix: 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.
flowchart TB
accTitle: The first hurdle in publishing
accDescr: Native 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.
pub["Run dotnet publish"] --> q{"Is a native toolchain installed"}
q -->|"no"| fail["Fails at the final native link"]
q -->|"yes"| out["A native DLL comes out per RID"]
out -.-> match["Match 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.
flowchart TB
accTitle: Isolating the problem when the call does not work
accDescr: If 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.
s1{"Did LoadLibraryW succeed?"}
s1 -->|"failed"| f1["The DLL was not loaded"]
f1 -.-> f1a["Suspect the path, the bitness, or dependent DLLs"]
s1 -->|"succeeded"| s2{"Did GetProcAddress succeed?"}
s2 -->|"failed"| f2["The export was not found"]
s2 -->|"succeeded"| ok["Callable 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.
- Does the name appear in
dumpbin /exports(if not, this is a C# side issue) - Does the string in
EntryPointmatch the string passed toGetProcAddressexactly (case is significant) - Do the bitnesses of the calling EXE and the DLL match
- Is the method marked with
UnmanagedCallersOnlystatic, and is it outside any generic - 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.
flowchart TB
accTitle: The order to check when GetProcAddress returns NULL
accDescr: Check 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.
c1["Does the name appear in dumpbin"] --> c2["Do the strings match exactly"]
c2 --> c3["Do the bitnesses match"]
c3 --> c4["Is it static and outside generics"]
c4 --> c5["Is the attribute in the published assembly"]
c1 -.->|"not listed"| cs["Investigate 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:
- If the publish output already contains an import library (
.lib), link against it - If it does not, prepare a
.deffile listing the export names and build the import library withlib.exe /def:NativeAotSample.def /out:NativeAotSample.lib /machine:x64 - 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.
flowchart TB
accTitle: Choosing between dynamic loading and static linking
accDescr: The 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.
q{"How do you want to integrate it"}
q -->|"slot it in like a plugin"| dyn["The LoadLibrary approach"]
q -->|"call it straight from a header"| stat["Static link with an import lib"]
stat -.-> risk["A 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.
flowchart TB
accTitle: What the ABI contract covers
accDescr: An 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["ABI (a contract at the machine-code level)"] --> a1["Calling convention"]
abi --> a2["Type layout"]
abi --> a3["Names and linking"]
a1 -.-> ex["cdecl 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.
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.
flowchart TB
accTitle: Types to expose at the boundary and types to keep inside
accDescr: Expose primitive types, structs with a fixed layout, handles, and a pointer paired with a length, and keep string, object, List<T>, Task, C++ classes, and the STL from leaking out.
edge["Types to expose at the boundary"] --> ok1["Primitives, fixed structs, and handles"]
edge --> ok2["A pointer plus a length"]
keepx["Types to keep inside"] --> ng1["string, List of T, and Task"]
keepx --> ng2["C++ 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.
flowchart TB
accTitle: An error design that keeps exceptions from crossing
accDescr: Do 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.
exc["An exception inside C#"] --> stop["Catch it on the inside of the boundary"]
stop --> code["The return value is a status code"]
stop --> outp["Actual data goes through pointer arguments"]
stop -.-> last["Extra 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.
flowchart TB
accTitle: Keep exports thin and put the implementation elsewhere
accDescr: Keep 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.
exp["Export method (a thin front desk)"] --> core["Internal class (ordinary C#)"]
exp -.-> abi["Only ABI validation and conversion"]
core -.-> test["Testable 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#.
flowchart TB
accTitle: The split that works well
accDescr: Leave 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.
app["An existing C / C++ application"] --> keepn["UI and device control stay in C++"]
app --> logic["Decisions, calculations, and configuration rules in C#"]
logic --> api["Connected through a small C API surface"]
api -.-> multi["Other 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.
flowchart TB
accTitle: Cases where it does not fit and the bridge to use instead
accDescr: Use 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.
q{"What are you after"}
q -->|"C++ types and exceptions as they are"| cli["Go to C++/CLI or a wrapper"]
q -->|"the registration and automation world"| com["Go to the COM context"]
q -->|"crossing bitness or processes"| ipc["Go to COM, IPC, or a separate process"]
q -->|"unloading it later"| ng["This 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
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.
flowchart TB
accTitle: Five rules for a boundary that does not break
accDescr: Flattening 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.
goal["A C# native DLL that does not break"] --> p1["Flatten to a C ABI"]
goal --> p2["Make lifetime explicit with handles"]
goal --> p3["Cross the boundary with error codes"]
goal --> p4["Pin down the calling convention"]
p1 -.-> p5["Keep 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
- 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
- DUMPBIN /EXPORTS - Microsoft Learn
- LIB Reference - Microsoft Learn
- Calling Native DLLs from C#: C++/CLI Wrapper vs P/Invoke - KomuraSoft Blog
- A Worked Example of a COM Bridge for Calling a 64-bit DLL from a 32-bit App - KomuraSoft Blog
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Why Arguments Break — The Rules of Windows Command-Line Arguments
Windows passes CreateProcess a single string that the receiver splits. Covers the CommandLineToArgvW, CRT, and .NET rules, ArgumentList, ...
Do Business Apps Run on Windows on Arm? — The Reality of x64 Emulation (Prism) and Native DLLs/COM
An answer, aimed at developers and IT staff, to 'will our business app run on Windows on Arm?' Covers how x64 emulation (Prism) works, th...
Safely Calling Win32 APIs from C# — A Practical P/Invoke Guide (DllImport / LibraryImport / CsWin32)
A practical rundown of what to watch for when calling Win32 APIs and native DLLs from C# via P/Invoke. Covers the differences between Dll...
Time Travel Debugging — Recording and Rewinding the Bugs That Never Reproduce in Long-Running Apps
A once-a-month bug leaves only its result in a crash dump. Record and rewind execution with WinDbg Time Travel Debugging (TTD): TTD.exe, ...
End of Servicing for Windows Printer Drivers — How Business Apps Should Prepare Their Report and Label Printing
Microsoft is phasing out v3/v4 printer drivers. What Windows protected print mode removes, and how to inventory and prepare report and la...
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.
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.