“Memory allocated at compile time” 究竟是什么意思?

193
在像C和C++这样的编程语言中,人们经常提到静态和动态内存分配。我理解这个概念,但是“所有内存都在编译时被分配(保留)”这个短语总是让我感到困惑。
就我所知,编译将高级别的C/C++代码转换为机器语言并输出一个可执行文件。在编译文件中如何“分配”内存?难道内存不总是通过虚拟内存管理等东西在RAM中分配吗?
按定义,内存分配不是一个运行时概念吗?
如果我在我的C/C++代码中创建了一个1KB的静态分配变量,那么可执行文件的大小是否会增加同样的数量?
这是其中一个页面上使用“静态分配”标题的页面。 回归基础:内存分配,一次历史之旅

在大多数现代架构中,代码和数据是完全分离的。虽然源文件中包含了代码和数据,但二进制文件只有对数据的引用。这意味着源文件中的静态数据只能作为引用来解析。 - Cholthi Paul Ttiopic
15个回答

215

在编译时分配内存意味着编译器会在编译时解析某些内容在进程内存映射中将被分配的位置。

例如,考虑一个全局数组:

int array[100];
编译器在编译时知道数组的大小和int的大小,因此它在编译时知道整个数组的大小。此外,默认情况下,全局变量具有静态存储期:它被分配在进程内存空间的静态内存区域(.data/.bss部分)中。根据这些信息,编译器在编译期间决定该数组将在静态内存区域的哪个地址中。
当然,这些内存地址是虚拟地址。程序假定它拥有自己的整个内存空间(例如从0x00000000到0xFFFFFFFF)。这就是为什么编译器可以做出“好的,在地址0x00A33211处放置数组”的假设。在运行时,MMU和操作系统将这些地址转换为真实/硬件地址。
初始化值为0的静态存储对象与其他情况略有不同。例如:
int array[] = { 1 , 2 , 3 , 4 };

在我们的第一个示例中,编译器仅决定数组将被分配到何处,并将该信息存储在可执行文件中。
对于初始化值的情况,编译器还会将数组的初始值注入到可执行文件中,并添加代码告诉程序加载器,在程序启动时进行数组分配后,应使用这些值填充数组。

这里有两个由编译器生成的汇编示例(使用x86目标的GCC4.8.1):

C ++ 代码:

int a[4];
int b[] = { 1 , 2 , 3 , 4 };

int main()
{}

输出汇编:

a:
    .zero   16
b:
    .long   1
    .long   2
    .long   3
    .long   4
main:
    pushq   %rbp
    movq    %rsp, %rbp
    movl    $0, %eax
    popq    %rbp
    ret

正如您所看到的,这些值被直接注入到汇编程序中。在数组a中,编译器生成了16个字节的零初始化,因为标准规定静态存储的内容应该默认初始化为零:

8.5.9(初始化)[注意]:
在任何其他初始化发生之前,程序启动时会对所有静态存储期对象进行零初始化。在某些情况下,稍后会进行额外的初始化。

我总是建议人们反汇编他们的代码,以查看编译器实际上如何处理C++代码。这适用于存储类别/持续时间(如此问题)和高级编译器优化。您可以指示编译器生成汇编程序,但在互联网上有很多友好的工具可以做到这一点。我的最爱是GCC Explorer


2
谢谢。这样解释清楚了许多。所以编译器输出的东西相当于“从0xABC到0xXYZ为变量array[]保留内存等等”,然后加载器在运行程序之前使用它来真正分配内存? - Talha Sayed
1
@TalhaSayed 没错。请查看编辑以查看示例。 - Manu343726
2
@Secko,我简化了一些内容。它只是提到程序通过虚拟内存工作,但由于问题不涉及虚拟内存,我没有深入讨论这个话题。我只是指出编译器可以在编译时对内存地址进行假设,这要归功于虚拟内存。 - Manu343726
2
@Secko 是的。嗯,“生成”的说法更好一些,我想。 - Manu343726
2
它被分配在进程内存空间的静态内存区域中。阅读时,分配了一些静态内存区域在我的进程内存空间中。 - Radiodef
显示剩余23条评论

29

编译时分配的内存意味着在运行时不会再有进一步的分配,也就是说不需要调用malloc, new, 或其他动态分配内存的方法。即使您并非始终需要所有这些内存,也会具有固定的内存使用量。

难道内存分配不是运行时概念吗?

在运行时之前,内存没有被使用,但在执行开始之前,系统会处理其分配。

如果我在C/C++代码中静态地分配了1KB的变量,它会将可执行文件的大小增加同样多吗?

仅仅声明静态变量不会使可执行文件的大小增加超过几个字节。如果声明具有非零初始值的静态变量,则会增加可执行文件的大小(为了保持初始值)。相反,链接器只会将这1KB的数量添加到系统加载器立即在执行之前为您创建的内存需求中。


1
如果我写static int i[4] = {2 , 3 , 5 ,5 },它会增加16个字节的可执行文件大小吗?你说:“仅仅声明静态变量不会使可执行文件的大小增加超过几个字节。但是,如果声明时给它一个非零的初始值,则会增加可执行文件的大小。”声明它带有初始值会有什么意义呢? - Suraj Jain
你的可执行文件有两个静态数据区域 - 一个用于未初始化的静态数据,另一个用于已初始化的静态数据。未初始化的区域实际上只是一个大小指示; 当运行程序时,该大小用于增加静态存储区域的大小,但程序本身不必持有任何超过使用多少未初始化数据的东西。对于已初始化的静态数据,您的程序不仅必须持有(每个)静态数据的大小,还必须持有其初始化值。因此,在您的示例中,您的程序将包含2、3、5和5。 - mah
它的实现定义了它被放置/分配的位置,但我不确定我理解需要知道的原因。 - mah

25

在编译时分配的内存意味着当您加载程序时,一部分内存将立即被分配,并且此分配的大小和(相对)位置是在编译时确定的。

char a[32];
char b;
char c;

这三个变量是在编译时“分配的”,这意味着编译器在编译时计算它们的大小(固定大小)。变量a将成为内存中的偏移量,比方说,指向地址0,b将指向地址33,c指向34(假设没有对齐优化)。因此,“分配1KB的静态数据不会增加代码的大小”,因为它只会改变其中的一个偏移量。 “实际空间将在加载时分配”。

真正的内存分配总是在运行时发生,因为内核需要跟踪它并更新其内部数据结构(每个进程分配了多少内存,页面等等)。区别在于编译器已经知道您将使用的每个数据的大小,这些数据将在程序执行时立即分配。

还要记住,我们谈论的是“相对地址”。变量将位于不同的实际地址。在加载时,内核将为进程保留一些内存,假设在地址x处,可执行文件中包含的所有硬编码地址都会增加x字节,因此示例中的变量a将位于地址x,b位于地址x+33等。


17

将变量添加到堆栈中占用N个字节,并不会(必然)使bin的大小增加N个字节。实际上,大部分时间只会添加几个字节。


让我们从一个例子开始,假设在您的代码中添加了1000个字符,这将以线性方式增加bin的大小。

如果这1k是一个字符串,包含一千个字符,像这样声明:

const char *c_string = "Here goes a thousand chars...999";//implicit \0 at end

如果你接着运行vim your_compiled_bin,你实际上能够在二进制文件中找到那个字符串。这种情况下,是的:可执行文件会变大1k,因为它完全包含了该字符串。
但是,如果你在栈上分配了一个intcharlong类型的数组,并在循环中进行赋值,大致如下:

int big_arr[1000];
for (int i=0;i<1000;++i) big_arr[i] = some_computation_func(i);

那么,不会:这不会增加二进制文件的大小...1000*sizeof(int)
在编译时分配意味着您现在理解的含义是(基于您的评论):编译后的二进制文件包含系统需要知道执行时每个函数/块将需要多少内存以及应用程序所需的堆栈大小的信息。这就是系统在执行您的二进制文件时分配的内容,而且您的程序将成为一个进程(好吧,执行您的二进制文件是一个进程...您明白我的意思)。
当然,我没有完整地描述这个过程:二进制文件包含有关堆栈实际需要多大的信息。基于这些信息(还有其他因素),系统将保留一块内存,称为堆栈,程序可以在其上自由使用。堆栈内存仍然在进程(即您的二进制文件被执行后的结果)启动时由系统分配。然后,进程将为您管理堆栈内存。当函数或循环(任何类型的块)被调用/执行时,该块中的本地变量会被推入堆栈,并且它们被删除(堆栈内存被“释放”,以便其他函数/块使用)。因此,声明int some_array[100]只会向二进制文件添加一些额外的字节信息,告诉系统函数X将需要100*sizeof(int)+ 一些簿记空间额外。


非常感谢。还有一个问题,函数的局部变量在编译时也会以相同的方式分配吗? - Talha Sayed
@TalhaSayed:是的,这就是我所说的:“系统需要知道每个函数/块需要多少内存的信息。”当您调用一个函数时,系统将为该函数分配所需的内存。当函数返回时,该内存将被释放。 - Elias Van Ootegem
我不能代表其他人,但是我下投票的原因是因为你的回答没有解释二进制文件大小增加的原因和方式,只有“什么”,而且其中一些甚至是不正确的。感觉你只是通过ls -l a.out收集数据点,然后编造抽象的“解释”,认为这些数据符合,而不是真正了解正确答案并试图向提问者传达相关部分的知识。 - Quuxplusone
样例事实错误:添加一个本地(堆栈)变量只是改变相关函数序言中的常量(即.text中的指令);一旦您拥有了一个堆栈帧,添加更多变量通常不会改变二进制文件的大小。添加一个零初始化的静态变量(在.bss中)根本不会改变二进制文件的大小(只会将“.bss大小”的存储在二进制文件中),并且肯定不会生成任何可执行指令。 - Quuxplusone
@Quuxplusone:我承认,我不知道栈内存是如何分配的,也不懂汇编语言。我知道我走了一些捷径,并且我知道我说了一些不完全正确的话。我从未声称我的答案会100%准确。但是为了辩护,OP的问题实际上归结为编译时分配的含义是什么,它是否会改变二进制文件大小,我在某种程度上回答了这个问题。但是你能告诉我我在哪里说添加一个栈变量会增加二进制文件大小吗(我的答案的第一行总是以“添加一个变量不一定...”开头)? - Elias Van Ootegem
显示剩余4条评论

17

在许多平台上,每个模块内的全局或静态分配将由编译器合并为三个或更少的合并分配(一个用于未初始化数据(通常称为“bss”),一个用于已初始化可写数据(通常称为“data”)和一个用于常量数据(“const”)),每种类型程序内的所有全局或静态分配将由链接器合并为每种类型的一个全局。例如,假设 int 为四个字节,则一个模块具有以下内容作为其唯一的静态分配:

int a;
const int b[6] = {1,2,3,4,5,6};
char c[200];
const int d = 23;
int e[4] = {1,2,3,4};
int f;
它将告诉链接器需要208字节的bss,16字节的"data"和28字节的"const"。此外,任何对变量的引用都将被替换为区域选择器和偏移量,因此a、b、c、d和e将分别被替换为bss+0、const+0、bss+4、const+24、data+0或bss+204。
程序链接时,所有模块的bss区域将被连接在一起;同样,数据和常量区域也是如此。对于每个模块,任何相对于bss的变量的地址都将增加所有先前模块bss区域的大小(数据和常量区域同理)。因此,当链接器完成时,任何程序都将有一个bss分配、一个数据分配和一个常量分配。
当程序加载时,通常会根据平台发生以下四种情况之一:
  1. 可执行文件将指示每种数据需要多少字节,并标明初始化数据区域的位置。它还将包括使用bss、数据或常量相对地址的所有指令的列表。操作系统或加载程序将为每个区域分配适当数量的空间,然后将该区域的起始地址添加到需要它的每个指令中。
  2. 操作系统将分配一块内存来保存所有三种数据,并向应用程序提供该块内存的指针。任何使用静态或全局数据的代码都将相对于该指针进行解引用(在许多情况下,该指针将在应用程序的生命周期内存储在寄存器中)。
  3. 操作系统最初不会为应用程序分配任何内存,除了保存其二进制代码所需的内存之外,但应用程序要做的第一件事就是从操作系统请求合适的分配,它将永远保留在一个寄存器中。
  4. 操作系统最初不会为应用程序分配空间,但是应用程序将在启动时请求合适的分配(如上所述)。应用程序将包括一份需要更新地址以反映内存分配位置的指令列表(与第一种样式相同),但是应用程序将包含足够的代码来自行修补而不是由操作系统加载器进行补丁。
所有这四种方法都有优缺点。但是,在每种情况下,编译器将把任意数量的静态变量合并为固定数量的内存请求,并且链接器将把所有这些东西都合并为少量的合并分配。即使应用程序必须从操作系统或加载程序接收一个内存块,但是编译器和链接器负责为所有需要它的各个变量分配大块内存中的单个片段。

13
你问题的核心是:“编译文件中的内存是如何“分配”的?难道不是所有带有虚拟内存管理的RAM中都会分配内存吗?按照定义,内存分配不是一个运行时的概念吗?”
我认为问题在于内存分配涉及到两个不同的概念。基本上,内存分配是指我们将“这个数据项存储在这个特定的内存块中”的过程。在现代计算机系统中,这包括两个步骤:
- 使用某些系统来决定存储该项的虚拟地址 - 将虚拟地址映射到物理地址
后一个过程纯粹是运行时的,但如果数据具有已知大小并且需要固定数量的数据,则前者可以在编译时完成。以下是基本原理:
编译器看到一个源文件,其中包含类似于以下行的内容:
```int i;```
编译器知道整数需要多少内存,并且知道它们的数量(在这种情况下为1),因此它可以为变量i分配内存。这与其他变量和函数一起保存在所谓的“数据段”或“BSS段”中。
这可能比较复杂,并且还有许多细节要涉及,但是这里简要介绍了内存分配的一些基础知识。
int c;
  • 它生成了汇编器的输出,指示其为变量'c'保留内存。这可能看起来像这样:

  • global _c
    section .bss
    _c: resb 4
    
    当汇编程序运行时,它会保持一个计数器,用于跟踪每个项目从内存“段”(或“区域”)的开头偏移的位置。这就像一个非常大的“结构体”中包含了整个文件中的所有内容,但此时并没有分配任何实际的内存,并且可以位于任何地方。它在表格中记录_c具有特定的偏移量(例如从段开始的510字节),然后将其计数器增加4,因此下一个这样的变量将在(例如)514字节处。对于需要_c地址的任何代码,它只需在输出文件中放置510,并添加一条注释,指出输出需要稍后添加到其中的包含_c的段的地址。

    链接器获取所有汇编输出文件并对它们进行检查。它为每个段确定一个地址,以便它们不会重叠,并添加必要的偏移量,以便指令仍然引用正确的数据项。对于像 c 占用的未初始化内存这样的情况(汇编程序通过编译器将其放入“.bss”段,这是一个保留未初始化内存的名称),它在输出中包括一个标题字段,告诉操作系统需要保留多少内存。它可能被重新定位(通常是如此),但通常设计为更有效地加载到一个特定的内存地址,并且操作系统将尝试在此地址加载它。此时,我们已经有了c将使用的虚拟地址的相当好的想法。

    物理地址实际上直到程序运行时才确定。但是,从程序员的角度来看,物理地址实际上并不重要——我们甚至永远不会知道它是什么,因为操作系统通常不会告诉任何人,它可能会频繁更改(即使在程序运行时),操作系统的主要目的就是抽象这一点。

    9

    可执行文件描述了为静态变量分配空间的方法。当您运行可执行文件时,系统会执行此分配操作。因此,您的1kB静态变量不会使可执行文件的大小增加1kB:

    static char[1024];
    

    除非您指定了初始化程序:
    static char[1024] = { 1, 2, 3, 4, ... };
    

    除了“机器语言”(即CPU指令)外,可执行文件还包含所需内存布局的描述。


    5

    内存可以以多种方式分配:

    • 在应用堆中(当程序启动时,操作系统会为您的应用程序分配整个堆)
    • 在操作系统堆中(因此您可以不断地获取更多内存)
    • 在垃圾收集器控制的堆中(与上述两者相同)
    • 在堆栈上(因此您可能会遇到堆栈溢出)
    • 在二进制文件(可执行文件)的代码/数据段中保留
    • 在远程位置(文件、网络等)(您将接收到一个句柄而不是指向该内存的指针)

    现在你的问题是什么是“编译时分配的内存”。显然,这只是一个措辞不当的说法,它应该是指二进制段分配或堆栈分配,或者在某些情况下甚至是堆分配,但在这种情况下,分配是通过不可见的构造函数调用来隐藏的。或者可能说这话的人只是想说内存不是在堆上分配的,但不知道堆栈或段分配,(或者不想深入了解这种细节)。

    但在大多数情况下,人们只是想说 分配的内存量在编译时是已知的

    只有当内存在应用程序的代码或数据段中保留时,二进制文件大小才会发生变化。


    1
    这个答案有些令人困惑,因为它谈到了“应用堆”、“操作系统堆”和“GC堆”,好像这些都是有意义的概念。我推断你在第一点中想说的是,一些编程语言可能(假设)使用“堆分配”方案,该方案从.data部分的固定大小缓冲区中分配内存,但这似乎不现实,对OP的理解有害。关于第二点和第三点,GC的存在并没有真正改变任何事情。至于第五点,你省略了“.data”和“.bss”之间相对更重要的区别。 - Quuxplusone

    4

    你说得对。内存实际上是在加载时分配(分页),也就是在可执行文件被带入(虚拟)内存时。内存也可以在那个时候初始化。编译器只是创建了一个内存映射。[顺便提一下,堆栈空间也是在加载时分配的!]


    2
    我认为你需要稍微退后一步。在编译时分配的内存……这是什么意思?它是否意味着预留了尚未制造的芯片上的内存,用于尚未设计的计算机?不是的。没有时间旅行,也没有能够操纵宇宙的编译器。
    因此,这必须意味着编译器在运行时生成指令来分配该内存。但是,如果从正确的角度看待它,编译器会生成所有指令,那么有什么区别呢?区别在于编译器做出决策,并且在运行时,你的代码不能改变或修改其决策。如果它在编译时决定需要50个字节,则在运行时,你不能让它决定分配60个字节——这个决策已经做出了。

    我喜欢使用苏格拉底方法的答案,但我仍然会为您错误的结论投反对票,即“编译器生成指令以在运行时以某种方式分配该内存”。请查看得票最高的答案,了解编译器如何在不生成任何运行时“指令”的情况下“分配内存”。(请注意,在汇编语言上下文中,“指令”具有特定的含义,即可执行操作码。您可能一直在口头上使用这个词来表示类似“食谱”的东西,但在这种情况下,这只会让OP感到困惑。) - Quuxplusone
    1
    @Quuxplusone:我已经阅读并点赞了那个答案。我的回答并没有特别针对初始化变量的问题。它也没有涉及自修改代码。虽然那个答案非常好,但它没有解决我认为很重要的问题——将事物放入上下文中。因此,我的回答希望能帮助OP(和其他人)停下来思考,在遇到他们不理解的问题时可能发生了什么。 - jmoreno
    @Quuxplusone:如果我在这里做出了错误的指控,那么很抱歉,但我认为你也是对我的答案进行-1评分的人之一。如果是这样,请问您是否介意指出我回答中的主要原因,并且您是否还愿意检查我的编辑?我知道我跳过了一些关于堆栈内存管理的真实内部工作方式的细节,所以我现在已经在我的答案中添加了一些关于我不是100%准确的内容 :) - Elias Van Ootegem
    @jmoreno 你提到的“它是否意味着为尚未制造的芯片上的内存,为尚未设计的计算机保留了某种内存?”这一点恰恰是“分配”这个词所暗示的错误含义,这让我从一开始就感到困惑。我喜欢这个答案,因为它正好涉及到我试图指出的问题。这里没有任何一个答案真正触及到那个特定的点。谢谢。 - Talha Sayed

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接