bootloader

计算机启动是一个很矛盾的过程:必须先运行程序,然后计算机才能启动,但是计算机不启动就无法运行程序!

它来自一句谚语:“pull oneself up by one‘s bootstraps”:“拽着鞋带把自己拉起来”。

为什么

操作系统最重要的部分是操作系统内核,因为内核需要直接与硬件交互来管理各个硬件,从而利用硬件的功能为用户进程提供服务。为了启动操作系统,就需要将内核程序在计算机上运行起来

  • 一个程序要能够运行,其必须能够被CPU 直接访问,所以不能放在磁盘上。
  • 另一方面,内存RAM 是易失性存储器,掉电后将丢失全部数据,所以不可能将内核代码保存在内存中。

因此,内核有可能放置的位置只能是CPU 能够直接访问的非易失性存储器——ROM 或FLASH中

但是,直接把操作系统内核放置在这样的非易失存储器上会有一些问题:

  1. 这种CPU 能直接访问的非易失性存储器的存储空间一般会映射到CPU可寻址空间的某个区域,这个是在硬件设计决定的

    这个区域的大小是有限的,如果功能比较简单的操作系统还能够放在其中,对于较大的普通操作系统显然不够。

  2. 如果操作系统内核在CPU加电后直接启动,没有相应的选择过程,意味着一个计算机上只能启动一个操作系统,这样的限制是不希望的。

  3. 把特定硬件相关的代码全部放在操作系统中也不利于操作系统的移植工作。

基于上述考虑,一般都会将硬件初始化的相关工作作为“bootloader”程序放在非易失存储器中,而将操作系统内核放在磁盘中。

这样的做法可有效解决上述的问题:

  1. 硬件初始化的相关工作从操作系统中抽出放在bootloader中实现,意味着通过这种方式实现了硬件启动和软件启动的分离

    需要存储的硬件启动相关指令不需要很多,能够很容易地保存在容量较小的ROM 或FLASH 中。

  2. bootloader 在硬件初始化完后,需要为软件启动(即操作系统内核的功能)做相应的准备,比如需要将内核镜像从存放它的存储器(比如磁盘)中读到RAM 中。

    既然bootloader 需要将内核镜像加载到内存中,那么它就能选择使用哪一个内核镜像进行加载,即实现多重开机的功能

  3. bootloader主要负责硬件启动相关工作,同时操作系统内核则能够专注于软件启动以及对用户提供服务的工作,从而降低了硬件相关代码和软件相关代码的耦合度,有助于操作系统的移植。

Bootloader的实现严重依赖于硬件,不仅与CPU相关,还和其他外设相关

但是通常Bootloader可以支持不同CPU架构,也可以支持不同操作系统的启动。

启动过程

从操作系统的角度看,bootloader 的目标就是正确地找到内核并加载执行

由于bootloader的实现依赖于CPU 的体系结构,因此大多数bootloader 都分为stage1 和stage2 两个部分。

在stage1时,此时需要初始化硬件设备,包括watchdog timer、中断、时钟、内存等。

  1. stage1时内存RAM尚未初始化完成,因而stage1运行的bootloader 程序直接从非易失存储器上(比如ROM或FLASH)加载。
  2. 当前阶段也不能在内存RAM 中运行,其自身运行会受诸多限制,比如某些非易失存储器(ROM)不可写,即使程序可写的FLASH 也有存储空间限制。
  3. 这就是为什么需要stage2 的原因。stage1除了初始化基本的硬件设备以外,会为加载stage2 准备RAM 空间,然后将stage2 的代码复制到RAM 空间,并且设置堆栈,最后跳转到stage2 的入口函数。

stage2 运行在RAM 中,此时有足够的运行环境从而可以用C语言来实现较为复杂的功能

这一阶段的工作包括:

  1. 初始化这一阶段需要使用的硬件设备以及其他功能

  2. 将内核镜像从存储器读到RAM 中,并为内核设置启动参数

  3. 最后将CPU 指令寄存器的内容设置为内核入口函数的地址

    即将控制权从bootloader 转交给操作系统内核。

从CPU 上电到操作系统内核被加载的整个启动的步骤如图:

image-20240318192338410

MIPS的启动过程

启动分为两步:启动计算机、接着导入操作系统。

这两个过程也是紧密相联的,通过bootloader/BIOS初始化硬件,进而进入内核态,开始使用操作系统中的代码进一步初始化硬件,释放硬件潜力,开始更多的功能。

MIPS的内存布局

MIPS的启动过程离不开MIPS的体系结构安排,包括内存的安排,在哪里存放OS代码等等。

首先需要注意虚拟地址和物理地址

  • 虚拟地址:程序中使用的地址一般称作虚拟地址(virtual address)、程序地址(program address)或者逻辑地址,
  • 物理地址:处理器发往总线的访存地址则称为物理地址(physical address)

OS使用的也是虚拟地址,通过MMU转换为物理地址发往CPU。全部虚拟地址的集合构成了一个地址空间。

  • kuseg(0x00000000-0x7FFFFFFF):用户态下唯一可用的地址

    • 需要通过MMU中的TLB 进行虚拟地址到物理地址的变换。对这段地址的存取都会通过cache
    • 除非MMU的设置被建立好,否则这2G的地址是不可用的。
  • kseg0(0x80000000-0x9FFFFFFF):这一段是内核态下可用的地址。

    • MMU 将地址的最高位清零& 0x7fffffff,即可映射到物理地址段的低512M空间(0x00000000-0x1fffffff)就得到物理地址用于访存。
    • 对这段地址的存取都会通过cache。因此cache设置好之前,不能随便使用这段地址
    • 通常一个没有MMU的系统会使用这段地址作为其绝大多数程序和数据的存放位置
    • 对于有MMU的系统,操作系统核心会存放在这个区域.
  • kseg1(0xA0000000-0xBFFFFFFF):与kseg0 类似,这段地址也是内核态下可用的地址。kseg1是唯一在系统重启时能正常工作的地址空间

    • MMU 将虚拟地址的高三位清零& 0x1fffffff就得到物理地址用于访存。这段虚拟地址也被连续地映射到物理地址的低512 MB 空间。
    • 对这段地址的存取不通过cache,往往在这段地址上使用MMIO(Memory-Mapped I/O)技术来访问外设
      • kseg1是唯一的在系统重启时的内存映射地址,重新启动时的入口向量0xBFC0_0000在kseg1中,对应的地址为0x1FC0_0000
  • kseg2(0xC0000000-0xFFFFFFFF):这段地址只能在内核态下使用。

    • 这块区域只能在核心态下使用并且要经过MMU的转换。在MMU设置好之前,不要存取该区域。对这段地址的存取都会通过cache
    • 除非在写一个真正的操作系统,否则没有理由用kseg2。有时会看到该区域被分为kseg2和kseg3,意在强调低半部分(kseg2)可供运行在管理态的程序使用.

image-20240318190858924

TLB 需要操作系统进行配置管理,因此在载入内核时,不能选用需要通过TLB转换的虚拟地址空间。因而内核就只能放在kseg0 或kseg1了。而kseg1是不经过cache 的,一般来说,利用MMIO 访问外设时才会使用kseg1。

在真实的系统中,运行在kseg1 中的bootloader 在载入内核前会进行cache 初始化工作。故可以将内核放在kseg0段。因此将内核的.text.data.bss段都放到kseg0中。

确定内核的位置后,要做的事情只剩下了一件:把内核加载到内存中

启动

启动时需要进行一系列初始化,包括串口、时钟初始化。之后对内存进行划分,对堆和栈进行初始化,保留出boot相应的代码,将代码从flash搬运到RAM上。

image-20240318194205649 image-20240318194453521

Bootloader将Linux 内核映像拷贝到RAM 中某个空闲地址处,然后一般有个内存移动操作,将内核移到指定的物理地址处。即内核取得控制权后执行的第一条指令的地址

Linux 内核启动的第一个阶段从/arch/mips/kernel/head.s文件开始的。而此处正是内核入口函数ernel_entry(),该函数是体系结构相关的汇编语言:

  1. 它首先初始化内核堆栈段,为创建系统中的第一个进程进行准备
  2. 接着用一段循环将内核映像的未初始化数据段清零
  3. 最后跳转到/init/main.c中的start_kernel()初始化硬件平台相关的代码。

X86的启动过程

核心在于,如何通过一步步的过程,释放硬件的能力。

具体见课件,说的不错。