操作系统启动过程深度剖析
一、课程导入与boot set回顾
好,我们开始学习第三讲。在这一讲中,我们将全面且深入地讲解操作系统启动的全过程。本讲承接上一堂课,上堂课我们介绍了操作系统启动的首个部分 ——boot set。现在我们先来简单回顾一下 boot set 的主要工作,同时换个思路思考它在启动流程中所承担的关键任务。
计算机启动时,操作系统最初存储在硬盘上,而计算机的工作依赖于取指执行,这就要求代码必须处于内存中。所以,操作系统启动的首要任务就是将其从磁盘载入内存。如果不把操作系统载入内存,计算机就无法读取和执行其中的指令,操作系统也就无法正常运行。这就如同厨师没有菜谱就无法做菜一样,因此计算机一上电,就应将磁盘上的操作系统读取到内存里并妥善放置。
这一关键任务起始于 BIOS。BIOS 会将存储着 boot set.s 程序的引导扇区读入内存。boot set.s 程序在被读入内存后,会读取并加载 setup 等后续部分的操作系统代码。它先读入 set up,接着在屏幕上显示 “loading system” 的 logo,之后调用 13 号中断,把操作系统后面的 system 部分也读入内存。至此,操作系统成功进入内存,boot set 完成使命,接下来执行权交给 set up。
二、set up的关键作用与操作
(一)set up在启动流程中的重要地位
上一课结束时,boot set将执行权交给了set up。接下来的核心内容就是set up,它在操作系统启动过程中起着承上启下的关键作用。set up是一段汇编程序,采用汇编语言编写是因为其能够严格控制程序执行内容,不像c语言需编译后才能确定执行结果,且有时c语言的执行过程并不完全受人为直接控制。目前操作系统启动过程仍需精细控制,尚未完成,set up的工作至关重要。
(二)获取物理内存大小
我们采用上一课的方法,挑选部分关键代码详细解读。其中,int 0x15指令尤为重要。它的作用是获取物理内存的大小,具体操作是使用该指令作为参数,通过int 0x15(15号bios中断)获取内存大小,获取的值存放在ax中,紧接着ax被赋给一个偏移量为2的内存地址处(当前所有段都指向9000,实际是把9000左移四位再加2,即90002这个地址处存储扩展内存的大小)。这里涉及间接寻址。
计算机在英特尔刚推出pc机时,仅有一兆内存,后来人们把一兆之后的内存称作扩展内存。如今,内存规格多样,常见的有1g、4g等。在开机时,获取并保存系统内存大小至关重要。因为操作系统负责管理计算机硬件,内存是其中极为重要的部分,管理内存的关键第一步便是清楚知晓内存的大小。这就好比家庭分地,只有清楚自家土地面积,才能合理耕种,否则可能种到别人家地里。而且不同机器的内存大小可能不同,所以操作系统必须明确内存容量,才能有效管理。
(三)移动操作系统代码
set up还进行了一个移动操作。先是将si和di清零,然后进行移动,移动长度由cx决定。从代码可知,ax先置零后赋值,ds的值为9000。根据之前所学,ds、si和es、di协同工作,把从9000开始的一段内容,也就是整个操作系统代码,全部移动到零地址处。
此时,从零地址开始就是操作系统的内容。如果操作系统原来存放在7c00处,这样移动会不会影响正在执行的set up呢?答案是肯定的。所以之前要从7c00处上移一段空间,目的是给操作系统代码留出存放空间。之后,操作系统核心代码就固定在零地址处,而诸如word、ppt、qq等应用程序则存放在操作系统之上。
三、set up与保护模式
(一)进入保护模式的必要性
set up完成上述工作后,即将退出执行阶段,但操作系统的执行不能中断,所以set up最后做了一件至关重要的事——进入保护模式。在16位模式下,cs左移四位加ip最多只能产生20位地址,对应一兆的内存访问空间,这样的计算机性能有限。如今计算机需要访问4g的空间,传统的16位寻址方式已无法满足需求,所以必须切换到32位模式,即保护模式。
(二)保护模式的切换原理
16位模式和32位模式的本质区别在于cpu内部的解释程序不同。以前按照cs左移四位加ip解释地址,现在则需通过另一条电路实现。有一个特殊的寄存器cr0,其最后一位为0代表16位模式(实模式),为1则是保护模式。通过两条指令,将1赋给ax(ax最后一位是一),再把ax的值赋给cr0,cr0的最后一位变为1,cpu就会切换到另一条解释执行指令的电路。
这条新电路涉及一个重要概念——gdt(全局描述符表,global described table或describing table )。这是计算机硬件(英特尔结构)辅助实现的,目的是生成32位地址,提高计算机执行速度。在保护模式下,cs不再通过左移四位产生地址,而是成为选择子(selector)。以前cs存放地址,现在存放查表的下标(索引),真正的段地址存放在表项中。例如cs等于8,就是去选择gdt表中的一个表项,从该表项取出地址,再与ip偏移相加,产生32位地址。所以,要使这套机制正常工作,必须有gdt表,而set up的重要任务之一就是初始化gdt表。
(三)gdt表的初始化及作用
gdt表的每个表项通常由4个16位的word组成,所以每个表项是64位。寻址以字节为单位,之前提到的8对应的就是这样一个表项。通过几条指令的配合,就能初始化gdt表。
接下来,我们用 cs
等于 8 去查全局描述符表(GDT),需要明确查出来的结果,因为它决定程序的跳转执行位置。
GDT 表项内容由硬件规定。从结构看,表项共八个字节,可分段理解。像第 4 位关联段基址 31…24 等信息,第 0 位关联段基址 15…0 等信息 。从具体 gdt
定义的一些数据项,如 .word 0,0,0,0
等可知,有两个 GDT 表项值为 0x0000
,一个用于只读(存代码),一个用于读写(存数据)。
从 GDT 表取出基值为 0 ,结合 jmpi 0,8
指令(8 是 GDT 索引 ),因 cs
取出基值为 0 ,加上指令中偏移量 ip
也为 0 ,得到地址为 0 地址。
此前 system
模块已挪到零地址,所以程序接下来跳到零地址执行,至此 set up
工作完成。
有了gdt表,再结合一条指令,即可启动32位模式。此时,工作方式发生变化,cs要到gdt表中查找地址,再与偏移相加。由于表的基址和偏移都是32位,ip也变为32位寄存器,两个32位相加仍是32位,这样就能访问4g的空间。
此外,在保护模式下,中断处理也发生变化,int 0、int 10、int 20等中断要到gdt表项中查找中断处理函数的入口地址,然后跳转执行。
四、system模块与make file
(一)system模块的结构与执行顺序
system模块由多个文件编译而成,其第一个段代码位于零地址处。为确保执行的是system的第一个模块,操作系统的编译结构有严格规定:磁盘主引导记录(MBR)的引导程序部分必须是boot set,否则BIOS无法正确读取后续操作系统相关内容;boot set从第二个扇区往后读,读取的是set up,这就要求操作系统第二个部分必须是set up 。所以,我们必须严格控制操作系统的编译结构,这和编写普通应用程序不同。编写应用程序时,使用集成开发环境,通过快捷键(如ctrl f9)就能自动编译,无需操心代码顺序。但编写操作系统时,一切都要自己控制,如果编译结果不符合要求,系统就无法正常工作。
(二)make file的作用与原理
为了控制操作系统的编译结构,我们需要编写make file。make file是一种树状结构,用于将一堆源码生成符合要求的操作系统镜像(image)。image依赖于多个部分产生,其中boot set依赖于boot set.s,set up依赖于set.app.s,同时还依赖于system和build等。当这些依赖项都准备好后,就会执行相应的操作,最终生成image。将image写入磁盘的零扇区,在开机引导时,操作系统就能顺利读入并启动,最终产生桌面,用户即可使用操作系统。
五、从汇编到c函数的跳转及main函数的工作
(一)head.s 函数的工作
head.s是system中的第一个模块,它的汇编语言与之前的boot set和set up不同,因为进入了保护模式,需要使用32位汇编代码。
head.s
作为 system
的第一个文件,发挥着重要作用。它又一次对中断描述符表(IDT)和全局描述符表(GDT)进行了初始化。此前 set up
建立临时 GDT 表,是为了执行 jmpi 0,8
这种跳转指令 ,实现特定位置的跳转。而如今,操作系统即将真正开始运作,就需要再次借助这些表,重新进行相关设置。
此外,head.s
还涉及诸多其他设置细节,例如开启 20 号地址线,开启后便能够访问 4G 内存。
在汇编语言方面,head.s
采用的汇编方式与之前大不相同。此前像 boot set
和 set up
运用的是 16 位汇编,比如指令形式可能是 ex ax
(目标操作数为 ax
)。但从现在起,进入保护模式后,要访问 32 位,汇编方式转变为 eax
这种 32 位汇编形式 。操作数的角色也发生反转,之前的源操作数变为目标操作数,反之亦然。
实际上,操作系统在汇编使用上颇为复杂,一共涵盖三种汇编方式。起先是 16 位汇编,用于 boot set
和 set up
;然后是 32 位汇编,像 head.s
以及后续部分程序的汇编代码都采用这种方式 ;还有一种是内嵌汇编,在 .c
文件中,当有些指令需要严格按照特定方式执行时,就会用到。
关于这三种汇编的语法,不会在此处详尽展开。后续用到时会简要讲解,若大家感兴趣,也可自行查阅相关资料。我们应先把握课程核心主线,在实际应用场景中再深入探究这些汇编语法。
(二)从汇编到c函数的跳转实现
head.s完成一系列设置后,要跳转到main.c执行。从汇编跳到c函数,原理和c函数调用c函数类似,主要依靠栈来实现。在c语言中,当一个函数调用另一个函数时,会先将参数从后往前压栈,然后把被调用函数的返回地址压栈,最后jump到被调用函数执行。被调用函数执行完后,遇到右括号,会执行ret指令,从栈中弹出返回地址,跳转回原来的位置继续执行。head.s跳到main.c也是类似的过程,通过压栈传递参数和返回地址,最终实现跳转。
(三)main函数的初始化工作
进入main函数后,会进行一系列初始化工作,如初始化内存、中断、键盘、鼠标、显示器、硬盘等。这里我们以内存初始化为例进行说明。
main函数中的mem init负责内存初始化工作,其目的是记录内存的使用情况。通过初始化一个数组(mem_map数组,也可称为mem_map表格)来实现,数组元素为零表示该内存区域未使用。通过循环操作,每次将当前值右移12位(相当于除以2的12次方,即4k),然后将这4k大小的区域置零,以4k为一片进行处理。这与内存管理中的分页概念相关,在后续内存管理部分会详细讲解。这样,内存初始化完成后,就形成了一个表示内存使用情况的表格。
六、操作系统启动过程总结
我们回顾本次和上次课的内容,讲解了boot set、set up、head和main等部分的工作,其中包括main函数中的mem init操作。到目前为止,操作系统的初始化和启动基本完成。操作系统启动主要做了两件事:一是将操作系统从磁盘读入内存,这样操作系统才能进行取指执行,发挥其功能;二是完成初始化工作,因为操作系统要管理计算机硬件设备,就需要针对每个硬件构建相应的数据结构并初始化,set up、head.s、main init、main等部分相互配合,获取硬件参数,初始化关键数据结构,为操作系统管理硬件做好准备。
理解操作系统启动过程对后续学习非常重要。在后续学习中,比如内存管理等内容时,我们会经常回顾初始化时建立的各种数据结构,这些知识将发挥重要作用。