一台装有 GNU/Linux 的计算机,从按下电源键开始,进行了一系列操作,最终显示出登录界面。本文对这一系列操作及一些涉及的概念进行梳理。

从按下电源键到显示登入界面,大概分为以下几个阶段:

  • BIOS 阶段
  • 内核装载阶段
  • 启动 init 进程
  • init (systemd)
  • 启动登入界面

BIOS 阶段

在 BIOS 阶段,计算机的行为 基本上被写死了,可以做的事情并不多。一般此阶段只进行通电、BIOS、引导程序、操作系统这四步。

从通电到 BIOS 的过程

下面以 x86 架构 CPU 为例,介绍通电到 BIOS 的过程。

  • 当我们按下电源键,主板会向电源组发出信号。接收到信号后,电源会提供合适的电压给计算机。

  • 当主板收到电源正常启动的信号后,主板会启动 CPU。启动时,CPU 将所有寄存器等中的数据进行重置,其中:

    • 代码段寄存器 CS 可见部分(CS)被初始化成 0xF000;

    • CS 不可见部分 (CS base) 被初始化成 0xFFFF0000;

    • 指令指针(IP)被初始化成 0xFFF0

  • 按上述值初始化后,CS:IP 初始值为 0xF000:0xFFF0,合并基地址后,可以得到第一条指令的地址是 0xFFFFFFF0。这个地址是 BIOS 的地址,实际上这个时候就开始 BIOS 的执行了

  • CPU 将继续执行以下内容:

    • CPU 到 ROM 中的 0xFFFFFFF0 处执行 BIOS 代码。然而,该地址处是一条 JUMP 代码,会重新设置 CS:IP 到 0xF000:E05B (写死的哈),继续执行其他 BIOS 代码。
  • 在 Legacy Boot 模式下,BIOS 会对外设进行自检,看看 RAM、显示器、键盘、鼠标、硬盘等外设是否完好。外设一切完好后,BIOS 会开始装载 MBR 的分区,执行基本装载程序。

  • 在 UEFI Boot 模式下,BIOS 的行为会稍有复杂一些,会依次执行 SEC (安全阶段)、PEI (Pre-EFI 阶段)、DXE (执行驱动配置环境阶段)、BDS (启动设备选择) 等几个阶段,初始化整个 EFI 环境。参考资料

MBR (Master Boot Record)

MBR,即 Master Boot Record,中文名称为「主引导记录」,位于磁盘上 第一个 逻辑扇区,也就是 0 磁道 0 柱面 1 扇区。MBR 所在的扇区又叫做「主引导扇区」,其中包含 基本装载程序(IPL,Initial Program Loader) 和 一个 分区表(DPT,Disk Partiton Table)

提前说明:任何硬盘,不论是采用传统 MBR 布局的还是新式 GPT 布局的硬盘,都 一定会 有一个 MBR 扇区。

基本装载程序

  • 基本装载程序」拥有 446 Bytes 的空间,用于拉起操作系统的。

  • 基本装载程序的⼯作目标是:在 整块硬盘上 寻找并加载 高级装载程序。基本装载程序通过分析分区表,找出 活动主分区 来完成这个任务。

    • MBR 体系下,活动主分区 在同一时间内只能拥有一个。

    • 基本装载程序首先会扫描分区表,找到唯一的那个 活动主分区

    • 找到后,基本装载程序会去把 活动主分区 的起始扇区(分区引导扇区,DBR)中的内容(也就是 高级装载程序的入口)读到 RAM 的特定位置(现在的 PC 机器一般在内存 0x7C00 位置),然后将指令指针挪到上述内存位置上,开始执行 高级装载程序 来加载系统。

  • GNU/Linux 系统通用的 Boot Loader 是 GRUB,它同时包含基本和高级装载程序。一般地,在安装 GNU/Linux 系统时,其所在磁盘的 MBR 扇区会被默认写入 GRUB 的基本装载程序(又称 GRUB Stage 1),它会被用来寻找 GRUB 的高级装载程序(又称 Stage 1.5、Stage 2)。

MBR 的分区表

  • MBR 中的分区表的空间只有 64 Byte,每个主分区占 16 Byte。

  • 由于只有 16 Byte 的空间用来记录每个分区的开始及结束地址,按照 MBR 分区表的格式定义,有 4 Byte 用来存储该分区的总扇区数,因此一个分区最多只能有 2^(4 * 8) = 2^32 个扇区,每个扇区存储 512 Byte 数据,总共 2^32 * 512 = 2^41 Byte,大概是 2 TB 多一点点的空间

  • 所以,MBR 分区表最大支持 2 TB 左右分区的寻址。

分区格式

磁盘分区格式目前主要有 MBRGUID/GPT 两种。

  • MBR 格式是前面叙述的 MBR 分区表所能描述的一种磁盘布局格式。按照 MBR 分区表定义 ——

    • MBR 分区表只支持一个硬盘上最多 4 个 主分区

    • MBR 分区表不支持 2TB 以上硬盘容量。

  • 不过,随着硬盘越做越大,4 个主分区和 2 TB 硬盘容量已不够人们使用。同时,MBR 定义的分区方式比较繁琐复杂;重装系统的过程中也会重复往 MBR 扇区写东西,容易造成 MBR 扇区报废,使得磁盘不再具备引导功能。于是产生了新的 GUID/GPT 分区格式。

  • GUID/GPT 分区格式支持的分区数量没有限制,也没有主分区的概念。

    • 需要注意的是 GUID/GPT 分区格式是 建立在 MBR 分区表基础之上 的。也就是说,GUID/GPT 格式的硬盘,也必会有一个 MBR 扇区。不同的是,GUID/GPT 格式的硬盘的 MBR 扇区是只读的「保护扇区」(也叫 MBR 兼容区块)。与 MBR 格式相似,这个「保护扇区」也分为两个部分,其中一个是存储「基本装载程序」的区域;另一个原本存放分区表的区域则仅 放入一个特殊标志符,用来表示此磁盘为 GPT 格式。

    • 支持 GUID/GPT 分区表的计算机,当识别出磁盘头部是特殊的 MBR 扇区时,就会认为这块硬盘是 GPT 格式的硬盘。GPT 分区格式的 MBR 扇区之所以被称为「保护扇区」,是因为它可以保证老式磁盘工具不会把 GPT 磁盘当作没有分区的空磁盘处理而用 MBR 覆盖掉本来存在的 GPT 信息,这样,不懂 GPT 分区表的磁盘管理程序,就会乖乖误认为整个磁盘只有 1 个分区,不会重建 MBR 信息,保护了磁盘的向下兼容性。

    • 可以这么理解:GUID/GPT 格式在整块硬盘上只划出一块儿 MBR 分区,并在这块分区上实现了逻辑分区的功能。下图中,LBA 0 处代表 MBR 扇区。

UEFI

  • 前面介绍,MBR 体系中,基本装载程序会到活动主分区的引导扇区中去读取高级装载程序,用高级装载程序来装入操作系统。这些由主板 BIOS 执行的、加载「高级装载程序」的流程,被称为老式引导(Legacy Boot)。

  • Legacy Boot 引导方式需要通过 MBR 分区图来查询活动分区,并从活动分区中加载 高级装载程序。由于 GPT 制式的硬盘不使用 MBR 分区表来管理磁盘分区,所以就不存在活动分区概念,因此,Legacy Boot 很自然的就不支持 GPT 格式硬盘

  • 一般而言,分区格式和引导方式存在两种组合:Legacy + MBR,UEFI + GPT。在 GPT 制式硬盘上,目前使用的是 UEFI (Unified Extensible Firmware Interface) 引导方式。这种方式需要主板支持。理论上,UEFI 引导方式是不挑硬盘制式的,但是微软曾规定过 UEFI 模式引导 Windows 仅支持 GPT 制式硬盘。

  • 许多新版系统都只支持 UEFI 引导。当前这种引导方式更为主流。

高级装载程序的加载

Legacy Boot、UEFI 加载高级装载程序的做法有一定不同。下面以 Debian 的 GRUB 为例,介绍两种 Boot 方式从主板上电到加载出 GRUB 菜单的流程。

Legacy Boot

Legacy Boot 流程加载高级装载程序(以 Debian + GRUB 为例)的流程如下。

  • (Stage 1)主板上电后,BIOS 会进行自检。自检结束后,BIOS 会读取并执行 MBR 中的 Boot Loader。这时候便进入了 GRUB 的 Stage 1。

    • Stage 1 的主要任务是:完成操作系统的自举,并将控制权交给操作系统。

    • 安装 GNU/Linux 操作系统时,一般都会进行 重建 MBR,也就是将 MBR 中原本的基本装载程序区域替换成 GRUB Stage 1 的基本装载程序。GRUB Stage 1 存储在刷机镜像文件的 boot.img 中。

    • 由于 MBR 中只有 446 Byte 空间能用来存储基本装载程序代码,十分有限,因此 GRUB Stage 1 这部分代码逻辑很简单,就是将安装了 GNU/Linux 系统的那个分区中的 Stage 1.5 代码读出来,并将控制权转交给 Stage 1.5。

  • (Stage 1.5)整个 Stage 1.5 有点像是「火炬传递」的过程。

    • Stage 1.5 开始执行后,在有限的执⾏空间内,程序会⼀个接一个地加载连续扇区,读取并执行扇区里边的内容(这些内容存储在刷机镜像文件的 core.img 中,其中包括一些 GRUB 的基本运行时环境,如设备框架、文件系统、环境变量、救援模式下的 Shell 等等,详见 参考资料),一步步为后⾯更复杂模块的执⾏创造条件。

    • 当 Stage 1.5 执行完成后,相当于跑起来一个迷你 OS,它拥有 文件系统、基本硬件驱动 等能力,这样就能通过 路径 在磁盘上,继续寻址和加载更大一些的程序了。

  • (Stage 2)Stage 1.5 执行结束后,文件系统已经建立起来,可以通过路径来寻址。程序会读取 /boot/grub 下的 menu.lst 和 grub.conf,加载及显示 GRUB 菜单。这样,高级装载程序 GRUB 就完全启动起来了。

(图片转载自 Linux系统启动过程_linux启动过程_多云转晴,适合debug的博客-CSDN博客)

UEFI Boot

在 UEFI Boot 模式下,BIOS 的行为会稍有复杂一些,会依次执行 SEC (安全阶段)、PEI (Pre-EFI 阶段)、DXE (执行驱动配置环境阶段)、BDS (启动设备选择) 等几个阶段,初始化整个 EFI 环境。

详细过程见下图(摘自 UEFI启动流程浅析_少时不识月0.的博客-CSDN博客):

  • 初始化 EFI 环境:主板上电后,BIOS 将进行自检(可选操作),然后将执行 ROM 中的 EFI 初始化。可以认为 EFI 环境就是一个非常迷你的 OS,它扩展了 BIOS 的功能,使它可以识别 GPT 格式的磁盘,识别一定格式的文件系统,拥有鼠标等驱动,甚至还具有一个 Shell,可用于执行符合 UEFI 接口标准的应用程序(EFI 程序)。

  • 按照 UEFI 标准,GPT 分区上需要有且仅有一个 ESP(EFI System Partition)分区,并且按照标准,该分区的 GUID 必须是 C12A7328-F81F-11D2-BA4B-00A0C93EC93B(写死的)。EFI 系统会识别到这个特殊 GPT 分区,并按照它的文件系统格式来装载该分区。(为了兼容 Windows,ESP 分区一般都被格式化成 FAT32。)

  • ESP 分区上的那些 .efi 文件就是 EFI 可执行文件,可用于启动 高级装载程序

    • 主板的 NVRAM 上维护了一个列表,里面注册了所有安装在 ESP 分区上的、能用于启动 高级装载程序 的 .efi 文件的路径 (可以用 efibootmgr 命令查看或修改),一般会按列表定义的顺序执行这些 .efi 文件。

    • 安装系统时,会自动往 NVRAM 的 .efi 文件列表中写入指向高级加载程序的 .efi 文件在 ESP 上的路径。

    • 例如,amd64 架构下 Debian + GRUB 的 .efi 是:\EFI\debian\grubx64.efi

    • amd64 架构下 Windows 的 .efi 是:\EFI\Microsoft\Boot\Bootmgfw.efi

  • 执行 \EFI\debian\grubx64.efi 文件后,会建立文件系统、挂载 ext4 分区,从而可以读取 /boot/grub 下的 menu.lst 和 grub.conf,加载及显示 GRUB 菜单。这样,高级装载程序 GRUB 就完全启动起来了。

内核装载阶段

当 GRUB 菜单被显示出来时,意味着当前基本装载程序已将控制权交给 GRUB,内核阶段就开始了。这时,系统已经不再处于特别底层的位置,可以做一些高级操作。

首先需要明确一点:GRUB 在引导 GNU/Linux 系统时主要的作用是:加载 Linux Kernel。以 Ubuntu 为例,系统会配置以下这样的一条 GRUB 菜单项

1
2
3
4
5
title Ubuntu, kernel 2.6.20-16-generic           <-- 在 Menu 中的显示名
root (hd1,0) <-- 设定 Kernel 所在的磁盘
kernel /boot/vmlinuz-2.6.20-16-generic root=UUID=3f784cd9-516f-4808-a601-b19356f6bdea ro quiet splash locale=zh_CN vga=0x318
<-- 设定使用的 Kernel 文件,/ 是指 /boot 分区
initrd /boot/initrd.img-2.6.20-16-generic <-- 设定 initrd 镜像文件名

当用户从 GRUB 菜单中选择了一项菜单项、并按下 Enter 时,会发生以下几件事:

  • root: 挂载指定的磁盘及分区(比如 hd1,0 表示挂载 1 号硬盘的 0 号分区);

  • kernel: 从指定的磁盘分区中,加载指定的 Kernel 映像文件到内存中,加载完成后会 JMP 到 Kernel 的入口点即 _start 处,把控制权传递给 Kernel。其中:

    • ro:启动时以只读方式挂载根文件系统,这是为了不让启动过程影响磁盘内的文件系统。

    • root=UUID=3f784cd9-516f-4808-a601-b19356f6bdea:指定 / 的所在位置。这里和以前的 Linux 版本不太一样了,不再通过分区的设备文件名或卷标号 (/dev/sdx) 来指定,而是通过分区的 UUID 来指定的。

  • 如果配置了 initrd 这一行,则它会被 Kernel 被加载到内存,然后由 Kernel 解压。解压之后,会执行 init 脚本。

initrd 这一行是指定 Kernel 在访问根文件系统前会加载的映像。其主要作用是在 Kernel 真正启动之前,为内核加载一些设备驱动程序

  • Linux Kernel 需要适应多种不同的硬件架构,但是将所有的硬件驱动编入 Kernel 又是不实际的,而且 Kernel 也不可能每新出一种硬件结构,就将该硬件的设备驱动写入内核。

  • initrd 实现了 Kernel 与驱动的解耦。实际上 Linux Kernel 仅仅包含了基本、较通用的硬件驱动,而其他驱动则是在系统安装过程中根据安装信息和系统硬件信息,将一部分设备驱动写入 initrd 映像中的。

  • 这样,在每次启动系统时,一部分设备驱动就放在 initrd 中来加载。这样以后进行驱动替换后,也可以省的重新再编译和打包内核。

其实有 root 和 kernel 两行就可以启动 GNU/Linux 了,前提是 Kernel 中要打包足够使用的设备驱动程序。

启动 init 进程

GRUB 在 load 内核完成之后,会将 vmlinuz 解压,并将其中的的 setup.bin 部分读到内存地址 0x90000 处,然后跳转到 0x90200 开始执行,恰好跳过了前面 512 字节的 bootsector(这个 sector 的程序对于硬盘启动是用不到的),从 _start 开始执行 Kernel。(具体解压、装载步骤有一些复杂,可以参考 这个链接这个链接。)

_start 会设置好栈,清空 bss,设置好 setup_header 结构,调用 16 位 main 切换到保护模式,最后跳转到 1MB 处的 vmlinux.bin 文件中。

从 vmlinux.bin 文件中 startup32、startup64 函数开始建立新的全局段描述符表和 MMU 页表,切换到长模式下解压 vmlinux.bin.gz。释放出 vmlinux 文件之后,由解析 elf 格式的函数进行解析,释放 vmlinux 中的代码段和数据段到指定的内存。然后调用其中的 startup_64 函数 (/arch/x86/kernel/head_64.S),在这个函数的最后,会 JUMP 到 Linux 内核的第一个 C 函数「x86_64_start_kernel」,接下来就是 C 语言的世界了。

Linux 内核第一个 C 函数「x86_64_start_kernel」重新设置 MMU 页表,随后便调用了最有名的 start_kernel 函数 (在 /init/main.c 中)。

(摘自 https://blog.csdn.net/qq_48322523/article/details/119941427)

详细 _start 过程可参考 这篇文章这篇文章 的解读。

总之,start_kernel 函数中调用了大多数 Linux 内核功能性初始化函数,初始化了各种设备,在最后调用 rest_init 函数建立了 2 个内核线程(kernel_init 和 kthreadd)。在其中的 kernel_init 线程中,最后调用了 init_post 函数,通过 run_init_process(“/sbin/init”) 建立了第一个用户态进程 /sbin/init (新系统中是 systemd)。

systemd 启动后,系统进入了用户态。

init (systemd)

内核被加载后,第⼀个运⾏的程序便是 /sbin/init ,该程序会读取/etc/inittab ⽂件,并依据此⽂件来进⾏初始化⼯作。

/etc/inittab

/etc/inittab ⽂件最主要的作⽤就是设定 Linux 系统的运⾏等级。如果你打开它,可以看到第一行可能是「 id:2:initdefault:」。initdefault 的值是 2,表明系统启动时的运行级别为 2。总共可以设置成 7 个级别 (Runlevel):

  • 运⾏级别 0:系统停机状态,系统默认运⾏级别不能设为 0,否则不能正常启动

  • 运⾏级别 1:单⽤⼾⼯作状态,root 权限,⽤于系统维护,禁⽌远程登陆

  • 运⾏级别 2:多⽤⼾状态 (没有NFS)

  • 运⾏级别 3:完全的多⽤⼾状态 (有NFS) ,登陆后进⼊控制台命令⾏模式

  • 运⾏级别 4:系统未使⽤,保留

  • 运⾏级别 5:X11 控制台,登陆后进⼊图形 GUI 模式

  • 运⾏级别 6:系统正常关闭并重启,默认运⾏级别不能设为6,否则不能正常启动

每个运行级别有不同的可运行的程序。根据运⾏级别的不同,系统会运⾏ /etc/rc0.d 到 /etc/rc6.d 中的相应的脚本,来完成相应的初始化⼯作和启动相应的服务。

总的来说,/sbin/init 主要完成两件事:

  • 启动内核模块:依据 /etc/modules.conf ⽂件或 /etc/modules.d ⽬录下的⽂件来装载内核模块。

  • 按照相应运⾏级别,执⾏ /etc/rcN.d 中的启动脚本程序。

/etc/init.d

前面提到,七种预设的「运行级别」各自有一个目录,存放需要开机启动的程序。不难想到,如果多个「运行级别」需要启动同一个程序,那么这个程序的启动脚本,就会在每一个目录里都有一个拷贝。这样会造成管理上的困扰:如果要修改启动脚本,岂不是每个目录都要改一遍?

Linux 的解决办法就是七个 /etc/rcN.d 目录里列出的程序,都设为链接文件,指向另外一个目录 /etc/init.d ,真正的启动脚本都统一放在这个目录中。init 进程逐一加载开机启动程序,其实就是运行 /etc/init.d 这个目录里的启动脚本。例如,从 /etc/rc2.d 目录中可看到:

1
2
3
4
5
6
7
8
9
10
11
12
$ ls -l /etc/rc2.d

README
S01motd -> ../init.d/motd
S13rpcbind -> ../init.d/rpcbind
S14nfs-common -> ../init.d/nfs-common
S16binfmt-support -> ../init.d/binfmt-support
S16rsyslog -> ../init.d/rsyslog
S16sudo -> ../init.d/sudo
S17apache2 -> ../init.d/apache2
S18acpid -> ../init.d/acpid
...

修改服务启动脚本时,就直接修改 /etc/init.d 里面的启动脚本,就可以了。