Preface
.NET NativeAOT is probably familiar to many developers. It can directly compile .NET assembly into native machine code, so that it can run directly away from the VM. A simple sentencedotnet publish -c Release -r <rid> /p:PublishAot=true
That's it.
When writing native programs such as C++ programs, we may need to do static linking so that the compiled programs can run without installing the used libraries on the target environment. This is very useful for systems like Linux with a varied environment.
So does .NET's NativeAOT do this too?
The answer is: Yes!
P/Invoke
In .NET, if we want to call native libraries (.dll, .so, .dylib, etc.), our commonly used method is P/Invoke.
For example, now I have a C++ library, export a function
int add(int x, int y)
, In .NET, I just need to simply write a sentence P/Invoke to create a static method to call it:
[DllImport("foo", EntryPoint = "add")]
extern static int Add(int x, int y);
(Add(3, 4));
This greatly simplifies our workload, we only need to know the function signature to easily import into .NET programs for use, and even automatically generate P/Invoke methods with the help of various code generation tools, such as CsWin32, is one of them.
When the P/Invoke method is called, the .NET runtime will look up and open the corresponding library file when we call it the first time, and then get the export symbol and get the call address to call it.
Direct P/Invoke under NativeAOT
You will find that in .NET, attributes are constants, and function signatures are even known at compile time. So will P/Invoke under NativeAOT have any targeted optimizations at compile time?
Of course that's...no! The working principle of P/Invoke in NativeAOT is basically exactly the same as that of non-NativeAOT: that is, binding is only performed when called at runtime. This is of course because of better compatibility, because even if you have some P/Invoke methods that don't actually exist in the library, there will be no problem as long as you don't call it, because they are all bound when you call it. (After all, you don't want to encounter various builds in C++ in .NET.unresolved symbol
Link error)
but! As mentioned earlier, since NativeAOT directly generates the final binary, it can actually utilize these constant information during compilation.
This is what I'm going to talk about next:
Direct P/Invoke is different from P/Invoke. It generates direct calls to P/Invoke's methods, and binds the function to the program startup and is performed by the operating system. In this case, the P/Invoke method will directly enter the compiled binary import table. If the corresponding method is missing at startup, it will fail to start directly.
When using Direct P/Invoke, we do not need to change any code, we just need to follow theModule name! Entry name
The format needs to be added to Direct P/Invoke. For example, beforeThe inside
add
, we just need to write in our project file:
<ItemGroup>
<DirectPInvoke Include="foo!add" />
</ItemGroup>
Importedfoo
In the moduleadd
All functions' P/Invoke will be automatically compiled into Direct P/Invoke.
The entry name can even be omitted here. If omitted, it means that all P/Invokes for this module are Direct P/Invokes:
<ItemGroup>
<DirectPInvoke Include="foo" />
</ItemGroup>
Further, we can directly import libc:
<ItemGroup>
<DirectPInvoke Include="libc" />
</ItemGroup>
Even if the list is too long, we can create a single line in a text file and then use it directly.DirectPInvokeList
To import:
<ItemGroup>
<DirectPInvokeList Include="" />
</ItemGroup>
Direct P/Invoke not only has better performance advantages, but also allows us to statically link the P/Invoke method.
Static links
With Direct P/Invoke, the symbols we need to call are already lying in our binary import table. So as long as we link the static library to our binary, our application can be started directly without any dependencies.
It is also very easy to do this, add it to the project fileNativeLibrary
Just:
<ItemGroup>
<NativeLibrary Include="" />
</ItemGroup>
If we need to support multiple platforms, such as supporting both Windows and Linux, we only need conditional import:
<ItemGroup>
<NativeLibrary Condition="$(('win'))" Include="" />
<NativeLibrary Condition="$(('linux'))" Include="" />
</ItemGroup>
In this way, we can directly link the static library to our program.
Furthermore, we can also pass various parameters to the linker to implement custom link behavior:
<ItemGroup>
<LinkerArg Include="/DEPENDENTLOADFLAG:0x800" Condition="$(('win'))" />
<LinkerArg Include="-Wl,-rpath,'/bin/'" Condition="$(('linux'))" />
</ItemGroup>
We can also passLinkerFlavor
Properties to set the linker you want to use (for example, ldd, bfd, etc.):
<PropertyGroup>
<LinkerFlavor>ldd</LinkerFlavor>
</PropertyGroup>
Distroless Application
At this point, we are actually able to statically link any third-party library. If it is Windows, then it ends with it, because the NativeAOT program itself only relies on ucrt, and the Windows API itself has already provided all API support; but if it is Linux, it is still a little bit worse because it depends on external libicu and OpenSSL. At this time, we need to use the properties provided by the official to switch to static links.
For libicu, this library mainly provides international support, and can be directly set if not required<InvariantGlobalization>true</InvariantGlobalization>
This will turn off this support. But if you need it, you can choose to link it statically:
<PropertyGroup>
<!-- Static link libicu -->
<StaticICULinking>true</StaticICULinking>
<!-- Embed ICU data -->
<EmbedIcuDataPath>/usr/share/icu/74.2/</EmbedIcuDataPath>
</PropertyGroup>
For OpenSSL, just:
<PropertyGroup>
<StaticOpenSslLinking>true</StaticOpenSslLinking>
</PropertyGroup>
Just do it.
Note that the machine you are using to build needs to have cmake and corresponding native static libraries to complete the construction. Specifically,libicu-dev
andlibssl-dev
。
In the last step, set our application to a purely static application:
<PropertyGroup>
<StaticExecutable>true</StaticExecutable>
</PropertyGroup>
Try it with a simple program
First we pull down the alpine image. The reason why Ubuntu is not used here is that the muslc that comes with alpine is more static link-friendly than glibc. Of course, you can also use Ubuntu and glibc, but glibc may have problems in static link environments.
docker pull /dotnet/sdk:9.0-alpine
After starting the container, install the third-party dependencies we need. Note that we need to install the static library together:
apk add cmake make clang icu-static icu-dev openssl-dev openssl-libs-static
Here we first prepare our static library: create a new file and write it in it
__attribute__((__visibility__("default")))
int add(int x, int y)
{
return x + y;
}
Then we create a static library:
clang -c -o -fPIC -O3
ar r
Then we create a C# console project:
mkdir Test && cd Test
dotnet new console
Then edit Add P/Invoke and call the function exported by foo add:
using ;
(Add(2, 3));
[DllImport("foo", EntryPoint = "add"), SuppressGCTransition]
extern static int Add(int x, int y);
Here we know that add is called quickly, so there is no need to let .NET runtime switch GC working mode, so we add[SuppressGCTransition]
to improve interoperability performance.
Then editAdd Direct P/Invoke and NativeLibrary, and set other properties:
<Project Sdk="">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<OutputType>Exe</OutputType>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<StaticOpenSslLinking>true</StaticOpenSslLinking>
<StaticExecutable>true</StaticExecutable>
</PropertyGroup>
<ItemGroup>
<DirectPInvoke Include="foo" />
<DirectPInvoke Include="libc" />
<NativeLibrary Include="../" />
</ItemGroup>
</Project>
Then use NativeAOT to publish our program!
dotnet publish -c Release -r linux-musl-x64 /p:PublishAot=true
It's done, see what's released:
ls -s bin/Release/net9.0/linux-musl-x64/publish/
total 3956
1360 Test 2596
As you can see, the generated binary volume is only 1360 KB! (By the way, this volume will be smaller under .NET 10). This binary contains everything you need to run the program without any additional dependencies, not even libc.
Let's see what code is finally generated:
objdump -d -S -M intel bin/Release/net9.0/linux-musl-x64/publish/Test
Find the Main function:
00000000000d50e0 <Test_Program___Main__>:
using ;
(Add(2, 3));
d50e0: 55 push rbp
d50e1: 48 8b ec mov rbp,rsp
d50e4: bf 02 00 00 00 mov edi,0x2
d50e9: be 03 00 00 00 mov esi,0x3
d50ee: e8 8d 02 f3 ff call 5380 <add>
d50f3: 8b f8 mov edi,eax
d50f5: e8 86 0a fc ff call 95b80 <System_Console_System_Console__WriteLine_7>
...
You can find that the generated code is very efficient. In addition, the reason why we can dump C# source code information here is that NativeAOT compilation will automatically generate debug symbol files, which is ours, if deleted, there will be no such information.
And we then looked up and found the address 5380<add>
, you can see:
0000000000005380 <add>:
5380: 8d 04 37 lea eax,[rdi+rsi*1]
5383: c3 ret
...
If we dump the code of the native library we compiled before:
objdump -d -S -M intel ../
You will get the following results:
0000000000000000 <add>:
0: 8d 04 37 lea eax,[rdi+rsi*1]
3: c3 ret
Have you found it? The static library we wrote in C was completely statically linked into the C# program! In this way, we do not need to configure any environment, retain any dependencies, and do not need to install any third-party libraries. We just need to copy the test executable program we built to any x64 Linux machine to run and output the results we want.
Try running:
./Test
5
Try the web server program again
This time we can try to create a Web API project called Test:
mkdir Test && cd Test
dotnet new webapiaot
After creation, we need to edit the project file:
<Project Sdk="">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<StaticOpenSslLinking>true</StaticOpenSslLinking>
<StaticExecutable>true</StaticExecutable>
</PropertyGroup>
<ItemGroup>
<DirectPInvoke Include="libc" />
</ItemGroup>
</Project>
Then a simple sentence:dotnet publish -c Release -r linux-musl-x64 /p:PublishAot=true
, the project is automatically compiled and generated, and we finallybin/Release/net9.0/linux-musl-x64/publish
We will find our final binary system below.
Let's copy it and execute it on other machines and see:
ldd ./Test
statically linked
Perfect. In this way, even if you throw it on the soft router, you don’t need to configure any environment to run.
Execute it and take a look:
./Test
info: [14]
Now listening on: http://localhost:5000
info: [0]
Application started. Press Ctrl+C to shut down.
info: [0]
Hosting environment: Production
info: [0]
Content root path: /root/Test
Visit and take a look:
curl -X GET http://localhost:5000/todos
[{"id":1,"title":"Walk the dog","dueBy":null,"isComplete":false},{"id":2,"title":"Do the dishes","dueBy":"2025-04-07","isComplete":false},{"id":3,"title":"Do the laundry","dueBy":"2025-04-08","isComplete":false},{"id":4,"title":"Clean the bathroom","dueBy":null,"isComplete":false},{"id":5,"title":"Clean the car","dueBy":"2025-04-09","isComplete":false}]
Perfect!
Conclusion
With NativeAOT and Direct P/Invoke, we are able to create fully statically linked .NET NativeAOT programs, allowing us to distribute the binary directly to any Linux distribution, running without configuring the environment or dependencies. This way, .NET unlocks the ability to build fully distroless binary.
And, the same applies to desktop applications like Avalonia! You only need to use Direct P/Invoke and NativeLibrary to statically link libSkiaSharp and ANGLE (libSkiaSharp needs to build a matching version from the source code, and ANGLE can directly download and install the static library with vcpkg). The Avalonia app you built with NativeAOT will be able to run on any Linux distribution that runs on any compatible hardware architecture.