C#.Net是一门优秀的跨平台编程语言,有着C++级别的运行速度,和大量用以提升开发效率的语法糖,以及极佳的跨平台能力。近期使用C#接入了一个C++库,在此稍作一下总结。
1. 动态库实现方案选择
近期在使用C#实现一些功能时,需要接入C++库。但C++由于众所周知的原因,同名函数可能有多种重载,因此实现的函数声明根据参数和重载等的不同,会被编译器加上不同的符号,导致无法被正确调用。
上述问题有两种解决方法:
- 使用
extern "C"
声明来声明一个纯C类型的函数,这也是后来实际采用的方式; - 仍然使用C++方式声明函数,但是在C#调用时,需要根据实际生成的函数声明来调用。
第二种方式其实属于一种hack,问题就在于,不同编译器实际生成的函数名并不一致,因此可能不同版本编译器、不同平台上,都需要分别解析一次实际符号,并修改C#里的调用源码。这种方式明显不实际且不通用。
而第一种,使用extern "C"
的方式声明,则是通用的跨语言交互方案。缺陷有两点:
- 缺少了C++的重载;
- 只能使用纯C类型(值,数组,结构,指针),不能使用C++类型。
而它的好处也显而易见:
- 不同编译器、不同平台下导出的符号表统一(即为函数名);
- 上述限制仅针对需要
EXPORT
的接口,内部仍然可以用C++实现,类、模板等不受影响;
这两点缺陷在可以接受范围内,因此采用这种方式实现跨语言交互。
既然使用了extern "C"
,这个问题的本质其实就变成了“如何使用C实现一个C++的动态库,并允许其它语言调用”。那这其实就已经是一个方案很成熟的领域了,分为这么几步:
- 首先用C实现动态库,并导出预期的符号(函数与类型);
- 其次,在不同平台上分别编译,得到不同平台上的库类型。
- 在调用语言内实现对应的接口、类型,并添加引用;
- 在调用语言内使用接口,测试功能是否正常。
2. 使用 C 实现 C++ 动态库
extern "C"
方案也会带来另一个问题:如果需要让C#调用C++库,那么必须通过纯C进行一次中转:
- C++的模板等必须使用纯C做展开,分别以不同的声明接入;
- 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::string
,std::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
,因此也要有对应的FreeDocument
;GetContent
内会有动态资源分配,因此也要有对应的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) 有两种方式:
- (仅Windows) COM交互
- (跨平台) Platform Invoke, P/Invoke
既然我们期望是能跨平台,那么自然选择P/Invoke。P/Invoke引用动态库的方式非常简单:
[DllImport("nativedep")]
static extern int ExportedFunction();
这样就声明好了一个引用函数,动态库名为nativedep
,函数名为ExportedFunction。
Windows上,搜索库的顺序是:
nativedep
nativedep.dll
Linux / macOS上,搜索库的顺序是:
nativedep.so
/nativedep.dylib
libnativedep.so
/libnativedep.dylib
nativedep
libnativedep
在C#中,同样有struct
与class
类型。但它的定义与C++里面并不相同。
粗略来讲,本质上C#的struct
与C++的struct
/class
相同,都是值类型,而C#的class
是引用类型,更接近于C++的shared_ptr
概念,但是生命周期由GC管理,区别于C++的作用域,并且由GC来解决循环引用的问题。
类型 | 传参 | 生命周期 | 内存布局 | DLL传参类型 | |
---|---|---|---|---|---|
C++ struct | 值类型 | 拷贝 | 作用域/new+delete | Sequential | |
C++ class | 值类型 | 拷贝 | 作用域/new+delete | Sequential | |
C# struct | 值类型 | 拷贝 | 作用域 | Sequential | C++ struct |
C# class | 引用类型 | 引用 | GC管理,自动回收 | Auto | C++ pointer |
C#的struct
类型相比class
还有一个问题是:没有构造函数和析构函数的概念。因此,如果希望使用RAII释放CppString
的资源,那么还是需要使用class
C#和C语言交互过程中,常用类型转换(Marshal)的关系如下,引用自官方文档[1]:
C# | C |
---|---|
byte | uint8\_t |
int | int32\_t |
uint | uint32\_t |
long | int64\_t |
ulong | uint64\_t |
char (CharSet.ANSI) | char |
char (CharSet.Unicode) | char16\_t |
class [LayoutKind.Sequential] | class的指针 |
array | array起始指针 |
首先需要实现一个自定义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
参考文章:
2 条评论
不错不错,我喜欢看 https://www.ea55.com/
猫猫好棒