Bootstrap

C++ 内存布局与字节序详解:类大小、结构体对齐、大小端与字节序转换


无意间刷到一些关于类大小的内容,正好写一篇文章作为另一篇的跳转,下面我们直接进入正题:

一、如何计算一个类的大小?

在 C++ 中,计算一个类的大小通常是通过 sizeof 运算符来完成的。sizeof 运算符可以用来计算一个类、结构体、数组或基本数据类型的内存占用大小。

如果要计算一个类的大小,需要考虑下面的因素:

  1. 类的成员变量

    • 类的大小由它的成员变量、继承的基类、以及内存对齐方式共同决定。成员变量包括基本类型、指针、对象、数组等。
  2. 内存对齐(Padding)

    • 由于大多数系统的内存访问效率较高时需要遵守对齐规则,因此类的大小可能会比成员变量的总和大一些。
    • 编译器通常会对类的成员进行内存对齐,使得每个成员变量的地址都满足特定的对齐要求。为了实现对齐,编译器可能会在成员变量之间插入空白(填充字节),即 “padding”。
  3. 虚函数

    • 如果类有虚函数,那么类通常会有一个虚函数表(vtable),这会占用额外的空间。
  4. 继承

    • 如果类继承了其他类(基类),则继承的成员也会计入该类的大小。

一般我们可以通过sizeof 直接计算类的大小,比如:

cout << sizeof(MyClass) << endl;

这会返回类 MyClass 在内存中的大小,单位是字节。


二、sizeof 计算的几个关键因素:

  • 内存对齐:编译器通常会在成员之间插入填充字节,以使每个成员按照其类型的对齐要求进行对齐。一般来说,int 的对齐要求是 4 字节,double 的对齐要求是 8 字节,char 对齐要求是 1 字节。

  • 虚函数:如果类中有虚函数,类会有一个虚函数表指针(vptr),这会占用额外的空间。在 64 位系统中,这通常是 8 字节。

  • 继承:如果类继承了其他类,继承的成员会增加类的大小。例如,子类继承了父类的成员变量和虚函数表指针。

2.1 特殊情况(空类、静态成员变量):

  • 空类:对于没有成员的空类,sizeof 计算结果通常为 1。原因是即使没有成员,C++ 也要求每个对象在内存中有一个唯一的地址。因此,空类的大小是 1 字节。

  • 类的静态成员变量:静态成员变量属于类本身,而不是类的每个实例。因此,静态成员变量的大小不计算在每个实例的大小中。只有实例化对象时,类的非静态成员变量的大小才会计算。


2.2 示例

#include <iostream>
using namespace std;

// 类 A:包含基本成员变量
class A {
public:
    int x;     // 4 字节
    char y;    // 1 字节
};

class B {
public:
    int x;     // 4 字节
    char y;    // 1 字节
    double z;  // 8 字节
};

class C {
public:
    virtual void foo() {} // 有虚函数
    int x;    // 4 字节
    double y; // 8 字节
};

class D {
public:
    static int staticVar; // 静态成员变量
    int x;                // 4 字节
    char y;               // 1 字节
};

// 基类 E,含有一个虚函数
class E {
public:
    virtual void bar() {}
    int x;
};

// 子类 F,继承自基类 E
class F : public E {
public:
    double y;
    char z;
};

int main() {
    cout << "Size of A: " << sizeof(A) << " bytes" << endl;  // 计算类 A 的大小
    cout << "Size of B: " << sizeof(B) << " bytes" << endl;  // 计算类 B 的大小
    cout << "Size of C: " << sizeof(C) << " bytes" << endl;  // 计算类 C 的大小
    cout << "Size of D: " << sizeof(D) << " bytes" << endl;  // 计算类 D 的大小
    cout << "Size of E: " << sizeof(E) << " bytes" << endl;  // 计算基类 E 的大小
    cout << "Size of F: " << sizeof(F) << " bytes" << endl;  // 计算子类 F 的大小

    return 0;
}

int D::staticVar = 10; // 静态成员变量的定义

代码解析和类大小的计算:

  1. 类 A

    class A {
    public:
        int x;     // 4 字节
        char y;    // 1 字节
    };
    
    • xint 类型,通常占 4 字节。
    • ychar 类型,占 1 字节。
    • 由于内存对齐规则,x 需要在 4 字节边界上对齐,所以 y 后面会有 3 字节的填充字节。最终,类 A 的大小为 8 字节
  2. 类 B

    class B {
    public:
        int x;     // 4 字节
        char y;    // 1 字节
        double z;  // 8 字节
    };
    
    • xint 类型,占 4 字节。
    • ychar 类型,占 1 字节。
    • zdouble 类型,占 8 字节。
    • y 后面会有 3 字节的填充,以满足 double 类型的 8 字节对齐要求。所以,类 B 的大小为 16 字节(4 + 1 + 3 填充 + 8 字节)。
  3. 类 C(有虚函数):

    class C {
    public:
        virtual void foo() {} // 有虚函数
        int x;    // 4 字节
        double y; // 8 字节
    };
    
    • C 中有一个虚函数,因此会为类对象添加一个虚函数表指针(vptr),通常占 8 字节(在 64 位系统上)。
    • x 占 4 字节,y 占 8 字节。
    • C 的总大小将是 24 字节(8 字节虚函数表指针 + 4 字节 x + 8 字节 y)。
  4. 类 D(含有静态成员变量):

    class D {
    public:
        static int staticVar; // 静态成员变量
        int x;                // 4 字节
        char y;               // 1 字节
    };
    
    • 静态成员变量 staticVar 并不属于类的每个实例,它是在类加载时分配内存,而不是为每个对象单独分配内存,因此它不会影响类对象的大小。
    • 只有 x(4 字节)和 y(1 字节),以及填充字节(3 字节),所以类 D 的大小为 8 字节
  5. 类 E(基类):

    class E {
    public:
        virtual void bar() {}
        int x;  // 4 字节
    };
    
    • E 含有一个虚函数,所以它也有一个虚函数表指针(vptr),通常占 8 字节。
    • x 占 4 字节。
    • E 的总大小为 12 字节(8 字节虚函数表指针 + 4 字节 x)。
  6. 类 F(继承自基类 E):

    class F : public E {
    public:
        double y; // 8 字节
        char z;   // 1 字节
    };
    
    • F 继承了类 E 的虚函数表指针(8 字节)和成员变量 x(4 字节)。
    • ydouble 类型,占 8 字节。
    • zchar 类型,占 1 字节。
    • 为了满足对齐要求,z 后面会有 7 字节的填充,以保证类的大小是 8 字节对齐的。
    • F 的总大小为 24 字节(8 字节虚函数表指针 + 4 字节 x + 8 字节 y + 1 字节 z + 7 字节填充)。

输出示例:

Size of A: 8 bytes
Size of B: 16 bytes
Size of C: 24 bytes
Size of D: 8 bytes
Size of E: 12 bytes
Size of F: 24 bytes

总结
在计算类的大小时,主要受到以下因素的影响:

  1. 成员变量的类型和对齐要求:编译器会根据成员的类型插入必要的填充字节,以确保成员按其对齐要求正确对齐。
  2. 虚函数:如果类中有虚函数,那么类的大小会包括虚函数表指针(vptr),通常占用 8 字节(在 64 位系统中)。
  3. 静态成员变量:静态成员变量不占用对象内存,因此它们不影响对象的大小。
  4. 继承:子类继承父类时,除了父类的成员和虚函数表指针外,还会增加自己独立的成员。

三、结构体对齐

关于类与结构体的对齐规则可以看下面的文章:

结构体的内存对齐(规则、存在原因、默认对齐数的修改等+实例分析)

上面的文章是关于C语言的struct的结构体对齐,而C++类与struct的结构体对齐规则与C语言是一致的,需要注意的就是C++的一些新内容、新特性:


3.1 alignas 关键字:

C++11 引入了 alignas 关键字,可以明确指定类或结构体的对齐要求。这样可以影响类或结构体的对齐方式。

示例:

struct A {
    char a;     // 1 字节
    int b;      // 4 字节
};

alignas(16) struct B {
    char a;     // 1 字节
    int b;      // 4 字节
};

通过 alignas(16),结构体 B 的起始地址会对齐到 16 字节边界,从而可能导致更多的填充字节。


3.2 C++ 结构体对齐举例

对于下面代码的四个类,我们进行画图分析:

#include <iostream>
using namespace std;

class Base {
public:
    virtual void func() {}  // 虚函数
    int baseData;           // 基类数据成员
};

class Derived : public Base {
public:
    double derivedData;     // 派生类数据成员
};

class VirtualBase {
public:
    virtual void vfunc() {} // 虚函数
    char vBaseData;         // 虚基类数据成员
};

class MultipleInheritance : public Derived, public VirtualBase {
public:
    float multipleData;     // 多重继承类的数据成员
};

int main() {
    cout << "Size of Base: " << sizeof(Base) << endl;
    cout << "Size of Derived: " << sizeof(Derived) << endl;
    cout << "Size of VirtualBase: " << sizeof(VirtualBase) << endl;
    cout << "Size of MultipleInheritance: " << sizeof(MultipleInheritance) << endl;

    return 0;
}

我们通过一个示例图分析class Base 结构体对齐后的大小,

  1. Base 类

有一个虚函数 func() 和一个 int baseData。由于虚函数,编译器会为它添加一个虚函数表指针 vptr。假设:

  • vptr 需要 8 字节。
  • int baseData 占用 4 字节。

由于 int 通常要求 4 字节对齐,Base 类的内存布局可能是:

  • 8 字节的 vptr,紧接着 4 字节的 int baseData,因此 Base 的总大小为 16 字节。
    在这里插入图片描述

其他的以此,我们分别进行分析:


  1. Derived

Derived 类继承自 Base,它有一个额外的 double derivedData。由于 double 通常要求 8 字节对齐,因此 Derived 类的布局会受到影响:

  • Base 部分(16 字节)已经包含虚函数表指针和 baseData
  • double derivedData 需要 8 字节,并且它的对齐要求是 8 字节。

因此,Derived 类的总大小是:

  • 16 字节(来自 Base 类的部分) + 8 字节(double derivedData)= 24 字节

  1. VirtualBase

VirtualBase 类有一个虚函数 vfunc() 和一个 char vBaseData。虚继承会引入虚基类指针。假设:

  • vptr 需要 8 字节(虚函数表指针)。
  • char vBaseData 占用 1 字节。

由于虚继承,VirtualBase 可能还需要填充字节来满足对齐要求,通常会填充 7 字节。

因此,VirtualBase 类的总大小为:

  • 8 字节(vptr) + 1 字节(vBaseData) + 7 字节填充 = 16 字节

  1. MultipleInheritance
    MultipleInheritance 类同时继承自 DerivedVirtualBase,因此它会包含两个虚函数表指针(一个来自 Base,一个来自 VirtualBase),以及来自这两个父类的数据成员。
  • Derived 类的部分:包含一个 8 字节的虚函数表指针和 double derivedData(8 字节),总计 24 字节。
  • VirtualBase 类的部分:包含一个 8 字节的虚函数表指针和 char vBaseData(1 字节),总计 16 字节。
  • MultipleInheritance 类自身的数据成员 multipleDatafloat 类型,4 字节)。

因为虚继承,MultipleInheritance 类还需要额外的填充字节来确保对齐,因此其总大小可能为:

  • 24 字节(来自 Derived)+ 16 字节(来自 VirtualBase)+ 4 字节(multipleData)+ 填充字节 = 48 字节

输出示例

Size of Base: 16
Size of Derived: 24
Size of VirtualBase: 16
Size of MultipleInheritance: 48

3.3 offsetof 宏

如果要知道一个结构体成员相对于结构体起始位置的偏移量,可以使用 offsetof 宏;offsetof 宏返回结构体成员相对于结构体起始地址的字节偏移量。

std::size_t offsetof(type, member); // #include <cstddef>
  • type 是结构体的类型。
  • member 是结构体的成员名称。

例子

假设有一个结构体:

#include <iostream>
#include <cstddef>  // 引入 offsetof 宏所在的头文件

struct MyStruct {
    char a;     // 1 字节
    int b;      // 4 字节
    short c;    // 2 字节
    double d;   // 8 字节
};

int main() {
    std::cout << "Offset of 'a': " << offsetof(MyStruct, a) << std::endl;  // 输出 0
    std::cout << "Offset of 'b': " << offsetof(MyStruct, b) << std::endl;  // 输出 4
    std::cout << "Offset of 'c': " << offsetof(MyStruct, c) << std::endl;  // 输出 8
    std::cout << "Offset of 'd': " << offsetof(MyStruct, d) << std::endl;  // 输出 16
    return 0;
}

3.3.1 如何理解偏移量?

偏移量是一个成员相对于结构体起始地址的字节数。在内存中,结构体成员是按特定顺序排列的,但为了满足对齐要求,编译器可能会插入填充字节。因此,实际偏移量不一定是成员的声明顺序所决定的,而是取决于对齐要求和编译器的布局规则。

应用场景

offsetof 宏在调试、内存管理、序列化和二进制协议处理中非常有用。例如,如果你需要通过指针操作结构体中的成员,或者在内存中对齐结构体成员时,知道偏移量可以帮助你正确计算成员的位置。

注意事项

  • offsetof 宏通常只用于已知结构体类型和成员名称的情况。
  • 使用 offsetof 获取偏移量时,不会引起任何实际的内存访问操作,它只是计算符号信息。

四、一些问题

4.1 什么是内存对齐,结构体是如何进行内存对齐的?

内存对齐(Memory Alignment)是指将数据按照其数据类型的要求存储在内存中的方式。由于不同的数据类型对内存存储有不同的要求,内存对齐旨在提高访问效率,确保数据按正确的边界存储,从而避免性能损失或者硬件异常。

对于第二问,上面的内容以及进行了解释。


4.2 如何让结构体按照指定的默认对齐数进行对齐?

在上文进行了 介绍,可以使用alignas关键字:alignas(16) struct B {}


4.3 如何知道结构体某个成员相对于起始位置的偏移量

在上文进行了介绍,我们可以使用 offsetof 宏;offsetof(MyStruct, a)


4.4 什么是大小端?如何测试一个机器是大端还是小端?有没有什么需要考虑大小端的场景

4.4.1 什么是大小端(Endianness)?

大小端是指在多字节数据类型(如 intfloatdouble 等)在内存中的存储顺序。它决定了数据的各个字节在内存中是如何排列的。

  • 大端(Big-endian):高位字节存储在低地址,低位字节存储在高地址。
  • 小端(Little-endian):低位字节存储在低地址,高位字节存储在高地址。

例如,假设有一个 32 位的整数 0x12345678,它占用 4 个字节。

  • 大端存储:内存中按顺序存储为 12 34 56 78,其中 12 存储在最小地址(低地址),78 存储在最大地址(高地址)。
  • 小端存储:内存中按顺序存储为 78 56 34 12,其中 78 存储在最小地址(低地址),12 存储在最大地址(高地址)。

示意图表

地址低字节端顺序(小端)高字节端顺序(大端)
0x10000x780x12
0x10010x560x34
0x10020x340x56
0x10030x120x78

4.4.2 如何测试一个机器是大端还是小端?

要测试一个机器的字节序,我们可以通过构造一个多字节数据类型,查看其数据在内存中的存储顺序。常用的方法是使用一个 2 字节(或 4 字节)数据,并检查它们在内存中的存储顺序。

示例:通过整数判断大小端

我们通过下面的代码可以测试机器的字节序:

#include <iostream>

int main() {
    // 采用一个 2 字节的整数
    unsigned int num = 0x12345678;  // 0x12 34 56 78(假设大端)
    unsigned char* bytePtr = reinterpret_cast<unsigned char*>(&num);

    // 检查存储顺序
    if (bytePtr[0] == 0x78) {
        std::cout << "This machine is Little Endian." << std::endl;
    } else if (bytePtr[0] == 0x12) {
        std::cout << "This machine is Big Endian." << std::endl;
    }

    return 0;
}

4.4.3 需要考虑大小端的场景

  1. 网络协议(如 TCP/IP)
    网络协议通常使用 大端 字节序(也称为网络字节序)。例如,IP 地址、端口号等数据在传输时是以大端方式发送的。因此,在网络通信时,可能需要进行字节序转换(即大端和小端之间的转换)。

    • 例子:在将数据发送到网络时,主机可能是小端系统,但数据需要以大端顺序发送。你可以使用像 htonl()(主机字节序到网络字节序转换)和 ntohl()(网络字节序到主机字节序转换)这样的函数来处理。
  2. 文件存储和二进制协议
    在读写二进制文件时,文件的字节顺序可能与系统的字节序不同。若文件是在不同的机器上生成的(可能有不同的字节序),在读取时需要考虑字节序转换。

    • 例子:某些二进制文件格式(如图像文件、音频文件等)可能规定了字节顺序,跨平台操作时需要进行字节序转换。
  3. 多平台开发
    在进行跨平台开发时,必须特别注意字节序的问题。例如,编写需要在不同架构之间共享数据的程序时(如跨操作系统通信、数据交换等),需要确保字节序一致,否则会导致数据解释错误。

  4. 硬件与嵌入式开发
    某些硬件设备和嵌入式系统可能使用特定的字节序。例如,某些 ARM 处理器是支持大端和小端模式的,可以通过配置寄存器来选择字节序。在与硬件进行通信时,必须确保正确处理字节序问题。

  5. 数据库和其他持久化存储
    数据库或持久化存储系统可能会以特定字节序存储数据(如在多平台数据库同步时)。在实现数据存储和读取时,正确处理字节序非常重要。


4.4.4 如何进行字节序转换?

在实际开发中,通常使用以下函数进行字节序转换:

  • htonl()(host to network long):将 32 位整数从主机字节序转换为网络字节序。
  • htons()(host to network short):将 16 位整数从主机字节序转换为网络字节序。
  • ntohl()(network to host long):将 32 位整数从网络字节序转换为主机字节序。
  • ntohs()(network to host short):将 16 位整数从网络字节序转换为主机字节序。
;