“编译时分配的内存”究竟意味着什么?

时间:2014-01-25 12:24:06

标签: c++ c memory memory-management terminology

在C和C ++等编程语言中,人们经常提到静态和动态内存分配。我理解这个概念,但短语“所有内存在编译期间被分配(保留)”总是让我困惑。

据我所知,编译将高级C / C ++代码转换为机器语言并输出可执行文件。如何在编译文件中“分配”内存?是不是内存总是在RAM中分配所有虚拟内存管理的东西?

根据定义,内存分配不是运行时概念吗?

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

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

Back To Basics: Memory allocation, a walk down the history

14 个答案:

答案 0 :(得分:172)

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

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

int array[100];

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

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

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

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

在我们的第一个例子中,编译器只决定数组的分配位置,将该信息存储在可执行文件中 在值初始化的东西的情况下,编译器还将数组的初始值注入到可执行文件中,并添加代码,告诉程序加载器在程序启动时的数组分配之后,应该用这些值填充数组。 / p>

以下是编译器生成的程序集的两个示例(带有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

答案 1 :(得分:26)

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

  

根据定义,内存分配不是运行时概念吗?

在运行时,内存不是正在使用,但是在执行之前,系统会立即开始分配。

  

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

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

答案 2 :(得分:22)

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

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

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

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

还要记住我们正在谈论相对地址。变量所在的实际地址将不同。在加载时,内核将为进程保留一些内存,比如地址x,并且可执行文件中包含的所有硬编码地址将增加x个字节,因此变量{{示例中的1}}位于地址a,b位于地址x,依此类推。

答案 3 :(得分: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,因为它包含完整的字符串 但是,如果你在堆栈上分配一个int s,charlong的数组,并将它分配给一个循环,那么这些行就是

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包含有关bin实际需要多大堆栈的信息。根据这些信息(除其他事项外),系统将保留一块称为堆栈的内存,程序可以自由统治。当启动进程(正在执行bin的结果)时,堆栈存储器仍由系统分配。然后,该过程为您管理堆栈内存。当一个函数或循环(任何类型的块)被调用/执行时,该块的本地变量被推送到堆栈,它们被删除(堆栈内存“释放”所以到说)由其他功能/块使用。因此声明int some_array[100]只会向bin中添加几个字节的附加信息,告诉系统函数X将需要100*sizeof(int) +额外的一些簿记空间。

答案 4 :(得分:15)

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

当一个程序被链接时,所有模块中的所有bss区域都被连接在一起;同样是数据和常量区域。对于每个模块,任何bss相对变量的地址将增加所有前面模块的bss区域的大小(同样,数据和const也是如此)。因此,当链接器完成时,任何程序都将有一个bss分配,一个数据分配和一个const分配。

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

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

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

  3. 操作系统最初不会为应用程序分配任何内存,除了保存其二进制代码的内容,但应用程序所做的第一件事就是从操作系统请求合适的分配,它将永远更多保留在登记册中。

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

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

答案 5 :(得分:12)

你问题的核心是:“如何在编译文件中分配”内存?是不是内存总是在RAM中分配所有的虚拟内存管理内容?根据定义,内存分配不是运行时概念?“

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

  • 某些系统用于决定项目存储的虚拟地址
  • 虚拟地址映射到物理地址

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

  • 编译器会看到一个源文件,其中包含一行看起来像这样的行:

    int c;
    
  • 它为汇编程序生成输出,指示它为变量“c”保留内存。这可能如下所示:

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

  • 链接器获取所有汇编程序的输出文件,并检查它们。它确定每个段的地址,以便它们不会重叠,并添加必要的偏移量,以便指令仍然引用正确的数据项。在像c占用的未初始化的内存的情况下(汇编程序被告知内存将由于编译器将其放入'.bss'段而未初始化,这是为未初始化的内存保留的名称),它在输出中包含一个头字段,告诉操作系统需要保留多少。它可能被重新定位(通常是),但通常被设计为在一个特定的内存地址处更有效地加载,并且OS将尝试在该地址加载它。此时,我们非常清楚c将使用的虚拟地址。

  • 在程序运行之前,实际上不会确定物理地址。然而,从程序员的角度来看,物理地址实际上是无关紧要的 - 我们甚至从未发现它是什么,因为操作系统通常不会告诉任何人,它可以经常更改(即使在程序运行时),以及无论如何,操作系统的主要目的是将其抽象出来。

答案 6 :(得分:9)

可执行文件描述了为静态变量分配的空间。当您运行可执行文件时,此分配由系统完成。所以你的1kB静态变量不会增加1kB的可执行文件的大小:

static char[1024];

除非您指定初始值设定项:

static char[1024] = { 1, 2, 3, 4, ... };

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

答案 7 :(得分:5)

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

    应用程序堆中的
  • (程序启动时,操作系统为您的应用程序分配了整个堆)
  • 在操作系统堆中(这样你可以抓住越来越多)
  • 垃圾收集器控制堆中的
  • (与上述相同)
  • 堆栈上的
  • (这样你就可以获得堆栈溢出)
  • 保留在二进制文件的代码/数据段中(可执行文件)
  • 在远程位置(文件,网络 - 并且您收到句柄而不是指向该内存的指针)

现在你的问题是什么是“在编译时分配的内存”。肯定这只是一个错误的措辞,它应该指代二进制段分配或堆栈分配,或者在某些情况下甚至指向堆分配,但在这种情况下,分配是由程序员眼睛通过隐形构造函数调用隐藏的。或者可能是那个说只是想说内存没有在堆上分配但是不知道堆栈或段分配的人。(或者不想进入那种细节)。

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

只有在应用程序的代码或数据段中保留内存时,二进制大小才会更改。

答案 8 :(得分:4)

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

答案 9 :(得分:2)

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

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

答案 10 :(得分:2)

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

因此,如果您的数据段是500字节,则您的程序具有500字节区域。如果将数据段更改为1500字节,则程序的大小将增加1000个字节。数据汇总到实际程序中。

这是编译更高级语言时发生的事情。实际数据区域在编译成可执行程序时会被分配,从而增加了程序的大小。该程序也可以动态请求内存,这是动态内存。你可以从RAM请求内存,CPU会让你使用它,你可以放弃它,你的垃圾收集器会将它释放回CPU。如果需要,它甚至可以由一个好的内存管理器交换到硬盘。这些功能是高级语言为您提供的。

答案 11 :(得分:2)

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

确实,在编译时无法分配内存。 但是,那么在编译时会发生什么。

这里有解释。 比方说,例如程序有四个变量x,y,z和k。 现在,在编译时它只是创建一个内存映射,确定这些变量相对于彼此的位置。 该图将更好地说明它。

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

empty field

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

first instance

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

second instance

第三个......

third instance

等等。

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

答案 12 :(得分:1)

接受的答案中给出了非常好的解释。以防万一我会发布我发现有用的链接。 https://www.tenouk.com/ModuleW.html

答案 13 :(得分:0)

编译器所做的众多事情之一是创建和维护一个 SYMTAB(section.symtab 下的符号表)。这将完全由编译器使用任何数据结构(列表、树...等)创建和维护,而不是为开发人员的眼睛。开发人员提出的任何访问请求都会首先到达此处。

现在关于符号表, 我们只需要知道两列符号名称和偏移量。

Symbol Name 列将包含变量名称,offset 列将包含偏移值。

让我们看一个例子:

int  a  ,  b  ,  c  ;

现在我们都知道寄存器 Stack_Pointer(sp) 指向栈顶。假设 sp = 1000。

现在符号名称列将包含三个值 a 然后 b 然后 c。提醒您所有变量 a 将位于堆栈内存的顶部。

因此 a 的等效偏移值将为 0。 (编译时间Offset_Value)

那么 b 和它的等效偏移值将是 1。(编译时间 Offset_Value)

那么 c 和它的等价偏移值将是 2。(Compile Time Offset_Value)

现在计算a的物理地址(或)运行时内存地址=(sp+a的offset_value) = (1000 + 0) = 1000

现在计算b的物理地址(或)运行时内存地址=(sp-b的offset_value) = (1000 - 1) = 996

现在计算c的物理地址(或)运行时内存地址=(sp-c的offset_value) = (1000 - 2) = 992

因此,在编译时,我们将只有偏移值,并且仅在运行时计算实际物理地址。

注意: Stack_Pointer 值只有在程序加载后才会分配。指针运算发生在 Stack_Pointer 寄存器和变量偏移之间,以计算变量物理地址。

        "POINTERS AND POINTER ARITHMETIC, WAY OF THE PROGRAMMING WORLD"