【Raymond-OS】Chapter 4. Enable Paging
【Raymond-OS】Chapter 4. Enable Paging
开启分页
一、过程分析
启用分页机制分为三步:
- 准备好页目录表和页表
- 将页表地址写入控制寄存器 cr3
- 寄存器 cr0 的 PG 位置 1
通过之前 gdt 表的构建,现在我们对表构建应该不再陌生,虽然表项结构不同,但套路基本是一样的
二、核心内容
2.1 多级页表
2.1.1 一级页表
页表即是一种地址映射,在保护模式下字长是 4B,最大可支持 4G 个地址,如果我们以字节为粒度进行映射,那我们的页表就需要 4G 个页表项,每个页表项 4B,这样算下来页表本身就 16GB 大小;另一个极端是,我们以 4GB 为粒度进行映射,那相当于只有一页,所有的地址都是页内偏移,这样页表项就只有 4B。这两个极端显然都不合适,提它们主要为了说明我们需要在二者中间找到一个平衡值,与这个值关联的概念就是页大小。
操作系统分页中的页大小一般为 4KB,即以 4KB 为粒度进行映射,因此页表需要有 4GB/4KB = 1M 个页表项(PTE),所有页表本身的大小可以控制在 1M * 4B = 4MB。这里稍微一提,因为是以 4KB 为粒度进行映射,表示页表项中地址指向的是某个 4KB 数据的起点,所以其地址的低 12 位其实都是 0,而如果默认低 12 位都是 0 的话,那低 12 位就可以作为控制位来做一些别的事情。
所以,在只有一级页表的情况下,寻址的过程为:
- 通过段描述符得到一个 32 位的逻辑地址
- 逻辑地址高 20 位作为页表索引,2^20 正应对着 1M 个页表项。因为字长是 4B,所以 index*4B 就得到页表项对于页表起始位置的偏移了。
- 通过页表项得到对应页的物理地址
- 逻辑地址低 12 位作为页内偏移,配合页的物理地址,得到最终的物理地址,2^12 正应对着 4KB 的页大小
整个过程是由页部件完成的,也就是说地址转化是有硬件支持的。
2.2.2 二级页表
一级页表 4MB,但是它作为入口,在初始化的时候是必须创建的,再加上之后每个进程需要持有自己的页表,所有 4MB 的空间开销不容忽视。解决这个问题的方法就是多级页表,所谓的多级页表就是多层映射,入口处的页表称之为页目录,其存储的页目录项 (PDE)为页表的地址,而其余页表则可以以懒加载的方式在必要时创建,如此便可尽量减少内存开销。
以二级页表为例,我们将之前 1MB 的页表映射项继续拆成两部分:页目录维护 1K 个页表映射,每个页表维护 1K 个地址映射,于是页目录本身大小便可控制在 4KB,而页表则可以在运行时动态创建。在寻址时,32 位地址的高 10 位用于在页目录中定位 PDE,中间 10 位则会在页表中定位 PTE,低 12 位依然作为页内偏移。
更多层级的页表与此类似
2.2.3 页表结构
PDE 及 PTE 的结构如下,之后我们会按照该接口构建对应的数据
位数 | 名称 | 页目录项描述 | 页表项描述 |
---|---|---|---|
0 | Present | 指示页表是否在内存中。1 表示存在,0 表示不存在。 | 指示页是否在内存中。1 表示存在,0 表示不存在。 |
1 | Read/Write | 指示页表中的页是否可写。1 表示可读写,0 表示只读。 | 指示页是否可写。1 表示可读写,0 表示只读。 |
2 | User/Supervisor | 指示页表中的页的访问权限。1 表示用户模式,0 表示超级用户模式。 | 指示页的访问权限。1 表示用户模式,0 表示超级用户模式。 |
3 | Page-level Write-through | 控制页表的缓存策略。 | 控制页的缓存策略。 |
4 | Page-level Cache Disable | 控制页表是否被缓存。 | 控制页是否被缓存。 |
5 | Accessed | 指示页表是否被访问过。由CPU设置,用于页面置换算法。 | 指示页是否被访问过。由CPU设置,用于页面置换算法。 |
6 | Dirty | 通常未使用。 | 指示页是否被写入过。由CPU设置。 |
7 | Page Size / Page Attribute Table | 指示页大小。0 表示4KB页,1 表示4MB页。 | 用于选择页的缓存属性(如果PAT被启用)。 |
8 | Global | 通常未使用。 | 指示页是否为全局页。全局页在CR4.PGE=1时不会被刷新。 |
9-11 | Available | 供操作系统软件使用。 | 供操作系统软件使用。 |
12-31 | Page Frame Address | 指向页表的物理地址。仅高20位有效。 | 指向物理页的物理地址。仅高20位有效。 |
2.2 创建页表
2.2.1 开辟页表目录空间
我们将页目录放在 1MB 处,即保护模式刚刚碰不到的地方,对于 4KB 的空间,循环 4096 次字节清零,以避免脏数据的影响
PAGE_DIR_TABLE_POS equ 0x100000
; 创建页目录以及页表
setup_page:
; 页目录表占据4KB空间,清零之
mov ecx, 4096
mov esi, 0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi
loop .clear_page_dir
2.2.2 创建页目录
创建目录页,其实就是设计一个 4KB 的内存布局,PDE 目录页项就是一个 4B 的地址,其低 12 位会携带一些控制信息。值得注意的是:
- 页目录创建时 Loader 本身也是在线性地址下执行的,开启分页后需要保证 Loader 里的指令地址依然可以正确解析执行。我们将第一个页表地址作为第一个页目录项,此时低 4MB 的虚拟地址其实是和物理地址一样的,这样也就满足了需求。
- 4GB 的虚拟内存空间其实包括用户空间和系统空间的,我们把高 1GB 留给操作系统,如上所说,我们的系统跑在低 4MB 的实际地址上,所有我们把高 1GB 的目录映射到低位的实际地址上即可,又因为我们的内核只需要 4MB 以内,所以只需要将高 1GM 的第一页映射到实际的低 4MB 即可
; 创建页目录表(PDE)
.create_pde:
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000 ;0x1000为4KB,加上页目录表起始地址便是第一个页表的地址
mov ebx, eax ;此时eax中即是第一个页表的位置,也是第一个页目录项PDE的值
or eax, PG_US_U | PG_RW_W | PG_P ;如之前所述,低12位被当做控制位,这里通过一些位运行修改控制位
mov [PAGE_DIR_TABLE_POS], eax ;设置第一个页目录项。让低4MB的虚拟地址和实际物理地址一致
; 第768(内核空间的第一个)个页目录项,与第一个相同,这样第一个和768个都指向低端4MB空间
mov [PAGE_DIR_TABLE_POS + 0xc00], eax
; 最后一个表项指向自己,用于访问页目录本身
sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS + 4092], eax
2.2.3 创建第一个页表
第一个页的内容是 loader 需要的,所以我们这里先初始化第一个页。页表项 PTE 的格式也只是 32 位数据,其中低 12 位用于表示业内偏移,高 20 位表示实际物理地址,而我们第一个页的 4MB 是希望映射到真实的低 4MB 地址的,所以数据初始化也就是把从 0 开始,步长为 4KB 的地址填入页表中
mov ecx, 1024 ;cx是循环计数器,控制loop,每个页包含1024个页表项
mov esi, 0 ;esi记录累加值,配合补偿可以得到每次loop所需要的地址值
mov edx, PG_US_U | PG_RW_W | PG_P ;地址从0开始,赋值控制位
.create_pte:
mov [ebx + esi * 4], edx
add edx, 4096 ;步长4kb
inc esi
loop .create_pte
::: import
我们我们只初始化了第一个页表,已经给第 768 个页目录项指定了地址,此时运行我们系统本身是可以的,但后续用户进程加入尽量之后就需要对其余页相关数据进行处理
:::
2.3 重置 gdt
在我们的设计中,我们将系统内核放到高 1GB 的地址空间,gdt 属于内核的一部分,所有这里我们将其地址偏移 3GB 后重新加载
; 保存gdt表内容到gdt_ptr
sgdt [gdt_ptr]
......
; 重新设置gdt描述符, 使虚拟地址指向内核的第一个页表
mov ebx, [gdt_ptr + 2] ;gdt_ptr是2B+4B的结构,后4B代表地址,这里是取到地址
add dword [gdt_ptr + 2], 0xc0000000 ;gdt表地址偏移3GB
or dword [ebx + 0x18 + 4], 0xc0000000 ;显存段的地址特殊处理一下
......
lgdt [gdt_ptr]
2.4 开启分页
当我们创建好页目录及页表后,分页的开启之后流程性的事情
; 页目录基地址寄存器
mov eax, PAGE_DIR_TABLE_POS
mov cr3, eax
; 打开分页
mov eax, cr0
or eax, 0x80000000
mov cr0, eax
三、相关内容
3.1 获取内存容量
系统启动阶段获取内存容量主要靠 BIOS 的 0x15 中断,具体来讲是靠其三个子功能,子功能号放在寄存器EAX或者 AX中
- EAX = 0xE820:遍历主机上的全部内存
- AX = 0xE801:分别检测低 15MB 和 16MB~4GB 内存,最大支持 4GB
- AH = 0x88:最多检测 64MB 内存,若实际内存超出此限制,也会以 64MB 返回
BIOS 中断是实模式下的方法,需要在进入保护模式之前调用。
这里我们只介绍 0xE820。 0xE820 是较为强大的功能,会按内存类型以迭代方式返回布局信息,该信息以地址范围描述符(ARDS, adderss range descriptor structure)的形式返回,每个描述符是如下所示 20 字节的结构
偏移量 | 字段名称 | 大小(字节) | 描述 |
---|---|---|---|
0 | BaseAddrLow | 4 | 内存区域的基址低 32 位 |
4 | BaseAddrHigh | 4 | 内存区域的基址高 32 位 |
8 | LengthLow | 4 | 内存区域的长度低 32 位,字节为单位 |
12 | LengthHigh | 4 | 内存区域的长度高 32 位,字节为单位 |
16 | Type | 4 | 内存区域的类型 |
type 字段具体信息如下
Type 值 | 描述 |
---|---|
1 | 可用内存。该内存区域是可用的,操作系统可以使用它来分配给应用程序或内核。 |
2 | 保留内存。该内存区域是保留的,通常不可用于操作系统或应用程序。这些区域可能用于特定硬件设备或 BIOS。 |
3 | ACPI 可重用内存。该内存区域由 ACPI 使用,但在操作系统初始化之后可以被重用。 |
4 | 保留的 ACPI 内存。该内存区域由 ACPI 使用,但在操作系统初始化之后不可被重用。 |
5 | 坏内存。该内存区域被标记为损坏,不应由操作系统或应用程序使用。 |
具体执行代码如下:
ards_buf times 244 db 0 ;这里是标记一块内存,存放接下来要读取的ards
ards_nr dw 0
total_memory_bytes dd 0
.e820_mem_get_loop:
;入参阶段
mov eax, 0x0000e820 ;eax存放的是子功能号,即0xE820
mov ecx, 20 ;ecx存放的是描述符字节大小,这里填20
int 0x15 ;调用0x15中断
;异常处理
;e820的处理过程会更新eflags寄存器的cf位,调用成功则cf=0,失败则cf=1
;jc命令检查标志位,如果失败了调用e820_mem_get_failed
jc .e820_mem_get_failed
;e820会把数据写到es:di指定的地方,所有这里需要给di更新一个单位
add di, cx
;计数
inc word [ards_nr]
;当ebx返回非0时代表仍有数据需要读取
cmp ebx, 0
jnz .e820_mem_get_loop
;记录数据之后使用
mov cx, [ards_nr]
mov ebx, ards_buf
;清空寄存器
xor edx, edx
.find_max_mem_area:
mov eax, [ebx]
add eax, [ebx + 8]
add ebx, 20
cmp edx, eax
jge .next_ards
mov edx, eax
.next_ards:
loop .find_max_mem_area
jmp .mem_get_ok
.e820_mem_get_failed:
mov byte [gs:0], 'f'
mov byte [gs:2], 'a'
mov byte [gs:4], 'i'
mov byte [gs:6], 'l'
mov byte [gs:8], 'e'
mov byte [gs:10], 'd'
; 内存检测失败,不再继续向下执行
jmp $
3.2 为什么需要分页
归根接地还是分段内存管理的粒度太粗了,在分段内存管理中,有关地址的保护信息是放在段描述符中,而段描述符是与段绑定的,当我们有内存置换的需求时,被置换的内存必然是需要维护一些额外信息的,所以在分段内存管理中,短描述符与段的绑定关系就决定了内存置换必须以段为单位,但是段作为置换单位来讲,一是大小不固定,不方便管理,更容易产生碎片,二是进程和段的关系不够灵活,段的置换对进程运行的影响更大。
于是,我们需要一种更灵活的方式来管理内存,它的粒度应该更小,以此来减少对进程的影响,它的大小最好固定,以此来简化内存分配、回收的过程,而这就是分页内存管理模式