保护模式进阶

获取内存

在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)。

image.png

地址范围描述符大小是4个字节,共5个字段,一共20个字节,每次调用中断0x15时就会返回这样一个奇似描述符的数据结构。ADRS用高低共64位的地址来描述这段内存的基地址,又用高低各32位共64位的地址来描述这段内存的长度,最后用Type来标识这段内存的类型,这里所谓的类型是说明这段内存的用途,即其是可以被操作系统使用,还是保留起来不能用。

image.png

由于在 32 位环境下工作,所以在 ARDS 结构属性中,我们只用到低 32 位属性。BaseAddrLow+LengthLow 是一片内存区域上限,单位是字节。

调用0xe820

此功能的调用步骤分为三步

  1. 填写好“调用前输入”中列出的寄存器

  2. 执行中断调用 int 0x15

  3. 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便会有对应的结果

image.png

image.png

0xe801子功能

0xe801子功能最多只能识别4GB内存,低于 15MB 的内存以 1KB 为单位大小来记录,单位数量在寄存器 AX 和 CX 中记录,其中 AX 和 CX 的值是一样的,所以在 15MB 空间以下的最大容量=AX * 1024 = 0x3c00 = 15MB。16MB~4GB 是以 64KB 为单位大小来记录的,单位数量在寄存器 BX 和 DX 中记录,其中 BX 和 DX 的值是一样的。

image.png

此中断的调用步骤如下:

  1. 将 AX 寄存器写入 0xE801

  2. 执行中断调用 int 0x15

  3. 在 CF 位为 0 的情况下,“返回后输出”中对应的寄存器便会有对应的结果

0x88子功能

0x88子功能最多只能识别64MB的内存,当内存大于64MB是只会显示63MB,因为此功能只会显示1MB之上的内存,不包括这1MB,所以在使用之时要加上这1MB

image.png

此中断的调用步骤如下:

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

分页

物理地址、虚拟地址、线性地址和逻辑地址

物理地址

物理地址就是物理上真正存在的地址,无论地址怎样变化,cpu最终访问的一定是物理地址。在实模式下,“段基址+偏移地址”直接输出的就是物理地址

虚拟地址和线性地址

在保护模式下,“段基址+段内偏移地址”称为线性地址。选择子指向段描述符,描述符中有段的段基址,而段内偏移地址就是要访问地址的低位。若未开启分页,则该线性地址等同于物理地址。若开启了分页功能,就又称之为虚拟地址,虚拟地址要经过专门的CPU部件才会转化为真正的物理地址

逻辑地址

无论在实模式或是保护模式下,段内偏移地址又称为有效地址、也成为逻辑地址,也就是程序员可见的地址。

地址之间的关系图

image.png

分页机制

分页机制是建立在分段机制之上的。在保护模式中段寄存器中的内容是选择子,但选择子最终就是为了要找到段基址,其内存访问的核心机制依然是“段基址:段内偏移地址”,这两个地址在相加之后才是绝对地址,在未开启分页时,此地址直接等同于物理地址。

image.png

开启分页之后,段部件输出的地址不再等同于物理地址,称之为虚拟地址,它是逻辑上的,是假的。

image.png

分页机制的思想是:通过映射,可以使连续的线性地址与任意物理内存地址相关联,逻辑上连续的线性地址其对应的物理地址可以不连续,分页机制的作用有两方面:

  • 将线性地址转换成物理地址

  • 用大小相等的页代替大小不等的段

image.png

每加载一个进程,操作系统按照进程中各段的起始范围,在进程自己的 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。

一级页表存在的问题
  1. 一级页表中所有页表项必须要提前建好,原因是操作系统要占用 4GB 虚拟地址空间的高 1GB,用户进程要占用低 3GB
  2. 一级页表中最多可容纳 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 最终转换成的物理地址

image.png

二级页表

二级页表的出现很好解决了一级页表中的问题。无论是几级页表,标准页尺寸都为4KB,所以4GB线性地址空间一共会有1M个标准页,一级页表是将这1M个标准页全部放在一个页表之中,而二级页表是将这1M个标准页分为1K个页表,每个页表有1K个页表项,即每个页表大小为1K*4Byte = 4KB,恰巧为一个标准页大小。这1K个页表的起始物理地址都登记在一个页目录表中,每一项称之为页目录项(Page Directory Entry, PDE),页目录项大小同页表项一样,都用来描述一个物理页的物理地址,其大小都是 4 字节,而且最多有 1024 个页表,所以页目录表也是 4KB 大小,同样也是标准页的大小

image.png

在二级页表中,虚拟地址的高10位用于在一个页目录中确定某一个页表的偏移地址,用中间的10位确定页的物理地址,再用最后的12位来表示物理页的偏移地址

所以在二级页表中找到最终的物理地址公式为:CR3页目录物理地址 + 虚拟地址高10位(PDE索引值) * 4 + 虚拟地址中间10位 (PTE索引值) 4 + 低12位(物理页偏移地址)*

image.png

页目录和页表项的结构

image.png

页目录和页表项都是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 不理会该位的值
启用分页机制

启用分页机制需要三个步骤

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

在一级页表中CR3寄存器存放的是页表的物理地址,而在二级分页中存放的是页目录的物理地址,所以CR3又称为页目录基址寄存器(Page Directory Base Register,PDBR)

image.png

页目录的起始地址是 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,内存布局图如下

image.png

image.png

一些值得注意的地方
  1. 在操作系统加载进内核之前,程序中一直运行的是loader程序,其本身的代码都在低端的1MB中,所以需要确保在分页机制下的地址和虚拟地址的的物理地址必须一一对应(即使是在分页的情况下也必须保证先运行loader),所以虚拟地址0 ~ 0xfffff必须映射到物理地址的0 ~ 0xfffff,也就是页目录表第0项对应的页表0 ~ 0xff页表项值已经确定
  2. 由于操作系统会被加载到低端的1MB之中,而操作系统所在的虚拟地址是在3GB往上的空间之中,所以操作系统对应的页表目录索引是0x300(高十位),也就是说页目录表第0x300项对应的页表0-0xff页表项值也已经确定
  3. 为了方便后面的操作系统内核的加载都放在低端的1MB内存之中,索引为768的页(0xc0000000 的高 10 位是 0x300,即十进制的 768)的虚拟地址 0xc0000000~0xc03fffff 之间的虚拟内存都指向低端 4MB 之内的物理地址,这自然包括操作系统所占的低端 1MB 物理内存。从而实现了操作系统高 3GB 以上的虚拟地址对应到了低端 1MB,也就是内核所占的就是低端 1MB
mbr.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
;主引导程序
;------------------------------------------------------------------------------
%include "boot.inc" ;让编译器在编译之前,把boot.inc文件包含进来
SECTION MBR vstart=0x7c00
mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00
mov ax,0xb800
mov gs,ax

;清屏
;利用0x06功能,上卷所有行,则可清屏
;-------------------------------------------------------------------------------
;INT 0x10 功能号:0x06 功能描述:上卷窗口
;-------------------------------------------------------------------------------
;输入;
;AH 功能号:0x06
;AL = 上卷的行数(如果为0,表示全部)
;BH = 上卷行属性
;(CL,CH) = 窗口左上角(X,Y)位置
;(DL,DH) = 窗口右下角的(X,Y)位置
;无返回值
mov ax,0600h
mov bx,0700h
mov cx,0 ;左上角:(0,0)
mov dx,184fh ;右下角:(80,25)
;因为VGA文本模式中,一行只能容纳80个字符,共25行
; 下标从0开始,所有0x18=24,0x4f=79
int 10h ;int 10h

;输出字符串MBR
mov byte [gs:0x00],'1'
mov byte [gs:0x01],0xA4

mov byte [gs:0x02],' '
mov byte [gs:0x03],0xA4

mov byte [gs:0x04],'M'
mov byte [gs:0x05],0xA4 ;A表示绿色背景闪烁,4表示前景颜色为红色

mov byte [gs:0x06],'B'
mov byte [gs:0x07],0xA4

mov byte [gs:0x08],'R'
mov byte [gs:0x09],0xA4

mov eax,LOADER_START_SECTOR ;起始扇区lba地址,0x2
mov bx,LOADER_BASE_ADDR ;写入的地址,0x900
mov cx,4 ;待写入的扇区数,由于loader.bin超过了512个字节,可能是多个扇区
call rd_disk_m_16 ;以下读取程序的起始部分(一个扇区)

jmp LOADER_BASE_ADDR + 0x300

;-------------------------------------------------------------------------------------
;功能:读取硬盘n个扇区
rd_disk_m_16:
;-------------------------------------------------------------------------------------
;eax=LBA扇区号
;bx=将数据写入的内存地址
;cx=读入的扇区数
mov esi,eax ;备份eax,因为al在out命令中会使用,会影响到eax的低8位
mov di,cx ;备份cx,cx在读数据的时候会使用到
;读写硬盘
;第一步:设置要读取的扇区数
mov dx,0x1f2 ;虚拟硬盘属于ata0,是Primary通道,所以sector count 是由0x1f2访问
mov al,cl ;cl是cx的低8位,就读一个扇区,这样就能传过去了
out dx,al ;读取的扇区数,sector count 记录要读取的扇盘数量

mov eax,esi ;恢复eax,现在eax存的是其实扇区lba的地址,0x2,第二个扇区

;第二步:将LBA地址存入 0x1f3 ~ 0x1f6

;LBA地址 7~0 位写入端口 0x1f3
mov dx,0x1f3 ;LBA low
out dx,al ;eax的第8位,就是al

;LBA地址 15~8 位写入端口 0x1f4
mov cl,8
shr eax,cl ;eax右移8位,让al的数,变为eax中8位
mov dx,0x1f4 ;LBA mid
out dx,al

;LBA地址 23~16 位写入端口 0x1f5
shr eax,cl ;再右移8位
mov dx,0x1f5 ;LBA high
out dx,al

shr eax,cl ;这样al为0000
and al,0x0f ;lba第24~27位
or al,0xe0 ;设置7~4位为1110,表示lba模式
mov dx,0x1f6 ;就是拼凑出device寄存器的值
out dx,al

;第3步:向0x1f7端口写入读命令,0x20
mov dx,0x1f7
mov al,0x20
out dx,al ;command:0x1f7,写入命令,写入的命令是读命令

;第四步:检测硬盘状态
.not_ready:
;同一端口,写时表示写入命令字,读时表示写入硬盘的状态,所以不需要更换dx的值
nop ;减少打扰硬盘的工作
in al,dx ;将Status的寄存器的值读入到al中
and al,0x88 ;第四位为1表示硬盘控制器已准备好数据传输,第七位为1表示硬盘忙,保存第4位和第7位
cmp al,0x08 ;若第4位为1,表示数据已经准备好了,若第7位为1,表示硬盘处于忙
jnz .not_ready ;若未准备好,继续等,判断结果是否为0

;第5步,从0x1f0端口读数据
mov ax,di ;这个时候di存的是上面备份的cx,及时要读取的扇区的数量
mov dx,256 ;每次in操作只读取两个字节,根据读入的数据总量(扇区数*512字节)
mul dx ;dx*ax就是总数量/2,然后将值送到cx中,cx就是要in的次数
mov cx,ax ;di为要读取的扇区数,一个扇区有512个字节,每次读入一个字,共需要di*512/2次,所以di*256

mov dx,0x1f0
.go_on_read:
in ax,dx ;读入到ax中
mov [bx],ax ;读入到bx指向的内存
add bx,2 ;每次读入2个字节
loop .go_on_read ;cx是循环的次数
ret

times 510-($-$$) db 0
db 0x55,0xaa

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
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
   %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=0x7
dd DESC_VIDEO_HIGH4 ; 此时dpl为0

GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ; 此处预留60个描述符的空位(slot)
SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0 ; 相当于(CODE_DESC - GDT_BASE)/8 + TI_GDT + RPL0
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

;人工对齐:total_mem_bytes4字节+gdt_ptr6字节+ards_buf244字节+ards_nr2,共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

;----------------- 打开A20 ----------------
in al,0x92
or al,0000_0010B
out 0x92,al

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

;----------------- cr0第0位置1 ----------------
mov eax, cr0
or eax, 0x00000001
mov cr0, eax

jmp dword SELECTOR_CODE:p_mode_start ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,
; 这将导致之前做的预测失效,从而起到了刷新的作用。
.error_hlt: ;出错则挂起
hlt

[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

; 创建页目录及页表并初始化页内存位图
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:160], 'V' ;视频段段基址已经被更新,用字符v表示virtual addr

jmp $

;------------- 创建页目录及页表 ---------------
setup_page:
;先把页目录占用的空间逐字节清0
mov ecx, 4096
mov esi, 0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi
loop .clear_page_dir

;开始创建页目录项(PDE)
.create_pde: ; 创建Page Directory Entry
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000 ; 此时eax为第一个页表的位置及属性
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)
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: ; 创建Page Table Entry
mov [ebx+esi*4],edx ; 此时的ebx已经在上面通过eax赋值为0x101000,也就是第一个页表的地址
add edx,4096
inc esi
loop .create_pte

;创建内核其它页表的PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ; 此时eax为第二个页表的位置
or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性US,RW和P位都为1
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
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
;-------------	 loader和kernel   ----------

LOADER_BASE_ADDR equ 0x900
LOADER_START_SECTOR equ 0x2
PAGE_DIR_TABLE_POS equ 0x100000

;-------------- gdt描述符属性 -------------
DESC_G_4K equ 1_00000000000000000000000b
DESC_D_32 equ 1_0000000000000000000000b
DESC_L equ 0_000000000000000000000b ; 64位代码标记,此处标记为0便可。
DESC_AVL equ 0_00000000000000000000b ; cpu不用此位,暂置为0
DESC_LIMIT_CODE2 equ 1111_0000000000000000b
DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2
DESC_LIMIT_VIDEO2 equ 0000_000000000000000b
DESC_P equ 1_000000000000000b
DESC_DPL_0 equ 00_0000000000000b
DESC_DPL_1 equ 01_0000000000000b
DESC_DPL_2 equ 10_0000000000000b
DESC_DPL_3 equ 11_0000000000000b
DESC_S_CODE equ 1_000000000000b
DESC_S_DATA equ DESC_S_CODE
DESC_S_sys equ 0_000000000000b
DESC_TYPE_CODE equ 1000_00000000b ;x=1,c=0,r=0,a=0 代码段是可执行的,非依从的,不可读的,已访问位a清0.
DESC_TYPE_DATA equ 0010_00000000b ;x=0,e=0,w=1,a=0 数据段是不可执行的,向上扩展的,可写的,已访问位a清0.

DESC_CODE_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_CODE2 + DESC_P + DESC_DPL_0 + DESC_S_CODE + DESC_TYPE_CODE + 0x00
DESC_DATA_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_DATA2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x00
DESC_VIDEO_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_VIDEO2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x0b

;-------------- 选择子属性 ---------------
RPL0 equ 00b
RPL1 equ 01b
RPL2 equ 10b
RPL3 equ 11b
TI_GDT equ 000b
TI_LDT equ 100b


;---------------- 页表相关属性 --------------
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
nasm -I include/ -o mbr.bin mbr.S 
nasm -I include/ -o loader.bin loader.S

dd if=boot/mbr.bin of=../a.img bs=512 count=1 conv=notrunc
dd if=boot/loader.bin of=../a.img bs=512 count=4 seek=2 conv=notrunc
效果图

image.png

关于地址映射

根据bochs中的调试指令 info tab可以查看虚拟地址映射的物理地址,图中左边是虚拟地址,右边是物理地址

image.png

第一条映射关系相当明显,映射了第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 属性

image.png

TLB 对开发人员不可见,但是开发人员依旧可以使用间接方法来改变TLB

  • 重新加载CR3(感觉像清空流水线)
  • 使用invlpg指令,如invlpg [0x1234]因为其中的0x1234并非立即数数,所以得采用[]