跨語言調用C#代碼的新方式-DllExport

語言: CN / TW / HK

簡介

上一篇文章使用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的博客 http://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;
}

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

附錄