《操作系统真像还原》操作系统实现——进入分页模式

Posted on May 17, 2021

进入保护模式后,我们对内存的访问仍然是基于物理地址的,我们运行的程序,大多是希望自己有一段连续的地址空间的,这样方便寻址。如果使用物理地址来访问内存,就必须真的给每个进程都分配大段地连续物理内存空间,这可能造成内存碎片难以处理的问题。为了解决这个问题,可以引入分页模式Paging mode),好处非常多,此处不再赘述,可以看 WIKI

直观地来看,分页模式做的就是将地址虚拟化,将物理地址以页(一页为 4KB)的粒度映射到虚拟地址上。这样可以给每个进程一个连续的虚拟地址,但是该进程实际使用的物理地址不是连续的,而是以 4K 为粒度的离散的空间,这样最大的碎片就只会有 4KB,基本解决了内存碎片的问题。

一级页表

为了实现这样的映射,自然的想法是就建立一张页表,把虚拟地址全部映射到物理地址上。由于一页为 4KB,32 位系统能够寻到的地址上限为 4GB,所以为了实现完全的映射,这个页表就需要有 1024 * 1024 个项,共计 1M,如果用一个 32 位数做为一个项,一个页表的大小就是 4M,考虑到一台电脑中同时会运行多个进程,进程一多,页表就会占用大量空间,非常的浪费,同时页表是需要有连续的物理地址空间的,这就又回到了物理地址时代的内存碎片的问题上了。

总结一下,一级页表存在这两个问题:

  1. 1M 个页表项常驻于内存,占用内存空间
  2. 页表需要连续的物理地址空间,会造成内存碎片过大的问题

通过二级页表可以解决这两个问题。

二级页表

二级页表就是通过一个页目录表Page Directory EntryPDE)来管理之前的一级页表的。

一级页表有 1024 * 1024 个项,每个项大小 4B,也就是 1024 * 4KB,那么用一个内存页可以存下 1024 个页表项,1024 个这样的页表(在二级页表中,页表特指有 1024 个项的页表,英文为Page Table EntryPTE)就可以将虚拟地址映射到物理地址上了,那么我们用一个目录存下这 1024 个页表的地址,就可以实现映射了,这一个目录,即页目录表也只需要占用一个页,原来的 4M 的空间就被拆分成了 1024 + 1 个页,解决了页表产生内存碎片的问题。

另外,由于我们往往不会在程序中使用全部的虚拟地址,所以没有必要把所有的虚拟地址都做好映射,只要把需要的段做上映射就可以了,通过二级页表,我们不需要一次性建好所有的页表项。

这里的不需要一次性建好可以多说两句,一级页表其实也是可以不一次性建好的,打个比方,不需要映射的地方清零就行了,代表不需要映射,但是要知道这里仍然占用了连续空间,存储这个项没有被建立的形象。而二级页表可以在页目录表直接表示这 4M 空间都没有映射,对应的 1024 个页表也不需要存在于内存中了。换句话说,就是二级页表的页目录表一项可以表示 4M 空间的状态,比一级页表一项表示 4KB 高到不知道哪里去了。这样就不需要让所有的页表存在于内存中,只需要让需要的存在,减少了内存占用。

宏观实现

实现的方法就是用 1025 个页来映射虚拟地址,其中只有页目录表一个页是必须存在且存在于固定的物理地址上。开启分页机制前,需要把页目录表基址存到控制寄存器 CR3 中。

然后硬件页部件就可以根据 32 位虚拟地址可以计算物理地址。虚拟地址的高10位(22 ~ 31 位)作为页目录表的索引,从目录中找到对应的页表,然后以虚拟地址的 12 ~ 21 作为页表的索引,找到虚拟地址的物理地址基址,以虚拟地址的低 12 位作为偏移,就可以访问到这整个页中的数据。这个计算过程是由硬件完成的。

从上面可以看出,由于页表和目录都是页对齐的,所以低 12 位都不需要使用,可以在其中存储一些控制信息,每一位对应如下

页目录表:

31 ~ 1211 ~ 9876543210
页表物理地址高 20 位AVLG00APCDPWTUSRWP

页表:

31 ~ 1211 ~ 9876543210
物理页地址高 20 位AVLGPATDAPCDPWTUSRWP

AVL:未被硬件使用

G:Global 位,若该位为 1,TLB(Translation Lookaside Buffer)中会缓存这个虚拟地址的映射。

PAT:页属性表位,在页粒度上设置内存属性

D:Dirty 位,脏位,CPU 在对一个页执行写操作时,会置该位为 1。该位仅对页表有效。

A:Accessed 位,访问位,为 1 表示被访问过。

PCD:Page-level Cache Disabled,页级高速缓冲禁止位,设置为 0 禁用高速缓存。

PWT:Page-level Write-Through,页级通写位,和高速缓存有关。

US:User/Supervisor 位,该位为 1 时,表示为 User 级,所有特权级都可以访问,为 0 时仅有 Ring 0 可以访问。

RW:设置读写属性,1 表示可读写,0 表示只读。

P:Present 位,特别的,与页中的 P 位不同,对于 PDE,1 表示对应的页目录表已存在,对于 PTE 表示对应的页已分配。

接下来我们在 loader 中设置并开启页表。

设置页表

使用一个函数来设置页表。由于现在还没有用户进程,所以暂时不需要设置内核地址之外的页表。使用类似于 Linux 的内核布局,内核代码处于虚拟地址 3G - 4G。不过其映射的物理地址,我们希望它处于低段。也就是把 3G - 4G 这个虚拟地址映射到低 0 - 1G 上。当然实际上我们的内核完全不需要这么多的空间,1M 的数量级都远远不到,所以就提前约定,内核使用物理地址 0 - 1M 的空间(也不是这里面的全部,有些硬件也映射到了这里)。那么我们的页目录表就建立在 0x10000 上(物理地址 1M 处)。

内核的虚拟地址处于 3G-4G 即 0xC0000000 ~ 0xFFFFFFFF,对应到页目录表中,就是 0x300 ~ 0x3FF 项(取高十位),换算成偏移地址(左移 2 位)就是 0xC00 ~ 0xFFC 这 256 项,每一项对应的页表可以映射 4M 的物理内存。之前也说了我们的内核只是用 0 - 1M 的空间,所以我们首先建立好 PDE[0x300] 对应的页表。

然后还需要建立 PDE[0],这是因为当前运行的 loader 原先是使用物理地址的,进入分页模式后,要保证物理地址转为虚拟地址后仍然能够映射到物理地址上,即自己映射自己,这样 loader 才能继续工作。loader 的基址是 0x600,对应的页目录表索引位 0,所以我们需要建立 PDE[0],不过由于 PDE[0] 和 PDE[0x300] 指向的是同一个页表,建立也不麻烦。

PDE 的位置固定,内核所使用的页表完全可以直接建立在 PDE 之后,反正这些页表是一直要使用的,不存在碎片的问题,所以就直接建立在 PDE 之后。

建立的过程就是下面的代码这样:

SetupPage:
; ---------- this function setup the Page Directory Entry and Page Table Entry ----------
    ; clear PTE
    mov ecx,0x1000                          ; 4K PDE
    mov esi,0                               ; use this reg the clear
    .SetupPage_ClearPDE:
        mov byte [PAGE_DIR_TABLE_POS + esi],0
        inc esi
    loop .SetupPage_ClearPDE

    ; setup PDE
    .SetupPage_CreatePDE:
        mov eax,PAGE_DIR_TABLE_POS
        add eax,0x1000                       ; addr of the first PTE 
        mov ebx,eax                          ; ebx is the base addr of PTEs

        ; make the PDE[0] and PDE[0xC00] point to the first PTE
        or eax,PG_US_U | PG_RW_RW | PG_P     ; set user page status
        mov [PAGE_DIR_TABLE_POS + 0x0],eax   ; the first PTE's place
        mov [PAGE_DIR_TABLE_POS + 0xC00],eax ; the first PTE used by kernel, point ot the fisrt PTE
        ; 0xC0000000 ~ 0xFFFFFFFF belongs to kernel
        sub eax,0x1000
        mov [PAGE_DIR_TABLE_POS + 0xFFC],eax  ; make the last Entry point to PDE itself

        ; creat PTE for kernel
        mov ecx,256                          ; 1M / 4K = 256
        mov esi,0
        mov edx,PG_US_U | PG_RW_RW | PG_P    ; User, RW, P
        .SetupPage_CreatePTE:
            mov [ebx + esi * 4],edx
            add edx,0x1000
            inc esi
        loop .SetupPage_CreatePTE

        mov eax,PAGE_DIR_TABLE_POS
        add eax,0x2000                      ; second PTE
        or eax,PG_US_U | PG_RW_RW | PG_P
        mov ebx,PAGE_DIR_TABLE_POS
        mov ecx,254                         ; 1022 - 769 + 1
        mov eax,769                         ; start from 769,the second PTE of kernel
        .SetupPage_CreateKernelPDE:
            mov [ebx + esi * 4],eax
            inc esi
            add eax,0x1000
        loop .SetupPage_CreateKernelPDE
        ret
; ---------- end of function SetupPage ----------

这里需要说明几点:

  • 页属性设置位 User 的原因是以后要在内核段中执行 init 进程,该进程是 Ring 3 的,所以需要这样设置
  • 最后把内核的需要的所有页目录都建立好了,看似没有意义,因为我们的内核明明只占用 1M 空间,但是实际上这是为了未来将内核共享给所有用户进程所做的处理。具体的,每个用户进程页表的内核段都是直接从 0x10000 的 PDE 拷被过去的,如果不提前建好页目录,当内核为一个用户进程建立了超过 4M 的资源后,其他用户由于页表对应索引处为零,就无法访问到这里的资源。

进入分页模式

进入分页模式,需要完成以下 4 步:

  1. 建立页表(之前已经完成)
  2. 预更新 GDT,使进入分页模式后能够立刻修改 GDTR,使各段使用正确的虚拟地址
  3. 设置 CR3 为 PDT 的地址
  4. 设置 CR0 的最高位为 1,正式开启分页模式

进入分页模式后,更新 GDTR,让各段可以使用上虚拟地址。

最后完成的 loader.S:

%include "boot.inc"

section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR

jmp LoaderStart                         ; 3 bytes
db 0
dd 0,0,0                                ; addr align
; offset 0x10

; set up GOT and descriptor
GDT_BASE: dd 0x00000000   
          dd 0x00000000   

CODE_DESC: dd 0x0000FFFF                ; low 32 bits
           dd DESC_CODE_HIGH4           ; high 32 bits

DATA_STACK_DESC: dd 0x0000FFFF          ; used by stack and data seg
            dd DESC_DATA_HIGH4

; text-mode display
; limit = (0xBFFFF - 0xB8000) / 4K = 0x7
VIDEO_DESC: dd 0x80000007 
            dd DESC_VIDEO_HIGH4

GDT_SIZE  equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1

times 60 dq 0                           ; reserve 60 GDTs

TOTAL_MEM_BYTES dd 0                    ; memory of the machine
                                        ; addr: LOADER_BASE_ADDR + 0x10 + 0x200 = 0x800

SELECTOR_CODE equ ((CODE_DESC - GDT_BASE) / 8) << 3 + TI_GDT + RPL0
SELECTOR_DATA equ ((DATA_STACK_DESC - GDT_BASE) / 8) << 3 + TI_GDT + RPL0
SELECTOR_VIDEO equ ((VIDEO_DESC - GDT_BASE) / 8) << 3 + TI_GDT + RPL0

; pointer point to GDT
gdt_ptr: dw GDT_LIMIT    ; low 16 bits of GDT reg
         dd GDT_BASE     ; high 32 bits of GDT reg
; end of GDT setup

LoaderStart:
; ---------- first, get the total memory of the machine ----------
; ---------- we must do it before enter the PE mode as we need the BIOS int ----------
; use bios int 0x15 sub 0xE801
    .LoaderStart_E801FailedRetry:
        mov ax,0xE801
        int 0x15
    jc .LoaderStart_E801FailedRetry
    ; calculate low 15MB memory
    mov cx,0x400
    mul cx
    shl edx,16
    and eax,0x0000FFFF
    or edx,eax
    add edx,0x100000                        ; add 1MB, this is caused by the memory hole
    mov esi,edx

    xor eax,eax
    mov ax,bx
    mov ecx,0x10000                         ; 64 * 1024
    mul ecx
    add esi,eax                             ; esi store the
    mov [TOTAL_MEM_BYTES],esi               ; now TOTAL_MEM_BYTES stores the total memory

    ; ---------- ready to enter Proctection mode ----------
    ; 1 open A20 address line
    ; 2 load GDT reg
    ; 3 set pe of cr0 to 1

    ; open A20
    in al,0x92   
    or al,0000_0010B                        ; save existed status
    out 0x92,al
    ; load GDT reg
    lgdt [gdt_ptr]
    ; set cr0, let's roll!
    mov eax,cr0
    or eax,0x00000001                       ; save existed status
    mov cr0,eax                             ; enter Protection mode

    jmp dword SELECTOR_CODE:ProctectionModeStart    ; reflesh assembly line
; ---------- end of function LoaderStart ----------

; ---------- now we are in 32-bits PE mode ----------
[bits 32]
ProctectionModeStart:
; set selectors
    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
    mov byte [gs:160],'P'

; first thing we do is start the page mode
    ; 1 setup PDE and related PTE
    call SetupPage
    ; 2 modify the GDT to make it work in paging mode
    sgdt [gdt_ptr]
    mov ebx,[gdt_ptr + 2]
    or dword [ebx + 0x18 + 4],0xC0000000    ; modify the VIDEO_DESC

    add dword [gdt_ptr + 2],0xC0000000      ; pre modify the GDTR value 
    add esp,0xC0000000                      ; also modify the stack 

    mov eax,PAGE_DIR_TABLE_POS
    mov cr3,eax

    mov eax,cr0
    or eax,0x80000000                       ; save existed status
    mov cr0,eax                             ; enable paging mode
  
    lgdt [gdt_ptr]                          ; change GDTR

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

SetupPage:
; ---------- this function setup the Page Directory Entry and Page Table Entry ----------
    ; clear PTE
    mov ecx,0x1000                          ; 4K PDE
    mov esi,0                               ; use this reg the clear
    .SetupPage_ClearPDE:
        mov byte [PAGE_DIR_TABLE_POS + esi],0
        inc esi
    loop .SetupPage_ClearPDE

    ; setup PDE
    .SetupPage_CreatePDE:
        mov eax,PAGE_DIR_TABLE_POS
        add eax,0x1000                       ; addr of the first PTE 
        mov ebx,eax                          ; ebx is the base addr of PTEs

        ; make the PDE[0] and PDE[0xC00] point to the first PTE
        or eax,PG_US_U | PG_RW_RW | PG_P     ; set user page status
        mov [PAGE_DIR_TABLE_POS + 0x0],eax   ; the first PTE's place, mapping loader's addr to itself
        mov [PAGE_DIR_TABLE_POS + 0xC00],eax ; the first PTE used by kernel, mapping to low 1M
        ; 0xC0000000 ~ 0xFFFFFFFF belongs to kernel
        sub eax,0x1000
        mov [PAGE_DIR_TABLE_POS + 0xFFC],eax  ; make the last Entry point to PDE itself

        ; creat PTE for kernel
        mov ecx,256                          ; 1M / 4K = 256
        mov esi,0
        mov edx,PG_US_U | PG_RW_RW | PG_P    ; User, RW, P
        .SetupPage_CreatePTE:
            mov [ebx + esi * 4],edx
            add edx,0x1000
            inc esi
        loop .SetupPage_CreatePTE

        mov eax,PAGE_DIR_TABLE_POS
        add eax,0x2000                      ; second PTE
        or eax,PG_US_U | PG_RW_RW | PG_P
        mov ebx,PAGE_DIR_TABLE_POS
        mov ecx,254                         ; 1022 - 769 + 1
        mov esi,769                         ; start from 769,the second PTE of kernel
        .SetupPage_CreateKernelPDE:
            mov [ebx + esi * 4],eax
            inc esi
            add eax,0x1000
        loop .SetupPage_CreateKernelPDE
        ret
; ---------- end of function SetupPage ----------

更新后的 boot.inc:

;---------- loader and kernel ----------
LOADER_BASE_ADDR equ 0x600                  ; 0x500 ~ 0x7BFF
LOADER_START_SECTOR equ 0x2

;---------- gdt related ----------
; G, D, L, AVL sign
DESC_G_4K equ 1_00000000000000000000000b    ; set grid 4K
DESC_D_32 equ  1_0000000000000000000000b    ; set 32 bit text mode
DESC_L    equ   0_000000000000000000000b    ; turn off 64 bit text mode
DESC_AVL  equ    0_00000000000000000000b    ; unused by CPU

; segment limit high 4 bits
DESC_LIMIT_CODEH equ 1111_0000000000000000b ; LIMIT 0xF(FFFF)
DESC_LIMIT_DATAH equ DESC_LIMIT_CODEH       ; LIMIT 0xF(FFFF)
DESC_LIMIT_VIDEOH equ 0000_0000000000000000b 

; Present sign
DESC_P_IN equ 1_000000000000000b            ; this segment is in RAM

; Descriptor Privilege Level (DPL sign)
DESC_DPL_RING_0 equ 00_0000000000000b       ; set RING 0 
DESC_DPL_RING_1 equ 01_0000000000000b       ; set RING 1 
DESC_DPL_RING_2 equ 10_0000000000000b       ; set RING 2 
DESC_DPL_RING_3 equ 11_0000000000000b       ; set RING 3 

; CPU segment status (S sign)
DESC_S_CODE equ 1_000000000000b             ; code segment
DESC_S_DATA equ DESC_S_CODE                 ; data segment
DESC_S_SYS  equ 0_000000000000b             ; sys segment (to cpu)

; OS segment status (type sign)
DESC_TYPE_CODE equ 1000_00000000b           ; code segment (r-x)
DESC_TYPE_DATA equ 0010_00000000b           ; data segment (rw-) 

; normalized Descriptor
DESC_CODE_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_L + \
DESC_D_32 + DESC_AVL + DESC_P_IN + DESC_LIMIT_CODEH + \
DESC_DPL_RING_0 + DESC_S_CODE + DESC_TYPE_CODE + 0x00

DESC_DATA_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_L + \
DESC_D_32 + DESC_AVL + DESC_P_IN + DESC_LIMIT_DATAH + \
DESC_DPL_RING_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x00

DESC_VIDEO_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_L + \
DESC_D_32 + DESC_AVL + DESC_P_IN + DESC_LIMIT_VIDEOH + \
DESC_DPL_RING_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x0B

;---------- selector status ----------
; Request Privilege Level
RPL0 equ 00b                                ; Ring 0
RPL1 equ 01b                                ; Ring 1
RPL2 equ 10b                                ; Ring 2
RPL3 equ 11b                                ; Ring 3

; Table Indicator
TI_GDT equ 000b                                 ; set GDT selector 
TI_LDT equ 100b                                 ; set LDT selector

; ---------- page related ----------
PAGE_DIR_TABLE_POS equ 0x10000              ; PDT start at 1M

PG_P     equ 1                              ; Present sign
PG_RW_RW equ 10                             ; page type:rw-
PG_RW_R  equ 00                             ; page type:r--
PG_US_U  equ 100                            ; User level
PG_US_S  equ 000                            ; Supervisor level

最后的效果:

由于已经将视频段 GDT 更新为虚拟地址,这里成功输出代表页表建立正确,成功进入分页模式。

未来对页表的修改

在 bochs 中使用 info tab 指令可以显示出当前的虚拟地址映射情况。我们先注释掉循环 .SetupPage_CreateKernelPDE,这样在显示映射时不会有太多无关信息,输入 info tab 就可以查看当前的映射情况,如下

<bochs:2> info tab
cr3: 0x000000010000
0x00000000-0x000fffff -> 0x000000000000-0x0000000fffff
0xc0000000-0xc00fffff -> 0x000000000000-0x0000000fffff
0xffc00000-0xffc00fff -> 0x000000011000-0x000000011fff
0xfff00000-0xfff00fff -> 0x000000011000-0x000000011fff
0xfffff000-0xffffffff -> 0x000000010000-0x000000010fff

可见有虚拟地址被直接映射到了页目录表上,通过对这些虚拟地址进行读写操作,我们可以容易的建立、修改新的页表。其中的具体原理这里不写了,其实没什么好说的,根据建立目录方式就可以看出来。