跨語言呼叫C#程式碼的新方式-DllExport

2022-09-16 12:01:34

簡介

上一篇文章使用C#編寫一個.NET分析器文章釋出以後,很多小夥伴都對最新的NativeAOT函數匯出比較感興趣,今天故寫一篇短文來介紹一下如何使用它。

在以前,如果有其他語言需要呼叫C#編寫的庫,那基本上只有通過各種RPC的方式(HTTP、GRPC)或者引入一層C++代理層的方式來呼叫。

自從微軟開始積極開發和研究Native AOT以後,我們有了新的方式。那就是直接使用Native AOT函數匯出的方式,其它語言(C++、Go、Java各種支援呼叫匯出函數的語言)就可以直接呼叫C#匯出的函數來使用C#庫。

廢話不多說,讓我們開始嘗試。

開始嘗試

我們先來一個簡單的嘗試,就是使用C#編寫一個用於對兩個整數求和的Add方法,然後使用C語言呼叫它。

1.首先我們需要建立一個新的類庫專案。這個大家都會了,可以直接使用命令列新建,也可以通過VS等IDE工具新建。

dotnet new classlib -o CSharpDllExport

2.為我們的專案加入Native AOT的支援,根據.NET的版本不同有不同的方式。

  • 如果你是.NET6則需要引入Microsoft.DotNet.ILCompiler這個Nuget包,需要指定為7.0.0-preview.7.22375.6,新版本的話只允許.NET7以上使用。更多詳情請看hez2010的部落格 https://www.cnblogs.com/hez2010/p/dotnet-with-native-aot.html

  • 如果是.NET7那麼只需要在專案屬性中加入<PublishAot>true</PublishAot>即可,筆者直接使用的.NET7,所以如下設定就行。

3.編寫一個靜態方法,並且為它打上UnmanagedCallersOnly特性,告訴編譯器我們需要將它作為函數匯出,指定名稱為Add。

using System.Runtime.InteropServices;

namespace CSharpDllExport
{
    public class DoSomethings
    {
        [UnmanagedCallersOnly(EntryPoint = "Add")]
        public static int Add(int a, int b)
        {
            return a + b;
        }
    }
}

4.使用dotnet publish -p:NativeLib=Shared -r win-x64 -c Release命令釋出共用庫。共用庫的擴充套件名在不同的作業系統上不一樣,如.dll.dylib.so。當然我們也可以釋出靜態庫,只需要修改為-p:NativeLib=Static即可。

5.使用DLL Export Viewer工具開啟生成的.dll檔案,檢視函數匯出是否成功,如下圖所示,我們成功的把ADD方法匯出了,另外那個是預設匯出用於Debugger的方法,我們可以忽略。工具下載連結放在文末。

6.編寫一個C語言專案來測試一下我們的ADD方法是否可用。

#define PathToLibrary "E:\\MyCode\\BlogCodes\\CSharp-Dll-Export\\CSharpDllExport\\CSharpDllExport\\bin\\Release\\net7.0\\win-x64\\publish\\CSharpDllExport.dll"

// 匯入必要的標頭檔案
#include <windows.h>
#include <stdlib.h>
#include <stdio.h>

int callAddFunc(char* path, char* funcName, int a, int b);

int main()
{
    // 檢查檔案是否存在
    if (access(PathToLibrary, 0) == -1)
    {
        puts("沒有在指定的路徑找到庫檔案");
        return 0;
    }

    // 計算兩個值的和
    int sum = callAddFunc(PathToLibrary, "Add", 2, 8);
    printf("兩個值的和是 %d \n", sum);
}

int callAddFunc(char* path, char* funcName, int firstInt, int secondInt)
{
    // 呼叫 C# 共用庫的函數來計算兩個數的和
    HINSTANCE handle = LoadLibraryA(path);

    typedef int(*myFunc)(int, int);
    myFunc MyImport = (myFunc)GetProcAddress(handle, funcName);

    int result = MyImport(firstInt, secondInt);

    return result;
}

7.跑起來看看

這樣我們就完成了一個C#函數匯出的專案,並且通過C語言呼叫了C#匯出的dll。同樣我們可以使用Go的syscall、Java的JNI、Python的ctypes來呼叫我們生成的dll,在這裡就不再演示了。

限制

使用這種方法匯出的函數同樣有一些限制,以下是在決定匯出哪種託管方法時要考慮的一些限制:

  • 匯出的方法必須是靜態方法。
  • 匯出的方法只能接受或返回基元或值型別(即結構體,如果有參照型別,那必須像P/Invoke一樣封送所有參照型別引數)。
  • 無法從常規託管C#程式碼呼叫匯出的方法,必須走Native AOT,否則將引發異常。
  • 匯出的方法不能使用常規的C#例外處理,它們應改為返回錯誤程式碼。

資料傳遞參照型別

如果是參照型別的話注意需要傳遞指標或者序列化以後的結構體資料,比如我們編寫一個方法連線兩個string,那麼C#這邊就應該這樣寫:

[UnmanagedCallersOnly(EntryPoint = "ConcatString")]
public static IntPtr ConcatString(IntPtr first, IntPtr second)
{
    // 從指標轉換為string
    string my1String = Marshal.PtrToStringAnsi(first);
    string my2String = Marshal.PtrToStringAnsi(second);
    // 連線兩個string 
    string concat = my1String + my2String;
    // 將申請非託管記憶體string轉換為指標
    IntPtr concatPointer = Marshal.StringToHGlobalAnsi(concat);
    // 返回指標
    return concatPointer;
}

對應的C程式碼也應該傳遞指標,如下所示:

// 拼接兩個字串
char* result = callConcatStringFunc(PathToLibrary, "ConcatString", ".NET", " yyds");
printf("拼接符串的結果為 %s \n", result);

....

char* callConcatStringFunc(char* path, char* funcName, char* firstString, char* secondString)
{

    HINSTANCE handle = LoadLibraryA(path);
    typedef char* (*myFunc)(char*, char*);

    myFunc MyImport = (myFunc)GetProcAddress(handle, funcName);

    // 傳遞指標並且返回指標
    char* result = MyImport(firstString, secondString);

    return result;
}

執行一下,結果如下所示:

附錄