《操作系统真象还原》分页

  本节是阅读第五章的收获。下面将阐述一些分页的相关内容。

分页

什么是分页

  分页,顾名思义,就是将内存分成大小相同的页。分页,通过映射的方式,将连续的线性地址转化为不连续的物理地址;这样,在处理器进入分页模式之后,用户直接访问的并不是物理地址,而是分页模式下的虚拟地址。

  上面有三个和地址相关的概念,分别为虚拟地址、线性地址和物理地址。

  在打开保护模式之前,仅有线性地址和物理地址的概念,物理地址就是CPU最终访问的真正地址,是指令或数据真正保存的数据的地方。而线性地址代表“段基址+段内偏移地址”,由于在实模式下,段基址+段内偏移地址等于物理地址,所以线性地址和物理地址数值上是一样的。

  而打开保护模式且打开分页模式之后,用户直接访问的是虚拟地址空间或是线性地址空间,线性地址仍然是段基址+段内偏移地址,虚拟地址数值上与线性地址相同。从概念上线性地址空间和虚拟地址空间有些不同,因为线性地址空间只有段的概念,没有页的概念;通过分页机制,将线性地址空间中大小不等的段转化为虚拟空间中大小相等的页。虚拟地址通过页表和页目录转化为最终的物理地址,分页机制如下图:

  总的来说,虚拟地址就是分页后程序或任务访问的地址,线性地址就是段基址+段内偏移地址,物理地址是CPU最终访问的地址。

为什么要分页

  那么为什么要分页呢?主要原因是内存分配的时候存在外部碎片。本来剩余的内存空间是足以分配给一个任务或进程的,但由于这些剩余的内存片并不连续,我们就不能连续地分配这些内存给任务或进程了。如下图,本来剩余的内存空间是35M,进程D的空间是20M+3KB的空间,按道理来说进程D的空间是少于剩余的内存空间的,应该能够放得下的,但由于进程D需要整一块塞进内存里面,不能拆分,就不能运行进程D了。所以导致不能分配的关键原因是我们分配内存时要求地址是连续的。

  有人可能会问为什么内存地址需要连续,不连续不行吗?我们先从指令说起,我们执行指令除非有跳转,都是默认下一条指令地址=当前指令的地址+当前指令的大小,换而言之,都是约定指令执行是连续的;数据也是,一般而言,我们请求一块数据缓冲区,我们都希望这个缓冲区是连续的,这样,我们就能够像数组一样对缓冲区进行连续读写。所以从用户的角度来说,我们都默认一个数据段或者代码段在逻辑上是连续的,这也符合我们的直觉。用户希望空间是连续的,但导致分配失败的原因又是“连续”,那么怎么才能解决这个问题呢?下面来看看分页是怎么解决这个问题的。

分页机制的原理

一级页表

  我们仍然希望访问的指令或数据是连续的,希望指令或数据的地址仍然保持着与分页前一样。所以我们像在分段模式下访问内存一样,采用段基址+段内偏移地址的模式访问内存,此时通过段部件输出线性地址,但我们肯定不能让线性地址代表物理地址,否则还是不能解决上述的内存分配的问题。我们这时候拿出了一张表,称为页表,将线性地址逐字节地映射到物理地址上,如下图,比如将0x0地址映射到物理内存0x0,将0x1映射到0x9,将0x2映射到0xfa,以此类推。这样子我们就能够保证用户访问的地址是连续的,但实际上,在物理内存是不连续的;以这种方式,我们能够充分的利用剩余的物理地址空间,不会因为“连续”的约束而导致分配内存失败。所以通过一级页表,我们就能很好地解决外部碎片产生的问题。

  那么,我们再细想一下,上面这种映射方式有什么问题。想一想,假设线性地址空间为4G,也就是有4G个地址,也就是页表里面有4G个页表项,假设一个页表项需要4个字节存储32位地址,那就是一个页表的大小是16G,比线性地址空间还大hhhh。因此,一一映射的方式太耗内存了,所以我们需要减少页表项的开销,页表项存储地址是4个字节变不了了,只能减少页表项的数量。我们将32位地址分为两部分,一部分代表内存块的数量,一部分代表内存块的尺寸,如下图。那么我们怎么找到合适的页尺寸呢,我们分配内存的单位是内存块的尺寸,尺寸太大,内存块数量太少,因而能分配的内存块也就少;尺寸太小,内存块数量太多,这样页表的开销就很大。所以我们只能折中选个值,而现在CPU采用的内存块大小恰好是4KB,这样内存块的数量就是1M个,我们就按这个值来吧。

  

   这样,我们不再将内存每个字节一一映射了。通过线性地址的高20位去索引页表的页表项找到对应的内存块基址,线性地址的低12位去索引这个内存块。既能够保证页表的大小不至于太大,也能确保分配的内存块不至于太少,也消除了外部碎片和“连续”导致的问题,其实已经差不多了,但一级页表并不是我们现在操作系统采用的页表模式,因为一级页表还是有一些问题,我们接下来继续来讨论。

二级页表

   一级页表已经很好了,为什么还不够呢。我们下面来讨论一下一级页表问题。

  ①一级页表有1M个页表项,也就是一个页表需要4M的内存空间,并且4M的内存空间也是需要连续分配的,这和采用页表的原因类似,如果内存的外部碎片过多,本来剩余的内存空间>4M的,但由于这些剩余内存并不连续,这样就分配不了内存给一个页表了。我们通过二级页表,将连续的4M内存划分为1K个4K大小的页,这样我们既可以用页为任务分配内存空间,也可以用页去为页表分配空间,给页表分配空间就不再具有特殊性了,不再需要连续了。

  ②一级页表,一定需要完整的4M的内存空间,这是肯定的,如果不分配完整的4M空间,某一些内存地址就访问不到了。每一个进程都会有一份页表的,如果进程数量很多,页表占用的内存还是挺可观的。采用二级页表就能节省空间,二级页表只需要为那些用到的内存地址分配页表;那些没有用到的内存地址,除非与用到的内存地址用到同一块页表,否则是不会分配页表的。所以,一级页表一般会比二级页表消耗更多的内存。

  下面来描述一下二级页表的结构,二级页表与一级相比多了一层,我们称这层为页目录。接着说,二级页表将一级页表的1M个页表项继续划分,将1M个页表项均分到1K个页表里,也就是1个页表有1K个页表项,这样1个页表的大小就是4K,正好一个页的大小。一个页表能索引的内存大小就是1K*4K=4M,1K个页表就能索引4M*1K=4G内存。页表已经划分好了,那么我们就需要考虑怎么索引到对应的页表。这时候页目录正式登场,页目录有1K个页目录项,一个页目录项指向一个页表,这样整个二级页表的结构大致就这样组成。

  那么如何将线性地址(虚拟地址)转换为最终的物理地址呢,和二级页表的关系是什么? 和一级页表类似,只不过32位的地址被划分为3个部分,高10位用来索引页目录找到页表的物理地址,中10位用来索引页表找到页的基址,低12位用于索引页。

  举个例子,如下图,指令是mov ax, [0x1234567],将0x1234567铺开到二进制0000_0001_00  1000_1101_00 010101100111后,可以得到页目录索引为4,页表的索引为0x234,页的索引为0x567。

  一个页目录项和页表项的大小为4B,设页目录的基址为DIR_BASE,页表的基址为PAGE_BASE,DIR_BASE+0x4 *(页目录项的大小4)=页目录索引为4所对应的物理地址,读取该物理地址存储的地址0x1000,即PAGE_BASE=0x1000。继续找页的基址,PAGE_BASE+0x234*(页表项的大小4)=页表索引为0x234时所对应的物理地址,同样读取该地址存储的地址0xfa000。继续找最终的物理地址,0xfa000+0x567=0xfa567得到最终的物理地址。

  从虚拟地址到物理地址的转换是由页部件去完成的,操作系统只需要准备好相应的结构,即可让页部件去计算最终的物理地址。

进入分页模式

   接下来,我们开始尝试编写代码,进入分页模式。

  进入分页模式有三个步骤:

  ①准备好页目录和页表。

  ②将页目录地址加载到cr3控制寄存器。

  ③将cr0控制寄存器的PG位打开。

  代码改动如下,头文件boot.inc在最后贴:

[bits 32]
p_mode_start:
   mov ax, SELECTOR_DATA
   mov ds, ax
   mov es, ax
   mov ss, ax
   mov esp,LOADER_STACK_TOP
   mov ax, SELECTOR_VIDEO
   mov gs, ax

   ; 创建页目录及页表并初始化页内存位图
   call setup_page

   ;要将描述符表地址及偏移量写入内存gdt_ptr,一会用新地址重新加载
   sgdt [gdt_ptr]          ; 存储到原来gdt所有的位置

   ;将gdt描述符中视频段描述符中的段基址+0xc0000000
   mov ebx, [gdt_ptr + 2]  
   or dword [ebx + 0x18 + 4], 0xc0000000      ;视频段是第3个段描述符,每个描述符是8字节,故0x18。
                          ;段描述符的高4字节的最高位是段基址的31~24位

   ;将gdt的基址加上0xc0000000使其成为内核所在的高地址
   add dword [gdt_ptr + 2], 0xc0000000

   add esp, 0xc0000000        ; 将栈指针同样映射到内核地址

   ; 把页目录地址赋给cr3
   mov eax, PAGE_DIR_TABLE_POS
   mov cr3, eax

   ; 打开cr0的pg位(第31位)
   mov eax, cr0
   or eax, 0x80000000
   mov cr0, eax

   ;在开启分页后,用gdt新的地址重新加载
   lgdt [gdt_ptr]             ; 重新加载

   mov byte [gs:160], 'V'     ;视频段段基址已经被更新,用字符v表示virtual addr

   jmp $

;-------------   创建页目录及页表   ---------------
setup_page:
;先把页目录占用的空间逐字节清0
   mov ecx, 4096
   mov esi, 0
.clear_page_dir:
   mov byte [PAGE_DIR_TABLE_POS + esi], 0
   inc esi
   loop .clear_page_dir

;开始创建页目录项(PDE)
.create_pde:                     ; 创建Page Directory Entry
   mov eax, PAGE_DIR_TABLE_POS
   add eax, 0x1000                  ; 此时eax为第一个页表的位置及属性
   mov ebx, eax                     ; 此处为ebx赋值,是为.create_pte做准备,ebx为基址。

;   下面将页目录项0和0xc00都存为第一个页表的地址,
;   一个页表可表示4MB内存,这样0xc03fffff以下的地址和0x003fffff以下的地址都指向相同的页表,
;   这是为将地址映射为内核地址做准备
   or eax, PG_US_U | PG_RW_W | PG_P         ; 页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问.
   mov [PAGE_DIR_TABLE_POS + 0x0], eax       ; 第1个目录项,在页目录表中的第1个目录项写入第一个页表的位置(0x101000)及属性(7)
   mov [PAGE_DIR_TABLE_POS + 0xc00], eax     ; 一个页表项占用4字节,0xc00表示第769个页表占用的目录项,0xc00以上的目录项用于内核空间,
                         ; 也就是页表的0xc0000000~0xffffffff共计1G属于内核,0x0~0xbfffffff共计3G属于用户进程.
   sub eax, 0x1000
   mov [PAGE_DIR_TABLE_POS + 4092], eax         ; 使最后一个目录项指向页目录表自己的地址

;下面创建页表项(PTE)
   mov ecx, 256                     ; 1M低端内存 / 每页大小4k = 256
   mov esi, 0
   mov edx, PG_US_U | PG_RW_W | PG_P         ; 属性为7,US=1,RW=1,P=1
.create_pte:                     ; 创建Page Table Entry
   mov [ebx+esi*4],edx                 ; 此时的ebx已经在上面通过eax赋值为0x101000,也就是第一个页表的地址 
   add edx,4096
   inc esi
   loop .create_pte

;创建内核其它页表的PDE
   mov eax, PAGE_DIR_TABLE_POS
   add eax, 0x2000              ; 此时eax为第二个页表的位置
   or eax, PG_US_U | PG_RW_W | PG_P  ; 页目录项的属性US,RW和P位都为1
   mov ebx, PAGE_DIR_TABLE_POS
   mov ecx, 254                 ; 范围为第770~1023的所有目录项数量
   mov esi, 769
.create_kernel_pde:
   mov [ebx+esi*4], eax
   inc esi
   add eax, 0x1000
   loop .create_kernel_pde
   ret

  setup_page,首先将页目录的4K内存清空位0,然后开始创建页目录项,将第1个和第769个(这里页目录和页表项从1开始算)页目录项设为第一个页表的地址;将最后一个页目录设为页目录的地址,将第一个页表映射到低1M的物理内存;将第770~1023的页目录项设置成第2~255个页表的地址,第2~255个页表是紧接着第1个页表之后的。流程图如下:

  下面解释一下各个步骤的意义:

  ① 清空页目录的内存作初始化,防止原来存在的数据指向错误的地方。

  ② 将第1个和第769个页目录项设置为第一个页表的地址,而后面第一个页表映射到了低1M物理内存,低1M物理内存存储着内核程序。主要是因为打开分页模式之后,首先获得的是虚拟地址,然后将这个虚拟地址转换到最终的物理地址。所以试图访问内核程序的地址已经变成了虚拟地址了,如果最后转换到的物理地址不是原来的物理地址就会出问题。举个例子,假设内核程序在打开分页模式之前,通过地址0x900读写变量A,此时的地址是线性地址,也是物理地址,因为还没打开分页模式;但是打开分页模式之后,我们再想读写这个变量A时,提交的还是0x900,但是这个地址已经变成了虚拟地址了,处理器最终要访问的是物理地址,而变量A的物理地址仍然是0x900,所以需要将虚拟地址0x900映射到物理地址0x900,即一一对应,这样才能保证之前的程序能够正确运行。综上,我们需要将虚拟地址空间的低1M与物理地址的低1M进行一对一映射。将第769个页目录项设置为第一个页表的地址,主要是将虚拟地址空间的高1G内存作为内核程序的空间,以后试图请求内核程序的帮助都会访问高1G内存的空间;而低1M内存也属于内核程序的一部分,所以将0xc000 0000~0xc001 0000也映射到低1M的物理地址。

  ③ 将最后一个页目录项指向页目录。想一想,如果我们高10位索引到了最后一项页目录,那中10位相当于也在索引页目录,那么低12位最终索引的是页表,对不对?所以,将最后一个页目录项指向页目录的作用是对页表进行操作。再细想一下,我们通过什么地址可以对页表进行操作。

  a. 我想最终访问页目录表,获得页目录项存储的页表地址。将高10位设置为最后一项页目录的索引,将中10位也设为最后一项页目录的索引,低12为再索引页目录表即可。

  b.我想最终访问页表,获得页表项存储的页地址。将高10位设置为最后一项页目录的索引,将中10位设为某个页目录项的索引,低12位就可以索引页表了。

  ④ 将第770~1023的页目录项设置为第2~255个页表的地址,按书上的说法是与之后建立用户进程相关,咱不在这里讨论。

  

  调用setup_page之后,将视频段描述符段基址+0xc000 0000,禁止用户进程直接访问显存,只能通过高1G的内核空间去访问显存。将栈指针和GDT也映射到内核地址空间。最后按三部曲打开分页模式。 

运行程序

  我把boot.inc搬到了include文件夹下,按下面的指令编译代码, -I指定头文件的目录位置,注意别把include后的'/'漏了

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

  写进硬盘,count写大点没关系,dd指令会自动识别代码长度,写到200都没关系hhh,重点是不能写少,否则代码没写全进硬盘就会出问题:

dd if=./loader.bin of=./hd60M.img bs=512 count=3 seek=2 conv=notrunc

  接下来执行代码:

bin/bochs -f bochsrc.disk

  执行结果如下图:

   再通过info tab看一下虚拟地址到物理地址的映射结果:

   结果和上面讨论的一致,低1M和高1G内存的低1M的虚拟地址映射到了低1M的物理地址,通过最后一个页目录项最终可以访问到255个页表和页目录。

TLB

  TLB,Translation Lookaside Buffer,简称快表。和段描述符缓存寄存器类似,毕竟每次访问地址都要访问一次内存里面的页目录、页表太耗时间了,比起CPU执行指令或访问寄存器这些要慢一个数量级,所以缓存又出来了。TLB高速缓存,用于缓解处理器与内存访问速度之间的不匹配。结构大致如下,保存着虚拟地址高20位到物理地址高20位的映射,这样子就能加快对内存的访问,不用每次访问内存都要问一下页表。当然,这样子处理器访问内存的话,首先访问到的就是TLB,所以务必保证TLB的有效性。

  TLB的有效性是由谁去保证呢?处理器?并不是,TLB什么时候会失效?页目录和页表数据被改变得时候就会失效,这时候操作系统肯定是知道的,所以维护TLB的有效性的工作就交给了操作系统。有什么办法可以更新TLB呢,一种是通过invlpg指令刷新某个虚拟地址对应的条目;一种是重新加载页目录,使整个TLB失效,进而重新加载TLB数据。

头文件boot.inc

  重新贴一下头文件boot.inc:

;-------------     loader和kernel   ----------

LOADER_BASE_ADDR equ 0x900 
LOADER_START_SECTOR equ 0x2
KERNEL_BIN_BASE_ADDR equ 0x70000
KERNEL_IMAGE_BASE_ADDR  equ   0x1500
KERNEL_START_SECTOR equ 0x9

PAGE_DIR_TABLE_POS equ 0x100000

;--------------   gdt描述符属性  -------------
DESC_G_4K   equ      1_00000000000000000000000b   
DESC_D_32   equ       1_0000000000000000000000b
DESC_L        equ        0_000000000000000000000b    ;  64位代码标记,此处标记为0便可。
DESC_AVL    equ         0_00000000000000000000b    ;  cpu不用此位,暂置为0  
DESC_LIMIT_CODE2  equ 1111_0000000000000000b
DESC_LIMIT_DATA2  equ DESC_LIMIT_CODE2
DESC_LIMIT_VIDEO2  equ 0000_000000000000000b
DESC_P        equ          1_000000000000000b
DESC_DPL_0  equ           00_0000000000000b
DESC_DPL_1  equ           01_0000000000000b
DESC_DPL_2  equ           10_0000000000000b
DESC_DPL_3  equ           11_0000000000000b
DESC_S_CODE equ             1_000000000000b
DESC_S_DATA equ      DESC_S_CODE
DESC_S_sys  equ             0_000000000000b
DESC_TYPE_CODE  equ          1000_00000000b    ;x=1,c=0,r=0,a=0 代码段是可执行的,非依从的,不可读的,已访问位a清0.  
DESC_TYPE_DATA  equ          0010_00000000b    ;x=0,e=0,w=1,a=0 数据段是不可执行的,向上扩展的,可写的,已访问位a清0.

DESC_CODE_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_CODE2 + DESC_P + DESC_DPL_0 + DESC_S_CODE + DESC_TYPE_CODE + 0x00
DESC_DATA_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_DATA2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x00
DESC_VIDEO_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_VIDEO2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x0b

;--------------   选择子属性  ---------------
RPL0  equ   00b
RPL1  equ   01b
RPL2  equ   10b
RPL3  equ   11b
TI_GDT     equ   000b
TI_LDT     equ   100b


;----------------   页表相关属性    --------------
PG_P  equ   1b
PG_RW_R     equ  00b 
PG_RW_W     equ  10b 
PG_US_S     equ  000b 
PG_US_U     equ  100b 
原文地址:https://www.cnblogs.com/thougr/p/12158456.html