背景
有的时候我们会遇到这种场景:我们希望以C#库的方式提供接口,或者直接用C#构建应用,但是有大量密集计算的任务,需要放在C/Cpp层面来提高性能,这时候就需要在C#中调用C/Cpp构建的Native API。当然,也会有反过来在C/Cpp中调用C# API的情况,但是这种相对比较少。
现在已知的可以做到C#调用Native API的方式有以下三种:
- C++/CLI
- COM
- P/Invoke
我觉得实践中最好用的还是P/Invoke,不仅使用形式上简洁易上手,而且可以在所有.NET支持的平台上使用。另外两种方式感兴趣的话可以翻阅微软官方文档,本文主要介绍P/Invoke的使用。
C++的编译有很多种方式,由于要考虑到跨平台,这里以CMake为示例。本文环境:Ubuntu20.04 LTS, GCC 9.3, .NET 6。
p.s. 这篇文章的主要内容是来自我自己以前的一个记录,以前的记录又应该主要取自互联网上某个前辈写的文章,但现在已经找不到了,所以没法注明出处,如果有侵权行为请联系删改。
应用方法
基本用法
项目目录结构如下所示:
.
├── CMakeLists.txt
└── lib
├── CMakeLists.txt
├── hello.c
└── hello.h
然后再定义一下头文件和c文件
// hello.h
#ifndef HELLO_H
#define HELLO_H
#include <stdio.h>
void HelloFunc();
int add(int a, int b);
#endif
// hello.c
#include "hello.h"
void HelloFunc()
{
printf("Hello World\n");
}
int add(int a, int b)
{
return a + b;
}
然后是两个CMake文件:
# CMakeLists.txt
PROJECT(PInvoke)
ADD_SUBDIRECTORY(lib)
# lib/CMakeLists.txt
SET(LIBHELLO_SRC hello.cpp)
ADD_LIBRARY(hello SHARED ${LIBHELLO_SRC})
到这里,我们可以编译出一个Native库了:
mkdir build
cd build
cmake ..
make
好了,我们顺利来到了.NET部分,创建一个.NET命令行项目,然后在Program.cs
里面输入以下代码:
using System.Runtime.InteropServices;
[DllImport(@"libhello", EntryPoint = "HelloFunc")]
extern static void HelloFunc();
[DllImport(@"libhello")]
extern static int add(int a, int b);
int t = add(2, 3);
Console.WriteLine(t);
HelloFunc();
我们不需要写明我们的库文件是Windows下的libhello.dll
还是Linux下的libhello.so
,只需要写出库名就行了,这也是P/Invoke的一个方便之处。EntryPoint
的作用是有时候我们在C/C++里面是一个函数名,在C#里想import成另一个名字,这时候就需要手动指定一下Native库里面的函数名了。
以上就是基本的用法了,对于新手开发者而言还是非常友好的。
进阶用法——处理指针
考虑以下C代码:
// hello.h
#ifndef HELLO_H
#define HELLO_H
#include <stdio.h>
void HelloFunc();
int add(int a, int b);
void print(int *p, int len);
#endif
// hello.c
#include "hello.h"
void HelloFunc()
{
printf("Hello World\n");
}
int add(int a, int b)
{
return a + b;
}
void print(int *p, int len)
{
for (int i = 0; i < len; i++)
{
printf("%d ", p[i]);
p[i] *= 2;
}
printf("\n");
}
print
的入参有一个指针,并且我们还想在C#中调用它,那该怎么做呢?
我们可以写出如下C#代码:
using System.Runtime.InteropServices;
[DllImport(@"libhello", EntryPoint = "HelloFunc")]
extern static void HelloFunc();
[DllImport(@"libhello")]
extern static int add(int a, int b);
[DllImport(@"libhello")]
extern static unsafe int print(int* p, int len);
unsafe{
int[] a = new int[10];
for (int i = 0; i < 10; i++){
a[i] = add(i, i);
}
fixed(int* p = &(a[0])){
print(p, 10);
}
for (int i = 0; i < 10; i++){
Console.Write(a[i] + ", ");
}
Console.Write("\n");
}
int t = add(2, 3);
Console.WriteLine(t);
HelloFunc();
这种方式是比较直接的,我们使用了unsafe block,在里面取到变量的指针,然后传递给print
函数。但这种方法不是很建议用,因为.NET有更好的封装:IntPtr
,这个可以对任意可以取指针的类型进行封装,然后向下进行传递(毕竟不管什么类型的指针,大小肯定是一样的,要么32bits,要么64bits)。我们对关键部分代码做一点改动:
[DllImport(@"libhello")]
extern static int print(IntPtr p, int len); // here
unsafe
{
int[] a = new int[10];
for (int i = 0; i < 10; i++)
{
a[i] = add(i, i);
}
fixed (int* p = &(a[0]))
{
print(new IntPtr(p), 10); // here
}
for (int i = 0; i < 10; i++)
{
Console.Write(a[i] + ", ");
}
Console.Write("\n");
}
为什么要这么做的最重要原因是:IntPtr可以随意传递,传到unsafe块里面也可以,但是裸指针的话就不太方便了。第二个原因算是第一个原因的延递:你肯定不希望代码里unsafe块满天飞吧。
当然,这可能也有一种担忧:IntPtr可以包装所有类型的裸指针,那么用户不知道,传递错误怎么办,这不是类型不安全了吗?
对于这个我认为不需要担心,或者说,出现了这一问题,应该审视以下自己的设计是否有问题。对于这种和Native库交互的底层代码,我们理应不暴露给用户,即便用户用的功能基本就是Native库的功能,我们也应该友好地给用户加上一层封装,使得这部分“肮脏”的操作对用户而言透明。
进阶用法——处理结构体
有的时候,Native API的入参有结构体指针怎么办?这里直接给出解决的示例:
// hello.h
#ifndef HELLO_H
#define HELLO_H
#include <stdio.h>
typedef struct vector3
{
float x, y, z;
} vector;
vector mul(vector *rls, vector *rhs);
#endif
// hello.c
#include "hello.h"
vector mul(vector *rls, vector *rhs)
{
vector v;
v.x = rls->x * rhs->x;
v.y = rls->y * rhs->y;
v.z = rls->z * rhs->z;
return v;
}
// Program.cs
using System.Runtime.InteropServices;
[DllImport(@"libhello")]
extern static unsafe vector3 mul(IntPtr lhs, vector3* rhs);
unsafe{
vector3 l, r;
l.x = 1;
l.y = 2;
l.z = 3;
r.x = 2;
r.y = 3;
r.z = 4;
vector3 v = mul(new IntPtr(&l), &r);
Console.WriteLine($"x: {v.x}, y: {v.y}, z: {v.z}");
}
[StructLayout(LayoutKind.Sequential)]
struct vector3{
public float x, y, z;
}
需要注意的点有以下几个:
- C#中结构体要标注为
LayoutKind.Sequential
。 - C#中结构体成员必须是public的。
- C#中结构体成员要和C中一致,且排列顺序相同。
进阶用法——处理回调函数
有时候,Native API中的一个入参是函数指针,亦或者,我们希望一些行为在我们的C#中临时决议,而不是直接写死在Native库中,这时候就需要传递一个回调函数给Native API。基本的原理就是委托+函数指针,示例如下:
// hello.h
#ifndef HELLO_H
#define HELLO_H
#include <stdio.h>
void callback(int value);
void callBackTest(void (*p)(int), int value);
#endif
// hello.c
#include "hello.h"
void callBackTest(void (*p)(int), int value)
{
p(value);
}
// Program.cs
using System.Runtime.InteropServices;
[DllImport(@"libhello")]
extern static int callBackTest(printFromCSharp cSharp, int value);
printFromCSharp callback = printFromCSharpImpl;
callBackTest(callback, 10);
static void printFromCSharpImpl(int value){
Console.WriteLine("This is called from CSharp: " + value);
}
delegate void printFromCSharp(int value);
进阶用法——调用C++库
上面都是以c库为示例,但是我们更常用的其实是cpp的库,直接按照上面方式调用cpp库会出现找不到函数入口点的情况,这是因为cpp中有继承、重载这些概念,导致编译后实际的函数签名和我们写的不一样。
解决方法很简单,在头文件里面加上extern
关键字即可:
#ifndef HELLO_H
#define HELLO_H
#if CPLUSPLUS
extern "C"
{
#endif
#include <stdio.h>
// code ...
#if CPLUSPLUS
}
#endif
#endif
注意extern
本身我们是加了条件编译的,只有我们按照cpp来编译,才会加上extern
,这主要是为了与c兼容,说不定哪天你的代码变成c实现了呢?(但我觉得有这种想法的通通该拉出去斩了★,°:.☆( ̄▽ ̄)/$:.°★ 。)
最后在CMake文件里面加一下cpp的定义:
SET(LIBHELLO_SRC hello.cpp)
add_definitions(-DCPLUSPLUS)
ADD_LIBRARY(hello SHARED ${LIBHELLO_SRC})
进阶用法——高效传递错误信息
很多时候,我们在Native库中写的代码如果发生错误会采集错误信息并向上层调用方传递,如果我们是pure c/cpp项目,那自然一切没有问题,只要在最上层打印出错误信息、抛出异常或者写入日志即可。
但如果是C#和Native库交互,异常必然是抛不到C#这一层的,而错误信息虽然我们可以定义一个结构体,里面包含一个字符串记录错误信息,一个数字代表错误码等,然后每个函数都有一个错误结构体指针,这样来传递错误信息,但是这种方式是效率比较低下的,严重影响性能,原因就在于,错误只是偶尔的,但是却每次都为此付出额外的代价。
正确的做法应该是这样,我们仍旧在Native库里面定义一个结构体代表错误信息,但是对于API,直接返回一个指针,这个指针在不发生错误的时候永远为nullptr,只有发生错误才是一个指向错误信息对象的指针:
struct ErrorInfo{
int errCode;
const char* errMsg;
}
ErrorInfo* CertainAPI([params...]){
if (error occured){
return new ErrorInfo([...]);
}
return nullptr;
}
不过这里有一个很严重的问题,相信细心的你已经发现了——那就是内存泄漏,我们new了一个对象,但是由于要把对象传出去,我们不能在函数返回前释放它。
还记得前面说的一段话吗?
对于这种和Native库交互的底层代码,我们理应不暴露给用户,即便用户用的功能基本就是Native库的功能,我们也应该友好地给用户加上一层封装,使得这部分“肮脏”的操作对用户而言透明。
这里便是一个体现,我们在C#代码中应当对此进行封装,我们可以写出类似下面的代码:
[DllImport(@"xxx")]
extern static IntPtr CertainAPI([param...]);
[DllImport(@"xxx")]
// 库文件里面也要加上相应的释放内存的API,但由于就是一个free的调用,这里便不冗述
extern static void FreeErrorInfoMemory(IntPtr errInfo);
class ErrorHandler{
public static void AssertOk(IntPtr errInfo)
{
if (errInfo == IntPtr.Zero)
{
return;
}
// Do something...
DisposeErrorInfo(errMsg);
// Throw the exception.
}
public static void DisposeErrorInfo(IntPtr errInfo)
{
FreeErrorInfoMemory(errInfo);
}
}
class Wrapping
{
public static CertainAPIForUser([param...])
{
var errInfo = CertainAPI(...);
ErrorHandler.AssertOk(errInfo);
}
}
这样一来,我们呈现给用户的是一个透明的API,并且当抛出异常之前,我们会再次调用一个用来释放内存的Native API来将内存释放掉,这样便兼顾了效率和可用性。
上面的实践其实来自于我自己的一个开源项目真实遇到的情况,如果有兴趣也可以参考Num.NET里面的实现,可以参照这两个文件夹下的文件:
- https://github.com/SteeWing/Num.NET/tree/master/apis
- https://github.com/SteeWing/Num.NET/tree/master/csharp/Num.NET/Native
总结
读到这里相信你也能看出来,P/Invoke还是很好用的,不过实践中也会有一些痛点:
- 如果你的C#库用了Native库并且要发布多个目标框架的Package,那么会很痛苦,你必须手动修改
.nuspec
文件(如果用NuGet的话)。 - 与Native交互会带来额外的封装的麻烦。
- 项目整体管理成本增大
如果可以,其实还是能不用就不用罢(┬┬﹏┬┬),简单一点的场景用C# unsafe其实也就够啦,上面提到的开源项目就是C++和C#混合构建的,算是中等大小的项目,我现在甚至都有点动摇,不知要不要转成pure C#实现了。