目录
一、前景回顾
二、规划页表
三、实现页表
四、运行测试
一、前景回顾
前面我们已经介绍了分页机制的运行原理,那么如何开启分页机制呢,也简单,分为如下三个步骤:
1、创建页目录表并初始化页内存。
2、将页目录表地址赋值为CR3。
3、打开CR0寄存器的PG位。
可以看出页表是分页机制的核心,接下来我们将开始在我们的系统上实现一个二级页表。
二、规划页表
设计页表其实就是设计内存布局,不过在规划内存布局之前,我们需要了解用户进程与操作系统之间的关系。
在操作系统中,为了计算机安全,用户进程始终是运行在低特权级的。用户进程需要访问硬件相关资源时,是需要向操作系统申请,然后通过系统调用的方式陷入操作系统,由操作系统去做并且将结果返回给用户进程。进程可以有多个,但是操作系统只有一个。所以操作系统必须“共享”给每一个进程,他们的关系如图所示。
对于每一个进程来说,它不单单是运行程序这么简单的事,它需要接受调度、阻塞、在需要陷入内核时还需要操作系统的帮助等等,因此,完整的一个进程是需要和操作系统配合才能完成正常的工作。也就是说每一个进程里面应该包含操作系统部分。Linux下的每一个进程,高1GB的空间就是留给操作系统的,低3GB的空间就是留给进程用户空间自身的,在我们的系统中,也遵循这样的安排。对于这高1GB的空间,我们并不是每创建一个新的进程,就将操作系统代码给复制到这1GB的空间中,这样显得笨重而且随着进程数的增加会占用更多的内存。实际上操作系统的代码只有一份,我们每创建一个新的进程,就让该进程的高1GB空间指向操作系统即可。
现在回过头来看看我们的系统,这里提前透露一下,后面完善了内核之后,我们的整个操作系统的代码也不到1MB,所以我们这里就假设最终操作系统的代码只有1MB,也就是说整个操作系统代码的存放区域从0x0到0xFFFFF。跟前面说的划分1GB的空间来存放操作系统代码差距特别大,很大一部分没有用上,因为我们的操作系统会比较简单,用不到这么多空间。不过内存划分还是按照Linux下的格式来,便于学习。
所以页目录表的地址,我们就存放在物理地址0x100000处,为了让页表紧挨着页目录表,页目录表本身占用4KB,所以第一个页表的物理地址是0x101000,还有其他的规划我一并表现在它们的物理内存布局中,如图所示。
这张图将我们的整个页表规划表现得一览无余,至于其中细节且让我娓娓道来。
首先我们知道,页表是用来给虚拟地址映射用的,虚拟地址的使用前提得是开启了分页机制,如果没有开启分页机制,那么页表就毫无用处。现在假设我们开启了分页机制,那么此时注意,我们使用的地址就不再是之前的线性地址了,而是虚拟地址。怎么说呢,以前我们要想让CPU访问一个地址,只需要将该地址拆解分别赋给CS和段内偏移就可以了,但是开启分页机制后,拆解出的这个地址就不能访问到期望的地址了,因为在开启分页机制后,CPU拿到这个地址,会根据CR3寄存器中存储的页目录表地址来进行寻址,最终得到的物理地址才是CPU真正去访问的地址。具体步骤请参考上一回的内容。
所以现在如果我们想要访问操作系统的代码,也就是低端1MB的内存,该如何访问呢?前面我们说过,Linux将用户进程的高1GB作为操作系统的空间,所以我们可以知道,在用户进程中,虚拟地址0xc0000000~0xffffffff便是映射到操作系统的1GB空间中去,而在我们的系统中,操作系统的代码总共占据了1MB的内存,所以从0xc0000000~0xc00fffff便是映射到我们操作系统的1MB空间,0xc0000000虚拟地址对应的页目录项应该是第768个,这个算起来容易,0xc0000000的高10位是0x300,即十进制的768。该目录项可以表示的内存空间是4MB,所以我们指定了一个页表来管理这4MB的空间,因此我们在页目录表的第768页目录项中填入该页表的物理地址0x101000,我们将目光转入到0x101000地址处,此处页表的第0到255页表项指向的物理内存便是我们操作系统的1MB空间。
从页目录项的第769到1022的页目录项中,我们都只是指定了页表地址,并没有给实际的页表初始化,因为我们的操作系统只占用了1MB空间,多余的也用不上,这里只是为了占个位置而已。
页目录项的第1023项可能会有人比较好奇,为什么该项指向的地址是页目录表本身地址,这里是为了能通过虚拟地址来访问到页目录表本身,如果后面需要修改页目录表,我们通过0xfffff000~0xffffffff就能访问到页目录表的第0到第1023项,感兴趣的朋友可以自己试试,看这个虚拟地址最终是否能转换成页目录表各项的物理地址。当然可能就会有人说,这样的话操作系统实际上就并没有占据1GB的内存空间了么,少掉了4MB的空间,事实上的确是这样,不过其实问题也不大,是吧。
最后我们再来看看为什么页目录表的第0项的内容是0x101000。原因是我们在加载内核之前,程序中一直都是运行的loader,它本身的代码都是在低端1MB之内的,必须保证之前段机制下的线性地址和分页后的虚拟地址对应的物理地址一致。
三、实现页表
前面说了这么多,接下来我们来实现我们的页目录表和页表。在loader.S文件中增加如下代码:
1 %include "boot.inc"
2 section loader vstart=LOADER_BASE_ADDR
3 LOADER_STACK_TOP equ LOADER_BASE_ADDR
4 jmp loader_start
5
6 ;构建gdt及其内部描述符
7 GDT_BASE: dd 0x00000000
8 dd 0x00000000
9 CODE_DESC: dd 0x0000FFFF
10 dd DESC_CODE_HIGH4
11 DATA_STACK_DESC: dd 0x0000FFFF
12 dd DESC_DATA_HIGH4
13 VIDEO_DESC: dd 0x80000007
14 dd DESC_VIDEO_HIGH4
15
16 GDT_SIZE equ $-GDT_BASE
17 GDT_LIMIT equ GDT_SIZE-1
18 times 60 dq 0 ;此处预留60个描述符的空位
19
20 SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0
21 SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0
22 SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0
23
24 ;以下是gdt指针,前2个字节是gdt界限,后4个字节是gdt的起始地址
25 gdt_ptr dw GDT_LIMIT
26 dd GDT_BASE
27
28 ;---------------------进入保护模式------------
29 loader_start:
30 ;一、打开A20地址线
31 in al, 0x92
32 or al, 0000_0010B
33 out 0x92, al
34
35 ;二、加载GDT
36 lgdt [gdt_ptr]
37
38 ;三、cr0第0位(pe)置1
39 mov eax, cr0
40 or eax, 0x00000001
41 mov cr0, eax
42
43 jmp dword SELECTOR_CODE:p_mode_start ;刷新流水线
44
45 [bits 32]
46 p_mode_start:
47 mov ax, SELECTOR_DATA
48 mov ds, ax
49 mov es, ax
50 mov ss, ax
51 mov esp, LOADER_STACK_TOP
52 mov ax, SELECTOR_VIDEO
53 mov gs, ax
54
55 mov byte [gs:160], 'p'
56
57 ;------------------开启分页机制-----------------
58 ;一、创建页目录表并初始化页内存位图
59 call setup_page
60
61 ;将描述符表地址及偏移量写入内存gdt_ptr,一会儿用新地址重新加载
62 sgdt [gdt_ptr]
63 ;将gdt描述符中视频段描述符中的段基址+0xc0000000
64 mov ebx, [gdt_ptr + 2]
65 or dword [ebx + 0x18 + 4], 0xc0000000
66
67 ;将gdt的基址加上0xc0000000使其成为内核所在的高地址
68 add dword [gdt_ptr + 2], 0xc0000000
69
70 add esp, 0xc0000000 ;将栈指针同样映射到内核地址
71
72 ;二、将页目录表地址赋值给cr3
73 mov eax, PAGE_DIR_TABLE_POS
74 mov cr3, eax
75
76 ;三、打开cr0的pg位
77 mov eax, cr0
78 or eax, 0x80000000
79 mov cr0, eax
80
81 ;在开启分页后,用gdt新的地址重新加载
82 lgdt [gdt_ptr]
83 mov byte [gs:160], 'H'
84 mov byte [gs:162], 'E'
85 mov byte [gs:164], 'L'
86 mov byte [gs:166], 'L'
87 mov byte [gs:168], 'O'
88 mov byte [gs:170], ' '
89 mov byte [gs:172], 'P'
90 mov byte [gs:174], 'A'
91 mov byte [gs:176], 'G'
92 mov byte [gs:178], 'E'
93
94 jmp $
95 ;---------------------------------------------
96
97 ;--------------函数声明------------------------
98 ;setup_page:(功能)设置分页------------
99 setup_page:
100 ;先把页目录占用的空间逐字节清0
101 mov ecx, 4096
102 mov esi, 0
103 .clear_page_dir:
104 mov byte [PAGE_DIR_TABLE_POS + esi], 0
105 inc esi
106 loop .clear_page_dir
107
108 ;开始创建页目录项
109 .create_pde:
110 mov eax, PAGE_DIR_TABLE_POS
111 add eax, 0x1000 ;此时eax为第一个页表的位置
112 mov ebx, eax
113
114 ;下面将页目录项0和0xc00都存为第一个页表的地址,每个页表表示4MB内存
115 ;页目录表的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问
116 or eax, PG_US_U | PG_RW_W | PG_P
117
118 ;在页目录表中的第1个目录项中写入第一个页表的地址(0x101000)和属性
119 mov [PAGE_DIR_TABLE_POS + 0x0], eax
120
121 mov [PAGE_DIR_TABLE_POS + 0xc00], eax
122
123 ;使最后一个目录项指向页目录表自己的地址
124 sub eax, 0x1000
125 mov [PAGE_DIR_TABLE_POS + 4092], eax
126
127 ;下面创建页表项(PTE)
128 mov ecx, 256 ;1M低端内存/每页大小4K=256
129 mov esi, 0
130 mov edx, PG_US_U | PG_RW_W | PG_P
131 .create_pte: ;创建page table entry
132 mov [ebx + esi*4], edx
133 add edx, 4096
134 inc esi
135 loop .create_pte
136
137 ;创建内核其他页表的PDE
138 mov eax, PAGE_DIR_TABLE_POS
139 add eax, 0x2000 ;此时eax为第二个页表的位置
140 or eax, PG_US_U | PG_RW_W | PG_P
141 mov ebx, PAGE_DIR_TABLE_POS
142 mov ecx, 254 ;范围为第769~1022的所有目录项数量
143 mov esi, 769
144 .create_kernel_pde:
145 mov [ebx + esi*4], eax
146 inc esi
147 add eax, 0x1000
148 loop .create_kernel_pde
149 ret
loader.S
我还是将之前的代码附上了,便于理解,新增的是开启分页机制以及函数声明部分。
我们重点来看看setup_page函数,该函数的作用便是创建页目录表并初始化页内存。PAGE_DIR_TABLE_POS在boot.inc文件中定义为0x100000,也就是我们事先说的页目录表的存储地址。
首先将以PAGE_DIR_TABLE_POS为起始地址的4096个字节,也就是一页物理页大小的内存空间给清0,随后再进行初始化。代码中有很多注释,这里就不再赘述。
四、运行测试
这里就不再赘述。还是和前面一样,通过nasm和dd命令将loader.S编译写入硬盘,运行boch得到如下画面。在boch的控制台输入info tab命令查看生成的页表。
左边是虚拟地址,右边是映射后的真实物理地址。对比我们前面设计的页表没有问题,说明我们的程序没有问题。
本回到此结束了,接下来我们要开始向内核进军,开始使用熟悉的C语言来编写程序了。欲知后事如何,请看下回分解。