前情提要
上一节我们讲到了获取物理内存,这节我们将开启内存分页
一、内存分页的作用
内存分页是一种操作系统和硬件协同工作的机制,用于将物理内存分割成固定大小的页面(通常为4KB)并将虚拟内存空间映射到这些页面上。内存分页的主要作用包括:
- 虚拟内存管理: 内存分页允许操作系统将进程的虚拟地址空间映射到物理内存中的不同页面上,从而实现了虚拟内存管理。这使得每个进程能够拥有独立的地址空间,提高了内存的利用率和安全性。
- 内存保护: 通过页表中的权限位可以对页面进行保护,例如只读、读写、执行等权限设置。这样可以保护操作系统和进程之间的内存隔离,防止非法访问或修改内存数据。
- 内存共享: 内存分页也支持不同进程之间的内存共享。多个进程可以将同一个物理页面映射到各自的虚拟地址空间中,从而实现共享内存的目的。
- 内存管理: 通过内存分页,操作系统可以更灵活地管理物理内存,如内存的分配、回收、页面置换(换出到磁盘、换入到内存)、内存压缩等操作。
- 减少外部碎片: 内存分页可以将物理内存划分为固定大小的页面,减少了外部碎片的产生,提高了内存的利用效率。
二、一级页表
分页机制是在分段机制的基础之上的,分段机制获取的地址就是之前我们用选择子选择到的全局描述符里面的段基址+EIP中的段内偏移地址,这两个地址相加可以获得实际的物理地址,在我们没有进行内存分页之前。
如果打开了分页机制,段部件输出的线性地址就不再等同于物理地址了,我们称之为虚拟地址,它是逻辑上的,是假的,不应该被送上地址总线。CPU必须要拿到物理地址才行,此虚拟地址对应的物理地址需要在页表中查找,这项查找工作是由页部件自动完成的。
我们直接举个例子讲述一级页表的工作方式,结合我们上节讲的GDT,假设选择子选择出来的段基址为0
,偏移地址为0x1234
三、二级页表
一级页表我们只是举个例子,用来说明页表的操作,但实际我们用的是二级页表,因为一级页表有些问题
1、一级页表中最多可容纳1M(1048576)个页表项,每个页表项是4字节,如果页表项全满的话,便是4MB大小
2、一级页表中所有页表项必须要提前建好,原因是操作系统要占用4GB虚拟地址空间的高1GB,用户进程要占用低3GB
3、每个进程都有自己的页表,进程一多,光是页表占用的空间就很可观了。
归根结底,我们要解决的是:不要一次性地将全部页表项建好,需要时动态创建页表项。
所以我们多套一层,多一个页目录项
每个进程都有自己的页表,这样的话每个进程中相同的虚拟地址可以映射到不同的物理地址中,这样的话就实现了进程与进程之间内存的隔离,顺便也解决了碎片化的问题。
四、页表项和也目录项
P,Present,意为存在位。若为1表示该页存在于物理内存中,若为0表示该表不在物理内存中。
RW,Read/Write,意为读写位。若为1表示可读可写,若为0表示可读不可写。
US,User/Supervisor,意为普通用户/超级用户位。若为1时,任意级别都可以访问。为0,只允许特权级别为0、1、2的程序访问。
PWT,Page-level Write-Through,意为页级通写位,也称页级写透位。若为1表示此项采用通写方式,本位用来间接决定是否用此方式改善该页的访问效率。这里直接置为0就可以。
PCD,Page-level Cache Disable,意为页级高速缓存禁止位,置为0。
A,Accessed,意为访问位。若为1表示该页被CPU访问过啦。是用来在内存不足时与将不常用的内存置换到硬盘中。
D,Dirty,意为脏页位。当CPU对一个页面执行写操作时,就会设置对应页表项的D位为1。
PAT,Page Attribute Table,意为页属性表位,置0。
G, Global,意为全局位,为1表示是全局页,为0表示不是全局页。若为全局页,该页将在高速缓存TLB中一直保存,无需繁琐的置换过程。
AV L,意为Available位,即保留位。
页表同描述符表一样,是个内存中的数据结构,处理器要使用它们,必须要知道它们的物理地址,所以页表也有个专门的寄存器来存储其地址。这就是控制寄存器cr3。控制寄存器cr3用于存储页表物理地址,所以cr3寄存器又称为页目录基址寄存器(Page Directory Base Register,PDBR)
由于页目录表所在的地址要求在一个自然页内,即页目录的起始地址是4KB的整数倍,低12位地址全是0。所以,只要在cr3寄存器的第31~12位中写入物理地址的高20位就行了。PWT位和PCD位在介绍页表项时说过了,它们用于设置高速缓存相关的特性,在此将其置为0即可。
五、开启内存分页机制
开启内存分页机制分为三步
1、准备好页目录以及页表
2、在cr3寄存器的第31~12位中写入页目录物理地址的高20位
3、寄存器cr0的PG位置1。(其中cr0寄存器的各个位在进入保护模式时有讲)
我们可以看代码了,loader.s添加了一下代码
; os/src/boot/loader.s
; 下面就是保护模式下的程序了
[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:320], 'M'
mov byte [gs:322], 'A'
mov byte [gs:324], 'I'
mov byte [gs:326], 'N'
call setup_page ; 创建页目录及页表并初始化页内存位图
;要将描述符表地址及偏移量写入内存gdt_ptr,一会用新地址重新加载
sgdt [gdt_ptr] ; 存储到原来gdt的位置
;将gdt描述符中视频段描述符中的段基址+0xc0000000
mov ebx, [gdt_ptr + 2]
or dword [ebx + 0x18 + 4], 0xc0000000 ;视频段是第3个段描述符,每个描述符是8字节,故0x18。
;段描述符的高4字节的最高位是段基址的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] ; 重新加载
mov byte [gs:320], 'V' ;视频段段基址已经被更新,用字符v表示virtual addr
mov byte [gs:322], 'i' ;视频段段基址已经被更新,用字符v表示virtual addr
mov byte [gs:324], 'r' ;视频段段基址已经被更新,用字符v表示virtual addr
mov byte [gs:326], 't' ;视频段段基址已经被更新,用字符v表示virtual addr
mov byte [gs:328], 'u' ;视频段段基址已经被更新,用字符v表示virtual addr
mov byte [gs:330], 'a' ;视频段段基址已经被更新,用字符v表示virtual addr
mov byte [gs:332], 'l' ;视频段段基址已经被更新,用字符v表示virtual addr
jmp $
setup_page: ; 创建页目录及页表
mov ecx, 4096
mov esi, 0
.clear_page_dir: ; 清理页目录空间
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi
loop .clear_page_dir
.create_pde: ; 创建页目录
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000 ; 此时eax为第一个页表的位置及属性,属性全为0
mov ebx, eax ; 此处为ebx赋值,是为.create_pte做准备,ebx为基址。
; 下面将页目录项0和0xc00都存为第一个页表的地址,
; 一个页表可表示4MB内存,这样0xc03fffff以下的地址和0x003fffff以下的地址都指向相同的页表,
; 这是为将地址映射为内核地址做准备
or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问.
mov [PAGE_DIR_TABLE_POS + 0x0], eax ; 第1个目录项,在页目录表中的第1个目录项写入第一个页表的位置(0x101000)及属性(7)
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ; 一个页表项占用4字节,0xc00表示第768个页表占用的目录项,0xc00以上的目录项用于内核空间,
; 也就是页表的0xc0000000~0xffffffff共计1G属于内核,0x0~0xbfffffff共计3G属于用户进程.
sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS + 4092], eax ; 使最后一个目录项指向页目录表自己的地址
;下面创建第一个页表PTE,其地址为0x101000,也就是1MB+4KB的位置,需要映射前1MB内存
mov ecx, 256 ; 1M低端内存 / 每页大小4k = 256
mov esi, 0
mov edx, PG_US_U | PG_RW_W | PG_P ; 属性为7,US=1,RW=1,P=1
.create_pte:
mov [ebx+esi*4],edx ; 此时的ebx已经在上面成为了第一个页表的地址,edx地址为0,属性为7
add edx,4096 ; edx+4KB地址
inc esi ; 循环256次
loop .create_pte
;创建内核其它页表的PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ; 此时eax为第二个页表的位置
or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性为7
mov ebx, PAGE_DIR_TABLE_POS
mov ecx, 254 ; 范围为第769~1022的所有目录项数量
mov esi, 769
.create_kernel_pde:
mov [ebx+esi*4], eax
inc esi
add eax, 0x1000
loop .create_kernel_pde
ret
boot.inc 添加了如下的宏定义
PAGE_DIR_TABLE_POS equ 0x100000
PG_P equ 1b
PG_RW_R equ 00b
PG_RW_W equ 10b
PG_US_S equ 000b
PG_US_U equ 100b
我们可以画个图,看一下现在的内存中的页目录和页表是怎么回事
其实有两个页目录项指向了第一个页表,第一个页目录以及第768个页目录,第768个页目录意味着虚拟地址为 1100_0000_00
开头的地址,这一部分指向了第一个PTE,第一个PTE首先包含了1024项,但是只有前256项被用到,这个地址范围是 0000_0000_00 ~ 0100_0000_00
,是虚拟地址的中10位,最后十二位就是在相应内存块的位置。
这部分作用就是将物理地址 0x00000~0xfffff
映射到虚拟地址 0xc0000000 ~ 0xc00fffff
。
这样我们的内核代码就放在物理地址1MB以下的位置即可。
最后看一下成果
六、修改页目录表与页表
最后一个页目录是指向了自己,这也为修改页目录表埋下了机会,否则内存虚拟化后,无法通过直接访问物理地址来访问内存,页目录表也不在虚拟内存可以访问的空间内,那么这个表相当于直接丢失了,无法访问
可以观察到有三个奇怪的地址映射,这就是最后一个页目录指向自己导致的
0xffc00000-0xffc00fff -> 0x000000101000-0x000000101fff
0xfff00000-0xffffefff -> 0x000000101000-0x0000001fffff
0xfffff000-0xffffffff -> 0x000000100000-0x000000100fff
1、若虚拟地址的高十位为 11_1111_1111
,那么索引为当前的页目录表,所以,当前的页目录表就被当做了页表
2、若虚拟地址的中十位为 11_1111_1111
,那么索引为当前的页目录表(被当做页表)的最后一项,指向的还是当前的页目录表,再配合虚拟地址的后12位就可以修改页目录表了,这就是 0xfffff000-0xffffffff -> 0x000000100000-0x000000100fff
这个地址映射的由来。
3、若虚拟地址的中十位为 00_0000_0000
, 那么索引为当前的页目录表(被当做页表)的第一项,指向的是第一项的PTE页表,再配合虚拟地址的后12位就可以修改第一个页表了,这就是 ,0xffc00000-0xffc00fff -> 0x000000101000-0x000000101fff
这个地址映射的由来。
4、若虚拟地址的中十位为 11_0000_0000 ~ 11_1111_1111
, 那么索引为当前的页目录表(被当做页表)的第768项到1024项,指向的是第768项到1024项的PTE页表,再配合虚拟地址的后12位就可以修改这些页表了,这就是 ,0xfff00000-0xffffefff -> 0x000000101000-0x0000001fffff
这个地址映射的由来。
拿到了这个虚拟地址,我们就可以直接访问这块内存对PDE与PTE进行修改。
结束语
这节讲述了内存的分页机制,以及如何利用这种分页机制,如何在已经开启分页机制的基础上对PDE与PTE进行修改。
下节我们将开启内核,以及用C语言编程
我将代码放在了github上,大家可以自行下载 https://github.com/lyajpunov/os.git