操作系统源码学习笔记(一) BIOS是如何和Linux操作系统配合启动的?

/ 默认分类 / 0 条评论 / 435浏览

一.操作系统如何加载到内存的

操作系统还没有加载之前(就是我们广义上理解的电脑都还没开启好呢),在RAM中什么程序也没有的时候,谁来完成加载软盘中操作系统的任务呢?答案就是BIOS程序. 那么问题来了,BIOS程序自身是如何启动的呢?如果BIOS程序也是由另外一个程序来启动的,那么就无限套娃了.所以既然使用软件的方式无法完成这项任务,那么就只能通过硬件加载的方式. 从硬件角度看,Intel 80x86系列的CPU可以分别在16位实模式和32位保护模式下运行。为了兼容,也为了解决最开始的启动问题,Intel将所有80x86系列的CPU,包括最新型号的CPU的硬件都设计为加电即进入16位实模式状态运行。同时,还有一点非常关键的是,将CPU硬件逻辑设计为加电瞬间强行将CS的值置为0xF000、IP的值置为0xFFF0,这样CS:IP就指向0xFFFF0这个地址位置,而0xFFFF0指向了BIOS程序的地址范围。

image.png 关于cpu处理器的几种工作模式可以参考我的这篇笔记

✍小知识✍

  1. CS寄存器(代码段寄存器):CS寄存器存储着当前代码段的段选择子(Segment Selector),用于指示CPU应该从内存的哪一个段中执行指令。在实模式下,CS寄存器的值是一个16位的段选择子,它与基于段的内存寻址方式配合使用,从而确定执行指令的物理地址。
  2. IP寄存器(指令指针寄存器):IP寄存器存储着当前指令的偏移量(Offset),用于指示CPU在当前代码段中的具体位置。IP寄存器的值与CS寄存器的值组合在一起构成了当前执行指令的物理地址。

在段式内存管理模式(如实模式)下,物理地址的计算方式是通过将CS寄存器的值左移4位(乘以16),然后加上IP寄存器的值,得到当前指令的物理地址。

因此,在文中提到的CS:IP指的是代码段寄存器和指令指针寄存器的组合,它们一起确定了执行下一条指令的物理地址。在x86架构的实模式下,CPU在加电时会将CS寄存器的值设置为0xF000,将IP寄存器的值设置为0xFFF0,使得CS:IP指向BIOS程序的起始地址,即0xFFFF0。

CS << 4 + IP = 0xF000 << 4 + 0xFFF0
             = 0xF0000 + 0xFFF0
             = 0xFFFF0

注意,这是一个纯硬件完成的动作!如果此时这个位置没有可执行代码,那么就什么也不用说了,计算机就此死机。反之,如果这个位置有可执行代码,计算机将从这里的代码开始,沿着后续程序一直执行下去。BIOS程序的入口地址恰恰就是0xFFFF0 ! 也就是说,BIOS程序的第一条指令就设计在这个位置。

二. BIOS(基本输入/输出系统)和操作系统程序加载

2.1 BIOS简介

BIOS程序被固化在计算机主机板上的一块很小的ROM芯片里。通常不同的主机板所用的BIOS也有所不同。 这些程序包含了一系列固化的指令,用于初始化硬件、进行自检(POST,Power-On Self-Test)、引导操作系统等任务。在计算机启动时,处理器会首先执行BIOS程序,以确保系统硬件的正确初始化和操作系统的加载。

2.2 BIOS程序分别做了哪些事情

  1. 电源开启:当计算机通电时,BIOS程序被激活并开始执行,也就是我们前面说的CS:IP加电自动设置为BIOS的程序起始位置.
  2. 自检(POST):BIOS执行自我检查,检测计算机硬件的完整性和功能。
  3. 初始化:BIOS初始化硬件,包括CPU、内存、外部设备等。
  4. 加载中断向量表和中断服务程序:BIOS加载中断向量表到内存中的固定地址,并将控制权转交给操作系统。
  5. 加载操作系统:BIOS根据设定的引导顺序,尝试从引导设备(如硬盘、光盘等)的引导扇区加载操作系统的引导加载程序。
  6. 传递控制权:BIOS将控制权传递给引导加载程序,引导加载程序负责加载操作系统的内核和其他必要文件到内存中,并开始执行操作系统的初始化过程。

下面我们再来详细介绍其中的几个重要的环节.

2.3 加载中断向量表和中断服务程序

BIOS程序在内存最开始的位置(0x00000)用1 KB的内存空间(0x00000~0x003FF)构建中断向量表,在紧挨着它的位置用256字节的内存空间构建BIOS数据区(0x00400~0x004FF),并在大约57 KB以后的位置(0x0E05B)加载了8KB左右的与中断向量表相应的若干中断服务程序。 image.png 中断向量表(Interrupt Vector Table,简称IVT)是一个由固定大小的条目组成的表格,用于在计算机中管理和处理中断请求。每个条目对应一个特定的中断向量,每个中断向量代表一个可能发生的中断事件。中断向量表保存了与每个中断事件相关的处理程序的地址,以便在中断发生时能够准确地定位到相应的处理程序。 最早的中断向量表中有256个中断向量,每个中断向量占4字节,其中两个字节是CS的值,两个字节是IP的值。每个中断向量都指向一个具体的中断服务程序。当中断发生时,处理器会根据中断号(中断向量)从中断向量表中获取相应的处理程序的地址,并将控制权转交给该处理程序执行。

2.4 加载操作系统

2.4.1 加载第一扇区程序bootsect.s

从现在开始,就要执行真正的boot操作了,即把软盘中的操作系统程序加载至内存。对于Linux 0.11操作系统而言,计算机将分三批逐次加载操作系统的内核代码。第一批由BIOS中断int 0x19把第一扇区bootsect的内容加载到内存;第二批、第三批在bootsect的指挥下,分别把其后的4个扇区和随后的240个扇区的内容加载至内存。

按照我们使用计算机的经验,如果在开机的时候马上按Del键,屏幕上会显示一个BIOS画面,可以在里面设置启动设备。现在我们基本上都是将硬盘设置为启动盘。选择启动设备的作用是告诉计算机在启动时从哪个设备加载操作系统。不同的启动设备可以是硬件设备(如硬盘、固态硬盘、光盘、软盘等)或网络设备(通过网络引导)。选择启动设备的过程发生在计算机启动时,通常在BIOS(基本输入/输出系统)或UEFI(统一可扩展固件接口)画面中完成。

设置好启动盘之后,BIOS会让CPU接收到一个int 0x19中断。CPU接收到这个中断后,会立即在中断向量表中找到int 0x19中断向量,然后找到对应的中断服务程序,这个中断服务程序的作用就是把软盘第一扇区中的程序(512 B)加载到内存中的指定位置。**这个中断服务程序的功能是BIOS事先设计好的,代码是固定的,与Linux操作系统无关。**无论Linux 0.11的内核是如何设计的,这段BIOS的中断服务程序所要做的就是“找到软盘”并“加载操作系统的第一扇区代码”.(只是做了这样一个操作)

这个扇区里的内容就是Linux 0.11的引导程序,也就是我们将要讲解的bootsect,其作用就是陆续把软盘中的操作系统程序载入内存。这样制作的第一扇区就称为启动扇区(boot sector)。第一扇区程序的载入,标志着Linux 0.11中的代码即将发挥作用了。这是非常关键的动作,从此计算机开始和软盘上的操作系统程序产生关联。第一扇区中的程序由bootsect.s中的汇编程序汇编而成(以后简称bootsect)。这是计算机自开机以来,内存中第一次有了Linux操作系统自己的代码,虽然只是启动代码。至此,已经把第一批代码bootsect从软盘载入计算机的内存了。下面的工作就是执行bootsect把软盘的第二批、第三批代码载入内存。

2.4.2 bootsect.s主要做了哪些工作?

首先,想和大家明确一点,当我们使用java,golang等类似的高级语言在编写程序的时候,如果我们声明使用一个变量,或者加在一个资源赋值给某一个变量,我们不需要考虑他们在内存中的分布,这其实是因为Java、Golang和Python等高级编程语言提供了抽象层,隐藏了底层内存管理的复杂性,使程序员可以专注于解决问题而不必过多关注内存分配和管理。 在这些语言中,变量的声明和赋值只是告诉编译器或解释器需要分配多少内存来存储数据,并且编译器或解释器负责处理内存的分配和释放。这样的抽象使得编写代码更加简洁和易于理解,同时也减少了内存管理方面的错误。另外,这些语言通常具有垃圾回收机制,可以自动释放不再使用的内存,进一步简化了内存管理的过程。因此,程序员可以专注于解决业务逻辑而不必过多关注底层的内存分配和释放细节。 image.png 但是,操作系统加载程序使用的是汇编语言,没有操作系统本身为我们分配合理的内存。因此只有靠操作系统的设计者把内存的安排想清楚,确保无论操作系统如何运行,都不会出现代码与代码、数据与数据、代码与数据之间相互覆盖的情况。 所以为了把第二批和第三批程序加载到内存中的适当位置,bootsect首先做的工作就是规划内存。 下面是bootsect的部分内存规划的源码示例:

//代码路径:boot/bootsect.s
    …
.globl begtext, begdata, begbss, endtext, enddata, endbss
.text
begtext:
.data
begdata:
.bss
begbss:
.text
SETUPLEN= 4                               ! nr of setup-sectors
BOOTSEG = 0x07c0                          ! original address of boot-sector
INITSEG = 0x9000                          ! we move boot here-out of the way
SETUPSEG= 0x9020                          ! setup starts here
SYSSEG  = 0x1000                          ! system loaded at 0x10000 (65536).
ENDSEG  = SYSSEG + SYSSIZE                ! where to stop loading
! ROOT_DEV:0x000- same type of floppy as boot.
!          0x301- first partition on first drive etc
ROOT_DEV= 0x306
…

上面的代码其实就是对后续需要加载的代码资源进行了内存分配,明确指定了哪些资源应该被加载到屋里内存的什么起始位置,包括将要加载的setup程序的扇区数(SETUPLEN)以及被加载到的位置(SETUPSEG);启动扇区被BIOS加载的位置(BOOTSEG)及将要移动到的新位置(INITSEG);内核(kernel)被加载的位置(SYSSEG)、内核的末尾位置(ENDSEG)及根文件系统设备号(ROOT_DEV)。

设置这些位置就是为了确保将要载入内存的代码与已经载入内存的代码及数据各在其位,互不覆盖,并且各自有够用的内存空间。

image.png

从现在起,我们的头脑中要时刻牢记这样一个概念:操作系统的设计者是要全面地、整体地考虑内存的规划的。

bootsect先将自身代码进行复制

bootsect最开始是通过bios来进行加载的,现在,为了可以让程序的内存分配完全由操作系统自身来进行分配,于是bootsect先进行的内存分配就是将自己的代码复制到另一个由操作系统程序自己指定的新位置(其实就是上面我们说的INITSEG),执行的代码如下:

jmpi	go,INITSEG
go:	mov	ax,cs
  1. jmpi go, INITSEG
    • jmpi 是跳转指令的一种形式,它会跳转到指定的地址,并且可以指定代码段(即 CS 寄存器)的值。
    • go 是跳转的目标标签,表示程序要跳转到 go 标签所指示的位置。
    • INITSEG 是一个标号或标签,它表示代码所在的段(segment)。
  2. go: mov ax, cs
    • go 是一个标签,用于标识程序中的一个位置,之前使用 jmpi 跳转指令指向了这个标签。
    • mov ax, cs 是一条汇编指令,将当前代码段的值(即 CS 寄存器中的值)移动到 ax 寄存器中。在启动代码中,这行代码可能用于初始化 ax 寄存器,以备后续的操作。
将setup程序加载到内存中

加载setup这个程序,要借助BIOS提供的int 0x13中断向量所指向的中断服务程序(也就是磁盘服务程序)来完成。

这个中断服务程序的执行过程与图1-3和图1-4中讲解过的int 0x19中断向量所指向的启动加载服务程序不同。

  • int 0x19中断向量所指向的启动加载服务程序是BIOS执行的,而int 0x13的中断服务程序是Linux操作系统自身的启动代码bootsect执行的。
  • int 0x19的中断服务程序只负责把软盘的第一扇区的代码加载到0x07C00位置,而int 0x13的中断服务程序则不然,它可以根据设计者的意图,把指定扇区的代码加载到内存的指定位置。

将软盘第二扇区开始的4个扇区,即setup.s对应的程序加载至内存的SETUPSEG处。 现在,操作系统已经从软盘中加载了5个扇区的代码。等bootsect执行完毕后,setup这个程序就要开始工作了。

加载第三部分内核代码——system模块

内核模块代码加载完毕之后,现在,bootsect程序的任务都已经完成!

到此为止,操作系统内核程序的加载工作已经完成。接下来的操作对Linux 0.11而言具有战略意义。系统通过已经加载到内存中的代码,将实现从实模式到保护模式的转变,使Linux 0.11真正成为“现代”操作系统。

下面要通过执行“jmpi 0, SETUPSEG”这行语句跳转至0x90200处,就是前面讲过的第二批程序——setup程序加载的位置。CS:IP指向setup程序的第一条指令,意味着由setup程序接着bootsect程序继续执行。下图形象地描述了跳转到setup程序后的起始状态,对应的代码如下: image.png

开始向32位模式转变,为main函数的调用做准备

接下来,操作系统要使计算机在32位保护模式下工作。这期间要做大量的重建工作,并且持续工作到操作系统的main函数的执行过程中。在本节中,操作系统执行的操作包括打开32位的寻址空间、打开保护模式、建立保护模式下的中断响应机制等与保护模式配套的相关工作、建立内存的分页机制,最后做好调用main函数的准备。

2.5 操作系统开始发挥作用

2.5.1 关闭中断功能

接下来,操作系统要使计算机在32位保护模式下工作。这期间要做大量的重建工作,并且持续工作到操作系统的main函数的执行过程中。在操作系统执行的操作包括打开32位的寻址空间、打开保护模式、建立保护模式下的中断响应机制等与保护模式配套的相关工作、建立内存的分页机制,最后做好调用main函数的准备。 关闭中断,即将CPU的标志寄存器(EFLAGS)中的中断允许标志(IF)置0。这意味着,程序在接下来的执行过程中,无论是否发生中断,系统都不再对此中断进行响应,直到main函数能够适应保护模式的中断服务体系被重建完毕才会打开中断,而那时候响应中断的服务程序将不再是BIOS提供的中断服务程序,取而代之的是由操作系统自身提供的中断服务程序。

2.5.2 打开A20,实现32位寻址

A20 是指 Intel x86 架构中的一个地址线。在早期的 Intel 80286 和 80386 处理器中,处理器的地址总线是 20 位的,这意味着它最多可以寻址 2^20个不同的内存地址,也就是 1 MB。但是,在实际中,有时候需要访问超过 1 MB 的内存。为了支持这种访问,Intel 在设计中引入了一个叫做 A20 控制线的功能。 A20 控制线的作用是控制地址线 20,允许处理器访问超过 1 MB 的内存空间。在启动时,A20 控制线可能被禁用,这意味着只能访问 1 MB 内存空间。要想访问超过 1 MB 的内存,需要打开 A20 控制线。为了打开 A20 控制线,需要向相应的控制寄存器发送特定的命令。这通常通过在汇编语言或低级语言中编写相应的指令来实现。一旦 A20 控制线被打开,处理器就可以访问超过 1 MB 的内存空间,实现了 32 位寻址能力。 CPU可以进行32位寻址,最大寻址空间为4 GB。即0xFFFFFFFF——4 GB。

image.png

2.5.3 为保护模式下执行head.s做准备

为了建立保护模式下的中断机制,setup程序将对可编程中断控制器8259A进行重新编程。

2.5.4 从setup程序跳转到head程序

jmpi   0, 8

到这里为止,setup就执行完毕了,它为系统能够在保护模式下运行做了一系列的准备工作。但这些准备工作还不够,后续的准备工作将由head程序来完成。

2.5.5 head程序

在执行main函数之前,先要执行三个由汇编代码生成的程序,即bootsect、setup和head。之后,才执行由main函数开始的用C语言编写的操作系统内核程序。

head程序与它们的加载方式有所不同。大致的过程是,先将head.s汇编成目标代码,将用C语言编写的内核程序编译成目标代码,然后链接成system模块。也就是说,system模块里面既有内核程序,又有head程序。两者是紧挨着的。要点是,head程序在前,内核程序在后,所以head程序名字为“head”。head程序在内存中占有25 KB + 184 B的空间。前面讲解过,system模块加载到内存后,setup将system模块复制到0x00000位置,由于head程序在system的前面,所以实际上,head程序就在0x00000这个位置。head程序、以main函数开始的内核程序在system模块中的布局示意图如图: image.png

head程序还会初始化页目录表和页表,并将它们放置在内存起始位置,为后续的内存管理操作做好准备,确保操作系统能够有效地管理内存并控制进程的安全运行。

分页机制是一种操作系统的内存管理技术,用于将物理内存划分成固定大小的块,称为页面(Page),并将逻辑地址空间划分成与物理页面相同大小的块,称为页(Page)。每个页面都映射到物理内存中的一个页面,这样程序就可以使用逻辑地址来访问内存,而不必关心内存的实际物理地址。 在分页机制下,操作系统维护一个页表(Page Table),其中记录了每个页的映射关系,即逻辑地址到物理地址的映射。当程序访问内存时,CPU会将逻辑地址发送给内存管理单元(MMU),MMU会根据页表将逻辑地址转换成物理地址,然后访问对应的物理内存。 分页机制的优势包括:

  1. 虚拟内存:分页机制为每个进程提供了一个独立的虚拟地址空间,使得每个进程都可以认为自己在独占一整块内存,从而提高了内存的利用率。
  2. 内存保护:通过分页机制,操作系统可以将不同进程的页表分开存放,从而实现进程间的内存隔离和保护。
  3. 内存共享:多个进程可以共享同一个物理页面,从而实现内存共享,减少了内存的重复使用。
  4. 页面置换:当物理内存不足时,分页机制可以通过页面置换算法将不常用的页面置换到磁盘上,从而释放出物理内存。

因此,分页机制是现代操作系统中一种重要的内存管理技术,为多道程序设计提供了基础,并且可以提高内存的利用率和系统的性能。在操作系统的笔记中我有详细介绍内存分页机制,欢迎大家参考。

head程序做好一切进入main函数的铺垫之后,就执行ret指令,cpu就会开始执行操作系统main函数代码了。

ret 指令是汇编语言中的一种指令,用于从子程序(或者称为函数)中返回到调用它的位置。

三.总结

学过C语言的人都知道,用C语言设计的程序都有一个main函数,而且是从main函数开始执行的。Linux 0.11的代码是用C语言编写的。奇怪的是,为什么在操作系统启动时先执行的是三个由汇编语言写成的程序,然后才开始执行main函数;为什么不是像我们熟知的C语言程序那样,从main函数开始执行呢。通常,我们用C语言编写的程序都是用户应用程序。这类程序的执行有一个重要的特征,就是必须在操作系统的平台上执行,也就是说,要由操作系统为应用程序创建进程,并把应用程序的可执行代码从硬盘加载到内存。现在我们讨论的是操作系统,不是普通的应用程序,这样就出现了一个问题:

从前面的节中我们知道,加载操作系统的时候,计算机刚刚加电,只有BIOS程序在运行,而且此时计算机处在16位实模式状态,通过BIOS程序自身的代码形成的16位的中断向量表及相关的16位的中断服务程序,将操作系统在软盘上的第一扇区(512字节)的代码加载到内存,BIOS能主动操作的内容也就到此为止了。准确地说,这是一个约定。对于第一扇区代码的加载,不论是什么操作系统都是一样的;从第二扇区开始,就要由第一扇区中的代码来完成后续的代码加载工作。

原因是,Linux 0.11是一个32位的实时多任务的现代操作系统,main函数肯定要执行的是32位的代码。编译操作系统代码时,是有16位和32位不同的编译选项的。如果选了16位,C语言编译出来的代码是16位模式的,结果可能是一个int型变量,只有2字节,而不是32位的4字节……这不是Linux 0.11想要的。Linux 0.11要的是32位的编译结果。只有这样才能成为32位的操作系统代码。这样的代码才能用到32位总线(打开A20后的总线),才能用到保护模式和分页,才能成为32位的实时多任务的现代操作系统。

head.s做的就是这项工作。这期间,head程序打开A20,打开pe、pg,废弃旧的、16位的中断响应机制,建立新的32位的IDT……这些工作都做完了,计算机已经处在32位的保护模式状态了,调用32位main函数的一切条件已经准备完毕,这时顺理成章地调用main函数。后面的操作就可以用32位编译的main函数完成。至此,Linux 0.11内核启动的一个重要阶段已经完成,接下来就要进入main函数对应的代码了。特别需要提示的是,此时仍处在关闭中断的状态!

综上在操作系统的启动过程中,可以分为两个主要阶段:加载操作系统和为32位保护、分页模式下的 main 函数执行做准备。

  1. 加载操作系统阶段:

该阶段从借助 BIOS 将 bootsect.s 文件加载到内存开始。 随后,相继加载 setup.s 文件和 system 文件,完成了操作系统程序的加载。 在这个阶段,操作系统的关键部分被加载到内存中,为后续的执行做准备。

  1. 为32位保护、分页模式下的 main 函数执行做准备阶段:

该阶段开始设置操作系统运行所需的关键环境,使得操作系统能够在32位保护、分页模式下正常运行。 首先设置 IDT(中断描述符表)和 GDT(全局描述符表),以处理异常和中断。 接着设置页目录表和页表,建立分页机制,以实现虚拟内存的管理。 为了确保系统正常运行,还会设置一些机器系统数据。 一旦一切就绪,跳转到 main 函数的执行入口,开始执行 main 函数。 通过以上步骤,操作系统顺利地完成了启动过程,并为后续的程序执行做好了准备。这个过程是操作系统启动的关键部分,确保了操作系统能够在硬件上正确运行,并为用户提供服务。

四.感谢以下技术大佬们的文档

参考书籍: Linux 内核设计的艺术(第2版)ISBN: 9787111421764 操作系统导论(原名:Operating Systems: Three Easy Pieces) ISBN: 9787115508232 参考github源码解析: linux0.11源码 linux0.12源码

!!!linus大佬的境界无人能敌!!! image.png