“在编译时分配的内存”的真正含义是什么?


159

在像C和C ++这样的编程语言中,人们经常引用静态和动态内存分配。我理解这个概念,但是“在编译时分配(保留)所有内存”这句话总是让我感到困惑。

据我了解,编译将高级C / C ++代码转换为机器语言并输出可执行文件。如何在已编译文件中“分配”内存?难道不是所有虚拟内存管理工具都总是在RAM中分配内存吗?

按照定义,内存分配不是运行时的概念吗?

如果我在C / C ++代码中创建一个1KB的静态分配变量,这将使可执行文件的大小增加相同的数量吗?

这是在“静态分配”标题下使用该短语的页面之一。

返璞归真:内存分配,回顾历史


在大多数现代体系结构中,代码和数据是完全隔离的。源文件在同一位置包含两个代码数据,而bin仅引用数据。这意味着源中的静态数据仅作为引用解析。
Cholthi Paul Ttiopic '16

Answers:


184

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

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

int array[100];

编译器在编译时知道数组的大小和的大小int,因此它在编译时知道数组的整个大小。默认情况下,全局变量还具有静态存储持续时间:它在进程存储空间的静态存储区域(.data / .bss节)中分配。有了这些信息,编译器将在编译期间决定该数组将位于该静态存储区的哪个地址

当然,内存地址是虚拟地址。该程序假定它具有自己的整个内存空间(例如,从0x00000000到0xFFFFFFFF)。这就是为什么编译器可以进行诸如“好的,数组将位于地址0x00A33211”的假设的原因。在运行时,MMU和OS会将地址转换为实际/硬件地址。

值初始化的静态存储有些不同。例如:

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 ++代码的实际作用。这适用于从存储类/持续时间(如此问题)到高级编译器优化。您可以指示编译器生成程序集,但是在Internet上有许多很棒的工具以友好的方式完成了该程序集。我最喜欢的是GCC Explorer


2
谢谢。这澄清了很多。因此,编译器输出的内容等同于“为变量array []等从0xABC到0xXYZ保留内存”。然后加载程序在运行程序之前使用它真正分配它?
Talha说2014年

1
@Talha说完全。参见编辑以查看示例
Manu343726 2014年

2
@Secko我简化了事情。它仅提及该程序通过虚拟内存工作,但是由于问题不在于虚拟内存,因此我没有扩展该主题。我只是指出,借助虚拟内存,编译器可以在编译时对内存地址进行假设。
Manu343726 2014年

2
@Secko是的。我认为“生成”是一个更好的词。
Manu343726

2
“它分配在过程存储空间的静态内存区域中” Reading在我的过程存储空间中分配了一些静态内存区域。
Radiodef 2014年

27

在编译时分配的内存仅意味着在运行时将不再进行任何分配-无需调用malloc,new或其他动态分配方法。即使您不需要一直使用所有内存,您的内存使用量也将保持固定。

按照定义,内存分配不是运行时的概念吗?

内存运行时尚未使用,而是在执行开始之前立即由系统处理。

如果我在C / C ++代码中创建一个1KB的静态分配变量,这将使可执行文件的大小增加相同的数量吗?

简单地声明为static不会使可执行文件的大小增加几个字节以上。用非零的初始值声明它(以便保留该初始值)。而是,链接器仅将这1KB的内存添加到系统加载程序在执行之前为您创建的内存需求中。


1
如果我写的static int i[4] = {2 , 3 , 5 ,5 }话,可执行文件大小会增加16个字节。您说:“仅声明静态变量不会将可执行文件的大小增加几个字节以上。使用非零的初始值声明可执行文件将是一个错误。”使用初始值声明它意味着什么。
Suraj Jain

您的可执行文件有两个用于存储静态数据的区域-一个用于未初始化的静态数据,另一个用于已初始化的静态数据。未初始化的区域实际上只是大小指示;当您的程序运行时,该大小用于增加静态存储区域,但程序本身所保存的内容不必超过所使用的未初始化数据量。对于初始化的静态变量,您的程序不仅必须包含(每个)静态变量的大小,还必须包含其初始化对象。因此,在您的示例中,您的程序将包含2、3、5和5。
2013年

它的实现定义为放置位置/分配方式,但是我不确定我是否需要知道。
2013年

23

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

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

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

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

还请记住,我们在谈论相对地址。变量将位于的实际地址将不同。在加载时,内核将为该进程保留一些内存,比方说在address处x,并且可执行文件中包含的所有硬编码地址将按x字节递增,因此a示例中的变量将在address x,b在address 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,您实际上可以在某处的垃圾箱中看到该字符串。在那种情况下,是的:可执行文件将大1 k,因为它包含完整的字符串。
但是,如果您分配ints,chars或long在堆栈上分配 s并循环分配,则沿这些方向的内容

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

然后,否:不会增加bin ...通过1000*sizeof(int)
在编译时分配,意味着您现在已经了解它的含义(基于您的评论):已编译的bin包含系统需要知道多少内存的信息执行时需要什么功能/块,以及应用程序所需的堆栈大小信息。这就是系统在执行bin时将分配的内容,并且您的程序成为一个进程(嗯,bin的执行就是……嗯,您明白我的意思了)。
当然,我不会在此处绘制全部图片:垃圾箱包含有关垃圾箱实际需要多大的信息。基于此信息(除其他事项外),系统将保留一块称为栈的内存,程序可以自由支配该内存。当启动进程(执行bin的结果)时,堆栈内存仍由系统分配。然后,该过程将为您管理堆栈存储器。当一个函数或循环(任何类型的块)被调用/执行时,该块本地的变量被压入堆栈,并被删除(可以说是“释放了”堆栈存储器),以供其他人使用。功能/块。所以宣布int some_array[100]只会向bin中添加一些字节的附加信息,告诉系统功能X将需要100*sizeof(int) +一些簿记空间。


非常感谢。还有一个问题,函数的局部变量在编译时是否也会以相同的方式分配吗?
塔哈(Talha)说2014年

@TalhaSayed:是的,这就是我说的意思:“信息需要系统知道什么功能/块需要多少内存。” 调用函数后,系统将为该函数分配所需的内存。函数返回的那一刻,该内存将再次被释放。
Elias Van Ootegem,2014年

至于您的C代码中的注释:实际并非如此。例如,字符串很可能在编译时仅分配一次。因此,它永远不会“释放”(我也认为术语通常仅在动态分配某些内容时使用),i不会“释放”或两者都不释放。如果i要驻留在内存中,它只会被压入堆栈,从某种意义上讲,这是没有释放的,无视它,i或者c将一直保存在寄存器中。当然,这全都取决于编译器,这意味着它不是黑白的。
phant0m 2014年

@ phant0m:我从未说过字符串是在堆栈上分配的,也只是指针,字符串本身将驻留在只读存储器中。我知道与局部变量关联的内存不会在free()调用的意义上被释放,但是一旦列出的函数返回,它们使用的堆栈内存就可以由其他函数释放。我删除了代码,因为它可能会使某些人感到困惑
Elias Van Ootegem 2014年

啊,我明白了。在这种情况下,请将我的评论表示为“您的措辞使我感到困惑”。
phant0m 2014年

16

在许多平台上,编译器会将每个模块内的所有全局或静态分配合并为三个或更少的合并分配(一个用于未初始化的数据(通常称为“ bss”),一个用于已初始化的可写数据(通常称为“数据”)。 ),以及一个用于常量数据(“ 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字节的“数据”和28字节的“ const”。此外,对变量的任何引用都将被区域选择器和偏移量替代,因此a,b,c,d和e将被bss + 0,const + 0,bss + 4,const + 24,data替代。 +0或bss + 204。

链接程序时,所有模块中的所有bss区域都串联在一起。同样是数据和常量区域。对于每个模块,任何bss相对变量的地址都将增加所有先前模块的bss区域的大小(同样,对于data和const同样)。因此,完成链接程序后,任何程序都将具有一个bss分配,一个数据分配和一个const分配。

加载程序时,取决于平台,通常会发生以下四种情况之一:

  1. 可执行文件将指示每种数据以及已初始化数据区域(可能在其中找到初始内容)所需的字节数。它还将包括所有使用bss,数据或常量相关地址的指令的列表。操作系统或加载程序将为每个区域分配适当的空间量,然后将该区域的起始地址添加到需要它的每个指令中。

  2. 操作系统将分配一个内存块来保存所有三种数据,并为应用程序提供指向该内存块的指针。任何使用静态或全局数据的代码都将相对于该指针取消引用(在许多情况下,该指针将在应用程序的生存期内存储在寄存器中)。

  3. 操作系统最初不会为应用程序分配任何内存,除了持有其二进制代码的内存之外,但是应用程序要做的第一件事就是向操作系统请求适当的分配,它将永远保存在寄存器中。

  4. 操作系统最初不会为应用程序分配空间,但是应用程序将在启动时请求适当的分配(如上所述)。该应用程序将包括指令列表,这些指令的地址需要更新以反映分配内存的位置(与第一种样式一样),但是该应用程序将包含足够的代码来对其自身进行修补,而不是由操作系统加载程序对其进行修补。 。

这四种方法都有优点和缺点。但是,在每种情况下,编译器都会将任意数量的静态变量合并为固定数量的固定内存请求,而链接程序会将所有这些静态变量合并为少量的合并分配。即使应用程序必须从操作系统或加载器接收大块内存,还是由编译器和链接器负责将大块中的各个块分配给需要它的所有单个变量。


13

您的问题的核心是:“如何在已编译的文件中“分配”内存?难道内存中不总是将所有虚拟内存管理内容分配给内存吗?按定义,内存分配不是运行时概念吗?”

我认为问题在于内存分配涉及两个不同的概念。从根本上说,内存分配是一个过程,我们说“此数据项存储在此特定的内存块中”。在现代计算机系统中,这涉及两个步骤:

  • 使用某些系统来确定将存储项目的虚拟地址
  • 虚拟地址映射到物理地址

后一个过程纯粹是运行时,但是如果数据具有已知大小并且需要固定数目的数据,则前一个过程可以在编译时完成。基本上是这样的:

  • 编译器会看到一个源文件,其中包含如下所示的一行:

    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
这个答案令人困惑(或困惑),因为它们谈论“应用程序堆”,“ OS堆”和“ GC堆”,好像它们都是有意义的概念一样。我推断,通过#1,您试图说某些编程语言可能(假设地)使用“堆分配”方案,该方案从.data节中的固定大小的缓冲区中分配内存,但这似乎不切实际,对人体有害。 OP的理解。关于#2和#3,GC的存在并没有真正改变任何东西。关于#5,您省略了.data和之间相对更重要的区别.bss
Quuxplusone

4

你是对的。实际上是在加载时分配(分页)内存,即在将可执行文件放入(虚拟)内存时。内存也可以在那一刻初始化。编译器仅创建一个内存映射。[顺便说一下,堆栈和堆空间也在加载时分配!]


2

我认为您需要退后一步。在编译时分配的内存...。这意味着什么?这是否意味着以某种方式保留了尚未制造的芯片上,尚未设计的计算机上的内存?不,不,时间旅行,没有可以操纵宇宙的编译器。

因此,这必须意味着编译器生成指令以在运行时以某种方式分配该内存。但是,如果您从正确的角度看待它,则编译器会生成所有指令,因此有什么区别。不同之处在于,编译器决定,并且在运行时,您的代码无法更改或修改其决定。如果它决定在编译时需要50个字节,那么在运行时,您将无法决定分配60个字节-该决定已经做出。


我喜欢使用Socratic方法的答案,但是我仍然对您的错误结论表示不满,因为该结论是“编译器生成了在运行时以某种方式分配该内存的指令”。请查看投票最多的答案,以了解编译器如何在不生成任何运行时“指令”的情况下“分配内存”。(请注意,汇编语言上下文中的“指令”具有特定的含义,即可执行操作码。您可能一直在口语中使用“指令” 一词来表示类似“配方”的内容,但是在这种情况下,这只会混淆OP。 )
Quuxplusone

1
@ Quuxplusone:我读了(并推荐)了这个答案。不,我的答案没有专门解决初始化变量的问题。它也没有解决自我修改的代码。虽然这个答案很不错,但它并没有解决我认为重要的问题-将事情放在上下文中。因此,我的回答希望,这将有助于OP(和其他组织)停止工作,并在他们遇到无法理解的问题时思考正在发生的事情或将要发生的事情。
jmoreno

@Quuxplusone:很抱歉,如果我在这里做出虚假指控,但我认为您也是-1回答我的答案的人之一。如果是这样,您会介意指出我回答的哪一部分是这样做的主要原因,并且您还愿意检查我的编辑吗?我知道我已经略过了一些关于堆栈内存管理方式的真正内在知识,所以现在我对我的回答仍然有些不准确:)
Elias Van Ootegem 2014年

@jmoreno您提到的要点是“是否意味着保留尚未制造的芯片上的内存,对于尚未设计的计算机上的内存,以某种方式保留?不。” 正是“分配”一词所隐含的错误含义,从一开始就使我感到困惑。我喜欢这个答案,因为它恰好指向我要指出的问题。这里的答案都没有真正触及这一点。谢谢。
塔哈(Talha)说2014年

2

如果您学习汇编程序设计,您将看到必须为数据,堆栈和代码等划分段。数据段是字符串和数字所在的位置。代码段是您的代码所在的地方。这些段内置在可执行程序中。当然,堆栈大小也很重要...您不会希望堆栈溢出

因此,如果您的数据段为500字节,则您的程序具有500字节的区域。如果将数据段更改为1500字节,则程序的大小将大1000字节。数据被组装到实际程序中。

这是编译高级语言时发生的情况。当将实际数据区域编译为可执行程序时,将对其进行分配,从而增加了程序的大小。该程序也可以动态请求内存,这就是动态内存。您可以从RAM请求内存,而CPU会将内存交给您使用,您可以放开它,然后垃圾回收器会将其释放回CPU。如有必要,甚至可以由一个好的内存管理器将其交换到硬盘上。这些功能是高级语言为您提供的。


2

我想借助几个图表来解释这些概念。

的确,确实不能在编译时分配内存。但是,实际上在编译时会发生什么。

这里是解释。假设某个程序有四个变量x,y,z和k。现在,在编译时,它仅生成一个内存映射,在此确定这些变量相对于彼此的位置。该图将更好地说明它。

现在想象一下,没有程序在内存中运行。我用一个大的空矩形显示。

空场

接下来,执行该程序的第一个实例。您可以将其可视化如下。这是实际分配内存的时间。

第一个例子

当该程序的第二个实例正在运行时,内存将如下所示。

第二审

还有第三..

第三审

等等等等。

我希望这种可视化可以很好地解释这个概念。


2
如果这些图显示了静态和动态内存之间的差异,它们将更加有用恕我直言。
Bartek Banachewicz 2014年

为了使事情简单,我故意避免这样做。我的重点是在没有太多技术混乱的情况下清晰地解释该基础。就静态变量而言。.前面的答案已经很好地说明了这一点。因此,我跳过了这一点。
user3258051 2014年

1
嗯,这个概念并不是特别复杂,所以我不明白为什么要使它变得比需要的简单,但是因为这只是一个免费的答案,所以可以。
Bartek Banachewicz 2014年

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.