0x00

关于原理和机制很多我直接在注释中写明了,所以就不过多阐述,更多的是总体上简单提一嘴。

0x01 为何分页

在有新进程需要内存而连续内存不够时,要么等某个进程使用内存完毕再给新进程使用,要么把某些进程使用的某些段内存先换出到硬盘,腾出一片连续内存来给新进程使用。

当然不可能采取第一种方法,不然新进程要等半天才能运行。第二种方法的思想是正确的,但是受限于段式内存管理,十分笨重,一次就要换出一整段,效率不高。并且如果内存很小甚至装不下任何一个进程的全部段,那不是啥都运行不了?

直接引用《操作系统真象还原》的原话: 问题的本质是在目前只分段的情况下,CPU 认为线性地址等 于物理地址。而线性地址是由编译器编译出来的,它本身是连续的,所以物理地址也必须要连续才行,但 我们可用的物理地址不连续。换句话说,如果线性地址连续,而物理地址可以不连续,不就解决了吗。

所以分页机制产生了。每一页不大不小 0x1000 字节。分页解除了线性地址与物理地址一一对应的关系,然后将它们的关系重新建立。通过某种映射关系,可以将线性地址映射到任意物理地址。也就是虚拟地址到物理地址的映射。这个乱序映射操作的单位就是页。如下图所示

0x02 虚拟地址空间的管理

以32位地址空间为例。

简单粗暴使用一级页表的话,也就是

也就是把 32 位虚拟地址的高 20 位划分为页表的索引,低 12 位划分为页内偏移进行一个映射。通过索引在一级页表中找到真实的物理地址的高 20 位,也就是物理页框号,在加上 12 位页内偏移就唯一确定了一个物理地址。

使用二级页表则是多划分一次。

把 32 位虚拟地址的高 10 位作为页目录表(PDE)的索引, 中间 10 位作为页表(PTE)的索引,低 12 位仍然是页内偏移。通过页目录表找到对应页表的物理地址,然后再对应页表项确定物理页的地址,加上页内偏移确定一个物理地址。

一级页表简单易懂,但是缺点是:每一个进程都要创建一个页表,而一个页表如果完全映射完所有物理内存,就需要 1M 个表项,每个表项是 4 字节,一张一级页表就要 4MB 内存。占用太大。

二级页表则可以动态的创建页表,并且每一个页表大小也只需一页也就是 0x1000 字节(4KB)。以及不提前创建好所有页表,也就是不提前分配物理页到虚拟地址空间,在程序访问某个虚拟地址时检索 PDE,如果对应的页表不存在,则现场创建一张页表,然后加入到页目录表;检索 PTE ,如果对应页面不存在,则现场分配或者替换一个物理页到页表中。这些功能在后续我们实现内核时需要实现:也就是缺页中断。

还有一个重要的问题:PDE 和 PTE 本身也处于内存中,如何通过虚拟地址访问他们本身呢? 解决方案是在 PDE 的最后一个表项放入 PDE 本身的物理地址。这样就实现了一个回环,可以通过虚拟地址访问 PDE 和 PTE 本身。

0x03 实现

还有进入保护模式前调用 BIOS 中断获取内存大小属性等信息的部分就不多赘述了,也就是调用几个 BIOS 提供的中断接口来获取内存信息。实现也就不放了。

代码注释有部分的细节机理。可能错误,请斧正。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
[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

mov byte [gs:0x10], 'P' ; 打印提示表明顺利进入保护模式
mov byte [gs:0x11], 0x0F

; ================== 准备进入虚拟地址空间
; ==== 创建页目录及页表
call setup_page

; ==== 将描述符表地址及偏移量写入内存 gdt_ptr 后续用虚拟地址重新加载
sgdt [gdt_ptr] ; 使用sgdt更新gdt_ptr是为了确保不出错(虽然原来已经自己定义好了)

mov ebx, [gdt_ptr + 2] ;加上2是因为gdt_ptr的低2字节是偏移量,高四字节才是GDT地址
or dword [ebx + 0x18 + 4], 0xc0000000
;段描述符高四字节的最高位是段基址的第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] ;重新加载GDT

mov byte [gs:0x20], 'V' ; 打印提示表明顺利开启分页进入虚拟地址空间
mov byte [gs:0x21], 0x0F

jmp $

setup_page:
; 虚拟地址32位划分
; [10位][10位][12位]
; ↓ ↓ ↓
; 目录 页表 页内偏移

; ==== 先把页目录表占用的空间逐字节清0
mov ecx, 0x1000
mov esi, 0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi
loop .clear_page_dir
; ==== 开始创建内核地址空间最低4MB的页目录项(PDE)
.create_pde:
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000 ;页目录表本身占用0x1000大小,+0x1000就是第一个页表的物理地址
mov ebx, eax ;此处为ebx赋值, 是为.create_pte做准备, ebx为基址
or eax, PG_US_U | PG_RW_W | PG_P ;页目录项的属性RW和P位为1,US为1表示用户属性,所有特权级都可以访问
mov [PAGE_DIR_TABLE_POS + 0x0], eax ;第一个目录项
; 将物理地址的最低的 4MB 映射到虚拟地址最低的 4MB, 为了在开启分页后loader能够继续正常执行
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ;一个页目录项占用4字节, +0xc00对应第0x300个页目录项
; 一个页目录项对应一张页表,一张页表大小0x1000字节, 一个页表项4字节, 共记录0x400页, 一页大小也是0x1000
; 那么第0x300个页目录项也就对应地址 0x300*0x400*0x1000 = 0xc0000000(内核地址的起始) 开始的 4MB 内存
; 也就将物理地址的最低的 4MB 映射到了虚拟地址空间中 内核地址最低的 4MB
sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS + 4092], eax ;使得最后一个目录项地址指向页目录表自己的地址

; ==== 开始创建内核地址空间最低4MB对应的页表项(PTE)
mov ecx, 256 ;1M低端内存/每页大小4K = 256, 因为物理地址最低的4MB只使用最低的1MB内存来存放kernel, 所以内核地址对应的的页表只需创建 256 页
mov esi, 0 ;虚拟地址0x0~0x3fffff和虚拟地址0xc0000000~0xc03fffff对应的物理页,现在只用了低1MB,此时虚拟地址是等于物理地址的
mov edx, PG_US_U | PG_RW_W | PG_P ; 页表属性。注意进入循环前第一页物理地址是0起始,所以直接用属性赋值即可
.create_pte: ;创建Page Table Entry
mov [ebx + esi*4], edx ;ebx为第一个页表的首地址,此前已赋值
add edx, 0x1000 ; 下一页
inc esi
loop .create_pte

; ==== 创建内核地址空间其他页面的PDE (内核占用1GB, 此前只创建了最低的4MB的PDE)
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ;此时eax为第二张页表的物理地址
or eax, PG_US_U | PG_RW_W | PG_P ;页表属性
mov ebx, PAGE_DIR_TABLE_POS
mov ecx, 254 ;范围为第0x301~0x3fe的所有页目录项数量(第0x3ff项是页目录表自身的物理地址)
mov esi, 769
.create_kernel_pde:
mov [ebx+esi*4], eax
inc esi
add eax, 0x1000 ; 下一张页表
loop .create_kernel_pde
; 至此就将整个内核地址空间(1GB)都映射到了物理地址最低的1GB
ret

最后 qemu 运行效果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(qemu) xp /16wx 0x100000
0000000000100000: 0x00101027 0x00000000 0x00000000 0x00000000
0000000000100010: 0x00000000 0x00000000 0x00000000 0x00000000
0000000000100020: 0x00000000 0x00000000 0x00000000 0x00000000
0000000000100030: 0x00000000 0x00000000 0x00000000 0x00000000
(qemu) xp /16wx 0x101000
0000000000101000: 0x00000027 0x00001007 0x00002007 0x00003007
0000000000101010: 0x00004007 0x00005007 0x00006007 0x00007007
0000000000101020: 0x00008007 0x00009007 0x0000a007 0x0000b007
0000000000101030: 0x0000c007 0x0000d007 0x0000e007 0x0000f007
(qemu) info mem
0000000000000000-0000000000100000 0000000000100000 urw
00000000c0000000-00000000c0100000 0000000000100000 urw
00000000ffc00000-00000000ffc01000 0000000000001000 urw
00000000fff00000-0000000100000000 0000000000100000 urw
(qemu) x/16wx 0xFFFFF000
fffff000: 0x00101027 0x00000000 0x00000000 0x00000000
fffff010: 0x00000000 0x00000000 0x00000000 0x00000000
fffff020: 0x00000000 0x00000000 0x00000000 0x00000000
fffff030: 0x00000000 0x00000000 0x00000000 0x00000000
(qemu) x/16wx 0xFFC00000
ffc00000: 0x00000027 0x00001007 0x00002007 0x00003007
ffc00010: 0x00004007 0x00005007 0x00006007 0x00007007
ffc00020: 0x00008007 0x00009007 0x0000a007 0x0000b007
ffc00030: 0x0000c007 0x0000d007 0x0000e007 0x0000f007

可以看到虚拟地址空间的映射完成了,并且使用虚拟地址对于 PDE 和 PTE 的访问也没问题。