背景

有的时候我们会遇到这种场景:我们希望以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;
}

需要注意的点有以下几个:

  1. C#中结构体要标注为LayoutKind.Sequential
  2. C#中结构体成员必须是public的。
  3. 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里面的实现,可以参照这两个文件夹下的文件:

总结

读到这里相信你也能看出来,P/Invoke还是很好用的,不过实践中也会有一些痛点:

  1. 如果你的C#库用了Native库并且要发布多个目标框架的Package,那么会很痛苦,你必须手动修改.nuspec文件(如果用NuGet的话)。
  2. 与Native交互会带来额外的封装的麻烦。
  3. 项目整体管理成本增大

如果可以,其实还是能不用就不用罢(┬┬﹏┬┬),简单一点的场景用C# unsafe其实也就够啦,上面提到的开源项目就是C++和C#混合构建的,算是中等大小的项目,我现在甚至都有点动摇,不知要不要转成pure C#实现了。