zoukankan      html  css  js  c++  java
  • 《操作系统真象还原》分页

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

    分页

    什么是分页

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

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

      在打开保护模式之前,仅有线性地址和物理地址的概念,物理地址就是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 
  • 相关阅读:
    Centos下Zookeeper的安装部署
    Zookeeper入门
    Redis高可用-主从,哨兵,集群
    Redis入门
    centos7 安装redis6.0.3
    二叉树的遍历及常用算法
    分享一个seata demo,讲两个个问题
    互联网公司,我们需要什么样的中层技术管理以及996和程序员有多大关系?
    Spring Boot微服务如何集成seata解决分布式事务问题?
    软件服务架构的一些感悟
  • 原文地址:https://www.cnblogs.com/thougr/p/12158456.html
Copyright © 2011-2022 走看看