C#.Net是一门优秀的跨平台编程语言,有着C++级别的运行速度,和大量用以提升开发效率的语法糖,以及极佳的跨平台能力。近期使用C#接入了一个C++库,在此稍作一下总结。

1. 动态库实现方案选择

近期在使用C#实现一些功能时,需要接入C++库。但C++由于众所周知的原因,同名函数可能有多种重载,因此实现的函数声明根据参数和重载等的不同,会被编译器加上不同的符号,导致无法被正确调用。

上述问题有两种解决方法:

  1. 使用extern "C"声明来声明一个纯C类型的函数,这也是后来实际采用的方式;
  2. 仍然使用C++方式声明函数,但是在C#调用时,需要根据实际生成的函数声明来调用。

第二种方式其实属于一种hack,问题就在于,不同编译器实际生成的函数名并不一致,因此可能不同版本编译器、不同平台上,都需要分别解析一次实际符号,并修改C#里的调用源码。这种方式明显不实际且不通用。

而第一种,使用extern "C"的方式声明,则是通用的跨语言交互方案。缺陷有两点:

  1. 缺少了C++的重载;
  2. 只能使用纯C类型(值,数组,结构,指针),不能使用C++类型。

而它的好处也显而易见:

  1. 不同编译器、不同平台下导出的符号表统一(即为函数名);
  2. 上述限制仅针对需要EXPORT的接口,内部仍然可以用C++实现,类、模板等不受影响;

这两点缺陷在可以接受范围内,因此采用这种方式实现跨语言交互。

既然使用了extern "C" ,这个问题的本质其实就变成了“如何使用C实现一个C++的动态库,并允许其它语言调用”。那这其实就已经是一个方案很成熟的领域了,分为这么几步:

  1. 首先用C实现动态库,并导出预期的符号(函数与类型);
  2. 其次,在不同平台上分别编译,得到不同平台上的库类型。
  3. 在调用语言内实现对应的接口、类型,并添加引用;
  4. 在调用语言内使用接口,测试功能是否正常。

2. 使用 C 实现 C++ 动态库

extern "C"方案也会带来另一个问题:如果需要让C#调用C++库,那么必须通过纯C进行一次中转:

  1. C++的模板等必须使用纯C做展开,分别以不同的声明接入;
  2. C++的类型(无论是STL还是自定义class)以及类型上的方法也需要用纯C展开;

比如我们有一个这样的类型:

class Document
{
public:
    Docunemt(std::string title): title_(title), content_() { }
    void AppendLine(int32_t number) { content_.emplace_back(std::to_str(number)); }
    void AppendLine(const char* str, int len) { content_.emplace_back(std::string(str, len)); }
    void AppendLine(const std::string& line) { content_.emplace_back(line); }
    std::string GetContent() const { /* do some string join works */ }
private:
    std::string title_;
    std::vector<string> content_;
}

C语言无法使用std::stringstd::vector<std::string>,也处理不了三个不同的AppendLine 重载,更不用提(没有放进示例)的模板。

因此,如果我们想要用导出为动态库,需要使用C语言以如下形式声明:

// 首先定义DLLEXPORT宏,指定哪些函数是需要导出的
#ifdef WIN32
#define DLLEXPORT __declspec(dllexport)
#else
#define DLLEXPORT __attribute__((visibility("default"))
#endif

class Document; // 这里是用于C++的前向声明
using i32 = int32_t;

extern "C"
{
struct CppString
{
    char* str = nullptr;
    int32_t len = 0;
}

DLLEXPORT Document* CreateDocument(const char* title_buff, i32 len);
DLLEXPORT void AppendLineInt(Document* doc, i32 number);
DLLEXPORT void AppendLineStr(Document* doc, const char* str, i32 len);
DLLEXPORT void GetContent(Document* doc, CppString* str_result);
DLLEXPORT void FreeCppString(CppString* str_result);
DLLEXPORT void FreeDocument(Document* doc);
} // extern "C"

上面的声明形式其实很简单,无非是将原来一些被C++封装好的对象创建、释放,用C进行手动管理。这对熟悉C++的你来说并不会是难事,仅仅是略微繁琐一些。

值得注意的是:我们使用了CreateDocument,因此也要有对应的FreeDocumentGetContent内会有动态资源分配,因此也要有对应的FreeCppString

不过不用过于担心,这里只要实现即可,这些资源的RAII仍然可以交给C#来完成

接下来是对以上接口的一些实现,由于不是重点要讲的内容,只提供一些大概思路:

Document* CreateDocument(const char* title_buff, i32 len)
{
    return new Document(std::string(title_buff, len));
}
void AppendLineInt(Document* doc, i32 number)
{
    doc->AppendLine(number);
}
void AppendLineStr(Document* doc, const char* str, i32 len)
{
    doc->AppendLine(std::string(str, len));
}
void GetContent(Document* doc, CppString* str_result)
{
    auto result = doc->GetContent();
    str_result.str = new char[result.size()];
    str_result.len = result.size();
    ::memcpy(str_result.str, result.data(), result.size());
}
void FreeCppString(CppString* str_result)
{
    delete[] str_result->str;
    str_result->str = nullptr;
    str_result->len = 0;
}
void FreeDocument(Document* doc)
{
    delete doc;
}

接下来只要在不同平台上分别编译出来对应的DLL即可:在本文的例子中,Windows上为doc_api.dll,Linux上为libdoc_api.so,而macOS上为libdoc_api.dylib。这一步就不用做过多展开了。

3. 在 C#.Net 里引入动态库

C#.Net本地互操作(Native Interoperability) 有两种方式:

  1. (仅Windows) COM交互
  2. (跨平台) Platform Invoke, P/Invoke

既然我们期望是能跨平台,那么自然选择P/Invoke。P/Invoke引用动态库的方式非常简单:

[DllImport("nativedep")]
static extern int ExportedFunction();

这样就声明好了一个引用函数,动态库名为nativedep,函数名为ExportedFunction。
Windows上,搜索库的顺序是:

  1. nativedep
  2. nativedep.dll

Linux / macOS上,搜索库的顺序是:

  1. nativedep.so / nativedep.dylib
  2. libnativedep.so/libnativedep.dylib
  3. nativedep
  4. libnativedep

在C#中,同样有structclass类型。但它的定义与C++里面并不相同。
粗略来讲,本质上C#的struct与C++的struct/class相同,都是值类型,而C#的class是引用类型,更接近于C++的shared_ptr概念,但是生命周期由GC管理,区别于C++的作用域,并且由GC来解决循环引用的问题。

类型传参生命周期内存布局DLL传参类型
C++ struct值类型拷贝作用域/new+deleteSequential
C++ class值类型拷贝作用域/new+deleteSequential
C# struct值类型拷贝作用域SequentialC++ struct
C# class引用类型引用GC管理,自动回收AutoC++ pointer

C#的struct类型相比class还有一个问题是:没有构造函数和析构函数的概念。因此,如果希望使用RAII释放CppString的资源,那么还是需要使用class

C#和C语言交互过程中,常用类型转换(Marshal)的关系如下,引用自官方文档[1]:

C#C
byteuint8\_t
intint32\_t
uintuint32\_t
longint64\_t
ulonguint64\_t
char (CharSet.ANSI)char
char (CharSet.Unicode)char16\_t
class [LayoutKind.Sequential]class的指针
arrayarray起始指针

首先需要实现一个自定义struct:CppString如下:

using System;
using System.Runtime.InteropServices;

/// 两种实现方式:
/// 1. 析构释放,由GC管理生命周期;
/// 2. IDisposable,可以写成 using var cppStr = new CppString() 的形式,超出作用域后自动释放
/// 作为示例,这里都实现了一下
[StructLayout(LayoutKind.Sequential)]
internal class CppString: IDisposable
{
    public IntPtr str;
    public int len;
    public void Dispose()
    {
        if (str == IntPtr.Zero || len == 0) return;
        FreeCppString(this);
    }
    ~CppString()
    {
        if (str == IntPtr.Zero || len == 0) return;
        FreeCppString(this);
    }
    public override string ToString()
    {
        if (str == IntPtr.Zero || len == 0) { return string.Empty; }
        var bytes = new byte[len];
        Marshal.Copy(str, buff, 0, len);
        return Encoding.UTF8.GetString(bytes);
    }
}

需要用到的数据类型实现好了,那么接下来就是引入具体的接口。这里将接口统一封装在internal class DocApi内,使用internal声明表示这仅仅是在本namespace内生效,不希望外部使用这个class。当然,也可以放在后文的class Document内部。

internal class DocApi
{
    [DllImport("doc_api", CharSet = CharSet.Ansi)]
    public static extert IntPtr CreateDocument(
        [MarshalAs(UnmanagedType.LPArray)] byte[] title,
        int len);

    [DllImport("doc_api", CharSet = CharSet.Ansi)]
    public static extert void AppendLineInt(IntPtr doc, int number);

    [DllImport("doc_api", CharSet = CharSet.Ansi)]
    public static extert void AppendLineStr(
        IntPtr doc, 
        [MarshalAs(UnmanagedType.LPArray)] byte[] str,
        int len);

    [DllImport("doc_api", CharSet = CharSet.Ansi)]
    public static extert void GetContent(CppString cppStr);
    [DllImport("doc_api", CharSet = CharSet.Ansi)]
    public static extert void FreeCppString(CppString cppStr);
    [DllImport("doc_api", CharSet = CharSet.Ansi)]
    public static extert void FreeDocument(IntPtr doc);
}

最后就是实现对外开放的C#对象Document

public class Document
{
    private IntPtr _ptr;
    public Document(string title)
    {
        var bytes = Encoding.UTF8.GetBytes(title);
        _ptr = DocApi.CreateDocument(bytes, bytes.Length);
    }
    ~Document()
    {
        DpcApi.FreeDocument(_ptr);
    }
    void AppendLine(int number)
    {
        DocApi.AppendLineInt(_ptr, number);
    void AppendLine(string str)
    {
        var bytes = Encoding.UTF8.GetBytes(str);
        DocApi.AppendLineStr(_ptr, bytes, bytes.Length);
    }
    public string Content // Readonly
    {
        get
        {
            using var cppStr = new CppString();
            DocApi.GetContent(_ptr, cppStr);
            return cppStr.ToString();
        }
    }
}

4. 使用 C#.Net 调用动态库

调用动态库的方式很简单,只要将动态库(*.dll, *.so, *.dylib放在可执行文件同级目录即可)。

比较麻烦的是,我更希望将动态库放在同一个位置,无论是在IDE内运行Debug/Release调试运行,还是运行Testing,或是Publish发布时,都可以自动将该动态库拷贝过去。这样就需要对C#的工程文件做出一些修改了。

本示例中,目录结构如下,动态库文件位于${Project}/doc_api/目录下

tree -L 2
.
├── doc_api
│   ├── doc_api.dll
│   ├── libdoc_api.dylib
│   └── libdoc_api.so
├── MyDocument
│   ├── DocApi.cs
│   ├── Document.cs
│   └── MyDocument.csproj
├── MyDocumentTest
│   ├── MyDocumentTest.cs
│   └── MyDocumentTestTest.csproj
├── MyProgram
│   ├── MyProgram.csproj
│   └── Program.cs
├── MyProject.sln
└── publish.bat

打开MyDocument工程的MyDocument.csproj文件,内容如下:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

修改为如下内容,即可在每次build时自动将三个文件拷贝到可执行文件所在的目录,在Publish发布时同样会一起携带。(这里没有做具体系统的区分,因此会带上全部的动态库)。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

    <ItemGroup>
      <ContentWithTargetPath Include="..\doc_api\doc_api.dll">
        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
        <TargetPath>doc_api.dll</TargetPath>
      </ContentWithTargetPath>
    </ItemGroup>
    <ItemGroup>
      <ContentWithTargetPath Include="..\doc_api\libdoc_api.so">
        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
        <TargetPath>libdoc_api.so</TargetPath>
      </ContentWithTargetPath>
    <ItemGroup>
      <ContentWithTargetPath Include="..\doc_api\libdoc_api.dylib">
        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
        <TargetPath>libdoc_api.dylib</TargetPath>
      </ContentWithTargetPath>
    </ItemGroup>

</Project>

5. C#.Net 项目发布

其实这一节本不必要,但多说一些也不影响。

dotnet 可以交叉编译,可以编译出仅有一个可执行文件的程序(当然,本例中由于使用了动态库,除了可执行文件还得带一个动态库文件),并且生成的可执行程序无系统依赖,不需要执行平台安装dotnet运行时。

以下是我常用的发布脚本,生成三个平台上的可执行二进制,供读者参考。

cd /d %~dp0
rd /s /q bin
rd /s /q publish

dotnet publish -c Release -p:PublishTrimmed=true -r win-x64 -p:PublishSingleFile=true --self-contained true MyProgram\MyProgram.csproj
dotnet publish -c Release -p:PublishTrimmed=true -r linux-x64 -p:PublishSingleFile=true --self-contained true MyProgram\MyProgram.csproj
dotnet publish -c Release -p:PublishTrimmed=true -r osx-x64 -p:PublishSingleFile=true --self-contained true MyProgram\MyProgram.csproj

mkdir publish
mkdir publish\win-x64 publish\linux-x64 publish\osx-x64

echo f|xcopy /s/q/y MyProgram\bin\Release\net6.0\win-x64\publish publish\win-x64
echo f|xcopy /s/q/y MyProgram\bin\Release\net6.0\linux-x64\publish publish\linux-x64
echo f|xcopy /s/q/y MyProgram\bin\Release\net6.0\osx-x64\publish publish\osx-x64

参考文章:

  1. Platform Invoke (P/Invoke)
  2. C++/C# interoperability
最后修改:2023 年 06 月 01 日
如果觉得我的文章对你有用,请随意赞赏