第二章 基础决定上层建筑——2.2 左眼看量—2.2.3 我的量被放到哪里去了

来源:互联网 发布:淘宝客服如何登陆 编辑:程序博客网 时间:2024/04/29 13:03

2.2.3 我的量被放到哪里去了】

代码写了成千上万,如果猛回头的时候却不知道自己亲手制造出来的变量们都被放到哪里去了,按照法律是应该拉出去看书的。下面我们就直捣黄龙,看看我们的变量们被安置到哪里去了。通过前面的文章,我们知道,我们写的代码需要被加载到内存里面来才能最终run起来,内存嘛,就是一块固件,在这个层面上来谈变量的安置点实在是太困难了,于是在c++的世界里面,我们从逻辑上把内存分成4个部分来存储变量,他们分别是栈区(stack)、堆区(heap)、全局/静态存储区、常量存储区。这是在网上最经常能看到的说法吧?好,我估计我们大多数人直接断章取义的看到这里都会晕死过去。那么,从这里开始,我们开始清醒起来,首先,如果还不知道“我的程序是怎么跑起来的”可以先看看前面那篇同名的文章,在此先中断一下。看完之后,我们知道,编译完之后,我们在本地磁盘上会有一个“程序运行说明书”——可执行文件。它主要分成这么几个区域,看图2.2

 

2.2 可执行文件结构简图

好,这下我们可以稍微清晰一点了,所谓的全局/静态存储区就是我们的.data这个段,而常量存储区就是我们的.rodata(很好理解,常量嘛就是只读read only咯)。其他的更细节的东西我们就不说了,下面来详细的看这四个区。

栈区(stack

直到目前为止,我们知道的只是stack存储着函数的局部变量这样一个事实,实际上,我们应该更具体一点来讨论这个所谓的stack,我们来看运行时栈(run-time stack),通过对run-time stack的理解我们可以更深入的理解栈的工作方式(不过,这里只是提一下,更深入的还在后头)。

首先,从数据结构的角度出发,栈是一种先进后出(FILO)的数据结构。那么进进出出的是什么呢?带着这个问题继续往下看,我们知道,栈和函数是紧密联系在一起的,每个函数都有一个记录自己信息的卡片,这个卡片就叫做activation records(活动记录)或者stack frames(堆栈帧),它的结构是什么样子的呢?如下面的图2.3所示:

 

2.3 运行时栈简图

于是,到这里的时候,我们可以看到,左边就是栈区,它相当于一个信息池,里面聚集了很多的活动记录。我们所说的局部变量就是在右边这个称作activation records里面分配得到的,关于这个activation recordsrun-time stack的工作流程我们会在以后谈到函数的时候作更具体的讨论,我们这里只关心量被存到哪里去这件事。

栈是由系统来控制的,程序员无法干预,入栈时会初始化变量等一些内容,弹栈的时候又会全部析构掉。同时在栈上分配的效率非常高,但是容量却是有限的。

堆区(heap

谈到堆的时候我们需要解决两个问题:如何向内核申请内存?内存申请来了之后如何管理?解决了这两个问题就基本了解了堆内存管理的原理了。

第一个问题:如何申请?

我们在前面的文字里面提到过每个进程都有一个描述它自己的结构,这个结构叫做task_struct,它由内核来管理,它里面有一项专门描述虚拟地址空间信息的,叫做mm_struct,实际mm_struct的结构还是比较复杂的,我们本着只了解皮毛的精神来对它进行一次人道主义阉割,可以得到一个瘦身版的task_struct,如图2.4所示

 

2.4 task_struct

光看这个图我们估计会浑身抽搐的,让我们把它具体到图上去,这样会好理解很多,上图,上虚拟地址空间图!如图2.5

 

2.5 虚拟地址空间

内核数据结构mm_struct中的成员变量start_codeend_code是进程代码段的起始和终止地址,start_data end_data是进程数据段的起始和终止地址,start_stack是进程堆栈段起始地址,start_brk是进程动态内存分配起始地址(堆的起始地址),还有一个 brk指向动态内存分配当前的终止地址(堆顶)。

我们知道,在 32x86系统上,每一个进程可以访问4 GB内存。刨去内核空间,我们实际能访问的是不到4GB的,一开始的时候内核也没有必要一下子把所有可用的内存全部给我们,因为毕竟同一时间并不是只有一个进程在跑,所以我们所说的4GB是虚拟地址空间,不是实际的物理内存,所以,我们肯定会碰到这种情况:当我们动态申请一片内存空间时,一开始默认分配的虚拟地址空间里的内存不够了(并不是说物理内存用光了)。怎么办?这个时候就需要向内核申请空间,为了装的比较专业,我们管这个申请叫做mapping(映射),读过高中的都知道映射是一个表示一一对应关系的数学术语 。换句话说,就是每个虚拟地址空间单元都对应的一个物理的存储单元,具体怎么对应那是内核的工作。Ok,车的有点远,言归正传,怎么向内核申请?基于 UNIX 的系统有两个可映射到附加内存中的基本系统调用:

brk brk() 是一个非常简单的系统调用。看看图2.5,我们有一个叫做brk的变量,它指向堆顶,也就是是进程映射的内存边界。 brk() 只是简单地将这个位置向高地址空间或者向低地址空间移动,向高地址空间移就可以将我们的虚拟地址空间映射到物理内存上,也就是向进程添加了内存。当然如果把brk往低地址空间移动那就是从进程取走内存还给内核了。

mmap mmap(),或者说是“内存映像”,类似于 brk(),但是更为灵活。首先,它可以映射任何位置的内存(有兴趣的可以Google一下看看mmap的函数声明,它的参数可比brk复杂多了)。其次,它不仅可以将虚拟地址映射到物理内存,它还可以将它们映射到文件和文件位置,这样,读写内存将对文件中的数据进行读写。不过,在这里,我们只关心 mmap 向进程添加被映射的内存的能力。在wikipedia里面关于mmap我们可以找到这么一句话:Anonymous mappings are mappings of that area of the process's virtual memory backed by the swap space instead of by a file in the file system name space. In this respect an anonymous mapping is similar to malloc, and is used in some malloc implementations for certain allocations. However, anonymous mappings are not part of the POSIX standard, though implemented by almost all systems.呵呵,有时候就是这么令人捉摸不透。我们不在此多纠缠,闪人。

第二个问题:如何管理?

Ok,到目前为止,我们已经成功的向内核骗取了一段内存,好了,天下好打不好管啊,我们怎么才能管理好我们打下的这篇天下呢?这就涉及到堆内存的管理问题,于是堆内存管理算法被推上了历史的舞台。随便翻开一本操作系统的书,我们都可以知道,一般来说用两种办法来管理堆内存:空闲链表法,位图法。

我们不妨做这样一个假想:内核是国王,它拥有一大片良田(堆内存),并且不是每块田都是一样大小的。他手下有一个聪明的大臣,叫做分配器。分配器大臣每天的工作就是管理全国良田的发放和回收。分配器怎么干呢?它在长期的实践工作中总结出了两套方法:空闲链表法和位图法。

先说空闲链表法。所谓空闲链表,就是分配器把一个个可用的内存串成一个一个长长的列表,这个列表就叫做空闲链表。当我们用户动态申请内存时,它就沿空闲链表寻找(怎么寻找?)一个大到足以满足用户请求所需要的内存块。然后,将该内存块(多大的块?)传给用户。当我们释放这片空间时,它将用户释放的内存块连接到空闲链上。到最后,空闲链会被切成很多的小内存片段,如果这时用户申请一个大的内存片段,那么空闲链上可能没有可以满足用户要求的片段了。于是,分配器开始想方设法的搞些内存出来(怎么搞?)以响应用户。

上面就是空闲链表法的基本思想,我们看到,在这段描述中我们提出了三个问题:怎么寻找?多大的块?怎么搞?这三个问题几乎就是这个算法中最核心的东西了。关于链表这个东西我们就不在赘述了,就那么回事。

怎么寻找?——我们管这个叫做搜索策略。一般来说的话有三种策略:

策略1:首次适配(first fit)。故名思意,就是我们从链表头开始一个一个找,当找到一个足以满足用户申请大小的块时,停止,把这个块按照某种策略分给用户(策略真多……)。这种方法的优点是它趋向于把大的空闲块留在链表的后头。但是缺点也非常明显,链表前边容易留下小空闲块的“碎片”(关于什么是碎片待会会小议一下),如果这个时候来个大块的请求,显然就要花更多的时间去适配。

策略二:下次适配(next fit)。这可是大名鼎鼎的Donald knuth提出来的,它和首次适配很像,只不过起点不是链表头,而是上次查询结束的地方。它基于这样一个假设:如果我们上次在某个空闲块里面已经发现了一个匹配,那么狠可能我们下一次我们也能在这个剩余的快中发现匹配。

策略3:最佳适配(best fit)。检查每个空闲块,选择满足了请求但是大小最小的空闲块。很显然,它不仅需要对空闲链表进行彻底的搜索,还需要判断。

多大的块?——当找到合适的空闲块了,分配多大的块给用户呢?

策略1:全部。很显然,容易造成内部碎片。

策略二:一分为二。把找到的空闲块一分为二,把满足用户申请大小的那部分分出去,另一部分留下。这样,不可避免的会带来外部碎片。

p.s关于什么叫内部碎片,后面说)

怎么搞?——Ok,当虚拟地址空间富裕的时候还好说,万一某个时候不够了怎么办呢?

办法1:合并相邻的空闲块。把小块合并成相对大的块,如果能满足,那就最好,如果合并之后还不能满足呢?那就办法2了。

办法2:向内核申请。这就回到了我们一开始说的那一堆事情。

好啦,空闲链表法说完了,下面说另一种算法——位图法。

位图法就比较简单,但是简单归简单,它特别有效。位图法的核心思想是:我们把内存分成大小相同的块,当遇到用户请求时,我们总是分配整数个块给用户,对每个块,我们用一个二进制位来对其进行表示,比如0表示已分配,1表示空闲。比如32X86系统,我们把内存切割成若干个大小为n位的块,于是我们需要一个大小为2^32/n位的位图,每一位对应一个块的状态,这样一来的话,我们管理空闲块就省事了许多。位图法的特点很鲜明:速度快、稳定性好、容易产生内存浪费(内部碎片)。更详细的内容有兴趣的话参照任意一本操作系统教程好了。

看来我们陷入的太深的,好啦,跳出来吧,下面一个是全局/静态存储区。

全局/静态存储区

在编译的时候,编译器把全局的、静态的变量存在这个区域。

常量存储区

故名思意。

好啦,说到这里我们就已经基本了解了我们定义的各个量存在哪里去了,下面为了更好的理解,跟上一个程序清单,让我们在巩固一下之前的知识:

//foo.cpp

int a = 10;//a的空间在全局/静态区得到分配。

void foo()

{

  char *p = hello;//pstack上分配,字符串“hello”在常量区分配

  int *q = new int[5];//qstack上分配,int[5]heap上分配,这句话的意思就是:在栈上有一个变量,它是一个指针,它指向了一片在堆上分配的内存空间

  delete []q;//好习惯哦

}

 

Ok,完全明白了,万事大吉了?NoNoNo,明白?我才刚上路嘞(露出大板牙笑)。实际上我们了解量的存放位置是为了避免我们不知所措的声明和使用变量。

程序员的一天是应该这样度过的

当他回首一天的时候

他不会因写不出代码而悔恨

也不会因面对编译器给出的segmentation fault不知所措而羞耻

这样在下班的时候

他才能够说,我的生命和全部的经历

都献给世界上最壮丽的事业----为挨踢事业而奋斗!'

关于碎片下次再说吧,累死啦

 

【延伸阅读】

http://en.wikipedia.org/wiki/Mmap 关于mmap

http://linux.die.net/man/2/brk 关于brk

http://linux.die.net/man/3/malloc 关于malloc

http://en.wikipedia.org/wiki/Call_stack 关于run-time stack

http://webster.cs.ucr.edu/AoA/Windows/HTML/IntermediateProceduresa2.html 关于action records

 

原创粉丝点击