操作系统——内存分页(八)

操作系统——内存分页(八)

2020-09-19 23:48:03 hawk


概述

  这一篇博客和后面的几篇博客,将会在保护模式的基础上,接触到更多的硬件,并对其进行相关的操作。这一篇博客主要完成对于虚拟内存的相关介绍和实验。


虚拟地址

  实际上,虽然通过loader程序,我们已经从实模式成功进入了保护模式,地址空间也达到了4GB。但是需要明确的是,这个内存空间仍然是所有进程(包括操作系统)共享这4GB的内存空间的。如果我们把段基址:段内偏移地址成为线性地址的话,则线性地址是唯一的,也就是同一时间线性地址应该只能属于某一个进程。

  然而这就会产生一个问题,实际上物理地址可能只有32MB(bochs虚拟机设置),但是实际上地址总线为32位,也就是每个进程对应的内存空间理论上也该是4GB。那么自然的,我们可以想到,每个进程对应的内存空间就是我们标题,虚拟空间。下面我们将具体分析一下虚拟空间的产生原因以及实现。

内存分页

  从最开始的实模式,到我们前面实现的保护模式,我们一直都是在内存分段机制下进行工作的。但是如果物理内存不足,或者内存碎片过多而无法容纳新的进程的话,这种分段机制反而成了累赘。我们考虑下图的情况

 

 

   下面这张图是一张内存管理中比较经典的布局。对于目前的内存分段机制来说,段基址:段内偏移产生的线性地址就是物理地址,而程序引用的线性地址是连续的,所以程序所分配到的物理地址也应该是连续的。所以虽然空闲空间是35M,大于进程D所需要的内存空间大小,但是由于空闲空间没有足够大的连续空间,因此仍然无法直接分配给进程D。因此合理的方案就是将一些老的进程中不常用的段换出到硬盘上,腾出空间给新进程使用,等老进程再次需要该段的时候,再从硬盘上加载到内存上即可,如下图所示

 

   

  对于如何将内存进行换入换出等操作,我之前是比较迷惑的,这里这本书比较好的解开了我的迷惑,下面说明一下。实际上对于操作系统来说,我们所写的代码仅仅是完成了某件事的一部分而已,还有很大一部分是CPU上的硬件负责的,由CPU自动完成——比如调用一个函数时,CPU自动将返回地址压入栈;进入中断时,压入返回地址、标志寄存器,根据当前特权级决定是否压入当前栈段寄存器及指针等。

  而对于内存段的换入,实际上CPU在引用一个段的时候,都要先查看段描述符,也就是我们前面实现的GDT/LDT的每一条值。如果描述符中的P位为0,则说明内存中不存在该段,此时CPU会抛出NP异常,转而去执行中断描述符表中的NP异常应对的中断处理程序,此中断处理程序是操作系统负责提供的,主要工作是将相应的段从外存(比如硬盘)中载入到内存,并且将该段描述符的P重新置为1即可。这样子就完成了内存段的换入。

  而对于内存段的移出,实际上是类似的。对于段描述符的A位,每次CPU在完成访问后,会自动将段描述符的A位置为1。而操作系统每发现该位为1后就重新将该位清0。这样在一个周期内统计该位为1的次数就知道该段的使用频率了,也就是当物理内存不足的时候,我们只需要将使用频率最低的段换出到硬盘上,并且将该段的描述符的P位置为0即可完成换出操作。

 

  这里铺垫完前面的基础,现在转入到我们的正题——为什么要采取分页机制。实际上也很好理解,一方面,如果物理内存特别的小,以至于无法容纳进程的任何一个段,那么实际上也就无法运行进程了,自然更无法完成段的换入换出;另一方面,如果进程的段都比较大,每一个进行换入换出操作都需要大量的IO操作,会导致系统响应奇慢无比,也是不现实的。而这些问题的本质,实际上都是由目前的内存机制上线性地址就是物理地址,而编译器产生的线性地址是连续的,因此这也就导致了每一个段的物理地址也必须是连续的。因此,如果我们可以解除线性地址与物理地址的一一对应的关系,然后通过某种映射关系,将线性地址重新映射到任意物理地址上,则我们可以解决程序使用的线性地址连续,而实际的物理地址不连续的问题。而这种映射关系实际上CPU已经给我们了最大的硬件支持,即页表。


页表

  首先需要明确的是,分页机制仍然是建立在分段机制的基础之上的,这也是计算机科学的优良传统——兼容性。对于内存访址来说,前面一直是段基址:段内偏移,分页机制也是在这个基础上的,只不过加了一步映射操作而已,如下图所示

 

  实际上分页机制的主体思想就是:通过映射,将连续的线性地址与任意物理内存地址相关联,逻辑连续的线性地址与其对应的物理地址可以不连续,也就是一方面,分页机制将线性地址转换为了物理地址;另一方面,分页机制用大小相等的页替代了大小不等的段。

  下面我们概述一下操作系统在分页机制下加载进程的过程。

  如上图所示,其表示一个进程的地址转换过程。每一个进程都有属于自己的4GB虚拟地址空间。而图上最右侧的4GB物理地址空间(实际的内存空间)是属于所有进程,包括操作系统在内的共享资源。其中标记已分配页的内存块,表示已经分配给了其他进程;而当前进程只能使用未分配的页。而前面也讲过,分页机制是建立在分段基址的基础上的,每加载一个进程,操作系统都会按照进程中各段的起始范围,在进程自己的4GB虚拟地址中找可用地址分配内存段,此时仅仅是逻辑上的记录,并没有分配真实的物理地址空间。随后操作系统才会开始为这些虚拟内存页分配真实的物理内存页——会在物理内存中查找可用的页,然后再页表中进行等级,从而完成虚拟页到物理页的分配。

一级页表

  实际上我们可以将地址分为高部分和低部分。如果我们人为的将低部分理解为一种内存单位的大小,将高部分理解为上述内存单位的数量,那么对于32位地址空间,也就是内存单位大小 * 内存单位数目 = 4GB。而页表就是将对应的页索引和上述内存块的一一映射即可。如果内存单元大小增大,则内存单元数目就减少,则页表中表示索引所消耗的存储就少,并且索引数目页减少,从而节省了页表空间。实际上,对于上面一直分析的内存单位,更为官方的名称是页,cpu中采用的页大小是4KB。这里需要理解一下,页仅仅是地址空间的计量单位,只要是4KB的地址空间都可以称为一页。所以4GB地址空间可以被划分为4GB / 4KB = 1M = 2 ^ 20个页,则自然页表中需要1M个页表项,而每个页表项的值即使相关的索引值(页表项大小是4字节,因此索引还需要乘以4),即最多也就是地址位数即可表示。实际上一级页表如下所示

 

 

  那么实际上通过线性地址映射到物理地址的方法就很明显了——将线性地址的高20位作为索引,再页表中进行索引,将索引的结果作为映射的物理页的高20位;将线性地址的低12位直接当作映射的物理页的低12位即可。

  实际上可能有人还有疑惑——如果进程都是用上面的线性地址。如果页表也是在内存中,那么操作系统如果要访问页表的话,也需要对线性地址进行转换的话,不是也需要将页表所在的内存进行映射,但是映射过程本身有需要页表,这不就是鸡生蛋,蛋生鸡的问题了?实际上不是这样的。一方面,分页机制打开前是会将页表地址加载到控制寄存器CR3中,这是开启分页机制的先决条件,而加载的页表地址自然是物理地址(没有打开分页机制,则没有地址映射);另一方面,如果开启了分页机制,实际上CPU就会通过页部件自动进行地址映射转换的,这里面所有操作都是相当于在物理地址下进行的,这里可以简单理解为我们每次地址转换的时候,CPU关闭了分页机制,当地址转换完后又恢复到之前的分页机制。这样子就不会出现鸡生蛋、蛋生鸡问题了,因为最后相当于在物理地址下查找了页表,然后在访问页表对应的值,仅仅相当于以前实模式下的地址访存,只不过连续访问了两次而已。这也就是最普通的一级页表。

二级页表

  实际上,虽然一级页表可以完成地址间的转换,但是仍然有较大的问题。一级页表最多可容纳1M个页表项,而每一个页表项是4字节(地址位数),全部填满也就是4MB,而当进程数多起来后,光是页表就会占用很多的空间。因此我们通过二级页表来解决这个问题。

  首先从理论上分析一下,4GB线性地址最多需要1M个页。一级页表是将这1M个标准页放置到一张页表中;而二级页表是将这1M个标准页平均放置到1K个页表中,每个页表包含有1K个页表项,即每个页表大小恰为4K,即又是一个页的大小。那么如何快速的访问这些页表呢。也很简单,专门有页目录表来存储这些页表,也就是每个页表的物理地址在页目录中都以页目录项(Page Directory Entry,PDE)的形式进行存储,每一个大小同样仍然是4K,为一页的大小。而这些页目录表和页表,仍然都是存储在物理内存中的。下面分析一下二级页表中的地址转换问题。

  由于页目录表仅仅包含1024个PDE,即2 ^ 10个PDE,因此我们只需要地址的高10位作为页目录表的索引,即可找到对应的页表。而这些页表根据前面的分析,仍然仅包含1024个页表项,因此使用紧邻着的10位作为页表的索引,即可最终找到对应的物理页,而最后的第12位,则作为页内偏移即可。那么其步骤实际上就是

1.    用虚拟地址的高10位乘以4,作为页目录表的偏移地址,加上页目录表的物理地址,所得到的和作为页表的物理地址
2.    用虚拟地址的中间10位乘以4,作为页表的偏移地址,加上页表的物理地址,所得到的和作为物理页地址
3.    用虚拟地址的最后12位,作为物理页的页内偏移,进行访问即可

  

  而一般的,每一个进程都有自己独立的页表,这样子,多进程并行执行时,也就是在各自的虚拟空间中运行,如下图所示

 

 

  

  而前面我们实际上一直没有具体讲解页表项和页目录项,既然给他们起了正式的名称,自然代表其包含有重要含义。这里我们想一下,既然页目录项或页表项记录的都是最小单位是页的地址,也就是代表着其值都是4K对齐的,也就是其低12位全部为0。既然如此,我们自然不可能浪费了这些空间,而会在上面进行记录,也确实如此,相关的格式如下所示

 

   下面简单介绍一下这些属性为所代表的信息

  1.  P,即Present,即为存在位。若为1表示该页存在于物理内存中;若为0表示该页不在物理内存中。实际上操作系统的页式虚拟内存管理就是通过P位和相应的pagefault异常来实现的;

  2.  RW,Read/Write,即读写位。若为1表示可读可写;否则表示可读不可写;

  3.  US,User/Supervisor,即普通用户/超级用户位。若为1表示处于User级,任意特权级(0、1、2、3特权级)的程序都可以访问该页;否则处于Supervisor,只允许0、1、2特权级的程序进行访问;

  4.  PWT,Page-level Write-Through,即页级通写位。若为1表示该页不仅是普通内存,还是高速缓存;

  5.  PCD,Page-level Cache Disable,即页级告诉缓存禁止位;若为1表示将该也进行高速缓存;

  6.  A,Accessed,意为访问为。类似于GDT中的A,若为1表示该页已经被CPU访问过。是由CPU置0,而操作系统会定期清0,然后统计次数用来内存移出;

  7.  D,Dirty,意为脏页位。当CPU对于一个页面执行写操作时,就会设置对应的页表项,但不会上升到对应的页目录项的D位;

  8.  PAT,Page Attribute Table,意为页属性表位,能够在页面一级的粒度上设置内存属性,机制比较复杂;

  9.  G,Global,即全局位。实际上由于多次访问内存,速度比较慢。因此往往会将物理地址转换结果存储在TLB(Translation Lookaside Buffer)中。而如果G为1,则该页将在高速缓存TLB中一直保存;否则会被替代掉;

  10.  AVL,仍然类似于GDT的段描述符中的AVL,表示可用。

启用分页机制步骤

  下面就简单介绍一下如何启用分页机制。实际上这也相当的简单,主要分为三步

  1.  准备好页目录和页表

  2.  将页表地址写入控制寄存器cr3

  3.  寄存器cr0的PG位置1即可

  对于cr0寄存器,我们已经比较熟悉了,前面从实模式进入保护模式的过程中,已经给出了相关的解释。下面我们主要简单的介绍一下cr3控制寄存器,毕竟之后我们需要写入数据。cr3控制寄存器格式如下所示

 

 

   实际上对于cr3控制寄存器的低12位来说,除了第3、第4位,其余为全部没有用。而对于PCD、PWT,用来表示缓存相关的信息,我们就默认设置为0即可,因此这里低12位全部为0即可。最后说一句,PG位就是cr0寄存器的第31位,也就是最高位,将其置1即可。

操作系统和用户进程

  这里说明一下,为了计算机的安全,用户进程必须运行在低特权级。当用户进程需要访问硬件相关的资源时,需要向操作系统申请,由操作系统去完成,然后将结果返回给用户进程。这也就导致了,用户进程可以有无限多个,但是操作系统只能有一个,所以,操作系统必须“共享”给所有用户进程。那么如何通过页表实现这个共享呢?很简单,只要操作系统属于用户进程的虚拟地址空间即可。也就是在所有用户进程的4GB虚拟地址空间中,高3GB以上的部分划分给操作系统,也就是映射到同一个物理页地址,其上是对应的操作系统代码即可;0-3GB是用户进程自己的虚拟空间即可。


实验

  这次代码稍微有一些复杂,代码仓库点击此链接。这一次的实验仍然是改进前面已经实现的loader程序,这次主要是用来打开分页机制,从而使我们的程序在虚拟地址空间中进行运行。这次需要稍微说明一下。首先页目录表和页表都存在于物理内存之中,我们需要将其放置在对应的位置中,由于我们已经开启了32位保护模式,因此这次我们不妨将其放置在物理地址的0x100000处,为了便于管理和实现,我们将页表和页目录表紧挨在一起,如下所示

 

   下一个问题就是前面提到的操作系统,或者说内核。实际上按照书上的预计,最后完全完成之后,内核体积大约也就是70KB以内,因此我们仍然将操作系统的内核放置在低1MB内存空间中,也就是最开始的实模式下的1MB内存布局中,当然,也可以放置在任何其他地方,但是通过这种方式,我希望跟深刻的理解一下内存的分页机制,因此将其仍然放置在了低1MB的内存空间中。但是前面我们已经分析过了,我们是需要把操作系统放置在所有用户进程的虚拟地址空间的高3GB上的,那么也就是相当于我们需要把虚拟地址的0xc0000000上的1MB地址映射到物理内存的1MB之内。我们给出修改后的boot.inc宏定义,实际上仅仅添加了一部分,这里给出来修改的部分,如下所示

PAGE_DIR_TABLE_POS equ 0x100000
;--------------------------页表相关属性------------------------------------------
;    下面的所有相关的性质主要描述PTE和PDE,格式如下所示
;    00000000000000000000_000_0_0_0_0_0_0_0_0_0b
;    页表物理页地址/页目录表物理页地址12-31位20_AVL3_G1_PAT1_D1_A1_PCD1_PWT1_US1_RW1_P1

PTDE equ 00000000000000000000_000_0_0_0_0_0_0_0_0_0b                            ;页表/页目录项

PTDE_G_0 equ PTDE                                                ;非全局页
PTDE_G_1 equ 00000000000000000000_000_1_0_0_0_0_0_0_0_0b                            ;全局页        

PTDE_D_1 equ 00000000000000000000_000_0_0_1_0_0_0_0_0_0b                            ;脏页面


PTDE_US_1 equ 00000000000000000000_000_0_0_0_0_0_0_1_0_0b                            ;User级别
PTDE_US_0 equ PTDE                                                ;超级用户

PTDE_RW_1 equ 00000000000000000000_000_0_0_0_0_0_0_0_1_0b                            ;可读可写
PTDE_RW_0 equ PTDE                                                ;可读不可写

PTDE_P_1 equ 00000000000000000000_000_0_0_0_0_0_0_0_0_1b                            ;页存在于物理页中
PTDE_P_0 equ PTDE                                                ;也不存在于物理页中

 

  仍然是简单的将页表项/页目录项的属性进行表示,增加可读性。下面是修改后的loader程序的源代码,如下所示

 

;    这里实现简单的loader,其讲系统由实模式进入保护模式
;    最后loader仍然会输出相关的字符串,然后进行悬停,方便进行观察
;------------------------------------------------------------------------

%include "boot.inc"
;    类似于C语言的宏定义
;--------------------------------------------------------------------------------
;    这个文件的主要定义如下所示
;    LOADER_BASE_ADDR equ 0x700
;    LOADER_START_SECTOR equ 0x1
;    GDT和GDT的选择子的宏定义


SECTION    LOADER    vstart=LOADER_BASE_ADDR         ;这个地址表示将起始地址设置为LOADER_BASE_ADDR——因为MBR会将loader程序加载到LOADER_BASE_ADDR处

LOADER_STACK_TOP equ LOADER_BASE_ADDR        ;这里提前说明一下,实际上loader的起始栈顶是LOADER_BASE_ADDR

    jmp    LOADER_START            ;16位实模式相对近转移

;-----------------这部分内容用来构建GDT结构,由于栈向下生长,因此不会破坏GDT--------------------------------
;    db: data byte,         1字节
;    dw: data word,         2字节
;    dd: data double-word,    4字节
;    dq: data quarter-word,    8字节


GDT_BASE:
    dd 0x00000000, 0x00000000        ;前面分析过了,GDT的第0个段描述符无法使用,因此直接置为0即可

GDT_CODE:
    dd 0x0000ffff, GDT_DES_CODE_HIGH_4B    ;这是GDT的第1个段描述符,代码段。由于采用了平坦模式,因此段基址设置为0, 段界限设置为0xfffff

GDT_DATA_STACK:
    dd 0x0000ffff, GDT_DES_DATA_HIGH_4B    ;这是GDT的第2个段描述符,数据(栈)段。由于采用了平坦模式,因此断机制设置为0,段界限设置为0xfffff
                        ;这里需要说明一下,这里纯数据和栈公用一个段,且该段向上扩展。但是栈的方向和段的拓展方向并没有关系。
                        ;段的拓展方向仅仅是用来约束段偏移的,即向上拓展的话,则[base_add, base_add + offset]是对应的段
                        ;向下拓展的话,则[base_add, base_add + offset]非该段,其余都是段描述符对应的段

GDT_VIDEO:
    dd 0x8000_0007, GDT_DES_VIDEO_HIGH_4B    ;这是GDT的第3个段描述符,显存数据段。未采用平坦模式,段基址为0xb8000,段大小为32KB,即段界限为0x7
                        

;-----------------下面定义一下GDT的选择子的--------------------------------
GDT_SECT_CODE equ ((0x0001 << 3) | GDT_SECT_TI_GDT | GDT_SECT_RPL_0)        ;描述符索引值为0x1;在GDT中索引;请求权限为0特权级
GDT_SECT_DATASTACK equ ((0x0002 << 3) | GDT_SECT_TI_GDT | GDT_SECT_RPL_0)    ;描述符索引值为0x2;在GDT中索引;请求权限为0特权级
GDT_SECT_VIDEO equ ((0x0003 << 3) | GDT_SECT_TI_GDT | GDT_SECT_RPL_0)        ;描述符索引值为0x3;在GDT中索引;请求权限为0特权级



;-----------------这里则是GDTR相关的信息,用来构造GDTR的值--------------------------------
GDT_SIZE equ ($ - GDT_BASE)                            ;当前内存中GDT的大小
GDT_LIMIT equ (GDT_SIZE - 1)                            ;GDTR中的值,由于其从0开始,需要减一,类似于段界限

times 60 dq 0                                    ;在预留60个段描述符的空间


GDT_PTR:
    dw GDT_LIMIT
    dd GDT_BASE                                ;低2个字节是GDT的界限,高4个字节是GDT内存起始地址




LOADER_START:
;    向1MB内存中的文本模式的显示适配器区域写入数据
;------------------------------------------------------------------------
;    每个字符2字节,其低字节为字符对应的ASCII码,高字节为字符的属性
;    由于其为背景蓝色,前景色浅品红色,不闪烁,其高字节值为 00011101b
;------------------------------------------------------------------------

    mov    cx, 0x0
    mov byte    al, [format]        ;初始化计数器cx,。由于前面已经设置了ds段寄存器为0,该指令相当于将字符属性字节读入ax寄存器中

LOOP_LOADER:
        mov    di, cx

        mov byte    dl, [di + loaderMsg];这里通过变址寻址访问内存,由于前面设置了ds段寄存器为0,这里直接获取字符串中的对应字符
        sub    dl, 0
        jz    LOOP_LOADER_END            ;判断字符串是否结束。有条件跳转,因此仅仅修改段偏移地址,由于cs始终为0,自然跳转到LOOPEND对应的位置

        add    di, di
    add    di, 160            ;由于VGA模式为80 * 25,即一行80个字符,每一个字符2字节,如果输出在终端的第2行,则需要从80 * 2 = 160的偏移开始

        mov byte    [es:di], dl        ;这里通过变址寻址访问内存

        add    di, 1
        mov byte    [es:di], al        ;这里通过变址寻址访问内存
        
    add    cx, 1
           jmp near    LOOP_LOADER            ;无条件相对近跳转,会重新跳转到LOOP处执行循环

LOOP_LOADER_END:
;------------------------------------------------------------------------
;    我们将上面的指令分析一下
;    可以看到,对于内存寻址来说,这里通过直接寻址进行寻址
;    我们每一次输入两个字节信息,其中低字节是上面分析的字符的属性
;    高字节是字符对应的ascii码,从而完成了内存的写入。




;------------------------准备进入保护模式------------------------------------------------------------------------------
;    1.    将GDT装载入GDTR中
    lgdt    [GDT_PTR]


;    2.    打开A20Gate
    mov    dx, 0x92
    in    al, dx
    or    al, 0000_0010b
    out    dx, al


;    3.    修改CR0寄存器
    mov    eax, cr0
    or    eax, 0x00000001
    mov    cr0, eax


;----------------------------------这里需要通过无条件跳转来刷新流水线,否则会出错,需要特别注意一下----------------
    jmp GDT_SECT_CODE:PROTECTION_MODE_START                        ;绝对地址远调用





;-------------------------------------下面是观察用的保护模式下的代码,用来确认成功进入保护模式---------------
[bits 32]
PROTECTION_MODE_START:

    mov    ax, GDT_SECT_DATASTACK
    mov    ds, ax
    mov    gs, ax
    mov    ss, ax                ;初始化各个段寄存器,将其都指向GDT_SECT_DATASTACK段描述符对应的段

    mov    esp, LOADER_STACK_TOP
    mov    ax, GDT_SECT_VIDEO
    mov    es, ax                ;这里将es设置为GDT_VIDEO段描述符对应的段,即显存段,那里没有使用平坦模式,访问显存仍然类似于实模式



;    在开启保护模式的基础上向文本模式的显示适配器区域写入数据
;------------------------------------------------------------------------
;    每个字符2字节,其低字节为字符对应的ASCII码,高字节为字符的属性
;    由于其为背景蓝色,前景色浅品红色,不闪烁,其高字节值为 00011101b
;------------------------------------------------------------------------

    mov    cx, 0x0
    mov byte    al, [format]        ;初始化计数器cx,。由于前面已经设置了ds段寄存器为0,该指令相当于将字符属性字节读入ax寄存器中

LOOP_PROTECT:
        mov    di, cx

        mov byte    dl, [di + protectionMsg];这里通过变址寻址访问内存,由于前面设置了ds段寄存器为0,这里直接获取字符串中的对应字符
        sub    dl, 0
        jz    LOOP_PROTECT_END            ;判断字符串是否结束。有条件跳转,因此仅仅修改段偏移地址,由于cs始终为0,自然跳转到LOOPEND对应的位置

        add    di, di
    add    di, 320            ;由于VGA模式为80 * 25,即一行80个字符,每一个字符2字节,如果输出在终端的第3行,则需要从80 * 2 * 2 = 320的偏移开始

        mov byte    [es:di], dl        ;这里通过变址寻址访问内存

        add    di, 1
        mov byte    [es:di], al        ;这里通过变址寻址访问内存
        
    add    cx, 1
           jmp near    LOOP_PROTECT        ;无条件相对近跳转,会重新跳转到LOOP处执行循环

LOOP_PROTECT_END:


;---------------------------------------准备开启分页机制---------------------------------------------------------------------------
;    1.    构建页目录表和页表
    call    SETUP_PAGE

;--------------------------------------------------移动GDT--------------------------------------------------------------------------
;    由于我们都将内核运行在3GB以上,因此我们同样需要重启加载GDT,从而确保GDTR是从内核空间进行访问的
;    其中需要注意的是,其余代码段、数据-栈段都是平坦模式,但是显存段并没有使用平摊模式,因此我们需要修改其基址到内核空间中
;    还有esp,也要修改其值到内核空间中
;    这些步骤很简单,就是加上内核空间的基址即可
    sgdt    [GDT_PTR]            ;将GDTR内容进行保存
    
    mov dword    ebx, [GDT_PTR + 2]        ;获取GDT的基址
    or dword    [ebx + 0x18 + 4], 0xc0000000    ;将显存段移动到0xc0000000, 也就是段描述符最高8位修改即可
    or dword    [GDT_PTR + 2],  0xc0000000    ;将GDT也移动到0xc0000000

    add    esp, 0xc0000000                ;同样将栈空间进行映射


;    2.    将页目录表地址写入控制寄存器cr3
    mov    eax, PAGE_DIR_TABLE_POS
    mov    cr3, eax

;    3.    修改CR0寄存器
    mov    eax, cr0
    or    eax, 0x80000000
    mov    cr0, eax

;--------------------------------------------------重载GDT-------------------------------------------------------------------------
    lgdt    [GDT_PTR]



;    在开启分页机制的基础上向文本模式的显示适配器区域写入数据
;------------------------------------------------------------------------
;    每个字符2字节,其低字节为字符对应的ASCII码,高字节为字符的属性
;    由于其为背景蓝色,前景色浅品红色,不闪烁,其高字节值为 00011101b
;------------------------------------------------------------------------

    mov    cx, 0x0
    mov byte    al, [format]        ;初始化计数器cx,。由于前面已经设置了ds段寄存器为0,该指令相当于将字符属性字节读入ax寄存器中

LOOP_PAGE:
        mov    di, cx

        mov byte    dl, [di + pageMsg];这里通过变址寻址访问内存,由于前面设置了ds段寄存器为0,这里直接获取字符串中的对应字符
        sub    dl, 0
        jz    LOOP_PAGE_END            ;判断字符串是否结束。有条件跳转,因此仅仅修改段偏移地址,由于cs始终为0,自然跳转到LOOPEND对应的位置

        add    di, di
    add    di, 480            ;由于VGA模式为80 * 25,即一行80个字符,每一个字符2字节,如果输出在终端的第4行,则需要从80 * 2 * 3 = 480的偏移开始

        mov byte    [es:di], dl        ;这里通过变址寻址访问内存

        add    di, 1
        mov byte    [es:di], al        ;这里通过变址寻址访问内存
        
    add    cx, 1
           jmp near    LOOP_PAGE        ;无条件相对近跳转,会重新跳转到LOOP处执行循环

LOOP_PAGE_END:
;------------------------------------------------------------------------
;    我们将上面的指令分析一下
;    可以看到,对于内存寻址来说,这里通过直接寻址进行寻址
;    我们每一次输入两个字节信息,其中低字节是上面分析的字符的属性
;    高字节是字符对应的ascii码,从而完成了内存的写入。




;    下面进行循环,确保程序悬停在该处,从而观察输出
;------------------------------------------------------------------------

    jmp    $            ;无条件相对近跳转,会重新跳转到LOOP处执行循环

;------------------------------------------------------------------------
;    我们将上面的指令分析一下
;    $表示当前行的地址,这样子相当于始终执行这一行指令,从而使程序悬停






;-----------------------------------下面开始创建页目录以及页表----------------------------------------------
SETUP_PAGE:
    
    mov    ecx, 4096                    ;先把页目录占用的空间逐字节清零
    mov    esi, 0

.CLEAR_PAGE_DIRECTORY:
    
    mov byte [ds:PAGE_DIR_TABLE_POS + esi], 0x0        ;平坦模式,且保护模式下内存访址很灵活
    inc    esi
    loop .CLEAR_PAGE_DIRECTORY                ;保护模式下,ecx作为循环计数器




;    创建页目录项PDE
;---------------------------------------------设计说明------------------------------------------------------
;    对于页目录表和页表,实际上上有一些额外的注意点
;    1.    在加载内核前,程序中运行的一直都是loader程序,其本身的代码都在低端1MB内存中,所以需要确保其在内存分页机制下和虚拟地址和段机制下的线性地址这两者对应的物理地址是一致的。而段机制下线性地址和物理地址一一对应。也就是虚拟地址0-0xfffff必须一一映射到物理地址的0-0xfffff,也就是页目录表第0项对应的页表0-0xff页表项值已经确定
;    2.    前面已经分析过了,操作系统会被加载到低1MB内存空间中,而同时我们需要将操作系统物理地址映射到高0xc0000000处,其对应的页目录表索引是0x300。那么也就是页目录表第0x300项对应的页表0-0xff页表项值已经确定
;    这里不妨直接将页目录表第0项页目录项和第0x300页目录项指向同一个页表
;    3.    我们有可能还想在虚拟地址中动态操作页表,因此将第1023页表项指向自己(开启分页机制后,虽然cr3寄存器也可以直接访问到页表的物理地址,但是即使我们获取了cr3寄存器值,也仅仅是物理地址,我们进行访存的时候,仍然会当做虚拟地址,在进行转换),这里要说明的就是,开启分页机制后,只有页部件可以以物理地址访问,其余所有的地址都会首先经过页部件进行转换。
;---------------------------------------------------------------------------------------------------------------------
;    首先构造页目录表的第0页目录项和第0x300的页目录项,分别指向对应的页表(页表和页表目录表紧挨着)
    mov    eax, PAGE_DIR_TABLE_POS
    add    eax, 0x1000                                ;即这是第1个页表地址
    mov    ebx, eax                                ;保存第1个页表的地址
    or    eax, PTDE_US_1 | PTDE_RW_1 | PTDE_P_1                    ;User用户级(任何特权级都可访问);可读可写;内存存在硬盘
    
;    将构造好的页表地址分别放入第0个页目录项和第0x300(偏移为0xc00)
    mov    [ds:PAGE_DIR_TABLE_POS], eax
    mov    [ds:PAGE_DIR_TABLE_POS + 0xc00], eax

;    将页目录项地址放入第1023(0x3ff)项(偏移为0xffc, 4092)
    sub    eax, 0x1000
    mov    [ds:PAGE_DIR_TABLE_POS + 4092], eax




;--------------------------------下面构建第一个页表的页表项(PTE)------------------------------------------------------
    mov    ecx, 0x100                                ;前面分析需要映射到低端1MB内存中,所以页表需要完成1MB / 4KB = 256个页表项
    mov    esi, 0
    mov    eax, PTDE_US_1 | PTDE_RW_1 | PTDE_P_1                ;User用户级(任何特权级都可访问);可读可写;内存存在硬盘

.LOOP_CREATE_PTE:
    mov    [ds:ebx + 4 * esi], eax                            ;ebx在前面保存了第1个页表的地址
    add    eax, 0x1000
    inc    esi
    loop    .LOOP_CREATE_PTE


;------------------------------------完善内核空间的其他页表---------------------------------------------------------------
;    前面将第0x300页目录项,也就是对应虚拟地址 0xc0000000~(0xc0000000 + 4M)的空间完成了映射(实际上完成了1M的部分映射)
;    第1023页目录项,完成了映射(映射到了自己,即页目录表)
;    但是为了完全共享内核,需要将0x300~1023所有的页目录项全部初始化好
;    否则如果仅仅初始化一部分,新建用户进程,为了共享会将内核页目录表复制到用户进程页目录表,但如果此时系内核申请内存,新使用了
;    页目录项,而对应的用户进程则无法同步
;------------------------------------------------------------------------------------------------------------------------
    mov    eax, PAGE_DIR_TABLE_POS
    add    eax, 0x2000                                ;此时指向第二个页表地址
    or    eax, PTDE_US_1 | PTDE_RW_1 | PTDE_P_1                ;User用户级(任何特权级都可访问);可读可写;内存存在硬盘
    mov    esi, 0x301
    mov    ecx, 254                                ;即1022 - 0x301 + 1
    mov    ebx, PAGE_DIR_TABLE_POS
.LOOP_CREATE_KERNEL_PDE:
    
    mov    [ds:ebx + 4 * esi], eax
    add    eax, 0x1000
    inc    esi
    loop    .LOOP_CREATE_KERNEL_PDE

    ret

;    下面进行常量设置
;------------------------------------------------------------------------
    loaderMsg db "Hawk's LOADER", 0                ;即伪操作指令,表示每一个元素大小为1字节, 并且在结尾为\x00表明字符串结束
    protectionMsg db "Now in PROTECTION mode", 0        ;即伪操作指令,表示每一个元素大小为1字节, 并且在结尾为\x00表明字符串结束
    pageMsg db "Now enable Paging", 0                ;即伪操作指令,表示每一个元素大小为1字节, 并且在结尾为\x00表明字符串结束
    format db 00011101b                        ;这里是显存中的字符属性,表明其为背景蓝色,前景色浅品红色,不闪烁   
    times (2560 - ($ - $$)) db 0                ;使用0填充至5个扇区

 

  这里相关的代码已经非常长了。但是实际上相当一部分代码是前面用来进入保护模式的代码。而新增的代码逻辑相当清晰——

  1.  创建页目录表和页目录;

  2.  将0-0xfffff和0xc00000000-0xc000fffff映射到相同的页表中,并且该页表映射到的物理页面是低端1MB内存空间(前面映射方便后面loader继续操作;后面映射为高端1GB内存为内核空间作铺垫)

  3.  然后调整GDTR;通过修改cr0和cr3控制寄存器,完成内存分页机制

  4.  重载GDT

 

  实际上这里需要说明的有几点

  1.  内核实际上会被加载到低端1MB空间中(目前还没加载),但是为了后面用户进程可以共享操作系统,所有进程的虚拟地址的高1GB是内核空间,低3GB是进程空间。后面的实现就是将页目录表中代表高1GB的页目录项实际上指向的就是内核代码所在物理页(即低端1MB空间 +其他物理空间),因此我们需要将页目录表中对应的(第0x300索引开始)页目录项进行设置。

  2.  当我们开启了分页机制后,cpu中所有的地址都是虚拟地址,但是当cpu进行访存时,所有地址都会经过页部件,使用二级页表进行地址转换,转换成物理地址。也就是如果cpu中使用了正确的物理地址,但是经过一次地址转换再去访问,反而无法访问到正确的地址。因此,我们无法通过获取cr3寄存器的值获取页目录表的物理地址来修改页目录表。这里通过在开启页目录表之前,就在当前目录表的最后一个页目录项中,将值设置为页目录表的起始地址。这样子实际上就可以直接通过虚拟地址访问到对应的页目录表和页表,从而直接对其进行修改。可能还是比较难以理解,这里我再详细说明一下。我们这里使用的都是二级页表。如果我们的虚拟地址是1111111111_______b,也就是最高10位全部为1,则实际上会进入页目录表的最后一个页目录项,并将其对应的值当做页表;而我们前面将最后一个页目录项的地址设置为页目录表的起始地址,也就是其将页目录表当做了页表,说起来可能很绕,但是仔细想一下,确实这个方法很厉害。如果我们要修改对应的页表的值,我们只需要将虚拟地址的中间10位设置为对应的页目录项的索引值,则就会访问一级页表中对应的索引项的值,在这里就是页目录项对应的索引项的值,也就是页表的地址。这里有一个特殊的,如果我们中间10位仍然全为1,则访问的就是一级页表对应的最后一项对应的地址,这里就是页目录表的最后一项,仍然是页目录表。这种设置即可以完成修改页目录表,同时可以任意修改页目录表所指向的页表。

  下面我们在虚拟机中进行测试,首先进行编译和构建,命令还是老样子,如下所示

nasm -I include/ -o mbr.bin mbr.S
nasm -I include/ -o loader.bin loader.S

dd if=mbr.bin of=../hawk.img bs=512 count=1 conv=notrunc
dd if=loader.bin of=../hawk.img bs=512 seek=1 count=5 conv=notrunc

 

  结果如下所示

 

 

  然后我们运行虚拟机,命令如下所示

~/bochs/bin/bochs -f bochsrc.disk

 

  结果如图所示

 

 

   此时我们观察一下页表的映射情况(注意,这里默认使用的就是二级页表),如图所示

 

   这里需要稍微解释一下——info tab应该是根据cr3寄存器的值,使用二级页表机制翻译页目录表。

  首先页目录表填充了第0项,第0x300-1022和第1023项,但是大部分页表项都没有填充。上面tab信息中,前两项十分好理解,就是第0项和第0x300指向第1个页表,而该页表指向低端1MB物理地址;

  第三、四和五项这里需要说明。首先其虚拟地址最高位都为1,也就是选择了页目录表的最后一项,然后将其中对应的地址当做页表。而最后一个页目录项的值指向的是页目录表的地址,从而其最后的物理地址会根据页目录表地址作为页表,和中间10位作为索引,这就非常有趣了。

  因为前面说了,一共填充了2个部分,如果中间10位位0,也就是访问高10位指定的页表的第0项对应的地址,这里也就是页目录表的第0项,其指向了第一个页表,也就是0x101000 + 偏移,和第三条信息一模一样。

  如果中间10位表示0x300-1022,也就是访问页表的第0x300-1022项对应的地址,这里也就是页目录表中第0x300-1022项对应的地址,(内核地址空间,前面将内核地址空间提前初始化,映射到了第2-254个页表),和第四条信息一模一样。

  如果中间10位全为1,也就是访问页表的最后一项对应的地址,这里也就是页目录表中最后一项,其指向了页目录表,也就是0x100000 + 偏移,和第五条信息一模一样。

posted @ 2020-09-21 13:56  hawkJW  阅读(791)  评论(0编辑  收藏  举报