【编程】C#中的结构体字节对齐
基本原则
- 结构体变量的首地址能够被其对齐字节数大小所整除
- 成员相对结构体首地址的偏移是【当前成员对齐数】的整数倍,不满足对前一成员填充字节
- 结构体总大小为【最大成员对齐数】的整数倍
成员对齐数,以下三者取最小值:
- 类型的非托管字节数
- Pack值
- 当前环境默认对齐字节数,例如Window 64位环境下默认是8字节
从实例看注意事项
例一
[StructLayout(LayoutKind.Sequential, Pack = 2)]
class Foo
{
// 整型 成员大小 计算下一成员首地址 下一成员对齐数 下一成员真实首地址
public sbyte i1; // 1 +0 = 1 [1] +0 = 1
public byte i2; // 1 +1 = 2 2 +0 = 2
public short i3; // 2 +2 = 4 2 +0 = 4
public ushort i4; // 2 +4 = 6 2 +0 = 6
public int i5; // 4 +6 = 10 2 +0 = 10
public uint i6; // 4 +10 = 14 2 +0 = 14
public long i7; // 8 +14 = 22 2 +0 = 22
public ulong i8; // 8 +22 = 30 2 +0 = 30
public nint i9; // [8] +30 = 38 2 +0 = 38
public nuint i10; // [8] +38 = 46 2 +0 = 46
// 浮点型
public float f1; // 4 +46 = 50 2 +0 = 50
public double f2; // 8 +50 = 58 2 +0 = 58
public decimal f3; // 16 +58 = 74 2 +0 = 74
// 其他
public bool b; // 4[1] +74 = 78 2 +0 = 78
public char c; // 1[2] +78 = 79 [2] +1 = 80
} //最大成员对齐数 结构体的非托管字节数
首先看 i2 的对齐数,这个结构体定义 Pack 值为 2,byte 类型占 1 字节,我的环境是 Windows 64 默认对齐字节数是 8,三者取最小,i2 的对齐数应该 1。因此第一行中 i1 是不需要对齐的。
之后看 i9 和 i10 的对齐数,这两个类型的非托管字节数和环境有关,我的环境中是 8 字节,然而结构体开头就定义了 Pack 值为 2,取最小值仍旧会取到 2。i8 占位后,下一成员首地址为 30,能够被 2 整除。因此这一行也不需要对齐。
最后看一下 bool 和 char,这是两个很特殊的存在。如果使用 sizeof
求 bool 的字节数为 1,求 char 的字节数为 2,但是使用 Marshal.SizeOf
求 bool 的非托管字节数为 4,求 char 的非托管字节数为 1。计算字节对齐要聚焦非托管字节数,即 bool 取 4,char 取 1。
例二
struct Bar
{
// 整型
public sbyte i1; // 1 +0 = 1 1 +0 = 1
public byte i2; // 1 +1 = 2 2 +0 = 2
public short i3; // 2 +2 = 4 2 +0 = 4
public ushort i4; // 2 +4 = 6 4 +2 = 8
public int i5; // 4 +8 = 12 4 +0 = 12
public uint i6; // 4 +12 = 16 8 +0 = 16
public long i7; // 8 +16 = 24 8 +0 = 24
public ulong i8; // 8 +24 = 32 8 +0 = 32
public nint i9; // 8 +32 = 40 8 +0 = 40
public nuint i10; // 8 +40 = 48 4 +0 = 48
// 浮点型
public float f1; // 4 +48 = 52 8 +4 = 56
public double f2; // 8 +56 = 64 [8] +0 = 64
public decimal f3; // 16 +64 = 80 4 +0 = 80
// 其他
public bool b; // 4[1] +80 = 84 1 +0 = 84
public char c; // 1[2] +84 = 85 [8] +3 = 88
}
第二个例子没有用类来定义结构体类型,也就没有 Pack 值这一说,那么大多数情况下,成员的对齐字节数就是成员的非托管字节数了,不过也要注意此时选取对齐数还是需要二者取最小的,因为环境默认对齐数是始终要考虑的。
我们可以将目光聚焦这个例子中的 f3,decimal 占用16字节,但是环境默认对齐数是 8,二者取小为 8,并不是 decimal 的非托管字节数。
然后我们看一下最后一行,结构体的最后一个成员没有下一个成员,但是仍旧有需要对齐的可能,原因可以回顾基本原则的第三条。这里结构中最大成员对齐数是 8,c 变量占位后,下一成员首地址为 85,并不是 8 的倍数,因此结构体的总字节数需要补齐到 88。
例三
struct Qux
{
public decimal quux; // 16 +0 = 16 1 +0 = 16
public byte quuux; // 1 +16 = 17 [8] +7 = 24
}
注意结构体中最大成员对齐数包括第一个成员,例如本例中第一个成员的对齐数是 8,所以结构体最后要补 7 位,使结构体大小等于 8 的倍数。
例四
[StructLayout(LayoutKind.Sequential, Pack = 16)]
class FooBar
{
public byte foo1; // 1 +0 = 1 [8] +7 = 8
public decimal foo2; // 16 +8 = 24 8 +0 = 24
public double foo3; // 8 +24 = 32 [8] +0 = 32
public decimal foo4; // 16 +32 = 48 4 +0 = 48
public int foo5; // 4 +48 = 52 [8] +4 = 56
}
如果不考虑环境的默认对齐字节数,本例十分容易出错。这里 Pack 的 16 在 Windows 64 下永远取不到,因为 环境默认参数为 8 字节,始终是更小的值。
例五
[StructLayout(LayoutKind.Sequential)]
class BarFoo
{
public bool bar1; // 4 +0 = 4 1 +0 = 4
public char bar2; // 1 +4 = 5 4 +3 = 8
public bool bar3; // 4 +8 = 12 1 +0 = 12
public char bar4; // 1 +12 = 13 4 +3 = 16
public int bar5; // 4 +16 = 20 1 +0 = 20
public char bar6; // 1 +20 = 21 4 +3 = 24
public bool bar7; // 4 +24 = 28 4 +0 = 28
}
这个例子如果把 bool 字节数视为 1,把 char 字节数视为 2,就会一直迷惑一直错了。
验证函数
private void Start()
{
Debug.Log("Class Foo:" + Marshal.SizeOf<Foo>());
Debug.Log("Struct Bar:" + Marshal.SizeOf<Bar>());
Debug.Log("Qux:" + Marshal.SizeOf<Qux>());
Debug.Log("FooBar: " + Marshal.SizeOf<FooBar>());
Debug.Log("BarFoo: " + Marshal.SizeOf<BarFoo>());
Debug.Log("sbyte:" + sizeof(sbyte) + " | " + "byte:" + sizeof(byte) + " | " + "short:" + sizeof(short) + " | " + "ushort:" + sizeof(ushort)
+ " | " + "int:" + sizeof(int) + " | " + "unit:" + sizeof(uint) + " | " + "long:" + sizeof(long) + " | " + "ulong:" + sizeof(ulong)
+ " | " + "float:" + sizeof(float) + " | " + "double:" + sizeof(double) + " | " + "decimal:" + sizeof(decimal)
+ " | " + "bool:" + sizeof(bool) + " | " + "char:" + sizeof(char));
Debug.Log("nint:" + Marshal.SizeOf<nint>());
Debug.Log("nuint:" + Marshal.SizeOf<nuint>());
Debug.Log("sbyte:" + Marshal.SizeOf<sbyte>() + " | " + "byte:" + Marshal.SizeOf<byte>() + " | " + "short:" + Marshal.SizeOf<short>() + " | " + "ushort:" + Marshal.SizeOf<ushort>()
+ " | " + "int:" + Marshal.SizeOf<int>() + " | " + "unit:" + Marshal.SizeOf<uint>() + " | " + "long:" + Marshal.SizeOf<long>() + " | " + "ulong:" + Marshal.SizeOf<ulong>()
+ " | " + "float:" + Marshal.SizeOf<float>() + " | " + "double:" + Marshal.SizeOf<double>() + " | " + "decimal:" + Marshal.SizeOf<decimal>()
+ " | " + "bool:" + Marshal.SizeOf<bool>() + " | " + "char:" + Marshal.SizeOf<char>());
}
运行结果:
因为Unity比较熟练就直接在Unity里验证了,类似的验证方法在其他C#环境中也很容易实现。
碎碎念
事实上单独理解同一种环境下同一种语言的字节对齐没太大意义,我意思是针对我这种不怎么接触硬件相关的程序员而言。我遇到的问题经常是同一种结构在 C++ 和 C# 工程之间传递时成员变量数值对应不上,或者从服务端拿到的结构数据解析异常。后续有时间也会补一下 C++ 的字节对齐相关,这种东西还是记录一下比较好,同一个 bool 在不同语言不同环境下一会儿字节数是 1 一会儿是 4,真的很容易记错。