【梳理】简明操作系统原理 第四章 内存管理(一):虚拟地址、段和空闲列表(内含文档高清截图)

参考教材:
Operating Systems: Three Easy Pieces
Remzi H. Arpaci-Dusseau and Andrea C. Arpaci-Dusseau
在线阅读:
http://pages.cs.wisc.edu/~remzi/OSTEP/
University of Wisconsin Madison 教授 Remzi Arpaci-Dusseau 认为课本应该是免费的。
————————————————————————————————————————
这是专业必修课《操作系统原理》的复习指引。
在本文的最后附有复习指导的高清截图。需要掌握的概念在文档截图中以蓝色标识,并用可读性更好的字体显示 Linux 命令和代码。代码部分语法高亮。

四 内存管理(一):虚拟地址、段和空闲列表

1、早年,设计操作系统很容易。因为用户们要求不高,不像后来总是要“易用”“高性能”“可靠”之类的。那时的操作系统非常小,只驻留于内存中最低的那部分地址。剩余空间则存放单个用户程序。
由于早期的计算机非常昂贵。为了更好地共享每一台机器,在后来,多道程序设计出现了。当一个程序因为请求IO或其它原因暂停时,OS就切换另一个程序执行。不久后,对算力的需求增长越来越强烈,于是分时系统诞生了。特别地,以程序员为主的很多人意识到了批处理的弊端,且疲于冗长的调试过程,于是交互越来越被强调。因为彼时已经存在大量用户同时使用一台机的情况,每个用户都希望及时获得程序的响应。
早期实现任务切换时,将暂停的程序的进度保存在磁盘里,然后从磁盘把另一份程序载入内存。但是磁盘的速度相比内存是非常慢的。之后内存容量越来越大,暂停的程序就可以直接保留在内存中了。这时需要保存的主要就是寄存器的值。再后来,对分时的需求越发强劲,多任务OS出现了。内存保护也开始发展,一个程序一般不允许读取其它程序的内存。
每个进程被分配了单独的内存空间,这就是地址空间(address space)。一个进程的地址空间是它可以自由访问的内存空间,保存了该程序的全部数据。一个进程的地址空间含有栈,用于刻画程序在函数调用链中执行到的位置(想一想递归的过程),并用于存储局部变量、参数和返回值。地址空间还含有堆,用于动态分配内存。这部分内存是由用户通过改动程序的代码自行申请的。地址空间还有用于其它用途的部分,比如保存static变量的静态存储区。但在这里我们只讨论三部分:代码、栈和堆。
本复习指导统一规定:“向下生长”指的是高地址空间向低地址空间(从非0x0到0x0)扩展,“向上生长”则相反。
一般而言,栈向下生长。Windows下,每个进程分配的栈空间默认为1 MB,Linux下通常为8 MB。栈空间超过限制,会发生栈溢出(Stack overflow)。栈和堆的一个主要区别就是堆空间的使用不是连续的,并且栈比堆快。
OS会给每一个进程分配一个虚拟地址(virtual address),又称线性地址(linear address)。程序给出的偏移称为逻辑地址(logical address),加上基地址(所在的一段空间的开头)以后就得到线性地址。每个进程的逻辑地址都从0开始,并与物理内存的地址一一对应。这称为物理内存对进程透明(transparent),是虚拟地址机制的第一个目标。也就是说,程序不能得知它在内存中的实际地址。OS与硬件(MMU)配合,确保进程不会进行非法访问。现代计算机系统中,MMU已经十分复杂,虚拟地址机制的细节这里一般不予讨论。总之,操作系统层面以上皆为虚拟地址,它们与物理地址的关系不一定是线性关系。
第二个目标是效率。为了确保虚拟地址机制的时空复杂度不过高,操作系统依赖硬件支持,比如TLB(翻译后备缓冲区)。具体实现将在相应的课程中学习。
第三个目标是安全。OS必须阻止一个进程在读写时不能访问到其它进程或操作系统本身的内存空间。

2、隔离(isolation)是可靠的操作系统必须实现的机制。所有的OS都会把不同进程的地址空间隔离,也会把用户进程和操作系统的内存空间隔离。现代操作系统往往采用微内核(microkernel)设计,把最核心的部分与系统的其它部分也隔离。
在C / C++程序中,用格式化输出函数printf通过格式符%p输出指针。你会看到一个十六进制的值,它的长度为32位或64位。这是一个虚拟地址。无论输出变量还是输出函数(包括main())的地址,它们都不对应物理地址。所有用户进程都无法直接获取变量或函数的物理地址,实际地址只有操作系统可见。当需要对指定地址进行操作时,OS和硬件会翻译成实际地址再做相应的操作。

3、在C / C++中,如果在函数内新建局部变量,编译器在编译时会补充申请栈内存的指令。当函数返回后,编译器也会补充相应指令,使得执行时局部变量在函数结束后被清除。这个申请内存的过程是隐式的(implicit)。而通过malloc和free,或new和delete来申请或释放新的内存时,过程是显式的(explicit)。自由度高是C / C++极为显著的特点。用户申请新的内存空间没有太多限制,而且需要手动释放新申请的空间,所以带来了很多bug。
在终端输入man malloc,可以查看该函数的使用说明。要分配的字节数可以用运算符sizeof给出。sizeof()的值会在编译时期确定,所以一般不视其为函数调用,而将其看作运算符。

调用free来释放新分配的内存。待释放内存的大小没有在语句中给出,需要在内存分配库中查找。
较新的语言中,多引入内存自动管理机制。垃圾收集器会自动释放不再被引用的内存,无需程序员手动释放。
在C / C++中,手动管理内存常见的错误有:
·段错误(segmentation fault)。原因是访问了非法的指针。例如复制一段数据时,未申请新的内存空间,就会引发此错误。
·缓冲区溢出(buffer overflow)。原因是数组访问越界。有时候运行过程中越界时不会报错,但是实际上可能已经覆写了其它变量。
·未初始化。注意用malloc新申请的内存不会初始化。
·未释放已经不使用的内存。
这会造成内存泄漏(memory leak)。例如在一个函数中用局部变量保存了新申请内存的首地址,当这个变量因为函数结束后被清除,或者不小心把这个变量指向了其它内存空间,那么原来新申请的空间还处于被占用的状态而无法释放,这就造成了内存泄漏。
在长期运行的应用程序或操作系统中,这是一个非常严重的问题。缓慢的内存泄漏经过长时间的积累,最终会耗尽全部的内存空间,从而不得不重启。垃圾回收机制是无法解决内存泄漏问题的:当垃圾收集器侦测到一段内存仍在被引用,就不会释放这段内存。
有些时候似乎不手动调用free并无问题。例如:程序运行时间非常短,内存的释放交由程序退出时OS自动清除其全部占用空间。虽然这并不会出错,但不是一个好的习惯。如果把这种习惯带到开发长期运行的OS或应用程序中,就会无意间造成内存泄漏。所以,一旦新申请的内存不再使用,就应该尽快释放。
当然,随着计算机科学与技术的发展,现在有的IDE(集成开发环境)和软件可以检查是否存在内存泄漏,例如purify和valgrind。杀毒软件可能会将大量产生内存泄漏的进程结束,有的操作系统也会结束造成大量内存泄漏的进程(例如恶意在短时间内通过死循环申请内存的进程)。
·在使用完毕前释放内存。当内存释放后,指向它的指针还没有被修改,此时这些指针称为野指针或悬挂指针(dangling pointer)。未初始化的指针变量也是野指针。通过野指针进行的操作可能导致程序崩溃或覆写了其它区域的内存。
·重复释放内存。释放已经释放的内存,是未定义行为(undefined behavior,UB)。
未定义行为是编程语言的标准中没有规定的行为。下标越界就是一种常见的未定义行为。编译器在优化时,假定不符合标准的行为永远不会发生,以充分进行优化、降低编译和运行需要的运算量,因为错误检查是要耗费算力的,而语言和编译器的设计者无法穷举所有的错误行为。
考虑下面的语句:
int a[10];
编译器容易检测诸如a[11]这样的越界,但如果是这样的语句:
int* p = a[5]; p[6] = 1;
就不容易检测了。能导致未定义的行为多种多样,设计语言标准和编译器时,难以覆盖全部的未定义行为。
另外,如果你是用C / C++编程的,你应当了解:C / C++的设计理念中具有非常重要的两条——
(1)信任程序员。(2)不需要为不使用的特性付出代价。
C / C++的发明者和C / C++编译器的作者默认C / C++程序员应当具有足够的能力使每一条语句都严格符合标准,并希望尽可能提升性能,因此许多常见的错误均被视为未定义。编译器不负责检查并报告未定义行为。
同一个未定义行为在不同环境下一般会引发不同的后果。
·不正确地释放内存。free必须释放由malloc分配的内存。如果free的参数不是原来新申请的内存的首地址,那么会出现严重错误。这种错误是坚决要避免的。

4、malloc和free都是库调用,不过它们在库中也是通过系统调用实现的。系统调用brk通过改变堆的末端的地址来调整堆的大小,sbrk则增加堆大小。但应该调用malloc和free而不要直接调用它们,否则会出现严重错误。

5、与malloc类似的函数有很多,比如calloc能在申请新内存的基础上将新空间初始化(清零)。realloc则申请更大的内存空间,并将指定空间的数据搬运到新的内存空间中。

6、回忆之前讲过的有限直接执行(LDE),它通过切换用户模式和内核模式来确保程序不会进行非法操作。LDE和基于硬件的地址翻译(hardware-based address translation),或者简写为地址翻译,将内存的物理地址映射到虚拟地址,来进一步限制程序的非法行为。这个机制要通过硬件、软件结合才能实现。

7、考虑下面的函数:
void func() {
int x = 3000;
x = x + 3;
}
将其转换为汇编语言是这样的:
30: int x = 3000;
00007FF61BC617EA C7 45 04 B8 0B 00 00 mov dword ptr [x],0BB8h
31: x = x + 3;
00007FF61BC617F1 8B 45 04 mov eax,dword ptr [x]
00007FF61BC617F4 83 C0 03 add eax,3
00007FF61BC617F7 89 45 04 mov dword ptr [x],eax
(环境:Windows 10 Professional,Visual Studio 2019,MSVC Debug模式(Release模式下该函数会被跳过))
在IDE中选择“反汇编(disassembly)”,能够看到每条汇编指令及其虚拟地址和机器码。基址寄存器(base register)用于实现偏移。一种偏移方案是:
物理地址 = 虚拟地址 + 基址
这样,应用程序访问0地址的时候,实际上访问的就是其在内存中的地址空间的开头。这个地址互相转换的过程叫做地址翻译,也叫动态重定位(dynamic relocation),因为它发生在运行时,而且在进程开始运行后,我们甚至还可以将整个地址空间移动。
除了基址寄存器,还有界限寄存器(bound register),又称限制寄存器(limit register)。每个CPU都有这样的寄存器,位于MMU中。当进程尝试访问内存时,处理器会检查其最终要访问的地址是否在界限寄存器中指定的限制范围内。如果不是,则CPU产生异常,进程可能会被终止。界限寄存器既可以存储地址空间的大小,又可以直接存储地址界限。这两种方法只有实现过程的区别(先检查大小后加基址,或者先加基址后检查实际地址),最终效果是一样的。
但是一定要注意:MMU进行地址转换时,并不仅仅使用这一种方法。在用户层面上看到的一段连续存储的数据,在物理内存中也许不是连续的,甚至两个进程还会有一部分虚拟地址对应的实际地址重叠。不过MMU会保证同一时刻一个内存单元只能由一个进程来访问。详细的机制这里不细讲。
早期的地址翻译由于没有硬件支持,是纯软件方法实现的,称为静态重定位(static relocation)。被称为加载器(loader)的软件来将可执行文件的代码中的地址统一增加一个偏移量,重写为实际地址。但是软件方法实现的地址翻译不提供保护,因为程序员可以设法写入特殊的地址来访问非法区域。另外,如果要将整个进程的地址空间在内存中移动,也比较困难。
硬件必须提供修改基址寄存器和界限寄存器的特权指令。

9、为了实现动态重定位,操作系统需要做这几件事:
(1)维护一个空闲列表(free list)来存放可用的内存块的标号。在进程创建和结束时,这个表都要及时修改。
(2)如果切换了其它进程,那么OS需要修改这两个寄存器的内容,使其与新进程匹配。当原进程恢复后,OS需要从PCB(进程控制块)中读取并还原这两个寄存器的原有内容。
(3)对于非法访问,CPU必须生成异常,操作系统侦测到后必须执行异常处理程序(exception handler)。这些处理程序要在系统启动时装入内存。

10、Linux系统中,许多情况下栈占用的地址比堆要高,栈和堆之间还剩余比较大的空间。不过堆的使用不一定是连续的。堆扩大时,新数据写入的位置在进程的地址空间范围内可以是无规律的。这与Linux内核版本有关。Windows的进程地址空间的分配方式与Linux的颇有不同。而且较新版本的Windows系统为了防止缓冲区溢出攻击,引入了地址空间布局随机化(address space layout randomization,ASLR),此时更不应对栈和堆的相对位置作任何假设。无论如何,内存中总是分散着许多未被利用的空间。这些空间都可以存放新的进程。
地址空间被分成若干个段(segment)。代码段、栈段、堆段等的基址寄存器和界限寄存器都保存在MMU中。

11、以直接使用“基址+虚拟地址”进行偏移为例,早年的VAX / VMS系统中,取虚拟地址的高2位来判断虚拟地址指向哪个段,然后进行相应的偏移,将虚拟地址转为物理地址。但如果代码只有3个段(代码段、栈段、堆段),那么最高位有一种组合不表示任何段,也就是说这两位并没有被充分利用起来,反而令剩下的位数能定位的范围(段的大小)少了足足一半。所以有些系统会把代码段放到堆中,这样就只可以用1位表示在哪个段。
也有其它的方法来判断一个特定的虚拟地址属于哪个段。有一种方法是考察地址是如何生成的。如果是由程序计数器(PC)生成的,那么就判断该地址属于代码段;如果是由栈或基址指针生成的,就判断其属于栈段;剩下的地址就判断属于堆。
栈一般向下生长,所以MMU中一般留一位来刻画向上还是向下生长。定位栈的地址时,就用相应的基址减去偏移。

12、为了节省内存,有时会让不同地址空间的段共享同一片内存区域。为了支持这种功能,硬件中引入了保护位(protection bits)。保护位设定了一个程序是否可以读写或执行段中的内容。如果把段标记为只读(包括同时允许读和执行),就可以为多进程所共享(当然进程们都无法得知实际上自己是与别的进程共享一段内存空间)。于是,判断内存访问是否非法的标准要添加一条。即使没有越界,如果权限不符,也视作非法。

13、早年的系统对段的分配更灵活,支持不同大小的段。这就需要在内存中建立一个段表(segment table)来保存每个段的信息。细粒度的段分配允许更充分的利用内存空间。

14、为了按段给每个进程正确分配内存空间,操作系统应该做这几件事:
(1)上下文切换时,必须保存或恢复相应的段寄存器中的数据。
(2)如果进程申请新内存,应当按照堆的空间是否足够来分情况处理(直接返回堆中的空闲部分,或者扩充堆的容量再返回空闲空间)。同时,段寄存器也要修改。如果物理内存已满或进程申请过于频繁,可以拒绝申请。
(3)如果不能找到满足要求的连续的空闲内存空间,那么需要对内存进行碎片整理,把内存中分散的段排到一起(移动段时,需暂停段所属的进程,并修改相应的段寄存器),尝试凑出足够大的连续的内存空间。不过碎片整理不算是一个好的方案,因为开销比较大。而且被排到一起的进程如果想扩充段的容量,那么又要对它本身甚至多个进程移动地址空间。更简单的办法是,维护一张空闲内存列表,在分配新的空间时尽量留下大段的连续空间。相关的算法有许多,将在后续章节讨论。

15、我们把内存碎片分成两种:一种是因为随机分配空闲空间造成剩余的未被分配的小块的空闲分散在各个段之间,称为外部碎片(external fragmentation);一种是因为分配器申请的空闲空间大小大于需要的大小,剩下的这部分虽然没被应用程序用到,但也被标记已分配,这些是内部碎片(internal fragmentation)。我们更多地讨论外部碎片。

16、空闲列表记录了一个堆中未使用的空间。这里用链表实现空闲列表。链表的每个节点记录一段连续的空闲内存的两个值:首地址和长度。如果用户要申请空间,在链表上取一个空闲的空间,然后修改该节点的值或删除该节点,并将新空间的首地址返回给用户。如果用户释放了申请的空间,那么要在链表上重新插入相应节点或修改相应节点的值。这里有一点要注意:由于不同空间的释放顺序不同,释放后可能会导致链表上的一片连续空间被多个节点表示为相邻的几片小的连续空间。如果链表中存在两个节点代表的空闲空间是连续的,那么要合并这两个节点,否则可能会导致具有足够的连续内存空间的情况被误判为空间不足。合并要一直进行到任何一段连续的空间在链表上仅由一个节点刻画为止。

17、通过free释放已分配空间时,并不需要传入已分配空间的大小作为参数。那么正确释放已分配空间是怎样做到的呢?多数分配器在每次分配的空间和一段空间分配一部分后剩下的空间前面都添加一个头(header),上面至少保存了新申请的这段空间的字节数。有时候这个头会包含额外的指针,以便加速释放。此外,头还包含了一个魔数(magic number),用于检验完整性及提供其它信息。
魔数一般是指硬写到代码里的整数常量,数值是编程者自己指定的,其他人不知道数值有什么具体意义。魔数常常用于检测文件类型。一般而言,一个特定的文件类型(如医学影像)都包含相同的魔数。通过魔数进行识别可以防止后缀名被修改造成的识别错误。此外,游戏《雷神之锤3:竞技场》源代码中,实现平方根倒数速算法时使用了一个魔数0x5f3759df,使得计算一个数的平方根的倒数远快于当时的主流算法,且精度表现几乎同样优秀。
释放空间时,最终释放的字节数包括头文件占用的字节数。

18、当分配器要求扩充堆空间时,通常会产生sbrk之类的系统调用,如果剩余空间允许,那么OS要查找空闲的一段物理内存,将其映射到地址空间中,并返回扩充后的末地址。

19、理想的分配器不但能迅速分配和释放内存,同时也尽可能少地产生碎片。不幸的是,由于分配和释放的顺序我们无法控制,常见的算法总有最坏的情况。下面简要介绍几种常见的空闲内存空间管理算法。
最佳适应算法(best fit):又称最小适应算法(smallest fit),在空闲列表中寻找满足要求的最小的块。虽然尽可能首先利用小空间,但是全表搜索很耗时。
最差适应算法(worst fit):在空闲列表中寻找最大的块。该方法尝试避免best fit可能产生的大量小碎片,不过由于也需要全表搜索,开销同样比较大,而且研究表明该算法的实际表现并不佳,也会产生大量碎片。
首次适应算法(first fit):找到第一个足够大的块。该方法大大减少了全表扫描的次数。如果列表将空闲空间按地址升序或降序排列,将表上的多块相邻空闲空间合成为一块会更容易,可以令产生的碎片更少。
下次适应算法(next fit):又称循环首次适应算法。取得第一个足够大的空闲内存块后,下次分配内存时从上次的搜索进度继续搜索。
此外还有其它方法,这里也简要介绍。例如:
伙伴系统(buddy system):伙伴系统每次分配的空间大小都为2的幂。在空闲列表中找到一个满足要求的块以后,将这个块依次分裂成两个伙伴块,伙伴块又各自分裂,直到再进行一次分裂就无法满足用户请求的空间大小。此时把其中一个块返回给用户。当需要合并时,可以快速判断哪两个块是伙伴块,并合并。
这里给出伙伴的概念,满足以下三个条件的称为伙伴:
1)两个块大小相同;
2)两个块地址连续;
3)两个块必须是同一个大块中分离出来的。
伙伴系统的内存分配和回收都很快,但是由于分配的空间总是2的幂,容易造成内部碎片。
slab分配:每个slab由一个或多个物理连续的页面组成,每个cache由一个或多个slab组成,每个内核数据结构都有一个 cache。例如,用于表示进程描述符、文件对象、信号量等的数据结构都有各自单独的cache。每个cache含有内核数据结构的对象实例(称为object)。例如,信号量cache有信号量对象,进程描述符cache有进程描述符对象,等等。也就是说,不同的数据结构已经预先“划好”了。到底slab中的哪些对象在使用,是通过专门的标记来刻画的。
slab分配器首先尝试在部分为空的slab中用空闲对象来满足请求。如果不存在,则从空的slab中分配空闲对象。如果没有空的slab可用,则分配新的slab,并将其分配给cache;从这个slab上再分配与对象大小匹配的内存。
slab分配器提供两个主要优点:
(1)因碎片而引起内存浪费尽可能少。
(2)可以快速满足内存请求。当对象频繁地被分配和释放时,如来自内核请求的情况,slab分配方案在管理内存时特别有效。分配和释放内存的动作可能是一个耗时过程。然而,由于对象已预先创建,因此可以从cache中快速分配(标记从空闲改为使用)。当内核用完对象并释放它时,对象被标记为空闲但不会被清除,也立即可用于后续的内核请求。分配空间时,由于尽量使用最近释放的对象的内存块,因此其驻留在CPU高速缓存中的概率会大大提高。
slab分配器首先出现在Solaris 2.4内核中。由于通用性质,Solaris现在也将这种分配器用于某些用户模式的内存请求。最初,Linux使用的是伙伴系统;然而,从版本2.2开始,Linux内核采用slab分配器。
较新Linux也包括另外两个内核内存分配器,SLOB和SLUB分配器。
简单块列表(SLOB)分配器用于有限内存的系统,例如嵌入式系统。SLOB工作采用3个对象列表:小(用于小于256字节的对象)、中(用于小于1024字节的对象)和大(用于小于页面大小的对象)。内存请求采用首先适应策略,从适当大小的列表上分配对象。
从版本2.6.24开始,SLUB分配器取代SLAB,成为Linux内核的默认分配器。SLUB通过减少SLAB分配器所需的大量开销,来解决SLAB分配的性能问题。

20、在链表中搜索内容是很慢的。先进的分配器会采用更优秀的数据结构来改善性能。例如平衡树、Splay树和偏序树。针对多核CPU的内存分配器也发展很快。

【梳理】简明操作系统原理 第四章 内存管理(一):虚拟地址、段和空闲列表(内含文档高清截图)_第1张图片
【梳理】简明操作系统原理 第四章 内存管理(一):虚拟地址、段和空闲列表(内含文档高清截图)_第2张图片
在这里插入图片描述
【梳理】简明操作系统原理 第四章 内存管理(一):虚拟地址、段和空闲列表(内含文档高清截图)_第3张图片
【梳理】简明操作系统原理 第四章 内存管理(一):虚拟地址、段和空闲列表(内含文档高清截图)_第4张图片
【梳理】简明操作系统原理 第四章 内存管理(一):虚拟地址、段和空闲列表(内含文档高清截图)_第5张图片
【梳理】简明操作系统原理 第四章 内存管理(一):虚拟地址、段和空闲列表(内含文档高清截图)_第6张图片
【梳理】简明操作系统原理 第四章 内存管理(一):虚拟地址、段和空闲列表(内含文档高清截图)_第7张图片
【梳理】简明操作系统原理 第四章 内存管理(一):虚拟地址、段和空闲列表(内含文档高清截图)_第8张图片
【梳理】简明操作系统原理 第四章 内存管理(一):虚拟地址、段和空闲列表(内含文档高清截图)_第9张图片
【梳理】简明操作系统原理 第四章 内存管理(一):虚拟地址、段和空闲列表(内含文档高清截图)_第10张图片
【梳理】简明操作系统原理 第四章 内存管理(一):虚拟地址、段和空闲列表(内含文档高清截图)_第11张图片

你可能感兴趣的:(专业课,#,操作系统原理)