跨语言调用C#代码的新方式-DllExport


简介

上一篇文章使用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的博客

  • 如果是.NET7那么只需要在项目属性中加入true即可,笔者直接使用的.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 
#include 
#include 

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

运行一下,结果如下所示:

附录

  • 本文代码链接:https://github.com/InCerryGit/BlogCodes/tree/main/CSharp-Dll-Export
  • DLL Export Viewer下载链接:https://www.nirsoft.net/utils/dllexp-x64.zip
  • NativeAOT文档:https://github.com/dotnet/runtime/tree/main/src/coreclr/nativeaot/docs