其实从理论上讲结构体的和一般的基本值数据类型的封送没有太大的区别,因为都是栈上内存块的处理(当然如果结构体内有引用类型的成员也需要处理堆上的内存块)。
Example:(最基本的结构体封送)
C++ Code:
struct Person
{
public :
LPCSTR name;
int age;
};
_declspec(dllexport)
void _stdcall PrintPerson(Person person)
{
setlocale(LC_ALL,
"
chs
" );
wprintf(L
"
Person Name:%s, Age:%d
" ,person.name,person.age);
}
C#包装类:
public
struct Person
{
public
string Name;
public
int Age;
}
[DllImport(
"
native.dll
" , EntryPoint =
"
PrintPerson
" , CharSet = CharSet.Ansi)]
public
static
extern
void PrintPerson(Person person);
C#调用代码:
NativeWrapper.PrintPerson(
new Person() { Name =
"
汪熊
" , _age =
27 });
看上去和封送一个int类型差不多,只是需要定义一个结构体。其实和封送int类型还是有区别的,因为struct毕竟是一个复合类型。在这里我们看上去和封送一样是
因为编绎器为我们做了很多默认配置的工作。这些工作可以通过StructLayout这个Attribute来定制。
根据MSDN:
StructLayout是用来设定结构体的内存物理布局的,有以下几种类型的物理布局:
Sequential(C#和C++编译器默认为使用该类型的布局) 设定结构体的物理内存布局为顺序的,成员按定义的顺序依次布局。每个成员所用的内存空间根据另一个参数Pack的值来确定。
Explicit 设定结构体的物理内存布局为显示指定。通过为字段设定FieldOffset值来控制每个字段的物理位置。当然,每个字段所占用的内存空间也是根据Pack的值来确定。
Auto CLR在运行时会自动选择一个合适的内存布局。
让我们来尝试打破一下默认的设定,加入一些自己的内存布局控制。
C++ 代码:
#pragma pack(push)
#pragma pack(8)
struct Person
{
public :
LPCSTR name;
int age;
double weight;
};
#pragma pack(pop)
_declspec(dllexport) void _stdcall PrintPerson(Person person)
{
setlocale(LC_ALL,"chs");
wprintf(L"Person Name:%s, Age:%d, Weight:%f",person.name,person.age,person.weight);
}
C#包装类:
[StructLayout(LayoutKind.Explicit,Pack =
8 ,CharSet=CharSet.Ansi)]
public
struct Person
{
[FieldOffset(
0 )]
public
string Name;
[FieldOffset(
4 )]
public
int Age;
[FieldOffset(
8 )]
public
double Weight;
}
[DllImport("native.dll", EntryPoint = "PrintPerson", CharSet = CharSet.Ansi)]
public static extern void PrintPerson(Person person);
C#调用代码:
NativeWrapper.PrintPerson(
new Person() { Name =
"
汪熊
" , Age =
27 , Weight =
172.5 });
显示的控制结构体的布局主要是通过Pack(内存对齐)和控制每个字段相对于结构体的起始位置的偏移(FieldOffset)来实现。
这里需要补习一下内存对齐的知识,做C++的可能比较了解,但是C#程序员可能会了解得比较少。
内存对齐
因为每种数据类型所占的字节数不一样,出于某种目的,编绎器会将结构的字段进行内存对齐(即让每个字段的起始位置是某个数值的倍数,这样每个字段的所占内存空间会有某一种规律,这样号称会提高CPU的存储速度)。这种规律是:
如果是按N字节对齐: 1.如果字段的类型所需字节数大于N,则字段的起始位置必须为N的倍数
2.如果字段的类型所需字节数小于N,则字段的起始位置必须为类型所需字节数的倍数
其实可以总结为起始位置必须为N和字段类型所需字节数中较小数值的倍数
基于上面的例子:在对方式为8的情况下,
第一个String起始位置为0(引用类型保存的是地址,因此32位系统中所需字节数应该为4),
第二个Int起始位置为4(32位系统所需字节数中为4),
第三个double起始位置为8 (32位系统所需字节数中为8) 。
如果上面的struct定义中double类型的字段放在int前面定义,则会不一样,如下:
[StructLayout(LayoutKind.Explicit,Pack =
8 ,CharSet=CharSet.Ansi)]
public
struct Person
{
[FieldOffset(
0 )]
public
string Name;
[FieldOffset(
8 )]
public
double Weight;
[FieldOffset(
16 )]
public
int Age;
}
说明:当采用默认Sequence类型的内存布局时,默认是按结构体中占用最多内存字节数的变量进行对齐。(在上面的例子中为8,这和C++中是一致的) 其实这应该是比较浪费空间的(上例中Name相当于占用了8个字节,浪费了4个字节)。如果不需要和非托管的代码交互,Struct我觉得最好不要采用默认的物理内存布局(Sequence).
结构体中包含字符 结构体中如果包含字符,则需要考虑编码的问题了。StructLayout中的CharSet属性可以控制结构体中字符串是封送为LPSTR (Ansi)还是LPWSTR (Unicode)。 同时在结构字段中也可以显示的通过MarshalAs来设置。举例说明:
[StructLayout(LayoutKind.Explicit,Pack =
8 ,CharSet=CharSet.Ansi)]
public
struct Person
{
[FieldOffset(
0 )]
[MarshalAs(UnmanagedType.LPWStr)]
public
string Name;
[FieldOffset(
8 )]
public
double Weight;
[FieldOffset(
16 )]
public
int Age;
}
在非托管代码中修改结构体(封送结构体指针) 有时候我们需要传送一个结构体到非托管代码中,然后由非托管代码填充值,最后由托管代码读取结构体中的值。其实这和在托管代码中修改以引用方式传递的值类型是一个道理。 Example: C++ 代码:
#pragma pack(push)
#pragma pack(8)
struct Person
{
public :
LPCWSTR name;
double weight;
int age;
};
#pragma pack(pop)
_declspec(dllexport)
void _stdcall ModifyPerson(Person* person)
{
person->age =
25 ;
person->name = L
"
Updated
" ;
person->weight =
60 ;
}
C#包装类:
[StructLayout(LayoutKind.Explicit,Pack =
8 ,CharSet=CharSet.Ansi)]
public
struct Person
{
[FieldOffset(
0 )]
[MarshalAs(UnmanagedType.LPWStr)]
public
string Name;
[FieldOffset(
8 )]
public
double Weight;
[FieldOffset(
16 )]
public
int Age;
}
[DllImport(
"
native.dll
" , EntryPoint =
"
ModifyPerson
" , CharSet = CharSet.Ansi)]
public
static
extern
void ModifyPerson(
ref Person person);
C#调用代码:
Person person =
new Person() { Name =
"
汪熊
" , Age =
27 , Weight =
0 };
NativeWrapper.ModifyPerson(
ref person);
Console.WriteLine(
string .Format(
"
Name:{0},Age:{1},Weight:{2}
" , person.Name, person.Age, person.Weight));