抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

这里提到的 linux 版本是 0.11,在当时,操作系统都是存在软盘里的,而 CPU 的逻辑电路被设计为只能运行内存中的程序。电路刚通电时,内存(RAM)里什么都没有,那么计算机是怎样将操作系统运行起来的呢?

写在前面

这里约定的计算机是基于 IA-32 系列 CPU,安装了标准单色显示器、标准键盘、一个软盘、一块硬盘、16 MB 内存,在内存中开辟了 2 MB 内存作为虚拟盘,并在 BIOS 中设置软驱为启动设备。

为什么选 Linux 0.11 内核?

  • Linux 0.11 内核代码虽然只有约两万行,但却实实在在的撑起了一个现代OS,更便于初学者学习理解;
  • 它是 Linux 其他后续版本的鼻祖,这样我们能更容易的看清设计者最初的、最根本的设计意图和设计指导思想;

系列参考资料:

  • 《Linux 内核设计的艺术-图解 Linux 操作系统架构设计与实现原理(第 2 版)》
  • 《IA-32-3》
  • 《Linux 内核完全注释》- 内核版本0.11 - 赵炯

说明:该系列是本人结合国科大相关课程总结的笔记心得,按书籍教程主要分为三大部分:

  1. 分析从开机加电到 OS 启动完成并进入怠速状态的整个过程:
    • 开机加电启动 BIOS,通过 BIOS 加载操作系统程序,对主机的初始化,打开保护模式和分页,调用 main 函数,创建进程 0、进程 1、进程 2 以及 shell 进程,并且具备用文件的形式与外设交互。
  2. OS 进入系统怠速后,在执行用户程序的过程中,OS 和用户进程的实际运行过程和状态:
    • 利用几个设计好的简单的又具有代表性的应用程序,以其执行作为引导,详细讲解安装文件系统、文件操作、用户进程与内存管理、多个进程对文件的操作以及进程间的通信。
  3. 整个 Linux 的设计指导思想,从微观到宏观的回归分析:
    • 重点部分:详细阐述主奴机制以及实现主奴机制的三项关键技术(保护和分页、特权级、中断),分析保障主奴机制实现的决定性因素–先机,详细讲解缓冲区、共享页面、信号、管道的设计指导思想。(作者尝试从操作系统设计者的视角讲解操作系统的设计指导思想。希望帮助读者用体系的思想理解、把握、驾驭整个操作系统以及背后的设计思想和设计意图)

BIOS 启动

前面说到 CPU 只能执行内存里的程序,而刚通电时,计算机内存为空,OS 处在软盘。那么,自然就需要由某个部分将 OS 加载到内存,而完成这项工作的就是 BIOS

问题就来了,BIOS 自身也是一段程序,那它最开始又是如何执行的呢?

这一过程得益于硬件 CPU 强制执行 BIOS。CPU 都被设计为加电即进入 16 位实模式状态运行,其硬件逻辑在加电瞬间就会设置 CS(0xF000)和 IP(0xFFF0),这样 CS:IP 也就指向 0xFFFF0 这个地址,而这里就处在 BIOS 中,是 BIOS 程序的入口地址

  • CPU 将要执行的指令的内存地址是由 IP(偏移)和 CS(段基址)组合来指定。实模式下该地址为绝对地址,指令指针为 16 位;保护模式下为线性地址,指令指针为 32 位,即 EIP。
  • BIOS 是固化在计算机主板上一块很小的 ROM 芯片里的程序。不同主板会有所不同,这里的 BIOS 是 8KB,所占地址段为 0xFE000 ~ 0xFFFFF

BIOS 执行

BIOS 启动后,开始执行一系列 BIOS 代码,完成自检等操作。检测显卡、内存等,并在屏幕上显示这些机器系统数据(显卡信息、内存信息等)。而这个过程需要重点说的就是:BIOS 在内存里建立中断向量表和中断服务程序

具体来说就是:
Mem-BIOS

  • 0x00000 ~ 0x003FF 这 1 KB 的内存空间里构建中断向量表
    • 一共有 256 个中断向量,每个占 4 B(2 B 为 CS 的值,2 B 为 IP 的值);
  • 在紧接着的 0x00400 ~ 0x004FF 这 256 B 的内存空间里构建 BIOS 数据区(存放机器系统数据);
  • 在接着大约 57 KB 之后的位置 0x0E05B 处加载了 8 KB 左右的与中断向量表相对应的中断服务程序

在上述自检过程完成后,计算机硬件(体系结构的设计)和 BIOS 一同产生一个 int 0x19 中断。CPU 收到此中断后,查找中断向量表,并找到 int 0x19 中断向量。这个中断向量就将 CPU 指向 0x0E6F2 这个地址执行(int 0x19 对应的中断服务程序的入口地址)。这个中断服务程序是 BIOS 事先设计好的(代码固定,与 OS 无关),它的工作就是:把软盘第一扇区中的程序 bootsect.s(512 B,因此两个扇区为 1 KB)加载到内存指定位置(0x07C00

这个过程就需要 OS 设计者和 BIOS 的设计者完成一种两头约定:

  • 操作系统:必须把最开始执行的程序 “定位” 在启动扇区(软盘中的 0 盘面 0 磁道 1 扇区);
  • BIOS:“约定” 接到启动操作系统的命令,就 “定位识别” 只从启动扇区把代码加载到 0x7C00(BOOTSEG)这个位置;

bootsect.s

在实模式下,指令指针 CS 和 IP 都是 16 位的,它们组合的内存寻址最大范围为 1 MB(20 位)。而 bootsect.s 首先的工作就是对这 1 MB 的内存空间进行规划,来确保后续代码的加载与已加载的代码互不干扰,并且都有足够大的内存空间可用;
MemPlanning-bootsect

  • bootsect.s 的开头会设定一段代码来设置相应的内存位置:
    • 将要加载的 setup 程序的扇区数(SETUPLEN)以及它要被加载到的位置(SETUPSEG);
    • 启动扇区 bootsect 被 BIOS 重新加载的位置(BOOTSEG)及将要移动到的新位置(INITSEG);
    • 内核(kernel)被加载的位置(SYSSEG)、内核的末尾位置(ENDSEG);
    • 根文件系统设备号(BOOT_DEV);

接着,booesect 按之前的内存规划将自己全部(512 B)的内容从 0x07C00BOOTSEG拷贝到新的内存位置 0x90000INITSEG)。

  • 由于之前说过的 OS 设计和 BIOS 设计的约定,才会在开始时 bootsect 被迫被加载到 0x07C00 的位置。现在将自身拷贝的 0x90000 处,则说明 OS 已经开始根据自身需要安排内存了;

拷贝完成后,但是 CPU 的 CS 还在原先的 0x07C0(BOOTSEG)处,于是需要修改 CS 值为 0x9000(INITSEG),并修改 IP 偏移值至下一条指令。这样,使程序在新的地方接着原先的过程执行。此时,代码整体位置改变,代码中的各个段也会随之改变,所以还需要设置 DS、ES、SS、SP 的值。这也是个分水岭,至此,程序可以开始执行更为复杂的数据运算类指令。
JumpAndSSSPDSES-bootsect

接下来,bootsect 还需要负责将 os 剩余的代码(setup.ssystem 模块)拷贝进内存中。整个拷贝过程分两次进行:

  • 拷贝 setup.s
    • 传参:事先将指定的扇区、加载的内存位置等信息传递给服务程序;
    • 参数传递完毕后,执行 int 0x13 指令,产生 int 0x13 中断,通过中断向量表找到对应的中断服务程序(0x0E6FE),将软盘第二个扇区开始的 4 个扇区(setup.s,2 KB)加载至内存的 SETUPSEG(0x90200)处,紧挨着重新拷贝后的 bootset.s;(注意:它还不足以影响到栈的内存空间);
  • 拷贝 system 模块(包括 head.s 和 main 函数开始的 os 内核程序):
    • bootsect 同样借助 BIOS 的 int 0x13 中断,调用 read_it 子程序,将软盘第六扇区开始的 240 个扇区的 sysytem 模块加载至内存的 SYSSEG(0x10000)处往后的 120 KB 空间中;
    • 整个加载过程时间较长,所以也会有一段用汇编编写的程序,使显示器显示 “Loading system…”,也涉及到其他 BIOS 中断;
  • system 模块是整个 OS 的核心部分,它由两部分构成:head.s 和 main 函数。
  • head.s 同之前加载的 bootsect.s 和 setup.s 一样,是由汇编代码生成的程序,它处在 system 的开头位置,在内存中占有 25 KB + 184 B 的空间;
  • main 函数紧接着 head.s 存在,它是由 c 语言编写的内核程序,可以说它才是真正的操作系统,在它之前执行的 bootsect.s、setup.s 和 head.s 都是为它做准备的;
  • 这种特殊性,也让 syetem 模块和 bootsect.s、setup.s 的加载方式不同。大致的过程是:先将 head.s 汇编成目标代码,将用 c 语言编写的内核程序编译成目标代码,然后再将它们链接成 system 模块。

至此,整个 OS 的代码都已被加载进内存。最后,由 bootsect 确认根设备号。根据开始处假设的计算机组成,这里经过一系列检测后,会确认计算机中实际安装的软盘驱动器为根设备,并将此信息写入机器系统数据。

bootsect.s 的工作已全部完成。

setup.s

在 bootsect.s 完成它所有的任务后,会执行 jmpi 0,SETUPSEG 跳转0x90200 处(setup.s 所在的位置),也即 CS:IP 指向 setup 程序的第一条指令,setup.s 开始执行

首先,setup.s 会利用 BIOS 提供的中断服务从设备上提取内核运行所需的机器系统数据 510 B(注意这里加载的不是 BIOS 数据区里的),并将这些数据加载到内存的 0x90000 ~ 0x901FC 位置。
LoadMachineSystemData-setup

  • 注意:加载的这部分机器系统数据是覆盖了原先已经执行完的 512 B 的 bootsect.s,只有 2 B 未被覆盖,这样很好的利用了内存空间,使内存利用率更高;

至此,整个操作系统内核程序所需的加载工作都已完成,通过内存中的这些代码数据,将开始实现从实模式到保护模式的转变

之前,我们所有用到的中断服务都是由 BIOS 提供,而之后 OS 会接管整个系统,它自身也会提供一套的中断服务系统。为了完全这个转变,不让接下来的工作出错,此时就得先关中断(cli)。即:将 CPU 的标志寄存器(EFLAGS)中的中断允许标志(IF)置 0

  • 关中断(cli)和开中断(sti)它们在 OS 里总是成对出现的,目的就是为了防止中断在某个过程中介入;
  • 此处,即将进行的是实模式下中断向量表和保护模式下中断描述符表(IDT)的交接。如果在此期间,用户不小心碰到键盘等引入了中断,就会导致不可预估的后果;

接下来,setup 程序会将位于 0x10000 的内核程序复制至内存的起始地址 0x00000(由 DS 和 ES 配合完成)。

  • 这个过程也就意味着,原先 BIOS 提供的中断向量表、BIOS 数据区以及相应的中断服务程序都完全被覆盖,旧的中断体系已经失效,系统准备向 32 位的保护模式转变;

然后,setup 程序利用自身提供的数据信息对中断描述符表寄存器(IDTR)和全局描述符表寄存器(GDTR)进行初始化设置,进而完成中断描述符比表(IDT)和全局描述符表(GDT)的初始化

  • GDT(全局描述符表):是一个存放段寄存器内容(段描述符)的数组,用来配合程序进行保护模式下的段寻址。在 OS 中进程切换具有重要意义,可理解为所有进程的总目录表,其中存放着每一个任务(task)局部描述符表(LDT)地址和任务状态段(TSS)地址,完成进程中各段的寻址、现场保护与现场恢复;
  • GDTR(GDT 基址寄存器):GDT 可以存放在内存中任意位置,而 GDTR 的内容就是标识 GDT 的入口地址;
  • IDT(中断描述符表):保存保护模式下所有中断服务程序的入口地址;
  • IDTR(IDT 基址寄存器):保存 IDT 起始地址;
  • 这个过程可以理解为:内核设计时,已经将两个表(包括所需的初始数据)写好,随着 OS 代码一起已经被加载到了内存,之后程序通过 lidtlgdt 指令将两个专用寄存器指向这两个表(即 idt_48 和 gdt_48 对应的标号处)即可;
    SetGDTRAndIDTR
    • 由图可以看到:IDTR 指向了 0x00000 位置(IDT 基址),此时出于关中断状态,IDT 还是一张空表;
    • GDTR 指向了 0x90200+ 位置(现在创建的 GDT 基址),此时内核还未运行没有进程,GDT 的内容也就如图所示,其余项全为空;

接下来的工作就是:打开 A20这也就意味着 CPU 可以进行 32 位寻址,最大寻址空间为 4 GB。下图显示了此时内存寻址空间的变化(注意区别):
MemAddressingChange

  • 此时,物理地址空间虽然受限于 16 MB,但是其线性地址空间已经变成了 4 GB;
  • 实模式下,CPU 寻址范围为 0 ~ 0xFFFFF,共 1 MB,需要 0 ~ 19 号(20 根)地址线。进入保护模式,使用 32 位寻址,自然也就需要打开剩余的 20 ~ 31 号地址线;
  • 实模式下,CS 和 IP 的最大值都为 0xFFFF,这两者组合而成的最大绝对地址为 0x10FFEF(16*CS+IP),而此时 CPU 最大寻址为 0xFFFFF。对于超出的这 16 KB 地址,CPU 会将其 “回滚” 至内存起始处寻址。而启用 A20 后,相当于关闭了 CPU 在实模式下的 “回滚” 机制,也就正好可以利用这个特点验证 A20 地址线是否已经打开;

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

之后,setup 还需要将 CR0(0 号 32 位控制寄存器,存放系统控制标志)寄存器的第 0 位(PE)置 1,即开启 CPU 的工作方式为保护模式;此后,寻址方式变化如下:
ChangeToAddressing

至此,setup 的工作全部完成,它还有最后一行代码 jmpi 0,8,跳转至 head 程序起始地址开始执行

  • 关于 0,8,对应到二进制也就为:0000,1000
    • 最后两位 00:表示内核特权级,对应的 11 就是用户特权级,其他形式无效;
    • 第三位 0:表示 GDT,如果为 1 则表示 LDT;
    • 再接下来的 1:表示所选的表(GDT)的 1 项(GDT 表项从 0 开始);
    • 由这些信息就可以推算出:内核代码段(0x08)、内核数据段(0x10)、用户代码段(0x17)、用户数据段(0x0F)
  • 再对整个的跳转指令寻址进行解读:
    • jmpi 0,8:它是处在内核级,会选择 GDT 的 1 项(代码段),这一项的解读如下:
      GDT1item
    • 由此确定段基址为 0x00000000,偏移为 0,也即 head 程序的起始位置处;

head.s

经过前面一连串的准备工作,这里终于进入到了 system 模块,但是这并不意味着就进入到了 main 函数的执行。前面加载时说过,在 system 模块的开头还有一段叫做 head.s 的代码。此时的 system 在内存的分布情况如下:
SystemInMem

head 程序的除了做一些调用 main 函数的准备工作之外,还会用程序自身的代码在程序自身所在的内存空间创建内核分页机制,即在 0x000000 的位置创建页目录表、页表、缓冲区、GDT、IDT,并将 head 程序已经执行过的代码所占内存空间覆盖(自身边执行,边自我覆盖)。具体过程如下:

标号 _pg_dir 标识内核分页机制完成后的内核起始位置,即物理内存起始位置 0x000000

前面的 jmpi 0,8,已经使得 CS 和 GDT 第二项关联(CS 不再是代码段基址而是代码段选择符),并且使代码段基址指向 0x000000。相应的,也需要修改 DS、ES、FS 和 GS 的值为 0x10(内核数据段);具体内容如下图:
DSESFSGS-head

SS 也需要变为栈段选择符(也是 0x10),栈顶指针也由 16 位的 SP 变为 32 位的 ESP(并且起始位置大概在 0x1E25C
SSESP-head

设置 IDT,搭建中断机制的整体框架,具体内容如下:

  • IDT 有 256 个表项,每个表项占 64 位,因此其大小为 2 KB。每个表项(中断描述符)结构如下:
    InterruptDescriptor
  • 设置相关的 IDT 寄存器:
    IDT-head
  • 让所有的中断描述符默认指向 ignore_int 这个位置(将来 main 函数再让中断描述符对应到具体的中断服务程序),即此时所有的中断服务程序都是指向同一段只显示一行提示信息就返回的服务程序(这样,既可以避免无意中覆盖代码数据,也可给开发过程中的误操提供提示);

废除旧的 GDT,并在内核新的位置重新创建 GDT
RebuildGDT-head

  • 重新设置一方面是设置新的位置,另一方面是修改段限长,这样做的目的也明显:旧 GDT 由 setup.s 设置,处在 0x90200+ 的位置,而现在它已经执行完毕,未来它所处的内存位置必然会被替换为别的用途(缓冲区),这样只好重新将它放在 head.s 已经执行完的位置最好,物尽其用。

段限长被修改了,那么相应的 DS、ES、FS、GS 及 SS 的段选择符也需要重新设置(再来一遍 0x10,重新加载 GDT 相应表项,这只是在原有基础上将段限长扩大为 16 MB,其他内容并未发生改变);ESP 不需要;
DSESFSGS2-head

检验 A20 地址线是否打开,检验方法就是之前提及的 “回滚法”,具体如下:

  • 通过在内存 0x000000 位置写入一个数据,然后比较此处和 1 MB(0x100000,已超过实模式寻址范围)处数据是否一致;

head 程序如果检测到数学协处理器,则将其设置为保护模式工作状态;

  • 486 处理器以前,为弥补 CPU 浮点运算不足设置的外置可选芯片。486 处理器之后,CPU 就都基本内置了协处理器,也就不需要此步;

接下来,就是 head.s 为调用 main 函数做最后的准备工作

  • 将 envp、argv、argc 压栈:将 L6 标号(也就是 main 函数异常退出后返回的执行位置)和 main 函数入口地址压栈。此时,栈顶为 main 函数的入口地址,这样在 head.s 执行完毕后,就可以直接通过 ret 指令执行 main 函数;
    PushForMain
  • 压栈完成后,head.s 跳转至 setup_paging: 执行,开始创建分页机制(下面创建的 4 个页表是内核专属页表,将来每个用户进程也会有它们自己的专属页表):
    SetPageTable
  • 页表设置完毕,但分页机制还没完全建立,还需要设置页目录表基址寄存器 CR3(3 号 32 位控制寄存器,高 20 位存放页目录表基址,当 CR0 的 PG=1 时,CPU 使用 CR3 指向的页目录表和页表进行虚拟地址到物理地址的映射)使之指向页目录表,再将 CR0 寄存器的最高位(PG,分页机制控制位,它必须在 PE=1 的保护模式下才能开启)置 1。此时分页机制完成后,总体状态如下:
    PageTableStatus
    • 注意:标号 pg_dir 标识的 0x0000 这个位置是内核通过分页机制能够实现线性地址等于物理地址的唯一起始位置;
  • 此时的内存分布情况如下(注意 head.s 的大小和内存分布的情况):
    MemDistribution-head

现在,就是 head.s 的最后一步,通过 ret 指令,将压入栈的 main 函数的执行入口地址弹出给 EIP,跳入 main 函数开始执行;

  • 这里需要说明一下,OS 的 main 函数和其他普通函数的调用过程不太一样;
  • 通常函数:都是通过 call 指令调用,它会将 EIP 的值自动压栈,保护返回现场,然后执行被调函数的程序。等到执行被调函数的 ret 指令时,自动出栈给 EIP 并还原现场,继续执行 call 的下一条执行;
  • 而操作系统则不同,它是用 ret 实现的调用操作系统的 main 函数。事先由 OS 的设计者手工编写代码压栈(压栈的 EIP 值为 main 函数的入口地址)和跳转,来模仿 call 的全部动作,进而去调用 setup_paging 函数。然后,当 setup_paging 函数执行到 ret 时,从栈中将 main 函数的入口地址 _main 弹出给 EIP,构成 CS:EIP,这也就等价于 CPU 开始执行 main 函数程序;

之后,操作系统 main 函数开始执行,但是注意:此时仍然是关中断的状态!