一台装有 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 左右分区的寻址。
分区格式
磁盘分区格式目前主要有 MBR 和 GUID/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 | title Ubuntu, kernel 2.6.20-16-generic <-- 在 Menu 中的显示名 |
当用户从 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 | $ ls -l /etc/rc2.d |
修改服务启动脚本时,就直接修改 /etc/init.d 里面的启动脚本,就可以了。