托管代码C#调用非托管C++ API, 封送嵌套结构体数组

一、前言:

         最近这两天由于项目需要,提供给客户的C++ 动态库需要返回自定义结构体数组,网上也查了很多资料, 推荐一本书, 《精通.NET互操作:P/Invoke、C++ Interop和COM Interop》 , 介绍Windows平台上的托管代码与非托管代码之间进行互操作的各种技术, 虽然里面没有结构体数组的传参例子。以前都是返回字节数组的,本以为很简单,意想不到的是,遇到各种坑。其中一个就是在C#中如何处理结构体嵌套以及定义结构体数组的问题,第二个是如何成功的获取由C++返回到结构体数组等问题。为了防止自己下次忘记,并重蹈覆辙,因此,写下这篇文章,记录下来。

二、相关资料

1、托管代码与非托管代码

1.1 托管代码 (managed code)

.NET Framework的核心是其运行库的执行环境,称为公共语言运行库(CLR)或.NET运行库。通常将在CLR的控制下运行的代码称为托管代码(managed code)。

运行库环境(而不是直接由操作系统)执行的代码。托管代码应用程序可以获得公共语言运行库服务,例如自动垃圾回收、运行库类型检查和安全支持等。这些服务帮助提供独立于平台和语言的、统一的托管代码应用程序行为。

1.2 非托管代码 (unmanaged code)

  在公共语言运行库环境的外部,由操作系统直接执行的代码。非托管代码必须提供自己的垃圾回收、类型检查、安全支持等服务;它与托管代码不同,后者从公共语言运行库中获得这些服务。

1.3 什么是托管?托管是什么意思?

托管代码就是基于.net元数据格式的代码,运行于.net平台之上,所有的与操作系统的交换有.net来完成,就像是把这些功能委托给.net,所以称之为托管代码。非托管代码则反之。

1.4 托管代码如何调用非托管代码(c sharp如何调用c++代码)?

两种常用的做法:

1. COM interop。

2. P/Invoke

a. 在托管客户端增加一条 DllImport语句和一个方法的调用。(摘自:超详细解析托管与非托管)

下面主要讲解P/Invoke方式。

2、P/Invoke:C#调用C++
P/Invoke的全称是Platform Invoke (平台调用) 它实际上是一种函数调用机制通 过P/Invoke我们就可以调用非托管DLL中的函数。

P/Invoke依次执行以下操作:

1. 查找包含该函数的非托管DLL

2. 将该非托管DLL加载到内存中

3. 查找函数在内存中的地址并将其参数按照函数的调用约定压栈

4. 将控制权转移给非托管函数


DllImport: 用来标识该方法是非托管的代码方法,在编译器编译的时候它能够正确的认识出被该特性标记的是外来代码段。当到达程序运行的时候,也能够正确的认识出该代码是引用非托管的代码,这样CLR会去加载非托管DLL文件,然后查找到入口点进行调用(关于此部分,《超详细解析托管与非托管》有详细说明)

CallingConvention:在平台调用的过程中起到查找入口点的作用,在托管代码进行非托管代码入口点查找时,会通过CallingConvention中的值进行确认非托管入口点的调用约定。

3、非托管函数和托管方法中数据类型对应

3.1 将C/C++ 等托管代码API中数据类型与C#托管方法数据类型进行转换,是数据封送。

对于每个 .NET Framework 类型均有一个默认非托管类型,公共语言运行库将使用此非托管类型在托管到非托管的函数调用中封送数据。string 类型默认非托管类型是LPTSTR,可以在非托管函数的 C# 声明中使用 MarshalAs 属性重写默认封送处理。例如:

[DllImport("msvcrt.dll")]
public static extern int puts([MarshalAs(UnmanagedType.LPStr)] string m);
puts 函数的参数的默认封送处理已从默认值 LPTSTR 重写为 LPSTR。

修改默认封送有什么用?

默认情况下,本机结构和托管结构在内存中的布局有所不同,因此,若要跨托管/非托管边界成功传递结构,需要执行一些额外步骤来保持数据的完整性。(摘自:《.NET学习之路----我对P/Invoke技术的理解(一)》)

因此,我传递结构体也是一样,由于自定义API中使用一个结构体,那么C#中没有一个对应的托管类型与之对应,需要为用户定义的结构指定自定义封送处理需要对参数进行处理。

可以为传递到非托管函数或从非托管函数返回的结构体和类的字段指定自定义封送处理属性。通过向结构体或类的字段中添加 MarshalAs 属性可以做到这一点。还必须使用 StructLayout 属性设置结构体或者类的布局,还可以控制字符串成员的默认封送处理,并设置默认封装大小。

这篇文章《MarshalAs的使用》有关于MarshalAs 详细说明。

3.2 封送,在此看来算是传数据,但是为什么要封送呢?封送是为了在托管内存和非托管内存中正常传递数据。有时,出于对性能的考虑,会对托管结构的成员进行重新排列,因此有必要使用 StructLayoutAttribute 特性指示该结构为顺序布局。 将结构封装设置显式设置为与本机结构所使用的设置相同的设置也是一个好办法。(摘自:《.NET学习之路----我对P/Invoke技术的理解(一)》)

其中,这个还是涉及C/C++结构体内存对齐,

根据相关资料,摘自《C#调用C++ dll时,结构体引用传参的方法》

在C/C++中,struct类型中的成员的一旦声明,则实例中成员在内存中的布局(Layout)顺序(C/C++结构体内存对齐)就定下来了,即与成员声明的顺序相同,并且在默认情况下总是按照结构中占用空间最大的成员进行对齐(Align);

然而在.net托管环境中,CLR提供了更自由的方式来控制struct中Layout:我们可以在定义struct时,在struct上运用StructLayoutAttribute特性来控制成员的内存布局。默认情况下,struct实例中的字段在栈上的布局(Layout)顺序与声明中的顺序相同,即在struct上运用[StructLayoutAttribute(LayoutKind.Sequential)]特性,这样做的原因是结构常用于和非托管代码交互的情形。如果我们正在创建一个与非托管代码没有任何互操作的struct类型,我们很可能希望改变C#编译器的这种默认规则,因此LayoutKind除了Sequential成员之外,还有两个成员Auto和Explicit,给StructLayoutAttribute传入LayoutKind.Auto可以让CLR按照自己选择的最优方式来排列实例中的字段;传入LayoutKind.Explicit可以使字段按照我们的在字段上设定的FieldOffset来更灵活的设置字段排序方式,但这种方式也挺危险的,如果设置错误后果将会比较严重。

因此,把结构体显示的声明为  [StructLayout(LayoutKind.Sequential)]即可.

默认(LayoutKind.Sequential)情况下,CLR对struct的Layout的处理方法与C/C++中默认的处理方式相同,即按照结构中占用空间最大的成员进行对齐(Align); 
使用LayoutKind.Explicit的情况下,CLR不对结构体进行任何内存对齐(Align),而且我们要小心就是FieldOffset; 
使用LayoutKind.Auto的情况下,CLR会对结构体中的字段顺序进行调整,使实例占有尽可能少的内存,并进行byte的内存对齐(Align)。

3.3 数据封送中指针处理

数据封送中指针处理的两种情况 :

3.3.1 普通指针

  在非托管代码中,基本数据类型对应的指针变量和数组,及单个结构体或类的指针,在C#中使用ref或out来实行封送。3,

3.3.2 Handle类型和自定义结构和类等数组

  C#中使用IntPtr来进行封送。

三、托管代码C#与非托管C++ 嵌套结构体的对应
1、在最近的项目中,非托管C++ 的结构体定义如下:

typedef struct CARDINFOBEAN
{
    unsigned char channel;    // 通道 0~23
 
    unsigned char exist;      // 0---当前通道没有扫描到卡,1---当前通道扫描到卡
 
    unsigned char snlen;      // 0---序列号4字节, 1---序列号7字节
 
    unsigned char alarm;      // 报警
 
    unsigned char cardtype;   // 卡片类型
 
    unsigned char sn[7];      // 卡号
}CardInfoBean;
 
typedef struct PROTOCOLBEAN
{
    unsigned char cardInfoBeanListCount;               //卡片信息结构体实际个数
 
    int protocolLength;                                //协议数据长度
 
    int channel;                                       //天线通道号
 
    unsigned char protocol[1024];                      //协议数据
 
    CardInfoBean cardInfoBeanList[255];                //天线号每个对应的卡片信息
}ProtocolBean;

在.h中API声明如下:

ReaderDLL int CallReader CardTest(UCHAR addr, UCHAR testCardType, ProtocolBean *protocolBeanList, int *protocolBeanListCount, int maxLength);
 

在上面的声明中,使用到了结构体的嵌套,API中通过指针返回嵌套结构体数组。

2、项目中需要使用C#调用C++ 中CardTest() API ,这个时候就遇到了,非托管代码与托管代码中自定义数据类型的对应和封送问题。

在前面的篇幅中,已经讲解的很明了,因此不做过多的叙述。

2.1 在C#对应的结构体定义如下:
 

[System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential, CharSet = System.Runtime.InteropServices.CharSet.Ansi)]
public struct CARDINFOBEAN
{
    /// unsigned char
    public byte channel;
 
    /// unsigned char
    public byte exist;
 
    /// unsigned char
    public byte snlen;
 
    /// unsigned char
    public byte alarm;
 
    /// unsigned char
    public byte cardtype;
 
    /// unsigned char[7]
        [System.Runtime.InteropServices.MarshalAsAttribute(System.Runtime.InteropServices.UnmanagedType.ByValArray, SizeConst = 7)]
        public byte[] sn;
    }
 
    [System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential, CharSet = System.Runtime.InteropServices.CharSet.Ansi)]
public struct PROTOCOLBEAN
{
    /// unsigned char
    public byte cardInfoBeanListCount;
 
    /// int
    public int protocolLength;
 
    /// int
    public int channel;
 
    /// unsigned char[1024]                         
	[System.Runtime.InteropServices.MarshalAsAttribute(System.Runtime.InteropServices.UnmanagedType.ByValArray, SizeConst = 1024)]
    public byte[] protocol;
 
    /// CardInfoBean[255]   
	[System.Runtime.InteropServices.MarshalAsAttribute(System.Runtime.InteropServices.UnmanagedType.ByValArray, SizeConst = 255, ArraySubType = System.Runtime.InteropServices.UnmanagedType.Struct)]
    public CARDINFOBEAN[] cardInfoBeanList;
}

在结构体CARDINFOBEAN中,对于基本数据类型只要与c++中的基本数据类型对应即可,需要特别说明的是基本数据类型数组的对应,默认在C#结构体中,与C++ 对应数组都需要使用MarshalAsAttribute指示指示如何在托管代码和非托管代码对应,并且使用UnmanagedType 的ByValArray 用于在结构体中出现的数组,应始终使用MarshalAsAttribute的SizeConst字段来指示数组的大小。

验证结构体的大小是否正确,可以使用Marshal.SizeOf(),即获取对象的非托管大小时,获得的是自己在C#定义的大小,然后使用sizeof()计算C/C++结构体的大小,即获取非托管的大小,对比是否一致。

在结构体PROTOCOLBEAN中,cardInfoBeanList是嵌套了CARDINFOBEAN的结构体数组,所以对应C#的数组,同样需要使用MarshalAsAttribute指定类型为ByValArray ,并且使用SizeConst字段来指示数组的大小。同样也需要使用ArraySubType = System.Runtime.InteropServices.UnmanagedType.Struct 指定成员为结构体类型。

MarshalAs这个属性很难用,很容易用错,用好需要对C#、C++布局方式有一定的了解才能做。

因此为微软提供了一个很好用的工具,Signature Tool ,具体使用可以阅读《使用Signature Tool自动生成P/Invoke调用Windows API的C#函数声明》这篇文章。

如下图,直接可以使用工具生成:

四、嵌套结构体的封送

4.1 在前面也提到,对于当个结构体,可以直接使用ref 或out 。对于结构体数组,则使用使用IntPtr来进行封送。

在C# 中,CardTest() API 对应方法定义如下:

 [DllImport("ICReader.dll", EntryPoint = "CardTest", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)]
 public static extern int CardTest(byte addr, byte testCardType, IntPtr protocolBeanList, ref int protocolBeanListCount, int maxLength);
 

则C#调用如下:

 
int memSize = Marshal.SizeOf(typeof(PROTOCOLBEAN)) * 1024;
IntPtr protocolBeanListBuf = Marshal.AllocHGlobal(memSize); 					// 通过使用指定的字节数,从进程的非托管内存中分配内存
 
//调用非托管类型api
CardTest(mAddrress, testCardType, protocolBeanListBuf, ref protocolBeanListCount, 1024);
 
//分配托管类型的内存
PROTOCOLBEAN[] protocolBeanList = new PROTOCOLBEAN[1024];
for (int ik = 0; ik < protocolBeanListCount; ik++)
{
	IntPtr ptr = new IntPtr(protocolBeanListBuf.ToInt64() + Marshal.SizeOf(typeof(PROTOCOLBEAN)) * ik);
	protocolBeanList[i] = (PROTOCOLBEAN)Marshal.PtrToStructure(ptr, typeof(PROTOCOLBEAN));        //保存数据
}
 
Marshal.FreeHGlobal(protocolBeanListBuf); // 释放内存

在上面中通过Marshal.Sizeof(),计算非托管类型所占用的空间,然后通过Marshal.AllocHGlobal()方法从进程的非托管内存中分配内存(字节流)。然后调用非托管类型的API,将API返回数据暂存到前面通过Marshal.AllocHGlobal()方法分配的内存中。接着,就讲非托管内存中的数据拷贝出来,使用下面这个方法:

        //
        // 摘要:
        //     将数据从非托管内存块封送到新分配的指定类型的托管对象。
        //
        // 参数:
        //   ptr:
        //     指向非托管内存块的指针。
        //
        //   structureType:
        //     待创建对象的类型。此对象必须表示格式化类或结构。
        //
        // 返回结果:
        //     一个托管对象,包含 ptr 参数指向的数据。
        //
        // 异常:
        //   System.ArgumentException:
        //     structureType 参数布局不是连续或显式的。- 或 -structureType 参数是泛型类型。
        //
        //   System.ArgumentNullException:
        //     structureType 为 null。
        [ComVisible(true)]
        [SecurityCritical]
        public static object PtrToStructure(IntPtr ptr, Type structureType);

这个方法需要一个带有当前非托管内存地址的IntPtr指针对象,因此,可以根据结构体大小和非托管内存的首地址计算每个结构体地址(类似C/C++ 内存操作)

IntPtr ptr = new IntPtr(protocolBeanListBuf.ToInt64() + Marshal.SizeOf(typeof(PROTOCOLBEAN)) * ik);

protocolBeanListBuf.ToInt64() 记录的就是非托管内存首地址,然后Marshal.SizeOf(typeof(PROTOCOLBEAN)) 是每个非托管类型所占用的空间。

最后,使用Marshal.PtrToStructure(), 将数据从非托管内存块封送到新分配的指定类型的托管对象(类似C/C++ 的memcpy() )

其实在C/C++ 中,结构体的拷贝和复制都是将结构体当做字节流进行操作的,因此这个与C/C++ 类似的。

最后使用Marshal.FreeHGlobal(), 释放以前从进程的非托管内存中分配的内存。

参考大神文章:

C# 调用dll 封送结构体 结构体数组

使用Signature Tool自动生成P/Invoke调用Windows API的C#函数声明

超详细解析托管与非托管

.NET学习之路----我对P/Invoke技术的理解(一)


--------------------- 
版权声明:本文为CSDN博主「gd6321374」的原创文章,遵循CC 4.0 by-sa版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/gd6321374/article/details/85050162

 

你可能感兴趣的:(c#)