一 预备 计算机的基本工作方式
在冯诺依曼体系结构的计算机中,计算机由五大部分组成:运算器,控制器,存储器,输入设备,输出设备。而运算器和控制器被做成了现代计算机的CPU,存储器也就是现在的内存,输入设备和输出设备对应了其他外设。
在这个体系下,计算机工作的基本方式是这样的:
- 程序的指令被放在内存中,内存中的每个字节都有其对应的地址,例如4K的内存,每个字节会被编号为
0 ~ 4K-1
- CPU用PC寄存器记录当前执行的指令地址,每次都会从PC指向的地址处读取一条指令执行,当执行完指令后,PC指针自动向下累加,指向下一条指令,从而CPU又会读取下一条指令执行。
如上图,假如一个应用程序被放在了0-64地址处,假如PC最开始为0,则CPU执行0处的指令,执行完后PC自动累加上一条指令的长度,例如上一条指令占两个字节,则PC自动加2,变为2,CPU又从执行地址2处的指令。
此外,我们在代码中还可以修改PC的值,从而实现跳转/调用等操作,例如图中的指令jmp 40
,就是让PC指针跳到40这个位置去执行。
在这种工作模式下,操作系统要执行一个用户程序只需要做两件事:
- 把用户程序指令加载到内存中
- 让设备CPU的PC指针,使其指向用户程序的超始位置
这样一来,CPU就会自动一条条的取指执行,从而执行完整个应用程序。
一切看起来好像都很简单,很正常,但是,jmp 40
这种指令却让我们遇到了大麻烦,为了能让这种跳转指令正常高效的执行,我们会遇到特别多的问题,而为了解决这些问题,需要对内存的格局一步步优化,从而引出整个内存管理结构。
二 相对地址/偏移地址
jmp 40
这一类跳语句的第一个问题在于,后面的40要如何解释。
进程(运行中的程序)是由操作系统加载到内存中的,它自身并不知道自己被加载到哪个位置,例如下图,这个进程的指令长度为100字节,它包含三个函数,0-20字节这块的指令为第一个函数,21-40为第二个,40-100为第三个。
假如我们想在第一个函数中调用第三个函数,可能函数一编译后的汇编指令中就存在这样一句:
jmp 40
即跳转到40这个位置去执行,由于40是函数三的起始位置,自然就跳到函数三去执行了。这种情况下似乎没什么总是,但是,如果应用程序被加载到的起始地址不是0呢?
如果应该程序被加载到的起始位置正好是物理内存0地址,这自然是没问题的,但是如果被加载到的起始位置是100,整个程序的指令位置就会变成下面的图:
这时候jmp 40
就没法跳到函数三去执行了,反而跳到了这个进程以外的地方去执行。
怎么办呢?操作系统为了解决这个问题,采用了一种偏移地址的方式,即把40解释成相对于进程起始地址的偏移量(所以也称相对地址)。具体做法是:
- 在内核中记录进程指令的起始位置,例如本例中会使用一个表格记录这个程序的起始位置为100,如下表所示。
- 在遇到
jmp 40指令时
,先用当前的进程号找到内存的起始位置(这里是100),然后把指令的目标地址40加上起始位置100得到真实的物理地址140,再跳到140执行。
进程号 | 起始位置 |
---|---|
0 | 100 |
1 | 500 |
2 | 700 |
而这张记录表被称为LDT表,这种方式就解决了指令跳转的问题。
三 内存分段
有了相对地址后,应用程序无论被加载到什么位置就都可以正常执行了。但是还有个问题:一个应用程序体积可能非常大,例如某个进程需要的内存可能是2G,难道直接在内存中划分2G的连续空间吗?
当然不行,首先不一定能找到这么长的连续空间,其次应用程序被加载到内存的不仅有指令,还有数据,指令是只读的,而数据是可读可写的,也就是说不同的内存区域可能还有不同的限制。
其次一个程序的指令很有可能是分批次加载到内存的,操作系统根本无法预知进程未来会加载多少内容。
由于上面的三种原因,我们不得不把指令和数据分开放。比如一个进程有三个指令模块和一个数据模块,我们就可以把四个模块的指令/数据分别放在不同的地方。如下面所示:
这样,每个模块就称为一个段,存放代码的中代码段,存放数据的叫数据段。
而跳转的时候,我们就不能相对进程起始位置了,而应该相对段的起始位置。例如在模块三中jmp 40
真正跳转的应该是1200 + 40 = 1240地址,而在模块一中jmp 40
应该跳转的是100 + 40 = 140地址。
所以,LDT表也不应该记录进程的起始地址,而应该记录每个段的起始地址,并且还需要记录这个段的操作权限(只读或可读可写),例如:
段号 | 起始位置 | 权限 |
---|---|---|
0 | 100 | R |
1 | 500 | RW |
2 | 700 | R |
由于不同的进程段号可能会重复,所以这个LDT表需要做成进程私有的。
四 内存分页
不同的进程的段需要的长度不同,可能有的段只需1K,而有的需要1000K,总之,基本是完全随机的。
那么,在给段分配内存时,需要如何分配?例如如下图所示,除了100K-150K和1000-1100K的位置外,其他内存位置都被别的段占用了。而此时来了一个请求,要求分配长度为40K的内存空间:
我们要怎样从空闲内存中划分出一块40K的区域给新的进程?
一般有三种策略:
最佳匹配:从空闲区域中选择空闲空间大小离申请目标大小最近的那一块,这里第一块空闲了50K,而第二块空闲了100K,所以从第一块分配40K。如图左所示。
最差匹配:与最佳匹配刚好相反,选择与目标空间相差最大的那一块,这里会选择1000K-1100K这一块空间。如下图中所示
-
最近匹配:从内存0地址开始找,找到的第一块可用的内存,然后就用这块内存分配。如下图右所示,这个例子中与最佳匹配的结果是一样的。
三种策略都有其缺点:
- 最佳匹配:会造成空间碎片,如上例,假如分配完40K内存后,又从1000K - 1100K分配了一个90K的内存,则会剩下2个10K的空闲内存。假如进程普遍申请的空间都在10K以上,则这两块内存就被浪费了。
- 最差匹配:会导致大内存申请不可用,例如本例分配完40K后,会剩下一个50K和一个60K的空闲空间。而如果再来一个90K的内存申请,就没法分配了。
- 最近匹配:太过于随机,甚至可能会同时出现上面两种问题。
为了即不让空闲空间太零碎,又可以满足大内存的申请,操作系统引入分页的概念:把内存打散,每4K为一个页,这样在应用程序分配内存时只需要找若干页分配即可,注意,这里找的页不需要连续。例如某个段的长度为11K,则它需要3个页,可能的情况是,三个页都不相邻:
这样做的好处是:没有碎片问题,因为页是紧凑的。也不会浪费太多,一个段最多浪费一页,也就是4K。
这样做的问题是:jmp 40
这种指令又不好使了,例如上图的例子,在页2中使用指令jmp 4001
,应该是跳转到页100中的某个位置去执行,而实际上会跳到页3的某个位置去执行(回想一个上面说的使用IDT表的跳转过程)。
为了解决这个问题,我们又需要调整IDT表,IDT表不再记录每个段的起始地址,而应该记录这个段每个页的起始地址。
具体做法是:都弄一张全局的页表
,这个页表记录每个页所在的位置。为了区分,我们把每一项在页表中的编号称为页号,而对应的真正的物理页的编号我们称为页框号。
而LDT表应该存什么东西呢?LDT表中存一个32位的数,图中写的是16进制,所以是8位,而这32位的前20位,也就是16进制前5位用于表示起始页号,后12位固定都是0。
如图,2号段的值0x00003 000
表明超始页号为3,也就是说偏移地址 0-4K都在3号页,4K-8K 都在4号页,8K-12K都在5号页,依次类推。 而3,4,5号页对应的物理页框号分别为0,1,101。
假如现在2号段使用跳转指令jmp 0x00002 ED0
那么首先找到LDT表中的项,这里是0x00003 000
,这个值表明该段的超始页号为0x00003
号。而0x00002 ED0
大概是11K多了,所以在5号页上(这种算法比较麻烦,我们可以直接把偏移地址16进制前5位加上起始地址即可, 0x00002 + 3 = 5
)
接下来可以在上图的页表中找到5号页的页框号为101,所以真正的物理地址肯定在101号物理页上,那么在页框号101中的偏移量是多少呢?当然就是 偏移地址 % 4K
了,对于本例来说也就是0x00002 ED0 % 4K = 0xED0
。所以真正的物理地址是101 * 4K + 0xED0 = 0x00101ED0
。
实际上由于 所以说明原偏移地址的后12位即16进制的后3位为页内偏移地址,而前20位即16进制前5位为页号偏移量。
注意这个是偏移地址,也就是指令
jmp xxx
中后面的数字的意义,而上上面的图是IDT表中的项的意义
五 虚拟内存
上面已经说清楚了偏移地址,内存分段,内存分页的原因和方式,接下来说说虚拟内存是个什么东西。
页表其实相当于做了一个虚拟页号到真实物理页号的作用,我们再看看页表的结构:
而每一页都是4K,所以这个图其实可以解释成下面这张图:
例如页号4对应的物理页框号为1,可以解释成有一个虚拟的地址16-20K对应了真实的物理地址4-8K。而这个页表用的地址组成的内存,就是我们说虚拟内存
5.1 虚拟内存的意义
上面所说的并不能解释清楚为什么要引入虚拟内存这个概念,因为不用虚拟内存,页表也可以完全的诠释页号为页框号的关系,下面来说说这个概念存在的意义。
首先,在程序被加载的时候,会先从内存空间中的到足够的页框(例如4页),这4个页框可以是不连续的。然后再把这些页映射到页表的项中。不过,页表中4个项必需是连续的。
所以说页表其实做了一个把不连续空间转换连续空间的事。
而当用户在写程序时,可以不用考虑真实的地址空间,而只需要面向这片连续的空间即可。回顾一下上面的例子,当CPU遇到jmp 0x00002 ED0
时,先从IDT表中找到表项0x00003 000
,表项中的前五位表示页号偏移量:
我们尝试把0x00002 ED0 + 0x00003 000
,其结果是0x00005 ED0
,而这个地址,不正好是对应的虚拟内存的地址吗?
这就是虚拟内存的意义,它使得应用程序员可以面向一段连续的空间工作,而操作系统又可以使用不连续的方式分配管理内存。
六 多级页表
使用页表的方式可以说已经完美解决了内存划分与分配的问题,但是内存划分与分配还有两个问题没解决:时间和空间问题。
首先是空间问题,在32位地址中内存最大是,由于每页是4K,所以共有页。页表中每项都占4个字节,所以页表一共需要占用4M空间。
这一看似乎没什么,不过有个点是,页表是进程私有的,也就是说每个进程中都有一个独立的页表。
对于页表为什么要做成进程私有的,后面到内存交换时会说
一个进程使用4M的页表空间,假如有100个进程,就需要使用400M的页表空间,这显然太大了。这怎么办呢?
最直观的想法是,我只记录使用到的页,没使用到的我都不记录,例如一个进程有两个段,分别占用了页号1/2/3和10000/10001,那我只需要记录这五项即可,剩下的项可以不记录了,如下图右所示。
这种方式空间虽然好了,但是查找时间高了。当我们要找某个页号对应的页框号时,我们最多只能二分查找,时间复杂度为O(lg(n))。对于1M页来说也就是,这意味首,我们为了执行这一条指令访问了内存近20次,本来CPU与内存的速度就相差非常多,现在还查20次肯定是受不了的。
但是如果像图左一样全部记录,虽然一次就可以直接查到项,但是空间实在太大,也不行。
有没有一种方法,时间复杂度好,空间复杂度也好?有,那就是多级页表的方法。我们可以把页号拆成两个部分,页号最大值为,也就是说有效位最多是20位,我们可以把前10位拆成一级页号,后10位拆成二级页号。
这样,由于两个页号都是10位,所以他们的最大值都是1024。并且每个一级页号也对应了1024个二级页号
因此我们可以把每个一级页对应的1024个二级页号都单独放到一个二级页表里,就形成了如下的结构
在这个结构中:
- 使用一个一级页表来关联一级页号和与其对应的二级页表
- 如果某一个一级页号下没有任何二级页号被使用了,那我们就不创建对应的二级页表
特别注意第二点,这一点说明了这个结构可以节省很多空间。设想一下,假如一个程序使用了1024页内存,最理想的情况下,这1024页分布集中,刚好处于同一个一级页号下。这种情况只需要一张一级页表和一张二级页表,占用的空间是:
相对于原来只用一张页表的情况,使用的空间小了非常多。即使不是最理想的情况,使用的空间也不会太高。
而查询的流程:
- 首先根据一级页号找到二级页表起始位置
- 再根据二级页表起始位置和二级页号找到物理页框号
需要两次查询,相比于原来多了一次,但是时间复杂度依然是O(1),速度且不论,至少次数还是相当少的。
从这里可以看出,把页表拆成两级页表的方式的确在访存次数的情况下大幅减少了空间的使用,是一种非常好的方法。那么,假如我们把一级页表拆成4层呢?也就是说把20位的页位拆成4个部分,每部分5位,分别是一级页号,二级页号,三级页号,四级页号,结果会怎样?
这里不再缀述,但是可以明确的是,在一定程度内分得越细,占用的空间越少,并且在实际的64位操作系统中的情况是:
- 64位地址中,只有48位用于寻址,也就是地址有效位是48位
- 48位有效地址中,最后12位是用于页内偏移量,前面的36位用作页号
- 前36位页号被拆成了4组,每组9位,分别是一级页号,二级页号,三级页号,四级页号
七 TLB表/快表
多级页表在保持访存次数为O(1)的情况下,大幅少减了占用的内存,可以说解决了空间的问题。不过,它依然没有解决时间的问题,因为虽然时间复杂度是O(1),但是内存与CPU的速度差距实在太大,差不多有几百倍。这个速度的差距导致即使只有几次的访存对于CPU来说也是难以接受的。
TLB表即快表是CPU中的一组寄存器,用于缓存页表中的项,但是TLB并不像页表一样,分了很多级,它直接把20位页号与物理页框号映射起来,与我们最开始说的没有分级的页表一样:
并且TLB表只会记录使用到的项。不过TLB表查询并不需要二分查找,CPU中专门做了硬件去查TLB表,只需要给出页号,硬件就能自动处理输出页框号
每次CPU需要查页表时,都会先根据20位页号从TLB表中查,如果TLB表不存在,再去页表中查,并且回写更新到TLB表中。
TLB是CPU中的一组寄存器,所以它无法做得很大,通常是64-1024之间。
有了TLB表之后,内在查询的速度才算是解决了。
八 内存的换入换出
内存换入换出的原因:虚拟内存统一提供4G视图,内存不够时需要使用磁盘交换
内存换入换出的算法:FIFO算法,MIN算法,LRU算法,时钟算法
这部分内容有点多,后续新写一篇文章
九 总结
最后,总结一下操作系统内存相关的内容。
我们知道,fork函数用于创建一个子进程,并且子进程与父进程共享内存空间,我们以子进程修改父进程内存中的数据为例,看下内存读写的整个流程:
子进程修改父进程内存的数据:
- 根据子进程CS:IP在自己的LDT表中找到起始页号,再加上偏移量计算出虚拟内存地址。
- 取出虚拟内存地址的前十位,作为一级页号,在一级页表中根据一级页号找到二级页表的起始位置。
- 取出虚拟内存地址的中间十位,作为二级页号,在二级页表中找到二级页表项
- 根据二级页表项的物理页框号和虚拟内存的后12位找到物理地址。
- 写数据,正常来说这里直接写就行了,但是由于是子进程修改父进程的内存,这里会发现二级页表项记录的操作权限为只读,也就是子进程不允许修改父进程的内存内容。
- 申请一页空闲的物理页,把物理页号替换为子进程二级页表项中的物理页号
- 把原来父进程的物理页的内容拷贝到新的物理页,这样就完成了这一页内存的拷贝。
- 修改新物理页中的内容,流程结束。