字节对齐和结构体内存布局

1,669 阅读11分钟
原文链接: zhuanlan.zhihu.com

之前遇到一个问题,需要动态计算 C 语言结构体(struct)的内存布局。在此记录一下。

问题

使用 MetalKit 实现 GLES 的一些接口。于是在运行时,将 GLSL 在转换成 Metal Shader Language(下文简写成 MSL),之后再编译这个 MSL。

在转换 Shader 时,GLSL 的 uniform、attribute、varying 变量分别收集起来,组成一个结构(struct)。比如

uniform float val0;
uniform bool val1;
uniform float val2[16];
uniform int val3;

会转换成

struct MtlFragmentUniforms {
	float val0;
	bool val1;
	float val2[16];
	int val3;
};

在 Metal 中,CPU 往 Shader 中传递数据,会使用 Buffer,设置到对应的索引中。CPU 往 Buffer 中写入二进制数据,假如这串二进制数据的内存布局跟 MtlFragmentUniforms 结构一致。Shader 就可以正确获取到 uniforms 的每个数值。

这里内部布局是指结构体本身的大小、结构体内每个字段的大小,以及每个字段在结构体中的偏移值。知道字段的偏移和大小,才能往二进制数据中正确填入数据。

日常编程中,没有必要过多考虑结构体的内存布局,编译器在编译的时候已计算好了。但这里,MSL 内的结构体是动态生成的,在编译时期根本还没有这个结构体,为了使得 CPU 传入的二进制数据跟 Shader 中的结构体精确对应,就需要自己来计算内部布局。

字节对齐

结构体内存布局涉及到字节对齐。字节对齐是指数据放在内存中,起始地址的限制。比如 4 字节对齐,起始地址就需要是 4 的倍数。8 字节对齐,起始地址就需要是 8 的倍数。假如起始地址为 8,8 是 4 的倍数,因此是 4 字节对齐,同样也是 8 字节对齐。假如起始地址为 9,它就不是 4 字节对齐了。

为什么需要字节对齐呢?

现代计算机中,内存每个字节都有自己的地址,理论上可以从任何起始地址访问任意类型的变量。但估计是为了简化电路设计,实际上计算机会将字节组成一个大点的格子,32 位机器,就以 4 个字节作为一个格子。64 位机器,就以 8 字节作为一个格子。在 64 位机上,就算读取 1 字节的数值,也需要读取整个 8 字节的格子。

下面都是以 LLVM 编译器,在 64 位机(8 字节格子)上分析。4 字节或者 8 字节的格子,一个紧挨一个,从 0 开始放,因此每个格子必然是字节对齐的。地址 [0, 8) 是一个格子,[8, 16) 是一个格子。

假如不考虑字节对齐,就很容易出现数据跨两个格子的情况。

比如下面结构

struct Base {
	bool val0;
	double val1;
};

假如不考虑字节对齐,数据就会一个紧挨着一个。val1 的偏移就是 1,它的大小为 sizeof(double) = 8。假设结构存放在地址 0 位置,val1 占据的地址就为 [1, 9), 就会同时占据地址为 [0, 8), [8, 16) 两个格子了。当内存地址不对齐,数据同时占据两个格子时,某些机器内部可能自动处理一下,读取两个格子的数据,将 double 数据拼接起来,再返回读取结果,但这样速度就慢了。而有些机器根本就不支持这种做法,当地址不对齐时,程序会直接崩溃。

因此编译器在编译代码的时候,会考虑字节对齐,目的是尽量让字段不要跨越两个格子。这样做虽然牺牲了一点点内存空间,但运行速度会更快,也更安全。

通常来说,在 64 位机下,对于基础数据类型,它的字节对齐就是它的 sizeof。比如 bool 类型,sizeof(bool) = 1, 要求 1 字节对齐,1 字节对齐就相当于不对齐。而 int 类型,sizeof(int) = 4, 就要求 4 字节对齐。double 类型,sizeof(double) = 8,要求 8 字节对齐。

假如是 32 位机,其最大的对齐为 4 字节。double 类型,虽然 sizeof(double) = 8,但也有可能是 4 字节对齐,而非 8 字节对齐。只是我没有 32 位机,就没有去测试了。

结构体布局计算

假如没有字节对齐,计算结构体内存布局会很容易。上例中,Shader 中的结构为

struct MtlFragmentUniforms {
	float val0;
	bool val1;
	float val2[16];
	int val3;
};

我们在转换时,可以知道每个字段的名字和类型。也可以知道 sizeof(float) = 4, sizeof(bool) = 1, sizeof(float[16] ) = 64, sizeof(int) = 4。

假如没有字节对齐,结构体字段就会一个紧挨一个。整个结构的大小就为 sizeof(float) + sizeof(bool) + sizeof(float[16]) + sizeof(int) = 73。字段 val2 的偏移就会为 sizeof(float) + sizeof(bool) = 5。

但一但考虑自己对齐,就有

  • val0 类型为 float,偏移为 0, 占据 4 字节。
  • val1 类型为 bool, 偏移为 4,占据 1 字节。
  • val2 类型为 float[16],本来应该挨着 val1, 偏移为 4 + 1 = 5。但要求 4 字节对齐。于是偏移调整为 8,占据 64 字节。
  • val2 类型为 int, 偏移为 8 + 64 = 72,占据 4 字节。

于是数据总大小为 72 + 4 = 76,而结构体内部字段最大对齐值为 4 字节。数据总大小为 76,是 4 的倍数,不用调整。于是整个结构体大小为 76 字节。

要验证我们的计算也很简单,在测试代码中定义结构体,使用 offsetof(struct MtlFragmentUniforms, val0) 就可以取得偏移值。只是我们原始问题根本就没有结构体,也就不能使用 offsetof,需要自己来计算。

假如觉得自己打印 offsetof 麻烦,可以写下列测试代码

// main.cpp
struct MtlFragmentUniforms {
    float val0;
    bool val1;
    float val2[16];
    int val3;
};

int main(int argc, char **argv) {
    return sizeof(MtlFragmentUniforms);
}

之后使用命令

clang -cc1 -fdump-record-layouts main.cpp

就可以打印出

*** Dumping AST Record Layout
         0 | struct MtlFragmentUniforms
         0 |   float val0
         4 |   _Bool val1
         8 |   float [16] val2
        72 |   int val3
           | [sizeof=76, dsize=76, align=4,
           |  nvsize=76, nvalign=4]

sizeof = 76, 就为整个结构的大小。align=4 为结构整个结构要求的对齐值,MtlFragmentUniforms 结构本身需要 4 字节对齐。而 dsize、nvsize、nvalign 几个字段跟 C++ 有关,不用关心。

正式代码

LLVM 编译器中 C 结构的布局计算可以参考代码 DataLayout.cpp

C++ 的布局计算可以参考 RecordLayoutBuilder.cpp,实际上,上面命令输出的详细布局,对应于文件中 DumpRecordLayout 函数。

RecordLayoutBuilder.cpp 的代码有点复杂,我们还是看 DataLayout.cpp 的代码。对于 C 结构,它们的计算结果是一样的。

StructLayout::StructLayout(StructType *ST, const DataLayout &DL) {
  assert(!ST->isOpaque() && "Cannot get layout of opaque structs");
  StructAlignment = 0;
  StructSize = 0;
  IsPadded = false;
  NumElements = ST->getNumElements();

  // Loop over each of the elements, placing them in memory.
  for (unsigned i = 0, e = NumElements; i != e; ++i) {
    Type *Ty = ST->getElementType(i);
    unsigned TyAlign = ST->isPacked() ? 1 : DL.getABITypeAlignment(Ty);

    // Add padding if necessary to align the data element properly.
    if ((StructSize & (TyAlign-1)) != 0) {
      IsPadded = true;
      StructSize = alignTo(StructSize, TyAlign);
    }

    // Keep track of maximum alignment constraint.
    StructAlignment = std::max(TyAlign, StructAlignment);

    MemberOffsets[i] = StructSize;
    StructSize += DL.getTypeAllocSize(Ty); // Consume space for this data item
  }

  // Empty structures have alignment of 1 byte.
  if (StructAlignment == 0) StructAlignment = 1;

  // Add padding to the end of the struct so that it could be put in an array
  // and all array elements would be aligned correctly.
  if ((StructSize & (StructAlignment-1)) != 0) {
    IsPadded = true;
    StructSize = alignTo(StructSize, StructAlignment);
  }
}

可以看到 StructAlignment 存储了结构体自身需要的对齐值,为结构体所有字段最大的对齐值。最终结构体的 StructSize 会调整成这个 StructAlignment 的整数倍。比如下面例子

// main.cpp
struct Test {
    double v0;
    int v1;
};

struct Test2 {
    int v0;
    Test v1;
};

int main(int argc, char **argv) {
    return sizeof(Test2);
}

使用命令 clang -cc1 -fdump-record-layouts main.cpp 打印出。

*** Dumping AST Record Layout
         0 | struct Test
         0 |   double v0
         8 |   int v1
           | [sizeof=16, dsize=16, align=8,
           |  nvsize=16, nvalign=8]

*** Dumping AST Record Layout
         0 | struct Test2
         0 |   int v0
         8 |   struct Test v1
         8 |     double v0
        16 |     int v1
           | [sizeof=24, dsize=24, align=8,
           |  nvsize=24, nvalign=8]

观察 Test 的输出。Test 的 v2, 偏移为 8,占据 4 个字节。Test 的 size 本来应该是 12 字节,但 Test 包含了 double,double 的对齐为 8,整个 Test 的对齐也应该为 8。于是 Test 的 size 也被调整成 8 字节对齐,变为了 16 字节。

为什么结构体本身的 size 也需要对齐呢?不能直接是 12 字节吗,这样会省些内存。

考虑下面代码,我们经常类似下面那样,动态分配内存。

Test* p = malloc(2 * sizeof(Test));

假如 Test 的 size 不调整对齐,为 12 字节,两个 Test 直接紧挨着。内存布局如下

p + 0  | struct Test
 p + 0  |   double v0
 p + 8  |   int v1
 p + 12 | struct Test
 p + 12 |   double v0
 p + 20 |   int v1

观察到第 2 个 Test 的 v0 字段。它的地址为 p + 12, 而 double 本身为 8 个字节,于是 v0 就会占据 [p + 12, p + 20) 的内存,这实际就会跨越 [p + 8, p + 16), [p + 16,p + 24) 两个格子。

为了预防这种跨格子的情况,结构体于是就需调整自身的 size。Test 大小调整为 16,无论怎么排列,内部的字段也不会跨越两个格子。

64 位机下,对于基础类型,比如 int, double 之类,它的对齐值为它的 size。但对于结构体,它的对齐值是其中所有字段对齐值的最大值。结构体需要保持自身的对齐值,这样当结构体嵌套的情况,计算方式就完全一样了。

字节对齐对代码的影响

平常很少需要自己计算 struct 的布局,但字节对齐会影响代码写法。比如我们定义结构时,需要注意字段的排列顺序,不然白白浪费内存。如下定义

struct Color {
    float r;
    float g;
    float b;
    float a;
};

struct FrameInfo {
    Color color;
    bool isColorDirty;
    float depth;
    bool isDepthDirty;
};

sizeof(FrameInfo) = 28,占据了 28 个字节。但其它代码不用改,只需要调整字段的顺序,将两个 bool 放在一起。

struct FrameInfo {
    bool isColorDirty;
    bool isDepthDirty;
    Color color;
    float depth;
};

调整后 sizeof(FrameInfo) = 24, 每个结构节省了 4 字节。当类似的结构很多时,节省的内存也挺可观的。

大家可能会说,现在的内存这样大,何必还死扣这种字节呢。只是在这种情况下,随手就改好了,不花什么成本,也影响代码可读性,也有收益,何乐而不为呢。俗话说,蚊子肉也是肉啊。

字节对齐有时也会影响文件的读取写入。比如我们将一些字符串写到二进制文件中。为了方便读取,我们先写入 4 字节的字符串长度,再写入字符串本身内容。

当写入 "hello", "world" 两个字符串,二进制文件中就有内容

5(占4字节),"hello"(占5字节),4(占4字节),“world”(占5字节)

我们将整个文件都读入内存(或者使用 mmap),再读取字符串。

class File {
    File(const std::string &path) {
        _data = readDataFromFile(path);
    }
    std::string readString() {
        // 先读取长度
        int len = *((int *)_data);
        _data += sizeof(int);

        // 再读取字符串内容
        std::string result(_data, _data + len);
        _data += len;
        return result;
    }

private:
    uint8_t *_data;
}

File file(path);
std::string str0 = file.readString();
std::string str1 = file.readString();

上述代码实际上是有问题的,在读取长度的时候,使用了一个指针强转。int len = *((int *)_data);

简单分析就可以知道,在读取 "hello" 字符串后,因为它只占据 5 字节。于是之后读取 "world" 的长度时,指针 _data 就不是 4 字节对齐。强转成 int*,就会跨越两个格子。对于某些机器,内部可能帮忙拼接。但对于一些机器,程序可能就直接崩溃了。

这种问题可以在写入文件和读取文件两个角度防止。很多文件格式,在写入字符串时,就要求对齐。比如 4 字节对齐,当字符串长度并非 4 字节倍数时,会在后面填充一些 0。比如上例中写入 "hello", "world" 两个字符串,最终为

5(占4字节),"hello"(占5字节),补0(3字节),4(占4字节),“world”(占5字节),补0(三字节)

这样读取函数 readString,就被修改为

std::string readString() {
    // 先读取长度
    int len = *((int *)_data);
    _data += sizeof(int);

    // 再读取字符串内容
    std::string result(_data, _data + len);
    _data += alignTo(len, 4);
    return result;
}

alignTo 函数就是将 len 调整成 4 字节对齐。

在读取时候防止,就是读取任何字段的时候都不应该强转。可以使用 memcpy 来读入长度,就为

int len = 0;
memcpy(&len, _data, sizeof(int));
_data += sizeof(int);

但实际上,这种读取方式也不一定是正确的,因为没有考虑字节顺序,但我们在这里先忽略这个问题。现在可接触到的机器基本都是小尾机。

为了安全,宁愿在读取的时候使用 memcpy,也不要直接强转。但直接强转速度会快一些,要求写入数据的时候,预先字节对齐。

在编译的时候,假如不修改 #pragma pack 之类的编译器对齐选项,编译出来的结构数据就是自然对齐的。当数据保存成二进制数据再读取时,就需要特别注意了。