阅读开启分页机制是本节的前置要求。
本节代码对应分支 open-page

boot.inc
在进入保护模式的基础上,boot.inc 增添了如下内容:

1
2
3
4
5
6
7
8
9
;========页目录地址和页表起始地址===========
PAGE_DIR_POS equ 0x00100000 ;目录表起始位置为1MB处
PAGE_TABLE_POS equ PAGE_DIR_POS + 4096 ;页表起始位置
;===========页表相关属性===================
PG_P equ 1b
PG_RW_R equ 00b
PG_RW_W equ 10b
PG_US_S equ 000b
PG_US_U equ 100b
  • 和 GDT 相同,页目录也可以放置在内存中的任何地方 ,这里我们直接将其放在 0x100000 处。
  • 为了使内存紧凑,这里让页表紧挨着页目录。注意,这不是必须的!页目录表大小为 4KB,所以页表地址在页目录地址的基础上加 4096 (0x1000)。

以下为内存映像图:

loader.s

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
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
;文件说明:loader.s
;%include "boot.inc"
;%include "loader.inc"

SECTION loader vstart=BASE_ADDR ;定义用户程序头部段
program_length dd program_end ;程序总长度[0x00]
;用户程序入口点
code_entry dw start-BASE_ADDR ;偏移地址[0x04]
dd section.loader.start ;段地址[0x06]
realloc_tbl_len dw 0 ;段重定位表项个数为0
;=========================================================
;GDT
;第0描述符不可用
GDT_BASE dd 0x00000000
dd 0x00000000
;第1描述符CODE
DESC_CODE dd 0x0000FFFF
dd DESC_CODE_HIGH4
;第2描述符DATA
DESC_DATA dd 0x0000FFFF
dd DESC_DATA_HIGH4
;第3描述符VIDEO
DESC_VIDEO dd 0x80000007
dd DESC_VIDEO_HIGH4

GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
;GDT指针
gdt_ptr dw GDT_LIMIT
dd GDT_BASE

SELECTOR_CODE equ ((DESC_CODE - GDT_BASE)/8)<<3 + TI_GDT + RPL0
SELECTOR_DATA equ ((DESC_DATA - GDT_BASE)/8)<<3 + TI_GDT + RPL0
SELECTOR_VIDEO equ ((DESC_VIDEO- GDT_BASE)/8)<<3 + TI_GDT + RPL0

loader_msg db 'r',11000010b,'e',11000010b,'a',11000010b,'l',11000010b,'-',11000010b
db 'm',11000010b,'o',11000010b,'d',11000010b,'e',11000010b
;=======================================================

start: ;程序入口
mov ax,0 ;转移到loader代码后,如果不想立即进入保护模式
mov ds,ax ;则最好先将各段寄存器清0
mov es,ax
mov ss,ax
mov gs,ax
mov fs,ax
print:
mov ax,0xb800 ;彩色字符模式视频缓冲区
mov es,ax
mov si,loader_msg ;ds:si
mov di,0 ;es:di
mov cx,18 ;9个字符,占18字节
cld
rep movsb
;========================================================
;1.打开A20
;2.加载GDT
;3.置PE=1
prepare:
;关中断
cli
;打开A20
in al,0x92
or al,0000_0010B
out 0x92,al
;加载GDT
mov ax,0
mov ds,ax
lgdt [gdt_ptr]
;CR0的第0位置1
mov eax,cr0
or eax,0x0000_0001
mov cr0,eax
;此后进入保护模式
jmp dword SELECTOR_CODE:p_mode_start ;刷新流水线,装载CODE选择子
;======================================================
[bits 32]
p_mode_start:
mov ax,SELECTOR_DATA
mov ds,ax
mov es,ax
mov ss,ax
mov esp,BASE_ADDR ;可以找其他合适的地方作为栈顶,这里使用BASE_ADDR
mov ax,SELECTOR_VIDEO
mov gs,ax

mov byte [gs:160],'p' ;在保护模式下打印
mov byte [gs:161],11000010b

call setup_page
sgdt [gdt_ptr] ;store gdt,将GDTR中的信息存到标号gdt_ptr处
mov ebx,[gdt_ptr+2] ;此时ebx中为GDT的基地址

;显存段是第3个段描述符,段描述符8字节,24~31段基址在高4字节
or dword [ebx+8*3+4],0xc0000000 ;c刚好对应高字节

;将gdt的基址加上0xc0000000使其位于内核镜像所在的高地址
add dword [gdt_ptr + 2], 0xc0000000

add esp, 0xc0000000 ;将栈指针同样映射到内核地址

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

mov eax, cr0 ;打开cr0的pg位(第31位)
or eax, 0x80000000
mov cr0, eax

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

mov byte [gs:320], 'V' ;视频段段基址已经被更新
mov byte [gs:322], 'i'
mov byte [gs:324], 'r'
mov byte [gs:326], 't'
mov byte [gs:328], 'u'
mov byte [gs:330], 'a'
mov byte [gs:332], 'l'
jmp $

;=======================================================
;将页目录要存放的内存清零,每次清空4bytes,清1024次,共4KB
setup_page:
mov ecx,1024
mov esi,0
.clear:
mov dword [PAGE_DIR_POS+esi],0
add esi,4
loop .clear

;创建页目录项,向页目录项中安装各个页表的物理地址
.create_PDE:
mov eax,PAGE_TABLE_POS ;此时eax为第0个页表的物理位置
mov ebx,eax ;备用

;下面在第0和0xc00目录项中安装第0个页表的地址,一个页表可表示4MB内存,
;这样虚拟地址中高1GB空间的起始4MB和低3GB空间的起始4MB都指向相同的页表。详见博客分析
;这是在为内核映射做准备。
or eax, PG_US_U | PG_RW_W | PG_P ;写入目录项属性,RW,PG位和US都为1
mov [PAGE_DIR_POS + 0*4], eax ;安装第0个目录项,对应虚拟地址0~4MB
mov [PAGE_DIR_POS + 768*4], eax ;安装第768个目录项,对应虚拟地址3GB~3GB+4MB

mov eax, PAGE_DIR_POS
or eax, PG_US_U | PG_RW_W | PG_P
mov [PAGE_DIR_POS + 1023*4], eax ;使最后一个目录项(第1023项)指向页目录表自己的地址

;下面创建第0页表的部分表项(PTE)
mov ecx,256 ;1M低端内存/每页大小4KB=256
mov esi,0
mov edx,PG_US_U | PG_RW_W | PG_P ;属性为7,US=1,RW=1,P=1;高12~31位的物理页地址为0,所以直接mov
.create_PTE:
mov [PAGE_TABLE_POS+4*esi],edx
add edx,4096 ;一个页表项一个物理页框,包含的物理空间为4KB
inc esi
loop .create_PTE

;创建虚拟地址高1GB空间其他对应的目录项
mov eax,PAGE_TABLE_POS+4096*1 ;此时eax为第1个页表的物理位置
or eax,PG_US_U | PG_RW_W | PG_P ;页目录项的属性US,RW和P位都为1
mov ecx, 254 ;范围为第769~1022的所有目录项数量,第1023目录项已经安装
mov esi, 769 ;从第769目录项开始
.create_kernel_pde:
mov ebx,PAGE_DIR_POS
mov [ebx+esi*4], eax
inc esi
add eax, 4096
loop .create_kernel_pde
ret
;========================================================
program_end equ $-BASE_ADDR

首先,务必先理清楚页目录表页目录项页表页表项的关系,否则上面的代码将会看得你一头雾水!另外再次强调,页目录项和页表项中装载的是物理地址,这点很重要。为方便对照,将开启分页机制中的页目录项/页表项的结构图搬过来:

让我们先聚焦 setup_page ,从第 123 行代码开始。

  1. 第 137 行,PG_US_U | PG_RW_W | PG_P ,这三位为 1,其他位都为 0 。

  2. 第 140 行,为什么要将第 0 号页表的地址装载到第 0 号目录项中?原因是:分页机制是在 loader 中开启的,而 loader 本身已经位于 1MB 物理内存中,所以我们必须保证开启分页前后 CS : EIP 都正确指向 1MB 内的相关 loader 代码,即必须保证之前段机制下的线性地址和分页后的虚拟地址所对应的物理地址一致也就是说虚拟地址下的 1MB 内存与真实物理地址下的 1MB 内存是完全一一对应的 。还是举个例子:开启分页前一瞬间 CS:IP=0x0000:10020x00001002这是真实的物理地址;开启分页后执行的一条指令的地址为 CS:IP=0x0000:1004 ,由于已经开启分页,这就成了虚拟地址,即 0x00001004 。按照分页机制中的计算方法,这个虚拟地址将映射到第 0 号页目录,因为其中装载的是第 0 号页表的地址,进而到第 0 号页表,虚拟地址中的 1 则将其映射到第 1 号页表项,对应的物理页框为 0x1000 (后续147~155行代码会将物理页框写入页表项),最后加上偏移地址 0x04 ,得到物理地址 0x00001004 。可见,开启分页前后物理地址和虚拟地址是相同的。

  3. 第 141 行,为什么将第 0 号页表的地址装载到第 768 号目录项中?原因是:第 768 号目录项对应的虚拟地址是 3GB~3GB+4MB,我们的内核镜像就在此处。实际内核位于低 1MB 的内存中,现在将其映射到内存 3GB 处,所以必须把第 0 号页表的地址装载到第 768 号目录项中

    必须说明的是,第 2, 3点之所以能够顺利将虚拟地址下的 1MB 内存与真实物理地址下的 1MB 内存一一映射,其基础是 147~155 代码,这段代码将虚拟 1MB 与物理 1MB 地址空间一一对应。

  4. 第 145 行,为什么往最后一个(1023)目录项中安装目录表自身的地址?这是为了在开启分页后,通过虚拟地址找到页表,这样才能动态操作页表 。二级页表是一种动态的数据结构,要申请一大块内存时可能会添加页表项,释放一块内存时可能会删减页表项。而页表和页目录都是存在于内存中的,要对其进行删减就必须知道它的地址,问题是现在已经进入了虚拟地址空间,我们该如何访问它呢?通过往最后一个目录项中安装目录表自身的地址可以迂回实现。当虚拟地址为 0xfffff000 ,即高 10 位和中间 10 位都为 0x3ff(1023) 时,通过高 10 位访问到第 1023 目录项,取得其中的地址;因为该地址为目录表自身地址,所以通过中间 10 位进行索引时会以该地址为基准, 也就是说,第一次索引和第二次索引都是在目录表中进行的(原本第一次索引是在目录表中,第二次索引在页表中);第二次索引后,取得的地址仍为目录表自身起始地址,而 CPU 会将其当作物理页框地址来使用;最后 12 位页内偏移地址置为 0,则最终虚拟地址就被映射成了目录表起始地址如果想访问目录表项,将 0xfffff000 改为 0xfffffxxx 即可,其中 xxx索引值*4 ,原因不再赘述。如果想访问页表项,则高 10 位为 0x3ff ,中间 10 位为索引值,此时得到相应页表的起始地址,CPU 将其作为物理页框地址,加上最后 12 位,为 索引值*4 ,最终映射成某页表项的地址 。此方式的核心在于:CPU 很笨,通过目录项原本应该取得页表的地址,然后访问该页表;然而此方式通过目录项取得的却仍是目录表的起始地址,但 CPU 可不知道这个是目录表的地址,它仍将其看作页表地址,并用中间 10 位继续索引。用代码描述可能更清晰:

    1
    2
    3
    4
    5
    6
    #define PDE_IDX(addr) ((addr & 0xffc00000) >> 22) //取得addr对应的页目录表索引,其实直接addr>>22也是可以的
    #define PTE_IDX(addr) ((addr & 0x003ff000) >> 12) //取得addr对应的页表索引
    uint32_t* pte = (uint32_t*)(0xffc00000 + ((vaddr & 0xffc00000) >> 10) + PTE_IDX(vaddr) * 4);
    //此时pte即为虚拟地址vaddr对应的PTE的地址
    uint32_t* pde = (uint32_t*)((0xfffff000) + PDE_IDX(vaddr) * 4);
    //此时pde即为虚拟地址vaddr对应的PDE的地址
  5. 第 148 行,注意这里只填充了一张页表的四分之一,一张页表可映射 4MB 内存,但我们的内核当前只有不到 1MB,所以只映射了 1MB 的空间。

  6. 第 157~168 行,物理内核不是只映射在高 1GB 虚拟空间的最低 1MB 处吗?为什么还要安装高 1GB 虚拟地址对应的其他目录项?这是为了实现内核完全共享 。所有用户进程的高 1GB 虚拟空间都会被映射到物理内核处,所以在为用户进程创建页表时,我们必须把内核页目录中第 768~1022 目录项复制到用户进程页目录的相同位置处(第 1023 目录项指向用户目录表的起始位置) 。如果不这样的话,进程陷入内核时,内核可能因为申请大量内存而新增页表,此时就必须手动将新增的内核页表同步到其他进程的页目录中,否则就只能部分共享。手动同步是很麻烦的,最简单的方式就是提前把高 1GB 的目录项定下来,未来创建用户进程页目录时直接复制过去。这是实现内核共享的关键!

    读者可能还是对这点存有疑惑,不要慌,学到内存管理基础篇后,你将恍然大悟。

以上是对 setup_page 代码的解析,下面我们聚焦 91~199 行代码。

  1. 第 91 行,sgdtstore gdt ,作用是将 GDTR 中的基地址和边界重新倒(dump)在指定地址处。因为无法直接在 GDTR 中修改,所以要先倒出来,在内存中修改,然后再使用 lgdt 重新加载进去。此处 sgdt 似乎有点鸡肋,因为 gdt_ptr 还在内核中,没有被覆盖。
  2. 第 95 行,将显存段的基地址放在了 3GB 处。打印功能涉及硬件(显存),所以是在内核中实现的,用户要打印须陷入内核,然后再调用打印功能,肯定不能让用户直接控制显存 。因此显存段的段基址要改为 3GB 以上。
  3. 第 98 行,将 GDT 也移入内核空间,将其基地址加上 3GB 。这不是必须的,如果分页后不重复加载 GDT,也可以不修改 GDT 的基址
  4. 第 100 行,将栈指针也指向内核空间,这点原因暂不清楚,后续补充。

最终效果如下:

另外,也可以通过 C 语言来设置页表,读者可自行尝试。