.NET 7 AOT 的使用以及 .NET 与 Go 互相调用
关注我们
(本文阅读时间:15分钟)
本文主要介绍如何在 .NET 和 Go 语言中生成系统(Windows)动态链接库,又如何从代码中引用这些库中的函数。
在 .NET 部分,介绍如何使用 AOT、减少二进制文件大小、使用最新的 [LibraryImport] 导入库函数;
在 Go 语言部分,介绍如何使用 GCC 编译 Go 代码、如何通过 syscall 导入库函数。
在文章中会演示 .NET 和 Go 相互调用各自生成的动态链接库,以及对比两者之间的差异。
本文文章内容以及源代码,都可以在下面链接中找到,希望可以给你带来帮助!
文章内容以及源代码
微软MVP实验室研究员
痴者工良
身份:高级程序员劝退师
简介:一个小逗比
技术栈:.NET、Go、Web、云计算、物联网、Linux、容器、微服务、Kubernetes
C# 部分
▌环境要求
SDK:.NET 7 SDK、Desktop development with C++ workload
IDE:Visual Studio 2022
Desktop development with C++ workload 是一个工具集,里面包含 C++ 开发工具,需要在 Visual Studio Installer 中安装,如下图红框中所示。
▌创建一个控制台项目
首先创建一个 .NET 7 控制台项目,名称为 CsharpAot。
打开项目之后,基本代码如图所示:
我们使用下面的代码做测试:
public class Program
static void Main
Console.WriteLine("C# Aot!");
Console.ReadKey;
▌体验 AOT 编译
这一步,可以参考官方网站的更多说明。
为了能够让项目发布时使用 AOT 模式,需要在项目文件中加上 PublishAottrue/PublishAot 选项。
然后使用 Visual Studio 发布项目。
发布项目的配置文件设置,需要按照下图进行配置。
AOT 跟生成单个文件两个选项不能同时使用,因为 AOT 本身就是单个文件。
配置完成后,点击发布,然后打开 Release 目录,会看到如图所示的文件。
.exe 是独立的可执行文件,不需要再依赖 .NET Runtime 环境,这个程序可以放到其他没有安装 .NET 环境的机器中运行。
然后删除以下三个文件:
CsharpAot.exp
CsharpAot.lib
CsharpAot.pdb
光用 .exe 即可运行,其他是调试符号等文件,不是必需的。
剩下 CsharpAot.exe 文件后,启动这个程序:
官方网站的更多说明
▌C# 调用库函数
这一部分的代码示例,是从一个开源项目中抽取出来的,这个项目封装了一些获取系统资源的接口,以及快速接入 Prometheus 监控。大家可以访问下面的链接了解一下这个项目。
因为后续代码需要,所以现在请开启 “允许不安全代码”。
本小节的示例是通过使用 kernel32.dll 去调用 Windows 的内核 API(Win32 API),调用 GlobalMemoryStatusEx 函数检索有关系统当前使用物理内存和虚拟内存的信息。
使用到的 Win32 函数可参考下面链接。
关于 .NET 调用动态链接库的方式,在 .NET 7 之前,通过这样调用:
[DllImport("Kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern Boolean GlobalMemoryStatusEx(ref MemoryStatusExE lpBuffer);
在 .NET 7 中,出现了新的操作方式 [LibraryImport]。
文档是这样介绍的:
Indicates that a source generator should create a function for marshalling arguments instead of relying on the runtime to generate an equivalent marshalling function at run time.
指示源生成器应创建用于编组参数的函数,而不是依赖运行库在运行时生成等效的编组函数。
简单来说,就是我们要使用 AOT 写代码,然后代码中引用到别的动态链接库时,需要使用 [LibraryImport] 引入这些函数。
我没有在 AOT 下测试过 [DllImport],大家感兴趣可以试试。
新建两个结构体 MEMORYSTATUS.cs、MemoryStatusExE.cs 。
MEMORYSTATUS.cs:
public struct MEMORYSTATUS{internal UInt32 dwLength;internal UInt32 dwMemoryLoad;internal UInt32 dwTotalPhys;internal UInt32 dwAvailPhys;internal UInt32 dwTotalPageFile;internal UInt32 dwAvailPageFile;internal UInt32 dwTotalVirtual;internal UInt32 dwAvailVirtual;}
MemoryStatusExE.cs:
public struct MemoryStatusExE{/// summary/// 结构的大小,以字节为单位,必须在调用 GlobalMemoryStatusEx 之前设置此成员,可以用 Init 方法提前处理/// /summary/// remarks应当使用本对象提供的 Init ,而不是使用构造函数!/remarksinternal UInt32 dwLength;
/// summary/// 一个介于 0 和 100 之间的数字,用于指定正在使用的物理内存的大致百分比(0 表示没有内存使用,100 表示内存已满)。/// /summaryinternal UInt32 dwMemoryLoad;
/// summary/// 实际物理内存量,以字节为单位/// /summaryinternal UInt64 ullTotalPhys;
/// summary/// 当前可用的物理内存量,以字节为单位。这是可以立即重用而无需先将其内容写入磁盘的物理内存量。它是备用列表、空闲列表和零列表的大小之和/// /summaryinternal UInt64 ullAvailPhys;
/// summary/// 系统或当前进程的当前已提交内存限制,以字节为单位,以较小者为准。要获得系统范围的承诺内存限制,请调用GetPerformanceInfo/// /summaryinternal UInt64 ullTotalPageFile;
/// summary/// 当前进程可以提交的最大内存量,以字节为单位。该值等于或小于系统范围的可用提交值。要计算整个系统的可承诺值,调用GetPerformanceInfo核减价值CommitTotal从价值CommitLimit/// /summary
internal UInt64 ullAvailPageFile;
/// summary/// 调用进程的虚拟地址空间的用户模式部分的大小,以字节为单位。该值取决于进程类型、处理器类型和操作系统的配置。例如,对于 x86 处理器上的大多数 32 位进程,此值约为 2 GB,对于在启用4 GB 调整的系统上运行的具有大地址感知能力的 32 位进程约为 3 GB 。/// /summary
internal UInt64 ullTotalVirtual;
/// summary/// 当前在调用进程的虚拟地址空间的用户模式部分中未保留和未提交的内存量,以字节为单位/// /summaryinternal UInt64 ullAvailVirtual;
/// summary/// 预订的。该值始终为 0/// /summaryinternal UInt64 ullAvailExtendedVirtual;
internal void Refresh{dwLength = checked((UInt32)Marshal.SizeOf(typeof(MemoryStatusExE)));}}
定义引用库函数的入口:
public static partial class Native{
/// summary/// 检索有关系统当前使用物理和虚拟内存的信息/// /summary/// param name="lpBuffer"/param/// returns/returns[LibraryImport("Kernel32.dll", SetLastError = true)][return: MarshalAs(UnmanagedType.Bool)]internal static partial Boolean GlobalMemoryStatusEx(ref MemoryStatusExE lpBuffer);}
然后调用 Kernel32.dll 中的函数:
public class Program{static void Main{var result = GetValue;Console.WriteLine($"当前实际可用内存量:{result.ullAvailPhys / 1000 / 1000}MB");Console.ReadKey;}
/// exception cref="Win32Exception"/exceptionpublic static MemoryStatusExE GetValue{var memoryStatusEx = new MemoryStatusExE;// 重新初始化结构的大小memoryStatusEx.Refresh;// 刷新值if (!Native.GlobalMemoryStatusEx(ref memoryStatusEx)) throw new Win32Exception("无法获得内存信息");return memoryStatusEx;}}
使用 AOT 发布项目,执行 CsharpAot.exe 文件。
代码示例
Win32 函数
▌减少体积
在前面两个例子中可以看到 CsharpAot.exe 文件大约在 3MB 左右,但是这个文件还是太大了,那么我们如何进一步减少 AOT 文件的大小呢?
大家可以访问下面链接了解如何裁剪程序。需要注意的是,裁剪是没有那么简单的,里面配置繁多,有一些选项不能同时使用,每个选项又能带来什么样的效果,这些选项可能会让开发者用得很迷茫。
经过我大量 的测试,选用了以下一些配置,能够达到很好的裁剪效果,供大家测试。
首先,引入一个库:
ItemGroupPackageReference Include="Microsoft.DotNet.ILCompiler" Version="7.0.0-*" //ItemGroup
接着,在项目文件中加入以下选项:
!--AOT 相关--PublishAottrue/PublishAot TrimModefull/TrimModeRunAOTCompilationTrue/RunAOTCompilationPublishTrimmedtrue/PublishTrimmedTrimmerRemoveSymbolstrue/TrimmerRemoveSymbolsPublishReadyToRunEmitSymbolsfalse/PublishReadyToRunEmitSymbolsDebuggerSupportfalse/DebuggerSupportEnableUnsafeUTF7Encodingtrue/EnableUnsafeUTF7EncodingInvariantGlobalizationtrue/InvariantGlobalization
最后,发布项目。
吃惊!生成的可执行文件只有 1MB 了,而且还可以正常执行。
虽然现在看起来 AOT 的文件很小了,但是如果使用到 文件会大得惊人。
所以,如果项目中使用到其他 nuget 包的时候,别想着生成的 AOT 能小多少!
如何裁剪程序
▌C# 导出函数
在 C 语言中,导出一个函数的格式可以这样:
// MyCFuncs.h#ifdef __cplusplusextern "C" { // only need to export C interface if// used by C++ source code#endif
__declspec( dllimport ) void MyCFunc;__declspec( dllimport ) void AnotherCFunc;
#ifdef __cplusplus}#endif
当代码编译之后,我们就可以通过引用生成的库文件,调用 MyCFunc、AnotherCFunc 两个方法。
如果不导出的话,别的程序是无法调用库文件里面的函数。
因为 .NET 7 的 AOT 做了很多改进,因此,.NET 程序也可以导出函数了。
新建一个项目,名字就叫 CsharpExport 吧,我们接下来就在这里项目中编写我们的动态链接库。
添加一个 CsharpExport.cs 文件,内容如下:
using System.Runtime.InteropServices;
namespace CsharpExport{public class Export{[UnmanagedCallersOnly(EntryPoint = "Add")]public static int Add(int a, int b){return a + b;}}}
然后在 .csproj 文件中,加上 PublishAot 选项。
然后通过以下命令发布项目,生成链接库:
dotnet publish -p:NativeLib=Shared -r win-x64 -c Release
看起来还是比较大,为了继续裁剪体积,我们可以在 CsharpExport.csproj 中加入以下配置,以便生成更小的可执行文件。
!--AOT 相关--PublishAottrue/PublishAotTrimModefull/TrimModeRunAOTCompilationTrue/RunAOTCompilationPublishTrimmedtrue/PublishTrimmedTrimmerRemoveSymbolstrue/TrimmerRemoveSymbolsPublishReadyToRunEmitSymbolsfalse/PublishReadyToRunEmitSymbolsDebuggerSupportfalse/DebuggerSupportEnableUnsafeUTF7Encodingtrue/EnableUnsafeUTF7EncodingInvariantGlobalizationtrue/InvariantGlobalization
更多资料
▌C# 调用 C# 生成的 AOT
在本小节中,将使用 CsharpAot 项目调用 CsharpExport 生成的动态链接库。
把 CsharpExport.dll 复制到 CsharpAot 项目中,并配置始终复制。
在 CsharpAot 的 Native 中加上:
[LibraryImport("CsharpExport.dll", SetLastError = true)][return: MarshalAs(UnmanagedType.I4)]internal static partial Int32 Add(Int32 a, Int32 b);
然后在代码中使用:
static void Main{var result = Native.Add(1, 2);Console.WriteLine($"1 + 2 = {result}");Console.ReadKey;}
在 Visual Studio 里启动 Debug 调试:
可以看到,是正常运行的。
接着,将 CsharpAot 项目发布为 AOT 后,再次执行:
可以看到,.NET AOT 调用 .NET AOT 的代码是没有问题的。
Golang 部分
Go 生成 Windows 动态链接库,需要安装 GCC,通过 GCC 编译代码生成对应平台的文件。
▌安装 GCC
需要安装 GCC 10.3,如果 GCC 版本太新,会导致编译 Go 代码失败。
打开 tdm-gcc 官网,通过此工具安装 GCC。
下载后,根据提示安装。
然后添加环境变量:
D:\TDM-GCC-64\bin
运行 gcc -v,检查是否安装成功,以及版本是否正确。
安装 GCC /
▌Golang 导出函数
本节的知识点是 cgo。
新建一个 Go 项目:
新建一个 main.go 文件,文件内容如下:
package main
import ("fmt")import "C"
//export Startfunc Start(arg string) {fmt.Println(arg)}
// 没用处func main {}
在 Golang 中,要导出此文件中的函数,需要加上 import "C",并且 import "C" 需要使用独立一行放置。
//export {函数名称} 表示要导出的函数,注意,// 和 export 之间没有空格。
将 main.go 编译为动态链接库:
gobuild -ldflags "-s -w"-o main.dll -buildmode=c-shared main. go
不得不说,Go 编译出的文件,确实比 .NET AOT 小一些。
前面,演示了 .NET AOT 调用 .NET AOT ,那么,Go 调用 Go 是否可以呢?
答案是:不可以。
因为 Go 编译出来的动态链接库本身带有 runtime,Go 调用 main.dll 时 ,会出现异常。
具体情况可以通过 Go 官方仓库的 Issue 了解。
这个时候,.NET 加 1 分。
虽然 Go 不能调用 Go 的,但是 Go 可以调用 .NET 的。在文章后面会介绍。
虽然说 Go 不能调用自己,这里还是继续补全代码,进一步演示一下。
Go 通过动态链接库调用函数的示例:
func main {maindll := syscall.NewLazyDLL("main.dll")start := maindll.NewProc("Start")
var v string = "测试代码"var ptr uintptr = uintptr(unsafe.Pointer(v))start.Call(ptr)}
代码执行后会报错:
cgo:
/
Go 官方仓库的 Issue
.NET C# 和 Golang 互调
▌C# 调用 Golang
将 main.dll 文件复制放到 CsharpAot 项目中,设置始终复制。
然后在 Native 中添加以下代码:
[LibraryImport("main.dll", SetLastError = true)]internal static partial void Start(IntPtr arg);
调用 main.dll 中的函数:
static void Main{string arg = "让 Go 跑起来";// 将申请非托管内存string转换为指针IntPtr concatPointer = Marshal.StringToHGlobalAnsi(arg);Native.Start(concatPointer);Console.ReadKey;}
在 .NET 中 string 是引用类型,而在 Go 语言中 string 是值类型,这个代码执行后,会出现什么结果呢?
执行结果是输出一个长数字。
我不太了解 Golang 内部的原理,不确定这个数字是不是 .NET string 传递了指针地址,然后 Go 把指针地址当字符串打印出来了。
因为在 C、Go、.NET 等语言中,关于 char、string 的内部处理方式不一样,因此这里的传递方式导致了跟我们的预期结果不一样。
接着,我们将 main.go 文件的 Start 函数改成:
//export Startfunc Start(a,b int) int{return a+b}
然后执行命令重新生成动态链接库:
go build -ldflags "-s -w" -o main.dll -buildmode=c-shared main.go
将 main.dll 文件复制到 CsharpAot 项目中,将 Start 函数引用改成:
[LibraryImport("main.dll", SetLastError = true)][return: MarshalAs(UnmanagedType.I4)]internal static partial Int32 Start(int a, int b);
执行代码调用 Start 函数:
static void Main{var result = Native.Start(1, 2);Console.WriteLine($"1 + 2 = {result}");Console.ReadKey;}
▌Golang 调用 C#
将 CsharpExport.dll 文件复制放到 Go 项目中。
将 main 的代码改成:
func main {maindll := syscall.NewLazyDLL("CsharpExport.dll")start := maindll.NewProc("Add")
var a uintptr = uintptr(1)var b uintptr = uintptr(2)result, _, _ := start.Call(a, b)
fmt.Println(result)}
将参数改成 1、9,再次执行:
在本文中,演示了 .NET AOT,虽然简单的示例看起来是正常的,体积也足够小,但是如果加入了实际业务中需要的代码,最终生成的 AOT 文件也是很大的。
例如,项目中使用 文件很大。
在 .NET 的库中,很多时候设计了大量的重载,同一个代码有好几个变种方式,以及函数的调用链太长,这样会让生成的 AOT 文件变得比较臃肿。
目前来说, ASP.NET Core 还不支持 AOT,这也是一个问题。
在 C# 部分,演示了如何使用 C# 调用系统接口。
这个库封装好了系统接口,开发者不需要自己撸一遍,通过这个库可以很轻松地调用系统接口,例如我最近在写 MAUI 项目,通过 Win32 API 控制桌面窗口,里面就使用到 pinvoke 简化了大量代码。
如何使用 C# 调用系统接口
/
微软最有价值专家(MVP)
微软最有价值专家是微软公司授予第三方技术专业人士的一个全球奖项。29年来,世界各地的技术社区领导者,因其在线上和线下的技术社区中分享专业知识和经验而获得此奖项。
MVP是经过严格挑选的专家团队,他们代表着技术最精湛且最具智慧的人,是对社区投入极大的热情并乐于助人的专家。MVP致力于通过演讲、论坛问答、创建网站、撰写博客、分享视频、开源项目、组织会议等方式来帮助他人,并最大程度地帮助微软技术社区用户使用 Microsoft 技术。
更多详情请登录官方网站:
谢谢你读完了本文!欢迎在评论区留言分享你的想法,并且转发到朋友圈。
关注微软开发者MSDN