平台调用 (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/

 

posted @ 2022-12-25 20:18  没有星星的夏季  阅读(1005)  评论(0编辑  收藏  举报