
Here's something that I didn't know you could do: You can build .NET native DLLs using AOT compilation, that can be compiled to platform specific native DLLs. For example, on Windows you can compile an AOT library down to a Standard Calling Convention type DLL that you can call from any tool that can access WinApi dlls.
I'm specifically looking at this for some Windows DLLs that I eons ago created C compiler built DLLs. Moving those into .NET and C# certainly would make a number of things easier including not having to install the C++ compiler and all its crazy Windows dependencies (C++ SDK, ATL etc.) and dealing with the constant version update hassles in Visual Studio when the C++ compiler revs.
My specific use case involves some very old FoxPro x86 code on the client end calling into DLL code, so I'll use FoxPro for the client end of the examples that call into the AOT code here.
Setting up an AOT Project for native Windows DLL Compilation
So here's what you need to create an .NET AOT DLL for use as a Windows API style DLL:
- Create a project file and set it up for AOT compilation
- Create a class and use specific attributes to specify native Exports
- Use
dotnet publish to build the native DLL for each target platform
Let's start with the project file:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<!-- These are the critical settings -->
<PublishAot>true</PublishAot>
<NativeLib>Shared</NativeLib>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
Next create a class and add the appropriate attributes:
public static class Exports
{
[UnmanagedCallersOnly(
EntryPoint = "Add",
CallConvs = new[] { typeof(CallConvStdcall) })]
public static int Add(int a, int b)
{
return a + b;
}
}
The key bit here are the [UnmanagedCallersOnly] attribute which marks the method for external access. For Windows Standard Call or 'WinApi' style DLLs you can specify the CallConvStdcall type which is required in order to generate the DLL export, so the exported function becomes visible.
Next you need to build the project. A plain build just builds a .NET DLL, but in order to create the native DLL you have to dotnet publish it.
Open a terminal in the project folder and then use:
dotnet publish -c Release -r win-x64 /p:PublishAot=true
dotnet publish -c Release -r win-x86 /p:PublishAot=true
You should publish for each of the platforms you want to target.
To call this now externally - FoxPro in my case - I can do the following:
lcDll = FullPath("wwDotnetBridge_Loader.dll")
DECLARE integer Add;
in (lcDll) ;
as DotnetAdd ;
integer a, integer b
? DotNetAdd(1,5)
Et voila: You now have a Windows compatible DLL that you can call from another application.

The file is 850k - not exactly tiny but considering it's .NET code that's not bad. As you add features though, the size of the DLL gets bigger. Obviously the code above does very little so that's about as small as the DLL gets and from here any dependencies used increase the size - the bigger the dependency the bigger the size! For the samples I show here (which are all very minimal!) the size will end up at about 1.5mb.
It's .NET but it's different
Although you're building your code with .NET, a Windows native DLL that is called using external non-.NET code has to behave more like a C/C++ method than a .NET interface. This means that while you can easily pass primitive parameters like the integers I passed above back and forth, for just about anything else you have to fall back to C style calling conventions, which means pointers for strings and objects, ints for boolean values and so on. The only straight through values are number primitive types (int, long, double, single).
This makes passing parameters in and out a bit more tedious. Here's an example of passing strings in and out:
[UnmanagedCallersOnly(
EntryPoint = "StringInStringOut",
CallConvs = new[] { typeof(CallConvStdcall) })]
public static int StringInStringOut(IntPtr input, IntPtr output)
{
// retrieve the input buffer
string inputStr = Marshal.PtrToStringAnsi(input) ?? string.Empty;
// get the empty buffer for size
string outputStr = Marshal.PtrToStringAnsi(output) ?? string.Empty;
if (outputStr.Length < 1)
return 0; // output buffer too small
var result = inputStr + " !!!"; // "Echoing back your message:\r\n\r\n" + inputStr;
WriteAnsiString(output, outputStr?.Length ?? 0 , result);
return outputStr.Length; // success
}
In typical Windows API style string output is passed in as a buffer address that is filled by the method. Input too comes in as a pointer that has to be deferenced back into a string first.
To call this from FoxPro looks like many Windows APIs by providing a pre-allocated buffer for the return value:
DECLARE integer StringInStringOut ;
in (lcDll) ;
string input, string@ output
lcOutput = SPACE(255)
? StringInStringOut("Hello World. Time is: " + TIME(), @lcOutput)
? lcOutput
Objects? Yes but no Thanks!
Object passing is even more painful especially when you work with a non-structured client like FoxPro that can't easily construct a fixed structured. For objects, the best way is to use Structures with fixed layouts that can be filled and read by the client.
Here's an example of passing data in via a structure, updating it and passing it back:
[StructLayout(LayoutKind.Sequential, Pack = 1, CharSet = CharSet.Ansi)]
public struct PersonInfo
{
public int Id;
public double Amount;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string Name;
}
[UnmanagedCallersOnly(
EntryPoint = "ProcessPerson",
CallConvs = new[] { typeof(CallConvStdcall) })]
public static int ProcessPerson(IntPtr personPtr)
{
if (personPtr == IntPtr.Zero)
return -1;
// read the structure from the VFP buffer
var person = Marshal.PtrToStructure<PersonInfo>(personPtr);
var id = person.Id; // return val
// Update fields
person.Id += 100;
person.Amount += 10.00;
person.Name = "Updated from .NET";
// Write updated struct back into same VFP buffer
Marshal.StructureToPtr(person, personPtr, false);
return id;
}
From FoxPro this code is ugly as you have to create the binary layout via string construction. Here's what that looks like:
lcStruct = ;
BINTOC(11, "4RS") + ; && int Id
BINTOC(99.95, "8B") + ; && double Amount - verify format
PADR("Rick", 64, CHR(0)) && fixed char[64]
? lcStruct
DECLARE INTEGER ProcessPerson IN (lcDll) STRING@ person
lnResult = ProcessPerson(@lcStruct)
? lnResult && 11
*** retrive the data back out
lnId = CTOBIN(SUBSTR(lcStruct, 1, 4), "4RS")
lnAmount = CTOBIN(SUBSTR(lcStruct, 5, 8), "8B")
lcName = SUBSTR(lcStruct, 13, 64)
lcName = LEFT(lcName, AT(CHR(0), lcName + CHR(0)) - 1)
? lnId && 111
? lnAmount
? lcName
Yeah that's not pretty, but it gives you the idea of how to pass complex data in and out using StdCall DLL interface. This isn't any better than standard C/C++ because we're building a native DLL.
There are FoxPro libraries that can help with structure conversions (Vfp2C32) but even then this process is tedious and error prone especially if structures change.
When does this make Sense?
I was pretty excited about the idea of having access to C# to create DLL interfaces to replace some of my ancient nearly unmaintainable DLL interfaces. Using C/C++ for critical components is a PITA and requires installation of the C compiler and all of the required dependencies which break every time a new version of the compiler or the Windows SDK ships. So yeah, replacing C with C# and AOT compilation seems like a good idea.
But... there are issues. I use .NET Interop a lot from different applications, but one thing that has always bugged me is the need for the loader DLL that fires up the .NET Runtime Host. First of that code is incredibly dense and LLMs invariably have gotten this wrong for me on many occasions trying to clean this up. Unfortunately it turns out that using .NET and AOT doesn't help here as COM wrapper interfaces are not part of AOT. And even if it was, it probably would bloat the startup to multi-meg size that would be unacceptable.
The reality is that in order to do something productive with this technology you really still need to use the awkward calling syntax which is a large part of the pain involved with C code in the first place. That doesn't go away with AOT. And if you need to do serious work in C# code that requires runtime dependencies you're very likely to end up with a fairly large DLL anyway.
For most scenarios I'd recommend just sticking to traditional ways to call into .NET via COM Interop or wwDotnetBridge or something similar, and just use the .NET Runtime interface. Yes it's not native code, but you do get benefit of a clean calling interface and code that is only slightly slower on first access as the JIT compiles code. After that I wouldn't expect any reduced performance compared to native AOT compilation vs. standard Interop access.
I could see it useful for some things. I may still end up using it for my wwDotnetBridge bootstrapping DLL. Although I can't use the runtime libraries to load up the .NET Runtime Host I might be able to make this work with P/Invoke calls. Again the only real benefit I see for this is that would allow me to kick out the C compiler from my build pipeline which would be a win.
Summary
In the end using AOT to create native DLLs is a very niche feature that has a very narrow optimal usage range, that probably benefits only certain bootstrapping scenarios. Almost everything else is probably better handled with full .NET interop with the same performance profile other than initial JIT compile overhead.
However, if you find yourself with one of those very narrow use cases, I hope this post has given you the background for what you need to know to create your AOT DLLs...
© Rick Strahl, West Wind Technologies, 2005-2026