首页 文章

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

提问于
浏览
143

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

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

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

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

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

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

12 回答

  • 21

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

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

    int array[100];
    

    编译器在编译时知道数组的大小和 int 的大小,因此它在编译时知道数组的整个大小 . 默认情况下,全局变量也具有静态存储持续时间:它分配在进程内存空间的静态内存区域(.data / .bss部分) . 鉴于这些信息, the compiler decides during compilation in what address of that static memory area the array will be .

    当然,内存地址是虚拟地址 . 该程序假定它有自己的整个存储空间(例如,从0x00000000到0xFFFFFFFF) . 这就是编译器可以做"Okay, the array will be at address 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 .

  • 172

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

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

    在运行时之前没有使用内存,但是在执行之前立即开始分配由系统处理 .

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

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

  • 4

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

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

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

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

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

  • 17

    在堆栈上添加占用N个字节的变量大小不超过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, char s或 long 的数组并将其分配给循环,则沿着这些行分配

    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的结果)时,堆栈内存仍由系统分配 . 然后,该过程为您管理堆栈内存 . 当调用/执行函数或循环(任何类型的块)时,该块的本地变量被推送到堆栈,并且它们被移除(堆栈存储器可以说是"freed")以供其他函数使用/块 . 因此声明 int some_array[100] 只会向bin添加几个字节的附加信息,这告诉系统函数X将需要额外的一些簿记空间 100*sizeof(int) .

  • 9

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

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

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

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

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

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

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

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

  • 5

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

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

    • 某些系统用于决定项目存储的虚拟地址

    • 虚拟地址映射到物理地址

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

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

    • 链接器使所有汇编程序's output files, and examines them. It determines an address for each segment so that they won' t重叠,并添加必要的偏移量,以便指令仍引用正确的数据项 . 在未初始化的内存中,如 c 所占用的那样(汇编程序被告知内存将由于编译器将其放在'.bss'段中而未初始化,这是为未初始化的内存保留的名称),它包含一个头字段在其输出中告诉操作系统需要保留多少 . 它可能被重新定位(通常是),但通常被设计为在一个特定的内存地址处更有效地加载,并且OS将尝试在该地址加载它 . 在这一点上,我们有一个很好的主意是什么虚拟地址将由 c 使用 .

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

  • 2

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

    static char[1024];
    

    除非您指定初始化程序:

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

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

  • 2

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

    • 在应用程序堆中(程序启动时,操作系统为您的应用程序分配了整个堆)

    • 在操作系统堆中(这样你就可以抓到越来越多)
      垃圾收集器控制堆中的

    • (与上面两者相同)

    • 在堆栈上(这样你就可以获得堆栈溢出)

    • 保留在二进制代码/数据段中(可执行文件)

    • 在远程位置(文件,网络 - 并且您收到句柄而不是指向该内存的指针)

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

    但在大多数情况下,人们只想说 the amount of memory being allocated is known at compile time .

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

  • 15

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

  • 12

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

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

  • 2

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

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

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

  • 26

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

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

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

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

    empty field

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

    first instance

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

    second instance

    第三个......

    third instance

    等等等等 .

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

相关问题