X86_Linux_0.11--开机加电到main函数之前
主要留作作者复习使用,默认读者懂得一些基本的X86体系结构知识
启动BIOS,准备实模式下的中断向量表和中断服务程序
现代的X86架构的机器在上电后会默认进入实模式(16位数据线、20位地址线,功能受限),执行BIOS,这是为了兼容以前的程序。
为什么开始启动计算机的时候,执行的是BIOS代码而不是操作系统自身的代码? 因为操作系统存在于外部存储设备上,不在内存中,上电时内存是空的,因此想要执行操作系统代码就需要有一个自带的程序来将操作系统代码引导进内存,BIOS就是这样的程序。BIOS提供了一个初始的环境和一系列底层服务,用于检测、测试和初始化硬件设备,如硬盘、内存等,它能够准备好中断向量表和中断服务程序,在操作系统运行之前提供一些基本的服务,当然,BIOS也是通过这些中断服务程序将操作系统MBR加载到内存的,这些中断服务程序还给MBR提供了一些基本的操作,比如将磁盘的数据加载到内存指定位置。
操作系统存在于软盘\硬盘之上,因此上电时不能立即运行操作系统,而是需要进行引导,即将操作系统载入内存进行运行。因此还需要一个能够引导操作系统进入内存运行的程序。这个程序叫BIOS,是一段被固化的程序,不能修改,它的任务就是引导操作系统进入内存,同时需要做一些必要的正确性检查。
X86架构的机器会在上电时自动将CS:IP设置为0xFFFF0,即默认从内存地址0xFFFF0这个地方存放的指令开始运行,这个地址是BIOS程序的第一条指令,这条指令是一条无条件跳转指令,跳转到BIOS的开头。为什么要跳转而不是直接执行真正的BIOS程序?因为各个厂商的BIOS不一定一样大,有些可能1KB,有些可能2KB,如果放在内存的开头位置,那么用户程序有可能要从0x400开始运行也可能从0x800开始运行,不好控制,因此需要将BIOS程序放在内存的尾部,当然,由于BIOS不一样长,因此BIOS开始的地址不固定,就借用一条跳转指令跳转到BIOS的开始地址。你知道的,0xFFFF0这个地址离1M(20根地址线最大寻址地址)只有16个字节,也放不了几条指令。
设置中断向量表
BIOS代码开始执行,首先会在内存最开始的位置(0x00000)用1KB的内存空间构建中断向量表(256项,每项4字节,0x00000~0x003ff),然后在紧挨着的位置使用256字节的空间构建BIOS的数据区(0x00400~0x004ff),然后在大概57KB以后(0x0E05B~0x0FFFE)的位置加载了8KB左右的与中断向量表配套的中断服务程序的代码。
加载操作系统内核程序,为保护模式做准备
加载bootsec程序
接下来BIOS的任务就是加载操作系统进入内存了。
首先,BIOS会使用int 0x19指令,即调用0x19号中断,CPU会根据中断向量表找到0x19号中断处理程序的入口地址,将CS:IP设置为此地址,这个中断处理程序的功能就是将磁盘的第零磁道第一扇区中大小为512字节的内容(bootsec程序,也叫MBR)加载到内存的指定位置中(0x07c00),并将CS:IP设置到这个地址。当然,实际上BIOS也会做一些检查,它要求这512字节的最后两个字节是0x55和0xaa这两个魔数。注意,目前来说中断程序是BIOS自带的,跟操作系统无关。
为什么是0x7C00 这个地址?
实际上这是CPU厂商与操作系统软件的一种约定,也是个历史问题。当时8088芯片搭配的操作系统是86-DOS,这个操作系统需要的内存最小是32KB(0x0000~0x7FFF),此前已经介绍了,内存开始的1KB个字节要放中断向量表,BIOS数据紧随其后,为了将更多的内存空间留给用户,因此就将bootsec程序放在内存的最后1KB的位置(程序占512B,程序产生的数据存放于紧贴着的512B),那么bootsec的位置就应该放在32KB-1KB,即0x7c00这个位置了。当然,也是为了方便后续再次利用这段内存,bootsec程序放在前面的话,后续加载别的程序就需要在那之后存放,那么bootsec占用的这段内存就不容易再次被利用,需要考虑的麻烦一点,因此干脆放最后,用起来需要考虑的就少了。
这段被加载到内存0x07c00位置的程序也就是bootsec程序了,这是操作系统自己的代码。它的任务同BIOS类似,就是把操作系统后续的代码加载到内存。(为什么不用BIOS做?BIOS是固化的程序,不适合太大,而且不同操作系统的引导程序也不一样,让操作系统自己来加载显然更灵活)
1
2
3
4
5
6
7
为什么BIOS只加载了一个扇区,后续扇区却是由bootsect代码加载?为什么BIOS没有直接把所有需要加载的扇区都加载?
标准化与兼容性:通过定义一个标准,即所有可启动介质的第一个扇区都包含有引导代码,这样可以确保不同制造商生产的硬件和软件之间的兼容性。这种做法简化了启动过程的设计,并且使得不同的存储设备能够以一致的方式被处理。
技术限制:早期的个人电脑硬件资源十分有限,包括内存大小以及处理器速度。直接加载整个操作系统或大量数据到内存中可能超出了当时硬件的能力。因此,采用分阶段加载的方法是一种更实际的选择。
灵活性:通过让第一个扇区内的引导代码来决定如何加载后续部分,可以提供更大的灵活性。例如,不同的操作系统可能需要以不同的方式被加载;或者,用户可能希望选择从多个安装在单一磁盘上的操作系统之一启动。这样的设计允许每个操作系统的引导加载器自行决定接下来要做什么。
加载setup程序
内存规划
现在BIOS已经将bootsec程序加载到内存了,理所当然的要执行这段程序。bootsec的任务就是将第二批、第三批操作系统代码加载到内存。
由于当前操作系统主要使用的是汇编语言,没有编译器等工具来自动规划内存,当然这种对内存布局定制化程度比较高的程序编译器也做不了。总而言之内存规划需要自己操作,因此bootsec首先做的就是规划内存布局。
1
2
3
4
5
6
7
SETUPLEN = 4 ;setup程序所占的磁盘的扇区数量
BOOTSEG = 0x07c0 ;bootsec程序的位置
INITSEG = 0x9000 ;bootsec程序将被移动的位置
SETUPSEF= 0x9020 ;setup程序开始的位置
SYSSEG = 0x1000 ;system加载的位置
ENDSEG = SYSSEG + SYSSIZE ;加载结束的位置
ROOT_DEV= 0x306 ;根文件系统设备号
复制自身
接下来bootsec做的动作就比较匪夷所思了:他将自身的代码(全部的512字节)复制到了内存的0x90000(INITSEG)这个位置。
1
2
3
4
5
6
7
8
9
10
start:
mov ax, #BOOTSEG
mov ds, ax
mov ax, #INITSEG
mov es, ax
mov cx, #256
sub si, si
sub di, di
rep
movw
这次复制,ds与si,es与di联合使用,ds(0x7c0), si(0x0000)和es(0x9000), di(0x0000)构成了源地址0x07c00和目标地址0x90000,每次从ds:si指向的内存取两个字节的数据复制到es:di指向的内存地址,共复制256次,即512个字节。
为什么BIOS将MBR加载到0x7c00位置后,MBR将自己复制到0x90000处?为什么不一步到位移动到0x90000处? 有几个原因:一是0x7c00这个地址是BIOS决定的,是历史遗留问题,操作系统没法改变,只能先将MBR放到0x7c00这个位置。二是此后将system代码移动到0x00000位置,会覆盖掉0x7c00地址处存放的代码。三是此时0x00000位置存放了中断向量表及BIOS的数据,不能被覆盖,因此移动到0x90000这个地址是比较合适的,当然了,你也可以设置它移动到其它地方,不与其它代码或者数据冲突就行了。
设置寄存器
复制完自身后,bootsec执行一个无条件跳转指令到0x90000段中bootsec应该执行的下一条指令,在新的位置继续执行bootsec剩下的代码。它做的工作为:
1
2
3
4
5
mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov sp, #0xff00 ;ss:sp组成栈指针,因此这两条指令将栈指针指向0x9ff00,设置了栈空间
加载setup
加载setup程序依赖BIOS提供的0x13号中断程序。与0x19号中断程序不同,0x13号中断服务程序可以接受外部参数,能够根据调用者的意图来执行程序。因此调用次中断之前需要先将参数存放在约定的寄存器中:
1
2
3
4
5
6
7
8
9
10
11
load_setup:
mov ds, #0x0000 ; drive 0, head 0
mov cx, #0x0002 ; sector 2, track 0
mov bx, #0x0200 ; address = 512, in INITSEG
mov ax, #0x0200 + SETUPLEN ; service 2, nr of INITSEG
int 0x13 ;
jnc ok_load_setup ; ok-continue
mov dx, #0x0000
mov ax, #0x0000 ; reset the diskette
int 0x13
j load_setup
这段程序将磁盘第二个扇区开始的4个扇区加载到0x90200这个位置(紧贴着bootsec),此时SS:SP指向的位置为0x9ff00,与0x90200还有很大的距离,因此存数据是足够的。
加载system模块
现在第二批代码(setup)已经加载入内存,需要加载第三批代码,即system,仍然使用BIOS提供的0x13号中断,且与加载setup流程基本相同。但本次加载的扇区数是240个,是之前4个扇区的60倍之多。因此需要的时间也要久很多。为了避免用户误会,此时Linux甚至会在屏幕上显示Loading system…,这也是通过BIOS提供的代码实现的。
本次加载将磁盘第六个扇区开始的约240个扇区加载到内存的SYSSEG(0x10000)处往后的120KB空间中。
至此,整个操作系统的代码已经被加载到了内存中,bootsec的主体工作也做完了,剩下的一件事就是确认一下根设备号,确认计算机中实际安装的软盘驱动器为根设备,并将信息写入机器系统数据。代码就不介绍了,意义不大。到此为止bootsec的任务就全部完成了,因此下面的一条指令就是跳到setup程序所在地址(0x90200),去执行setup程序。
setup开始执行
setup程序开始做的第一件事就是利用BIOS提供的中断服务程序从设备上提取内核运行所需的机器系统数据,包括光标位置、显示页面等数据,并分别从中断向量0x41、0x46所指向的地址处获取硬盘参数表1、硬盘参数表2,把他们存放在0x9000:0x0080和0x9000:0x0090处。这些机器系统数据被保存在内存的0x90000~0x901FC位置(此前bootsec程序的位置,过河拆桥),它们在以后main函数执行时要发挥重要作用。
到此为止,操作系统的内核程序加载工作已经完成了。下面将实现从实模式到保护模式的切换!
开始向32位模式转变,为main函数的调用做准备
在这里,操作系统执行的操作主要是打开32位寻址空间、打开保护模式、建立保护模式下的中断响应机制等与保护模式配套的相关工作,建立内存的分页机制,最好做好调用main函数的准备。
关中断、移动system至内存起始位置
首先,setup程序将CPU的标志寄存器(EFLAGS)中的中断允许标志(IF)置0,也就是禁用中断,直到main函数建立起了完善的保护模式下的中断服务体系。
为什么要关中断? 在进入保护模式的准备期间,操作系统将破坏原有的中断服务体系,这意味着如果这个阶段发生了中断,系统将发生无法预料的行为,死机是一定的,因此在建立新的中断服务体系之前绝对不能响应中断。
接下来setup程序又做了一个匪夷所思的操作,它将位于0x10000处的内核程序复制到了内存地址的起始位置(0x00000)处!回顾一下会发现,这个位置原本是BIOS建立的中断向量表及BIOS数据的位置!这么做有一箭三雕的效果:
- 废除了原来BIOS建立的中断向量表,也就是废除了BIOS提供的实模式下的中断服务程序。
- 收回了刚刚结束使用寿命的程序所占的内存空间。
- 让内核代码占据内存物理地址最开始、天然的、有利的位置。 可谓是破旧。下面就需要立新了:
setup程序接下来根据自身携带的数据信息对中断描述符表寄存器(IDTR)和全局描述符表寄存器(GDTR)进行初始化设置。如果你不知道什么是IDTR和GDTR,我建议你RTFW。
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
end_move:
mov ax, #SETUPSEG
mov ds, ax
lidt idt_48
lgdt gdt_48
gdt:
.word 0, 0, 0, 0 ;dummy
.word 0x07FF ;8Mb-limit = 2047
.word 0x0000 ;base address=0
.word 0x9A00 ;code read/exec
.word 0x00C0 ;granularity=4096, 386
.word 0x07FF
.word 0x0000
.word 0x9200 ;data read/write
.word 0x00C0
idt_48:
.word 0 ;idt limit=0
.word 0, 0 ;idt base=0L
gdt_48:
.word 0x800 ;gdt limit=2048, 256 GDT entries
.word 512 + gdt, 0x9 ;gdt base = 0x9xxxx
这里最后一行,512 + gdt, 0x9
实际上是将gdtr寄存器高32位设置为0x90200+gdt,也就是gdt这个标签的地址,因为当前是在setup程序中,段基址是0x90200。
打开A20地址线,实现32位寻址
接下来,setup程序会打开A20地址线,实现32位的寻址,也就是最大寻址空间达到了4GB!代码就不介绍了,意义不大。
重新设置中断响应序列
为了建立保护模式下的中断响应机制,setup程序将对可编程中断控制器8259A进行设置,如果你不知道8259A是什么,那么请看这里。setup对8259A的设置代码就不细看了,总之,在这一过程中,setup将8259A原来的IRQ0x00~IRQ0x0F对应的中断号重新分布,使之对应的中断号为int 0x20~int 0x2F。
为什么要这么做?因为在保护模式下,中断号0x00~0x1F会被Intel处理器保留作内部不可屏蔽中断和异常,如果不对8259A进行重新设置,那么8259A原先的中断就会被覆盖。
切换到保护模式
接下来setup的任务就是将CPU的工作模式设置到保护模式。通过将CR0寄存器的第0位(PE)置1,CPU即可工作在保护模式下。
1
2
3
mov ax, #0x0001
lmsw ax
jmpi 0, 8
CPU进入保护模式后,就需要根据GDT的指示决定后续执行哪里的程序了。
为什么jmpi 0, 8这条指令可以执行? 你可能会有这样的疑问:为什么进入保护模式了,应该将CS寄存器的值解释为段选择子了,但是CPU为什么还能执行下一条jump指令? 在lmsw ax执行完成后,处理器进入保护模式。此时的CS=0x9020,但因为保护模式刚刚开启,EIP指向下一条指令(即jmpi 0, 8),所以这条指令在实模式基址规则下仍然可以找到并执行。也就是说其实将CPU设置为保护模式的时候,jump指令已经执行了,并且它正确的将CS设置为8,因此没有问题。
为什么是jmpi 0, 8
?进入保护模式后,CS内的内容代表段选择子,也就是GDT表的表项偏移,这里的jmpi 0, 8
,将CS设置为8,IP设置为0,这里的8需要当作0b1000来看待,最低两位表示的是请求特权级,第2位表示的是查找GDT表还是LDT表,0表示查找GDT表,而其余高位乘上8则表示的相对于GDTR或者LDTR所存地址的相对偏移,由于每个表项占8个字节,因此除了低三位之外的高位也就是GDT或者LDT的表项索引(这里的0b1000也就是gdt表的第一个表项),取此段描述符中的基地址作为段基地址,IP的值为相对此段基地址的偏移,由于第一个表项中的基地址为0,因此,此时CS:IP指向的是0x00000000这个地址,也就是之前加载的第三批代码,即system代码,当然,开始的地方的程序叫做head程序。
head.s开始执行
其实载入的第三批代码中,最开始的一段代码是用汇编语言编写的程序,在main函数执行之前执行,称为head程序,它有25KB+184B大小。它的主要任务是在调用main函数之前做一些准备工作,此外它还对自己执行过河拆桥式的程序:它从自己代码开始的位置(0x00000000)创建了页目录表、页表、缓冲区、GDT、IDT,将自己已经执行过的代码所占的空间覆盖,真是一个乐于奉献的程序。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
这是一段AT&T格式的汇编,也是gcc支持的那一种,跟之前的汇编格式有所不同。
这种格式的汇编,源操作数在左边,目标操作数在右边
.text
.globl _idt, _gdt, _pg_dir, _tmp_floppy_area
_pg_dir:
startup_32:
movl $0x10, %eax
mov %ax, %es
mov %ax, %fs
mov %ax, %gs
lss _stack_start, %esp
call setup_idt
call setup_gdt
...
_pg_dir
就是内核代码的起始地址,也就是0x00000000,head程序将在此建立页目录表,为分页机制做准备。
此前的jmpi 0, 8
指令已经将CS设置为了8,即保护模式下该有的格式,但其余的寄存器内容还是实模式下的格式,因此四个mov
操作将eax
,es
,fs
,gs
等寄存器设置为0x10,实际上就是0b10000,指向GDT的第二个表项,也就是数据段。
此外,栈指针也要进行设置,SS要变成栈段选择符,栈顶指针也要变成32位的esp,理所当然的,SS的值也是0x10,esp指向_stack_start的低32位, 也就是user_stack
这个数据结构的末尾位置,user_stack
的起始地址是0x1E25C,占4K个字节。
设置IDT表
接下来,head程序对IDT进行设置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
setup_idt:
lea ignore_int,%edx ; 将ignore_int的地址加载到%edx
movl $0x00080000,%eax ; 将0x00080000加载到%eax
movw %dx,%ax ; 将%edx的低16位加载到%ax
movw $0x8E00,%dx ; 将0x8E00加载到%dx
lea _idt,%edi ; 将_idt的地址加载到%edi
mov $256,%ecx ; 将256加载到%ecx
rp_sidt:
movl %eax,(%edi) ; 将%eax的值存储到_idt的当前位置
movl %edx,4(%edi) ; 将%edx的值存储到_idt的当前位置+4
addl $8,%edi ; %edi增加8(每个IDT项占用8字节)
dec %ecx ; %ecx减1
jne rp_sidt ; 如果%ecx不为0,跳转到rp_sidt
lidt idt_descr ; 加载IDT描述符到IDTR寄存器
ret ; 返回
实际上就是填充IDT表,一共填充256个表项而且内容都一样,当然,要理解这里你需要了解IDT表的含义,这个请RTFW。在本次填充过程中,head将所有IDT表项中,中断服务程序的偏移地址都设置为了ignore_int这个地址,它的功能是打印一个消息并返回,没什么实际作用。
IDT占的内存空间为0x054B8~0x05CB8,共2KB
重新设置GDT表
在这之后,head程序还会调用setup_gdt,目的是废除已有的GDT,并在内核中新的位置重建GDT,当然,也要设置gdtr的新值。这次的设置和setup程序设置的内容几乎一样,只把段限长从8MB提升到了16MB。
GDT占的内存空间为0x05CB8~0x064B8,共2KB
为什么要废除之前的GDT重新建立? 主要是之前的GDT是setup程序设置的,在0x90200 + 偏移量这个位置,它很容易就被其它程序或者数据覆盖掉了,这是不能接受的,因此需要将GDT放在一个安全的位置,也就是head程序现在存在的位置。当然,你可能会问既然现在要重新设置GDT,为什么当时干脆不设置了,何必多此一举,其实不然,进入保护模式之后的寻址方式就是段标识符中的基地址+偏移的形式了,如果当时没有GDT那三个表项,那么在保护模式就无法运行。
你可能还要问,能不能setup直接把GDT建立在head的位置,这样就不用重新建立了,这是有道理的,但是如果先建立GDT,后移动system程序,GDT就会被覆盖,反过来也一样。当然你可能还会想,能不能先计算出被移动到0x00000000位置开始的head程序中存放的GDT的位置,将它设置为GDTR的值,我觉得这是可行的,不过Linus不是这么写的。实现同一个功能的程序,它的设计方法有很多,做为程序员,有些道理不必深究,因为其实那是作者想那么写就那么写了,你也可以不这么写。当然,有些设计有极有智慧的,这种学习之即可。
设置完新的GDT之后,Linus的做法是对其他段寄存器也进行重新设置,包括DS、ES、FS、GS和SS等等,细心的你可能发现,这些寄存器原来的值就是0x10,重新加载是不是多次一举呢?其实不是的,因为这些段寄存器实际上有96为,高80位是对程序员不可见的,它缓存了段选择器对应的段描述符的信息,包括基地址,段限长和属性,因此需要重新设置这些段寄存器,刷新不可见部分的内容,否则如果使用这些段寄存器来访问8~16MB的地址的时候会出现段限长超限错误(这也是head设置的GDT和setup设置的GDT的唯一区别了)
细心的你还会发现此时CS没有被重新加载!这是因为目前来说8MB的限长足够head程序使用的,等后面需要跳到main函数的时候再将CS的值重新加载也不迟。
再次检查A20地址线
接下来,head会检查A20地址线到底有没有真的打开并且有效,它是这么检查的:
1
2
3
4
5
xorl %eax,%eax
1: incl %eax # check that A20 really is enabled
movl %eax,0x000000 # loop forever if it isn't
cmpl %eax,0x100000
je 1b
实际上,如果A20地址线没有打开或者没有效果,那么超越1MB的地址将进行回滚,也就是对1M进行取模,那么0x100000就会回滚到0x00000这个地址,如果向0x00000这个地址写数据(这里是1),读出0x100000这个地址的值和写入的数据一样,说明进行了回滚,那么说明A20地址线打开失效了!
检测数学协处理器
确认A20地址线打开后,head会检测是否存在数学协处理器(FPU),如果存在就将其设置为保护模式工作(80486之后的处理器内置FPU,因此这是为了兼容之前的处理器,当然了,现在看来这都是历史的包袱,也就是屎山,因此新立门户抛弃历史包袱是有意义的)。
接着,head就要为main函数做最后的准备了,也是head程序的最后一个阶段。
参数压栈
这里你可能需要补充一些有关X86架构函数调用栈的知识,请RTFW
设置完协处理器之后,head会将main函数需要的参数、一个L6标号(main函数返回地址)和main函数的入口地址先后压栈,目的是后续能够使用ret指令能够跳转到main函数中,且main函数如果退出,还能退出到L6程序(是个死循环)中继续运行,甚至在这种情况下应用程序还能做一些系统调用,此时如果还有进程存在,仍能够进行轮转调度。(当然,正常情况下,main函数不应该退出)。
1
2
3
4
5
6
7
after_page_tables:
pushl $0 ;parameters for main
pushl $0
pushl $0
pushl $L6 ;return address for main
pushl $_main
jmp setup_paging
你可能在想为什么用ret不用call? 其实无论是call还是ret,本质上都是一个跳转指令,只不过call会将当前指令的下一条指令地址(返回地址)压栈,然后跳到目标地址;ret会将栈指针指向的参数当作返回地址出栈,跳转到这个地址。这里使用ret是因为不想将下一条指令压栈,而是准备好参数直接执行main函数。你可能会问,那使用jmp指令不也行吗?你真聪明,确实可以,但是Linus没有这么干,为什么?因为他乐意。你可能还会问,就想用call指令不行吗?其实也行,你只需要在执行call之前将main函数的参数压栈,然后保证call指令下面一条指令就是标签L6就行了,那么call指令会自动将L6压栈,效果与先将L6、main压栈再调用ret一样。
你可能在想为什么用ret不用call? 其实对于这个问题,杨立祥老师还认为,main函数就是在机器上直接运行的最底层的代码了,就不应该有返回值,因此如果使用call,那么语义上将还会返回,返回给谁?这是不符合逻辑的,因为他已经是最底层了,因此使用ret来模拟call,符合语义。 当然了我是不那么认可汇编这里谈高级语言的语义这个东西的,我认为这些是编译器层面、软件层面的约定而已,无论用什么方式,参数能正常传递,能正常跳转到main即可。
这些压栈操作完成之后,head程序就要去干一件大事了!
创建分页机制
完成压栈动作后,head程序将跳转到setup_paging取执行,创建分页机制。
首先,head程序将页目录表和4个页表放在物理内存的起始位置(页目录表和页表各占4KB,这也是一个页的大小),也就是将内存开始的5页内容全部清零,为初始化页目录表和页表做准备。细心的你可能已经发现了,head程序使用页目录表和页表覆盖了程序自身的空间。
将页目录表和页表放在物理内存的起始位置,意义重大,是操作系统能够掌握全局、掌控进程在内存中安全运行的基石之一。
随后,head程序设置了页目录表的前四项,使之分别指向了刚才创建的四个页表,称为pg0
, pg1
, pg2
, pg3
。然后,head将第四个页表的最后一个表项(pg3 + 4092)指向寻址范围(寻址范围是16MB,之前段限长就是16MB,四个页表每个有1024个表项,每个表项可以代表一个4KB大小的内存空间,因此四个页表一共可以代表4096*4096等于16M个字节的内存空间)的最后一个页面,也就是0xFFF000开始的4KB大小的内存空间。接着,head开始从高地址向低地址方向继续填写这4个页表,也就是重复之前的过程一直到将四个页表填充其对应的内存地址。
这四个页表是专属的内核页表,将来每个用户进程都会有字节的专属页表。
到目前为止,内存布局是这样的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌────────────────────────────────────┐
~ ~
~ main ~
~ ~
├────────────────────────────────────┤ 0x064b8
│ 全局描述符表 (2KB) │
├────────────────────────────────────┤ 0x05cb8
│ 中断描述符表 (2KB) │
├────────────────────────────────────┤ 0x054b8
│ head剩余代码 (184B) │
├────────────────────────────────────┤ 0x05400
│ 软件缓冲区 (1KB) │
├────────────────────────────────────┤ 0x05000
│ 页表3 (4KB) │
├────────────────────────────────────┤ 0x04000
│ 页表2 (4KB) │
├────────────────────────────────────┤ 0x03000
│ 页表1 (4KB) │
├────────────────────────────────────┤ 0x02000
│ 页表0 (4KB) │
├────────────────────────────────────┤ 0x01000
│ 页目录表 (4KB) │
└────────────────────────────────────┘ 0x00000
可以看到,head程序仅剩184B的大小了,操作系统设计者的内存规划能力真的可以的。
当前,虽然已经建立了页表,但是分页机制的建立还没有完成,还需要将页目录表的起始地址设置到页目录表基址寄存器CR3中,再将CR0寄存器的最高位置1(CR0的最高位是分页机制控制位(PG),当CR0的PE位置1,也就是处于保护模式时,允许将PG位置1,表示打开分页机制),这样就建立好了分页机制了。
1
2
3
4
5
6
7
...
xorl %eax, %eax
movl %eax, %cr3 ; 将0x00000000放入cr3寄存器,认定页目录表在内存的起始位置
movl %cr0, %eax
orl $0x80000000, %eax
movl %eax, cr0
...
当然了,聪明的你会发现,这分页机制打开和没打开好像没什么两样,因为目前虚拟地址在页表中对应的地址就是顺序的与虚拟地址值相同物理地址,这是因为当前还在内核中,出于简化考虑,使用的是平坦模型(虚拟地址等于物理地址,这个时候的虚拟地址也叫线性地址),应用程序不会这样干的。
进入main函数!
现在,head程序要执行它的最后一条代码了:ret
,之前head已经将main函数的参数、地址等数据压入了栈中,此时执行ret
,CPU会将栈顶的main函数入口地址弹出到EIP
寄存器中,那么,也就进入了main函数了!
注意,此时中断仍然是关闭的!
保护模式在“保护”什么?它的“保护”体现在哪里?特权级的目的和意义是什么?分页有“保护”作用吗?
你好,有的:
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
保护模式在保护什么?
1. 保护内存
通过分段机制,进程只能访问被授权的内存段(对应GDT表项内指定的)
通过分页机制,进程只能访问映射到其地址空间的物理内存页面
2. 保护CPU资源
通过GDT指定的特权级,防止用户程序直接执行危险的指令(特权指令)
3. 保护操作系统
通过分段与分页管理,防止用户程序直接修改系统关键数据结构
确保内核代码与用户代码分离,避免用户态对内核态的干扰
保护的体现?
1.分段机制
2.分页机制
3.特权级机制
特权级的目的意义?
目的:
将操作系统和用户程序的运行环境分隔开,确保系统核心的安全(内存安全)。
限制用户程序执行特权指令,避免对硬件和系统状态造成破坏(CPU安全)。
意义:
隔离性:内核态和用户态的隔离使得即使用户程序出现问题,也不会影响系统的稳定性。
安全性:通过分级权限,确保只有操作系统内核可以直接操作关键资源。
系统稳定性:避免恶意程序或错误代码修改系统资源。
分页是否有“保护”作用?
有的,而且很明显:
1. 内存隔离
分页实现了每个进程独立的虚拟地址空间,防止进程互相干扰,一个进程无法直接访问其他进程的内存数据。
2. 访问控制
页表条目中的权限位可以限制页面的读写和执行权限,防止用户态进程访问只有内核态才能访问的内存区域。
3. 阻止非法访问
如果某个进程尝试访问未映射的页面,CPU会触发页错误异常,操作系统可以通过异常处理机制应对非法访问行为。
根据内核分页为线性地址恒等映射的要求,推导出四个页表的映射公式,写出页表的设置代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
四个页表,每个页表有1024个表项,一共4096个页表项,每个页表项映射了4KB大小的内存空间,因此一共映射了4K*4KB = 16MB大小的内存,每个页表管理4MB的内存,页表项的映射公式如下:
0x001000 ------ 0x000000
0x001004 ------ 0x001000
0x001008 ------ 0x002000
.......
0x004ffc ------ 0xfff000
也就是内存0x001000 + n地址处的内容位0x000000 + n * 1024, n = 0, 4, 8, ... , 4092。
对于页表则是:
0x001000 ~ 0x001ffc ------ 0x000000 ~ 0x3fffff
0x002000 ~ 0x002ffc ------ 0x400000 ~ 0x7fffff
0x003000 ~ 0x003ffc ------ 0x800000 ~ 0xbfffff
0x004000 ~ 0x004ffc ------ 0xc00000 ~ 0xffffff
就四项,根本不需要公式