Songqian Li's Blog

去历史上留点故事

从这一刻起,我们才算开始了真正的操作系统学习之旅

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)。
image.png
此结构中的字段大小都是 4 字节,共 5 个字段,所以一个描述符大小为 20 字节。每次 int 0x15 之后,BIOS 就返回这样一个结构的数据。其中 Type 字段用来描述这段内存的类型,即是否可被操作系统使用,具体意义见下表。
image.png
为什么 BIOS 会按类型来返回内存信息呢?原因是这段内存可能是:

  1. 系统的 ROM
  2. ROM 用到了这部分内存
  3. 设备内存映射到了这部分内存
  4. 由于某种原因,这段内存不适合标准标准设备使用

由于我们目前在 32 位环境下工作,所以在 ARDS 结构属性中,我们只用到低 32 位属性。BaseAddrLow+LengthLow是一片内存区域上限,单位是字节。正常情况下,不会出现较大的内存区域不可用的情况,除非安装的物理内存极其小。所以这意味着在所有返回的 ARDS 结构里,此值最大的内存块一定是操作系统可使用的部分,即主板上配置的物理内存容量。
BIOS 中断只是一段函数例程,调用它就要为其提供参数,下面介绍一下0x820子功能的参数。
image.png
image.png
ECX 和 ES:DI 寄存器,是典型的“值-结果”型参数。
中断的调用步骤:

  1. 填写好“调用前输入”中列出的寄存器;
  2. 执行中断调用 int 0x15
  3. 在 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 以上空间的内存实际大小=BX
64*1024。
image.png
此中断的调用步骤:

  1. 将 AX 寄存器写入 0xE801;
  2. 执行中断调用 int 0x15;
  3. 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便有对应的结果。

下面我们解答两个问题:

  1. 为什么要分“前 15MB”和“ 16MB 以上”这两部分来展示 4GB 内存?

历史遗留问题。

80286 拥有 24 位地址线,其寻址空间是 16MB。当时有一些 ISA 设备要用到地址 15~但以上的内存作为缓冲区,也就是此缓冲区为山田大小,所以硬件系统 就把这部分内存保留下来,操作系统不可以用此段内存空间。
保留的这部分内存区域就像不可以访问的黑洞, 这就成了内存空洞memory hole,虽然现在很少能碰到这些老 ISA 设备了,但为了兼容这部分空间还是保留下来,只不过可以通过 BIOS 选项的方式由用户自己选择是否开启。

  1. 为什么寄存器结果是重复的?如寄存器 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。
image.png
中断返回后,AX 寄存器的值的单位是 1KB,调用步骤如下:

  1. 将 AX 寄存器(表格中有错误)写入 0x88;
  2. 执行中断调用 int 0x15
  3. 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便有对应的结果。

5.1.5 实战内存容量检测

mbr.S中的jmp LOADER_BASE_ADDR改为jmp LOADER_BASE_ADDR + 0x300
将 loader.S 改为下述代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
%include "boot.inc"
SECTION LOADER vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR

;构建 GDT 及其内部的描述符
GDT_BASE: dd 0x00000000
dd 0x00000000

CODE_DESC: dd 0x0000FFFF
dd DESC_CODE_HIGH4

DATA_STACK_DESC: dd 0x0000FFFF ;直接用普通的数据段作为栈段
dd DESC_DATA_HIGH4

VIDEO_DESC: dd 0x80000007 ;limit=(0xbffff - 0xb8000)/4k = 7
dd DESC_VIDEO_HIGH4;此时dpl为0

GDT_SIZE equ $ - GDT_BASE ;获取 GDT 大小
GDT_LIMIT equ GDT_SIZE - 1 ;获取 段界限
times 60 dq 0 ;预留60个空位,为以后填入中断描述符表和任务状态段TSS描述符留空间 (times 60 表示后面的内容循环60次,是nasm提供的伪指令)
SELECTOR_CODE equ (0x0001 << 3) + TI_GDT + RPL0 ; 相当于(CODE DESC - GOT BASE) /8 + TI_GDT + RPLO
SELECTOR_DATA equ (0x0002 << 3) + TI_GDT + RPL0
SELECTOR_VIDEO equ (0x0003 << 3) + TI_GDT + RPL0

; total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记。
; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
total_mem_bytes dd 0
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

;以下是 gdt 指针,前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
;loadermsg db '2 loader in real.'
;人工对齐:total_mem_bytes 4字节 + gdt_ptr 6字节 + ards_buf 244字节 + ards_nr 2字节 , 共256字节
ards_buf times 244 db 0
ards_nr dw 0 ;用于记录ards结构体数量

loader_start:

;------- int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局 -------

xor ebx, ebx ;第一次调用时,ebx值要为0
mov edx, 0x534d4150 ;edx只赋值一次,循环体中不会改变
mov di, ards_buf ;ards结构缓冲区
.e820_mem_get_loop: ;循环获取每个ARDS内存范围描述结构
mov eax, 0x0000e820 ;执行int 0x15后,eax值变为0x534d4150,所以每次执行int前都要更新为子功能号。
mov ecx, 20 ;ARDS地址范围描述符结构大小是20字节
int 0x15
jc .e820_failed_so_try_e801 ;若cf位为1则有错误发生,尝试0xe801子功能
add di, cx ;使di增加20字节指向缓冲区中新的ARDS结构位置
inc word [ards_nr] ;记录ARDS数量
cmp ebx, 0 ;若ebx为0且cf不为1,这说明ards全部返回,当前已是最后一个
jnz .e820_mem_get_loop

;在所有ards结构中,找出(base_add_low + length_low)的最大值,即内存的容量。
mov cx, [ards_nr] ;遍历每一个ARDS结构体,循环次数是ARDS的数量
mov ebx, ards_buf
xor edx, edx ;edx为最大的内存容量,在此先清0
.find_max_mem_area: ;无须判断type是否为1,最大的内存块一定是可被使用
mov eax, [ebx] ;base_add_low
add eax, [ebx+8] ;length_low
add ebx, 20 ;指向缓冲区中下一个ARDS结构
cmp edx, eax ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
jge .next_ards
mov edx, eax ;edx为总内存大小
.next_ards:
loop .find_max_mem_area
jmp .mem_get_ok

;------ int 15h ax = E801h 获取内存大小,最大支持4G ------
; 返回后, ax cx 值一样,以KB为单位,bx dx值一样,以64KB为单位
; 在ax和cx寄存器中为低16M,在bx和dx寄存器中为16MB到4G。
.e820_failed_so_try_e801:
mov ax,0xe801
int 0x15
jc .e801_failed_so_try88 ;若当前e801方法失败,就尝试0x88方法

;1 先算出低15M的内存,ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
mov cx,0x400 ;cx和ax值一样,cx用做乘数
mul cx
shl edx,16
and eax,0x0000FFFF
or edx,eax
add edx, 0x100000 ;ax只是15MB,故要加1MB
mov esi,edx ;先把低15MB的内存容量存入esi寄存器备份

;2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
xor eax,eax
mov ax,bx
mov ecx, 0x10000 ;0x10000十进制为64KB
mul ecx ;32位乘法,默认的被乘数是eax,积为64位,高32位存入edx,低32位存入eax.
add esi,eax ;由于此方法只能测出4G以内的内存,故32位eax足够了,edx肯定为0,只加eax便可
mov edx,esi ;edx为总内存大小
jmp .mem_get_ok

;----------------- int 15h ah = 0x88 获取内存大小,只能获取64M之内 ----------
.e801_failed_so_try88:
;int 15后,ax存入的是以kb为单位的内存容量
mov ah, 0x88
int 0x15
jc .error_hlt
and eax,0x0000FFFF

;16位乘法,被乘数是ax,积为32位.积的高16位在dx中,积的低16位在ax中
mov cx, 0x400 ;0x400等于1024,将ax中的内存容量换为以byte为单位
mul cx
shl edx, 16 ;把dx移到高16位
or edx, eax ;把积的低16位组合到edx,为32位的积
add edx,0x100000 ;0x88子功能只会返回1MB以上的内存,故实际内存大小要加上1MB

.mem_get_ok:
mov [total_mem_bytes], edx ;将内存换为byte单位后存入total_mem_bytes处。
;-------------- 准备进入保护模式 -----------------------
;1 打开A20
;2 加载gdt
;3 将cr0的pe位置1


;------------ 1. 打开A20 ------------------
in al, 0x92
or al, 00000010B
out 0x92, al

;------------ 2.加载GDT ------------
lgdt [gdt_ptr]


;------------ 3.将CR0的PE位置1 ------------
mov eax, cr0
or eax, 0x00000001
mov cr0, eax

jmp dword SELECTOR_CODE:p_mode_start ;刷新流水线 流水线是CPU 的工作方式,会把当前指令和后面的几个指令同时放在流水线中重叠执行,由于之前的代码是16位,接下来的代码变成32位了,指令按照16位进行译码会出错,通过刷新流水线可以解决这个问题

.error_hlt: ;出错则挂起
hlt

[bits 32] ;编译成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:160],'P'

jmp $

代码说明:

  1. 第 28 行定义了 4 字节的变量 total_mem_bytes,此变量用于存储获取到的内存容量,以字节为单位;
  2. ards_buf的 244 字节是凑出来的,无实际意义,是为了让loader_start在文件内的偏移地址是0x100+0x200=0x300,是个整数。
  3. 每执行一次int 0x15后,寄存器 eax、ebx、ecx 斗湖更新。eax 的值由之前的子功能号变成了字符串 SMAP 的 ASCII 码,ebx 为新的后续值,ecx 为实际写入缓冲区的中的字节数。其中 ebx 不用干涉,原封不动地作为输入即可。eax 和 ecx 寄存器每次调用前都要更新为正确的输入参数,所以放在了循环体中。接下来每得到一个 ARDS 结构后,便将 di 增加一个 ARDS 结构大小(这里是 20 字节),以指向缓冲区中国你的下一个 ARDS 存放的位置,然后将变量 ards_nr 加 1,以记录 ARDS 的个数,用于在后面的代码中遍历所有 ARDS,找出最大内存块。
  4. 56~69 行是找出最大的内存块。思路是对每一个 ARDS 结构中的 BaseAddrLow 与 LengthLow 相加求和,遍历完所有 ARDS,值最大的则为内存容量,由于 BaseAddrLow+LengthLow 的单位是字节而无需转换,之后便直接跳转到.mem_get_ok,将此容量数写入变量 total_mem_bytes。由于三种方法探测到的内存容量都是统一跳转到.mem_get_ok 处以字节形式写入到变量 total_mem_bytes,所以三种方法中内存容量都要用 edx 来保存。

执行 bochs:

  1. bochs -f bochsrc.disk
  2. c 继续执行
  3. Ctrl + c中断运行
  4. xp 0xb00

得到的结果为0x02000000,换算为十进制即为 32MB,这与 bochsrc.disk 中megs参数配置的内存大小相同。
image.png

5.2 启用内存分页机制,畅游虚拟空间

5.2.1 内存为什么要分页

5.2.2 一级页表

分页机制的作用有两方面

  1. 将线性地址转换成物理地址。
  2. 用大小相等的页代替大小不等的段。

image.png
32 位地址表示 4GB 空间,可以将 32 位地址分成高低两部分,低地址部分是内存块大小,高地址部分是内存块数量,它们满足:内存块数*内存块大小=4GB。
页是地址空间的计量单位,并不是专属物理地址或线性地址,只要是 4KB 的地址空间都可以称为一 页,所以线性地址的一页也要对应物理地址的一页。一页大小为 4KB ,这样一来, 4GB 地址空间被划分 4GB/4KB=1M 个页,也就是 4GB 空间中可以容纳 1048576 个页,页表中自然也要有 1048576 个页表项, 这就是我们要说的一级页表。一级页表如图 5-11 内存 所示。
image.png
所以虚拟地址的高 20 位用来定位一个物理页,低 12 位用来在该物理页内寻址,那么怎样用线性地址找到页表中对应的页表项呢?

在此之前,我们要知道两件事:

  1. 分页机制打开前要将页表地址加载到控制寄存器 cr3 中,这是启用分页机制的先决条件之一,所以寄存器cr3中的是页表的物理地址,页表中的页表项自然也是物理地址了;
  2. 虽然内存分页机制的作用是将虚拟地址转换成物理地址,但其转换过程相当于在关闭分页机制下 进行,过程中所涉及到的页表及页表项的寻址,它们的地址都被 CPU 当作最终的物理地址直接送上地址总线,不会被分页机制再次转换。

因此地址转换过程原理如下:
物理地址 = 线性地址的高 20 位 * 4 + cr3中的页表物理地址 + 线性地址的低 12 位
由于地址转换算法是固定的,故 CPU 中集成了专门用来干这项工作的硬件模块,这个模块被称为页部件,举个例子,以mov ax, [0x1234]来说:
image.png
假设在平坦模型下工作,无论段选择子的值是多少,其所指的段基址都是 0。故指令中的有效地址0x1234,其“段基址:段内偏移地址”为0:0x1234,经过段部件处理后,输出的线性地址是0x1234。此线性地址被送入页部件。页部件分析地址的高 20 位为0x00001,低 12 位为0x234。将高 20 位作为页表项索引,再将其*4+cr3中页表的物理地址,得到索引指代的页表项的物理地址,从该物理地址中读取物理页的地址0x9000,将物理页地址和线性地址低 12 位相加得到0x9234,此为线性地址最终转换为的物理地址。

5.2.3 二级页表

为什么要搞二级页表?

  1. 一级页表中最多可容纳 1M (1048576)个页表项,每个页表项是 4 字节,如果页表项全满的话, 便是 4MB 大小;
  2. 一级页表中所有页表项必须要提前建好,原因是操作系统要占用 4GB 虚拟地址空间的高 1GB, 用户进程要占用低 3GB;
  3. 每个进程都有自己的页表,进程一多,光是页表占用的空间就很可观了。

归根结底,我们要解决的是:不要一次性地将全部页表项建好,需要时动态创建页表项。
无论是几级页表,标准页的尺寸都是 4KB。所以 4GB 线性地址空间最多有 1M 个标准页。一级页表是将这 1M 个标准页放到一张页表中,二级页表是每个页表包含 1K 个页表项,共放置到 1K 个页表中。页表项大小是 4B,页表包含 1K 个页表项,故一个页表大小 4KB,这刚好是一个标准页的大小。而 1K 个页表的物理地址又都存放在页目录项中,故页目录项的大小也是 4KB。
所以页目录表和页表同样可以存于内存中,它们的内存会由操作系统在物理内存中分配和释放。
image.png
故二级页表的地址转换步骤如下:
物理地址 = [线性地址的中 10 位*4 + [线性地址的高 10 位 *4 + PDT 物理地址] ] + 线性地址的低 12 位
例如寻址0x1234567
image.png
每个进程都有自己的页表,另外任务切换时页表也需要跟着切换。

页目录项(PDE)和页表项(PTE)的结构:

image.png
其中 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 有两种方式:

  1. invlpg 指令针对单独虚拟地址条目清理
  2. 重新加载 cr3 寄存器,这将直接清空 TLB

启用分页机制的步骤

  1. 准备好页目录表和页表
  2. 将页表地址写入控制寄存器cr3
  3. 寄存器cr0的 PG 位置 1

控制寄存器cr3用于存储页表物理地址,所以cr3基础怒气又被称为页目录基址寄存器(Page Directory Base Register,PDBR)
image.png
由于页目录表所在的地址要求在一个自然页内,即页目录的起始地址是 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 启用分页机制

页表将按照如下方式部署:
image.png
页目录表的位置我们放在物理地址0x100000处为了让页表和页目录表紧凑一些(非必须),咱们让页表紧挨着页目录表,由于页目录表本身占 4KB,所以第一个页表的物理地址是0x101000

代码改动

boot.inc 新增:

1
2
3
4
5
6
7
8
;---------- loader 和 kernel ----------
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

下面是关于分页功能的函数,只是为了建立页目录表和页表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
;1.-------- 创建页目录和页表 --------
setup_page:
;先把页目录占用的空间逐字节清 0
mov ecx, 4096
xor esi, esi
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi
loop .clear_page_dir

;2.创建页目录项(Page Directory Entry)
.create_pde:
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000 ;第一个页表的位置(仅次于页目录表,页目录表大小4KB)
mov ebx ,eax ;0x00101 000

;下面将页目录项0和OxcOO都存为第一个页表的地址 ,每个页表表示4MB内存
;这样Oxc03fffff(3G-3G04M)以下的地址和Ox003fffff(0-4M)以下的地址都 指向相同的页表
;这是为将地址映射为内核地址做准备
or eax, PG_US_U | PG_RW_W | PG_P ;用户特权级,可读可写,存在内存
mov [PAGE_DIR_TABLE_POS + 0x0] , eax ;第一个目录项,0x00101 007
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ;第0xc00高10位0x300=768个页表占用的目录项,0xc00以上属于kernel空间,这里是把第769个目录页和第1个目录页指向同一个页表的物理地址:0x101000
;系统实际位于0~0x100000内存地址中,将系统虚拟地址0xc00000000映射到这低1M的空间内,只需要让0xc0000000的地址指向和低1M相同的页表即可

sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS + 4092], eax ;使最后一个目录项指向页目录表自己的位置

;3.创建页表项(Page Table Entry)
mov ecx, 256 ;1M低端内存/每页大小4K = 256
mov esi, 0
mov edx, PG_US_U | PG_RW_W | PG_P ;地址为0x0,属性为7,111b
;这个页表项提供map地址的范围是0x0~0x100000,也就是低端1M
.create_pte:
mov [ebx+esi*4], edx
add edx, 0x1000
inc esi
loop .create_pte ;低端1M内存中,物理地址=虚拟地址,这里创建了1M空间的页表项

;创建内核其他页表的PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ;第二个页表
or eax, PG_US_U | PG_RW_W | PG_P ;111b
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
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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:160],'P'
;创建页目录和页表并初始化页内存位图
call setup_page

;gdt需要放在内核里将描述符表地址&偏移量写入内存gdt_ptr,一会用新的地址加载
sgdt [gdt_ptr] ;取出GDT地址和偏移信息,存放在gdt_ptr这个内存位置上

;视频段需要放在内核里与用户进程进行共享
;将gdt描述符中视频段的段基址+0xc0000000
mov ebx, [gdt_ptr + 2] ;这里gdt_ptr前2字节是偏移量,后4字节是GDT基址,先选中GDT
or dword [ebx + 0x18 + 4], 0xc0000000 ;一个描述符8字节,0x18处是第3个段描述符也就是视频段,修改段基址最高位为C,+4进入高4字节,用or修改即可

;将gdt的基址加上 0xc0000000 成为内核所在的地址
add dword [gdt_ptr + 2], 0xc0000000
add esp, 0xc0000000 ;将栈指针同样map到内核地址

;页目录赋值给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 ax, SELECTOR_VIDEO ;这里书上存在问题,寄存器没刷新直接写入,就没往虚拟内存里写入了,这里需要重新初始化一下gs寄存器
mov gs, ax
mov byte [gs:320], 'V'
jmp $
  • 第 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 寄存器,显示结果:

image.png

5.2.6 用虚拟地址访问页表

使用info tab可以看到页表中虚拟地址到物理地址的映射关系。(info
是用来查看各种数据的命令,tab 是指页表)。
下图为我们目前的虚拟地址映射情况:
image.png
其中前两行的映射是第 0 个页表和第 768 个页表的作用,后三行是由于将页目录表写入了最后一个页目录项,也就是mov [PAGE_DIR_TABLE_POS + 4092], eax的作用。
针对后三个映射的分析:

  1. 0x00000000ffc00000-0x00000000ffc00fff -> 0x000000101000-0x000000101fff0xffc00000高 10 位全为 1,即 1111111111b=0x3ff=1023,则此为内核空间页目录项的第 1023 项,此目录项存放的是页目录项的地址;中间 10 位全为 0,即检索到第 0 个页表项,此时检索的页表项即为页目录表的第 0 个页目录项,其中记录的是第一个页表的物理地址0x101000,故该值被认定为最终的物理页地址;由于低 12 位为全 0,故最终的物理地址为0x101000+0x000 = 0x1010000xffc00fff同理。
  2. 0x00000000fff00000-0x00000000ffffefff -> 0x000000101000-0x0000001fffff0xfff00000高 10 位全为 1,中间 10 位为 1100000000b=0x300,此为第 768 个页目录项,该目录项指向的页表与第 0 个页目录项相同,故该地址指向的物理地址与上面相同。
  3. 0x00000000fffff000-0x00000000ffffffff -> 0x000000100000-0x000000100fff0xfffff000高十位全为 1,中间十位也全为 1,故检索的是第 1023 个页目录项对应页表的第 1023 个页表项,第 1023 个页目录项的物理地址是页目录项自身的物理地址0x00100000,也就是找页目录项对应的第 1023 个页表项,即为页目录项第 1023 个页目录项,仍为页目录项的物理地址0x00100000,又由于低 12 位全为 0,故最终物理地址为:0x00100000 + 0x000 = 0x001000000x00100fff同理。

总结:若虚拟地址的高 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,俗称快表。
image.png
TLB 中的条目是虚拟地址的高 20 位到物理地址高 20 位的映射结果,实际上就是从虚拟页框到物理页框的映射。除此之外 TLB 中还有一些属性位,比如页表项的 RW 属性。
有了 TLB,处理器在寻址之前会用虚拟地址的高 20 位作为索引来查找 TLB 中的相关条目,如果命中则返回虚拟地址所映射的物理页框地址,否则会查询内存中的页表,获得页框物理地址后再更新 TLB。
缓存相当于数据源的快照,为了保证缓存与数据源同步变化,这就涉及到缓存刷新的问题。目前 TLB 并不自动更新,处理器也不负责,而是把维护工作交给操作系统开发人员。
尽管 TLB 对开发人员不可见,但依然有两种方法可以间接更新 TLB:

  1. 一是针对 TLB 中所有条目的方法一一重新加载 CR3,比如将 CR3 寄存器的数据读出来后再写入 CR3,这会使整个 TLB 失效。
  2. 二是方法是针对 TLB 中某个条目的更新。处理器提供了指令 invlpg (invalidate page),它用于在 TLB 中刷新某个虚拟地址对应的条目,处理器是用虚拟地址来检索 TLB 的,因此很自然地,指令 invlpg 的操作数也是虚拟地址,其指令格式为 invlpg m
相关文章
评论
分享
  • 《操作系统真象还原》:第八章 内存管理系统

    8.1 makefile 简介 这部分可参考阮一峰的讲解:https://www.ruanyifeng.com/blog/2015/02/make.html 8.1.1 makefile 是什么 makefile 是 Linu...

    《操作系统真象还原》:第八章 内存管理系统
  • 《操作系统真象还原》:第七章 中断

    7.1 中断是什么,为什么要有中断 运用中断能够显著提升并发,从而大幅提升效率。 7.2 操作系统是中断驱动的 略 7.3 中断分类 把中断按事件来源分类,来自 CPU 外部的中断就称为外部中断,来自 CPU 内部的中断称为内部...

    《操作系统真象还原》:第七章 中断
  • 《操作系统真象还原》:第六章 完善内核

    6.1 函数调用约定简介 咱们实验使用cdecl。这里提一下stdcall,cdecl与stdcall的区别在于由谁来回收栈空间。 stdcall是被调用者清理参数所占的栈空间。 举例来说: 12int subtract(int ...

    《操作系统真象还原》:第六章 完善内核
  • 《操作系统真象还原》:第五章 保护模式进阶——加载内核

    5.3 加载内核 5.3.1 用 C 语言写内核 第一个 C 语言代码: 1234int main(void) { while(1); return 0;} 这个内核文件什么都没做,通过while(1)这个死循...

    《操作系统真象还原》:第五章 保护模式进阶——加载内核
  • 《操作系统真象还原》:第四章 保护模式入门

    4.1 保护模式概述 在本章大家会见到全局描述符表、中断描述符表、各种门结构,这是 CPU 提供给应用的,咱们用好就行。 保护模式强调的是“保护”,它是在 Intel 80286 CPU 中首次出现,这是继 8086 之后,Inte...

    《操作系统真象还原》:第四章 保护模式入门
  • 《操作系统真象还原》:第三章 完善MBR——I/O接口

    3.3 让我们对显示器说点什么吧 3.3.1 CPU 如何与外设通信——IO 接口 IO 接口功能: 设置数据缓冲,解决 CPU 与外设的速度不匹配 设置信号电平转换电路 设置数据格式转换 设置时序控制电路来同步 CPU 和外部...

    《操作系统真象还原》:第三章 完善MBR——I/O接口
  • 《操作系统真象还原》:第三章 完善MBR——CPU的实模式

    针对汇编 几个知识点: 第 1 行和第 4 行的 mov 操作,机器码第 1 个宇节都是B8,而另外第 2、3 行同样是 mov 指令,机器码却有天壤之别,似乎找不到共性。原因是机器码是由很多部分组成的,比如指令前缀、主操作码字...

    《操作系统真象还原》:第三章 完善MBR——CPU的实模式
  • 《操作系统真象还原》:第二章 编写 MBR

    先了解 CPU 的两种工作模式:实模式和保护模式 实模式(英语:Real mode)是 Intel 80286 和之后的 x86 兼容 CPU 的操作模式。实模式的特性是一个 20 比特的区段存储器地址空间(意思为只有 1MB 的存...

    《操作系统真象还原》:第二章 编写 MBR
  • 《操作系统真象还原》:第一章 环境配置

    第 0 章:一些你可能正感到迷惑的问题 摘记 0.28 MBR、EBR、DBR 和 OBR 各是什么 MBR 位于整个硬盘最开始的块, EBR 位于每个子扩展分区,各子扩展分区中只有一个逻辑分区。 MBR 和 EBR 位于分...

    《操作系统真象还原》:第一章 环境配置
  • 6月阅读总结

    “零拷贝”技术 Sogou C++ Workflow:搜狗公司的 C++服务器引擎,支持 500k QPS Reducing CPU scheduler latency in Linux:CPU 调度算法 BMQ 和 CFS 的对比...

    6月阅读总结