现代C++的内存分配策略

· · 科技·工程

注:以下 LLVM IR 与 Clang 版本均为 18.1.8,使用 Oz 优化。为增强可读性,将给出简化的 LLVM IR(去除调试信息)

导入

从各种教程网站和 Oier 的口口相传中,我们认为 C++ 的局部变量内存分配策略是按需分配,也就是当定义变量时分配栈内存,但是这是一个错误的观点。

例如在缺氧代码求助帖中,lz 使用 gdb 进行调试但是段错误发生点在函数开头,而无法进一步调试错误位置。如果没有真正了解现代 C++ 的内存分配策略,在赛场上可能会遇到许多玄学错误,导致 AFO。

内存布局

在一个标准的 C++ 程序中,一共有三种内存空间,分别是:

  1. 栈空间
  2. 堆空间
  3. 静态空间/数据段

栈空间

栈内存是专门用于存放局部数据的空间,包括但不限于:

  1. 函数传参
  2. 局部变量
  3. 临时变量

栈空间通常由编译器自动分配,可以使用 -Wl --stack=<SIZE> 选项修改栈空间。

但需要注意的是,栈空间并非即开即用,而是在 entry 中全部分配后再进行操作的。

如以下 C++ 代码

int main(){
    for(;;){
        int a = 1;
        int b[1010];
        if(a){
            int c = 1;
        }
    }
}

生成的 LLVM IR 代码为:

; Function Attrs: mustprogress noinline norecurse nounwind optnone uwtable
define dso_local noundef i32 @main() #0 {
  %1 = alloca i32, align 4
  %2 = alloca i32, align 4
  %3 = alloca [1010 x i32], align 16
  %4 = alloca i32, align 4
  store i32 0, ptr %1, align 4
  br label %5

5:                                                ; preds = %9, %0
  store i32 1, ptr %2, align 4
  %6 = load i32, ptr %2, align 4
  %7 = icmp ne i32 %6, 0
  br i1 %7, label %8, label %9

8:                                                ; preds = %5
  store i32 1, ptr %4, align 4
  br label %9

9:                                                ; preds = %8, %5
  br label %5, !llvm.loop !5
}

我们可以看到,无论是在条件语句中的还是在循环语句中的所有变量都是被事先分配好的,所以如果你在条件分支语句中多次定义了变量 x ,而且 x 相当大时就会 MLE(例如一个 array)。

堆内存

堆内存是程序运行期间动态分配的内存,由程序员通过诸如 new、malloc 等函数显式申请,使用 delete 或 free 手动释放。堆内存的分配和释放由操作系统负责管理。

例如以下代码:

int* create_array(int n) {
    return new int[n];
}

其中就申请了一个长度为 n 的堆内存。堆内存由于其分散性,难以被 cache 命中,所以使用指针写的树速度是一定不如使用数组写的树的。如果你觉得被卡常了,不妨使用这种方法。

STL 动态数据类型

例如 std::vector, std::queue 等,他们都是在运行时动态申请内存的,那么他们就申请到的都是堆内存。通常这些数据类型都只在内存不足时分配 size \le 2^k Byte 的内存,然后再将数据拷贝至新内存空间。所以我们使用 reserve 函数可以预先分配内存,防止拷贝提升性能。

静态内存/数据段

静态内存用于存储程序中的全局变量、静态变量和常量数据。该部分内存生命周期与程序相同,从程序启动到结束始终存在。通常分为以下几个区域:

  1. 数据段:
    • 存储初始化的全局变量和静态变量。
  2. BSS 段:
    • 存储未初始化的全局变量和静态变量。
  3. 只读段:
    • 存储常量数据,例如字符串字面量。 例如以下代码:
int global_var = 42;
const char* str = "Hello, World!";
static int static_var;

对于的 LLVM IR 如下:

@global_var = dso_local global i32 42, align 4
@str = private unnamed_addr constant [13 x i8] c"Hello, World!\00", align 1
@static_var = internal global i32 0, align 4

可以看到:

  1. 已初始化的 global_var 被存储在数据段;
  2. 字符串常量 str 被存储在只读段;
  3. 未初始化的 static_var 位于 BSS 段。

数据段

顾名思义,存放数据的段内存,其中内容可变,大小在编译时确定,存储在可执行文件中,作为代码段的一部分。

如以下关于 global_var 的汇编代码:

    .data
global_var:
    .long   42                              # 0x2a

    .addrsig

BSS 段

BSS 是 Block Started by Symbol 的简称,在进入程序时由 CRT(C 运行时库) 分配内存,如由上述关于 static_var 的 LLVM 代码编译后的汇编代码:

    .bss
    .align 4
static_var:
    .zero   4               # 4 字节,未初始化的静态变量

只读段

通常以 .rodata 标识,是在汇编级别的只读。

.section .rodata           # 定义只读数据段
    .align 4
msg:                            # 字符串常量
    .string "Hello, World!"

只读段在开启 O1 以上的优化时会被常量传递。