第 14 章 Linux 开机启动流程(UEFI+systemd)

作者: Brinnatt 分类: ARM64 Linux 基础精修 发布时间: 2022-01-21 12:20

计算机启动流程可以分为几个大阶段:

  1. 内核加载前
    • 本阶段和操作系统无关,Linux 或 Windows 或其它系统在这阶段的顺序是一样的
  2. 内核加载中 –> 内核启动完成
  3. 内核加载后 –> 系统环境初始化完成
  4. 终端加载、用户登录

这几个阶段中又有很多小阶段,每个阶段都各司其职。本文将主要介绍 UEFI+systemd 环境下的 Linux 系统启动流程。目前国内 ARM64 生态虽然还处于初级阶段,但使用的是新的 UEFI 规范,而且 ARM64 CPU 只支持 UEFI 规范。

历史上的 Bios+MBR+SysV 系统启动方式仅流行于 x86 架构,由于存在时间比较久,所以无处不在。如果想要了解该方式下的系统启动流程,请参考 Linux Previous Booting Processing

14.1、开机流程图预览

下图是开机的全局流程图,具体的细节后文再详细描述。、

booting processing

14.2、按下电源和固件阶段

按下电源,计算机开始通电,最重要的是要接通 cpu 的电路,然后通过 cpu 的针脚让 cpu 运行起来,只有 cpu 运行起来才能执行相关代码跳到第一个程序:bios 或 uefi 上,并将 CPU 控制权交给 bios 或 uefi 程序。

下面不考虑从网络启动系统的方式,只考虑启动本地系统。

14.2.1、使用 bios 的固件阶段

对于 BIOS 来说,BIOS 的工作包括:

  1. POST,即加电对部分硬件进行检查
  2. POST 自检之后,BIOS 初始化一部分启动所需的硬件(比如会启动磁盘,某些机器可能还会启动键盘)
  3. 根据启动顺序找到排在第一位的磁盘
  4. BIOS 跳转到所选择磁盘的前 446 字节,这 446 字节的代码是第一个 bootloader 程序,BIOS 加载 bootloader 并将 CPU 控制权交给 bootloader 程序
    • 磁盘的第一个扇区(前 512 字节)称为 MBR,其中前 446 字节是 bootloader 程序,中间 64 字节是磁盘分区表,最后两个字节是固定 0x55AA 的魔数标记,标记该磁盘的 MBR 是否有效,如果无效,则读取启动顺序中的第二位磁盘
    • MBR 中的 bootloader 是硬编码在磁盘第一个扇区中的,像 grub 类的启动管理器会安装各个阶段的 boot loader,包括这个 MBR bootloader
  5. 执行 MBR 中的 bootloader 程序,这个 bootloader 可能会直接加载内核,也可能会跳转到更多的 bootloader 程序上
    • 因为 MBR 中的 bootloader 只有 446 字节,代码量非常有限,所以有些系统会使用多段 bootloader 的方式。比如 grub 在安装 MBR 的时候,所安装的 MBR bootloader 最后的代码逻辑是跳转到下一个阶段的 bootloader(也是 grub 安装的),如果下一个阶段的 bootloader 还不够,那么还可以有更多阶段的 bootloader。这种模式称为链式启动
    • 因为内核镜像和启动管理器配置文件等内核启动所必须的文件都在 boot 分区下,所以中间某个 bootloader 程序会加载 boot 分区所在文件系统驱动,使之能够识别 boot 分区并读取 boot 分区中的文件
  6. 最后一个 bootloader 将获取内核启动参数(比如从 boot 分区读取 grub 配置文件获取内核参数),并加载操作系统的内核镜像(grub 配置文件中指定了内核镜像的路径),同时向内核传递内核启动参数,然后将 CPU 控制权交给内核程序

至此,内核已经加载到内存中,并进入到了内核启动阶段,CPU 控制权将转移到内核,内核开始工作。

说明:Bios 典型支持的是 MBR 分区类型,但也支持 GPT 分区类型。UEFI 典型支持的是 GPT 分区类型,但也支持 MBR。从系统启动的角度看,无需在乎是 MBR 还是 GPT,其基本目的都是找到各阶段的 bootloader。

14.2.2、使用 uefi 的固件阶段

UEFI 支持读取分区表,也支持直接读取一个文件系统。UEFI 不会启动任何 MBR 扇区中的 bootloader(即使有安装 MBR),而是从非易失性存储中找到启动条目(boot entry) 并启动它。

典型的 UEFI 支持的非易失性存储有 FAT12、FAT16 和 FAT32 文件系统(即 EFI 系统分区),但是发行商也可以加入额外的文件系统,只要提供对应文件系统的驱动程序即可。比如 Macs 支持 HFS+ 文件系统。此外,UEFI 也支持 ISO-9660 光盘。

UEFI 会启动 EFI 系统分区中的 EFI 程序,所谓的 EFI 程序即类似 bootloader 的程序,比如单纯的 bootloader(systemd-boot 工具也可以制作基于 UEFI 的 bootloader) 程序,类似 GRUB 的启动管理器、UEFI shell 程序等。这些程序通常位于 efi 系统分区下的 /EFI/vendor_name 目录中,不同发行商可加入不同的 efi 程序。在 EFI 分区的 /efi/ 目录下还有一个 boot 目录,这个目录中保存了所有的启动条目。

如下图,EFI 目录下除了 Boot 目录外,还有 4 个发行商(Acronis、deepin、Microsoft、Ubuntu) 各自的 EFI 程序目录。

EFI entry

当使用 UEFI 固件时,CPU 通电后,UEFI 的工作内容主要包括:

  1. POST,即加电对部分硬件进行检查
  2. POST 自检之后,UEFI 程序初始化一部分启动所需的硬件(比如会启动磁盘,某些机器可能还会启动键盘)
  3. UEFI 读取保存在 EFI 系统分区中的启动记录,从而决定启动哪个 EFI 程序以及从哪里启动该程序
    • EFI 分区中的 \efi\boot\bootx64.efi(64 位 UEFI) 可查找要启动的 EFI 程序
  4. UEFI 启动 efi 程序,并获取内核启动参数、加载内核镜像到内存等

至此,内核已经加载到内存中,并进入到了内核启动阶段,CPU 控制权将转移到内核,内核开始工作。

14.3、内核启动阶段

到目前为止,内核已经被加载到内存掌握了控制权,且收到了 boot loader 最后传递的内核启动参数,包括 init ramdisk 镜像的路径

但注意,所有的内核镜像都是以 bzImage 方式压缩过的,压缩后 CentOS 6 的内核大小大约为 4M,CentOS 7 的内核大小大约为 5M,CentOS 8 的内核大小约 9M。所以内核要能正常运作下去,它需要进行解压释放。

说明:谁解压内核?

  • 内核引导协议要求 bootloader 最后将内核镜像读取到内存中,内核镜像是以 bzImage 格式被压缩。bootloader 读取内核镜像到内存后,会调用内核镜像中的 startup_32() 函数对内核解压,也就是说,内核是自解压的。
  • 解压之后,内核被释放,开始调用另一个 startup_32() 函数(同名),startup32 函数初始化内核启动环境,然后跳转到 start_kernel() 函数,内核就开始真正启动了,PID=0 的 0 号进程也开始了……

当内核真正开始运行后,将从 /boot 分区找到 initial ram disk image(后面简称 init ramdisk) 并解压。init ramdisk 要么是 initrd,要么是 initramfs,它们可使用 dracut 工具生成,早期系统用 initrd 比较多,现在都用initramfs,所以后面默认会以 initramfs 来描述 init ramdisk。

init ramdisk 解压之后就得到了内核空间的根文件系统(这是启动过程中的早期根分区,也称为虚根)。对于使用 systemd 的系统来说,得到虚根之后就可以调用集成在 initramfs 中的 systemd 程序,其 PID=1。从现在开始,就进入了早期的用户空间(early userspace),systemd 进程可以在此时做一些内核启动剩余的必要操作。

完成内核启动的必要操作后,systemd 最后会将虚根切换为真实的根分区(即系统启动后用户看到的根分区),并进入真正的用户空间阶段。systemd 从此开始就成为了用户空间的总管进程,也是所有用户空间进程的祖先进程。

14.3.1、详细分析内核启动阶段

上面描述的内核启动过程很简单,但里面还有一些细节值得思考。

14.3.1.1、如何找到 init ramdisk

实际上,内核运行后,会创建一个负责内核运行环境初始化的进程,该进程会根据 boot loader 传递过来的内核启动参数找到 init ramdisk 的路径。正常情况下,该路径在 boot 分区中。之所以能够读取 Boot 分区是因为在固件阶段,bootloader 程序已经加载了 boot 分区的文件系统驱动。

14.3.1.2、为什么要用 init ramdisk

因为直到现在还无法读取根分区,甚至目前还没有根分区的存在,但显然,之后是要挂载根分区的。但是要挂载根分区进而访问根分区,需要知道根分区的类型(比如是 xfs 还是 ext4),从而装载根文件系统的驱动模块。

由于不同用户在安装操作系统时,可能会选择不同的文件系统作为根文件系统的类型,所以不同用户的操作系统在内核启动过程中,根文件系统类型是不同的。如何知道该用户所装的操作系统的根文件系统是哪种类型的?

事实上,在安装操作系统的最后阶段,会自动收集当前所装操作系统的信息,并根据这些信息动态生成一些文件,包括用户所选择的根文件系统的类型以及对应的文件系统驱动模块。这个过程收集的内容会保存在 init ramdisk 镜像中。

如下图,是某次我安装 CentOS 7 过程中截取下来的生成 initramfs 镜像文件的图片。

initramdisk

14.3.1.3、内核中根分区的来源

内核会将 init ramdisk 镜像解压在一个位置下,这个位置称为 rootfs,即根文件系统,这个根分区称为虚根,因为这个根分区和系统启动后用户看到的根分区不是同一个根分区。

对于使用 initramfs 镜像的 ramdisk 来说,这个 rootfs 即为 ramfs(ram file system),它是一个在解压 initramfs 镜像后就存在且挂载的文件系统,但是系统启动之后用户并不能找到它,因为在内核启动完成后它就会被切换到真实的根文件系统。

用户也可以手动解压 /boot/initramdisk-xxx.img 镜像:

[root@arm64v8 ~]# mkdir /tmp/ramfsdisk
[root@arm64v8 ~]# cd /tmp/ramfsdisk
[root@arm64v8 ramfsdisk]# /usr/lib/dracut/skipcpio /boot/initramfs-4.14.0-49.10.1.el7a.ft1500a.aarch64.img | zcat | cpio -id
88724 blocks
[root@arm64v8 ramfsdisk]# ls
bin  etc   lib    proc  run   shutdown  sysroot  usr
dev  init  lib64  root  sbin  sys       tmp      var
[root@arm64v8 ramfsdisk]#

可以想象一下,init ramdisk 镜像解压在 /tmp/ramfsdisk 目录下,那么这个目录就可以看作是在内核启动过程中的 rootfs。解压得到的根目录和系统启动后的根目录内容很相似:

[root@arm64v8 ~]# cd /tmp/ramfsdisk
[root@arm64v8 ramfsdisk]# ls
bin  etc   lib    proc  run   shutdown  sysroot  usr
dev  init  lib64  root  sbin  sys       tmp      var
[root@arm64v8 ramfsdisk]#

再深入一点看,会发现 ramdisk 中已经生成了我当前操作系统根分区和 boot 分区的驱动模块。

# 查出 boot 分区是 ext4 文件系统类型
[root@arm64v8 ~]# df -h | grep "/boot$"
/dev/sda2       976M   87M  822M  10% /boot
[root@arm64v8 ~]# blkid /dev/sda2 
/dev/sda2: UUID="02d21f87-4742-4e2f-92c1-e8df1001a781" TYPE="ext4" PARTUUID="bc2ffbf4-0347-4c4c-8233-d7055992b437" 
[root@arm64v8 ~]#

# 查出 / 分区也是 ext4 文件系统类型
[root@arm64v8 ~]# df -h | grep "/$"
/dev/sda3       121G  1.8G  113G   2% /
[root@arm64v8 ~]# blkid /dev/sda3 
/dev/sda3: UUID="7d2ddfd1-f735-4176-b2a8-cf64cc13ffa0" TYPE="ext4" PARTUUID="5e86d10f-0198-4e18-93d2-52b4f47f6f1b" 
[root@arm64v8 ~]#

# initramfs 中已经具备了ext4 驱动模块
[root@arm64v8 ~]# cd /tmp/ramfsdisk/
[root@arm64v8 ramfsdisk]# tree usr/lib/modules/4.14.0-49.10.1.el7a.ft1500a.aarch64/kernel/fs/usr/lib/modules/4.14.0-49.10.1.el7a.ft1500a.aarch64/kernel/fs/
├── ext4
│   └── ext4.ko
├── fat
│   ├── fat.ko
│   └── vfat.ko
├── jbd2
│   └── jbd2.ko
└── mbcache.ko

3 directories, 5 files
[root@arm64v8 ramfsdisk]#

14.3.1.4、PID=1 进程的来源

或者说,PID=1 的 init 程序集成在 init ramdisk 中?在内核启动过程中就加载了它?

对于早期的 initrd 来说,init 程序并没有集成在 initrd 中,所以那时的内核会在装载完根分区驱动模块后去根分区寻找 /sbin/init 程序并调用它。且对于使用 initrd 的启动过程来说,加载 /sbin/init 的时机比较晚,很多内核启动过程中的环境都是由内核进程而非 init 进程完成的。

对于 initramfs,它已经将 init 程序集成在 init ramdisk 镜像文件中了。如下:

[root@arm64v8 ~]# cd /tmp/ramfsdisk/
[root@arm64v8 ramfsdisk]# ls -l
total 44
lrwxrwxrwx.  1 root root    7 Sep  5 23:04 bin -> usr/bin
drwxr-xr-x.  2 root root 4096 Sep  5 23:03 dev
drwxr-xr-x. 11 root root 4096 Sep  5 23:04 etc
lrwxrwxrwx.  1 root root   23 Sep  5 23:03 init -> usr/lib/systemd/systemd
lrwxrwxrwx.  1 root root    7 Sep  5 23:04 lib -> usr/lib
lrwxrwxrwx.  1 root root    9 Sep  5 23:04 lib64 -> usr/lib64
drwxr-xr-x.  2 root root 4096 Sep  5 23:04 proc
drwxr-xr-x.  2 root root 4096 Sep  5 23:04 root
drwxr-xr-x.  2 root root 4096 Sep  5 23:04 run
lrwxrwxrwx.  1 root root    8 Sep  5 23:04 sbin -> usr/sbin
-rwxr-xr-x.  1 root root 3117 Sep  5 23:03 shutdown
drwxr-xr-x.  2 root root 4096 Sep  5 23:04 sys
drwxr-xr-x.  2 root root 4096 Sep  5 23:03 sysroot
drwxr-xr-x.  2 root root 4096 Sep  5 23:03 tmp
drwxr-xr-x.  7 root root 4096 Sep  5 23:04 usr
drwxr-xr-x.  2 root root 4096 Sep  5 23:03 var
[root@arm64v8 ramfsdisk]#

仔细观察上面的文件结构。init 是一个指向 systmed 的软链接(因为这里使用的是 systemd 而不是 SysV),此外还有几个重要的目录proc、sys、dev、sysroot。

由于内核加载到这里已经初始化一些运行环境了,有些环境是可以保留下来的,这样系统启动后就能直接使用这些已经初始化的环境而无需再次初始化。比如内核的运行状态等参数保存在虚根的 /proc 和 /sys(当然,内核运行环境是保存在内存中的,这两个目录只是内核暴露给用户的路径),再比如,已经收集到的硬件设备信息以及设备的运行环境也要保存下来,保存的位置是 /dev。

sysroot 则是最重要的,它就是系统启动后用户看到的真正的根分区。没错,真正的根分区只是 ramdisk 中的一个目录。

14.3.2、systemd 在内核启动阶段做的事

在内核启动阶段,当调用了集成在 initramfs 中的 systemd 之后,systemd 将接管后续的启动操作。但是在这个阶段,systemd 具体会做哪些操作呢?

分析 systemd 在启动阶段所做的事之前,最好对启动流程中的 systemd 能有一个全局的了解。

下图适用于即将解释的内核启动阶段中 systemd 的流程,也适用于内核启动完成后 systemd 的流程。

systemd in ramfs

在启动过程中,systemd 有几个大目标,每个目标以 .target 表示。systemd 的 target 主要作用是对多个任务进行分组,比如 basic.target 中,可能包含了很多任务。

  • 第一个大目标:sysinit.target

    sysinit.target 主要目的是读系统运行环境做初始化,初始化完成后进入下一个大目标

  • 第二个大目标:basic.target

    basic.target 的作用主要是在环境初始化完成后执行一些基本任务,算是做一些早期的开机自启动任务,basic.target 完成后进入第三个大目标

  • 第三个大目标:default.target 运行级别

    default.target 是一个软链接,链接到不同的 target 表示进入不同的运行级别,运行级别阶段的目的是为最终的登录做准备:

    • 如果是内核启动过程中(内核启动时调用了集成在 initramfs 中的 systemd 就处于这个过程),那么大目标是 initrd.target,该 target 的目标是为后续虚根切换到实根做初始化准备,比如检查并挂载根文件系统,最终虚根切换实根,并进入真正的用户空间,也即完成了内核启动或内核已经成功登录
    • 如果不是内核启动过程中,可能会选择不同的运行级别,通常是图形终端的 graphical.target,或者多用户的运行级别 multi-user.target,它们的目标都是完成系统启动,并准备让用户登录系统

所以,在内核启动阶段,要分析 systemd 的工作流程,沿着 sysinit --> basic --> initrd --> kernel launched 这条路线即可。

回到在内核启动阶段,systemd 接管后续的启动操作,具体会做哪些事呢?在 man bootup 手册中给出了内核启动阶段中 systemd 的工作流程图。

bootup-systemd

注意,图中所有涉及到的 unit 文件均来自 initramfs 镜像解压后的目录,也即虚根,而不是来自真实的根文件系统,因为现在还没有真实的根文件系统。例如 <ramfs>/usr/lib/systemd/system/sysinit.target 文件。

对于已经启动完成的正常系统来说,sysinit.target 是用于做系统初始化的,basic.target 则是系统初始化完成后执行的一些基本任务,比如启动所有定时器任务,开始监控指定文件等,算是系统启动过程中早期的开机自启动任务。

但是在内核启动阶段,sysinit.target 和 basic.target 所代表的含义,显然与启动完成后这两个文件代表的含义有所不同。在内核启动阶段,sysinit.target 中的 sys 指的是内核阶段的虚拟系统,basic 则代表 ramdisk 决定要执行的基础任务。

换句话说,在内核启动阶段,systemd 也将 initramfs 所启动的环境看作是一个操作系统,只不过是一个虚拟的操作系统。

当 basic.target 完成后,进入 initrd.target,之所以是 initrd.target,是因为 initramfs 中的 default.target 软链接指向的是 initrd.target。

[root@arm64v8 ~]# cd /tmp/ramfsdisk/usr/lib/systemd/system
[root@arm64v8 system]# ll -l default.target 
lrwxrwxrwx. 1 root root 13 Sep  5 23:04 default.target -> initrd.target
[root@arm64v8 system]#

下图描述了 initrd.target 阶段的工作流程。

boot-initrd

在 initrd 阶段,systemd 为后续切换到真实的根文件系统做准备,比如检查根文件系统并将其挂载在 <ramfs>/sysroot 下,再比如从 /etc/fstab 中找出部分需要在这个阶段挂载的分区。如果一切没问题,将进入最后的阶段:从 initramfs 的虚根 <ramfs>/ 切换到实根 <ramfs>/sysroot

从此开始,<ramfs>/sysroot 将代表真实的根文件系统,systemd 也将从这个根文件系统调用 init 程序(systemd) 替换当前的 systemd 进程(所以 PID 仍然为 1)。从此内核引导阶段退出舞台,开始进入真正的用户空间,systemd 进程也将在这个用户空间开始下一阶段的流程:sysinit -> basic -> default -> login。

14.4、内核启动后,用户登录前

当 systemd(这个是来自真实根文件系统的 systemd 进程) 进入到用户空间后,systemd 将执行下一轮工作流程,全局路线为:sysinit.target -> basic.target -> default.target -> ... -> login。

其中 default.target 是一个软链接,一般指向 graphical.target(图形界面) 或 multi-user.target(多用户模式),对应于 SysV 系统中的运行级别阶段。

需注意,在这里涉及到的所有 unit 文件都来自于真实的根文件系统,例如 /usr/lib/systemd/system/sysinit.target

而内核启动阶段 systemd 工作路线中涉及到的 unit 文件都来自于 initramfs 构建的虚根,例如 <ramfs>/usr/lib/systemd/system/sysinit.target,而且前文也提到过,在 systemd 眼中,initramfs 构建的也是一个系统,只不过是虚拟系统,最终 systemd 会从这个虚拟系统中切换到真实的系统中,切换的内容主要包括两项:切换根分区,切换 systemd 进程自身。

在流程的每一个大阶段,和前面介绍的 initramfs 中的 systemd 是类似的。

basic.target 完成后,将通过 default.target 决定进入哪一个运行级别。如果是进入 graphical.target,那么会启动和图形终端相关的服务任务,如果是进入 multi-user.target,那么:

[root@arm64v8 ~]# cd /usr/lib/systemd/system
[root@arm64v8 system]# ls -1 multi-user.target.wants/
dbus.service
getty.target
plymouth-quit.service
plymouth-quit-wait.service
systemd-ask-password-wall.path
systemd-logind.service
systemd-update-utmp-runlevel.service
systemd-user-sessions.service
[root@arm64v8 system]#

其中几项需要注意:

  • getty.target:启动虚拟终端实例(或容器终端实例)并初始化它们
  • systemd-logind:负责管理用户登录操作
  • systemd-user-sessions:控制系统是否允许登录
  • systemd-update-utmp-runlevel:切换运行级别时在 utmp 和 wtmp 中记录切换信息
  • systemd-ask-password-wall:负责查询用户密码

除了这几个 multi-user.target 所依赖的服务外,在 /etc/systemd/system/multi-user.target.wants 下也有需要启动的服务,这里的服务是用户定义的 systemd 类的开机自启动服务。

[root@arm64v8 ~]# cd /etc/systemd/system/
[root@arm64v8 system]# ls multi-user.target.wants/
auditd.service      kdump.service              NetworkManager.service  rpcbind.service
chronyd.service     ksm.service                nfs-client.target       rsyslog.service
crond.service       ksmtuned.service           postfix.service         sshd.service
firewalld.service   libvirtd.service           remote-fs.target        tuned.service
irqbalance.service  netcf-transaction.service  rhel-configure.service
[root@arm64v8 system]#

不仅如此,为了兼容早期 sysV 的 rc.local 开机自启动功能,systemd 会检查 /etc/rc.local 是否具有可执行权限,如果具备,systemd 会在此阶段自动执行 rc.local 中的命令。

标签云