[Linux]initamfs/initrd是如何切换到磁盘文件系统的

1,285 阅读12分钟

Windows中,将磁盘抽象成了一个个的磁盘驱动器,并为他们分配了CBDEF等等的多个盘符,文件系统由此来访问磁盘上的文件。在之前,即使只有一块磁盘,在装机时也会在一块磁盘上手动分区为C盘和其他的逻辑分区,防止在系统恢复时导致数据丢失,但是也经常因为C盘空间不够大导致问题。

而Unix或者inux上,/之下,就是我们的根目录,没有其它的分区的概念,如果我们有多块磁盘或者多块设备的情况下,因为没有了分区、驱动器的概念,所以我们如果要访问这些设备,我们必然要一个切入点。而Linux采取的操作是设备 + 挂载,一般来说,设备会被挂载到/dev/下,此时设备也是一个文件,它也存在于文件系统中,比如一块磁盘:/dev/sda,就是我们加载的第一块磁盘。如果我们要挂载第二块磁盘,我们需要把/dev/sdb挂载到某个目录下,例如/mnt

1. 挂载点

Unix允许任意目录挂载一个设备代表的目录树,比如我们可以使用lsblk查看当前挂载的设备信息:

➜  ~ lsblk
NAME MAJ:MIN RM   SIZE RO TYPE MOUNTPOINTS
sda    8:0    0 363.1M  1 disk
sdb    8:16   0     4G  0 disk [SWAP]
sdc    8:32   0     1T  0 disk /mnt/wslg/distro
                               /

其中,Linux为我们挂载的三个硬盘,分别命名为sda、sdb和sdc,这里使用的是WSL2虚拟的Windows磁盘,其中的第三个,正对应着我们Windows主机对于的磁盘,但是被子系统挂载到Linux中,作为根目录了,其中它的挂载点分别是:/mnt/wslg/distro/,也就是我们的Root目录,如果我们去访问/mnt/wslg/distro,得到就是我们的根目录:

➜  ~ cd /mnt/wslg/distro
➜  distro ls
bin   dev  home  lib    lib64   lost+found  mnt  proc  run   snap  sys  usr  wslCdKadG  wslKCLOdl  wslcfMceH  wsllFeHKN
boot  etc  init  lib32  libx32  media       opt  root  sbin  srv   tmp  var  wslKBJNnH  wslOaGApd  wsljAFjog  wslnOecCh

2. 在挂载点上挂载文件或者设备

mount系统调用可以帮助我们在文件系统的任何目录下挂载一个新的设备,比如是独立的磁盘、U盘,亦或者是文件,常见于挂载ISO镜像文件,比如此时我们有一个新的U盘插入系统之后,我们需要去访问它,此时我们就需要手动将这个新的设备挂载到磁盘的某个位置上。

插入U盘之后,以WSL为例,我们首先需要去查看对应的U盘盘符:F。

然后再在Linux中,使用mount命令挂载对应的U盘:

/mnt sudo mount -t drvfs F: /mnt/

完成之后,U盘中的内容就被挂载到了/mnt/文件夹下,此时你去访问/mnt就好像在访问U盘一样。

如果我们需要解除挂载,那么只需要

sudo umount /mnt

如果你先后挂载了A和B两个磁盘或者文件,此时的mnt并不会变得非常混乱,因为后挂载的B会在A之上,就好像一个栈一样,在AB都被挂载到一个挂载点(/mnt)下之后,你去访问将只能访问到B,一旦B被umout掉了之后,你才能去访问A的内容,所以一个挂载点上虽然可以挂载多个数据,但是只有最后一个被挂载的数据可用。

如果当前目录是/mnt目录,并且将A挂载在/mnt下,此时再去挂载B完成之后,你再调用ls查看文件目录你会发现仍然是A的目录,即使使用pwd查看路径也没有问题,但是一旦你退到上级目录,再重新进入/mnt,此时才是对于的B目录。

同理,你也可以挂载一个镜像文件,比如img文件:

➜  / sudo mount /home/r0ei1y/img/disk.img /mnt

不难发现,我们在Linux中对设备或者镜像文件的操作点就在于我们的挂载点,无论是挂载还是推出(umount)其实都是在对挂载点做操作。

3. 文件挂载与Linux启动的关系

相对而言,启动阶段的文件系统是特殊的。

系统启动初期是不存在文件系统这个东西的,是后来在经过初始化,文件系统才承担起文件管理的重任的,那么在BIOS上电到文件系统初始化完成之前的一段时间,Linux也需要去访问文件,使用一些二进制文件或者驱动来实现系统的启动,那么这一段时间似乎就成了一块真空的时间片。

3.1 initrd/initramfs

Init 初始化

ram 随机访问存储器(内存)

fs 文件系统

即一个用于Linux初始化的内存文件系统,也叫initrd,即init ram disk。 用来支持两阶段的引导过程。initrd文件中包含了各种可执行程序和驱动程序,它们可以用来挂载实际的根文件系统,然后再将这个 initrd RAM磁盘卸载,并释放内存。在很多嵌入式Linux系统中,initrd 就是最终的根文件系统。

其中的文件一般来说非常少,比如一个用于系统其他组件初始化的脚本或者是一些最最基本的,用于支撑上述组件初始化的一些工具程序,比如busybox,它虽然是单个程序,但是其中有非常多的二进制功能模块,不同的指令对应不同的功能,比如使用busybox中内置的cat查看文本:

 /bin busybox cat ~/install.sh

亦或者是使用 busybox中内置的shell程序来解析用户输入命令:

➜  ~bin busybox sh


BusyBox v1.30.1 (Ubuntu 1:1.30.1-7ubuntu3) built-in shell (ash)
Enter 'help' for a list of built-in commands.

/usr/bin $

总而言之,他们都是为了系统启动初期阶段内置的一些程序。如果你去实现一个最小的Linux,你就可以通过链接依赖BusyBox中的一些二进制命令来完成简易版的系统工具,比如cat、比如shell。(当然功能上稍微会弱一些)

3.2 切换文件系统

在上面提到的,initramfs完成自己的使命之后,他会做几件事情,将自己替换成对应的、真正的可持续的文件系统:

  1. 读入真正的磁盘设备,比如读入后是:/dev/sda
  2. 创建新的挂载点比如是:/new_root
  3. 把(真)磁盘设备挂载到挂载点new_root之上。
  4. 通过二进制工具(也就是某个程序,可能是busybox中的某个程序)将原有的,通过initramfs创建的内存文件系统替换为new_root对应的文件目录。

此时new_root,就成了新的/目录,对应的正是此前通过initramfs中加载的/dev/sda目录,

背后的系统调用正是pivot_root,对应的busybox命令是busybox switch_root。

4. [实践] 试试initramfs

实验工具集合:github.com/R0ei1y/exp-…

其中的Makefile中内置了三个相关的指令,详见工具集合中的readme

上文提到了,启动linux的时候,会有一个临时的内存文件系统帮我们做一些相关的启动工作,我们可以借助一个linux-5.4.226内核来实践一下。

首先我们下载内核,完成之后下载initramfs需要的内容,置于initramfs文件夹下,整体结构长这样:

➜  initramfs tree
.
├── bin
│   └── busybox
└── init

整体上就是一个bin文件夹,下面存有busybox这个二进制文件,init则是一个shell脚本文件,我们在其中直接使用/bin/busybox下的sh命令打开一个busybox中内置的shell,不做其他的操作:

➜  initramfs cat init
#!/bin/busybox sh
/bin/busybox sh

在这里你实际上可以通过switch_root替换掉我们的内存文件系统,但是秉持着“先跑起来再说”的原则,我们一步步来,在其中先运行一个busybox中的Shell。

其次,我们需编译一下Linux的内核,大致流程是先输入make menuconfig进行编译,完成之后会出现vmlinux和在其它目录下的:./arch/x86/boot/bzImage,后者是我们需要的Linux内核文件。

然后我们需要将initramfs文件夹下的文件制作成一个文件系统镜像,因为我们使用的是qemu来进行模拟,所以直接用cpio + gzip将它压缩成一个gz压缩文件即可。

    @cd initramfs && find . -print0 | cpio --null -ov --format=newc | gzip -9 \
      > ../initramfs.cpio.gz

完成之后,此时的initramfs.cpio.gz位于Linux内核源码的根目录,然后我们可以使用如下命令让qemu运行起来:

  qemu-system-x86_64 \
      -nographic \
      -serial mon:stdio \
      -m 128 \
      -kernel ./arch/x86/boot/bzImage \
      -initrd ./initramfs.cpio.gz \
      -append "console=ttyS0 quiet acpi=off"

紧接着你会发现你运行起来的Qemu中,除了一个Shell,啥也没有,甚至常用的ls、clear等等命令都没有,

但是有一个特殊的命令是可以使用的,也就是cd,因为cd是Shell内置的命令,你可以在一个正常的Linux环境下使用where查看一下,诸如ls、clear等等通常都是单独的程序,而cd则是内置的命令,因为每个程序都有自己的工作目录,工作目录一般会由自己去切换而不是由其它的程序切换,cd就是起到切换工作目录作用的命令。

这样一来,我们就加载了我们的initramfs作为我们的初始文件系统,虽然上述的命令或者程序在这个文件系统中并不存在,但是在busybox中确有一些类似的程序,可以作为暂时的替代使用。

你可以在initramfs文件夹下预制一个你自己编写的HelloWorld的程序,你会发现也是能正常执行的(当然,你得静态编译):

/bin # busybox ls
busybox        hello
/bin # ./hello
HelloWorld!
/bin #

正经的一个操作系统应该不会直接用初始文件系统给用户使用,所以在initramfs中,我们需要补全它的功能,在init文件中做文章,首先我们没有关于mount指令的一个壳程序,我们无法在initramfs中使用我们的mount、umount和创建文件夹相关的系统调用。我们都要在前面套上一个busybox的壳。

因为本质上你想要调用mount,最终是调用到mount系统调用,Linux一般会提供一个程序:mount程序会调用mount系统调用,mount程序就是mount调用的壳程序:

➜  ~ where mount
/usr/bin/mount
/bin/mount
➜  ~ where mkdir
/usr/bin/mkdir
/bin/mkdir
➜  ~

我们可以做一层链接,把busybox的一些命令给它连接到外部,这样你就可以愉快地ls、mkdir了,比如:

/ # /bin/busybox ln -s /bin/busybox /bin/ls
/ # ls
bin   dev   init  root
/ # /bin/busybox ln -s /bin/busybox /bin/mount
/ # mount
mount: no /proc/mounts
/ # /bin/busybox ln -s /bin/busybox /bin/mkdir
/ # mkdir
BusyBox v1.31.1 (2020-03-22 13:22:47 UTC) multi-call binary.

Usage: mkdir [OPTIONS] DIRECTORY...

Create DIRECTORY

        -m MODE Mode
        -p      No error if exists; make parent directories as needed
/ #
  • 虚拟磁盘镜像创建

因为我们要实现initramfs向real disk fs的切换,所以我们要在qemu中指定一个磁盘驱动,也就是一个磁盘设备,我们使用如下的命令创建一个磁盘镜像文件,并格式化为 ext2:

dd if=/dev/zero of=./disk.img bs=1M count=1024
mkfs.ext2 -q ./disk.img

在qemu的启动项中添加:

-hda  ./qemu-driver/disk.img # 对应disk.img的位置
  • 在虚拟磁盘中创建init脚本

然后我们要挂载这个镜像,在mnt下操作,并在里面创建新的bin和etc/init文件,这是隶属于新的文件系统的文件了,当然你也可以直接从initramfs复制一份,我这里直接复制了一份,稍微改了一下内容:

➜  qemu-driver sudo mount disk.img /mnt
➜  /mnt tree
├── bin
│   ├── busybox
│   ├── hello
│   └── hello-dynamic
├── etc
│   └── init
└── lost+found  [error opening dir]
#!/bin/busybox sh
export PS1='(iREd)➜  '

/bin/busybox sh
➜  qemu-driver sudo umount /mnt
  • 在initramfs中,挂载新文件系统

重新启动qemu,此时我们可以按照如下的步骤去调用:

# 默认你已经做了busybox的链接了,如果没有在前三条命令前加上busybox
mknod /dev/sda b 8 0
mkdir -p /newroot
mount -t ext2 /dev/sda /newroot

上述的内容依次是:

  1. 创建设备节点,可以理解为找到qemu上挂载的disk.img
  2. 创建挂载点:newroot
  3. 挂载

然后使用switch_root工具来切换根文件系统:

  • (错误的)使用switch_root切换文件系统
exec busybox switch_root /newroot /etc/init

你会发现失败了。

内核意料之外地(也算是意料之中,实验基本不会一次就成功)抛出了一个Panic:

(rEd)➜  exec busybox switch_root /newroot /etc/init
BusyBox v1.31.1 (2020-03-22 13:22:47 UTC) multi-call binary.

Usage: switch_root [-c CONSOLE_DEV] NEW_ROOT NEW_INIT [ARGS]

Free initramfs and switch to another root fs:
chroot to NEW_ROOT, delete all in /, move NEW_ROOT to /,
execute NEW_INIT. PID must be 1. NEW_ROOT must be a mountpoint.

[  146.946058] Kernel panic - not syncing: Attempted to kill init! exitc0[  146.946937] CPU: 0 PID: 1 Comm: init Not tainted 5.4.226 #1

这个报错的根本原因是,switch_root只能在PID = 1的进程中被调用,通常是init,而我们现在使用的Shell是由initramfs中的init派生出来的子进程,所以这个操作是不被允许的。

现实里基本上也不会出现这种情况,毕竟大多情况下initramfs完成了切换文件系统使命之后,就会主动去执行目标文件系统的init或者是/etc/init,很少有在init中直接启动一个Shell,然后Shell中做操作,Shell的PID并不等于1。

  • (正确的)使用switch_root切换文件系统

因此,我们要把这个内容写入到initramfs的init中去,以确保在init脚本执行的对应的init进程中,完成对文件系统的切换:

busybox mknod /dev/sda b 8 0
busybox mkdir -p /newroot
busybox mount -t ext2 /dev/sda /newroot
exec busybox switch_root /newroot /etc/init

在这之后,就可以成功地从initramfs切换到disk.img镜像对应的文件系统了,因为我们修改了PS1的值,对应的用户命令行的显示就由原先的#变成了(iREd)➜

(iREd)➜  ls
bin         etc         lost+found  proc  s
(iREd)➜  bin/hello
HelloWorld!
(iREd)➜

此外还要注意的点是,我们在新的文件系统的init文件中是这样写的:

#!/bin/busybox sh
export PS1='(iREd)➜  '

/bin/busybox sh

如果我们将最后一行的内容注释掉,你会发现系统在完成switch_root之后就抛出了一个Panic了,这是因为我们的init进程在执行完/etc/init脚本之后关闭了,如果不做额外的配置,此时系统也会跟着关闭,并抛出一个Panic:

end Kernel panic - not syncing: Attempted to kill init! exit

由此,就完成了从内存文件系统 -> 磁盘文件系统的一次跃迁。