手写操作系统(十)-内存分页

代码、内容参考来自于包括《操作系统真象还原》、《一个64位操作系统的设计与实现》以及《ORANGE’S:一个操作系统的实现》。

1.概述

未开启分页时,程序中引用的线性地址是连续的,所以物理地址也连续。就算剩下内存可用,大于每一个连续内存的进程程序还是无法放入内存。

分页机制的核心思想:解除线性地址和物理地址的一一对应关系,使线性地址连续,而物理地址不连续。使连续的线性地址可以与任意物理内存地址相关联。

内存寻址的核心仍然是“段基址+段内偏移地址”,这两个地址的相加求和是由CPU部件自动完成的,形成线性地址(绝对地址)。因此,分页机制只能在分段后进行。

如果分页,则线性地址等于物理地址;若不分页,则线性地址等于虚拟地址。此虚拟地址对应的物理地址需要在页表中查找,这部分由页部件自动完成。

分页机制的作用:

  • 将线性地址转换为物理地址
  • 用大小相等的页代替大小不等的段

如图:

从线性空间到虚拟空间再到物理地址空间,每个空间大小都是4GB,图上的4GB物理地址空间属于所有进程包括操作系统在内的共享资源,每个进程都有自己的4GB虚拟空间。

操作系统会为这些虚拟内存页分配真实的物理内存页,它查找物理内存中可用的页,然后在页表中登记这些物理页地址,这样就完成了虚拟页到物理页的映射,这样每个进程都以为自己独享4GB地址空间。

 

2.一级页表

页尺寸的选择:

  • 32位地址表示4GB空间,因此,内存块数*内存块大小 = 4GB
  • CPU中采取的页大小为4KB(物理内存),所以4GB的地址空间被划分为4GB/4KB = 1M个页。所以页表中有1M个页表项。(页表大小为1M*4B = 4MB)
  • 因为是以4KB(2^12B)为页大小,所以页表项中物理地址的后3位都是一样的。内存以字节为单位。
  • 页:地址空间的计量单位,并不是专属于物理地址或线性地址,只要是4KB的地址空间都可以称为一页。

一级页表如图:

由于页大小是4KB,所以页表项中的物理地址都是4k的整数倍,故用十六进制表示的地址,低 3 位都是 0。就拿第3个页表项来说,其值为0x3000,表示该页对应的物理地址是 0x3000。

内存分页机制的作用是将虚拟地址转换成物理地址,但其转换过程相当于在关闭分页机制下进行,过程中所涉及到的页表及页表项的寻址,它们的地址都被CPU当作最终的物理地址直接送上地址总线,不会被分页机制再次转换。

在32位保护模式下任何地址都是用32位二进制表示的,包括虚拟地址也是。虚拟地址的高20位可用来定位一个物理页,低12位可用来在该物理页内寻址。比如用线性地址的高20位作为页表项的索引,每个页表项要占用4字节大小,所以这高20位的索引乘以4后才是该页表项相对于页表物理地址的字节偏移量。用cr3寄存器中的页表物理地址加上此偏移量便是该页表项的物理地址,从该页表项中得到映射的物理页地址,然后用线性地址的低12位与该物理页地址相加,所得的地址之和便是最终要访问的物理地址。

CPU中集成了专门用来干这项工作的硬件模块称为页部件。当程序中给出一个线性地址时,页部件分析线性地址,按照以上步骤,自动在页表中检索到物理地址。

 

 

2.二级页表

一级页表中所有页表项必须要提前建好,原因是操作系统要占用4GB虚拟地址空间的高1GB,用户进程要占用低 3GB,每个进程都有自己的页表,进程一多,光是页表占用的空间就太大了。所以我们需要时动态创建页表项。

无论几级页表,页大小都是4KB,所以4GB的空间最多容纳1M个页。一级页表是将1M个页放在一张页表中,而二级页表是将其以1K为单位放在1K个页表中。因为页表项为4字节大小,因此页表大小正好为4KB(标准页大小)。

页目录表用来存储这1K个页表,每个页表的物理地址都在页目录表中以页目录项(Page Directory Entry,PDE)形式存储,页目录项大小等于页表项大小,所以页目录表也是4KB大小。

页目录项的构成:31-22位(10位)用来在页目录表中定位一个页表,21-12位(10位)用来在页表中定位具体的物理页,11-0位(12位)用于页内偏移量。

用虚拟地址的高10位乘以4,作为页目录表内的偏移地址,加上页目录表的物理地址,所得的和,便是页目录项的物理地址。读取该页目录项,从中获取到页表的物理地址。

用虚拟地址的中间10位乘以4,作为页表内的偏移地址,加上在第1步中得到的页表物理地址,所得的和,便是页表项的物理地址。读取该页表项,从中获取到分配的物理页地址。

虚拟地址的高 10 位和中间 10 位分别是 PDE 和 PTE 的索引值,所以它们需要乘以 4。但低 12 位就不是索引值,其表示的范围是 0~0xfff,作为页内偏移。

虚拟地址的低 12 位加上第 2 步中得到的物理页地址,所得的和便是最终转换的物理地址。

结构如下:

 

目录项和页表项中的都是物理页地址,标准页大小是4KB,故地址都是4K的倍数,也就是地址的低12位是0,所以只需要记录物理地址高20位就可以啦。这样省出来的12位(第0~11位)可以用来添加其他属性。

其他属性如下:

  • P,Present,意为存在位。若为1表示该页存在于物理内存中,若为0表示该表不在物理内存中。操作系统的页式虚拟内存管理便是通过P位和相应的pagefault异常来实现的。
  • RW,Read/Write,意为读写位。若为1表示可读可写,若为0表示可读不可写。
  • US,User/Supervisor,意为普通用户/超级用户位。若为1时,任意级别都可以访问。为0,只允许特权级别为0、1、2的程序访问。
  • PWT,Page-level Write-Through,意为页级通写位,也称页级写透位。若为1表示此项采用通写方式,表示该页不仅是普通内存,还是高速缓存。此项和高速缓存有关,“通写”是高速缓存的一种工作方式,本位用来间接决定是否用此方式改善该页的访问效率。这里直接置为0就可以。
  • PCD,Page-level Cache Disable,意为页级高速缓存禁止位,若为1表示该页启用高速缓存,为0表示禁止将该页缓存。这里将其置为0。
  • A,Accessed,意为访问位。若为1表示该页被CPU访问过啦。是用来在内存不足时与将不常用的内存置换到硬盘中。
  • D,Dirty,意为脏页位。当CPU对一个页面执行写操作时,就会设置对应页表项的D位为1。 此项仅针对页表项有效,并不会修改页目录项中的D位。
  • PAT,Page Attribute Table,意为页属性表位,能够在页面一级的粒度上设置内存属性。置0。
  • G, Global,意为全局位,为1表示是全局页,为0表示不是全局页。若为全局页,该页将在高速缓存TLB中一直保存,给出虚拟地址直接就出物理地址,无需繁琐的置换过程。
  • AVL,意为Available位,即保留位。

页表同描述符表一样,是个内存中的数据结构,处理器要使用它们,必须要知道它们的物理地址,所以页表也有个专门的寄存器来存储其地址。这就是控制寄存器cr3。控制寄存器cr3用于存储页表物理地址,所以cr3寄存器又称为页目录基址寄存器(Page Directory Base Register,PDBR)

结构如下:

由于页目录表所在的地址要求在一个自然页内,即页目录的起始地址是4KB的整数倍,低12位地址全是0。所以,只要在cr3寄存器的第31~12位中写入物理地址的高20位就行了。PWT位和PCD位在介绍页表项时说过了,它们用于设置高速缓存相关的特性,在此将其置为0即可。

 

3.代码编写

启用分页机制步骤如下:

  • 准备好页目录表及页表。
  • 将页表地址写入控制寄存器 cr3。
  • 寄存器cr0的PG位置1。

用户进程可以有无限多,但OS只有一个,所以OS必须共享给所有用户进程。

所以需要将4GB的虚拟空间分成两部分,一部分给OS,一部分给用户进程。
虚拟空间的0-3GB是用户进程自己的虚拟空间,为了实现共享操作系统,让所有用户进程3GB~4GB的虚拟地址空间都指向同一个操作系统,即虚拟地址的3-4GB本质上都是指向同一物理页地址。

页目录表和页表都存在于物理内存之中,页目录表我们就放在物理地址0x100000处。为了让页表和页目录表紧凑一些(这不是必须的),就让页表紧挨着页目录表。页目录本身占4KB,所以第一个页表的物理地址是0x101000。

目测完成之后,内核体积大概就是70KB以内,所以还是充分利用低端1MB内存,以后会把内核加载到低端1MB之内,mbr、loader、操作系统内核都将放置在这1MB空间内,当然这1MB是指物理内存的0~0xfffff,内核在物理内存1MB之内,内核地址需要映射到虚拟地址3GB之上。也就是将虚拟地址0xc0000000之上的1MB 地址映射到物理内存1MB之内。

在boot.inc定义页目录表的物理地址

;页目录表的物理地址,因为低端1MB用于表示内核,而0x10_0000是出1MB后的地址
PAGE_DIR_TABLE_POS equ 0x10_0000
;页表相关属性,b表示二进制
PG_P equ 1b                         ;P = 1表示该页存在于内存中
PG_RW_R equ 00b                     ;RW表示读写位,0表示只读
PG_RW_W equ 10b                     ;RW表示读写位,1表示可读写
PG_US_S equ 000b                    ;表示PTE和PDE的US属性是S,表示超级用户,该页不能被特权级为3的进程访问
PG_US_U equ 100b                    ;表US属性是U,表示普通用户,该页可以被所有进程访问

PAGE_DIR_TABLE_POS用于定义页目录表的物理地址,它的值为0x100000,也就是我们会把页目录表放置到物理内存0x100000。这是出了低端1MB 空间的第一个字节。剩下的用于页目录项PDE和页表项PTE中的属性,是用二进制直接定义的,因此各二进制数字都以字符b结尾。

比如PG_US_S表示PTE和PDE的US属性值为S,这里把S的值定义为000b,注意,虽然写了3个0,最右边的两个0是占位的,只有最左边的0才表示US的值,因此US位的值为0,这表示此PTE或PDE指向的内存不能被特权级3的任务访问,处理器只允许特权级为0、1、2的任务访问该PTE或PDE指向的内存。

PG_US_U 的意思是 US位的值为1,处理器允许任何特权级的任务访问 PTE 或PDE 指向的内存。PG_P表示PTE或PDE指向的物理内存页框已位于内存中,当物理内存不足时,操作系统的虚拟内存管理机制有可能会将该PDE或PTE指向的物理页框换出到磁盘上,此时PDE或PTE的P位便被操作系统置为0,处理器访问该PDE或PTE时会触发page_fault缺页异常,操作系统为该异常注册了中断处理程序,该程序会将所缺的页从磁盘上重新加载到内存中,并将P位置为1,中断处理程序运行结束后,处理器会再次该PDE或PTE,发现P位为1,顺利通过。

PG_RW_W和PG_RW_R分别表示PDE或PTE指向的物理内存可写、只读。

loader的代码更改如下:

%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR ; 程序开始的地址
LOADER_STACK_TOP equ LOADER_BASE_ADDR ; 栈顶地址
;构建gdt及其内部的描述符
GDT_BASE:  dd    0x00000000
           dd    0x00000000

CODE_DESC: dd    0x0000FFFF
           dd    DESC_CODE_HIGH4

DATA_STACK_DESC:  dd    0x0000FFFF
                 dd    DESC_DATA_HIGH4

VIDEO_DESC: dd    0x80000007           ; limit=(0xbffff-0xb8000)/4k=0x7
            dd    DESC_VIDEO_HIGH4     ; 此时dpl为0

GDT_SIZE   equ   $ - GDT_BASE
GDT_LIMIT  equ   GDT_SIZE - 1

times 60 dq 0                ; 此处预留60个描述符的slot

SELECTOR_CODE  equ (0x0001<<3) + TI_GDT + RPL0   ; 第一个选择子
SELECTOR_DATA  equ (0x0002<<3) + TI_GDT + RPL0   ; 第二个选择子
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0   ; 第三个选择子


total_mem_bytes dd 0       ; 保存内存容量,以字节为单位

; 以下是定义gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr  dw  GDT_LIMIT
         dd  GDT_BASE



ards_buf times 244 db 0     ; 人工对齐:total_mem_bytes4字节+gdt_ptr6字节+ards_buf244字节+ards_nr2,共256字节
ards_nr dw 0               ; 用于记录ards结构体数量

loader_start:
; 获取内存容量,int 15, ax = E820h
    xor ebx, ebx              ;第一次调用时,ebx值要为0
    mov edx, 0x534d4150       ;edx只赋值一次,循环体中不会改变
    mov di, ards_buf          ;ards结构缓冲区

    .e820_mem_get_loop:           ;循环获取每个ARDS内存范围描述结构
        mov eax, 0x0000e820       ;执行int 0x15后,eax值变为0x534d4150,所以每次执行int前都要更新为子功能号。
        mov ecx, 20              ;ARDS地址范围描述符结构大小是20字节
        int 0x15
        jc .e820_failed_so_try_e801    ;若cf位为1则有错误发生,尝试0xe801子功能
        add di, cx               ;使di增加20字节指向缓冲区中新的ARDS结构位置
        inc word [ards_nr]        ;记录ARDS数量
        cmp ebx, 0               ;若ebx为0且cf不为1,这说明ards全部返回,当前已是最后一个
        jnz .e820_mem_get_loop

        ;在所有ards结构中,找出(base_add_low + length_low)的最大值,即内存的容量。
        mov cx, [ards_nr]         ;遍历每一个ARDS结构体,循环次数是ARDS的数量
        mov ebx, ards_buf
        xor edx, edx             ;edx为最大的内存容量,在此先清0

    .find_max_mem_area:           ;无须判断type是否为1,最大的内存块一定是可被使用
        mov eax, [ebx]            ;base_add_low
        add eax, [ebx+8]          ;length_low
        add ebx, 20              ;指向缓冲区中下一个ARDS结构
        cmp edx, eax             ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
        jge .next_ards
        mov edx, eax             ;edx为总内存大小

    .next_ards:
        loop .find_max_mem_area
        jmp .mem_get_ok

    ; 获取内存容量,int 15, ax = E801h
    .e820_failed_so_try_e801:
        mov ax,0xe801
        int 0x15
        jc .e801_failed_so_try88       ;若当前e801方法失败,就尝试0x88方法

        ; 先算出低15M的内存,ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
        mov cx,0x400              ;cx和ax值一样,cx用做乘数
        mul cx
        shl edx,16
        and eax,0x0000FFFF
        or edx,eax
        add edx, 0x100000         ;ax只是15MB,故要加1MB
        mov esi,edx               ;先把低15MB的内存容量存入esi寄存器备份

        ; 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
        xor eax,eax
        mov ax,bx
        mov ecx, 0x10000          ;0x10000十进制为64KB
        mul ecx                  ;32位乘法,默认的被乘数是eax,积为64位,高32位存入edx,低32位存入eax.

        add esi,eax              ;由于此方法只能测出4G以内的内存,故32位eax足够了,edx肯定为0,只加eax便可
        mov edx,esi              ;edx为总内存大小
        jmp .mem_get_ok

    ; 获取内存容量,int 15, ah = 0x88
    .e801_failed_so_try88:
        ;int 15后,ax存入的是以kb为单位的内存容量
        mov ah, 0x88
        int 0x15
        jc  .error_hlt
        and eax,0x0000FFFF

        ;16位乘法,被乘数是ax,积为32位.积的高16位在dx中,积的低16位在ax中
        mov cx, 0x400      ;0x400等于1024,将ax中的内存容量换为以byte为单位
        mul cx
        shl edx, 16        ;把dx移到高16位
        or  edx, eax       ;把积的低16位组合到edx,为32位的积
        add edx,0x100000   ;0x88子功能只会返回1MB以上的内存,故实际内存大小要加上1MB


    ;将内存换为byte单位后存入total_mem_bytes处。
    .mem_get_ok:
        mov [total_mem_bytes], edx

    ; 进入保护模式三步骤

    ; 1.打开A20地址线

    in   al,0x92
    or   al,0000_0010B
    out  0x92,al

    ; 2.加载gdt描述符

    lgdt [gdt_ptr]

    ; 3.修改cr0标志寄存器的PE位

    mov  eax, cr0
    or   eax, 0x00000001
    mov  cr0, eax

    jmp dword SELECTOR_CODE:p_mode_start ; 刷新流水线,避免分支预测的影响
                                   ; 远跳将导致之前做的预测失效,从而起到了刷新的作用。
    .error_hlt:          ;出错则挂起
        hlt

    ; 下面就是保护模式下的程序了
    [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所有的位置,sgdt可以获取gdt表的位置,可以在3环和0环运行,会将gdtr寄存器能容返回

        ;因为内核运行在3GB之上,打印功能肯定要在内核中运行,不能让用户程序控制显存
        ;将gdt描述符中视频段描述符中的段基址+0xc000_0000
        mov ebx,[gdt_ptr + 2]                 ;gdt前2字节是偏移量,后4字节是基址,这里是取得gdt基址
        or dword [ebx + 0x18 + 4], 0xc000_0000
        ;视频段是第3个段描述符,每个描述符是8字节,故0x18
        ;段描述符的最高4字节用来记录段基址,段基址的第31-24位

        ;修改完显存描述符后,来修改gdt基址,我们把gdt也移到内核空间中
        ;将gdt的基址加上0xc000_0000使其成为内核所在的最高位
        add dword [gdt_ptr + 2], 0xc000_0000
        add esp, 0xc000_0000                 ;将栈指针同样映射到内核地址

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

        ;打开cr0的pg位
        mov eax,cr0
        or eax,0x8000_0000
        mov cr0, eax

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

        mov byte [gs:160],'V'
        jmp $


    ;------------------创建页目录表及页表-------------------
    setup_page:
    ;先把页目录表占用的空间逐字清零
        mov ecx, 4096                ;4KB,ecx = 0用于loop循环的终止条件
        mov esi, 0

    .clear_page_dir:
        mov byte [PAGE_DIR_TABLE_POS + esi],0       ;PAGE_DIR_TABLE_POS用于定义页目录表的物理地址
        inc esi                     ;esi++,PAGE_DIR_TABLE_POS为基址,esi为变址
        loop .clear_page_dir

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

    ;下面将页目录项0和0xc00都存为第一个页表的地址,指向同一个页表,每个页表表示4MB内存
    ;这样0xc03f_ffff以下的地址和0x003f_ffff以下的地址都指向相同的页表
    ;这是为将地址映射为内核地址做准备
        or eax, PG_US_U | PG_RW_W | PG_P     ;页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问,逻辑或后结果为0x7
        mov [PAGE_DIR_TABLE_POS + 0x0],eax  ;第一个目录项,在页目录表中第一个页目录项写入第一个页表的位置(0x10_1000)及属性(7),eax = 0x10_1007
        mov [PAGE_DIR_TABLE_POS + 0xc00],eax
    ;一个页表项占4B,0xc00表示第768个页表占用的目录项,0xc00以上的目录项用于内核空间,也就是页表的0xc000_0000-0xffff_ffff共计1G属于内核
    ;0x0-0xbfff_ffff共计3G属于用户进程
        sub eax, 0x1000                          ;eax = 0x10_0000
        mov [PAGE_DIR_TABLE_POS + 4092],eax     ;使最后一个目录项指向页目录表自己的地址,4096-4=4092,因为一个目录项4B,为了将来能动态操作页表

    ;下面创建页表项PTE
    ;创建第一个页表0x10_1000,它用来分配物理范围0-0x3f_ffff之间的物理页,也就是虚拟地址0-0x3f_ffff和0xc000_0000-0xc03f_ffff对应的物理页。
        mov ecx, 256                             ;因为目前只用到了1MB内存,所以只分配这1MB。1M低端内存/每页大小4k = 256页,即256个页表项,一共要创建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赋值为0x10_1000,也就是第一个页表的地址

        add edx, 4096                            ;edx + 4KB指向下一个页表的起始地址(第二个页表)
        inc esi
        loop .create_pte

    ;创建内核其他页表的PDE,即内核空间中除第0个页表外的其余所有页表对应的页目录项
        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                             ;范围为第769-1022的所有目录项数量,第255个已经指向了页目录表本身
        mov esi, 769

    .create_kernel_pde:
        mov [ebx+esi*4], eax                     ;从第二个页目录项开始写
        inc esi
        add eax, 0x1000
        loop .create_kernel_pde
        ret

接下来对上面的代码进行解释

创建页目录表和页表

上面新加的代码致上分成两部分,第一部分先建立个页目录表,后面的第二部分建立个页表。

代码解释一

这里先清空页目录表所占的内存。

由于内存中有大量随机数据,需要先将它们统一初始化为0。

页目录表大小是4KB,也就是4096个字节,这里用loop循环指令逐个清0,loop指令会用到ecx做循环计数器,所以为ecx寄存器赋值为4096,每次循环一次后, ecx会减1,直到ecx为0。

setup_page:
;先把页目录表占用的空间逐字清零
    mov ecx, 4096                ;4KB,ecx = 0用于loop循环的终止条件
    mov esi, 0

.clear_page_dir:
    mov byte [PAGE_DIR_TABLE_POS + esi],0       ;PAGE_DIR_TABLE_POS用于定义页目录表的物理地址
    inc esi                     ;esi++,PAGE_DIR_TABLE_POS为基址,esi为变址
    loop .clear_page_dir

 

代码解释二

这里是创建页目录项

先在这里,eax 被设置为第一个页表的位置和属性,用于后续创建页表项。ebx 则被保留作为基址,在后续创建页表项时会用到。

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

 

这里对 eax 的操作设置了页目录项的属性:

  • PG_US_U 表示用户态权限;
  • PG_RW_W 表示读写权限;
  • PG_P 表示存在位,表示该页表或页表项存在。
or eax, PG_US_U | PG_RW_W | PG_P     ;页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问,逻辑或后结果为0x7

 

寄存器eax是页目录项的内容(提醒一下,PG_US_U| PG_RW_W | PG_P逻辑或的结果是0x7),分别将其存入到页目录项的第 0 项和第 768项,一个页表项占4B,0xc00表示第768个页表占用的目录项,0xc00以上的目录项用于内核空间,也就是页表的0xc000_0000~0xffff_ffff共计1G属于内核, 0xc00/4=768。页目录项代表一个页表,也就是将第一个页目录项和第768个页目录项都设置为相同的页表地址和属性,这两处都是指向同一个页表。首先明确一下,它们共同所指向的这个页表地址是0x101000,它将来要指向的物理地址范围是0~0xfffff,只是1MB的空间,其余3MB并未分配。

这两处指向同一个页表。因为我们在加载内核之前,程序中运行的一直都是loader,它本身的代码都是在1MB之内,必须保证之前段机制下的线性地址和分页后的虚拟地址对应的物理地址一致。第0个页目录项代表的页表,其表示的空间是0~0x3fffff,包括了1MB (0~0xfffff),这样可以确保引导加载程序在分页启用后仍然能够正确运行,因为它的代码和数据仍位于物理内存低端,通过0~0x3fffff的映射可以保证引导加载程序的正确执行。

那为什么也要把该地址放置到第768项呢?前面说过啦,我们将来会把操作系统内核放在低端1M物理内存空间,但操作系统的虚拟地址是0xc0000000以上,该虚拟地址对应的页目录项是第768个。这个算起来容易,0xc0000000的高10位是0x300,即十进制的768。这样虚拟地址0xc0000000~0xc03fffff之间的内存都指向的是低端4MB之内的物理地址,这自然包括操作系统所占的低端1MB物理内存。从而实现了操作系统高3GB以上的虚拟地址对应到了低端1MB,也就是如前所说我们内核所占的就是低端1MB。

将页目录项0和0xc00都存为第一个页表的地址,指向同一个页表,每个页表表示4MB内存,这样0xc03f_ffff以下的地址和0x003f_ffff以下的地址都指向相同的页表。

mov [PAGE_DIR_TABLE_POS + 0x0], eax    ; 将第一个页目录项设置为第一个页表的位置和属性
mov [PAGE_DIR_TABLE_POS + 0xc00], eax  ; 将第768个页目录项(0xc00处)也设置为第一个页表的位置和属性

 

这段代码确保最后一个页目录项指向页目录表自身的起始地址,这样可以在将来动态修改页表时使用。最后一个页目录是指向了自己,这也为修改页目录表埋下了机会,否则内存虚拟化后,无法通过直接访问物理地址来访问内存,页目录表也不在虚拟内存可以访问的空间内,那么这个表相当于直接丢失了,无法访问。

eax 原来的值是0x101007,这是第一个页表的页目录项。将eax减去0x1000后,eax的值为0x100007。 PG_US_U和PG_US_S 是PDE 或PTE的属性,它用来限制某些特权级的任务对此内存空间的访问(无论该内存空间中存放的是指令,还是普通数据)。PG_US_U表示PDE或PTE的US位为1,这说明处理器允许任意4个特权级的任务都可以访问此PDE或PDE指向的内存。PG_US_S表示PDE或PTE的US位为0,这说明处理器允许除特权级3外的其他特权级任务访问此PDE或PDE指向的内存。此时若使用属性PG_US_S也没问题,不过将来我们会实现init进程,它是用户级程序,而它位于内核地址空间,也就是说将来我们会在特权级3的情况下执行init,这会访问到内核空间,这就是此处用属性PG-US-U的目的,eax本身是页目录项,现在将其加入到页目录表中最后一个页目录项中,目的是为了将来能够动态操作页表。

PAGE_DIR_TABLE_POS + 4092 这个表达式表示在页目录表中定位到第1023个页目录项,用来管理虚拟地址空间中的最后1GB的物理内存地址。

sub eax, 0x1000                          ;eax = 0x10_0000
mov [PAGE_DIR_TABLE_POS + 4092],eax     ;使最后一个目录项指向页目录表自己的地址,4096-4=4092,因为一个目录项4B,为了将来能动态操作页表

 

代码解释三

这里完成创建页表,此页表是页目录中的第0个页目录项对应的页表,它用来分配物理地址范围0~0x3fffff(即1MB范围)之间的物理页,这也就是虚拟地址0x0~0x3fffff和虚拟地址0xc0000000~0xc03fffff对应的物理页。一个页表表示的内存容量是4MB,但我们目前只用到了第1个1MB空间,所以我们只为这1MB空间对应的页表项分配物理页。每个物理页是4KB,所以只需要1MB/4KB=256个页表项即可。同样是用loop指令循环为页表项赋值,所以ecx作为循环计数器被赋值为256大家可以看出,”add edx, 4096″,edx是物理页的页表项,每次edx加上4KB,其物理地址是连续分配的,即在低端1MB内存中,虚拟地址等于物理地址。

;下面创建页表项PTE
;创建第一个页表0x10_1000,它用来分配物理范围0-0x3f_ffff之间的物理页,也就是虚拟地址0-0x3f_ffff和0xc000_0000-0xc03f_ffff对应的物理页。
    mov ecx, 256                             ;因为目前只用到了1MB内存,所以只分配这1MB。1M低端内存/每页大小4k = 256页,即256个页表项,一共要创建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赋值为0x10_1000,也就是第一个页表的地址

    add edx, 4096                            ;edx + 4KB指向下一个页表的起始地址(第二个页表)
    inc esi
    loop .create_pte

 

代码解释四

这里创建除第 768 个页表之外的其他页表对应的 PDE,也就是内核空间中除第 0 个页表外的其余所有页表对应的目录项。虽然前面已经创建了第768个页表的PDE了,但它只是一个页表的空间。尽管我们的内核甚至连4MB的内存空间都绰绰有余,也就是说1个页表足矣应付了,只需要在页目录表中安装一个PDE就够了,但为了真正实现内核被所有进程共享,还是在页目录表中为内核额外安装了254个页表的PDE (第255个PDE已经指向了页目录表本身),也就是内核空间的实际大小是1GB减去4MB的差,当然了,必须要为页表中具体的PTE分配物理页框后才算真正的内存空间,此处还不算,此处在页目录表中把内核空间的目录项写满,目的是为将来的用户进程做准备,使所有用户进程共享内核空间。

我们将来要完成的任务是让每个用户进程都有独立的页表,也就是独立的虚拟4GB空间。其中低3GB属于用户进程自己的空间,高1GB是内核空间,内核将被所有用户进程共享。为了实现所有用户进程共享内核,各用户进程的高1GB必须都指向内核所在的物理内存空间,也就是说每个进程页目录表中第768~1022个页目录项都是与其他进程相同的(各进程页目录表中第1023个目录项指向页目录表自身),因此在为用户进程创建页表时,我们应该把内核页表中第 768~1022 个页目录项复制到用户进程页目录表中的相同位置。一个页目录项对应一个页表地址,页表地址固定了,后来新增的页表项也只会加在这些固定的页表中。如果不这样的话,进程陷入内核时,假设内核为了某些需求为内核空间新增页表(通常是申请大量内存),因此还需要把新内核页表同步到其他进程的页表中,否则内核无法被“完全”共享,只能是“部分”共享。所以,实现内核完全共享最简单的办法是提前把内核的所有页目录项定下来,也就是提前把内核的页表固定下来,这是实现内核共享的关键。这样一来,内核所在的空间完全被所有进程共享,所有进程都可以使用内核提供的服务,内核若为任意一个用户进程在内核空间中创建了某些资源的话,其他进程都可以访问到该资源。

如果此处仅仅是安装第768个页目录项的话,第769~1022个目录项是空的,将来即使把第768~1022个目录项复制给用户进程时,内核空间也仅是其低4MB被所有进程共享,万一在某些情况下内核使用的空间超过4MB,要用到第769个页目录项对应的页表,由于此处未提前准备该目录项,创建的新页表(的PDE)只会安装在当时那个进程的页目录表中,而切换了其他进程后,新进程的页目录表中并不包含新页表的PDE,,因此无法访问到最新的内核空间。

;创建内核其他页表的PDE,即内核空间中除第0个页表外的其余所有页表对应的页目录项
    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                             ;范围为第769-1022的所有目录项数量,第255个已经指向了页目录表本身
    mov esi, 769

.create_kernel_pde:
    mov [ebx+esi*4], eax                     ;从第二个页目录项开始写
    inc esi
    add eax, 0x1000
    loop .create_kernel_pde
    ret

 

启用分页

上面是创建页目录表和页表,这里就是启用分页的全部代码

启用分页机制步骤如下:

  • 准备好页目录表及页表。
  • 将页表地址写入控制寄存器 cr3。
  • 寄存器cr0的PG位置1。

先建立页目录表和所需要的页表,开启分页的第一步:先准备好页表。

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

 

为了重启加载 GDT 做准备。因为我们在页表中会将内核放置到3GB 以上的地址,我们也把GDT放在内核的地址空间,在此通过sgdt指令,将GDT的起始地址和偏移量信息dump (像倒水一样)出来,依然存放到gdt_ptr处,一会儿待条件成熟时,我们再从地址gdt_ptr处重新加载GDT

sgdt [gdt_ptr]: 这条指令的作用是将当前全局描述符表寄存器 (GDTR, Global Descriptor Table Register) 的内容(即描述符表的基地址和界限)存储到内存中 gdt_ptr 所指向的位置。

;要将描述符表地址及偏移量写入内存gdt_ptr中,一会儿用新地址加载
sgdt [gdt_ptr]                      ;存储到原来gdt所有的位置,sgdt可以获取gdt表的位置,可以在3环和0环运行,会将gdtr寄存器能容返回

 

这里是修改显存段的段描述符的段基址,因为将来内核运行在3GB以上,打印功能将来也是在内核中实现,肯定不能让用户进程直接能控制显存。故显存段的段基址也要改为3GB以上才行。大家都知道32位虚拟地址空间共4GB,若用十六进制表示,最高位(第31位)每变化4位就表示1GB空间,其实就是16位/4GB=4位/1GB,意为每1GB内存空间需要4位来表示。所以,0x00000000~0x3ffffffff是第1个1GB 内存,0x40000000~0x7fffffff是第2个1GB.0x80000000~0xbfffffff是第3个1GB内存,0xc0000000~0xfffffffff是第4个1GB内存。内核是3GB以上,范围就是第4个1GB,故虚拟地址0xc0000000~0xff才是内核地址。

gdt_ptr处的值包括两部分,前部分是2字节大小的偏移量,其后是4字节大小GDT基址。这里先要得到GDT地址,所以gdt_ptr加2,即”mov ebx, [gdt_ptr+2]”。之后ebx是GDT的地址。由于显存段描述符是GDT中第3个描述符,一个描述符大小是8字节,所以ebx要加上0x18才能访问显存段描述符。这里要将段描述符的基地址修改为3GB以上,所以在原有地址的基础上要加上0xc0000000。只有最高位是c,其他位都为0,段描述符中记录段基址最高位的部分是在段描述符的高4字节的最高1字节,所以ebx不仅要加上0x18,还要加上0x4。为了省事,我们直接将整个4字节做或运算。最后就是第157行的指令”or dword [ebx + 0x18 + 4], 0xc0000000″,在修改完了显存段描述符后,现在可以修改GDT基址啦,我们把GDT也移到内核空间中。所以直接将gdt_ptr+2处的GDT基址加了0xc0000000。其实这不是必须的,如果分页后不重复加载GDT的话,也可以不修改GDT基址。栈地址也修改成内核使用的地址,所以直接把 esp 加了 0xc0000000。

;因为内核运行在3GB之上,打印功能肯定要在内核中运行,不能让用户程序控制显存
;将gdt描述符中视频段描述符中的段基址+0xc000_0000
mov ebx,[gdt_ptr + 2]                 ;gdt前2字节是偏移量,后4字节是基址,这里是取得gdt基址
or dword [ebx + 0x18 + 4], 0xc000_0000
;视频段是第3个段描述符,每个描述符是8字节,故0x18
;段描述符的最高4字节用来记录段基址,段基址的第31-24位

;修改完显存描述符后,来修改gdt基址,我们把gdt也移到内核空间中
;将gdt的基址加上0xc000_0000使其成为内核所在的最高位
add dword [gdt_ptr + 2], 0xc000_0000
add esp, 0xc000_0000                 ;将栈指针同样映射到内核地址

 

后面就是启用分页机制的第二步及第三步,将页目录表物理地址赋值给cr3寄存器后,随后启用 crO 寄存器的 pg 位。

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

;打开cr0的pg位
mov eax,cr0
or eax,0x8000_0000
mov cr0, eax

最后把 GDT 重新加载

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

 

最后有一个注意的点

如果在执行 hlt 指令之前没有进行必要的流水线刷新,可能会导致之前的指令流状态仍然存在,这可能包括了失效的分支预测信息。这种情况下,执行 hlt 可能会导致不可预测的行为或者不正确的执行路径。
为了确保程序的正确性和稳定性,特别是在处理错误或者在程序需要等待的情况下,应该先执行刷新流水线的操作(例如通过长跳转),然后再执行 hlt 指令。

 

 

4.执行结果

可以看到在分页后, GDT的基址会变成3GB之上的虚拟地址,显存段基址也变成了3GB这上的虚拟地址,上面的框框是新的GDT的段基址,已经变成了0xc0000900,下面的框框是显存段描述符的段基址,已经是0xc00b8000,,不再是0xb8000了。

开启分页之后,除了可以在物理内存0x100000处后看到页目录表外,我们还可以在虚拟机中利用info tab命令看到页表中虚拟地址到物理地址的映射关系。info是用来查看各种数据的命令,tab表示页表。

看第一行,虚拟地址0x00000000~0x000fffff,这是虚拟空间低端1M内存,其对应的物理地址是0x000000000000~0x0000000fffff。 这是第0个页表,上面的代码就是ecx=256的那个,为256个页表项分配物理页。

第二行,虚拟地址0xc0000000~0xc00fffff,这是第768个页表。 由于第0个页目录项和第768个页目录项指向的是同一个页表,所以其映射的物理地址依然是0x000000000000~0x0000000fffff。

第三行,最后一个目录项是第1023个目录项,虚拟地址的高10位用来访问页目录表中的目录项,如果高10全为1,即1111111111b=0x3ff=1023,则访问的是最后一个目录项,该目录项中的高20位是页目录表本身的物理地址0x100000,不过,由于该地址是经过虚拟地址的高10位索引到的,所以被认为是个页表的地址,也就是说,页目录表此时被当作页表来用。线性地址的中间10位用来在页表中定位一个页表项,从该页表项中获取物理页地址。这时,若虚拟地址中间10位为0000000000b=0x0,即检索到第0个页表项,此时的页目录表作为页表,故第0个页表项实际上是页目录表的第0个页目录项,其中记录的是第一个页表的物理地址,其值是0x101000,此值被认为是最终的物理页地址。如果虚拟地址的低12位为000000000000b=0x000,最终的物理地址是0x101000+0x000=0x101000,故虚拟地址是0xffc00000~0xffc00fff,其被映射的物理地址范围是0x00101000~0x00101fff。也就是高10位若为0x3ff,则会访问到页目录表中最后一个页目录项,由于页表中也是1024个页表项,故中间10位若为0x3ff,则会访问到页表中最后一个页表项。

第四行,虚拟地址0xfff00000的高10位依然为0x3ff,中间10位是1100000000b=0x300,这是第768个页目录项,该页目录项指向的页表与第0个页目录项指向的页表相同。 所以虚拟地址0xfff00000映射为物理地址0x00101000成立。

第五行,0xfffff000的高10位是0x3ff,中间10位依然是0x3ff,将其换成二进制后就容易看出来了。如果高10位为0x3ff,则会访问到最后一个页目录项,该页目录项中是我们的页目录表的物理地址。目录项中的应该是页表的物理地址,所以此页目录表被当作页表来用。中间10位也是0x3ff,用它在页表内索引页表项,在此是在页目录表中索引,所以,索引到的是最后一个项目,页部件认为该项是页表项,但其实是页目录项,该页目录项中的是页目录表的物理地址。虚拟地址的低12位是0x000,所以得到的物理地址最终是页目录表的物理地址+0x000=页目录表的物理地址。我们的页目录表物理地址是0x00100000。于是虚拟地址0xfffff000映射成为物理地址0x00100000成立,也同样容易理解0xffffffff映射为0x00100fff。

如果虚拟地址的高 20 位为 0xfffff,经过我们的页目录表映射,将会访问到页目录表自己的物理地址。

页表也位于内存中,修改页表的操作也需要通过内存地址才行,页表是将虚拟地址转换成物理地址的映射表,在分页机制下,如何用虚拟地址访问到页表自身呢?

获取页目录表物理地址:

  • 让虚拟地址的高20位为0xfffff,低12位为0x000,即0xfffff000,这也是页目录表中第0个页目录项自身的物理地址。
  • 访问页目录中的页目录项,即获取页表物理地址:要使虚拟地址为0xfffffxxx,其中xxx是页目录项的索引乘以4 的积。
  • 访问页表中的页表项:要使虚拟地址高 10 位为 0x3ff,目的是获取页目录表物理地址。中间 10 位为页表的索引,因为是10位的索引值,所以这里不用乘以4。低12位为页表内的偏移地址,用来定位页表项,它必须是已经乘以4后的值。

 

5.快表

每一个虚拟地址到物理地址的转换都要重复以上过程,会转换过程中频繁的内存访问,所以我们需要缓存。

快表:CPU的高速缓存,它专门用来存放虚拟地址页框和物理地址页框的映射关系,用来匹配高速的处理器速率和低速的内存访问速度。

TLB必须实时更新,因为TLB里面存储的是程序运行所依赖的指令和数据的内存地址。但如果实时读取内存中的页表去更新TLB的话,这又回到了从内存查询映射关系的老路,因此,TLB由开发人员手动控制。

更新的两种方法:

  • 重新加载cr3:将cr3寄存器的数据读出再写入cr3,会使整个TLB失效。
  • invlpg(invalidate page):针对TLB中某个条目的更新。
    指令格式:invlpg m。m为虚拟内存地址,并不是立即数。
    例如:更新虚拟地址0x1234对应的条目,指令为invlpg [0x1234]而不是invlpg 0x1234

 

6.参考

郑钢著操作系统真象还原

田宇著一个64位操作系统的设计与实现

丁渊著ORANGE’S:一个操作系统的实现

暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇