这篇笔记主要是关于 C++ 中 struct 结构体和类似的 union 的使用,以及它们各自在内存中的实际占用。这一类的数据结构也被叫做异质的数据结构(Heterogeneous Data Structure)。
Struct
声明
struct 的声明很简单。定义一个名为 StructName 的结构体的语法如下。
struct StructName {
DataType1 member1;
DataType2 member2;
// ...
};
声明一个结构体变量的方式即
StructName myStruct;
在这里,结构体的成员可以包含不同类型的数据成员,例如 int, float, char,或是其他结构体,或数组、指针。
访问结构体的成员需要用操作符(.)。例如,myStruct.member1;
初始化
在声明的时候,就可以初始化
StructName myStruct = {value1, value2};
也可以声明指向结构体的指针。此后,用箭头操作符(->)来访问这个指针所指向的结构体的成员。
StructName myStruct = {value1, value2};
StructName *myStructPtr = &myStruct;
std::cout << (myStructPtr -> member1); // not myStructPtr.member1 !
内存对齐
对于结构体,内存对齐是一个重要的策略,它涉及到如何在内存中安排结构体的成员,以优化访问速度,减少内存空间的浪费。
以下为内存对齐的基本原则:
结构体的起始地址对齐。例如,如果结构体中最大的成员是 uint32_t,占用 4 字节,那么结构体的起始地址就很可能会在 4 字节的边界上对齐。
结构体成员对齐。结构体内的每个成员相对于结构体的起始地址通常会对齐到该成员类型自然对齐的边界上。例如,一个 4 字节 int 成员通常在 4 字节边界上对齐。
结构体总大小对齐。结构体的总大小通常会被填充以对齐到最大成员的对齐边界上。 这意味着结构体的大小可能会比所有成员大小的总和要大。
为了满足上面的对齐基本原则,有的时候编译器会在结构体成员之间或结构体的末尾添加额外的未使用内存,也叫做 Padding (填充)。这种 Memory padding 有助于确保每个成员都在适当的内存地址上。
示例
假设有如下的结构体定义。
struct SomeExample {
char a; // 1 byte
int b; // 4 bytes
char c; // 1 byte
}
根据对齐规则,这个结构体可能在内存中的布局如下:
内存中最大的成员是 int, 占用 4 字节,因此我们以 4 字节为边界对齐。
char a :占用 1 字节。填充 3 字节。
int b:占用 4 字节。
char c: 占用 1 字节,填充 3 字节。
此时,整个结构体的大小为 12 字节。只要它的起始位置是 4 的倍数,那么它的结束位置之后的那一位也始终会是 4 的倍数。
在结构体中,编译器通常会按照成员的顺序来布局成员,并根据每个成员的对齐要求添加必要的填充。因此,如果我们将结构体声明的顺序进行调整:
struct SomeExample {
char a; // 1 byte
char c; // 1 byte
int b; // 4 bytes
}
那么,根据内存对齐的规则,这个结构体可能在内存中的布局就会如下:
内存中最大的成员是 int, 占用 4 字节,因此我们以 4 字节为边界对齐。
char a :占用 1 字节。不填充。
char c:紧随 a 后,占用 1 字节。填充 2 字节。
char c: 占用 4 字节,不填充。
此时,整个结构体的大小仅为 8 字节,比之前少了整整 1/3。且依然符合内存对齐的要求。
在一些情况下,程序员可能会因为指针运算的问题去调整对齐策略,有的时候也并非上述第二种情况一定优于第一种情况,因为第一种情况下,我们都按照 int 的大小去调整起始位置就可以去快速获得每一个成员的地址,而第二种情况下我们移动指针的步长有时是 1, 有时是 2, 有时又是 4。最终,要根据项目具体需求来调整结构体的声明。
内存位置
在 C++ 中,数据类型会被存储在不同的位置,这取决于它们的声明方式和生存周期。 包括类和结构体,它们根据自己声明方式不同,也有可能存储在内存中不同的位置。
内存中可以存储的区域有以下几种:
代码段(Text Segment): 用来存储程序的可执行代码(机器指令)。
通常是只读的,以防止程序错误修改其指令。
操作系统加载程序时,将代码段映射为只读内存。
数据段(Data Segment): 存储已初始化的全局变量和静态变量。
数据段在程序启动时由操作系统初始化,并且在程序结束时释放。
数据段在程序的整个生命周期内都存在。
只读数据段(Read-only Data Segment): 存储只读数据,如字符串字面量和常量。
只读数据段通常是只读的,防止程序错误修改这些数据。
BSS段 (Block Started by Symbol): 存储未初始化的全局变量和静态变量。
BSS段在程序开始执行时被初始化为零。
与数据段不同,不占据任何文件中的实际空间,而是由操作系统在程序加载时分配并清零。
堆(heap):存储动态分配的内存。
堆内存由程序员管理,可以在程序运行时动态分配和释放。
堆内存的大小通常比栈要大,但需要手动释放,否则可能会造成内存泄漏。
栈(stack):存储局部变量、函数参数、返回地址等。
栈内存由操作系统自动管理,具有后进先出(LIFO)的特点。
每次函数调用时,栈帧被分配,当函数返回时,栈帧被释放。
以下是常见数据类型和它们在内存中的存储位置。
局部变量(栈)
局部变量通常存储在栈(stack)上,它们的生存期在其所在的函数作用域内。当函数调用时,栈帧被分配,当函数返回时,栈帧被释放。
例如,
void foo() {
int x = 10; // x 是 foo() 中的局部变量,存储在栈上
}
全局变量和静态变量(数据段)
全局变量和静态变量存储在数据段中,这些变量在程序开始时被分配,并在程序结束时被释放。
例如,
int globalVar = 10; // 全局变量,存储在数据段中
void foo() {
static int staticVar = 20; // 静态变量,在数据段中
}
动态分配的变量(堆)
通过 new 或者 malloc 等动态分配的变量存储在堆(heap)上。这些变量的生存期由程序员控制,需要使用 delete 或者 free 手动释放。
例如,
void foo() {
int* p = new int(10); // p 指向的内存存储在堆上
delete p; // 手动释放堆内存
}
常量(代码段或者只读数据段)
字符串字面量和其他常量通常存储在代码段或只读数据段中。
例如,
const char* str = "Hello World!"; // 字符串字面量存储在只读数据段中。
根据上面的介绍,不难判断结构体和类对象在 C++ 中的存储位置也取决于它们的声明和分配方式。以下是几种常见的情况及其存储位置:
局部变量(栈)
如果结构体或者类对象是作为局部变量在函数中声明的,它们通常会被分配到栈上。例如,
void foo() {
struct MyStruct {
int a;
int b;
};
MyStruct s; // s 存储在栈上
}
全局变量或静态变量(数据段)
如果结构体或者类对象是作为全局变量或静态变量声明的,它们会被分配在数据段中。例如,
struct MyStruct {
int a;
int b;
};
MyStruct globalStruct; // globalStruct 在数据段中
void foo() {
static MyStruct staticStruct; // staticStruct 也在数据段中
}
动态分配(堆)
如果结构体或者类对象是通过动态分配(例如,使用 new 运算符)创建的,它们会被分配在堆上。例如,
struct MyStruct {
int a;
int b;
};
void foo() {
MyStruct *p = new MyStruct(); // p指向的对象在堆上
delete p; // 手动释放内存
}
类成员
如果一个类对象包含其他类或者结构体作为成员,那么这些成员的存储位置取决于包含它们的对象的存储位置。例如,
struct InnerStruct {
int a;
int b;
};
struct OuterStruct {
InnerStruct inner;
};
void foo() {
OuterStruct outer; // outer 和 outer.inner 都存储在栈上
OuterStruct *p = new OuterStruct(); // p 指向的 outer 和 outer.inner 都存储在堆上
delete p;
}
这些规则适用于 C++ 中的大多数情况。但特殊情况下,例如使用自定义的内存分配器,存储位置可能会有所不同。
Σχόλια