保护模式进阶
保护模式进阶
获取内存
在linux中有许多方法获取内存容量,其本质上是调用 BIOS 中断 0x15 实现的,分别是 BIOS 中断 0x15 的 3 个子功能。
- EAX=0xE820:遍历主机上全部内存。
- AX=0xE801: 分别检测低 15MB 和 16MB~4GB 的内存,最大支持 4GB。
- AH=0x88:最多检测出 64MB 内存,实际内存超过此容量也按照 64MB 返回。
0xe820子功能
ARDS
BIOS 中断 0x15 的子功能 0xE820 能够获取系统的内存布局,由于系统内存各部分的类型属性不同,BIOS 就按照类型属性来划分这片系统内存,所以这种查询呈迭代式,每次 BIOS 只返回一种类型的内存信息,直到将所有内存类型返回完毕。子功能 0xE820 的强大之处是返回的内存信息较丰富,包括多个属性字段,所以需要一种格式结构来组织这些数据。内存信息的内容是用地址范围描述符来描述的,用于存储这种描述符的结构称之为地址范围描述符(Address Range Descriptor Structure,ARDS)。
地址范围描述符大小是4个字节,共5个字段,一共20个字节,每次调用中断0x15时就会返回这样一个奇似描述符的数据结构。ADRS用高低共64位的地址来描述这段内存的基地址,又用高低各32位共64位的地址来描述这段内存的长度,最后用Type来标识这段内存的类型,这里所谓的类型是说明这段内存的用途,即其是可以被操作系统使用,还是保留起来不能用。
由于在 32 位环境下工作,所以在 ARDS 结构属性中,我们只用到低 32 位属性。BaseAddrLow+LengthLow 是一片内存区域上限,单位是字节。
调用0xe820
此功能的调用步骤分为三步
填写好“调用前输入”中列出的寄存器
执行中断调用 int 0x15
在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便会有对应的结果
0xe801子功能
0xe801子功能最多只能识别4GB内存,低于 15MB 的内存以 1KB 为单位大小来记录,单位数量在寄存器 AX 和 CX 中记录,其中 AX 和 CX 的值是一样的,所以在 15MB 空间以下的最大容量=AX * 1024 = 0x3c00 = 15MB。16MB~4GB 是以 64KB 为单位大小来记录的,单位数量在寄存器 BX 和 DX 中记录,其中 BX 和 DX 的值是一样的。
此中断的调用步骤如下:
将 AX 寄存器写入 0xE801
执行中断调用 int 0x15
在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便会有对应的结果
0x88子功能
0x88子功能最多只能识别64MB的内存,当内存大于64MB是只会显示63MB,因为此功能只会显示1MB之上的内存,不包括这1MB,所以在使用之时要加上这1MB
此中断的调用步骤如下:
- 将 AX 寄存器写入 0x88
- 执行中断调用 int 0x15
- 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便会有对应的结果
分页
物理地址、虚拟地址、线性地址和逻辑地址
物理地址
物理地址就是物理上真正存在的地址,无论地址怎样变化,cpu最终访问的一定是物理地址。在实模式下,“段基址+偏移地址”直接输出的就是物理地址
虚拟地址和线性地址
在保护模式下,“段基址+段内偏移地址”称为线性地址。选择子指向段描述符,描述符中有段的段基址,而段内偏移地址就是要访问地址的低位。若未开启分页,则该线性地址等同于物理地址。若开启了分页功能,就又称之为虚拟地址,虚拟地址要经过专门的CPU部件才会转化为真正的物理地址
逻辑地址
无论在实模式或是保护模式下,段内偏移地址又称为有效地址、也成为逻辑地址,也就是程序员可见的地址。
地址之间的关系图
分页机制
分页机制是建立在分段机制之上的。在保护模式中段寄存器中的内容是选择子,但选择子最终就是为了要找到段基址,其内存访问的核心机制依然是“段基址:段内偏移地址”,这两个地址在相加之后才是绝对地址,在未开启分页时,此地址直接等同于物理地址。
开启分页之后,段部件输出的地址不再等同于物理地址,称之为虚拟地址,它是逻辑上的,是假的。
分页机制的思想是:通过映射,可以使连续的线性地址与任意物理内存地址相关联,逻辑上连续的线性地址其对应的物理地址可以不连续,分页机制的作用有两方面:
将线性地址转换成物理地址
用大小相等的页代替大小不等的段
每加载一个进程,操作系统按照进程中各段的起始范围,在进程自己的 4GB 虚拟地址空间中寻找可用空间分配内存段,此虚拟地址空间可以是页表,也可以是操作系统维护的某种数据结构,此阶段的分配是逻辑上的,并没有真正写入物理内存。在分页机制下,代码段和数据段在逻辑上被拆分为以页为单位的小内存块。接着操作系统开始为这些虚拟内存分配真正的物理页地址,并在页表中登记这些物理页地址,完成虚拟页到物理页的映射
一级页表
假设表中有4G个页表项,则32位的地址要用4字节的页表项来存储,页表总共大小位4Byte * 4G = 16GB,这种存储方法实际上就是将4G个页分配成4G个内存块,每个内存块为1字节大小,光页表就有16GB,得不偿失。所以解决方法就是少分几个页,将每个页的尺寸变大,CPU中采用的页就是4KB,所以4GB空间一共有4 * 1024 * 1024=1048576个页表项,也就是说页表中也需要1048576个页表项,这个页表被称为一级页表
由于页大小是4KB,所以页表项中的物理地址都是 4k 的整数倍,故用十六进制表示的地址,低 3 位都是 0。就拿第3 个页表项来说,其值为 0x3000,表示该页对应的物理地址是 0x3000。
一级页表存在的问题
- 一级页表中所有页表项必须要提前建好,原因是操作系统要占用 4GB 虚拟地址空间的高 1GB,用户进程要占用低 3GB
- 一级页表中最多可容纳 1M(1048576)个页表项,每个页表项是 4 字节,如果页表项全满的话,便是 4MB 大小,但是每个进程都有自己的页表,进程一多,光是页表占用的空间就相当大了
虚拟地址(线性地址)和物理地址的转换
分页机制打开前要将页表地址加载到控制寄存器 cr3 中,这是启用分页机制的先决条件之一。所以,在打开分页机制前加载到寄存器 cr3 中的是页表的物理地址,页表中页表项的地址自然也是物理地址了。
一个页表项对应一个页,所以,用线性地址的高 20 位作为页表项的索引,每个页表项要占用 4 字节大小,所以这高 20 位的索引乘以 4 后才是该页表项相对于页表物理地址的字节偏移量。用 cr3 寄存器中的页表物理地址加上此偏移量便是该页表项的物理地址,从该页表项中得到映射的物理页地址,然后用线性地址的低 12 位与该物理页地址相加,所得的地址之和便是最终要访问的物理地址。
总结下就是:页表项的物理地址 = CR3中页表的物理地址(物理起始地址) + 高20位虚拟地址 (页索引) 4 (每个页表项大小)+ 低12位虚拟地址(页偏移量)*
由于地址转换算法已经是固定的,所以 CPU 中集成了专门用来干这项工作的硬件模块,该模块称为页部件。当程序中给出一个线性地址时,页部件分析线性地址,按照以上算法,自动在页表中检索到物理地址。
用 mov ax,[0x1234]
解释一下,0x1234
称为有效地址,它作为“段基址:段内偏移地址”中的段内偏移地址。这样段基址为 0
,段内偏移地址为 0x1234
,经过段部件处理后,输出的线性地址是 0x1234
。假设打开了分页,线性地址 0x1234
被送入了页部件。页部件分析 0x1234
的高20 位,用十六进制表示高 20 位是 0x00001
,将此项作为页表项索引,再将该索引乘以 4 后加上 cr3 寄存器中页表的物理地址,这样便得到索引所指代的页表项的物理地址,从该物理地址处(页表项中)读取所映射的物理页地址:0x9000
。线性地址的低 12 位是 0x234
,它作为物理页的页内偏移地址与物理页地址0x9000
相加,和为 0x9234
,这就是线性地址 0x1234
最终转换成的物理地址
二级页表
二级页表的出现很好解决了一级页表中的问题。无论是几级页表,标准页尺寸都为4KB,所以4GB线性地址空间一共会有1M个标准页,一级页表是将这1M个标准页全部放在一个页表之中,而二级页表是将这1M个标准页分为1K个页表,每个页表有1K个页表项,即每个页表大小为1K*4Byte = 4KB,恰巧为一个标准页大小。这1K个页表的起始物理地址都登记在一个页目录表中,每一项称之为页目录项(Page Directory Entry, PDE),页目录项大小同页表项一样,都用来描述一个物理页的物理地址,其大小都是 4 字节,而且最多有 1024 个页表,所以页目录表也是 4KB 大小,同样也是标准页的大小
在二级页表中,虚拟地址的高10位用于在一个页目录中确定某一个页表的偏移地址,用中间的10位确定页的物理地址,再用最后的12位来表示物理页的偏移地址
所以在二级页表中找到最终的物理地址公式为:CR3页目录物理地址 + 虚拟地址高10位(PDE索引值) * 4 + 虚拟地址中间10位 (PTE索引值) 4 + 低12位(物理页偏移地址)*
页目录和页表项的结构
页目录和页表项都是4字节大小,但是在其中只有12~31位才是表示页表物理页地址和物理页地址,这是因为标准页的大小都为4K,低12位都是0,只需要记录高20位即可
- P:Present,存在位,若为 1 表示该页存在于物理内存中,若为 0 表示该表不在物理内存中。
- 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 表示此项采用通写方式,表示该页不仅是普通内存,还是高速缓存
- PCD:Page-level Cache Disable,意为页级高速缓存禁止位。若为 1 表示该页启用高速缓存,为 0 表示禁止将该页缓存
- A:ACCESSED:Accessed,意为访问位。若为 1 表示该页被 CPU 访问过,若为0表示该页还未访问。与段描述符中的A、P位有异曲同工之妙。和它们一样,这里页目录项和页表项中的 A 位也可以用来记录某一内存页的使用频率(操作系统定期将该位清 0,统计一段时间内变成 1 的次数),从而当内存不足时,可以将使用频率较低的页面换出到外存(如硬盘),同时将页目录项或页表项的 P位置 0,下次访问该页引起 pagefault 异常时,中断处理程序将硬盘上的页再次换入,同时将 P 位置 1
- PAT:Page Attribute Table,意为页属性表位,能够在页面一级的粒度上设置内存属性。比较复杂,将此位置 0 即可
- G:Globle,此 G 位用来指定该页是否为全局页,为 1 表示是全局页,为 0 表示不是全局页。由于虚拟地址转物理地址需要花费大量时间,所以需要将虚拟地址和物理地址的转换结果存储起来,存放在TLB(Translation Lookaside Buffer)中。
- AVL:意为 Available 位,表示软件,操作系统可用该位,CPU 不理会该位的值
启用分页机制
启用分页机制需要三个步骤
- 准备好页目录和页表
- 将页表地址写入控制寄存器CR3中
- 将寄存器CR0的PG位置为1
在一级页表中CR3寄存器存放的是页表的物理地址,而在二级分页中存放的是页目录的物理地址,所以CR3又称为页目录基址寄存器(Page Directory Base Register,PDBR)
页目录的起始地址是 4KB 的整数倍,低 12 位地址全是 0。所以,只要在 cr3 寄存器的第 31~12 位中写入物理地址的高 20 位就行了,另外,cr3 寄存器的低 12 位中,除第 3 位的 PWT 位和第 4 位的 PCD 位外,其余位都没用,因为控制寄存器和通用寄存器是可以互传数据的,所以直接用mov
指令即可,例如mov cr[0~7]
代码部分
代码设计思路
设计思路与Linux类似,在用户进程4GB虚拟空间的高3GB以上的空间划分给操作系统,0 ~ 3GB是用户进程自己的虚拟空间。为了让所有的用户共享一个操作系统,必须让所有用户的3GB ~ 4GB的空间映射到同一个物理地址,这片物理地址就是操作系统的所在处。
分页机制得有页目录表,页目录表中的是页目录项,其中记录的是页表的物理地址及相关属性,所以还得有页表。我们实际的页目录表及页表也将按照此空间位置部署,地址的最下面是页目录表,往上依次是页表。页目录表和页表都存在于物理内存之中。页目录表的位置,放在物理地址 0x100000 处。为了让页表和页目录表紧凑一些(这不是必须的),让页表紧挨着页目录表。页目录本身占 4KB,所以第一个页表的物理地址是 0x101000,内存布局图如下
一些值得注意的地方
- 在操作系统加载进内核之前,程序中一直运行的是loader程序,其本身的代码都在低端的1MB中,所以需要确保在分页机制下的地址和虚拟地址的的物理地址必须一一对应(即使是在分页的情况下也必须保证先运行loader),所以虚拟地址0 ~ 0xfffff必须映射到物理地址的0 ~ 0xfffff,也就是页目录表第0项对应的页表0 ~ 0xff页表项值已经确定
- 由于操作系统会被加载到低端的1MB之中,而操作系统所在的虚拟地址是在3GB往上的空间之中,所以操作系统对应的页表目录索引是0x300(高十位),也就是说页目录表第0x300项对应的页表0-0xff页表项值也已经确定
- 为了方便后面的操作系统内核的加载都放在低端的1MB内存之中,索引为768的页(0xc0000000 的高 10 位是 0x300,即十进制的 768)的虚拟地址 0xc0000000~0xc03fffff 之间的虚拟内存都指向低端 4MB 之内的物理地址,这自然包括操作系统所占的低端 1MB 物理内存。从而实现了操作系统高 3GB 以上的虚拟地址对应到了低端 1MB,也就是内核所占的就是低端 1MB
mbr.S
1 | ;主引导程序 |
loader.S
1 | %include "boot.inc" |
boot.inc
1 | ;------------- loader和kernel ---------- |
编译
1 | nasm -I include/ -o mbr.bin mbr.S |
效果图
关于地址映射
根据bochs中的调试指令 info tab
可以查看虚拟地址映射的物理地址,图中左边是虚拟地址,右边是物理地址
第一条映射关系相当明显,映射了第0个页目录项的物理地址
第二条映射关系是第768个页表完成的映射关系,它所映射的也是第0个页目录项的物理地址,所以和第一条映射的物理地址相同
第三条映射关系中0xffc00000,负责表示页表项索引的高十位全部为1,而表示页表的索引中间10位全部为0,所以他指向的是最后一个页表(即第1023个页表)的第0项,而最后一个页表项中存放的是第0个页表的物理地址,所以指向了第0个页表,其值是 0x101000,此值被认为是最终的物理页地址。剩下的12就是偏移量,加上去就能得到0x00101fff,即最终的映射的物理地址是0x00101000~0x00101fff
第四条映射关系0xfff00000 的高 10 位依然为 0x3ff,中间 10 位是 1100000000b=0x300,这是第 768 个页目录项,该页目录项指向的页表与第 0 个页目录项指向的页表相同。所以虚拟地址 0xfff00000 映射为物理地址0x00101000 成立,即最终的映射的物理地址是0x00101000~0x00101fff
第五条映射关系0xfffff000高 10 位为 0x3ff,中间10位是0x3ff,映射的是第1023页的1023项,由于1023页目录项指向了该页目录的首地址,而页目录的首地址映射的是自己的物理地址,所以这里的前20位映射相当于是将页目录看作是一个页表进行映射,所以最低物理地址就是页目录的物理地址0x00100000,最大物理地址0x00100000+212-1=0x00100fff。由此可以得出结论:如果虚拟地址的高 20 位为 0xfffff,经过我们的页目录表映射,将会访问到页目录表自己的物理地址。
用虚拟地址获取页表中各数据类型的方法总结
- 获取页目录表物理地址:让虚拟地址的高 20 位为 0xfffff,低 12 位为 0x000,即 0xfffff000,这也是页目录表中第 0 个页目录项自身的物理地址
- 访问页目录中的页目录项,即获取页表物理地址:要使虚拟地址为 0xfffffxxx,其中 xxx 是页目录项的索引乘以 4 的积
- 访问页表中的页表项:要使虚拟地址高 10 位为 0x3ff(第768个页目录项,指向的是页目录的物理地址),目的是获取页目录表物理地址。中间 10 位为页表的索引,因为是 10 位的索引值,所以这里不用乘以 4。低 12 位为页表内的偏移地址,用来定位页表项,它必须是已经乘以 4 后的值
快表
虽然分表机制解决了内存碎片等一系列问题,但是需要寄存器和内存花费更多的时间去寻址,为了解决这个问题,还是采用了缓存思想 ,添加了一个高速缓存专门用于虚拟地址页框和存储页框物理地址,这个高速缓存称之为快表( TLB,Translation Lookaside Buffer)
高速缓存由于成本等原因,容量一般都很小,TLB 也是,因此 TLB 中的数据只是当前任务的部分页表,而且只有 P 位为 1 的页表项才有资格在 TLB 中,如果 TLB 被装满了,需要将很少使用的条目换出。TLB 里面存储的是程序运行所依赖的指令和数据的内存地址,任意时刻都必须保证地址的有效性,否则程序必然出错,所以 TLB 必须实时更新
TLB 中的条目是虚拟地址的高 20 位到物理地址高 20 位的映射结果,实际上就是从虚拟页框到物理页框的映射。除此之外 TLB中还有一些属性位,比如页表项的 RW 属性
TLB 对开发人员不可见,但是开发人员依旧可以使用间接方法来改变TLB
- 重新加载CR3(感觉像清空流水线)
- 使用
invlpg
指令,如invlpg [0x1234]
因为其中的0x1234
并非立即数数,所以得采用[]