《操作系统真象还原》第五章(2)——启用内存分页机制
文章目录
- 前言
- 为什么内存要分页?
- 一级页表
- 分段机制和分页机制
- 分页机制的原理
- 二级页表
- 为什么还要有二级表?
- 二级页表的结构
- 二级页表如何寻址?
- 一个具体的例子
- 页目录项和页表项
- 启动分页机制
- 创建页目录和页表
- 正式进入分页模式
- 检验分页机制是否正确运行
- 结语
前言
这是第五章第二部分,主要研究内存分页问题
为什么内存要分页?
核心问题是如果目前连续的物理内存不再够下一个进程载入了,怎么办?
目前我们的线性地址和物理地址都是连续的,并且一一对应,一个进程在一段连续的线性地址上,同时也在一段连续的物理地址上运行,而进程之间的内存碎片无法得到利用。
如果我们能接触物理地址和线性地址的对应就好了,这样进程在逻辑上用一段连续的线性地址,在物理上可以用不连续的物理地址,这样就解决了问题。因此引入内存分页。
内存分页的本质就是:通过建立页表,实现将连续线性地址映射到任意物理地址。
一级页表
分段机制和分页机制
分页机制建立在分段机制之上。分段机制是intel IA32架构的底层机制,无法修改。我们先简要介绍一下分段机制。
实模式下段寄存器里保存段基址,保护模式下段寄存器保存选择子,但归根结底内存访问的核心都是段基址:段内偏移地址,二者组合获得绝对地址,这个绝对地址就是线性地址。在分段机制下,这个线性地址就对应物理地址,可以直接送上地址总线,CPU拿来就能直接用。
分页机制建立在分段机制之上,段部件先照常工作处理出线性地址,如果此时打开了分页机制,这个线性地址就不再是实际的物理地址了,我们称之为虚拟地址,这个虚拟地址对应的物理地址是多少?这就是我们下一步要解决的问题。想要解决这个问题,要先了解分页机制的原理和页表的结构。
分页机制的原理
分页机制有两个作用:1.将线性地址转化为物理地址 2.用大小相等的页代替大小不等的段。
一个页的大小是4KB,页可以看作是地址空间的一种单位,它不是某个具体的物理地址或线性地址。4GB内存可以分成1M个页。一个页表就要有1M项,一个页表项对应一页内存(页表项大小并不是一页!),这个有1M项的页表就是一级页表。
那么,一个虚拟地址怎么对应一个页表的一个页呢?一个虚拟地址有32位,高20位用来对应一个页(220=1024*1024=1M,正好4GB内存分成了1M个页),低12位用来在页内寻址(212=4K)。
有两个注意事项:
- 分页机制打开前,要将页表地址(也就是页表项的起始地址)加载到控制寄存器cr3里,这样就把物理地址放到了页表项里。(这也是启用分页机制的先决条件)
- 页表的转换过程相当于在关闭分页机制下进行。也就是说页表本身的地址,以及通过页表项内容得到的地址,会被直接送上地址总线处理。(否则就会出现无限递归,结果被再次转换)。
高二十位对应的索引的公式为:高20位*4(一个页表项是4字节,所以乘4)+cr3保存的页表项起始地址。通过这个索引拿到页表项内容地址,这个地址+低12位地址=最终给CPU的物理地址。
来看一下书上的例子:

解释:假如目前有一个地址0:0x1234,经过段部件处理后得到虚拟地址为0x00001234,因为已经打开了分页机制,这个地址被送上页部件。高20位0x00001送入索引计算公式,得到索引,用索引读表,得到物理地址0x9000,再加上低12位0x234,得到最终的物理地址0x9234,这就是虚拟地址转化的物理地址。
二级页表
为什么还要有二级表?
一级表有这些问题:
- 一级页表中最多可容纳 1M(1048576)个页表项,每个页表项是 4 字节,如果页表项全满的话,便是 4MB 大小。
- 一级页表中所有页表项必须要提前建好,原因是操作系统要占用 4GB 虚拟地址空间的高 1GB,用户进程要占用低 3GB。
- 每个进程都有自己的页表,进程一多,光是页表占用的空间就很可观了。
归根结底,我们要解决的是:不要一次性地将全部页表项建好,需要时动态创建页表项。如何解决呢?引入二级页表。
二级页表的结构
首先,一个标准页(内存空间的一种单位)仍然是4KB,我们要映射4GB物理内存,要有1M个标准页。在一级页表中,这1M个标准页的索引被放在一个表里,在二级页表中,1M个标准页索引被分成1K组,每组1K个页的索引,可以认为我们建立了1K张表,每个表有1K个项,每个项储存着一个内存页的索引。
如何组织1K个表?引入一个页目录。页目录同样是一个表,有1K项,每个项对应一个表。一个页表项是4字节大小,1K个项正好是4K字节一个标准页的大小。最终形成了:
页目录(其中存有1K个表的表头地址)->某个表(其中存有1K个标准页的索引)->表项(对应某个具体的标准页)。
示意图:

二级页表如何寻址?
首先我们先确定,我们有1K个页表,每个页表1K项,每个项对应1个标准页,标准页大小是4KB,总共就是1024x1024x4028=4GB,对应4GB物理内存。也就是说,一个32位物理内存地址,必然对应某个页表的某个项的物理地址。
32位地址的高10位(31-22位)用来在页目录中的页目录项(简称PDE)定位一个页表;中间10位(21-12位)用来在(页目录项对应的)页表中定位一个页表项(简称PTE);最低12位,用来对应(页表项对应的)具体页4KB内的具体地址。
同一级页表一样,访问任何页表内的数据都要通过物理地址。由于页目录项 PDE 和页表项 PTE 都是4 字节大小,给出了 PDE 和 PTE 索引后,还需要乘以 4,再加上页表物理地址,这才是最终要访问的绝对物理地址。转换过程背后的具体步骤如下。
- 用虚拟地址的高 10 位乘以 4,作为页目录表内的偏移地址,加上页目录表的物理地址,所得的和,便是页目录项的物理地址。读取该页目录项,从中获取到页表的物理地址。
- 用虚拟地址的中间 10 位乘以 4,作为页表内的偏移地址,加上在第 1 步中得到的页表物理地址,所得的和,便是页表项的物理地址。读取该页表项,从中获取到分配的物理页地址。
- 虚拟地址的高 10 位和中间 10 位分别是 PDE 和 PTE 的索引值,所以它们需要乘以 4。但低 12 位就不是索引值啦,其表示的范围是 0~0xfff,作为页内偏移最合适,所以虚拟地址的低 12 位加上第 2 步中得到的物理页地址,所得的和便是最终转换的物理地址。
一个具体的例子
先看一张图片:

假设目前有虚拟地址0:0x01234567,高10位是0x4,中间10位是0x234,低12位是0x567。高10位x4+页目录的物理地址,得到第一个中间量0x1000,这个中间量就是某个页表的页表地址。中间10位x4+0x1000,得到第二个中间量0xfa000,这个值就是某个标准页的物理地址。再加上最后12位指示的页内偏移地址0x567,就是最后给CPU的物理地址0xfa567。
页目录项和页表项
我们之前只是泛泛的谈到这两个部分,说它们是4字节大小,保存物理地址,现在详细介绍一下它们的结构。
我们知道一个标准页是4KB大小,所以每个标准页的页基址一定是4KB的倍数,这样32位中最低的12位(11-0位)一定就是0,我们可以利用这些位来记录一些属性。从低到高介绍一下。
- P,Present,意为存在位。若为 1 表示该页存在于物理内存中,若为 0 表示该表不在物理内存中。操作系统的页式虚拟内存管理便是通过 P 位和相应的 pagefault 异常来实现的。
- RW,Read/Write,意为读写位。若为 1 表示可读可写,若为 0 表示可读不可写。
- US,User/Supervisor,意为普通用户/超级用户位。若为 1 时,表示处于 User 级,任意级别(0、1、2、3)特权的程序都可以访问该页。若为 0,表示处于 Supervisor 级,特权级别为 3 的程序不允许访问该页,该页只允许特权级别为 0、1、2 的程序可以访问。
- PWT,Page-level Write-Through,意为页级通写位,也称页级写透位。若为 1 表示此项采用通写方式,表示该页不仅是普通内存,还是高速缓存。此项和高速缓存有关,“通写”是高速缓存的一种工作方式,本位用来间接决定是否用此方式改善该页的访问效率。直接置为 0 就可以。
- PCD,Page-level Cache Disable,意为页级高速缓存禁止位。若为 1 表示该页启用高速缓存,为 0 表示禁止将该页缓存。这里咱们将其置为 0。
- A,Accessed,意为访问位。若为 1 表示该页被 CPU 访问过啦,所以该位是由 CPU 设置的。这里页目录项和页表项中的 A 位也可以用来记录某一内存页的使用频率(操作系统定期将该位清 0,统计一段时间内变成 1 的次数),从而当内存不足时,可以将使用频率较低的页面换出到外存(如硬盘),同时将页目录项或页表项的 P位置 0,下次访问该页引起 pagefault 异常时,中断处理程序将硬盘上的页再次换入,同时将 P 位置1。
- D,Dirty,意为脏页位。当 CPU 对一个页面执行写操作时,就会设置对应页表项的 D 位为 1。此项仅针对页表项有效,并不会修改页目录项中的 D 位。
- PAT,Page Attribute Table,意为页属性表位,能够在页面一级的粒度上设置内存属性。比较复杂,将此位置 0 即可。
- G,Global,意为全局位。由于内存地址转换也是颇费周折,先得拆分虚拟地址,然后又要查页目录,又要查页表的,所以为了提高获取物理地址的速度,将虚拟地址与物理地址转换结果存储在 TLB(Translation Lookaside Buffer)中,TLB 以后咱们会细说。在此先知道 TLB 是用来缓存地址转换结果的高速缓存就 ok 啦。此 G 位用来指定该页是否为全局页,为 1 表示是全局页,为 0 表示不是全局页。若为全局页,该页将在高速缓存 TLB 中一直保存,给出虚拟地址直接就出物理地址啦,无需那三步骤转换。由于 TLB 容量比较小(一般速度较快的存储设备容量都比较小),所以这里面就存放使用频率较高的页面。
- AVL,意为 Available 位,表示软件和操作系统可用该位,CPU 不理会该位的值,我们也不必理会。
启动分页机制
启用分页机制,我们要按顺序做好三件事。
- 准备好页目录表及页表。
- 将页表地址写入控制寄存器 cr3。
- 寄存器 cr0 的 PG 位置 1。
创建页目录和页表
先简单分析一下操作系统和用户进程之间的关系。一个用户进程想要正常运行,必须和操作系统配合,所以操作系统必须要共享给所有的用户进程。我们通过页表实现这一点。我们把虚拟地址空间的3-4GB给操作系统,0-3GB给用户进程。
我们的操作系统大小在1MB以内,MBR,Loader,内核都在物理内存0-0xfffff这1MB空间里。前面说过为了共享,需要把这1mb物理内存对应到0xc0000000-0xc00fffff(3gb到3gb+1mb)这段虚拟线性地址上。
我们的页目录建立在物理内存1mb之后第一个4kb空间上,页目录每一项对应一个页表,一个页表对应4mb内存空间,所以我们页目录项第0项对应的页表对应0-0x003fffff共4mb物理空间,包括了前1mb空间。我们先分配1mb空间,剩下3mb暂时不分配。
在加载内核之前,CPU一直在运行Loader,它必须在分页机制启动前后都可以正常运行。段机制下,这部分线性地址就是0-0x3fffff,分页机制下,这部分虚拟内存是0xc0000000-0xc03fffff,所以我们用两个页表对应它,也就是两个页目录项。
代码如下:
;-------------------- 加载内核到缓冲区 --------------------------------
;-------------------- 启动分页 --------------------------------
;-------------------- 跳转到内核区 --------------------------------
;-------------------- 创建页目录和页表 --------------------------------
;首先先明确一点,那就是我们这里只处理低1mb的页表,目标是让0~1mb和3gb~3gb+1mb都映射低1mb
setup_page:
;先逐字节清0
mov ecx,4096 ;循环4096次
mov esi,0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS+esi],0
inc esi
loop .clear_page_dir ;清理出4KB的空间
;创建两个页目录项
.create_pde:
mov eax,PAGE_DIR_TABLE_POS
add eax,0x1000 ;起始位置+4KB,指向第一个页表,为后面的创建页表项作准备
mov ebx,eax
or eax,PG_US_U|PG_RW_W|PG_P ;将后12位和前20位地址拼接在一起,组成一个表项
;后面三个或后=0x7,代表这个目录项允许任意特权级访问,可写,后续我们会用到init用户级进程。
;将页目录0和0x300项都指向第一个页表的地址,都对应0-4mb的物理地址。
mov [PAGE_DIR_TABLE_POS+0x0],eax ;0项
mov [PAGE_DIR_TABLE_POS+0x300],eax ;0x300项转化为10进制是3*256=768
sub eax,0x1000
mov [PAGE_DIR_TABLE_POS+4092],eax ;在页目录最后一项1023项写入页目录本身的地址,所以是4096-4
;创建页表项
mov ecx,256 ;目前我们先安排低1mb内存,1M/4k=256项
mov esi,0
mov edx,PG_US_U|PG_RW_W|PG_P ;edx存每个表项的相对地址,初始为0+0x7
.create_pte
mov [ebx+esi*4],edx ;每过4字节有一项,每项存着一页,一页是4k
add edx,4096
inc esi
loop .create_pte ;循环256次
;创建内核其他页表的PDE
;这部分是3gb后的部分,共享给所有用户线程的,所以只关注页目录768项后的项,1023项用来存页目录本身的地址,不需要处理
;这里创建剩下的769~1022=254项
mov eax,PAGE_DIR_TABLE_POS
add eax,0x2000 ;从第二个页表开始,映射到目录里
or eax,PG_US_U|PG_RW_W|PG_P
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
正式进入分页模式
这部分代码比较简单,就是所谓的三部曲:
- 准备好页目录表及页表。
- 将页表地址写入控制寄存器 cr3。
- 寄存器 cr0 的 PG 位置 1。
1我们上面已经完成了。
这部分代码如下:
;-------------------- 启动分页 --------------------------------
call setup_page ;创建页目录和页表
sgdt [gdt_ptr] ;要将描述符表地址及偏移量写入内存 gdt_ptr,一会儿用新地址重新加载
mov ebx,[gdt_ptr+2] ;先获取gdt基址,基址存在后四位
or dword [ebx+0x18+4],0xc0000000 ;将 gdt描述符中视频段描述符中的段基址+0xc0000000
;视频段是第 3 个段描述符,每个描述符是 8 字节,24=0x18
;段描述符的高 4 字节的最高位是段基址的第 31~24 位
add dword [gdt_ptr+2],0xc0000000 ;将 gdt 的基址加上 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
lgdt [gdt_ptr] ;在开启分页后,用 gdt 新的地址重新加载
mov byte [gs:160],'V' ;视频段段基址已经被更新,用字符 v 表示 virtual addr
jmp $
注意我们已经删掉了之前的这部分代码:
mov ax,SELECTOR_VIDEO
mov gs,ax
mov byte [gs:160], 'P' ;通过显卡打印一个字符,验证是否进入保护模式
检验分页机制是否正确运行
老样子,编译+写入+运行bochs
nasm -I include/ -o loader.bin loader.S
dd if=/home/hongbai/bochs/loader.bin of=/home/hongbai/bochs/bin/c.img bs=512 count=4 seek=2 conv=notrunc
cd bin
./bochs -f bochsrc.disk
输入6和c,结果如下

出现了预定结果V
ctrl+c退出,再输入info gdt

出现了一组报错,大意是gdtr寄存器+8*x指向了一个无效的地址0xc0000903。
我尝试了强制清零0号段描述符,以及先打开分页模式再调整gdt两种方法,都没有解决这个报错,暂且搁置,写完内核后再回来解决。
输入info tab,出现

这部分和书上一致,说明内存分页已经开启。
结语
这是第五章第2部分,开启分页模式。基本完成了任务,还有一个小报错留待后续解决。下一部分就要进入内核了,尽情期待。