从这一刻起,我们才算开始了真正的操作系统学习之旅
5.1 获取物理内存容量
5.1.1 Linux 获取内存的方法
在 Linux 2.6 内核总是用detect_memory
函数来获取内存容量的。其函数本质上是通过调用 BIOS 中断 0x15 实现的,分别是 BIOS 中断 0x15 的三个子功能,子功能号要存放到寄存器 EAX 或 AX 中,如下:
- EAX=0xE820:遍历主机上全部内存
- AX=0xE801: 分别检测低 15MB 和 16MB~4GB 的内存,最大支持 4GB
- AH=0x88:最多检测出 64MB 内存,实际内存超过此容量也按照 64MB 返回
5.1.2 利用 BIOS 中断 0x15 子功能 0xe820 获取内存
BIOS 中断 0x15 的子功能 0x820 能够获取系统的内存布局
由于系统内存各部分的类型属性不同,BIOS 就按照类型属性来划分这片系统内存,所以这种查询呈迭代式,每次 BIOS 只返回一种类型的内存信息,直到将所有内存类型返回完毕。0xE820
返回的内存信息包括多个属性字段,内存信息的内容使用地址范围描述符来描述的,用于存储这种描述符的结构称之为地址范围描述符(Address Range Descriptor Structure, ARDS)。
此结构中的字段大小都是 4 字节,共 5 个字段,所以一个描述符大小为 20 字节。每次 int 0x15 之后,BIOS 就返回这样一个结构的数据。其中 Type 字段用来描述这段内存的类型,即是否可被操作系统使用,具体意义见下表。
为什么 BIOS 会按类型来返回内存信息呢?原因是这段内存可能是:
- 系统的 ROM
- ROM 用到了这部分内存
- 设备内存映射到了这部分内存
- 由于某种原因,这段内存不适合标准标准设备使用
由于我们目前在 32 位环境下工作,所以在 ARDS 结构属性中,我们只用到低 32 位属性。BaseAddrLow+LengthLow
是一片内存区域上限,单位是字节。正常情况下,不会出现较大的内存区域不可用的情况,除非安装的物理内存极其小。所以这意味着在所有返回的 ARDS 结构里,此值最大的内存块一定是操作系统可使用的部分,即主板上配置的物理内存容量。
BIOS 中断只是一段函数例程,调用它就要为其提供参数,下面介绍一下0x820
子功能的参数。
ECX 和 ES:DI 寄存器,是典型的“值-结果”型参数。
中断的调用步骤:
- 填写好“调用前输入”中列出的寄存器;
- 执行中断调用
int 0x15
; - 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便会有对应的结果。
5.1.3 利用 BIOS 中断 0x15 子功能 0xe801 获取内存
此方法最大只能识别 4GB 内存,且检测到的内存是分别存放到两组寄存器中的。
低于 15MB 的内存以 1KB 为单位大小来记录, 单位数量在寄存器 AX 和 CX 中记录,其中 AX 和 CX 的值是一样的,所以在 15MB 空间以下的实际内存容量=AX1024。AX、CX 最大值为0x3c00
,即0x3c00*1024=15MB
。
16MB ~ 4GB 是以 64KB 为单位大小来记录的,单位数量在寄存器 BX 和 DX 中记录,其中 BX 和 DX 的值是一样的,所以 16MB 以上空间的内存实际大小=BX64*1024。
此中断的调用步骤:
- 将 AX 寄存器写入 0xE801;
- 执行中断调用 int 0x15;
- 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便有对应的结果。
下面我们解答两个问题:
- 为什么要分“前 15MB”和“ 16MB 以上”这两部分来展示 4GB 内存?
历史遗留问题。
80286 拥有 24 位地址线,其寻址空间是 16MB。当时有一些 ISA 设备要用到地址 15~但以上的内存作为缓冲区,也就是此缓冲区为山田大小,所以硬件系统 就把这部分内存保留下来,操作系统不可以用此段内存空间。
保留的这部分内存区域就像不可以访问的黑洞, 这就成了内存空洞memory hole
,虽然现在很少能碰到这些老 ISA 设备了,但为了兼容这部分空间还是保留下来,只不过可以通过 BIOS 选项的方式由用户自己选择是否开启。
- 为什么寄存器结果是重复的?如寄存器 AX 和 CX 相等, BX 和 DX 相等?
手册原文:
1 | Not sure what this difference between the ”Extended” and ”Configured” numbers are, but they appear to be identical, as reported from the BIOS. |
所以这里就不深究了。
5.1.4 利用 BIOS 中断 0x15 子功能 0xe88 获取内存
此中断只能识别最大 64MB 的内存
即使内存容量大于 64MB,也只会显示 63MB,又因为此中断只会显示 1MB 以上的内存,不包括这 1MB,所以在使用时要自己加上 1MB。
中断返回后,AX 寄存器的值的单位是 1KB,调用步骤如下:
- 将 AX 寄存器(表格中有错误)写入 0x88;
- 执行中断调用 int 0x15
- 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便有对应的结果。
5.1.5 实战内存容量检测
将mbr.S
中的jmp LOADER_BASE_ADDR
改为jmp LOADER_BASE_ADDR + 0x300
,
将 loader.S 改为下述代码:
1 | %include "boot.inc" |
代码说明:
- 第 28 行定义了 4 字节的变量 total_mem_bytes,此变量用于存储获取到的内存容量,以字节为单位;
ards_buf
的 244 字节是凑出来的,无实际意义,是为了让loader_start
在文件内的偏移地址是0x100+0x200=0x300
,是个整数。- 每执行一次
int 0x15
后,寄存器 eax、ebx、ecx 斗湖更新。eax 的值由之前的子功能号变成了字符串 SMAP 的 ASCII 码,ebx 为新的后续值,ecx 为实际写入缓冲区的中的字节数。其中 ebx 不用干涉,原封不动地作为输入即可。eax 和 ecx 寄存器每次调用前都要更新为正确的输入参数,所以放在了循环体中。接下来每得到一个 ARDS 结构后,便将 di 增加一个 ARDS 结构大小(这里是 20 字节),以指向缓冲区中国你的下一个 ARDS 存放的位置,然后将变量 ards_nr 加 1,以记录 ARDS 的个数,用于在后面的代码中遍历所有 ARDS,找出最大内存块。 - 56~69 行是找出最大的内存块。思路是对每一个 ARDS 结构中的 BaseAddrLow 与 LengthLow 相加求和,遍历完所有 ARDS,值最大的则为内存容量,由于 BaseAddrLow+LengthLow 的单位是字节而无需转换,之后便直接跳转到.mem_get_ok,将此容量数写入变量 total_mem_bytes。由于三种方法探测到的内存容量都是统一跳转到.mem_get_ok 处以字节形式写入到变量 total_mem_bytes,所以三种方法中内存容量都要用 edx 来保存。
执行 bochs:
bochs -f bochsrc.disk
c
继续执行Ctrl + c
中断运行xp 0xb00
得到的结果为0x02000000
,换算为十进制即为 32MB,这与 bochsrc.disk 中megs
参数配置的内存大小相同。
5.2 启用内存分页机制,畅游虚拟空间
5.2.1 内存为什么要分页
略
5.2.2 一级页表
分页机制的作用有两方面
- 将线性地址转换成物理地址。
- 用大小相等的页代替大小不等的段。
32 位地址表示 4GB 空间,可以将 32 位地址分成高低两部分,低地址部分是内存块大小
,高地址部分是内存块数量
,它们满足:内存块数*内存块大小=4GB。
页是地址空间的计量单位,并不是专属物理地址或线性地址,只要是 4KB 的地址空间都可以称为一 页,所以线性地址的一页也要对应物理地址的一页。一页大小为 4KB ,这样一来, 4GB 地址空间被划分 4GB/4KB=1M 个页,也就是 4GB 空间中可以容纳 1048576 个页,页表中自然也要有 1048576 个页表项, 这就是我们要说的一级页表。一级页表如图 5-11 内存 所示。
所以虚拟地址的高 20 位用来定位一个物理页,低 12 位用来在该物理页内寻址,那么怎样用线性地址找到页表中对应的页表项呢?
在此之前,我们要知道两件事:
- 分页机制打开前要将页表地址加载到控制寄存器
cr3
中,这是启用分页机制的先决条件之一,所以寄存器cr3
中的是页表的物理地址,页表中的页表项自然也是物理地址了;- 虽然内存分页机制的作用是将虚拟地址转换成物理地址,但其转换过程相当于在关闭分页机制下 进行,过程中所涉及到的页表及页表项的寻址,它们的地址都被 CPU 当作最终的物理地址直接送上地址总线,不会被分页机制再次转换。
因此地址转换过程原理如下:
物理地址 = 线性地址的高 20 位 * 4 + cr3
中的页表物理地址 + 线性地址的低 12 位
由于地址转换算法是固定的,故 CPU 中集成了专门用来干这项工作的硬件模块,这个模块被称为页部件
,举个例子,以mov ax, [0x1234]
来说:
假设在平坦模型下工作,无论段选择子的值是多少,其所指的段基址都是 0。故指令中的有效地址0x1234
,其“段基址:段内偏移地址”为0:0x1234
,经过段部件处理后,输出的线性地址是0x1234
。此线性地址被送入页部件。页部件分析地址的高 20 位为0x00001
,低 12 位为0x234
。将高 20 位作为页表项索引,再将其*4+cr3
中页表的物理地址,得到索引指代的页表项的物理地址,从该物理地址中读取物理页的地址0x9000
,将物理页地址和线性地址低 12 位相加得到0x9234
,此为线性地址最终转换为的物理地址。
5.2.3 二级页表
为什么要搞二级页表?
- 一级页表中最多可容纳 1M (1048576)个页表项,每个页表项是 4 字节,如果页表项全满的话, 便是 4MB 大小;
- 一级页表中所有页表项必须要提前建好,原因是操作系统要占用 4GB 虚拟地址空间的高 1GB, 用户进程要占用低 3GB;
- 每个进程都有自己的页表,进程一多,光是页表占用的空间就很可观了。
归根结底,我们要解决的是:不要一次性地将全部页表项建好,需要时动态创建页表项。
无论是几级页表,标准页的尺寸都是 4KB。所以 4GB 线性地址空间最多有 1M 个标准页。一级页表是将这 1M 个标准页放到一张页表中,二级页表是每个页表包含 1K 个页表项,共放置到 1K 个页表中。页表项大小是 4B,页表包含 1K 个页表项,故一个页表大小 4KB,这刚好是一个标准页的大小。而 1K 个页表的物理地址又都存放在页目录项中,故页目录项的大小也是 4KB。
所以页目录表和页表同样可以存于内存中,它们的内存会由操作系统在物理内存中分配和释放。
故二级页表的地址转换步骤如下:
物理地址 = [线性地址的中 10 位*4 + [线性地址的高 10 位 *4 + PDT 物理地址] ] + 线性地址的低 12 位
例如寻址0x1234567
:
每个进程都有自己的页表,另外任务切换时页表也需要跟着切换。
页目录项(PDE)和页表项(PTE)的结构:
其中 12-31 位,共 20 位用来存放物理地址,剩余 0-11 位可用来添加其他属性:
属性 | 属性含义 | 解析 |
---|---|---|
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 表示此项采用通写方式,表示该页不仅是普通内存,还是高速缓存。此项和高速缓存有关,“通写”是高速缓存的一种工作方式,本位用来间接决定是否用此方式改善该页的访问效率。这里咱们置为 0 |
PCD | Page-level Cache Disable,页级高速缓存禁止位 | 为 1 表示该页启用高速缓存,为 2 表示禁止将该页缓存。这里咱们置为 0 |
A | Accessed,访问位 | 为 1 表示该页被 CPU 访问过 |
D | Dirty,脏页位 | 当 CPU 个页面执行写操作时,就会设置对应页表项的 D 位为 1。此项仅对页表项有效 |
PAT | Page Attribute Table,页属性表位 | 能够在页面一级的粒度上设置内存属性。比较复杂,置 0 即可 |
G | Global,全局位 | 为 1 表示是全局页,该页会一直在高速缓存 TLB 中保存,为 0 表示不是全局页 |
AVL | Available,可用位 | 操作系统可用该位, CPU 不理会该位的值 |
清空 TLB 有两种方式:
- 用
invlpg
指令针对单独虚拟地址条目清理- 重新加载 cr3 寄存器,这将直接清空 TLB
启用分页机制的步骤
- 准备好页目录表和页表
- 将页表地址写入控制寄存器
cr3
- 寄存器
cr0
的 PG 位置 1
控制寄存器cr3
用于存储页表物理地址,所以cr3
基础怒气又被称为页目录基址寄存器(Page Directory Base Register,PDBR)
由于页目录表所在的地址要求在一个自然页内,即页目录的起始地址是 4KB 的整数倍,故其低 12 位地址全是 0,所以只需要在 cr3 中存储物理地址的高 20 位即可。另外 cr3 的低 12 位中,只有 PWT 和 PCD 可用。
因为控制寄存器是可以与通用寄存器互相传递数据的,所以为 cr3 寄存器赋值可以用现成的mov
指令,例如:mov cr[0~7], r32 或 mov r32, cr[0~7]
。
最后将cr0
寄存器的PG
位置1
,系统便进入了内存分页运行机制,段部件输出的线性地址成为虚拟地址。
5.2.4 规划页表之操作系统与用户进程的关系
分页的第一步就是要准备一个页表,所以现在我们要设计一个页表。设计页表其实就是设计内存布局。
由于用户进程是共享操作系统的,所以页表要满足一个基本要求:共享。只要让操作系统属于用户进程的虚拟地址空间就好了。我们可以把 4GB 虚拟地址空间分成两部分,一部分专门划给操作系统,另一部分就归用户进程使用。
这里我们可以学习 Linux 的做法,虚拟地址空间的 0~3GB 是用户进程,3GB~4GB 是操作系统。
5.2.5 启用分页机制
页表将按照如下方式部署:
页目录表的位置我们放在物理地址0x100000
处为了让页表和页目录表紧凑一些(非必须),咱们让页表紧挨着页目录表,由于页目录表本身占 4KB,所以第一个页表的物理地址是0x101000
。
代码改动
boot.inc 新增:
1 | ;---------- loader 和 kernel ---------- |
下面是关于分页功能的函数,只是为了建立页目录表和页表:
1 | ;1.-------- 创建页目录和页表 -------- |
-
181~ 189 行是清空页目录表
-
191~ 205 行是创建页目录项
为什么要在两处指向同一个页表?
是因为在加载内核前,程序运行的一直都是 loader,它本身的代码都是在 1MB 以内,必须保证之前段机制下的线性地址和分页后的虚拟地址对应到物理地址一致。
第 0 页目录项代表的页表大小为 4MB,表示空间为0~0x3fffff
,包括了 1MB(0~0xffffff),所以用第 0 项来保证 loader 在分页机制下依然运行正确。
放到 768 项是因为我们将来会把操作系统内核放在低端 1M 物理内存空间,但操作系统的虚拟地址是0xc0000000
以上,即该虚拟地址对应的页目录项是第 768 个。第 768 项表示的空间是0xc0000000~0xc03fffff
,其包含了操作系统内核所占的低端 1MB 物理内存,从而实现了操作系统高 3GB 以上的虚拟地址对应了低端 1MB。 -
204 ~ 205 行是在页目录的最后二个页目录项中写入页表自己的物理地址
目的是为了将来能够动态操作页表
使用PG_US_U
是因为将来会实现 init 进程,它是用户级程序但它位于内核地址空间,也就是将来会在特权级 3 下执行 init,这会访问到内核空间。 -
207 ~ 215 行是创建页表
由于目前只用到了 1MB 空间,所以我们只为这 1MB 空间分配物理页,即需要分配 1MB/4KB = 256 个页表项。
-
217 ~ 228 行创建除第 768 个页表之外的其他页表对应的 PDE,也就是内核空间中除第 0 个页表外的其余所有页表对应的目录项。
第 0 个 PDE 刚刚已经创建,第 255 个 PDE 指向了页目录表本身,故在页目录表中还可以为内核额外安装 254 个 PDE。
当然必须要为页表中具体的 PTE 分配物理页框后才算真正的内存空间,此处还不算,此处在 页目录表中把内核空间的目录项写满,目的是为将来的用户进程做准备,使所有用户进程共享内核空间。
这里还需解释下,我们将来要完成的任务是让每个用户进程都有独立的页表,也就是独立的虚拟 4GB 空间。其中低 3GB 属于用户进程自己的空间,高 1GB 是内核空间,内核将被所有用户进程共享。为了实现所有用户进程共享内核,各用户进程的高 1GB 必须“都”指向内核 所在的物理内存空间,也就是说每个进程页目录表中第 768 ~ 1022 个页目录项都是与其他进程相同的(各进程页目录表中第 1023 个目录项指向页目录表自身)。因此在为用户进程创建页表时,我们应该把内核页表中第 768 ~ 1022 个页目录项复制到用户进程页目录表中的相同位置。
一个页目录项对应一个页表地址,页表地址固定了,后来新增的页表项也只会加在这些固定的页表中。如果不这样的话,进程陷入内核时, 假设内核为了某些需求为内核空间新增页表(通常是申请大量内存),因此还需要把新内核页表同步到其他进程的页表中,否则内核无法被“完全”共享,只能是“部分”共享。。所以,实现内核完全共享最简单的办法是提前把内核的所有页目录项定下来,也就是提前把内核的页表固定下来,这是实现内核共享的关键。
p_load_start 内容改动:
1 | p_mode_start: |
- 第 14 ~ 15 行,是为了重启加载 GDT 做准备。因为我们在页表中会将内核放置到 3GB 以上的地址,我们也把 GOT 放在内核的地址空间,在此通过 sgdt 指令,将 GOT 的起始地址和偏移量信息 dump (像倒水一样)出来,依然存放到 gdt_ptr 处, 一会儿待条件成熟时,我们再从地址 gdt_ptr 处重新加载 GDT;
- 第 17 ~ 20 行是修改显存段的段描述符的段基址,因为将来内核运行在 3GB 以上,打印功能将来也是在内核中实现,肯定不能让用户进程直接能控制显存。故显存段的段基址也要改为 3GB 以上才行。 大家都知道 32 位虚拟地址空间共 4GB,若用十六进制表示,最高位(第 31 位)每变化 4 位就表示 1GB 空间,也没什么高深的,其实就是 16 位/4GB=4 位/1GB,意为每 1GB 内存空间需要 4 位来表示;
- 第 22 ~ 23 行将 gdt 基址移到内核空间;
- 第 24 行将栈地址移到内核空间;
- 第 26 ~ 33 行是启用分页机制的第 2/3 步,即将页目录地址赋值给
cr3
寄存器,启用cr0
寄存器的 PG 位; - 第 37 ~ 39 行是为了检查在分页急之下程序是否工作正常,在 39 行直接写入字符"V",但这里书中存在问题,寄存器没刷新直接写入就没有往虚拟内存里写入了,故这里需要重新初始化 gs 寄存器,显示结果:
5.2.6 用虚拟地址访问页表
使用info tab
可以看到页表中虚拟地址到物理地址的映射关系。(info
是用来查看各种数据的命令,tab 是指页表)。
下图为我们目前的虚拟地址映射情况:
其中前两行的映射是第 0 个页表和第 768 个页表的作用,后三行是由于将页目录表写入了最后一个页目录项,也就是mov [PAGE_DIR_TABLE_POS + 4092], eax
的作用。
针对后三个映射的分析:
0x00000000ffc00000-0x00000000ffc00fff -> 0x000000101000-0x000000101fff
:0xffc00000
高 10 位全为 1,即 1111111111b=0x3ff=1023,则此为内核空间页目录项的第 1023 项,此目录项存放的是页目录项的地址;中间 10 位全为 0,即检索到第 0 个页表项,此时检索的页表项即为页目录表的第 0 个页目录项,其中记录的是第一个页表的物理地址0x101000
,故该值被认定为最终的物理页地址;由于低 12 位为全 0,故最终的物理地址为0x101000+0x000 = 0x101000
。0xffc00fff
同理。0x00000000fff00000-0x00000000ffffefff -> 0x000000101000-0x0000001fffff
:0xfff00000
高 10 位全为 1,中间 10 位为 1100000000b=0x300,此为第 768 个页目录项,该目录项指向的页表与第 0 个页目录项相同,故该地址指向的物理地址与上面相同。0x00000000fffff000-0x00000000ffffffff -> 0x000000100000-0x000000100fff
:0xfffff000
高十位全为 1,中间十位也全为 1,故检索的是第 1023 个页目录项对应页表的第 1023 个页表项,第 1023 个页目录项的物理地址是页目录项自身的物理地址0x00100000
,也就是找页目录项对应的第 1023 个页表项,即为页目录项第 1023 个页目录项,仍为页目录项的物理地址0x00100000
,又由于低 12 位全为 0,故最终物理地址为:0x00100000 + 0x000 = 0x00100000
,0x00100fff
同理。
总结:若虚拟地址的高 20 位为**0xfffff**
,那么经过页目录表的映射,将会访问到页目录表自身的物理地址。
所以用虚拟地址获取页表各数据类型的方法:
- 获取页目录表物理地址:让虚拟地址的高 20 位为 0xfffff,低 12 位为 0x000,即
0xfffff000
,这是页目录表中第 0 个页目录项自身的物理地址; - 访问页目录中的页目录项,即获取页表物理地址:要使虚拟地址为
0xfffffxxx
,其中 xxx 是页目录项的索引*4 的积; - 访问页表中的页表项:要使虚拟地址高 10 位为 0x3ff,目的是获取页目录表物理地址。中间 10 位为页表的索引,因为是 10 位的索引值,所以这里不用乘以 4。低 12 位为页表内的偏移地址,用来定位页表项,它必须是己经乘以 4 后的值。公式为:
0x3ff<<22 + 中间10位<<12 + 低12位
5.2.7 快表 TLB(Translation Lookaside Buffer)简介
专门用来存放虚拟地址页框与物理地址页框的映射关系,这个调整缓存就是 TLB,俗称快表。
TLB 中的条目是虚拟地址的高 20 位到物理地址高 20 位的映射结果,实际上就是从虚拟页框到物理页框的映射。除此之外 TLB 中还有一些属性位,比如页表项的 RW 属性。
有了 TLB,处理器在寻址之前会用虚拟地址的高 20 位作为索引来查找 TLB 中的相关条目,如果命中则返回虚拟地址所映射的物理页框地址,否则会查询内存中的页表,获得页框物理地址后再更新 TLB。
缓存相当于数据源的快照,为了保证缓存与数据源同步变化,这就涉及到缓存刷新的问题。目前 TLB 并不自动更新,处理器也不负责,而是把维护工作交给操作系统开发人员。
尽管 TLB 对开发人员不可见,但依然有两种方法可以间接更新 TLB:
- 一是针对 TLB 中所有条目的方法一一重新加载 CR3,比如将 CR3 寄存器的数据读出来后再写入 CR3,这会使整个 TLB 失效。
- 二是方法是针对 TLB 中某个条目的更新。处理器提供了指令 invlpg (invalidate page),它用于在 TLB 中刷新某个虚拟地址对应的条目,处理器是用虚拟地址来检索 TLB 的,因此很自然地,指令 invlpg 的操作数也是虚拟地址,其指令格式为
invlpg m
。