您的位置:首页 > 其它

当我们谈论开机的时候我们在谈论什么(三)——段页式存储

2015-12-18 22:25 525 查看
本文谈论的PC都是基于X86架构,本文谈论的实现段页式存储都是基于32位操作系统。

首先说一点题外话:我在写这篇文章的时候并没有实现我想要的段页式存储。本来规划的是写一个段页式存储的,后来觉得这样我后边每一个程序都要分一个段,填充一个选择子、一个描述符(这些都是程序员手动实现的,不理解原理,没有关系,后边我会讲一点原理的部分,以及我实现的并不是我想要的段页式存储)。

本文主要讲这几个内容:

1、段页式存储是什么?

2、自己怎么实现一个段页式存储?

另外一部分的题外话(我的博文基本上都是听我胡扯和讲一点技术):

1、当我写完了段页式存储的时候,才算是看明白书本上讲的到底是什么,之前完全连个边都没摸到。

2、我希望在看本文之前你能思考一下,如果给你一个空白的一大块内存,要求你做到对其分页分段,你会怎么做。

3、如果看完本文你明白了上面这个问题,那这部分原理你已经通了。

4、为我喜欢的一个长辈祈福,愿一切都安好。

一、什么是段页式存储

1、段页式存储

我们现在用的pc的内存基本上都是4G以上的,现代操作系统支持进程并发执行,意思是同时在内存中的可能是好几个进程,这好几个进程要共享内存这个资源,每个进程需要的资源不一样。内存管理者怎么把这些内存分配给这些进程呢?

办法就是:进程去申请资源,需要多少去申请多少(我们暂且不管虚拟内存这一回事,认为进程申请资源的时候内存就会分配给他);

这个办法就是我们常说的段式存储的雏形。这种办法在实现上需要程序员自己手动申请所需要的段,因为系统并不知道这个程序需要多少内存。这种解决方案会把内存划分成一个一个大小不一的“段”(因为每个进程需要的内存是不相等的),这种办法有两个问题:

1、经过了一系列申请内存和返还内存操作之后内存会产生很多的外部碎片(外部碎片是指内存中的还没被分配出去的很小的内存片;内部碎片是指分配出去的没被使用的很小的内存片)。根据定义我们可以看出来,如果碎片是怎么也无法避免的话,内部碎片要比外部碎皮好解决一点。因为分配出去的,回收回来就不是碎片了。但是外部碎片的话,如果不关机会一直存在,很影响系统的健壮性。

2、需要程序员手动为自己的代码分配内存(实际上就是初始化段描述符和段选择子),这样是比较烦人的。

基本上是为了解决上述的两个问题,提出了页式存储。意思是把内存分成一个一个的页面,固定大小的(一般都是4096或者4096K)。内存管理并不关心页面里边是什么东西,只关心有多少多少个页面。因为分配都是固定大小的,所以不会出现外部碎片(也可以说不可能有碎片小于4096,这个对于一些进程已经够了所以不认为是碎片);而且内存变成了进程自己申请,大小固定,进程运行内存不够时候产生一个缺页异常,然后系统自动分配内存,详情参见之前写的一篇关于缺页的博文/article/9291032.html

IA32 规定内存必须分段,所以我们并不能实现只支持分页的内存管理。必须要在分段的基础上再进行分页,但是我们可以和Linux一样巧妙地避开分段管理,至于怎么避免见后文。

2、地址转换

根据上文,我们把内存分成了一块一块的同时,必然要提供一套地址转换机制,这里说的段页式存储是指在分页的基础上进行分段。意思就是说,内存会被分成一页一页,然后在进行分段,为了使内存管理变得简单,我们极力避免一页被分开。

前面讲的是段页式,我们就重点说说段页式的地址转换机制。你搞懂了这个,纯分段是很简单的。先说三个概念:逻辑地址、线性地址和物理地址。

物理地址:内存单元编址是按字节编,物理地址指的是每一个内存单元真是的地址,cpu根据物理地址就可以直接访问该单元。

线性地址:线性地址的命名是为了区别逻辑地址这个非线性地址,实际上这个也是一个逻辑上的地址,线性地址可以通过分页机制转换为物理地址。表示形式是:20位页号加上12位页内偏移(我们假设每页大小是4096字节)。

逻辑地址:程序员在程序中写的地址。表示形式为段地址:偏移量。逻辑地址通过分段机制转化为线性地址。

综上所述段页式存储的地址转化是:

逻辑地址——>线性地址——>物理地址

二、自己实现一个段页式

在这一部分我会讲一点我之前写的代码以及这个代码为什么后来我怎么推翻了,现在切入正题。

1、分段

上一篇博文讲保护模式的时候实际上已经讲了一些内存分段的代码了,前面讲了分段需要程序员自己手动分段,意思是要程序员自己手动填写段选择子和段描述符,并且初始化gdt或者ldt(这个取决于段寄存器中的引用描述符指示位)。照例,讲几个要用的概念:

1、段选择子:在实模式下段寄存器是16位,跳到了保护模式下段寄存器还是16位,但是段基址变成了32位,这个时候段寄存器保存的就是可以找到段基址的索引。这个索引被称为段选择子,由13位的索引,两位RPL和一位描述符指示位构成。这个结构是需要程序员自己填写的。

2、段描述符:描述一个段的一个数据结构,占8个字节。具体字段参见百度百科,解释很详细。这个结构也是需要程序员自己初始化的。

下面讲一点cpu怎么根据填充的段选择子得到相应的段描述符。因为一个描述符占8个字节,所以这个选择子对应的描述符在描述符表中的偏移量是这个选择子×8,gdt的首地址存储在gdtr里边。所以该段描述符的地址就是首地址+偏移量,相当于首地址+(选择子×8)。

分段的具体操作:

1、填写自己需要的段选择子

2、初始化需要的段描述符(代码段、数据段,如果使用了堆栈的话还要初始化堆栈段的描述符)

3、得到最后一个空描述符的起始地址,添加自己的描述符

详细代码参见保护模式博文,在这里要强调一点。通常我们约定俗成的把gdt的第一项设为一个空的描述符,这是为了系统的健壮性。如果程序员失误忘了填写段选择子或者是一些别的原因导致系统根据一个空的段选择子去索引对应的段描述符,一个空选择子索引到的描述符必定是gdt表中的第一项。如果gdt第一个设置成空描述符,会引起一个异常。我们可以对这个异常进行处理。如果没有这个设计,系统还是回运行下去,但是肯定是不对的。大部分情况下,一个异常要比一个逻辑错误容易debug太多了。

2、分页

比起分段,分页显得要简单粗暴一点。和我们现实生活中类似,把内存分成一页一页,必然就要提供一个目录,这个我们称为页表,页表填充的内容成为页表项,用来描述每一个页,页表项图示任何一本操作系统书上都描述的很详细,为了不是博文变得冗长,在此不在赘述,

那么有一个问题,也大小以4K为例,那我4G的内存就可以分1M个页,对应的就有1M个页表项。每个页表项占四个字节,每页也就只能存1k个页表项。如果只是采用一级页表的话,页表就有1024页,每次我们找哪个页是要遍历的,这么大一个数据结构,最坏情况遍历一遍所花费代价是不可接受的。所以,我们提出了多级页表。

分页必然要有页表,4G内存,两级页表差不多够了。所以我们后文以两级页表为例。

对于内存来说,分页并不是从0地址处开始,内存要留出一部分的空间给gdt,idt,ldt这些表来用。一般开始的地址都是2M。两级页表第一级称为页目录,第二级称为页表。页目录的起始地址存储在cr3寄存器里,cr0寄存器里第0位pe位是分页允许。

分页的大概过程是:

1、初始化页目录表

2、初始化页表

3、页目录起始地址送到cr3寄存器

4、设置cr0的pe位。

详细代码如下:

PageDirBase     equ 200000h ; 页目录开始地址: 2M
PageTblBase     equ 201000h ; 页表开始地址: 2M+4K

mov ax, SelectorPageDir ; 此段首地址为 PageDirBase
mov es, ax
mov ecx, 1024       ; 共 1K 个表项
xor edi, edi
xor eax, eax
mov eax, PageTblBase | PG_P  | PG_USU | PG_RWW
.1:
stosd
add eax, 4096       ; 为了简化, 所有页表在内存中是连续的.
loop    .1

; 再初始化所有页表 (1K 个, 4M 内存空间)
mov ax, SelectorPageTbl ; 此段首地址为 PageTblBase
mov es, ax
mov ecx, 1024 * 1024    ; 共 1M 个页表项, 也即有 1M 个页
xor edi, edi
xor eax, eax
mov eax, PG_P  | PG_USU | PG_RWW
.2:
stosd
add eax, 4096       ; 每一页指向 4K 的空间
loop    .2

mov eax, PageDirBase
mov cr3, eax
mov eax, cr0
or  eax, 80000000h


4G内存如果全部采用两级页表分页的话,页目录最多有1024条,每表项四字节,所以一共占一页。所以页表的起始地址是页目录起始地址往后偏移一页。然后后边先初始化页目录表,接下来页表。

这里要注意一点就是,上面我们说了页表项中不仅仅存储了页面的物理地址,还存储了一些别的东西。这些标志为之类的是页面分配出去以后才会填写的。

所以分页就讲完了,你可以看到,比起分段简单直接多了。

在linux中并没有牵扯到分段这个概念,你可能会说IA32不允许内存管理不分段。所以它采用了一个比较粗暴的方法:就直接分四个段(内核代码段,内核数据段,用户代码段,用户数据段),所有的运行在内核态的代码存储在内核代码段,数据在内核数据段,运行在用户态的代码存储在用户代码段,数据在用户数据段,这样就不用程序员自己去分段了,省了好多麻烦。

我之前写的代码并没有借鉴这个优点,写的是支持段页式,既有分段也有分页。因为我并不要求说一定要分段,所以这个方法被放弃了。现在重新写一个跟内核相似的段式,直接把四个段一次填好加载进gdt。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: