分段机制和分页机制,共同构成了保护模式下的地址映射机制,也为多任务操作系统以及程序部分加载的实现提供了支持。本节我们将探究分页机制的原理,并结合源码来研究在启动CPU分页机制前,操作系统都做哪些设置。
1.再练神功
九阴真经残卷(四)----分页机制
首先在开始讲解分页机制前,我们要明白三个地址的含义:逻辑地址、线性地址和物理地址。程序运行的时候,所有的地址都是逻辑地址,例如指令的地址、数据的地址,经过上节的分段机制转换后,就变成了线性地址(段选择子指向的段描述符里的段基址+32位偏移地址==线性地址),在没有启动分页机制的情况下,线性地址就实际对应着我们的物理地址(内存的绝对地址,而不是虚拟的线性地址)。如果我们启动了分页机制,那么现在的32位线性地址还需要经过分页机制转换才能真正得到物理地址(逻辑地址=>(分段机制)=>线性地址=>(分页机制)=>物理地址)。不知道有木有小伙伴曾经和我一样好奇,逻辑地址怎么来的?程序启动之后的指令地址和数据地址是怎么来的呢?想知道的童鞋可以看我另外一篇文章《计算逻辑地址》
我们先来看下,对于分页机制,32位的线性地址是个什么样子的结构:
我们看到,在分页机制“眼里”,线性地址就是三组索引值,第一部分是页目录索引,第二部分是页表索引,第三部分是页内索引(和数组下标一样),而分页机制又对线性地址做了什么转换呢?(这边引用jn1158359135的<深入详解保护模式下的内存分页机制>中的一幅图,自己画太麻烦了)
这个转换过程类似二维数组的索引=>页目录基地址[目录][页表]取出对应物理内存页(一页的大小4K)的起始地址,加上页内偏移量后就是我们线性地址对应的物理地址了。每个进程必须有且仅有一个页目录,这样由于各个进程的页目录基址不同,所以即使相同地址,最后映射的地址也是不同的,每个进程相当于独享4GB空间。为了使用CPU的分页机制,操作系统需要准备初始化好的页目录和页表,并且让CR3指向页目录的基址。
要知道怎么初始化页目录和页表就需要知道它们的结构,接下来就让我们一起来看下吧(此图和解释出处同上图)~
- 页框基地址标志(Field域):指示页表或页的基地址。
- Present标志(P位):在页目录/页表项中,该标志位置1表示对应的页表或页驻留在内存中,反之则说明不在内存中。若在地址转换过程中发现该位为0,分页单元将该线性地址放入CR2控制寄存器中,并产生缺页异常。
- Read/Write标志(R/W位):在页目录/页表项中,Read/Write标志清零表示相应的页表或页是只读的,否则为可读可写。
- User/Supervisor标志(U/S位):指示访问页表或页所需的特权级,若该标志为0,那么当前特权级小于3时才能对相应的页表/页寻址,反之则总能对相应的页表或页进行寻址。
- PWT和PCD标志:用于控制高速缓存对页表/页的处理方式。
- Accessed标志(A位):当分页单元对物理页框寻址时设置该标志位。注意分页单元不会重置这个标志,置位操作必须由操作系统执行。
- Dirty标志(D位):只在页表项中存在该标志位,当对某个物理页框执行写操作时设置该位。操作系统在调度时根据该位判断是否将该页写回磁盘,以此来保证数据之间的一致性要求。同Accessed标志一样分页单元从不重置该标志,而是交由操作系统完成。
- Page Size标志(PS位):该位只在页目录项中使用。若该位置1,则启用扩展分页机制——即将二级分页模型切换为一级分页模型,32位线性地址被分为10位的页目录域以及22位的偏移量域。
2.再试神功
既然又修得一张残卷,当然要练练手,我们接着上节的代码继续
- movl $0x10,%eax # reload all the segment registers
- mov %ax,%ds # after changing gdt. CS was already
- mov %ax,%es # reloaded in 'setup_gdt'
- mov %ax,%fs
- mov %ax,%gs
- lss _stack_start,%esp
由于上节我们重新设置了全局描述符表和中断描述符表,所以这边重新设置下段选择子
- xorl %eax,%eax
- 1: incl %eax # check that A20 really IS enabled
- movl %eax,0x000000
- cmpl %eax,0x100000
- je 1b
- movl %cr0,%eax # check math chip
- andl $0x80000011,%eax # Save PG,ET,PE
- testl $0x10,%eax
- jne 1f # ET is set - 387 is present
- orl $4,%eax # else set emulate bit
- 1: movl %eax,%cr0
- jmp after_page_tables
这段代码就是反复检测是不是A20开关真开启了,然后判断数学协处理器是否存在,转到after_page_tables执行分页机制相关处理
- .text
- .globl _idt,_gdt,_pg_dir
- _pg_dir:
- ;上节的代码,此处省略
- ;本节已介绍代码也省略
- .org 0x1000
- pg0:
- .org 0x2000
- pg1:
- .org 0x3000
- pg2: # This is not used yet, but if you
- # want to expand past 8 Mb, you'll have
- # to use it.
- .org 0x4000
- after_page_tables:
- pushl $0 # These are the parameters to main :-)
- pushl $0
- pushl $0
- pushl $L6 # return address for main, if it decides to.
- pushl $_main
- jmp setup_paging
- L6:
- jmp L6 # main should never return here, but
- # just in case, we know what happens.
我们看到after_page_tables前面有_pg_dir(0x0)、pg0(0x1000)和pg1(0x2000),这些就是后面我们用来保存页目录和页表的地方,它们的大小都是4K,即一个内存页的大小。标签after_page_tables后压入了几个0(main函数参数)和一个main函数地址(这个就是我们操作系统的主函数),然后跳转到后面的页表设置环节,后面的L6我们通过Linus大神的注释可以知道这个地方应该永远不会到达,执行到这说明操作系统出错了,写这个表达式只是以防万一。
- setup_paging:
- movl $1024*3,%ecx
- xorl %eax,%eax
- xorl %edi,%edi /* pg_dir is at 0x000 */
- cld;rep;stosl
这段代码是对_pg_dir、pg0和pg1所对应的内存块的初始化操作,都清空成0。edi指向逻辑地址0x0(分页未开启,分段在内核态不对逻辑地址进行改变),即物理地址0x0,也就是_pg_dir的起始位置,而_pg_dir、pg0和pg1一共3*4k==12k,现在将eax的内容0,循环赋值给ds:edi指向的内容,每次清除4个字节,一共1024*3次,则一共清除1024*3*4==12k。在完成初始化操作后,就要开始设置页目录和页表的内容了
- movl $pg0+7,_pg_dir /* set present bit/user r/w */
- movl $pg1+7,_pg_dir+4 /* --------- " " --------- */
现在我们有一个页目录和两个页表,首先设置页目录项,将页目录的前两项页目录项中的页框基地址修改为这两个页表的基地址,并且访问属性为可读写且无权限限制(对页目录项格式不熟悉的可以参见残卷四,$pg0和$pg1是页表基地址,7是访问属性和权限)
- movl $pg1+4092,%edi
- movl $0x7ff007,%eax /* 8Mb - 4096 + 7 (r/w user,p) */
接下来,这两个页表要映射整个8M内存(0.11版本是),8M内存的最后一页物理内存的基地址地址是0x7ff000(一页大小0x1000==4k),加上访问属性为可读写且无权限限制即7,对应的页表项的内容应该是0x7ff007,而这一项要写入的地址是$pg1+4092(4*1024-4,每个页表4K字节,每个页表项4字节,所以表达式表示的是pg1页表的最后一项)。为什么要映射整个8M内存而且为什么要这样映射?因为操作系统内核必须能准确访问所有内存,而且在内核态逻辑地址==线性地址==物理地址(进程的页目录项和页表项的填写都是在操作系统的代码里完成的,而且填入的都是实际物理地址,如果操作系统在逻辑地址空间找到一个空闲的内存页准备分配给进程,却还要需要自己模仿CPU算出实际物理地址,这不是自找麻烦么?),所以内核态的分页机制也不能对线性地址做任何改变,所以内核的页目录项里的地址是按页连续的,页表项里的地址也是按页连续的
- std
- : stosl /* fill pages backwards - more efficient :-) */
- subl $0x1000,%eax
- jge 1b
- xorl %eax,%eax /* pg_dir is at 0x0000 */
- movl %eax,%cr3 /* cr3 - page directory start */
- movl %cr0,%eax
- orl $0x80000000,%eax
- movl %eax,%cr0 /* set paging (PG) bit */
- ret /* this also flushes prefetch-queue */