.NET 7 AOT 的使用以及 .NET 與 Go 互相調用

背景

其實,規劃這篇文章有一段時間了,但是比較懶,所以一直拖着沒寫。

最近時總更新太快了,太捲了,所以藉着 .NET 7 正式版發佈,熬夜寫完這篇文章,希望能夠追上時總的一點距離。

本文主要介紹如何在 .NET 和 Go 語言中如何生成系統(Windows)動態鏈接庫,又如何從代碼中引用這些庫中的函數。

在 .NET 部分,介紹如何使用 AOT、減少二進制文件大小、使用最新的 [LibraryImport] 導入庫函數;

在 Go 語言部分,介紹如何使用 GCC 編譯 Go 代碼、如何通過 syscall 導入庫函數。

在文章中會演示 .NET 和 Go 相互調用各自生成的動態鏈接庫,以及對比兩者之間的差異。

本文文章內容以及源代碼,可以 https://github.com/whuanle/csharp_aot_golang 中找到,如果本文可以給你帶來幫助,可以到 Github 點個星星嘛。

C# 部分

環境要求

SDK:.NET 7 SDKDesktop development with C++ workload

IDE:Visual Studio 2022

Desktop development with C++ workload 是一個工具集,裏面包含 C++ 開發工具,需要在 Visual Studio Installer 中安裝,如下圖紅框中所示。

image-20221109182246338

創建一個控制檯項目

首先創建一個 .NET 7 控制檯項目,名稱爲 CsharpAot

打開項目之後,基本代碼如圖所示:

image-20221109184702539

我們使用下面的代碼做測試:

public class Program
{
    static void Main()
    {
        Console.WriteLine("C# Aot!");
        Console.ReadKey();
    }
}

體驗 AOT 編譯

這一步,可以參考官方網站的更多說明:

https://learn.microsoft.com/zh-cn/dotnet/core/deploying/native-aot/

爲了能夠讓項目發佈時使用 AOT 模式,需要在項目文件中加上 <PublishAot>true</PublishAot> 選項。

image-20221109184850615

然後使用 Visual Studio 發佈項目。

發佈項目的配置文件設置,需要按照下圖進行配置。

image-20221109201612226

AOT 跟 生成單個文件 兩個選項不能同時使用,因爲 AOT 本身就是單個文件。

配置完成後,點擊 發佈,然後打開 Release 目錄,會看到如圖所示的文件。

image-20221109194100927

.exe 是獨立的可執行文件,不需要再依賴 .NET Runtime 環境,這個程序可以放到其他沒有安裝 .NET 環境的機器中運行。

然後刪除以下三個文件:

    CsharpAot.exp
    CsharpAot.lib
    CsharpAot.pdb

光用 .exe 即可運行,其他是調試符號等文件,不是必需的。

剩下 CsharpAot.exe 文件後,啓動這個程序:

image-20221109194207563

C# 調用庫函數

這一部分的代碼示例,是從筆者的一個開源項目中抽取出來的,這個項目封裝了一些獲取系統資源的接口,以及快速接入 Prometheus 監控。

不過很久沒有更新了,最近沒啥動力更新,讀者可以點擊這裏瞭解一下這個項目:

https://github.com/whuanle/CZGL.SystemInfo/tree/net6.0/src/CZGL.SystemInfo/Memory

因爲後續代碼需要,所以現在請開啓 “允許不安全代碼”。

本小節的示例是通過使用 kernel32.dll 去調用 Windows 的內核 API(Win32 API),調用 GlobalMemoryStatusEx 函數 檢索有關係統當前使用物理內存和虛擬內存的信息

使用到的 Win32 函數可參考:https://learn.microsoft.com/zh-cn/windows/win32/api/sysinfoapi/nf-sysinfoapi-globalmemorystatusex

關於 .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.csMemoryStatusExE.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 ,而不是使用構造函數!</remarks>
    internal UInt32 dwLength;

    /// <summary>
    /// 一個介於 0 和 100 之間的數字,用於指定正在使用的物理內存的大致百分比(0 表示沒有內存使用,100 表示內存已滿)。
    /// </summary>
    internal UInt32 dwMemoryLoad;

    /// <summary>
    /// 實際物理內存量,以字節爲單位
    /// </summary>
    internal UInt64 ullTotalPhys;

    /// <summary>
    /// 當前可用的物理內存量,以字節爲單位。這是可以立即重用而無需先將其內容寫入磁盤的物理內存量。它是備用列表、空閒列表和零列表的大小之和
    /// </summary>
    internal UInt64 ullAvailPhys;

    /// <summary>
    /// 系統或當前進程的當前已提交內存限制,以字節爲單位,以較小者爲準。要獲得系統範圍的承諾內存限制,請調用GetPerformanceInfo
    /// </summary>
    internal 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>
    /// 當前在調用進程的虛擬地址空間的用戶模式部分中未保留和未提交的內存量,以字節爲單位
    /// </summary>
    internal UInt64 ullAvailVirtual;


    /// <summary>
    /// 預訂的。該值始終爲 0
    /// </summary>
    internal 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"></exception>
    public static MemoryStatusExE GetValue()
    {
        var memoryStatusEx = new MemoryStatusExE();
        // 重新初始化結構的大小
        memoryStatusEx.Refresh();
        // 刷新值
        if (!Native.GlobalMemoryStatusEx(ref memoryStatusEx)) throw new Win32Exception("無法獲得內存信息");
        return memoryStatusEx;
    }
}

使用 AOT 發佈項目,執行 CsharpAot.exe 文件。

image-20221109202709566

減少體積

在前面兩個例子中可以看到 CsharpAot.exe 文件大約在 3MB 左右,但是這個文件還是太大了,那麼我們如何進一步減少 AOT 文件的大小呢?

讀者可以從這裏瞭解如何裁剪程序:https://learn.microsoft.com/zh-cn/dotnet/core/deploying/trimming/trim-self-contained

需要注意的是,裁剪是沒有那麼簡單的,裏面配置繁多,有一些選項不能同時使用,每個選項又能帶來什麼樣的效果,這些選項可能會讓開發者用得很迷茫。

經過筆者的大量測試,筆者選用了以下一些配置,能夠達到很好的裁剪效果,供讀者測試。

首先,引入一個庫:

	<ItemGroup>
		<PackageReference Include="Microsoft.DotNet.ILCompiler" Version="7.0.0-*" />
	</ItemGroup>

接着,在項目文件中加入以下選項:

		<!--AOT 相關-->
		<PublishAot>true</PublishAot>	
		<TrimMode>full</TrimMode>
		<RunAOTCompilation>True</RunAOTCompilation>
		<PublishTrimmed>true</PublishTrimmed>
		<TrimmerRemoveSymbols>true</TrimmerRemoveSymbols>
		<PublishReadyToRunEmitSymbols>false</PublishReadyToRunEmitSymbols>
		<DebuggerSupport>false</DebuggerSupport>
		<EnableUnsafeUTF7Encoding>true</EnableUnsafeUTF7Encoding>
		<InvariantGlobalization>true</InvariantGlobalization>
		<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
		<MetadataUpdaterSupport>true</MetadataUpdaterSupport>
		<UseSystemResourceKeys>true</UseSystemResourceKeys>
		<IlcDisableReflection >true</IlcDisableReflection>

最後,發佈項目。

喫驚!生成的可執行文件只有 1MB 了,而且還可以正常執行。

image-20221109203013246

筆者注:雖然現在看起來 AOT 的文件很小了,但是如果使用到 HttpClientSystem.Text.Json 等庫,哪怕只用到了一兩個函數,最終包含這些庫以及這些庫使用到的依賴,生成的 AOT 文件會大得驚人。

所以,如果項目中使用到其他 nuget 包的時候,別想着生成的 AOT 能小多少!

C# 導出函數

這一步可以從時總的博客中學習更多:https://www.cnblogs.com/InCerry/p/CSharp-Dll-Export.html

PS:時總真的太強了。

image-20221109235629370

在 C 語言中,導出一個函數的格式可以這樣:

// MyCFuncs.h
#ifdef __cplusplus
extern "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

當代碼編譯之後,我們就可以通過引用生成的庫文件,調用 MyCFuncAnotherCFunc 兩個方法。

如果不導出的話,別的程序是無法調用庫文件裏面的函數。

因爲 .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 選項。

image-20221109203907544

然後通過以下命令發佈項目,生成鏈接庫:

 dotnet publish -p:NativeLib=Shared -r win-x64 -c Release

image-20221109204002557

看起來還是比較大,爲了繼續裁剪體積,我們可以在 CsharpExport.csproj 中加入以下配置,以便生成更小的可執行文件。

		<!--AOT 相關-->
		<PublishAot>true</PublishAot>
		<TrimMode>full</TrimMode>
		<RunAOTCompilation>True</RunAOTCompilation>
		<PublishTrimmed>true</PublishTrimmed>
		<TrimmerRemoveSymbols>true</TrimmerRemoveSymbols>
		<PublishReadyToRunEmitSymbols>false</PublishReadyToRunEmitSymbols>
		<DebuggerSupport>false</DebuggerSupport>
		<EnableUnsafeUTF7Encoding>true</EnableUnsafeUTF7Encoding>
		<InvariantGlobalization>true</InvariantGlobalization>
		<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
		<MetadataUpdaterSupport>true</MetadataUpdaterSupport>
		<UseSystemResourceKeys>true</UseSystemResourceKeys>
		<IlcDisableReflection >true</IlcDisableReflection>

image-20221109204055118

C# 調用 C# 生成的 AOT

在本小節中,將使用 CsharpAot 項目調用 CsharpExport 生成的動態鏈接庫。

CsharpExport.dll 複製到 CsharpAot 項目中,並配置 始終複製

image-20221109204210638

CsharpAotNative 中加上:

    [LibraryImport("CsharpExport.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.I4)]
    internal static partial Int32 Add(Int32 a, Int32 b);

image-20221109204443706

然後在代碼中使用:

    static void Main()
    {
        var result = Native.Add(1, 2);
        Console.WriteLine($"1 + 2 = {result}");
        Console.ReadKey();
    }

在 Visual Studio 裏啓動 Debug 調試:

image-20221109205726963

可以看到,是正常運行的。

接着,將 CsharpAot 項目發佈爲 AOT 後,再次執行:

image-20221109204645302

可以看到,.NET AOT 調用 .NET AOT 的代碼是沒有問題的。

Golang 部分

Go 生成 Windows 動態鏈接庫,需要安裝 GCC,通過 GCC 編譯代碼生成對應平臺的文件。

安裝 GCC

需要安裝 GCC 10.3,如果 GCC 版本太新,會導致編譯 Go 代碼失敗。

打開 tdm-gcc 官網,通過此工具安裝 GCC,官網地址:

https://jmeubank.github.io/tdm-gcc/download/

image-20221109183853737

下載後,根據提示安裝。

image-20221109183510422

然後添加環境變量:

D:\TDM-GCC-64\bin

image-20221109183553817

運行 gcc -v,檢查是否安裝成功,以及版本是否正確。

image-20221109183708204

Golang 導出函數

本節的知識點是 cgo,讀者可以從這裏瞭解更多:

https://www.programmerall.com/article/11511112290/

新建一個 Go 項目:

image-20221109190013070

新建一個 main.go 文件,文件內容如下:

package main

import (
	"fmt"
)
import "C"

//export Start
func Start(arg string) {
	fmt.Println(arg)
}

// 沒用處
func main() {
}

在 Golang 中,要導出此文件中的函數,需要加上 import "C",並且 import "C" 需要使用獨立一行放置。

//export {函數名稱} 表示要導出的函數,注意,//export 之間 沒有空格。

main.go 編譯爲動態鏈接庫:

go build -ldflags "-s -w" -o main.dll -buildmode=c-shared main.go

image-20221109230719499

不得不說,Go 編譯出的文件,確實比 .NET AOT 小一些。

前面,筆者演示了 .NET AOT 調用 .NET AOT ,那麼, Go 調用 Go 是否可以呢?

答案是:不可以。

因爲 Go 編譯出來的 動態鏈接庫本身帶有 runtime,Go 調用 main.dll 時 ,會出現異常。

具有情況可以通過 Go 官方倉庫的 Issue 瞭解:https://github.com/golang/go/issues/22192

這個時候,.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)
}

代碼執行後會報錯:

image-20221109204808474

.NET C# 和 Golang 互調

C# 調用 Golang

main.dll 文件複製放到 CsharpAot 項目中,設置 始終複製

image-20221109204850583

然後在 Native 中添加以下代碼:

    [LibraryImport("main.dll", SetLastError = true)]
    internal static partial void Start(IntPtr arg);

image-20221109205246781

調用 main.dll 中的函數:

    static void Main()
    {
        string arg = "讓 Go 跑起來";
        // 將申請非託管內存string轉換爲指針
        IntPtr concatPointer = Marshal.StringToHGlobalAnsi(arg);
        Native.Start(concatPointer);
        Console.ReadKey();
    }

在 .NET 中 string 是引用類型,而在 Go 語言中 string 是值類型,這個代碼執行後,會出現什麼結果呢?

image-20221109205306709

執行結果是輸出一個長數字。

筆者不太瞭解 Golang 內部的原理,不確定這個數字是不是 .NET string 傳遞了指針地址,然後 Go 把指針地址當字符串打印出來了。

因爲在 C、Go、.NET 等語言中,關於 char、string 的內部處理方式不一樣,因此這裏的傳遞方式導致了跟我們的預期結果不一樣。

接着,我們將 main.go 文件的 Start 函數改成:

//export Start
func 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);

image-20221109205838696

執行代碼調用 Start 函數:

    static void Main()
    {
        var result = Native.Start(1, 2);
        Console.WriteLine($"1 + 2 = {result}");
        Console.ReadKey();
    }

image-20221109205746457

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

image-20221109212938467

將參數改成 19,再次執行:

image-20221109212956228

其他

在本文中,筆者演示了 .NET AOT,雖然簡單的示例看起來是正常的,體積也足夠小,但是如果加入了實際業務中需要的代碼,最終生成的 AOT 文件也是很大的。

例如,項目中使用 HttpClient 這個庫,會發現裏面加入了大量的依賴文件,導致生成的 AOT 文件很大。

在 .NET 的庫中,很多時候設計了大量的重載,同一個代碼有好幾個變種方式,以及函數的調用鏈太長,這樣會讓生成的 AOT 文件變得比較臃腫。

目前來說, ASP.NET Core 還不支持 AOT,這也是一個問題。

在 C# 部分,演示瞭如何使用 C# 調用系統接口,這裏讀者可以瞭解一下 pinvokehttp://pinvoke.net/

這個庫封裝好了系統接口,開發者不需要自己擼一遍,通過這個庫可以很輕鬆地調用系統接口,例如筆者最近在寫 MAUI 項目,通過 Win32 API 控制桌面窗口,裏面就使用到 pinvoke 簡化了大量代碼。

本文是筆者熬夜寫的,比較趕,限於水平,文中可能會有錯誤的地方,望大佬不吝指教。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章