操作系统原理之《从系统上电到第一个用户进程的启动》

本文将以ucore为例,介绍一下从系统上电开始,到第一个用户进程的启动过程。这个过程是非常复杂和琐碎的,如果读者是刚刚接触操作系统,有些部分我可能不会介绍的十分详细,读者可以自行Google查阅不懂的部分,最好还是在理解理论的基础上自己做一些实验。实验用的硬件平台是x86架构(用qemu模拟的),实验用的ucore代码是清华大学教学操作系统,读者可以在github上搜索ucore_os_lab,也可以搜索neuzm,我自己fork了一份,提交了我的实验结果和自己打的一些log,关于实验环境的配置可以参考ucore配套的实验指导书。

内容简介

本文将按以下几下部分进行介绍,分别对应操作系统启动过程的关键模块。首先是BIOS(Basic Input/Output System)将ROM中第一个扇区的内容导进内存,这个扇区的内容就是bootloader,bootloader之后会跳转到kernel去执行,对于kernel来说,它首先需要完成对中断的设置,然后实现物理内存和虚拟内存的管理,之后初始化线程和进程,进而初始化调度器,由于每一个进程都好像拥有整个4G内存(虚拟内存机制),所以所有进程的虚拟内存之和显然要大于真实的物理内存,在建立多个进程之前需要首先完成swap机制的建立,这里涉及硬盘上的一个换入换出区和一套换入换出算法。然后初始化文件系统和时钟,从文件系统中读取第一个用户进程开始执行。
上述是一个非常简略的内容介绍,接下来我们展开分析。

bootloader

bootloader是系统上电以后执行的第一段用户代码(BIOS是出厂时写好的,不可更改)。有人可能会问,为什么要有bootloader?干脆在BIOS里把引导做好不就行了?因为我们一台电脑可能安装多种操作系统,世面上的os(operating system,操作系统,下文在用到操作系统的地方都简写为os)种类很多,一旦将bootloader放在bios里,bootloader就变得不可更改了,那对于os的设计来说就麻烦许多,所以在os启动之前加一段引导,也就是bootloader。
bios会从磁盘中第一个扇区读取出bootloader,我们知道磁盘的一个扇区是512字节,因此bootloader不能超过512字节。bios执行完毕后,会将ip指针指向bootloader的第一条指令,那么bootloader完成什么功能呢?它主要完成以下四条功能:

  • 完成对物理内存的检测(因为这部分只能在实模式中做);
  • 从实模式切换到保护模式,完成段表的基本配置,使能段机制;
  • 从磁盘中把ucore_kernel读到内存中;
  • 将控制权交给kernel(ip指针指向kernel的第一条指令)。

接下来就进入到kernel中去执行。
注:这里提到的kernel指的是ucore的内核,为了简化书写。

中断配置

即使是一个最简单的os也需要能够处理中断,在最初设计操作系统的时候,还没有虚拟内存管理,甚至物理内存管理都十分简单,因为当时还没有什么分段分页机制,所有的地址都是物理地址,都是直接寻址的。具体的设置比较琐碎,这里不详细介绍,只介绍下完成的思路。
这里需要初始化一个中断描述符表(就是常说的idt,interrupt descriptor table),在ucore编译时,会生成一个vector.S,这里面记录了每一个中断号的中断产生时的跳转地址,kernel通过这个vector数组生成一个idt数组,每一个数组项记录很多信息,比较关键的就是跳转地址和dpl值。然后执行一个lidt操作即可。

物理内存管理

在bootloader阶段,已经完成了对物理内存的探测,这里采用的是int 15里面的e820中断探测的,把探测的结果存到物理内存为0x8000处,然后将它们按页进行管理起来(其实就是按照page_size=4096进行管理,这时还没有启动页机制)。物理内存管理还需要初始化一个pmm_manager的数据结构,里面保存了各种物理内存管理的算法,这个pmm_manager采用的是最简单的物理内存分配策略,即最先匹配策略。
在初始化物理内存管理的时候,同时使能页机制。ucore内核的第一条指令的虚拟地址是0xC0010000,但是它的实际物理地址却是0x10000,这时候需要由段机制来完成这个转换。简单说来,机器在运行时共有3种地址:虚拟地址、线性地址和物理地址,虚拟地址通过段表变成线程地址,线性地址通过页转换变成物理地址。在这个阶段,页机制还没有开启,只有段机制,所以有这样的关系:
虚拟地址-0xC0000000=线程地址=物理地址;
开启页表之后,在内核范围内需要达到这样的关系:
虚拟地址=线性地址=物理地址+0xC0000000;
这些其实都很容易做到,读者只需要简单学习一下段页式的编程方法就行了,但是这里需要注意,就是在这个过渡过程,因为你不可能用一条指令同时完成段式的关闭和页式的开始的转换,所以在关闭段表的转换功能之前需要先做一个设置,即线性地址从3G到3G+4M的范围内时,对应的物理地址是(线性地址-0xC0000000)。

虚拟内存管理

其实虚拟内存管理是和多进程同时存在的。虚拟内存管理主要为每个进程申请一个页,这个页作为pdt(page discriptor table),然后在每次进程切换的时候将这个pdt的基址,记为pgdir,存到cr3寄存器里。这里面涉及一些技术,比如每个进程所指向的那个页表究竟该不该在内存中(页面置换算法)以及相关的数据结构和算法的设计。还有,当执行fork()系统调用的时候,究竟该不该把对应的页表信息都拷贝过去?(涉及copy on write算法)。由于这部分不是我们本篇博客所重点讨论的,不再详细分析了。

线程和进程管理

其实系统启动到这个阶段的时候,还没有进程和线程的概念,首先我们需要把当前的执行序设为系统的第0个线程,一般称之为idle线程,这个线程在完成了启动操作之后什么也不干,只是不停的查询当前系统里是否存在就绪线程,如果存在,将执行权限交给就绪线程。首先初始化idle线程的数据结构,然后创建系统的第一个线程——init线程,同时创建这个线程对应的进程——init进程,其实关键操作就是初始化对应的数据结构,也就是分配一个进程id,然后把ip指针指向init线程的入口地址就行了。init进程是所有进程的父进程,init进程会启动一个user_main进程,这就是我们的第一个用户进程了。但是此时这个进程还不会执行,因为调度器还没有初始化,程序还是在idle线程的初始化流程中执行。(就算调度器初始化完成了也没用,因为此时还没有开启中断,调度器无法工作,只能按照idle线程的顺序执行)

调度器

调度器的初始化就是对相应数据结构的初始化,需要设计对应的调度算法,ucore目前支持时间片轮转调度算法和stride调度算法,都比较简单,不展开叙述了。

初始化swap和文件系统

swap就是建立一个对换区,完成内存和外设之间存储的换入换出,文件系统的初始化就比较麻烦了,首先建立虚拟文件系统,将外设也当做一种文件,需要配置每个外设(stdin、stdour和disk0),然后从disk0中读取super_block,完成simple file system 的初始化,这是目前ucore支持的文件系统,然后要为每个文件系统的根节点的inode(index node,索引节点)做初始化,还有一系列的工作,读者可以翻看我之前介绍的ucore文件系统那篇博客。

执行init进程

到此时,开启中断,idle线程主动调用就绪线程,init进程才开始执行,它首先创建一个user_main线程,然后这个线程调用exec()这个系统调用,从文件系统中把目标程序load进内存,这里的目标程序是"sh",然后将argc和argv传给目标程序,其中argc是main函数的参数个数,argv是个指针,指向参数,可以用argv[0]、argv[1]这样的方式调用参数。然后把ip指针指向main函数的入口地址,执行系统调用返回(iret),就可以完成从内核态调回到用户态,然后就在ip指针指向的指令处开始执行,这里忽略了很多堆栈的申请和调整,读者可以自行分析一下。

原文地址:https://www.cnblogs.com/miachel-zheng/p/7055361.html