“编译时分配的内存”到底是什么意思?

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

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

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

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

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

回到基础: 内存分配,历史漫步

53165 次浏览

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

static char[1024];

当然,除非您指定了初始值设定项:

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

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

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

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

该内存在运行时之前不是 正在使用,而是在执行开始之前由系统处理其分配。

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

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

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

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

int array[100];

编译器在编译时知道数组的大小和 int的大小,所以它在编译时知道整个数组的大小。另外,默认情况下,全局变量具有静态存储持续时间: 它被分配到进程内存空间的静态内存区域(。资料/。网上服务)。根据这些信息,编译器在编译期间决定数组在该静态内存区域的地址

当然,内存地址是虚拟地址。该程序假设它有自己的整个内存空间(例如,从0x0000000到0xFFFFFFFF)。这就是为什么编译器可以做这样的假设: “好的,数组将位于地址0x00A33211”。在运行时,MMU 和操作系统将地址转换为实际/硬件地址。

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

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 上有一些非常好的工具可以友好地完成这项工作。我最喜欢的是 海湾合作委员会探索者

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

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

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

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

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

在堆栈上添加占用 N 个字节的变量并不会(必然)将 bin 的大小增加 N 个字节。实际上,大多数情况下它只会添加几个字节。
让我们从一个示例开始,该示例演示如何向代码 威尔中添加1000个字符以线性方式增加容器的大小。

如果1k 是一个字符串,由1000个字符组成,声明如下

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

然后到 vim your_compiled_bin你就能看到那个字符串在垃圾箱的某个地方。在这种情况下,是的: 可执行文件将大1千,因为它包含完整的字符串。
但是,如果您在堆栈上分配一个 intcharlong数组,并在循环中分配它,那么可以采用下面的方法

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

然后,不: 它不会增加垃圾桶... 由 1000*sizeof(int)
编译时的分配意味着你现在已经理解了它的意思(基于你的注释) : 编译后的 bin 包含了系统需要的信息,以了解执行时函数/块需要多少内存,以及关于应用程序需要的堆栈大小的信息。这就是系统在执行 bin 时分配的内容,你的程序就变成了一个进程(好吧,你 bin 的执行就是这个进程... ... 好吧,你明白我的意思了)。
当然,我在这里并没有描绘出完整的画面: bin 包含有关该 bin 实际需要的堆栈有多大的信息。根据这些信息(以及其他信息) ,系统将保留一块称为堆栈的内存,程序可以自由支配这块内存。在启动进程(执行 bin 的结果)时,堆栈内存仍然由系统分配。然后,进程为您管理堆栈内存。当一个函数或循环(任何类型的块)被调用/执行时,该块的本地变量被推送到堆栈中,然后它们被移除(堆栈内存可以说是 “自由”)以供其他函数/块使用。因此,声明 int some_array[100]只会向 bin 添加几个字节的额外信息,这将告诉系统函数 X 将需要额外的 100*sizeof(int) + 一些簿记空间。

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

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

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

现在你的问题是什么是“编译时分配的内存”。毫无疑问,这只是一个措辞不当的说法,它被认为是指二进制段分配或堆分配,在某些情况下甚至指堆分配,但在这种情况下,分配是隐藏在程序员的眼睛不可见的构造函数调用。或者可能说这句话的人只是想说内存不是在堆上分配的,但不知道堆栈或段分配。(或者不想深入那种细节)。

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

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

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

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;

它会告诉链接器,bss 需要208字节,“ data”需要16字节,“ const”需要28字节。此外,对变量的任何引用都将被区域选择器和偏移量替换,因此 a、 b、 c、 d 和 e 将分别被 bss + 0、 const + 0、 bss + 4、 const + 24、 data + 0或 bss + 204替换。

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

当加载一个程序时,根据平台的不同,通常会发生以下四种情况之一:

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

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

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

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

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

您的问题的核心是: “如何在编译后的文件中“分配”内存?内存不是总是在 RAM 中分配的吗? 所有的虚拟内存管理都是这样的?按照定义,内存分配不是一个运行时概念吗?”

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

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

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

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

    int c;
    
  • It produces output for the assembler that instructs it to reserve memory for the variable 'c'. This might look like this:

    global _c
    section .bss
    _c: resb 4
    
  • When the assembler runs, it keeps a counter that tracks offsets of each item from the start of a memory 'segment' (or 'section'). This is like the parts of a very large 'struct' that contains everything in the entire file it doesn't have any actual memory allocated to it at this time, and could be anywhere. It notes in a table that _c has a particular offset (say 510 bytes from the start of the segment) and then increments its counter by 4, so the next such variable will be at (e.g.) 514 bytes. For any code that needs the address of _c, it just puts 510 in the output file, and adds a note that the output needs the address of the segment that contains _c adding to it later.

  • The linker takes all of the assembler's output files, and examines them. It determines an address for each segment so that they won't overlap, and adds the offsets necessary so that instructions still refer to the correct data items. In the case of uninitialized memory like that occupied by c (the assembler was told that the memory would be uninitialized by the fact that the compiler put it in the '.bss' segment, which is a name reserved for uninitialized memory), it includes a header field in its output that tells the operating system how much needs to be reserved. It may be relocated (and usually is) but is usually designed to be loaded more efficiently at one particular memory address, and the OS will try to load it at this address. At this point, we have a pretty good idea what the virtual address is that will be used by c.

  • The physical address will not actually be determined until the program is running. However, from the programmer's perspective the physical address is actually irrelevant—we'll never even find out what it is, because the OS doesn't usually bother telling anyone, it can change frequently (even while the program is running), and a main purpose of the OS is to abstract this away anyway.

我觉得你应该退后一点。在编译时分配的内存..。那是什么意思?这是否意味着还没有被制造出来的芯片上的存储器,还没有被设计出来的计算机上的存储器,以某种方式被保留了下来?没有。不,时间旅行,没有编译器可以操纵宇宙。

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

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

所以如果你的数据段是500字节,你的程序有一个500字节的面积。如果将数据段更改为1500字节,则程序的大小将增加1000字节。数据被组装成实际的程序。

这就是编译高级语言时的情况。实际数据区域在编译成可执行程序时被分配,从而增加了程序的大小。程序也可以动态地请求内存,这就是动态内存。你可以从 RAM 中请求内存,CPU 会把内存给你使用,你可以放开它,你的垃圾收集器会把它释放回 CPU。如果需要的话,一个好的内存管理器甚至可以把它交换到硬盘上。这些特性是高级语言提供的。

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

的确,不能在编译时分配内存。 但是,在编译时实际发生的情况是。

解释来了。 例如,一个程序有四个变量 x,y,z 和 k。 现在,在编译时,它只是创建一个内存映射,其中确定了这些变量相互之间的位置。 这个图将更好地说明它。

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

empty field

接下来,执行该程序的第一个实例。 你可以把它想象成这样。 这是实际分配内存的时间。

first instance

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

second instance

第三个。

third instance

诸如此类。

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

在被接受的答案中给出了很好的解释。以防万一,我会张贴我发现有用的链接。 Https://www.tenouk.com/modulew.html

编译器所做的众多工作之一是创建和维护 SYMTAB (section.SYMTAB 下的符号表)。这将纯粹由编译器使用任何数据结构(列表,树等)创建和维护,而不是为开发人员的眼睛。开发人员提出的任何访问请求都会首先出现在这里。

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

“符号名称”列将具有变量名称,而偏移量列将具有偏移量值。

让我们看一个例子:

int  a  ,  b  ,  c  ;

现在我们都知道,寄存器 Stack _ Pointer (sp)指向堆栈内存的顶部。假设 sp = 1000。

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

所以 a 的等效偏移值是0。 (编译时间偏移量 _ 值)

然后 b 和它的等效偏移量值将是1

那么 c 和它的等效偏移量值将是2

现在计算 a 的物理地址(或)运行时内存地址 = (sp + 偏移量 _ 值) = (1000 + 0) = 1000

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

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

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

注: 只有在程序加载后,堆栈 _ 指针值才会被赋值。指针算法发生在堆栈 _ 指针寄存器和变量偏移量之间,以计算变量 Physical Address。

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

分享一下我从这个问题中学到的东西。

你可以通过两个步骤来理解这个问题:

  • 首先,编译步骤: compiler生成二进制文件。在 Linux 系统中,二进制文件是 ELF (Executable and Linkable Format)格式的文件。ELF 文件包含几个部分,包括 .bss.data
.data
Initialized data, with read/write access rights


.bss
Uninitialized data, with read/write access rights (=WA)

.data.bss只是映射到进程内存布局的片段,其中包含静态变量。

  • 第二,加载步骤。当执行二进制文件时,ELF文件将被加载到进程的内存中。加载程序可以从 ELF 文件中找到静态变量的信息。

简单地说,编译器和加载器遵循相同的标准进行通信,标准是 ELF 格式。