前言

.NET NativeAOT 想必不少开发者都已经很熟悉了,它可以将 .NET 程序集直接编译到原生的机器代码,从而可以脱离 VM 直接运行。简单的一句 dotnet publish -c Release -r <rid> /p:PublishAot=true 就可以做到。

在编写 C++ 程序之类的原生程序时,我们可能需要做静态链接,这样编译出来的程序无需在目标环境上安装使用到的库就能运行起来。这对 Linux 这种环境多变的系统非常有用。

那 .NET 的 NativeAOT 是否也做到这一点呢?

答案是:可以!

P/Invoke

在 .NET 中,想要调用原生库(.dll、.so、.dylib等等),我们常用的方法是 P/Invoke。

例如现在我有一个 C++ 库 foo.dll,导出了一个函数 int add(int x, int y),那在 .NET 中,我只需要简单的编写一句 P/Invoke 创建一个静态方法就能够调用它:

[DllImport("foo", EntryPoint = "add")]
extern static int Add(int x, int y); Console.WriteLine(Add(3, 4));

这极大地简化了我们的工作量,我们只需要知道函数签名就能轻而易举地导入 .NET 程序中使用,甚至可以借助各种代码生成工具自动生成 P/Invoke 方法,例如 CsWin32 就是其中之一。

当调用 P/Invoke 方法时,.NET 运行时会在我们第一次调用它的时候查找并打开对应的库文件,然后获取导出符号拿到调用地址进行调用。

NativeAOT 下的 Direct P/Invoke

你会发现在 .NET 中,attribute 都是常量,而函数签名更是编译时已知的,那么 NativeAOT 下的 P/Invoke 会不会有什么编译时的针对性优化呢?

那当然是...没有的!NativeAOT 中的 P/Invoke 工作原理和非 NativeAOT 时基本上是完全一致的:也就是在运行时调用的时候才进行绑定。这么做当然是因为兼容性更好,因为即使你有一些 P/Invoke 方法在库中实际不存在,只要不去调用它也不会出现问题,因为它们都是在你调用的时候才进行绑定的。(毕竟你也不希望在 .NET 中遇到 C++ 里各种各样的构建时 unresolved symbol 链接错误)

但是!正如前面所说,NativeAOT 既然直接产生最终二进制,那么其实是可以在编译时利用到这些常量信息的。

这就是我接下来说的 Direct P/Invoke:

Direct P/Invoke 不同于 P/Invoke,它会对 P/Invoke 的方法生成直接调用,并且将函数绑定放到程序启动时由操作系统来进行。这种情况下,P/Invoke 方法会直接进入编译出的二进制的导入表,如果启动时缺失了对应的方法会直接启动失败。

使用 Direct P/Invoke 的时候,我们不需要更改任何的代码,只需要在项目文件中按照 模块名!入口点名 的格式加入需要编译成 Direct P/Invoke 的方法即可。例如我们前面 foo.dll 里面的 add,我们只需要在我们的项目文件中写:

<ItemGroup>
<DirectPInvoke Include="foo!add" />
</ItemGroup>

导入了 foo 模块中 add 函数的 P/Invoke 就全都会被自动编译成 Direct P/Invoke。

在这里入口点名甚至可以被省略,如果省略的话则表示对这个模块所有的 P/Invoke 都是 Direct P/Invoke:

<ItemGroup>
<DirectPInvoke Include="foo" />
</ItemGroup>

进一步,我们可以直接导入 libc:

<ItemGroup>
<DirectPInvoke Include="libc" />
</ItemGroup>

甚至如果列表太长的话,我们还可以单独创建一个文本文件里面一行一个,然后直接用 DirectPInvokeList 来导入:

<ItemGroup>
<DirectPInvokeList Include="NativeMethods.txt" />
</ItemGroup>

Direct P/Invoke 不仅有着更好的性能优势,而且允许我们对 P/Invoke 方法进行静态链接。

静态链接

有了 Direct P/Invoke,我们需要调用的符号已经躺在了我们二进制的导入表里,那么我们其实只要把静态库链接到我们的二进制里去,我们的应用就能无需任何的依赖直接启动了。

做到这一点也是非常的简单,在项目文件里加入 NativeLibrary 即可:

<ItemGroup>
<NativeLibrary Include="foo.lib" />
</ItemGroup>

如果我们需要支持多平台,例如同时支持 Windows 和 Linux,那我们也只需要条件导入即可:

<ItemGroup>
<NativeLibrary Condition="$(RuntimeIdentifier.StartsWith('win'))" Include="foo.lib" />
<NativeLibrary Condition="$(RuntimeIdentifier.StartsWith('linux'))" Include="libfoo.a" />
</ItemGroup>

这样我们就可以把静态库直接链接到我们的程序当中来了。

进一步,我们还可以给链接器传递各种参数实现自定义链接行为:

<ItemGroup>
<LinkerArg Include="/DEPENDENTLOADFLAG:0x800" Condition="$(RuntimeIdentifier.StartsWith('win'))" />
<LinkerArg Include="-Wl,-rpath,'/bin/'" Condition="$(RuntimeIdentifier.StartsWith('linux'))" />
</ItemGroup>

我们还可以通过 LinkerFlavor 属性来设置想要使用的 linker(例如 ldd、bfd 等等):

<PropertyGroup>
<LinkerFlavor>ldd</LinkerFlavor>
</PropertyGroup>

Distroless 应用

到了这里,其实我们已经能够做到静态链接任何的第三方库了。如果是 Windows 的话到此为止,因为 NativeAOT 程序自身只依赖 ucrt,Windows API 自身就已经提供了全部的 API 支持;但如果是 Linux 的话则还差一点,因为依赖外部的 libicu 和 OpenSSL,这个时候就需要我们使用官方为我们提供的属性来切换到静态链接了。

对于 libicu 而言,这个库主要提供国际化支持,如果不需要的话可以直接设置 <InvariantGlobalization>true</InvariantGlobalization> 这样就会关闭这个支持。但如果你需要的话则可以选择把它静态链接了:

<PropertyGroup>
<!-- 静态链接 libicu -->
<StaticICULinking>true</StaticICULinking>
<!-- 嵌入 ICU data -->
<EmbedIcuDataPath>/usr/share/icu/74.2/icudt74l.dat</EmbedIcuDataPath>
</PropertyGroup>

而对于 OpenSSL 而言,只需要:

<PropertyGroup>
<StaticOpenSslLinking>true</StaticOpenSslLinking>
</PropertyGroup>

即可。

注意你用来构建的机器需要有 cmake 以及对应的原生静态库才能完成构建,具体而言, libicu-devlibssl-dev

最后一步,将我们的应用设置成纯静态应用即可:

<PropertyGroup>
<StaticExecutable>true</StaticExecutable>
</PropertyGroup>

用一个简单程序试试

首先我们拉下来 alpine 的镜像。这里之所以不用 Ubuntu 之类的是因为 alpine 自带的 muslc 相对于 glibc 而言对静态链接更加友好。当然你也可以用 Ubuntu 和 glibc,只不过 glibc 在静态链接环境下可能会出问题。

docker pull mcr.microsoft.com/dotnet/sdk:9.0-alpine

启动容器后安装我们需要的第三方依赖,注意这里要把静态库也一并安装:

apk add cmake make clang icu-static icu-dev openssl-dev openssl-libs-static

这里我们首先准备我们的静态库:新建一个 foo.c 文件,里面编写

__attribute__((__visibility__("default")))
int add(int x, int y)
{
return x + y;
}

然后我们创建一个静态库:

clang -c -o libfoo.o foo.c -fPIC -O3
ar r libfoo.a libfoo.o

紧接着我们创建一个 C# 控制台项目:

mkdir Test && cd Test
dotnet new console

然后编辑 Program.cs 添加 P/Invoke 并调用 foo 导出的函数 add:

using System.Runtime.InteropServices;

Console.WriteLine(Add(2, 3));

[DllImport("foo", EntryPoint = "add"), SuppressGCTransition]
extern static int Add(int x, int y);

这里我们知道 add 的调用很快,因此无需让 .NET runtime 切换 GC 工作模式,因此我们添加 [SuppressGCTransition] 以提升互操作性能。

然后编辑 Test.csproj 添加 Direct P/Invoke 和 NativeLibrary,并且设置其他属性:

<Project Sdk="Microsoft.NET.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="../libfoo.a" />
</ItemGroup> </Project>

然后用 NativeAOT 发布我们的程序!

dotnet publish -c Release -r linux-musl-x64 /p:PublishAot=true

大功告成,看看发布出了什么:

ls -s bin/Release/net9.0/linux-musl-x64/publish/

total 3956
1360 Test 2596 Test.dbg

可以看到,生成的二进制体积仅仅只有 1360 KB!(顺带一提这个体积在 .NET 10 下还会更小)。这一个二进制包含了运行程序所需要的所有东西,无需任何的额外依赖,甚至连 libc 都不需要。

让我们看看最终到底生成了什么代码:

objdump -d -S -M intel bin/Release/net9.0/linux-musl-x64/publish/Test

找到 Main 函数:

00000000000d50e0 <Test_Program___Main__>:
using System.Runtime.InteropServices; Console.WriteLine(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>
...

可以发现生成的代码非常的高效。另外,这里之所以能 dump 出 C# 源码信息是因为 NativeAOT 编译会自动生成调试符号文件,也就是我们的 Test.dbg,如果删掉了的话那就没有这些信息了。

而我们接着往上翻找到 5380 地址处的 <add>,则可以看到:

0000000000005380 <add>:
5380: 8d 04 37 lea eax,[rdi+rsi*1]
5383: c3 ret
...

如果此时我们 dump 一下我们之前编译出来的原生库的代码的话:

objdump -d -S -M intel ../libfoo.o

会得到如下结果:

0000000000000000 <add>:
0: 8d 04 37 lea eax,[rdi+rsi*1]
3: c3 ret

发现了么?我们用 C 编写的静态库被我们彻底静态链接进了 C# 程序中!如此一来,我们不需要配置任何的环境,也不需要保留任何的依赖项,更不需要安装任何的第三方库,只需要把我们构建出来的 Test 这个可执行程序拷贝到任何一台 x64 的 Linux 机器上,就能运行输出我们想要的结果。

试着运行一下:

./Test
5

再试试 Web 服务器程序

这次我们可以试着创建一个叫做 Test 的 Web API 项目:

mkdir Test && cd Test
dotnet new webapiaot

创建好之后我们需要编辑一下项目文件 Test.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">

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

然后简单一句:dotnet publish -c Release -r linux-musl-x64 /p:PublishAot=true,项目自动编译生成,我们最终在 bin/Release/net9.0/linux-musl-x64/publish 下即可找到我们最终的二进制。

我们拷贝出来在其他机器上执行一下 ldd 看看:

ldd ./Test
statically linked

完美。这么一来你哪怕扔到软路由上,不需要配置任何环境都能运行。

执行一下看看:

./Test
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5000
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
Content root path: /root/Test

访问一下看看:

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}]

完美!

结语

有了 NativeAOT 和 Direct P/Invoke,我们能够创建完全静态链接的 .NET NativeAOT 程序,从而允许我们把二进制直接分发到任意的 Linux 发行版上,无需配置环境或依赖项就能运行。如此一来,.NET 解锁了构建完全 distroless 的二进制的能力。

并且,这同样适用于 Avalonia 这类桌面应用程序!你只需要利用 Direct P/Invoke 和 NativeLibrary 把 libSkiaSharp 和 ANGLE 静态链接进去(libSkiaSharp 需要自己从源码构建匹配的版本,ANGLE 可以用 vcpkg 直接下载安装静态库),你用 NativeAOT 构建出来的 Avalonia app 将能够在随便一个兼容的硬件架构上跑的任意的 Linux 发行版上跑起来。

用 .NET NativeAOT 构建完全 distroless 的静态链接应用的更多相关文章

  1. 怎么快速构建自己的C/C++程序?——有关编译、静态链接和SCons

    怎么快速构建自己的C/C++程序?--有关编译.静态链接和SCons 1. 写在前面 最初写C++是在Visual Studio这个IDE里,那时我并没有makefile的概念,对程序的编译和链接的一 ...

  2. [CSAPP-II] 链接[符号解析和重定位] 静态链接 动态链接 动态链接接口

    1 平台 转http://blog.csdn.net/misskissc/article/details/43063419 1.1 硬件 Table 1. 硬件(lscpu) Architecture ...

  3. 利用CMake生成动态或静态链接库工程

    install解释: TARGETS版本的install命令 install(TARGETS targets... [EXPORT <export-name>] [[ARCHIVE|LIB ...

  4. 关于C语言静态链接的个人理解,欢迎指正

    摘要:本篇主要介绍在静态链接中多个文件合并.地址确定.符号解析和重定位相关问题,以GCC编译器为例.     首先,链接器链接多个文件时,采用何种方式合并为一个文件?方式一,按序叠加,即多个文件依次叠 ...

  5. Qt之创建并使用静态链接库

    1.创建静态链接库 静态库的工程名字 添加包含的模型 更改一下类的名字 我的静态编译库的工程. 写一个简单的静态哭的代码为后面测试静态库使用 cpp代码: #include "staticb ...

  6. mingw编译opencv动态链接库和静态链接库及使用方法

    前言 我一直不知道编译的过程以及cmake, make 这些工具是干什么的,所有抽时间研究了一下. 简单来说就是 cmake 是根据 CMakeLists.txt 用来生成 makefile文件的.而 ...

  7. vs2010静态链接MFC库报链接错误

    由于需要将MFC程序在其它电脑上运行,所以需要将动态链接的MFC改成静态链接,本以为很简单,没想到链接的时候出现下面的链接错误: uafxcw.lib(afxmem.obj) : error LNK2 ...

  8. 原创 C++应用程序在Windows下的编译、链接:第三部分 静态链接(二)

    3.5.2动态链接库的创建 3.5.2.1动态链接库的创建流程 动态链接库的创建流程如下图所示: 在系统设计阶段,主要的设计内容包括:类结构的设计以及功能类之间的关系,动态链接库的接口.在动态链接库中 ...

  9. vc下的静态链接库与动态链接库(一)

    一.静态库与动态库的区别 目前以lib后缀的库有两种,一种为静态链接库(Static Libary,以下简称“静态库”),另一种为动态连接库(DLL,以下简称“动态库”)的导入库(Import Lib ...

  10. C/C++ 静态链接库(.a) 与 动态链接库(.so)

    平时我们写程序都必须 include 很多头文件,因为可以避免重复造轮子,软件大厦可不是单靠一个人就能完成的.但是你是否知道引用的那些头文件中的函数是怎么被执行的呢?这就要牵扯到链接库了! 库有两种, ...

随机推荐

  1. java解析CSV文件三种方法(openCSV)

    一.简介1.pom.xml<!-- csv文件解析依赖 --><dependency> <groupId>com.opencsv</groupId> & ...

  2. ClickHouse-4SQL参考

    SQL参考 ClickHouse支持以下形式的查询: SELECT INSERT INTO CREATE ALTER 其他类型的查询 ClickHouse SQL 语句 语句表示可以使用 SQL 查询 ...

  3. w3cschool-Go 教程

    https://www.w3cschool.cn/go/ Go 是一个开源的编程语言,它能让构造简单.可靠且高效的软件变得容易. Go是从2007年末由Robert Griesemer, Rob Pi ...

  4. C 2017笔试题

    1.下面程序的输出结果是 int x=3; do { printf("%d\n",x-=2); }while(!(--x)); 输出:1 -2 解析:x初始值为3,第一次循环中运行 ...

  5. vscode python remote debug极速入门

    本文适用范围 主要适用于debug python 程序,尤其是深度学习刚入门需要使用remote 连接到linux进行程序运行,想调试一下的同学. 当然非深度学习也可以参考食用本文哈哈哈. 极速入门版 ...

  6. 注册全局组件(H5) 任意页面使用

    在view下创建components文件夹. 在components下创建文件夹base. base文件夹是用来存放 基础组件的. 比如说页面中很多处都在使用的公共组件 如你需要自定义的按钮 在com ...

  7. 探寻SRC漏洞平台

    探寻SRC漏洞平台 SRC(Security Researcher Acknowledgement Program)是各大互联网厂商开启的漏洞发现奖励计划,也就是我们常说的漏洞赏金计划(bug bou ...

  8. Mac安装Scala2.12

    一.下载Scala brew install scala@2.12 二.设置环境变量 vim ~/.bash_profile export SCALA_HOME=/usr/local/opt/scal ...

  9. windows配置maven

    1.下载mavenhttps://mirrors.tuna.tsinghua.edu.cn/apache/maven/maven-3/ 中找到相应的版本2.解压3.配置环境变量MAVEN_HOMED: ...

  10. 记录一次修复 JetBrains Rider 控制台输出乱码

    在使用 JetBrains Rider 调试程序时,控制台输出日志出现了乱码. 歪打正着结果困扰许久的问题得到了解决,于是记录下了这个小短文. 具体的修复建议如下:将终端编码设置为 GB2312 具体 ...