《操作系统真像还原》操作系统实现——进入分页模式
进入保护模式后,我们对内存的访问仍然是基于物理地址的,我们运行的程序,大多是希望自己有一段连续的地址空间的,这样方便寻址。如果使用物理地址来访问内存,就必须真的给每个进程都分配大段地连续物理内存空间,这可能造成内存碎片难以处理的问题。为了解决这个问题,可以引入分页模式(Paging mode),好处非常多,此处不再赘述,可以看 WIKI。
直观地来看,分页模式做的就是将地址虚拟化,将物理地址以页(一页为 4KB)的粒度映射到虚拟地址上。这样可以给每个进程一个连续的虚拟地址,但是该进程实际使用的物理地址不是连续的,而是以 4K 为粒度的离散的空间,这样最大的碎片就只会有 4KB,基本解决了内存碎片的问题。
一级页表
为了实现这样的映射,自然的想法是就建立一张页表,把虚拟地址全部映射到物理地址上。由于一页为 4KB,32 位系统能够寻到的地址上限为 4GB,所以为了实现完全的映射,这个页表就需要有 1024 * 1024 个项,共计 1M,如果用一个 32 位数做为一个项,一个页表的大小就是 4M,考虑到一台电脑中同时会运行多个进程,进程一多,页表就会占用大量空间,非常的浪费,同时页表是需要有连续的物理地址空间的,这就又回到了物理地址时代的内存碎片的问题上了。
总结一下,一级页表存在这两个问题:
- 1M 个页表项常驻于内存,占用内存空间
- 页表需要连续的物理地址空间,会造成内存碎片过大的问题
通过二级页表可以解决这两个问题。
二级页表
二级页表就是通过一个页目录表(Page Directory Entry,PDE)来管理之前的一级页表的。
一级页表有 1024 * 1024 个项,每个项大小 4B,也就是 1024 * 4KB,那么用一个内存页可以存下 1024 个页表项,1024 个这样的页表(在二级页表中,页表特指有 1024 个项的页表,英文为Page Table Entry,PTE)就可以将虚拟地址映射到物理地址上了,那么我们用一个目录存下这 1024 个页表的地址,就可以实现映射了,这一个目录,即页目录表也只需要占用一个页,原来的 4M 的空间就被拆分成了 1024 + 1 个页,解决了页表产生内存碎片的问题。
另外,由于我们往往不会在程序中使用全部的虚拟地址,所以没有必要把所有的虚拟地址都做好映射,只要把需要的段做上映射就可以了,通过二级页表,我们不需要一次性建好所有的页表项。
这里的不需要一次性建好可以多说两句,一级页表其实也是可以不一次性建好的,打个比方,不需要映射的地方清零就行了,代表不需要映射,但是要知道这里仍然占用了连续空间,存储这个项没有被建立的形象。而二级页表可以在页目录表直接表示这 4M 空间都没有映射,对应的 1024 个页表也不需要存在于内存中了。换句话说,就是二级页表的页目录表一项可以表示 4M 空间的状态,比一级页表一项表示 4KB 高到不知道哪里去了。这样就不需要让所有的页表存在于内存中,只需要让需要的存在,减少了内存占用。
宏观实现
实现的方法就是用 1025 个页来映射虚拟地址,其中只有页目录表一个页是必须存在且存在于固定的物理地址上。开启分页机制前,需要把页目录表基址存到控制寄存器 CR3 中。
然后硬件页部件就可以根据 32 位虚拟地址可以计算物理地址。虚拟地址的高10位(22 ~ 31 位)作为页目录表的索引,从目录中找到对应的页表,然后以虚拟地址的 12 ~ 21 作为页表的索引,找到虚拟地址的物理地址基址,以虚拟地址的低 12 位作为偏移,就可以访问到这整个页中的数据。这个计算过程是由硬件完成的。
从上面可以看出,由于页表和目录都是页对齐的,所以低 12 位都不需要使用,可以在其中存储一些控制信息,每一位对应如下
页目录表:
31 ~ 12 | 11 ~ 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|---|---|
页表物理地址高 20 位 | AVL | G | 0 | 0 | A | PCD | PWT | US | RW | P |
页表:
31 ~ 12 | 11 ~ 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|---|---|
物理页地址高 20 位 | AVL | G | PAT | D | A | PCD | PWT | US | RW | P |
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 步:
- 建立页表(之前已经完成)
- 预更新 GDT,使进入分页模式后能够立刻修改 GDTR,使各段使用正确的虚拟地址
- 设置 CR3 为 PDT 的地址
- 设置 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
可见有虚拟地址被直接映射到了页目录表上,通过对这些虚拟地址进行读写操作,我们可以容易的建立、修改新的页表。其中的具体原理这里不写了,其实没什么好说的,根据建立目录方式就可以看出来。