×
广告

内存中的堆和栈到底是什么

96
周鶏
2016.12.03 22:20* 字数 6834

文章也同时在个人博客 http://kimihe.com/更新

引言

网络上关于内存中各区段作用的文章有很多,但不得不吐槽一下,这些文章大多相互引用,内容大同小异,没有把问题讲解清楚。

因此,笔者想通过本文,借助汇编的知识,深入底层讲解内存模型。本文的结构如下:

  • 程序在内存中的存储模型
  • 编程过程中常见的几类变量所在的位置和作用
  • 堆和栈的细节
  • 起到抛砖引玉作用的底层原理(这意味着你需要自己去深入研究才能真正理解清楚)
  • 实验验证

前三小节是浅尝辄止地引题,详细原理请见第四小节,最后在第五小节笔者给出了可实际操作的方法,帮助大家更直观地理解。文章可能较长,请坚持读完,或者择篇章阅读。

网上的资料

首先,笔者罗列出一些质量尚可的博客,大家可以先阅读一下。之后笔者会针对大家可能存在的疑惑,从底层来一一讲解清楚。

《堆和栈的区别 之 数据结构和内存》

《执行可执行程序时内存分配的方式&&BSS段》

网上很多文章都引用到了下面这段代码:

int a = 0; //全局初始化区
char *p1; //全局未初始化区
main()
{ 
    int b; //栈
    char s[] = "abc"; //栈
    char *p2; //栈
    char *p3 = "123456"; //123456\0在常量区,p3在栈上
    static int c =0; //全局(静态)初始化区
    p1 = (char *)malloc(10); //堆
    p2 = (char *)malloc(20);  //堆
}

并且如果你搜索关键词“内存堆栈图”,将很容易找到下面这张图:


这也是笔者在查阅资料时很不满的地方,许多文章互相引用,内容雷同,却没有把问题的本质讲清楚。

因此,笔者将基于上述代码以及这张图,这两个最常被引用的东西,来把原理讲清楚。

程序在内存中的模型

注:本文所指的程序皆在用户空间运行,即不涉及操作系统类程序和驱动程序。

目前流行的那几种高级语言,归根到底,底层实现的思路都是差不多的。而且当今以Intel主流的CPU架构(虽然也有ARM),其设计理念也是一脉相承的。

要讲清楚内存模型,我们就要深入底层涉及到汇编,很多高级语言都会经历翻译到汇编这一中间过程,汇编可以直观地使用机器指令,是最接近的底层的语言。

在一个汇编程序中,常常把一个用户空间程序按习惯分为三个段:.data段,.bss段,.text段。

.data段

.data段包含了已经初始化了的数据项,这些数据在程序开始运行前就拥有自己的值,这些值是可执行文件的一部分,当可执行文件被加载到内存中用于执行时,这些数据也被加载到内存中。

定义的初始化数据越多,可执行文件就越大,运行它的时候也就需要更长的时间才能将它们从磁盘加载到内存。

一些全局或者静态的,且经过定义初始化过的变量,就属于该段。例如下面代码中的a,指针p以及b三个变量:

int a = 2;
int *p = &a;

int main () 
{
    static int b = 1;
...
...
    return 0;
}

.bss段

并不是所有数据项在程序开始之前都拥有值,例如你可以定义一个缓冲区来存在某些数据,这个缓冲区是.bss段中定义的。

分别定义.data段.bss段中的数据,它们一个重要的区别就是:.data段中的数据会添加到可执行文件的大小上,而.bss段中的则不会。即便你给.bss段定义一个1M字节的缓冲区,其最终可执行文件大小也几乎不变(除了大约50个字节用于描述外)。

程序在加载时知道哪些数据项没有初值,它会为这些数据项分配空间,而具有初值的数据线会与其初值一同读入。

一些全局或者静态的,且未经过初始化的变量,属于.bss段。例如上文中.data段段的三个变量,如果不进行初始化,就会存储在本段中。

.text段

以上两个段都是源程序所需要的数据,而真正组成程序的机器指令则存放在.text段中。一般情况下,在.text段中不进行数据项的定义。.text段包含名为标号(label)的符号,这些符号用于标识跳转和调用程序代码位置。

程序内存中的堆栈

先附上笔者在学习汇编时的一张笔记图,字比较丑,望各位见谅。


程序内存中的堆栈附笔记

通过该笔者图大家能够大概了解内存中上述三个段的位置。至于其中的堆栈以及笔记的含义请继续阅读后文。

编程过程中常见的几类变量

观察最开头经常被引用的那段代码,其中涵盖了几类最常见的变量以及其对应的存储位置。在上一小节中,我们已经说明了全局变量和静态变量存储的位置取决于是否进行过初始化。对于堆栈的解释我们留到下一小节。这里我们着重讲解文字常量区。

文字常量区

考虑如下代码:

char *p3 = "123456"; //123456\0在常量区,p3在栈上

这个文字常量区是什么?显然它与字符串存放有关。所谓字符串是指位于连续内存区域中的一个字符序列。字符串通过在起始处关联一个标号来进行定义。在汇编中,常见的字符串定义如下:

MSG: db "something"

它是位于.data段中的。和.data段中的所有变量一样,它也是一种已经初始化的数据:带有一个值,而不仅仅是一个在将来某时刻用于存放数据的内存空间。MSG标号和DB指令在内存中指定一个字节作为字符串的起点,而字符串中的字符数则告诉汇编编译器为该字符预留多少个字节的存储空间。

但高级语言中的字符串可能要比这里复杂一点,以C语言为例,针对printf函数中包含的字串。笔者认为其存储于.data段和.text段之间的一个名叫.rodata段的地方。即那张常见的“堆栈内存图”中底部绿色的“只读区”

大家可以发现,现在引出了更多的背后细节。因此,更为深入的说明我会留到第四个小节:底层的原理。

堆和栈的细节

下面进入第三小节,讲解堆和栈,这也是最开头代码中仍未涉及的两种变量存储位置。注意:我们在汇编中常说的堆栈,其实是栈,并不包含堆。

在此之前,推荐大家看一下stackoverflow的这个问答What and where are the stack and heap?

栈由系统管理。但是为什么呢?

首先,栈是一个后进先出(LIFO)结构。当把数据放入栈时,我们把数据push进入;当从栈取出数据时,我们把数据pop出来。栈随着数据被压入或者弹出而增长或者减小。最新压入栈的项被认为是在“栈的顶部”。当从栈中弹出一个项时,我们得到的是位于栈最顶部的那一个。就像给弹夹上子弹,只能在顶部进行操作。

在x86体系中,栈顶由堆栈指针寄存器ESP来标记,它是一个32位寄存器,里面存放着最后一个压入栈顶的项的内存地址。正因为有它,我们才能够随时操作到需要的项。需要注意的是,栈顶是朝着地内存方向增长的。

再来看我拍的照片,为于.bss段之间有一段空余内存,C程序经常使用这种剩余内存空间来为那些为于堆内存中的,“已经在运行中”的变量分配空间。我们常说的堆就存在于这里。

二者分别存储什么以及原因

可以看到栈有一个ESP寄存器管理,从底层就实现了一种“自动化”,而堆似乎并没有额外的东西来帮助管理。

此外,栈的大小需要有一定的限制,栈的增长是向低地址扩展,如照片中看到的,如果栈不断地增加,很可能会与.bss段发生碰撞,这是不堪设想的,系统会发出错误并终止程序。

栈应该被看成一个短期存储数据的地方,存在在栈中的数据项没有名字,只是按照后进先出来操作罢了。栈经常可以用来在寄存器紧张的情况下,临时存储一些数据,并且十分安全。当寄存器空闲后,我们可以从栈中弹出该数据,供寄存器使用。这种临时存放数据的特性,使得它经常用来存储局部变量,函数参数,上下文环境等。

相反,堆相对于栈,更加强调需要进行控制。常见的就是我们手动申请,手动释放。因此可以分配更大的空间,但开销也会更多。

底层原理

抛砖引玉

上面三个小节对于底层原理都是浅尝辄止,一上来就讲得很深入,会增加阅读负担。但在这一小节,我们必须讲一些底层的东西。不过笔者必须提前声明,虽然我们会涉及很多底层的知识,但对于整个计算机系统,这仍旧是冰山一角的知识。笔者在这里更多地是起到抛砖引玉的作用,完全讲解清楚,可能需要一本书的篇幅,而且笔者水平也很有限。这意味着如果你阅读了本文,有所启发想要一探究竟,可能就真的需要自己去探索了。

好书推荐

在这里,笔者推荐一本书:《深入理解计算机系统(原书第2版)》。我很诧异这本书竟然出到第三版了,注意第三版针对64位CPU,学习的话还是在32位下比较方便,因此推荐第二版。

可执行目标文件

程序在运行前以可执行文件的形式存储在磁盘中,我们先来看一下这张图:

典型的ELF可执行目标文件

ELF格式是类UNIX系统中可执行文件的常见格式,在众多表项中我们重点关注:.text,.rodata,.data,.bss这四个小段(节)。可以看到.text和.rodata属于只读存储器段(代码段),而.data,.bss属于可读可写存储器段(数据段)。下面具体说明这四个小段。

.text

存放已编译程序段机器代码。

.rodata

存放只读数据,如C语言中printf语句中的格式串和开关语句的跳转表。

所谓开关语句的跳转表,一个典型的例子就是switch(开关)语句的汇编实现,其使用了数组来映射代码块的地址,以此构成一张跳转表,相关的内容存储于只读数据中。

.data

已初始化的全局C变量。局部C变量在运行时保存在栈中,既不出现在.data中,也不在.bss中。

.bss

未初始化的全局C变量。如前文汇编语言讲解中提到的,它在目标文件中不占据实际空间,仅仅是一个占位符。目标文件格式区分初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。

值得一提的是,.bss原本是IBM704汇编语言(大约在1957年)中Block Started by Simple指令的首字母缩写,并沿用至今。不过在今天,我们只需要记住区别.data.bss的最简单的方法就是把.bss看成是“更好地节省空间”(Better Save Space)的缩写!

有一些特例
  • 标记有static静态标志的局部变量不在栈中管理,而是根据有无初始化,在.data或者.bss中。
  • 对于GCC编译器,初始化为0的变量存储在.bss中。

所以说,如果想真的搞清楚来龙去脉,仍旧需要你自己去阅读各类文献。

加载可执行目标文件

可执行文件在内存中运行时,有一个运行时存储器印象,我们来看一下这其中的情况,如下图:


Linux 运行时存储器映像

这张图涵盖了本文所讲的大多数知识点。相比于前文的那张汇编语言内存图,更加细分了。

  • 代码段总是从地址0x08048000处开始。
  • 数据段在接下来的下一个4KB对齐的地址处。
  • 运行时读/写段(数据段)之后接下来的第一个4KB对齐的地址处,并通过malloc库往上(高地址方向)增长。
  • 中间还有一个段是为共享库(shared library)保留的。
  • 用户总是从最大的合法用户地址开始,向下增长(低地址方向)
  • 栈上方的段是为操作系统驻留存储器部分(也就是内核)的代码和数据保留的。
  • 当程序开始运行时,加载器在可执行文件中段头部表的指引下,将可执行文件的相关内容拷贝到代码段和数据段。

上述的一些名词,比如共享库,其含义可能需要你自己去研究。Tips:Windows的.DLL。另外,笔者在参考各类文献时发现,上述诸如数据段data段等名字经常包含不同的含义,且经常一个概念有多种说法。例如只读段又可以被认为是代码段。这里大家需要注意我们所说的数据段不是指data段,而是data段bss段

其实完全细分的名称会与操作系统和CPU架构有关,笔者在这里只能针对共通的地方加以概括。

动态存储器分配

这里重点讲一下堆。

动态存储器分配维护这一个进程的虚拟存储器区域,称为堆(heap)。我们假设堆是一个请求二进制零的区域,它紧接在未初始化的.bss区域后开始,并向上(高地址方向)生长。对于每一个进程,内核维护这一个变量brk(读作"break"),它指向堆堆顶部。如下图:

分配器将堆视为一组不同大小的块(block)的集合来维护。每一个块就是一个连续的虚拟存储片(chunk),要么已分配,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲的块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,知道它被释放。这种释放要么是应用程序显式执行的,要么是存储器分配起自身隐式执行的。

  • 显式分配器(explicit allocator),要求应用显式地释放任何已分配的块。如C中的malloc和free。C++中的new和delete。
  • 隐式分配器(implicit allocator),要求分配器检测一个已分配的块何时不再被程序所使用,就去释放这个块。隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的且已被分配的块的过程叫做垃圾收集(garbage collection)。不用我说,你们也可能已经想到了Java的垃圾回收机制。

可见堆也并不是非要人工手动去管理的,文章最开始的一些说法确实是值得推敲的。

对于堆的组织方式,笔者略提一下其中的一种方式:我们可以将堆组织为一个连续的已分配块和空闲块的序列,我们称这种结构为隐式空闲链表。空闲块通过头部中的大小字段隐含地连接着,分配器可以通过遍历堆中的所有块,从而间接遍历整个空闲块的集合。如下图:


隐式空闲链表

此外,笔者还想顺带说一个很容易出问题的地方:对于C语言malloc的内存区域,通过一个指针去访问,当该片内存被free后,请务必将无效指针设为NULL!请务必将无效指针设为NULL!请务必将无效指针设为NULL!(在iOS对应的OC中,请将对象指针设为nil。)

之所以要这样,简而言之,在分配器的实现细节中,在调用free返回之后,指向分配区域的指针仍会指向被释放了的块(野指针)。现在,该块已经实效,如果再通过该野指针去访问,会出现可怕的后果。因此应该确保在该块被一个新的malloc调用重新初始化之前,不再使用该野指针,最好的防治误用的做法就是给指针置零。

分配器的设计和实现是复杂的,想要一探究竟还需要你自己去研究。

过程和栈帧

这里讲述最后一点:栈。
C语言中的函数,对应汇编中的过程。一个过程调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。另外,它还必须在进入时为过程的局部变量分配空间,并在退出时释放这些空间。上述的数据传递,局部变量的分配和释放通过操纵程序帧来实现。

程序用程序栈来支持过程调用。机器用栈来传递过程参数,存储返回信息,保存寄存器用于以后恢复,以及本地存储。为单个过程分配的那部分成为栈帧(stack frame)。下图描绘了栈帧的通用结构,最顶端的栈帧以两个指针界定,寄存器%ebp为帧指针,而寄存器%esp为栈指针当程序执行时,栈指针可以移动,因此大多数信息访问都是相对于帧指针的。(注:%espESP是同一个寄存器的不同说法而已,%ebp同理)

栈帧结构

假设过程P(调用者)调用过程Q(被调用者),则Q的参数放在P的栈帧中。另外,当P调用Q时,P中的返回地址被压入栈中,形成P的栈帧的末尾。返回地址就是当P从Q返回时应该继续执行的地方。Q的栈帧从保存的帧指针的值(例如寄存器%ebp的副本)开始,后面时保存的其他寄存器的值。

过程Q也用栈来保存其他不能存放在寄存器中的局部变量,这样做的原因如下:

  • 没有足够的寄存器存放所有的局部变量。和前文汇编语言部分解释的原因相同。
  • 有些局部变量是数组或者结构,因此必须通过数组或者结构引用来访问。
  • 要对一个局部变量使用地址操作符'&',我们必须能够为它生成一个地址。

另外,Q也会用栈帧来存放它调用的其它过程的参数。参数n位于相对于%ebp偏移量为4+4n字节的地方。较大的参数(如结构体和较大的数字格式)需要栈上更大的区域。

正如前文所讲,栈向低地址方向增长。栈指针%esp指向栈顶元素,可以用push存入数据,用pop取出数据。将栈指针的值减小适当的大小可以分配没有指定初始值的数据的空间(加入数据栈顶向低地址方向移动)。类似地,可以通过增加栈指针来释放空间(取出数据栈顶向高地址方向移动)。

实验环节(更新于2016/12/10)

纯理论的东西可能让人没有实感,对于各区段在内存中的模型,笔者也一直思索该如何以编程的方式展现,今天终于找到了一个好方法。

首先,请确保你有一个Linux或类Unix的系统环境,我们需要用一些命令。笔者是在Mac上实验,发现Mac的命令有点差异,于是ssh到了自己的Ubuntu服务器。

开始实验

考虑下述代码:

#include <stdio.h>
int a = 0; //全局初始化区
char *p1; //全局未初始化区


int main()
{
    int b; //栈
    char s[] = "abc"; //栈
    char *p2; //栈
    char *p3 = "123456"; //123456\0在常量区,p3在栈上
    static int c =0; //全局(静态)初始化区
    p1 = (char *)malloc(10); //堆
    p2 = (char *)malloc(20);  //堆

    return 0;
}

其实就是那段引用烂了的代码,笔者补全了int main()return 0。我们以此为蓝本,修改一些代码来观察生成的可执行文件的结构,以此让大家对各区段的作用有个清晰的认识。

复制粘贴编辑,gcc编译完成后,笔者将其命名为origin。接着在命令行中键入:

> size origin

可以看到如下结果:

   text    data     bss     dec     hex filename
   1384     568      24    1976     7b8 origin

关注前三个表项,列出了各区段的大小,请记住这些大小。

修改一(加入全局变量并初始化)

我们在main()函数前加入一个全局数组,并初始化一下,代码如下:

#include <stdio.h>
int a = 0; //全局初始化区
char *p1; //全局未初始化区

int arr[1000] = {233}; // 修改的代码在这里,全局数组已初始化
int main()
{
    int b; //栈
    char s[] = "abc"; //栈
    char *p2; //栈
    char *p3 = "123456"; //123456\0在常量区,p3在栈上
    static int c =0; //全局(静态)初始化区
    p1 = (char *)malloc(10); //堆
    p2 = (char *)malloc(20);  //堆

    return 0;
}

同样编译并执行size命令,笔者将其命名为addToDataSection,得到如下结果:

   text    data     bss     dec     hex filename
   1384    4584      24    5992    1768 addToDataSection

注意到data段大小增加了4000字节,原因就是全局数组在源码编译后,会直接增加到生成的可执行文件中,1000个int在32位下就是10004B = 4000B*。

修改二(加入全局变量但不初始化)

接下来,对于增加的全局数组,去掉其初始化操作,代码如下:

#include <stdio.h>
int a = 0; //全局初始化区
char *p1; //全局未初始化区

int arr[1000]; // 全局数组不进行初始化
int main()
{
    int b; //栈
    char s[] = "abc"; //栈
    char *p2; //栈
    char *p3 = "123456"; //123456\0在常量区,p3在栈上
    static int c =0; //全局(静态)初始化区
    p1 = (char *)malloc(10); //堆
    p2 = (char *)malloc(20);  //堆

    return 0;
}

编译,命名为addToBssSection,执行size命令,结果如下:

   text    data     bss     dec     hex filename
   1384     568    4064    6016    1780 addToBssSection

可以看到bss段增加了4000字节,别急,这并不意味着bss段增加的数组会作用于生成的可执行文件,还记得上文说过的吗?bss段并不增加可执行文件大小,只是加入少许记录信息。我们ls三个文件即可看到区别:

-rwxrwxr-x 1 ubuntu ubuntu 8658 Dec 10 18:53 origin*
-rwxrwxr-x 1 ubuntu ubuntu 12720 Dec 10 18:55 addToDataSection*
-rwxrwxr-x 1 ubuntu ubuntu 8695 Dec 10 18:56 addToBssSection*

可以看到bss段段增加并未显著增大可执行文件的大小,只有data段才会有所影响,增加了大约4000字节。

修改三(加入局部变量)

在这里,我们把全局数组移入main()函数中,代码如下:

#include <stdio.h>
int a = 0; //全局初始化区
char *p1; //全局未初始化区


int main()
{
    int b; //栈
    char s[] = "abc"; //栈
    char *p2; //栈
    char *p3 = "123456"; //123456\0在常量区,p3在栈上
    static int c =0; //全局(静态)初始化区
    p1 = (char *)malloc(10); //堆
    p2 = (char *)malloc(20);  //堆


    int arr[1000] = {233};// 内部数组

    return 0;
}

同样编译命名为addLocalVariable,执行size命令,结果如下:

   text    data     bss     dec     hex filename
   1456     568      24    2048     800 addLocalVariable

可以看到data段和bss段都没有什么变化,这说明局部变量不存储于这两个段,同时我们ls来查看一下四个文件:

-rwxrwxr-x 1 ubuntu ubuntu 8658 Dec 10 18:53 origin*
-rwxrwxr-x 1 ubuntu ubuntu 12720 Dec 10 18:55 addToDataSection*
-rwxrwxr-x 1 ubuntu ubuntu 8695 Dec 10 18:56 addToBssSection*
-rwxrwxr-x 1 ubuntu ubuntu 8668 Dec 10 18:59 addLocalVariable*

可执行文件大小也几乎不变,说明局部变量不会保存在其中。

修改四(局部变量设置为静态,根据是否初始化有不同的结果)

下面我们进行最后一个修改,把上述的内部数组加上static关键词,代码如下:

#include <stdio.h>
int a = 0; //全局初始化区
char *p1; //全局未初始化区


int main()
{
    int b; //栈
    char s[] = "abc"; //栈
    char *p2; //栈
    char *p3 = "123456"; //123456\0在常量区,p3在栈上
    static int c =0; //全局(静态)初始化区
    p1 = (char *)malloc(10); //堆
    p2 = (char *)malloc(20);  //堆


    static int arr[1000] = {233};// 静态数组

    return 0;
}

编译命名为addStaticVariable,执行size命令,结果如下:

   text    data     bss     dec     hex filename
   1384    4584      24    5992    1768 addStaticVariable

仔细观察,发现结果与增加全局初始化数组是一样的,这说明带有static关键词的局部变量并不存放在中,如果未初始化则存在于data段。

大家可以去掉静态数组的初始化语句,编译后执行size会返回如下:

   text    data     bss     dec     hex filename
   1384     568    4080    6032    1790 addStaticVariable

可以看到原先data段的增量转移到了bss段。同时在更改前后分别ls一下可以看到如下区别:

// 数组进行初始化,编译在data段中时,其体积计算在可执行文件中
-rwxrwxr-x 1 ubuntu ubuntu 12726 Dec 11 22:20 addStaticVariable*

// 数组未初始化,编译在bss段中时,其体积不计算在可执行文件中
-rwxrwxr-x 1 ubuntu ubuntu  8702 Dec 13 15:41 addStaticVariable*

大家可以尝试在C程序中开一个很大的局部变量数组,看看编译器会怎样提示你。

之前我曾经在Win7的VS上试过,int数组若含有超过1000个元素,编译器就总是提示编译失败。后来解决的办法是利用static关键词,将其编译进bss段。因为默认的局部变量数组存放在栈中,一下子开太大会超过Windows的限制。不过显然,生成的exe文件在执行前需要读取更多的信息。

实验小结

我们可以将上述实验结果总结如下:

  • data段保存在目标文件中
  • bss段不保存在目标文件中(除了记录bss段在运行时所需的大小)
  • 局部变量并不进入可执行文件,它们在运行时创建,一般在栈上。
  • 含有static关键词修饰的变量根据有无初始化,存储于数据段,即data段bss段

题外话

不少公司面试喜欢问内存中堆和栈区别,以及内存模型等等。这里笔者发现了一个略有trick又不失区分度的题目:请写一段代码,用来指明程序中堆栈段的大致位置。

后续会公布答案,答案非常简单也很神奇,请大家积极思考或者留言~

总结

以上就是笔者对于堆栈以及内存模型的一些理解,在总结过程中参考不少资料,以确保可靠性。希望能够解答大家的疑惑。

感谢阅读,欢迎分享、关注、点赞~

KimiTalk
Web note ad 1