0x01 背景知识

存储数据的介质可以是内存,也可以是硬盘,但这两种介质都只能提供数据存储能力,并没有数据组织的能力。

比如,当我把一个文件存到内存或硬盘的某一个位置之后,如果我不记录这个位置,后面是无法再找到这个文件的。

所以,在有了内存和硬盘这些存储介质之后,我们还需要一种机制,来记录各种文件和目录的存放位置,以及后期对它们的检索能力,而这种机制,就叫做文件系统。

文件系统的种类有很多,比如我们常见的基于硬盘的文件系统ext4,以及基于内存的文件系统sysfs。

不同文件系统在内核里的实现各不相同,为了给使用者提供一套统一的接口,linux内核抽象出了vfs (virtual filesystem)层,即虚拟文件系统层,这样,当我们想要访问某个文件时,只用调用vfs的对应函数就好了,vfs会帮我们调用底层真实文件系统的对应函数,以此来获得文件数据。

有关vfs以及各种文件系统的介绍,可以查看对应的官方文档: Filesystems in the Linux kernel

我们在和vfs打交道时,为其提供的最主要的信息就是文件路径,而文件路径有两种格式,一种是绝对路径,即从根目录开始查找文件,一种是相对路径,即从当前目录查找文件。

所以,vfs如果想要解析我们提供的路径,就必须还要知道一个信息,即我们当前的根目录和当前目录指向哪里。

那这些信息存放在哪里了呢?

我们和操作系统交互的方式是通过程序,或者更确切的说,是通过进程,所以当我们要求vfs帮我们解析一个路径时,其实是这个进程在要求vfs解析这个路径,所以vfs获取的根目录或当前目录,其实就是这个进程所属的根目录或当前目录。

所以,存放根目录及当前目录的最好位置,就是在进程里。

而且,在进程内,我们是可以通过 chdir 这个系统调用来修改当前目录的,也就是说,每个进程都有自己独有的当前目录,这就更说明,根目录和当前目录信息,只能存放在进程里。

到这里有些同学可能会有疑问,当前目录存放在进程里比较好理解,但根目录应该是所有进程共用的吧,为什么也要存放在进程里呢?

这是因为,不仅进程所属的当前目录是可以修改的,进程所属的根目录也是可以修改的,修改的方式就是通过 chroot 这个系统调用。

根目录和当前目录,存放在进程的具体位置为:

current指向的是当前进程,进程对应的结构体是struct task_struct,在这个结构体里,有一个fs字段,它又指向struct fs_struct结构体,而在struct fs_struct结构体里面,则存放了当前进程所属的根目录及当前目录,即root和pwd字段。

那有了这两个字段,vfs就可以解析我们提供的文件路径,进而找到对应的文件数据了。

0x02 进程所属根目录及当前目录的初始化

当在a进程里创建一个b进程时,内核会以a进程结构体为模板,拷贝一个新的进程结构体,然后再根据b进程的要求,对这个结构体进行修改,最终修改后的结构体,就是b进程。

之所以要以拷贝a进程结构体的方式创建b进程,是因为b进程中很多信息,要继承自a进程,比如,b进程所属的根目录以及当前目录,就是继承自a进程的。

既然子进程是通过继承父进程数据的方式,来获取其所属的根目录以及当前目录的,那第一个init进程的根目录和当前目录是从哪里继承的呢?

init进程的根目录和当前目录,其实是继承自内核里面的init_task。

init_task是内核里定义的一个全局变量,其类型为struct task_struct结构体,也就是进程所使用的结构体。进程所有的初始信息,都被定义在了init_task指向的这个结构体里,我们可以把它看成是其他进程的模板。

那我们看一下init_task里面定义的初始根目录及当前目录是什么:

我把无关字段都折叠了,只保留了fs字段,因为根目录和当前目录就是存放在这个字段对应的结构体里。

我们继续看init_fs的定义:

从上图我们可以看到,init_fs里根本就没有定义其所属根目录和当前目录,这是怎么回事呢?

其实也比较容易理解,目录的概念是依托于文件系统的,当内核在内存中创建了一个文件系统实例时,这个实例可能对应于一个内存文件系统,也可能对应于一个硬盘文件系统,只有在此时,这个文件系统实例才有了一个属于它的根目录,也只有在此时,内核中目录的概念才会产生。

通过上面这句话我们也可以知道,每个文件系统实例都有自己独有的根目录,那这些文件系统实例,是如何组成一个统一的目录体系呢?

其实就是通过挂载的方式,比如b文件系统实例的根目录,挂载到a文件系统实例的/mnt目录下,那以后我们在访问a文件系统实例的/mnt目录时,其实访问的就是b文件系统实例的根目录。

上面的init_fs里之所以没有定义其对应的根目录和当前目录,是因为在内核的编译阶段,还没有创建出根文件系统实例,根文件系统实例是在内核启动时创建的,也就是说,在那个时候,才会对init_fs里的root和pwd字段进行初始化。

下图为创建根文件系统的函数:

在上图中,先用过vfs_kern_mount函数,创建了一个根文件系统实例,该文件系统的类型是rootfs,它是一个基于内存的文件系统。

在拿到这个文件系统实例mnt之后,我们就可以通过mnt->mnt_root字段,获取其对应的根目录。

这个根目录被赋值到root.dentry字段里,然后root变量又通过set_fs_pwd和set_fs_root函数,被赋值为current指向的进程的当前目录和根目录。

那current当前指向的是哪个进程结构体呢?

current对应的就是pcpu_hot里的current_task字段,而current_task字段的初始值,就是init_task。

所以我们刚才疑惑的,init_task里的init_fs里的root和pwd字段,是在哪里设置的,现在就有了答案,就是在init_mount_tree这个函数里设置的。

此时,init_task所属的根目录和当前目录,指向的就是rootfs文件系统实例的根目录。

我们在有了这个目录体系之后,后面就会在这个目录树里寻找init程序,然后启动它,作为linux系统里的第一个进程。

但是,我们刚才也说过,刚刚创建的rootfs文件系统实例,是一个基于内存的文件系统,它内部现在除了一个根目录之外,是没有任何其他文件或目录的。

那我们后面要启动的init进程,是从哪里来的呢?

它的来源有两种方式,接下来我们就按照内核的启动流程,顺序讲一下这几种方式。

我们遇到的第一种方式就是initramfs。

0x03 initramfs

initramfs是一种cpio格式的归档文件,它可以把多个文件打包在一起,这个比较类似于linux下的tar文件,initramfs文件可以是被压缩的,也可以是没有被压缩的,linux内核都可以解析。

我们可以通过两种方式为linux内核提供initramfs文件,linux内核在启动过程中,会分别去尝试解析这两个initramfs文件,如果对应的initramfs文件存在的话,内核就会把它里面的打包文件,解压到上面创建的rootfs文件系统里。

我们刚才说过,rootfs文件系统是基于内存的,所以从initramfs里解压出来的文件,其实就是被存放到了内存里,只不过我们后面可以通过文件路径的方式,来访问这些文件。

向linux内核提供initramfs文件的两种方式分别为:内核在编译时,可以内置打包一个initramfs文件,另一种方式就是我们可以通过initrd参数,向内核传递一个外部的initramfs文件。

我们来分别讲下这两种方式。

0x04 内核内置打包initramfs

负责将initramfs编译进内核的代码位于usr目录下,其基本原理就是我们要提供一个这样格式的文件:

这个文件里面就指定了,我们要把哪些文件打包到内核,或者说,我们要在rootfs的根目录里创建什么文件。

上图这个文件,就是在告诉内核,要在rootfs根目录里先创建/dev目录,然后再创建/dev/console文件,最后再创建/root目录。

这个是内核默认的创建内置initramfs的配置文件,我们还可以通过内核配置,指定一个我们自己的配置文件,或者直接就修改这个默认配置文件。

总之,指定内置的initramfs文件内容的方式有很多种,这里就不一一介绍了,有兴趣的话可以自己看下usr目录下的代码,或者说找我问下。

0x05 initrd内核参数的方式传递initramfs

除了在内核编译时,内置打包一个initramfs文件,我们还可以通过指定initrd内核参数的方式,向内核传递一个initramfs文件。

在我们使用bootloader启动内核时,主要就是使用的这种方式。

比如说,我当前机器使用的是systemd-boot这个bootloader,我就可以在下面这个配置文件里指定initrd参数,来向内核传递initramfs文件:

这种方式的基本原理就是,bootloader会把内核以及initrd文件都加载到内存,然后把加载到内存的initrd文件的位置和大小设置到一个struct boot_params结构体对象里,然后再把执行权限转交给内核,并把struct boot_params对象传递给内核。

内核在解析initramfs文件时,就会检查struct boot_params对象里的对应字段是否设置了,如果设置了的话,就认为我们通过initrd参数向内核传递了一个initramfs文件,内核就会将这个initramfs文件里的内容,解压到rootfs的根目录里。

0x06 解压initramfs

不管我们使用上面讲的哪种方式,我们的最终目的,都是为了向内核传递initramfs文件,这样内核就可以把这个initramfs文件解压到rootfs文件系统的根目录,进而就可以查找在这个目录里是否有/init程序,如果有的话,内核就开始执行init程序,这也就标志着内核的启动流程结束了。

负责解压initramfs文件的函数为:

上图中,我把无关逻辑折叠起来了。

注意这个函数里调用了两次unpack_to_rootfs函数,unpack_to_rootfs函数的目的就是将initramfs解压到rootfs根目录下。

第一次unpack_to_rootfs函数调用是为了解压内置的initramfs,第二次这个函数的调用是为了解压通过initrd参数传递给内核的initramfs。

0x07 执行从initramfs解压出来的init程序

在完成对上述initramfs的解压之后,在内核启动流程后期,就会尝试执行从initramfs里解压出来的init程序:

上图中,当检测到ramdisk_execute_command变量不为null时,就会使用run_init_process函数,执行ramdisk_execute_command对应的程序。

ramdisk_execute_command不为null表示检测到从initramfs解析出来了init程序,ramdisk_execute_command默认指向的init程序路径为/init。

也就是说,如果我们把一个init程序打包到initramfs的根目录,那当内核解压initramfs完毕之后,后续就会执行我们提供的这个init程序。

0x08 动手实验initramfs

上面我们讲完了initramfs的理论知识,现在我们来动手实验下。

我们最终的目的就是把init程序打包到initramfs文件的根目录下,这样内核就会执行我们提供的这个init程序,作为第一个进程。

为了实验方便,我们直接用内置的initramfs打包方式,我们把usr/default_cpio_list文件修改成如下内容:

该文件的前三行没有变动,还是原内容,我们添加了后三行。

后三行的大致意思为,将我当前机器上的/usr/bin/busybox文件打包到initramfs的根目录,然后在initramfs的根目录创建一个软链接sh,让其指向/busybox,最后再在initramfs根目录创建一个软连接tree,也指向/busybox。

这里,我们选择busybox作为我们后续的测试程序,打包进了initramfs,是有两点原因。一是因为busybox是一个静态编译的程序,即它没有任何依赖,所以比较方便打包到initramfs里,二是因为busybox内置了很多命令,可以方便我们后续的各种测试。

看上图中我们创建的到busybox的两个软连接,如果我们执行这两个软连接,其实就是在执行busybox里对应的内置命令。

在做好上述配置之后,我们就可以构建内核了,然后我们就使用qemu虚拟机,启动我们新构建的内核。

使用的qemu命令为:

上图中-kernel参数是告诉qemu执行我们刚构建好的内核,-append参数对应的值,会当作内核参数传递给内核。

注意上图中的选中部分,我们使用rdinit=/sh这个内核参数告诉内核,我们要执行从initramfs解压出来的程序是/sh,而不是默认的/init。

sh程序,其实就是busybox里内置的shell。

下面我们运行刚才那条qemu命令,我们就会进入到busybox的shell环境:

注意上图中的选中行,这条内核日志告诉我们,内核执行的init程序,就是我们传入的/sh。

下面我们再执行一些命令做一些测试:

由上图可见,sh进程所处的当前目录就是根目录,而且根目录下的所有文件,就是上面我们在usr/default_cpio_list里配置的文件。

0x09 真正的根文件系统的挂载

以上我们通过几小节的内容,讲了如何通过initramfs的方式,向linux内核提供init程序,下面我们再讲下向linux内核提供init程序的另外一种方式,即直接挂载真正的根文件系统。

上面我们说到,为了提供一个目录体系,我们创建了一个rootfs文件系统实例,虽然这个文件系统下只有一个空的根目录,但它为我们后续解压initramfs,以及挂载真正的根文件系统提供的基础。

rootfs并不是最终的真正的根文件系统,它只是在内核启动过程中,用于临时过渡的一个文件系统,我们真正的根文件系统一般是存储于硬盘的,类型一般是ext4。

其实上面我们打包进initramfs里的init程序,也并不是最终真正的init程序,它也只是一个过渡程序,它的主要作用就是挂载真正的根文件系统,然后再执行真正根文件系统里的init程序,这个init程序一般为systemd。

下面我们来实际看一下打包进initramfs里的init程序。

在上面的图中我们也展示过,我当前机器内核在启动时,使用的是/boot/initramfs-linux.img这个initramfs,我们把它解压下,看下里面的内容:

我使用的linux发行版是archlinux,所以上图中是用lsinitcpio命令解压的initramfs,其他的linux发行版使用的命令可能不同,这个要注意一下。

在上图中可以看到一个init程序,这个就是内核解压完initramfs后,要执行的init程序,我们看下它的内容:

为了方便理解,我还是把无关内容折叠了。

由上图可以看到,这个init程序其实是个shell脚本,它的主体逻辑是,先把真正的根文件系统挂载到/new_root目录,然后再使用switch_root命令,先把根目录下的其他无关文件删掉,然后再把/new_root设置为新的根目录,最后再执行新根目录下的init程序,其路径为/sbin/init。

那我们是怎么把真正的根文件系统的位置,告诉这个init脚本的呢?

其实就是通过root这个内核参数。

上图就是我当前机器的linux内核的启动配置,注意选中部分,就是传给内核的root参数,它表示的就是真正的根文件系统在哪里,它的真实指向其实是硬盘的一块分区。

综上我们可以看到,我们通过initramfs的方式向内核提供init程序,其最终目的也是为了挂载真正的根文件系统,然后执行真正的根文件系统下的真正的init程序。

那当我们没有提供initramfs给内核,内核会怎么处理呢?

很简单,内核会直接解析root参数,尝试挂载其指向的真正的根文件系统,并执行它内部的init程序,这个和我们上面看到的initramfs里的init脚本逻辑基本一致。

上图就是内核里的对应逻辑,它先等待initramfs解压完成,然后检查ramdisk_execute_command指向的文件,默认为/init,是否存在,如果不存在,则调用prepare_namespace函数,开始尝试自己解析root参数,并挂载其指向的真正的根文件系统。

如果prepare_namespace函数成功挂载真正的根文件系统,那在这个函数执行结束后,我们当前的根目录,就已经指向的是这个真正的根文件系统的根目录了。

然后,内核的逻辑继续往下执行,最终会尝试在各种地方寻找init程序,并把它作为第一个进程开始执行。

这张图我们之前在讲initramfs时也看到过,不过这次因为initramfs里没有解压出init程序,ramdisk_execute_command变量在上图中的kernel_init_freeable函数里已经被置为null了,所以不会再尝试执行initramfs里的init程序,这个函数会尝试在一些固定地方,比如/sbin、/etc等目录下,寻找init程序执行。

这些地方寻找到的init程序,就是真正的根文件系统下的,真正的init程序了。

对于我当前机器来说,这个init程序就是systemd:

以上就是通过挂载真正的根文件系统,向linux内核提供init程序的方式。

0x0a 疑问

那有些同学可能会问,既然有了这种内置的挂载根文件系统的方式,为什么还要使用initramfs呢?

这是因为,现实中的根文件系统的挂载过程可能非常复杂,因为根文件系统并不一定存在于本地硬盘上,它可能是在一个网络节点上,甚至它还有可能被压缩被加密等等,如果想在内核里处理所有这些情况,是完全不现实的。

所以,内核的当前状态是,提供了一个内置的挂载简单根文件系统的逻辑,而针对复杂的根文件系统挂载,则可以使用initramfs的方式,由用户自行处理。

0x0b 总结

以上,就是根文件系统初始化和init程序运行的全部内容,下面我们再画一张图来总结下:

0x0c 其他

如果有对linux及linux内核感兴趣的,可以扫描右侧二维码添加我的微信。

另外,我开设了一门 linux内核启动流程源码分析 课程,有对内核源码感兴趣的,欢迎报名。