阅读 492

浅谈操作系统启动与内核初始化

迷茫的时候,就朝着热情的方向走。

BIOS 的引导

当按下开机键,你的主板开始加电,在刚加电的时候电脑会做一些初始化寄存器的工作,比如将 CS代码段寄存器 设置为 0xFFFF ,将 IP指令寄存器 设置为 0x0000,这样我们第一条指令就会指向 0xFFFF0 ,于是 CPU 执行这条指令,这条指令的内容很简单,就是跳转到指定位置去进行 BIOS 的初始化工作。

那么,所谓的指定位置究竟是在哪呢?这就要从 实模式 开始讲了。

所谓实模式就是 实地址模式 (使用的是物理地址),我查阅了一些资料,发现这个东西就很奇葩了。对于早期的 CPU 是从16位发展起来的,十六位的概念是什么意思?我们知道 CPU 和 内存之间需要经过 总线 进行通信(当然现在手机 ARM 结构可能不一样)。对于十六位 CPU,也就是说其寄存器是十六位的(当然不一定说 CPU 位数决定所有寄存器的位数,这里可以暂且这么理解)。而对于十六位的寄存器来说只能存放 2^16 = 65536 这么大的数,所以也就意味着我们通过寄存器存放内存地址最多只能存的下 65536 字节,也就是 64KB ,那也就说内存只有 64KB

而我们所说的实地址都是 1MB 呀,这是怎么来的呢?

原因是 CPU 和 内存之间的总线有 20 条,人们为了利用这多出来的 4 条,使用 两个寄存器来表示地址 ,也就是我上文所说的 CSIP。而如何使用两个寄存器 16+16=32位 来表示 20 位的地址呢?想想上面两个寄存器 0xFFFF0x0000 是如何得出来 0xFFFF0 的?CS 指的是段寄存器,而 IP 指的是偏移寄存器(暂且这么叫吧),两者结合表示的地址就是——段基地址 << 4 + 偏移地址。这样我们就能拥有二十位的寻址了,也就是 1MB

说完实模式的由来,我们继续来看看第一段所说的指定位置是什么意思。现在我们拥有了 1MB 的内存空间,那么我们该如何使用呢?首先我们要启动 BIOS 吧,它是一个程序肯定要占用内存的,那么我们就给 BIOS 分配内存。所以我们内存就划出一块 ROM 区域来映射 BIOSROM

然后我们开始运行 BIOS 程序,首先 BIOS 先检查身边的硬件是否有故障(即 POST 加电自检)。如果你曾经进入过 BIOS 界面,你就会发现你可以获取像内存、处理器、硬盘等硬件数据。

然后进行分配中断、IO端口、DMA资源等,这个时候会建立一个中断向量表和中断服务程序主要用于用户进行键盘和鼠标操作。

比如我们在重装系统需要进入 BIOS 界面的时候需要在开机那几秒中敲击键盘的某一个键,这中间的几秒中其实就是 BIOS 在进行加电自检(有些主板可以设置 POST 延迟),如果经过这几秒你没有选择进入 BIOS ,那么 BIOS 就会进行默认启动项的加载,这也是 BIOS 最后的任务了,即选择启动设备运行其 引导程序boot

那么 BIOS 如何判断引导程序的位置呢?这就要说到 启动盘(MBR) 了,启动盘一般在 第一扇区占用 512 字节且以 0xAA55 结束。当 BIOS 认为满足条件的时候就会 将处理器的权利移交给该程序 ,也就是将启动盘的数据复制到物理内存(请注意我们现在还在实地址模式,所以用的是物理内存地址) 0x7c00 处,然后 跳转0x7c00 处执行。

boot引导程序的工作

上面提到了 boot引导程序 只有512字节(除去末尾标识其实也就510字节),这么大能干什么?好吧干不了什么。你可以理解为加载操作系统就是一次火箭升空,需要一级一级的助推器,而 boot 就是其中的一级。

所谓助推器就是通过它再加载其他引导程序,就拿 GRUB (一种操作系统启动管理器) 举例,510字节是无法装载全部的 GRUB 的,而在这510字节所干的事情就是 装载第二引导装载程序

而在这个时候就又要考虑一个问题了,假设你现在是 boot 程序的开发者,你想使用 boot 去加载 loader 程序(也就是第二引导装载程序),那么一种办法是将 loader 也像 boot 一样放在磁盘的指定物理位置,然后将这个物理位置硬编码在 boot 程序中。毫无疑问,这肯定会带来诸多问题,比如说硬编码的方式比较 死板不灵活 ,没有文件系统支持就必须保证之后的程序在磁盘上是连续的。当然这也是一种办法,像早期普遍使用的 LILO 引导程序就是这么干的。

与其以后需要一直承受这种硬编码的折磨和痛苦,那倒不如长痛不如短痛,我可以直接在 boot 程序中去 创建一个简单文件系统 ,而 GRUB 就是这么做的。甚至我们可以理解为 GRUB 是一个小型的操作系统。

总之 boot 的主要任务就是创建了简易文件系统并且去装载第二引导程序了。

Loader引导加载程序

随着 boot 这个一级助推器脱离火箭,处理器的控制权就移交给了 Loader 程序。而这个 Loader 主要干了三件事情。

  • 检测硬件信息
  • 模式切换
  • 向内核传递数据,启动内核

首先就是通过 BIOS 的中断服务去获取硬件信息,这是为了第三步给内核传输数据做准备。

然后就是 模式切换 ,注意我们此时还在实模式,只有 1MB 的物理寻址空间,而后面需要加载内核,这肯定是不够的。所以这个时候我们就要去打开 A20 了,也就是第二十一根地址线。

为什么要去打开它,上面我们也说了原始的8086 8088处理器只有20根地址线,而后面进入80286时代的时候就变成了24根地址线,这个时候如果使用24根地址线的话肯定能访问到 1MB 以上的内存空间,这就与前面不兼容了。而那个时候86实在是太成功了,市面上都是这种类型,如果你新造出一个不一样的,也就意味着软件,驱动都要重新搞,用户是不可接受的。 所以为了兼容20根地址线,就使用了 A20 作为一种限制

打开了 A20 ,像32位系统就能进入保护模式使用32根地址线,这个时候访问内存地址就变成了 4GB ,而对于 64 位 进入 IA-32e 模式访问内存就更大了。

抛开了 1MB 的窄小空间,这个时候我们就可以真正有所作为了。刚刚我们第一步获取的硬件信息就有用了,还有就是设置一些内核启动参数,然后去将信息输入到 内核启动程序 。伴随着 Loader 程序最后一条指令的完成,内核就开始启动了。

内核头程序

不是说现在内核开始启动了么,这个 内核头程序 又是什么呢?在内核启动前还需要进行 全局段描述表(GDT)、中断描述表(IDT) 以及 页表 的结构初始化,为后面内核进行 中断处理内存管理 的初始化奠定基础。而内核头程序就是来干这个的,它是一段特殊的汇编代码,必须在内核程序执行之前得到执行。

内核初始化

当进入内核初始化之后,整个主程序就会调用一系列的初始化函数,第一个当然是创建0号进程了,它是唯一一个没有经过 fork()kernel_thread() 产生的进程。

回想一下 BIOS ,在进行 POST加电自检 后立马就进行了中断处理的初始化,因为在一个程序进行执行的时候我们必须首先要考虑到异常情况,所以内核初始化过程中在进程的初始化完成之后首要的就是 进行中断处理的初始化 。依托于内核头程序完成的 IDT 以及 GDT ,我们在进行一些中断策略等初始化操作就完成了中断处理的初始化了。

中断初始化完成之后就是 内存管理初始化 ,对于内存管理的初始化,主要就是 如何获取物理内存信息,如何进行物理内存的分配管理以及如何设计可用内存和可用物理页的分配与回收 等等。

内存管理初始化初始化完成之后就需要进行 进程管理的初始化 了。而对于进程管理我们可以分为两个步骤,如何设计我们的 进程控制结构(PCB) 以及如何设计 进程间的调度策略

然后就是 文件系统的初始化 ,如何进行文件到磁盘逻辑地址的映射,逻辑地址到物理地址的映射也是我们需要解决的事情。还记得一些对于文件的系统调用么,例如 open()read()write() 等。当我们进行这些系统调用的时候肯定需要对底层的物理磁盘介质进行操作,而 VFS(虚拟文件系统) 就是我们文件与物理介质中的胶水层。这一步其实并没有进行整个文件系统的初始化,而仅仅是创建了第一个挂载点目录/ 和进行一些格式的定义 ,也就是说我们现在并不能通过类似 /root/xxx 去访问文件,第一是此时并没有进行 物理磁盘文件系统的挂载 ,第二是此时 还没有用户进程 (现在都在内核中)。

等到文件系统初始化完成之后我们就需要 创建1号进程 ,也就是我们的 第一个用户进程 。在虚拟文件系统初始化完成之后就需要进入用户态以完成真正的用户文件系统的创建过程。如果说 grub 中有配置 initrd 的话就会首先执行再 ramdisk 中的 init ,这个 init 主要干的事情就是先根据存储系统的类型 加载驱动 ,有了驱动之后我们就可以通过 ramdisk 去设置真正的根文件系统了,然后我们就可以访问根文件系统中的 init 程序做一些用户态的初始化了。

当用户进程有了祖先之后就会创建内核进程的祖先,也就是 2号进程 。它的作用很简单,就是 管理调度其它的内核线程 。它会一直循环运行 kthreadd 函数,这个函数主要就是 负责所有内核态的线程的调度和管理

等到内核态也有了管理者,整个内核的初始化流程也就结束了。用户就可以开始自己创建进程,运行各种软件了。

本文使用 mdnice 排版