平台调用 (P/Invoke):托管代码(C#)调用非托管代码(C/C++)
首先,本文基于.net6来实现C#代码来调用C/C++程序(VS2022),主要从三个角度来说明:简单的调用实现、自定义类和结构体、回调函数。
其次,C#调用C/C++一般是通过调用C/C++的动态连接库来实现的,而windows和linux、macos下的动态链接库是有区别的,后面再说,所以本文以动态链接库来实现调用
最后,你可能会问,C#能调用C/C++,那么C/C++能反过来调用C#么?其实在一定条件下也是可以的,但是C/C++与C#原理差别太大,C#比C/C++更高级,所以一般不会反着来,其次,都用C/C++为什么还要用C#呢?
本文Demo例子源码下载:https://pan.baidu.com/s/1YcvKbxmvub5yG69AkucIww (提取码:1qeb)
准备
介绍之前,我们需要先创建项目,首先,新建一个C#控制台项目,也可以是其它项目,取个名称,我这里叫CSharpConsole:
为了后续的方便,我们可以直接引用C/C++的库,我们调整一下,右键项目=》【编辑项目文件】,输入下面的内容:
<ItemGroup>
<None Include="..\lib\*.*" Link="lib\%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<None Update="..\lib\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
右键项目=》【属性】=》【生成】=》【常规】=》勾选不安全代码,或者【编辑项目文件】,在第一个PropertyGroup节点下添加一个节点:
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
这么做是因为后面会介绍一些指针的东西,完整的结果:
注:这里修改项目文件是非必须的,主要为了后续的方便演示,当生成项目时,动态库文件会自动生成到../lib目录下,这样就可以直接引用加载了
接着新建一个C++的项目,可以是空项目,也可以是一个动态链接库项目,这里以空项目介绍,空项目改一些配置就成了动态库项目:
注:windows和linux使用的是不同的空项目
这里我创建了window和linux的两种动态库项目,为了方便演示,分别取名Project-win和Project-linux。
接着设置项目配置类型为动态库:右键项目=》【属性】=》【常规】=》【配置类型】
为了方便,我们这里还设置了输出目录($(SolutionDir)\lib),这样方便我们后续的使用,与上面CSharpConsole项目读取动态库对应起来
注:windows下的动态库是以dll为后缀,linux则是以so为后缀,此外如果是linux的动态库项目,那么就需要在linux系统里面去编译,所以编译需要linux的环境,我们可以在【工具】=》【选项】=》【跨平台】=》【连接管理器】中添加一个linux系统的连接,或者在项目生成的时候,会弹框要求填写一个连接:
此外,既然是编译,那么自然就还要求这个环境已经部署好了C++的编译环境,所以我们需要安装g++,我这边使用的是ubuntu20.04,所以我直接使用apt安装了:
# ubuntu
sudo apt install g++
# centos
sudo yum install gcc-c++
最后分别在Project-win和Project-linux两个项目中添加一个源文件和头文件,比如我这里都叫demo.cpp和demo.h,然后生成项目,最后的项目结构是这个样子的:
注:linux下的动态库文件只有一个.so文件,文件名默认是:lib[项目名].so,windows下的动态库文件有三个(.dll,.lib,.exp),但是最终有用的只有.dll文件,文件名默认是:[项目名].dll,如果要修改生成的动态库文件名,右键项目=》【属性】=》【常规】=》【模板文件名】修改即可,但是不建议修改,如果要修改,也是建议修改成一样的名称
简单的调用实现
准备工作做完了,我们来实现一个简单的调用逻辑,也就实现一个加法计算吧!
首先,我们编写头文件demo.h,如果是windows下的动态库,那么编辑Project-win下面的demo.h,linux的动态库则编辑Project-linux下面的demo.h:
Project-win:
#pragma once
extern "C" __declspec(dllexport) int Plus(int n1, int n2);
Project-linux:
#pragma once
extern "C" int Plus(int n1, int n2);
注:后续的demo.h中都是Project-win下的,Project-linux下的就是比Project-win少了__declspec(dllexport)部分
然后分别在两个demo.cpp中编辑:
demo.cpp:
#include "demo.h"
int Plus(int n1, int n2)
{
return n1 + n2;
}
最后生成一遍,编译后的动态库就会放到我们解决方案的下的lib目录下,也就是前面创建项目时修改的输出目录,它也是CSharpConsole项目添加的一个链接目录,这样,我们动态库就编译好准备好了。
注:这里有一个重点,而且目前还有一些疑惑
先说说上面几个代码的含义:
#pragma once //预编译指令,保证头文件只被编译一次,多以去掉也没关系
extern //extern置于变量或者函数前,以标示变量或者函数的定义在别的文件中,这里只是声明
"C" //完整的应该是extern "C"这种写法,表示采用C的标准,使用C的编译器来编译而不是C++的编译器
__declspec(dllexport) //__declspec是一种扩展属性的定义,一般在windows环境下使用,__declspec(dllexport)表示当前函数可以导出被其它库使用,与之对应的__declspec(dllimport)表示当前函数由外部导入
这里我有一点疑惑,就是这里无论是__declspec(dllexport)还是__declspec(dllimport)都是可以的,可以正确的导出函数供外部调用。
虽然我们可以通过一些预编译指令来简化这种写法,但是这三项是必须的(linux是两项),如果缺少,则可能报:Unable to find an entry point named 'XXX' in DLL 'XXXX'
当我们发现这种错误时,说明没有在指定的dll中没有找到对应名称的入口,这时,我们可以使用VS自带的命令行工具来检测:
输入命令:dumpbin /exports 【动态库文件】
比如我这里执行后的结果是:
上图中的Plus就是导出的函数名,如果在demo.h中声明的函数没有"C"选项,那么导出函数名是:
此时你会发现,导出的函数名是?Plus@@YAHHH@Z,这就是找不到指定名称入口的原因!其实,这就是C++编译的一个特性导致的。
好了,现在我们可以调用动态库了,我们使用DllImport特性来声明动态库的引入,CSharpConsole中Program完整的调用代码如下:
using System.Runtime.InteropServices;
namespace CSharpConsole
{
internal class Program
{
static void Main(string[] args)
{
if (OperatingSystem.IsWindows())
{
var sum = WindowsPlus(1, 2);
Console.WriteLine("sum:" + sum);
}
else
{
var sum = LinuxPlus(1, 2);
Console.WriteLine("sum:" + sum);
}
}
//linux下的调用
[DllImport("lib/Project-linux.so", EntryPoint = "Plus")]
static extern int LinuxPlus(int n1, int n2);
//windows下的调用
[DllImport("lib/Project-win.dll", EntryPoint = "Plus")]
static extern int WindowsPlus(int n1, int n2);
}
}
运行即可查看到输出结果:sum:3
这里使用到了DllImportAttribute,表示从动态库中加载函数调用,它有一个参数,表示动态库的路径,可以是一个相对路径,也可以是觉得路径,其它参数含义如下:
BestFitMapping:是否启用最佳映射,默认是true
CallingConvention:指定用于传递方法参数的调用约定。 默认值为 WinAPI
CharSet:控制名称重整以及将字符串参数封送到函数中的方式。 默认值为 CharSet.Ansi
EntryPoint:指定要调用的 DLL 入口点
ExactSpelling:指示是否应修改非托管 DLL 中的入口点的名称
PreserveSig:控制托管方法签名是否应转换成返回 HRESULT 并且返回值有一个附加的 [out, retval] 参数的非托管签名,默认值为 true,即不应转换签名
SetLastError:允许调用方使用 Marshal.GetLastWin32Error API 函数来确定执行该方法时是否发生了错误。 在 Visual Basic 中,默认值为 true;在 C# 和 C++ 中,默认值为 false。
ThrowOnUnmappableChar:当Unicode 字符无法转换成一个 ANSI字符时,true则表示抛出异常,false则用?代替
咋一看,这几个参数说的很迷糊,后面再开一篇博文来介绍吧,东西有点多,本文主要实现调用。
到这里,简单的调用就基本上说完了,剩下的就是基本的类型映射,类型对上了,上面的基本调用就都能通了,这个类型的对应,有个专业的术语叫做类型封送,也就是说我们在托管代码(C#)和非托管代码之间的数据转换。
常见类型的对应主要有:
C#类型或者关键字 | C/C++类型 |
System.Byte、byte | uint8_t |
System.SByte、sbyte | int8_t |
System.Int16、short | int16_t |
System.UInt16、ushort | uint16_t |
System.Int32、int | int32_t |
System.UInt32、uint | uint32_t |
System.Int64、long | int64_t |
System.UInt64、ulong | uint64_t |
System.Char、char | char、char16_t、char*、char16_t* |
System.IntPtr、nint | intptr_t |
System.UIntPtr、nuint | uintptr_t |
数组 | 指向数组开头的指针 |
System.Double | double_t |
System.Text.StringBuilder | char*、char16_t* |
其实上面表格来着官方,看起来迷迷糊糊的,而且多数类型我们是不会用到的,所以现实使用时,我们可以按照下面的方式来对应,应该可以满足绝大多数的需求了:
C#类型或者关键字 | C/C++类型 |
System.Int16、short | short |
System.Int32、int | int/long |
System.Int64、long | long long |
System.Single、float | float |
System.Double、double | double |
System.Char、char | char |
System.Boolean、bool | bool |
System.String、string | char* |
数组 | 指针 |
委托 | 函数指针 |
这几个是基本类型的对应,还有几点需要说明一下:
1、C#中的bool其实就是一个整型,如果需要,可以使用int等代替
2、C#中数组在C/C++中一般是作为一个指针接收的,比如int[]对应int*,double[]对应double*,如果是多维数组,那么就是指针的指针
3、如果使用了数组,一般还需要一个数组的长度参数
4、字符串的可以认为一个字符数组,所以可以使用char*接收
5、对于C#的DateTime类型,一般来说我们是不能使用DateTime类型的,因为它不是一个简单的基础类型,解决办法有两个:
一、将DateTime转换成tick,然后使用long类型来实现
二、使用自定结构来实现,这个后文介绍
一个完整的例子:
demo.h:
extern "C" __declspec(dllexport) int Invoke(short s, int i, long l, float f, double d, char c, bool b, char* pc, int* pi, float* pf);
demo.cpp:
#include "demo.h"
#include "iostream"
//..
int Invoke(short s, int i, long l, float f, double d, char c, bool b, char* pc, int* pi, float* pf)
{
printf("s=%d\n", s);
printf("i=%d\n", i);
printf("l=%d\n", l);
printf("f=%g\n", f);
printf("d=%g\n", d);
printf("c=%c\n", c);
printf("b=%d\n", b);
printf("pc=%s\n", pc);
printf("pi[0]=%d\n", pi[0]);
printf("pf[0]=%g\n", pf[0]);
return 0;
}
C#调用:
using System.Runtime.InteropServices;
namespace CSharpConsole
{
internal class Program
{
static void Main(string[] args)
{
//..
if (OperatingSystem.IsWindows())
{
WindowsInvoke(1, 2, 3, 3.14f, 3.1415, 'A', true, "hello", new int[] { 1 }, new float[] { 3.14f });
}
else
{
LinuxInvoke(1, 2, 3, 3.14f, 3.1415, 'A', true, "hello", new int[] { 1 }, new float[] { 3.14f });
}
}
//..
//linux下的调用
[DllImport("lib/Project-linux.so", EntryPoint = "Invoke")]
static extern int LinuxInvoke(short s, int i, long l, float f, double d, char c, bool b, string pc, int[] pi, float[] pf);
//windows下的调用
[DllImport("lib/Project-win.dll", EntryPoint = "Invoke")]
static extern int WindowsInvoke(short s, int i, long l, float f, double d, char c, bool b, string pc, int[] pi, float[] pf);
}
}
自定义类和结构体
上面已经实现了C#调用C/C++的过程,但是实际开发过程中,我们会发现,调用的时候会传递很多参数,比如上面的Invoke函数的调用,这样一方面不方便使用,另一方面不方便拓展,幸运的是,C#允许我们自定义类或者结构体来实现封送,比如对于C#的DateTime,我们可以自定一个类或者结构体来实现:
using System.Runtime.InteropServices;
namespace CSharpConsole
{
[StructLayout(LayoutKind.Sequential)]
public class MyDateTime
{
public int Year { get; set; }
public int Month { get; set; }
public int Day { get; set; }
public int Hour { get; set; }
public int Minute { get; set; }
public int Second { get; set; }
}
}
自定义类型使用StructLayoutAttribute特性修饰,同时指定类型中的属性只是对应到C/C++中结构中字段的位置,有三个选择:
Sequential:表示顺序布局,调用时会将C#类或者结构体中的属性按照顺序放到C/C++中结构体的对应位置,这个时候,C/C++对应的结构只要类型对上就可以了,而名称可以随意
Explicit:表示显式布局,这个时候我们需要对C#的每个字段用FieldOffsetAttribute特性来指定位置
Auto:表示自动选择。
这里一般都是使用顺序布局:Sequential
接着我们需要使用C/C++定义对应的结构体和接口:
demo.h:
#pragma once
struct MyDateTime {
int Year;
int Month;
int Day;
int Hour;
int Minute;
int Second;
};
//..
extern "C" __declspec(dllexport) int DateTime(MyDateTime myDateTime);
demo.cpp实现接口:
#include "demo.h"
#include "iostream"
//..
int DateTime(MyDateTime myDateTime)
{
MyDateTime dt = myDateTime;
printf("%d-%d-%d %d:%d:%d\n", dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second);
return 0;
}
C#调用:
using System.Runtime.InteropServices;
namespace CSharpConsole
{
internal class Program
{
static unsafe void Main(string[] args)
{
//...
var myDateTime = new MyDateTime() { Year = 2022, Month = 12, Day = 25, Hour = 12, Minute = 35, Second = 56 };
if (OperatingSystem.IsWindows())
{
WindowsDateTime(myDateTime);
}
else
{
LinuxDateTime(myDateTime);
}
}
//..
//linux下的调用
[DllImport("lib/Project-linux.so", EntryPoint = "DateTime")]
static extern int LinuxDateTime(MyDateTime myDateTime);
//windows下的调用
[DllImport("lib/Project-win.dll", EntryPoint = "DateTime")]
static extern int WindowsDateTime(MyDateTime myDateTime);
}
}
回调函数
上面介绍的都是对数据的操作,但是我们C#还有一个叫委托的东西,而委托本质上就是一个函数指针,这样我们同样可以使用委托来实现上述操作:
同样,首先,我们需要定义一个C#委托类型:
namespace CSharpConsole
{
public delegate void MyDelegate(int year, MyDateTime MyDateTime);
}
接着我们需要使用C/C++定义对应的结构体和接口:
demo.h:
#pragma once
struct MyDateTime {
int Year;
int Month;
int Day;
int Hour;
int Minute;
int Second;
};
typedef void(*MyDelegate)(int y, MyDateTime myDateTime);
//...
extern "C" __declspec(dllexport) int Run(int y, MyDelegate del);
demo.cpp实现函数:
#include "demo.h"
#include "iostream"
//...
int Run(int y, MyDelegate del)
{
int i = 2020;
while (i <= y)
{
MyDateTime myDateTime = MyDateTime();
myDateTime.Year = i;
myDateTime.Month = 1;
myDateTime.Day = 1;
del(i, myDateTime);
i++;
}
return 0;
}
然后C#调用:
using System.Runtime.InteropServices;
namespace CSharpConsole
{
internal class Program
{
static unsafe void Main(string[] args)
{
//..
if (OperatingSystem.IsWindows())
{
WindowsRun(2023, (year, myDateTime) => Console.WriteLine($"Year:{year},DateTime:{myDateTime.Year}-{myDateTime.Month}-{myDateTime.Day}"));
}
else
{
LinuxRun(2023, (year, myDateTime) => Console.WriteLine($"Year:{year},DateTime:{myDateTime.Year}-{myDateTime.Month}-{myDateTime.Day}"));
}
}
//..
//linux下的调用
[DllImport("lib/Project-linux.so", EntryPoint = "Run")]
static extern int LinuxRun(int n, MyDelegate del);
//windows下的调用
[DllImport("lib/Project-win.dll", EntryPoint = "Run")]
static extern int WindowsRun(int n, MyDelegate del);
}
}
这样,我们就实现了可以往C/C++程序中注册一个函数句柄,当需要的时候,函数的调用让C/C++去触发。
总结
其实,到这里基本上就已经说完了,应该能满足多数据的开发需求了,但是还有几点没有说道,这里也记一下,后续再写博客补充:
1、DllImport各参数的使用说明,网上很多说的不清楚,每个字段的使用例子也没有,我觉得有需要的话,后续可以补充一下各个字段的使用demo
2、跨平台支持,比如本文中的例子,有linux和windows两个版本,但是调用的时候,都是使用的if(OperatingSystem.IsWindows())来判断当前环境的系统,这种方式非常不友好!
其实还有异常捕捉等没有讲到,但这是后话,可能就不说了,建议是C#处理C#的异常,C/C++按照自己的方式来处理,当然你可能回想,异常捕捉哪有那么麻烦,直接try-catch不就行了么?而且你调试也会发现,C/C++抛出的异常确实可以不做到,但是这些是依赖windows底层的api来实现的,想其它系统不一定有这种api支持,导致的结果就可能是非托管代码发生异常,导致我们整个进程就直接挂掉了!
参考:
https://learn.microsoft.com/en-us/dotnet/standard/native-interop/