理解 .NET 结构体字段的内存布局

目录

  • 前言
  • 基本概念
  • 结构体的默认字段布局
    • 对齐
    • [64 位系统与 32 位系统的对齐要求差异](#64 位系统与 32 位系统的对齐要求差异)
    • [默认字段布局中 对齐要求 与 偏移量 的关系](#默认字段布局中 对齐要求 与 偏移量 的关系)
    • 填充
  • 包含引用类型字段的结构体的默认字段布局
  • [用 StructLayoutAttribute 控制字段布局](#用 StructLayoutAttribute 控制字段布局)
    • LayoutKind.Sequential
      • [Pack 为 0 时等于默认布局](#Pack 为 0 时等于默认布局)
      • [Pack 不为 0 时,取 Pack 和 字段类型大小 的较小值](#Pack 不为 0 时,取 Pack 和 字段类型大小 的较小值)
      • [Pack 设置为 1 时,会形成密集的字段布局](#Pack 设置为 1 时,会形成密集的字段布局)
      • [Pack 不为 0 的结构体作为其他结构体字段时](#Pack 不为 0 的结构体作为其他结构体字段时)
    • LayoutKind.Explicit
      • [Pack 为 0 时,结构体按照最大字段默认对齐要求对齐](#Pack 为 0 时,结构体按照最大字段默认对齐要求对齐)
      • [Pack 不为 0 时,结构体实例按照 Pack 与 最大字段对齐要求 的较小值对齐](#Pack 不为 0 时,结构体实例按照 Pack 与 最大字段对齐要求 的较小值对齐)
      • [将 Pack 属性设置为 1 可以消除结构体实例的末尾填充](#将 Pack 属性设置为 1 可以消除结构体实例的末尾填充)
      • [Pack 属性不为 0 的结构体作为其他结构体字段时](#Pack 属性不为 0 的结构体作为其他结构体字段时)
    • LayoutKind.Auto
  • 作为数组元素时的结构体实例

前言

大部分情况下我们并不需要关心结构体字段的内存布局,但是在一些特殊情况下,比如性能优化、和非托管代码交互、对结构体进行序列化等场景下,了解字段的内存布局是非常重要的。

本文写作时 最新的 .NET 正式版是 .NET 9,以后的版本不保证本文内容的准确性,仅供参考。

本文将介绍 .NET 中结构体字段的内存布局,包括字段的对齐(Alignment)、填充(Padding)以及如何使用 StructLayoutAttribute 来控制字段的内存布局。

对齐的目的是为了 CPU 访问内存的效率,64 位系统和 32 位系统中对齐要求存在差异,下文如果没有特别说明,均指 64 位系统。

填充则是为了满足对齐要求而在字段之间或结构体末尾添加的额外字节。

结构体的对其规则同时适用于栈上和堆上的结构体结构体实例,方便起见,大部分例子将使用栈上结构体实例来演示。

一些资料是从 字段的偏移量(offset)为出发点来介绍字段的内存布局的,但笔者认为从字段的 内存地址 出发更容易理解。

由于一些资料并没有找到明确的官方的解释,笔者是在实验和推导的基础上总结出这些规则的,可能会有不准确的地方,欢迎读者在评论区指出。

本文虽然没有直接介绍引用类型的字段布局,但引用类型实例的字段的内存布局概念与结构体实例的内存布局是相同的。不同之处在于引用类型的默认布局是 LayoutKind.Auto,而结构体的默认布局是 LayoutKind.Sequential。读者可以自己尝试观察引用类型实例字段的内存布局。

本文将使用下面的方法来观察字段的内存地址:

csharp 复制代码
// 打印日志头
void PrintPointerHeader()
{
    Console.WriteLine(
        $"| {"Expr",-15} | {"Address",-15} | {"Size",-4} | {"AlignedBySize",-13} | {"Addr/Size",-12} |");
}

// 打印指针的详细信息
unsafe void PrintPointerDetails<T>(
    T* ptr,
    [CallerArgumentExpression("ptr")] string? pointerExpr = null)
    where T : unmanaged
{
    ulong addressValue = (ulong)ptr;
    ulong typeSize = (ulong)sizeof(T);

    decimal addressDivBySize = addressValue / (decimal)typeSize;
    bool isAlignedBySize = addressValue % typeSize == 0;

    Console.WriteLine(
        $"| {pointerExpr,-15} | {addressValue,-15} | {typeSize,-4} | {isAlignedBySize,-13} | {addressDivBySize,-12:0.##} |"
    );
}

并使用 ObjectLayoutInspector 这个开源库来观察字段的内存布局。

项目地址:https://github.com/SergeyTeplyakov/ObjectLayoutInspector

nuget 包地址:https://www.nuget.org/packages/ObjectLayoutInspector

csharp 复制代码
dotnet add package ObjectLayoutInspector --version 0.1.4

基本概念

以下是理解结构体字段布局的几个关键点:

  • 字段顺序 :字段在结构体实例中的排列顺序,默认按声明顺序排列,但可以通过 StructLayoutAttribute 来控制。

  • 对齐(Alignment):对齐需要分成三部分理解:

    • 字段的对齐要求(alignment requirement):指字段在内存中的地址必须是其对齐要求的倍数。对于基元类型(primitive types),对齐要求默认等于其大小,非基元类型的对齐要求取决于结构体中最大字段的对齐要求。
    • 结构体实例的大小:必须是结构体对齐要求的整数倍。
    • 结构体实例的起始地址:在 64 位系统中,数据的地址按 8 字节 对齐有利于提升 CPU 的访问效率,32 位系统中则为 4 字节对齐。
  • 填充(Padding):为了满足对齐要求,runtime 可能会在结构体实例字段之间及末尾插入填充字节。这些填充字节不会被显式声明,但会影响字段在内存中的实际布局。

结构体的默认字段布局

对齐

字段默认的对齐要求是类型的大小。例如,int 类型的字段需要在 4 字节对齐边界(alignment boundary)上,而 double 类型的字段需要在 8 字节对齐边界上。如果字段类型并非基元类型(primitive types),则对齐要求取决于结构体中最大字段的对齐要求。对齐要求为 2 的整数次幂,例如 1、2、4、8 等。最大对齐要求为 8 字节。

注意:decimal 不属于基元类型,目前版本中由三个字段组成,实例大小为 16 字节,按 8 字节对齐。

bash 复制代码
Type layout for 'Decimal'
Size: 16 bytes. Paddings: 0 bytes (%0 of empty space)
|===============================|
|   0-3: Int32 _flags (4 bytes) |
|-------------------------------|
|   4-7: UInt32 _hi32 (4 bytes) |
|-------------------------------|
|  8-15: UInt64 _lo64 (8 bytes) |
|===============================|

下面是一个简单的示例,展示了结构体字段的默认布局:

csharp 复制代码
using System.Runtime.CompilerServices;

var foo = new Foo();
var bar = new Bar();
var baz = new Baz();

unsafe
{
    PrintPointerHeader();

    PrintPointerDetails(&foo);
    PrintPointerDetails(&foo.a);
    PrintPointerDetails(&foo.b);

    PrintPointerDetails(&bar);
    PrintPointerDetails(&bar.foo);
    PrintPointerDetails(&bar.foo.a);
    PrintPointerDetails(&bar.foo.b);

    fixed (Foo* bazFooPtr = &baz.foo)
    {
        PrintPointerDetails(bazFooPtr);
        PrintPointerDetails(&bazFooPtr->a);
        PrintPointerDetails(&bazFooPtr->b);
    }
}

struct Foo
{
    public int a;
    public long b;
}

struct Bar
{
    public Foo foo;
}

class Baz
{
    public Foo foo;
}

输出结果如下:

bash 复制代码
| Expr            | Address         | Size | AlignedBySize | Addr/Size    |
| &foo            | 6095528264      | 16   | False         | 380970516.5  |
| &foo.a          | 6095528264      | 4    | True          | 1523882066   |
| &foo.b          | 6095528272      | 8    | True          | 761941034    |
| &bar            | 6095528248      | 16   | False         | 380970515.5  |
| &bar.foo        | 6095528248      | 16   | False         | 380970515.5  |
| &bar.foo.a      | 6095528248      | 4    | True          | 1523882062   |
| &bar.foo.b      | 6095528256      | 8    | True          | 761941032    |
| bazFooPtr       | 12885617264     | 16   | True          | 805351079    |
| &bazFooPtr->a   | 12885617264     | 4    | True          | 3221404316   |
| &bazFooPtr->b   | 12885617272     | 8    | True          | 1610702159   |

首先看 Foo 结构体,它有两个字段 ab,分别是 intlong 类型,对齐要求分别是 4 字节和 8 字节。

所以 Foo 实例在栈上的地址按照 8 字节 对齐(6095528264 / 8 = 761941033)。

a 字段是 foo 的第一个字段,它的地址也就是 foo 的起始地址,自然也满足 int 的对齐要求(6095528264 / 4 = 1523882066)。

b 字段是 foo 的第二个字段,它的地址为 6095528272,满足 long 的对齐要求(6095528272 / 8 = 761941032)。

Bar 结构体包含一个 Foo 类型的字段 foo,它的对齐要求也是 8 字节(取最大字段 long 的对齐要求),所以 bar 的地址也是按照 8 字节对齐(6095528248 / 8 = 761941031)。bar.foo.abar.foo.b 的地址也满足各自的对齐要求。

Baz 类包含一个 Foo 类型的字段 foo,由于 Baz 是引用类型,所以它的实例在堆上分配内存。BazFoo 类型字段也依旧需要满足 8 字节对齐要求(12885617264 / 8 = 1610702158)。

64 位系统与 32 位系统的对齐要求差异

在 64 位系统中,结构体实例的起始地址默认按 8 字节对齐。

而在 32 位系统中,结构体实例的起始地址默认按 4 字节对齐。经笔者测试,CPU 为 intel 时 只按 4 字节对齐,CPU 为 AMD 时 如果结构体包含了 8 字节对齐的字段,则按 8 字节对齐,否则按 4 字节对齐。

首先在 64 位系统上运行下面的代码:

csharp 复制代码
using System.Runtime.CompilerServices;
using ObjectLayoutInspector;

unsafe
{
    var foo = new Foo();
    var bar = new Bar();

    // 方法 PrintPointerHeader 和 PrintPointerDetails 在前言部分已经定义
    PrintPointerHeader();
    PrintPointerDetails(&foo.a);
    PrintPointerDetails(&foo.b);
    PrintPointerDetails(&foo.c);
    PrintPointerDetails(&bar.d);
    PrintPointerDetails(&bar.e);
    PrintPointerDetails(&bar.f);
}

TypeLayout.PrintLayout<Foo>();
TypeLayout.PrintLayout<Bar>();

struct Foo
{
    public int a;
    public long b;
    public byte c;
}

struct Bar
{
    public int d;
    public int e;
    public byte f;
}

输出结果如下:

bash 复制代码
| Expr            | Address         | Size | AlignedBySize | Addr/Size    |
| &foo.a          | 985964996520    | 4    | True          | 246491249130 |
| &foo.b          | 985964996528    | 8    | True          | 123245624566 |
| &foo.c          | 985964996536    | 1    | True          | 985964996536 |
| &bar.d          | 985964996504    | 4    | True          | 246491249126 |
| &bar.e          | 985964996508    | 4    | True          | 246491249127 |
| &bar.f          | 985964996512    | 1    | True          | 985964996512 |
Type layout for 'Foo'
Size: 24 bytes. Paddings: 11 bytes (%45 of empty space)
|==========================|
|   0-3: Int32 a (4 bytes) |
|--------------------------|
|   4-7: padding (4 bytes) |
|--------------------------|
|  8-15: Int64 b (8 bytes) |
|--------------------------|
|    16: Byte c (1 byte)   |
|--------------------------|
| 17-23: padding (7 bytes) |
|==========================|


Type layout for 'Bar'
Size: 12 bytes. Paddings: 3 bytes (%25 of empty space)
|==========================|
|   0-3: Int32 d (4 bytes) |
|--------------------------|
|   4-7: Int32 e (4 bytes) |
|--------------------------|
|     8: Byte f (1 byte)   |
|--------------------------|
|  9-11: padding (3 bytes) |
|==========================|

可以看到,FooBar 结构体的实例大小分别为 24 字节和 12 字节,且它们的起始地址都满足 8 字节对齐要求。

在 Windows 环境中,如果安装了 x86 版本的 .NET SDK,可以在 csproj 文件中添加以下属性来让项目运行在 32 位的环境中:

xml 复制代码
<PropertyGroup>
    <RuntimeIdentifier>win-x86</RuntimeIdentifier>
</PropertyGroup>

下面是 intel CPU 的输出结果:

bash 复制代码
| Expr            | Address         | Size | AlignedBySize | Addr/Size    |
| &foo.a          | 43511772        | 4    | True          | 10877943     |
| &foo.b          | 43511780        | 8    | False         | 5438972.5    |
| &foo.c          | 43511788        | 1    | True          | 43511788     |
| &bar.d          | 43511760        | 4    | True          | 10877940     |
| &bar.e          | 43511764        | 4    | True          | 10877941     |
| &bar.f          | 43511768        | 1    | True          | 43511768     |
Type layout for 'Foo'
Size: 24 bytes. Paddings: 11 bytes (%45 of empty space)
|==========================|
|   0-3: Int32 a (4 bytes) |
|--------------------------|
|   4-7: padding (4 bytes) |
|--------------------------|
|  8-15: Int64 b (8 bytes) |
|--------------------------|
|    16: Byte c (1 byte)   |
|--------------------------|
| 17-23: padding (7 bytes) |
|==========================|


Type layout for 'Bar'
Size: 12 bytes. Paddings: 3 bytes (%25 of empty space)
|==========================|
|   0-3: Int32 d (4 bytes) |
|--------------------------|
|   4-7: Int32 e (4 bytes) |
|--------------------------|
|     8: Byte f (1 byte)   |
|--------------------------|
|  9-11: padding (3 bytes) |
|==========================|

FooBar 结构体的起始地址和字段地址都只满足 4 字节对齐要求(43511772 / 4 = 10877943),而不是 8 字节对齐要求。

下面是 AMD CPU 的输出结果:

csharp 复制代码
运行上述代码,输出结果如下:

```bash
| Expr            | Address         | Size | AlignedBySize | Addr/Size    |
| &foo.a          | 47706560        | 4    | True          | 11926640     |
| &foo.b          | 47706568        | 8    | True          | 5963321      |
| &foo.c          | 47706576        | 1    | True          | 47706576     |
| &bar.d          | 47706548        | 4    | True          | 11926637     |
| &bar.e          | 47706552        | 4    | True          | 11926638     |
| &bar.f          | 47706556        | 1    | True          | 47706556     |
Type layout for 'Foo'
Size: 24 bytes. Paddings: 11 bytes (%45 of empty space)
|==========================|
|   0-3: Int32 a (4 bytes) |
|--------------------------|
|   4-7: padding (4 bytes) |
|--------------------------|
|  8-15: Int64 b (8 bytes) |
|--------------------------|
|    16: Byte c (1 byte)   |
|--------------------------|
| 17-23: padding (7 bytes) |
|==========================|


Type layout for 'Bar'
Size: 12 bytes. Paddings: 3 bytes (%25 of empty space)
|==========================|
|   0-3: Int32 d (4 bytes) |
|--------------------------|
|   4-7: Int32 e (4 bytes) |
|--------------------------|
|     8: Byte f (1 byte)   |
|--------------------------|
|  9-11: padding (3 bytes) |
|==========================|

Foo 的起始地址仍然满足 8 字节对齐要求,但 Bar 的起始地址不再满足 8 字节对齐要求(47706548 / 8 = 5963318.5),而是满足 4 字节对齐要求(47706548 / 4 = 11926637)。

默认字段布局中 对齐要求 与 偏移量 的关系

偏移量(offset)是指字段相对于结构体实例起始地址的距离,决定了字段在内存中的位置。

偏移量的值取决于对齐要求和字段的顺序,会在未被顺序在前的字段占用的内存空间中取对齐要求的最小整数倍。

下面几个设计确保了不管结构体实例的起始地址如何,任意一个字段只要给定一个满足对齐要求的偏移量,就可以满足该字段的对齐要求:

  • 对齐要求总是 2 的整数次幂。
  • 实例的起始地址(按 8 字节 对齐)总是满足最大字段的对齐要求。
  • 偏移量的值是对齐要求的整数倍

下面做一个简单的推导来帮助读者理解:

假设结构体中最大字段的对齐要求为 2^m(m 为 <= 8 的非负整数),则 runtime 会保证结构体实例的起始地址也是 2^m 的整数倍,可记作 2^m * k(k为非负整数)。

若某字段的对齐要求为 2^n(n≤m),其偏移量必为 2^n 的整数倍,记为 2^n * f(f为非负整数)。

则该字段实际地址为:

结构体起始地址 + 字段偏移量 = (2^m * k) + (2^n * f)

由于 2^m 必定可以被 2^n 整除(因为 n≤m),所以无论 k 和 f 取何值,上述字段地址总能被 2^n 整除。这就保证了该字段的地址总是满足其对齐要求。

因此,只要给每个字段的 偏移量 选择其 对齐要求 的整数倍,就能保证结构体任何实例、任意字段的地址都天然对齐,而无需依赖结构体起始地址的额外信息。

csharp 复制代码
unsafe
{
    var foo = new Foo();

    var addr = (ulong)&foo;

    Console.WriteLine($"a offset: {(ulong)&foo.a - addr}");
    Console.WriteLine($"b offset: {(ulong)&foo.b - addr}");
    Console.WriteLine($"c offset: {(ulong)&foo.c - addr}");
}

struct Foo
{
    public int a;
    public long b;
    public byte c;
}

输出结果如下:

bash 复制代码
a offset: 0
b offset: 8
c offset: 16

填充

填充(Padding)分为两部分:

  1. 字段之间的填充:为了满足对齐要求,.NET 可能会在字段之间插入填充字节。字段之间的填充由字段的偏移量决定。

  2. 结构体末尾的填充:为了确保结构体的大小是最大字段对齐要求的倍数,.NET 可能会在结构体末尾添加填充字节。末尾填充保证了数组中连续的结构体实例在内存中也满足对齐要求。

借助 ObjectLayoutInspector 库,我们可以观察到结构体的内存布局,包括字段之间的填充和结构体末尾的填充。

csharp 复制代码
using ObjectLayoutInspector;

TypeLayout.PrintLayout<Foo>();
TypeLayout.PrintLayout<Bar>();

struct Foo
{
    public int a;
    public long b;
    public byte c;
}

struct Bar
{
    public byte c;
    public int a;
    public long b;
}

输出结果如下:

bash 复制代码
Type layout for 'Foo'
Size: 24 bytes. Paddings: 11 bytes (%45 of empty space)
|==========================|
|   0-3: Int32 a (4 bytes) |
|--------------------------|
|   4-7: padding (4 bytes) |
|--------------------------|
|  8-15: Int64 b (8 bytes) |
|--------------------------|
|    16: Byte c (1 byte)   |
|--------------------------|
| 17-23: padding (7 bytes) |
|==========================|


Type layout for 'Bar'
Size: 16 bytes. Paddings: 3 bytes (%18 of empty space)
|==========================|
|     0: Byte c (1 byte)   |
|--------------------------|
|   1-3: padding (3 bytes) |
|--------------------------|
|   4-7: Int32 a (4 bytes) |
|--------------------------|
|  8-15: Int64 b (8 bytes) |
|==========================|

FooBar 虽然包含了相同类型的字段,但由于字段的顺序不同,导致它们的内存布局和填充字节数量也不同。Foo 需要在在末尾添加 7 字节的填充才能满足其大小是最大字段对齐要求的倍数。

包含引用类型字段的结构体的默认字段布局

如果结构体包含引用类型字段,则该结构体的默认布局为 LayoutKind.Auto

csharp 复制代码
using ObjectLayoutInspector;

TypeLayout.PrintLayout<Foo>();

struct Foo
{
    public int a;
    public string b;
    public byte c;
}
bash 复制代码
Type layout for 'Foo'
Size: 16 bytes. Paddings: 3 bytes (%18 of empty space)
|===========================|
|   0-7: String b (8 bytes) |
|---------------------------|
|  8-11: Int32 a (4 bytes)  |
|---------------------------|
|    12: Byte c (1 byte)    |
|---------------------------|
| 13-15: padding (3 bytes)  |
|===========================|

StructLayoutAttribute 控制字段布局

在某些情况下,我们可能需要控制结构体字段的内存布局,以满足特定的性能要求或与非托管代码交互。可以使用 StructLayoutAttribute 特性来控制结构体的内存布局。

StructLayoutAttribute 有两个重要的属性:

  • LayoutKind:指定结构体的布局方式,可以是 Sequential(按声明顺序排列)、Explicit(显式指定字段偏移量)或 Auto(自动布局)。

  • Pack:指定结构体及其字段的对齐要求,其值必须为 0、1、2、4、8、16、32、64 或 128,否则无法编译成功,默认值为 0。** 指定 Pack > 8 时, 等效于 Pack = 8,因为目前版本没有任何类型的对齐要求超过 8 字节。**

Pack 属性在 LayoutKind.Auto 布局中无效。在 LayoutKind.Sequential 布局中,Pack 属性用于指定字段的对齐要求及结构体实例的对齐要求;在 LayoutKind.Explicit 布局中,Pack 属性用于结构体的对齐要求,会影响结构体实例的末尾填充。

LayoutKind.Sequential

Pack 为 0 时等于默认布局

csharp 复制代码
using System.Runtime.InteropServices;
using ObjectLayoutInspector;

TypeLayout.PrintLayout<Foo>();

[StructLayout(LayoutKind.Sequential, Pack = 0)]
struct Foo
{
    public int a;
    public long b;
    public byte c;
}
bash 复制代码
Type layout for 'Foo'
Size: 24 bytes. Paddings: 11 bytes (%45 of empty space)
|==========================|
|   0-3: Int32 a (4 bytes) |
|--------------------------|
|   4-7: padding (4 bytes) |
|--------------------------|
|  8-15: Int64 b (8 bytes) |
|--------------------------|
|    16: Byte c (1 byte)   |
|--------------------------|
| 17-23: padding (7 bytes) |
|==========================|

Pack 不为 0 时,取 Pack 和 字段类型大小 的较小值

Pack 设置为 4 时,intlong 字段的对齐要求都将被设置为 4 字节,而 byte 字段的对齐要求仍然是 1 字节。结构体的对齐要求是 4 字节。

csharp 复制代码
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using ObjectLayoutInspector;

{
    var foo = new Foo();

    // 方法 PrintPointerHeader 和 PrintPointerDetails 在前言部分已经定义
    PrintPointerHeader();
    PrintPointerDetails(&foo.a);
    PrintPointerDetails(&foo.b);
    PrintPointerDetails(&foo.c);
}

TypeLayout.PrintLayout<Foo>();

[StructLayout(LayoutKind.Sequential, Pack = 4)]
struct Foo
{
    public int a;
    public long b;
    public byte c;
}
bash 复制代码
| Expr            | Address         | Size | AlignedBySize | Addr/Size    |
| &foo.a          | 782597876240    | 4    | True          | 195649469060 |
| &foo.b          | 782597876244    | 8    | False         | 97824734530.5 |
| &foo.c          | 782597876252    | 1    | True          | 782597876252 |
Type layout for 'Foo'
Size: 16 bytes. Paddings: 3 bytes (%18 of empty space)
|==========================|
|   0-3: Int32 a (4 bytes) |
|--------------------------|
|  4-11: Int64 b (8 bytes) |
|--------------------------|
|    12: Byte c (1 byte)   |
|--------------------------|
| 13-15: padding (3 bytes) |
|==========================|

结构体实例的起始地址按 8 字节 对齐(782597876240 / 8 = 97824734530)。但其大小取满足 4 字节对齐要求的最小整数倍 16 字节( 末尾字段 c 的偏移量为 12,最小只能取到 16),并在末尾添加 3 字节的填充。

Pack 设置为 1 时,会形成密集的字段布局

Pack 设置为 1 时,所有字段的对齐要求都将被设置为 1 字节,这意味着结构体实例将按照 1 字节对齐。此时,结构体实例的字段将紧密排列,不会有额外的填充字节。

csharp 复制代码
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using ObjectLayoutInspector;

var foo = new Foo
{
    a = 1,
    b = 2,
    c = 3
};

unsafe
{
    // 方法 PrintPointerHeader 和 PrintPointerDetails 在前言部分已经定义
    PrintPointerHeader();
    PrintPointerDetails(&foo.a);
    PrintPointerDetails(&foo.b);
    PrintPointerDetails(&foo.c);
}

TypeLayout.PrintLayout<Foo>();

[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct Foo
{
    public int a;
    public long b;
    public byte c;
}
bash 复制代码
| Expr            | Address         | Size | AlignedBySize | Addr/Size    |
| &foo.a          | 302463314288    | 4    | True          | 75615828572  |
| &foo.b          | 302463314292    | 8    | False         | 37807914286.5 |
| &foo.c          | 302463314300    | 1    | True          | 302463314300 |
Type layout for 'Foo'
Size: 13 bytes. Paddings: 0 bytes (%0 of empty space)
|==========================|
|   0-3: Int32 a (4 bytes) |
|--------------------------|
|  4-11: Int64 b (8 bytes) |
|--------------------------|
|    12: Byte c (1 byte)   |
|==========================|

起始地址为 8 的倍数(302463314288 / 8 = 37807914286),但结构体实例的大小变为 13 字节,没有末尾填充。

Pack 不为 0 的结构体作为其他结构体字段时

如果外层的结构体采用默认字段布局则,则其实例的起始地址取决嵌套结构体的最大字段默认对齐要求,其实例大小取决该结构体的最大字段对齐要求。

csharp 复制代码
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using ObjectLayoutInspector;

unsafe
{
    PrintPointerHeader();
    PrintPointerDetails(&bar.foo.a);
    PrintPointerDetails(&bar.foo.b);
    PrintPointerDetails(&bar.foo.c);
    PrintPointerDetails(&bar.d);
}

TypeLayout.PrintLayout<Bar>();

[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct Foo
{
    public int a;
    public long b;
    public byte c;
}

struct Bar
{
    public Foo foo;
    public int d;
}
bash 复制代码
| Expr            | Address         | Size | AlignedBySize | Addr/Size    |
| &bar.foo.a      | 724703897336    | 4    | True          | 181175974334 |
| &bar.foo.b      | 724703897340    | 8    | False         | 90587987167.5 |
| &bar.foo.c      | 724703897348    | 1    | True          | 724703897348 |
| &bar.d          | 724703897352    | 4    | True          | 181175974338 |
Type layout for 'Bar'
Size: 20 bytes. Paddings: 3 bytes (%15 of empty space)
|==============================|
|  0-12: Foo foo (13 bytes)    |
| |==========================| |
| |   0-3: Int32 a (4 bytes) | |
| |--------------------------| |
| |  4-11: Int64 b (8 bytes) | |
| |--------------------------| |
| |    12: Byte c (1 byte)   | |
| |==========================| |
|------------------------------|
| 13-15: padding (3 bytes)     |
|------------------------------|
| 16-19: Int32 d (4 bytes)     |
|==============================|

在上面的例子中,Bar 结构体包含一个 Foo 类型的字段 foo

Bar 的实例起始地址也满足 8 字节对齐要求(724703897336 / 8 = 90587987167)。

foo 的对齐要求为 1 字节, d 的对齐要求为 4 字节,所以 Bar 的实例大小为 20 字节(4 的整数倍),并在food 之间添加了 3 字节的填充。

LayoutKind.Explicit

Pack 为 0 时,结构体按照最大字段默认对齐要求对齐

Explicit 布局中,我们需要显式指定每个字段的偏移量。使用 FieldOffsetAttribute 来指定字段的偏移量。此时偏移量可以是任意值,甚至允许重叠字段。

此时虽然字段地址可能由于是任意值而不满足对齐要求,但结构体实例的起始地址依旧按 8 字节 对齐,且结构体实例的大小是最大字段对齐要求的整数倍。

csharp 复制代码
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using ObjectLayoutInspector;

var foo = new Foo
{
    a = 1,
    b = 2,
    c = 3
};

unsafe
{
    // 方法 PrintPointerHeader 和 PrintPointerDetails 在前言部分已经定义
    PrintPointerHeader();
    PrintPointerDetails(&foo.a);
    PrintPointerDetails(&foo.b);
    PrintPointerDetails(&foo.c);
}

TypeLayout.PrintLayout<Foo>();

[StructLayout(LayoutKind.Explicit, Pack = 0)]

struct Foo
{
    [FieldOffset(0)]
    public int a;
    [FieldOffset(3)]
    public long b;
    [FieldOffset(11)]
    public byte c;
}
bash 复制代码
| Expr            | Address         | Size | AlignedBySize | Addr/Size    |
| &foo.a          | 6095151432      | 4    | True          | 1523787858   |
| &foo.b          | 6095151435      | 8    | False         | 761893929.38 |
| &foo.c          | 6095151443      | 1    | True          | 6095151443   |
Type layout for 'Foo'
Size: 16 bytes. Paddings: 4 bytes (%25 of empty space)
|==========================|
|   0-3: Int32 a (4 bytes) |
|--------------------------|
|  3-10: Int64 b (8 bytes) |
|--------------------------|
|    11: Byte c (1 byte)   |
|--------------------------|
| 12-15: padding (4 bytes) |
|==========================|

上面例子中,Foo 结构体的字段 abc 的偏移量分别为 0、3 和 11。可以看到,虽然字段的地址不再满足对齐要求,但结构体实例的起始地址仍然按 8 字节 对齐(6095151432 / 8 = 761893929),且结构体实例的大小为 16 字节(最大字段对齐要求的整数倍),末尾添加了 4 字节的填充。

如果将 c 字段的偏移量改为 16,则结构体实例的大小将变为 24 字节,并且会在末尾添加 7 字节的填充。

csharp 复制代码
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using ObjectLayoutInspector;

var foo = new Foo
{
    a = 1,
    b = 2,
    c = 3
};

unsafe
{
    PrintPointerHeader();
    PrintPointerDetails(&foo.a);
    PrintPointerDetails(&foo.b);
    PrintPointerDetails(&foo.c);
}

TypeLayout.PrintLayout<Foo>();
bash 复制代码
| Expr            | Address         | Size | AlignedBySize | Addr/Size    |
| &foo.a          | 6166536512      | 4    | True          | 1541634128   |
| &foo.b          | 6166536515      | 8    | False         | 770817064.38 |
| &foo.c          | 6166536528      | 1    | True          | 6166536528   |
Type layout for 'Foo'
Size: 24 bytes. Paddings: 12 bytes (%50 of empty space)
|==========================|
|   0-3: Int32 a (4 bytes) |
|--------------------------|
|  3-10: Int64 b (8 bytes) |
|--------------------------|
| 11-15: padding (5 bytes) |
|--------------------------|
|    16: Byte c (1 byte)   |
|--------------------------|
| 17-23: padding (7 bytes) |
|==========================|

Pack 不为 0 时,结构体实例按照 Pack 与 最大字段对齐要求 的较小值对齐

Explicit 布局中,如果设置了 Pack 属性且不为 0,则结构体实例将按照 Pack 的值对齐。字段的偏移量仍然可以是任意值,但结构体实例的大小将受到 Pack 属性的影响。

csharp 复制代码
var foo = new Foo
{
    a = 1,
    b = 2,
    c = 3
};

unsafe
{
    PrintPointerHeader();
    PrintPointerDetails(&foo.a);
    PrintPointerDetails(&foo.b);
    PrintPointerDetails(&foo.c);
}

TypeLayout.PrintLayout<Foo>();

[StructLayout(LayoutKind.Explicit, Pack = 4)]
struct Foo
{
    [FieldOffset(0)]
    public int a;
    [FieldOffset(5)]
    public long b;
    [FieldOffset(16)]
    public byte c;
}
bash 复制代码
| Expr            | Address         | Size | AlignedBySize | Addr/Size    |
| &foo.a          | 6122676544      | 4    | True          | 1530669136   |
| &foo.b          | 6122676549      | 8    | False         | 765334568.63 |
| &foo.c          | 6122676560      | 1    | True          | 6122676560   |
Type layout for 'Foo'
Size: 20 bytes. Paddings: 7 bytes (%35 of empty space)
|==========================|
|   0-3: Int32 a (4 bytes) |
|--------------------------|
|     4: padding (1 byte)  |
|--------------------------|
|  5-12: Int64 b (8 bytes) |
|--------------------------|
| 13-15: padding (3 bytes) |
|--------------------------|
|    16: Byte c (1 byte)   |
|--------------------------|
| 17-19: padding (3 bytes) |
|==========================|

在上面的例子中,由于 Pack 属性设置为 4,与 long 类型的 8 字节 比较则结构体实例应按照 4 字节 对齐。因为 c 的偏移量为 16,所以 Foo 的大小在取值此时符合条件的 4 的最小整数倍后变为 20 字节,并在末尾添加了 3 字节 的填充。

改成 Pack = 128 后,结构体实例的大小按照最大字段默认对齐要求 8 字节 对齐。

csharp 复制代码
var foo = new Foo
{
    a = 1,
    b = 2,
    c = 3
};

unsafe
{
    PrintPointerHeader();
    PrintPointerDetails(&foo.a);
    PrintPointerDetails(&foo.b);
    PrintPointerDetails(&foo.c);
}

TypeLayout.PrintLayout<Foo>();

[StructLayout(LayoutKind.Explicit, Pack = 128)]
struct Foo
{
    [FieldOffset(0)]
    public int a;
    [FieldOffset(5)]
    public long b;
    [FieldOffset(16)]
    public byte c;
}
bash 复制代码
| Expr            | Address         | Size | AlignedBySize | Addr/Size    |
| &foo.a          | 6104211776      | 4    | True          | 1526052944   |
| &foo.b          | 6104211781      | 8    | False         | 763026472.63 |
| &foo.c          | 6104211792      | 1    | True          | 6104211792   |
Type layout for 'Foo'
Size: 24 bytes. Paddings: 11 bytes (%45 of empty space)
|==========================|
|   0-3: Int32 a (4 bytes) |
|--------------------------|
|     4: padding (1 byte)  |
|--------------------------|
|  5-12: Int64 b (8 bytes) |
|--------------------------|
| 13-15: padding (3 bytes) |
|--------------------------|
|    16: Byte c (1 byte)   |
|--------------------------|
| 17-23: padding (7 bytes) |
|==========================|

将 Pack 属性设置为 1 可以消除结构体实例的末尾填充

csharp 复制代码
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using ObjectLayoutInspector;

var foo = new Foo
{
    a = 1,
    b = 2,
    c = 3
};
unsafe
{
    PrintPointerHeader();
    PrintPointerDetails(&foo.a);
    PrintPointerDetails(&foo.b);
    PrintPointerDetails(&foo.c);
}

TypeLayout.PrintLayout<Foo>();

[StructLayout(LayoutKind.Explicit, Pack = 1)]
struct Foo
{
    [FieldOffset(0)]
    public int a;
    [FieldOffset(5)]
    public long b;
    [FieldOffset(16)]
    public byte c;
}
bash 复制代码
| Expr            | Address         | Size | AlignedBySize | Addr/Size    |
| &foo.a          | 468685679112    | 4    | True          | 117171419778 |
| &foo.b          | 468685679117    | 8    | False         | 58585709889.63 |
| &foo.c          | 468685679128    | 1    | True          | 468685679128 |
Type layout for 'Foo'
Size: 17 bytes. Paddings: 4 bytes (%23 of empty space)
|==========================|
|   0-3: Int32 a (4 bytes) |
|--------------------------|
|     4: padding (1 byte)  |
|--------------------------|
|  5-12: Int64 b (8 bytes) |
|--------------------------|
| 13-15: padding (3 bytes) |
|--------------------------|
|    16: Byte c (1 byte)   |
|==========================|

此时实例的起始地址仍然按 8 字节 对齐(468685679112 / 8 = 58585709889),但实例的大小则是 17 字节,末尾填充为 0 字节。

Pack 属性不为 0 的结构体作为其他结构体字段时

csharp 复制代码
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using ObjectLayoutInspector;

var bar = new Bar
{
    foo = new Foo(),
    d = 4
};

unsafe
{
    PrintPointerHeader();
    PrintPointerDetails(&bar.foo.a);
    PrintPointerDetails(&bar.foo.b);
    PrintPointerDetails(&bar.foo.c);
    PrintPointerDetails(&bar.d);
}

TypeLayout.PrintLayout<Bar>();

[StructLayout(LayoutKind.Explicit, Pack = 1)]
struct Foo
{
    [FieldOffset(0)]
    public int a;
    [FieldOffset(5)]
    public long b;
    [FieldOffset(16)]
    public byte c;
}

struct Bar
{
    public Foo foo;
    public int d;
}
bash 复制代码
| Expr            | Address         | Size | AlignedBySize | Addr/Size    |
| &bar.foo.a      | 967090628200    | 4    | True          | 241772657050 |
| &bar.foo.b      | 967090628205    | 8    | False         | 120886328525.63 |
| &bar.foo.c      | 967090628216    | 1    | True          | 967090628216 |
| &bar.d          | 967090628220    | 4    | True          | 241772657055 |
Type layout for 'Bar'
Size: 24 bytes. Paddings: 7 bytes (%29 of empty space)
|==============================|
|  0-16: Foo foo (17 bytes)    |
| |==========================| |
| |   0-3: Int32 a (4 bytes) | |
| |--------------------------| |
| |     4: padding (1 byte)  | |
| |--------------------------| |
| |  5-12: Int64 b (8 bytes) | |
| |--------------------------| |
| | 13-15: padding (3 bytes) | |
| |--------------------------| |
| |    16: Byte c (1 byte)   | |
| |==========================| |
|------------------------------|
| 17-19: padding (3 bytes)     |
|------------------------------|
| 20-23: Int32 d (4 bytes)     |
|==============================|

在上面的例子中,Bar 结构体包含一个 Foo 类型的字段 foo,由于 Foo 的最大字段对齐要求为 8 字节,所以 Bar 的实例起始地址也满足 8 字节对齐要求(967090628200 / 8 = 120886328525)。

foo 的对齐要求为 1 字节, d 的对齐要求为 4 字节,所以 Bar 的实例大小为 24 字节(4 的整数倍),并在food 之间添加了 3 字节的填充。

LayoutKind.Auto

使用 LayoutKind.Auto 时,runtime 将根据字段的类型和声明顺序自动确定字段的布局,会调整实例字段的排列顺序和对齐要求,以优化内存布局和性能。

LayoutKind.Auto 也是引用类型实例字段的默认布局方式。

csharp 复制代码
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using ObjectLayoutInspector;

var foo = new Foo
{
    a = 1,
    b = 2,
    c = 3
};

unsafe
{
    PrintPointerHeader();
    PrintPointerDetails(&foo.a);
    PrintPointerDetails(&foo.b);
    PrintPointerDetails(&foo.c);
}

TypeLayout.PrintLayout<Foo>();


[StructLayout(LayoutKind.Auto)]
struct Foo
{
    public int a;
    public long b;
    public byte c;
}
bash 复制代码
| Expr            | Address         | Size | AlignedBySize | Addr/Size    |
| &foo.a          | 6166815056      | 4    | True          | 1541703764   |
| &foo.b          | 6166815048      | 8    | True          | 770851881    |
| &foo.c          | 6166815060      | 1    | True          | 6166815060   |
Type layout for 'Foo'
Size: 16 bytes. Paddings: 3 bytes (%18 of empty space)
|==========================|
|   0-7: Int64 b (8 bytes) |
|--------------------------|
|  8-11: Int32 a (4 bytes) |
|--------------------------|
|    12: Byte c (1 byte)   |
|--------------------------|
| 13-15: padding (3 bytes) |
|==========================|

上面例子中,Foo 结构体的字段 b 被放在了前面,各字段都按照其类型大小进行了对齐,相较于默认布局,Foo 结构体的内存布局更加紧凑,减少了填充字节的数量。

等效于于下面的结构体定义

csharp 复制代码
[StructLayout(LayoutKind.Sequential, Pack = 0)]
struct Foo
{
    public long b;
    public int a;
    public byte c;
}

作为数组元素时的结构体实例

默认字段布局

默认布局的结构体实例在数组中也会按照最大字段对齐要求进行对齐。每个结构体实例的起始地址都是该结构体最大字段对齐要求的整数倍。

因此默认布局下,数组中的每个结构体的字段都是满足对齐要求的。

csharp 复制代码
using System.Runtime.CompilerServices;

unsafe
{
    // 读者也可以替换成堆上分配的数组来查看运行结果
    var arr = stackalloc Foo[] { new Foo(), new Foo() };

    PrintPointerHeader();
    
    PrintPointerDetails(&arr[0].a);
    PrintPointerDetails(&arr[0].b);
    PrintPointerDetails(&arr[0].c);
    PrintPointerDetails(&arr[1].a);
    PrintPointerDetails(&arr[1].b);
    PrintPointerDetails(&arr[1].c);
}

struct Foo
{
    public int a;
    public long b;
    public byte c;
}
bash 复制代码
| Expr            | Address         | Size | AlignedBySize | Addr/Size    |
| &arr[0].a       | 1029625933216   | 4    | True          | 257406483304 |
| &arr[0].b       | 1029625933224   | 8    | True          | 128703241653 |
| &arr[0].c       | 1029625933232   | 1    | True          | 1029625933232 |
| &arr[1].a       | 1029625933240   | 4    | True          | 257406483310 |
| &arr[1].b       | 1029625933248   | 8    | True          | 128703241656 |
| &arr[1].c       | 1029625933256   | 1    | True          | 1029625933256 |

非默认字段布局

因为数组中结构体实例是连续存储的,如果结构体实例的字段布局进行了非默认的调整,则可能导致第二个开始的构体实例完全不满足对齐要求(包括实例的起始地址和字段地址)。

csharp 复制代码
using System.Runtime.CompilerServices;

unsafe
{
    // 读者也可以替换成堆上分配的数组来查看运行结果
    var arr = stackalloc Foo[] { new Foo(), new Foo() };

    PrintPointerHeader();
    
    PrintPointerDetails(&arr[0].a);
    PrintPointerDetails(&arr[0].b);
    PrintPointerDetails(&arr[0].c);
    PrintPointerDetails(&arr[1].a);
    PrintPointerDetails(&arr[1].b);
    PrintPointerDetails(&arr[1].c);
}

[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct Foo
{
    public int a;
    public long b;
    public byte c;
}
bash 复制代码
| Expr            | Address         | Size | AlignedBySize | Addr/Size    |
| &arr[0].a       | 654696769936    | 4    | True          | 163674192484 |
| &arr[0].b       | 654696769940    | 8    | False         | 81837096242.5 |
| &arr[0].c       | 654696769948    | 1    | True          | 654696769948 |
| &arr[1].a       | 654696769949    | 4    | False         | 163674192487.25 |
| &arr[1].b       | 654696769953    | 8    | False         | 81837096244.13 |
| &arr[1].c       | 654696769961    | 1    | True          | 654696769961 |