操作系统学习笔记(四) 存储模型和虚拟内存
一、存储器抽象:地址空间
现代计算机采用多道程序设计,系统中同时运行了多个进程。要保证多个程序同时在内存中并且互不影响,就需要解决两个问题:保护和重定位。
地址空间:一个进程可以用来寻址内存的一套地址集合。
每个进程都有自己的一个地址空间,并且这个地址空间独立于其他进程的地址空间。有了地址空间,一个进程就知道了自己可以寻址的范围,从而解决了保护的问题。
解决了这个问题,又有一个问题出现了:内存不够用。
现在的PC,一般也就是4G或者8G的内存,甚至可能只有2G。而一个进程,几百M都是很正常的,大型的游戏可能数G了。
一台电脑要同时运行许多进程,以及一些操作系统自身占用的内存,如果全部程序都在内存中,那么内存是肯定不够用了。
交换:把一个进程完整调入内存,使该进程进入内存运行一段时间,然后把它存回磁盘。这样,空闲的进程主要存储在磁盘上,不运行就不占用内存。
交换技术的问题在于,磁盘与内存之间的I/O操作是很慢的,而且会在内存中产生很多空洞(hole)。所以要每隔一段时间进行内存紧缩,这样会花费大量的时间。
编程语言通常有动态内存的功能,例如C中的malloc,C++中的new和allocator等,一般在堆上进行分配。操作系统需要对动态内存进行管理,方法主要有两种
(1)位图。位图是一种数据结构,简单来说,就是用一个比特位来表示可用和不可用两种状态。把内存分成大小为N的单元,用一个二进制位来表示可用或者不可用,从而可以用一个比较小的空间表示一大段内存的使用情况。
位图的缺点是耗时大,查找空闲需要大量的时间。
(2)空闲链表。操作系统维护一个记录着已分配内存段和空闲内存段的链表。当申请一块新内存的时候,要从空闲链表中进行查找,匹配方式有很多种:首次适配,下一次适配,最佳适配等等。可以对链表进行排序以提高查询的速度。
此外,还可以对常用大小的内存块分别设置链表,比如4KB的块组成一个链表,8KB块组成一个链表等。这种方式与Linux采用的伙伴算法有一定的相似之处。
二、虚拟内存
虚拟内存是大部分现代操作系统所采用的方式。交换方式虽然可以解决一部分内存不够用的问题,但是I/O操作代价是比较大的,磁盘速度比较慢。
虚拟内存的基本思想,就是每个进程有自己的地址空间,并且把这个空间分成许多块,每一块叫做页面(page)。每一页都是一个连续的地址范围,他们可以被映射到物理内存,页并不需要在进程运行的每一时刻都在内存中,它可以只在需要的时候才被调入,其他时间可以被调出内存。
进程对应的虚拟地址,构成了虚拟地址空间。虚拟内存可以通过MMU(内存管理单元)映射为物理地址。物理内存中的页称为页框(page frame)。
实际内存中,用一个标志位表示一个页在不在内存中。如果要读的页不在内存中,将会产生一个缺页中断,使得CPU陷入操作系统中,把页面的内容读到页框中,再次请求该页。
虚拟内存可以被分为虚拟页号(高位)+偏移量(低位)。
页表:页号可以作为页表的索引,来找到该虚拟页面对应的页表项。由页表项可以找到页框号,把页框号送到偏移量的高位,形成送往内存的物理地址。物理地址=页框号+偏移量。
一个页表项需要包括:保护位(允许的访问类型),修改位(是否被修改),‘’在/不在‘’位(是否在内存中),高速缓存允许位,以及最为重要的页框号。当然,还可能有更多位。
页表的问题在于,如果内存很大,页表项非常多,那么页表占用的内存也会很大。另外,每个进程都要有自己的页表,因为页表提供了虚拟内存到物理内存的转换。
TLB(快表,转换检测缓冲区):相当于一种缓存,因为有些页面的访问频率很高,所以把这些页面放在一个高速缓存中(例如内置在MMU中)。
需要访问某个页时,先从TLB中查找,未命中才向页表查找,并用它替换掉另外一个页表项。
软失效和硬失效:页不在TLB中,但是在内存中,称为软失效;在硬盘中,称为硬失效。很显然,硬盘I/O是很慢的。
多级页表:页表项太多,会占用太多的内存。于是,多级页表应运而生。本质上,多级页表就是一级索引的项仍然是一个索引,而非页表项。这样可以节约大量的空间,避免把全部页表一直保存在内存中。
倒排页表:之前的页表是虚拟地址到物理地址的转换。倒排页表,就是物理地址到虚拟地址的映射。这样做的目的,也是为了应对页表太大的问题。这样,每个物理页框有一个表项,记录(进程-虚拟页面)映射到了该页框。倒排页表的缺陷,在于进程使用虚拟地址的时候不方便,必须搜索这个倒排页表才能得到物理页框地址。为了提高速度,可以采用散列的方法。
三、Linux系统中的实现
1、基本概念
Linux操作系统中,每个进程都有三个段:代码段、堆栈段、数据段。通常,代码段不发生变化。
数据段有两部分:初始化的数据和未初始化的数据(BSS段)。未初始化的数据在加载后被映射到一个专门的静态零页框,赋值后进行写时复制。
Linux允许数据段随着内存的分配和回收而增长和缩减,从而解决动态分配的问题。
Linux允许共享代码段内存,但是每个进程的数据和堆栈不共享。
Linux允许内存映射文件,即把一段内存映射到文件上,文件可以想数组一样被读写。
2、Linux系统中的内存管理
Linux区分三种内存区域:
(1)ZONE_DMA:可以DMA操作的页。
(2)ZONE_NORMAL:正常规则映射的页。
(3)ZONE_HIGHMEM:高内存地址的页,不永久性映射。
Linux的内存由三部分组成:常驻内存的内核和内存映射,以及被划分为页框的其他部分。内核维护一个内存映射,包含所有物理内存使用情况的信息:
首先,Linux维护一个页描述符数组mem_map,页描述符为page类型,系统中的每个物理页框都有一个页描述符,每个页描述符都有一个指针,指向它所属的地址空间(倒排页表),另外有一对指针可以与其他描述符形成双向链表,记录空闲页框和一些其他域。
因为物理内存被分成了三个区,所以为每个区维护一个区域描述符。区域描述符记录了每个区域内存使用情况,以及一个空闲区数组,第i个元素标记2^i个空闲页的第一个块的第一个页描述符(指针数组)。
Linux采用了一个四级页表。
3、Linux的内存分配机制
Linux支持多种内存分配机制,页面分配器使用了伙伴算法。
当一块内存过大时,会被分为两个大小相同的部分,组成一对伙伴。划分会一直持续,直到再次划分不足以容纳。释放内存时,如果一对伙伴都为空闲,则会进行合并。
同时,LInux维护一个指针数组,数组的元素是大小为1 2 4……个单位的内存块链表的头部。
另外,还有一种slab分配器,它也使用伙伴算法获得内存块,但是从其中切出给小的单元。例如,一个65页面的块,需要一个128页面的块来使用。
PS:图片来自《现代操作系统》
四、一些零碎的东西
共享库:我们知道,一部分内存如果被多个进程共享,通常都是只读状态。如果一个进程对它进行了写操作,那么这些页面就“脏”了,系统会为另外的进程复制该页面,这就是写时复制。
当一个程序与共享库链接时,链接器没有加载被调用的函数,而是加载了一小段能够在运行时绑定被调用函数的存根例程。关键在于,一旦一部分共享库被装载,就不需要再次装载它了。共享库会按照页面装载到内存,不需要的部分暂时不会被装载。
另外,当共享库中的函数被修改时,并不需要重新编译调用了这个函数的程序。共享库相当于一个模块,只需要进行调用。
另外一个问题是重定位。链接的两个主要步骤,就是符号解析和重定位。函数的重定位也就是获取实际的地址。因为共享库在内存中只有一份,而两个进程中,可能在自己进程地址空间的不同位置需要共享页面。这样,在编译共享库时,需要用一个编译选项告诉编译器,不产生使用绝对地址的指令,而是只产生使用相对地址的指令。这样,无论共享库被放置在虚拟地址空间的什么位置,指令都可以正常工作。只使用相对偏移量的代码被称作位置无关代码。
与分页有关的实现工作:
创建新进程时:操作系统要确定程序和数据初始有多大,创建一个页表,并在内存空间中为页表分配空间并进行初始化。进程被换出时,页表不必驻留内存,但是当进程在内存中时,页表也必须在内存中。为了解决内存不足的问题,可以把一部分内容放在磁盘交换区。Linux就有交换区(swap),可以通过一系列指令设置交换区大小以及查看交换区的使用。当然,硬盘I/O速度很慢,swap使用过多可能会造成速度变慢许多。好像我就有这种体验...实验室电脑比较渣,虚拟机内存设置的比较少,然后用top查看的时候就发现内存和swap区占用都很多,操作简直慢如蜗牛0 0通常是因为我开了Pycharm...