0x01 背景知识
存储数据的介质可以是内存,也可以是硬盘,但这两种介质都只能提供数据存储能力,并没有数据组织的能力。
比如,当我把一个文件存到内存或硬盘的某一个位置之后,如果我不记录这个位置,后面是无法再找到这个文件的。
所以,在有了内存和硬盘这些存储介质之后,我们还需要一种机制,来记录各种文件和目录的存放位置,以及后期对它们的检索能力,而这种机制,就叫做文件系统。
文件系统的种类有很多,比如我们常见的基于硬盘的文件系统ext4,以及基于内存的文件系统sysfs。
不同文件系统在内核里的实现各不相同,为了给使用者提供一套统一的接口,linux内核抽象出了vfs (virtual filesystem)层,即虚拟文件系统层,这样,当我们想要访问某个文件时,只用调用vfs的对应函数就好了,vfs会帮我们调用底层真实文件系统的对应函数,以此来获得文件数据。
有关vfs以及各种文件系统的介绍,可以查看对应的官方文档: Filesystems in the Linux kernel
我们在和vfs打交道时,为其提供的最主要的信息就是文件路径,而文件路径有两种格式,一种是绝对路径,即从根目录开始查找文件,一种是相对路径,即从当前目录查找文件。
所以,vfs如果想要解析我们提供的路径,就必须还要知道一个信息,即我们当前的根目录和当前目录指向哪里。
那这些信息存放在哪里了呢?
我们和操作系统交互的方式是通过程序,或者更确切的说,是通过进程,所以当我们要求vfs帮我们解析一个路径时,其实是这个进程在要求vfs解析这个路径,所以vfs获取的根目录或当前目录,其实就是这个进程所属的根目录或当前目录。
所以,存放根目录及当前目录的最好位置,就是在进程里。
而且,在进程内,我们是可以通过 chdir 这个系统调用来修改当前目录的,也就是说,每个进程都有自己独有的当前目录,这就更说明,根目录和当前目录信息,只能存放在进程里。
到这里有些同学可能会有疑问,当前目录存放在进程里比较好理解,但根目录应该是所有进程共用的吧,为什么也要存放在进程里呢?
这是因为,不仅进程所属的当前目录是可以修改的,进程所属的根目录也是可以修改的,修改的方式就是通过 chroot 这个系统调用。
根目录和当前目录,存放在进程的具体位置为:
![](/1-1.be21ddcf.png)
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里面定义的初始根目录及当前目录是什么:
![](/2-1.dd81ef06.png)
我把无关字段都折叠了,只保留了fs字段,因为根目录和当前目录就是存放在这个字段对应的结构体里。
我们继续看init_fs的定义:
![](/2-2.fe9598ac.png)
从上图我们可以看到,init_fs里根本就没有定义其所属根目录和当前目录,这是怎么回事呢?
其实也比较容易理解,目录的概念是依托于文件系统的,当内核在内存中创建了一个文件系统实例时,这个实例可能对应于一个内存文件系统,也可能对应于一个硬盘文件系统,只有在此时,这个文件系统实例才有了一个属于它的根目录,也只有在此时,内核中目录的概念才会产生。
通过上面这句话我们也可以知道,每个文件系统实例都有自己独有的根目录,那这些文件系统实例,是如何组成一个统一的目录体系呢?
其实就是通过挂载的方式,比如b文件系统实例的根目录,挂载到a文件系统实例的/mnt目录下,那以后我们在访问a文件系统实例的/mnt目录时,其实访问的就是b文件系统实例的根目录。
上面的init_fs里之所以没有定义其对应的根目录和当前目录,是因为在内核的编译阶段,还没有创建出根文件系统实例,根文件系统实例是在内核启动时创建的,也就是说,在那个时候,才会对init_fs里的root和pwd字段进行初始化。
下图为创建根文件系统的函数:
![](/2-3.4fb790bb.png)
在上图中,先用过vfs_kern_mount函数,创建了一个根文件系统实例,该文件系统的类型是rootfs,它是一个基于内存的文件系统。
在拿到这个文件系统实例mnt之后,我们就可以通过mnt->mnt_root字段,获取其对应的根目录。
这个根目录被赋值到root.dentry字段里,然后root变量又通过set_fs_pwd和set_fs_root函数,被赋值为current指向的进程的当前目录和根目录。
那current当前指向的是哪个进程结构体呢?
![](/2-4.5a6c7e19.png)
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目录下,其基本原理就是我们要提供一个这样格式的文件:
![](/4-1.08244ff7.png)
这个文件里面就指定了,我们要把哪些文件打包到内核,或者说,我们要在rootfs的根目录里创建什么文件。
上图这个文件,就是在告诉内核,要在rootfs根目录里先创建/dev目录,然后再创建/dev/console文件,最后再创建/root目录。
这个是内核默认的创建内置initramfs的配置文件,我们还可以通过内核配置,指定一个我们自己的配置文件,或者直接就修改这个默认配置文件。
总之,指定内置的initramfs文件内容的方式有很多种,这里就不一一介绍了,有兴趣的话可以自己看下usr目录下的代码,或者说找我问下。
0x05 initrd内核参数的方式传递initramfs
除了在内核编译时,内置打包一个initramfs文件,我们还可以通过指定initrd内核参数的方式,向内核传递一个initramfs文件。
在我们使用bootloader启动内核时,主要就是使用的这种方式。
比如说,我当前机器使用的是systemd-boot这个bootloader,我就可以在下面这个配置文件里指定initrd参数,来向内核传递initramfs文件:
![](/5-1.755cfb52.png)
这种方式的基本原理就是,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文件的函数为:
![](/6-1.192b2f48.png)
上图中,我把无关逻辑折叠起来了。
注意这个函数里调用了两次unpack_to_rootfs函数,unpack_to_rootfs函数的目的就是将initramfs解压到rootfs根目录下。
第一次unpack_to_rootfs函数调用是为了解压内置的initramfs,第二次这个函数的调用是为了解压通过initrd参数传递给内核的initramfs。
0x07 执行从initramfs解压出来的init程序
在完成对上述initramfs的解压之后,在内核启动流程后期,就会尝试执行从initramfs里解压出来的init程序:
![](/7-1.9fb81429.png)
上图中,当检测到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文件修改成如下内容:
![](/8-1.23f1d2f7.png)
该文件的前三行没有变动,还是原内容,我们添加了后三行。
后三行的大致意思为,将我当前机器上的/usr/bin/busybox文件打包到initramfs的根目录,然后在initramfs的根目录创建一个软链接sh,让其指向/busybox,最后再在initramfs根目录创建一个软连接tree,也指向/busybox。
这里,我们选择busybox作为我们后续的测试程序,打包进了initramfs,是有两点原因。一是因为busybox是一个静态编译的程序,即它没有任何依赖,所以比较方便打包到initramfs里,二是因为busybox内置了很多命令,可以方便我们后续的各种测试。
看上图中我们创建的到busybox的两个软连接,如果我们执行这两个软连接,其实就是在执行busybox里对应的内置命令。
在做好上述配置之后,我们就可以构建内核了,然后我们就使用qemu虚拟机,启动我们新构建的内核。
使用的qemu命令为:
![](/8-2.e65d25bb.png)
上图中-kernel参数是告诉qemu执行我们刚构建好的内核,-append参数对应的值,会当作内核参数传递给内核。
注意上图中的选中部分,我们使用rdinit=/sh这个内核参数告诉内核,我们要执行从initramfs解压出来的程序是/sh,而不是默认的/init。
sh程序,其实就是busybox里内置的shell。
下面我们运行刚才那条qemu命令,我们就会进入到busybox的shell环境:
![](/8-3.bf9cd64d.png)
注意上图中的选中行,这条内核日志告诉我们,内核执行的init程序,就是我们传入的/sh。
下面我们再执行一些命令做一些测试:
![](/8-4.b0bfea0a.png)
由上图可见,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,我们把它解压下,看下里面的内容:
![](/9-1.025e358b.png)
我使用的linux发行版是archlinux,所以上图中是用lsinitcpio命令解压的initramfs,其他的linux发行版使用的命令可能不同,这个要注意一下。
在上图中可以看到一个init程序,这个就是内核解压完initramfs后,要执行的init程序,我们看下它的内容:
![](/9-2.468c8ba6.png)
为了方便理解,我还是把无关内容折叠了。
由上图可以看到,这个init程序其实是个shell脚本,它的主体逻辑是,先把真正的根文件系统挂载到/new_root目录,然后再使用switch_root命令,先把根目录下的其他无关文件删掉,然后再把/new_root设置为新的根目录,最后再执行新根目录下的init程序,其路径为/sbin/init。
那我们是怎么把真正的根文件系统的位置,告诉这个init脚本的呢?
其实就是通过root这个内核参数。
![](/9-3.9318c1d2.png)
上图就是我当前机器的linux内核的启动配置,注意选中部分,就是传给内核的root参数,它表示的就是真正的根文件系统在哪里,它的真实指向其实是硬盘的一块分区。
综上我们可以看到,我们通过initramfs的方式向内核提供init程序,其最终目的也是为了挂载真正的根文件系统,然后执行真正的根文件系统下的真正的init程序。
那当我们没有提供initramfs给内核,内核会怎么处理呢?
很简单,内核会直接解析root参数,尝试挂载其指向的真正的根文件系统,并执行它内部的init程序,这个和我们上面看到的initramfs里的init脚本逻辑基本一致。
![](/9-4.49e03329.png)
上图就是内核里的对应逻辑,它先等待initramfs解压完成,然后检查ramdisk_execute_command指向的文件,默认为/init,是否存在,如果不存在,则调用prepare_namespace函数,开始尝试自己解析root参数,并挂载其指向的真正的根文件系统。
如果prepare_namespace函数成功挂载真正的根文件系统,那在这个函数执行结束后,我们当前的根目录,就已经指向的是这个真正的根文件系统的根目录了。
然后,内核的逻辑继续往下执行,最终会尝试在各种地方寻找init程序,并把它作为第一个进程开始执行。
![](/9-5.6e01090e.png)
这张图我们之前在讲initramfs时也看到过,不过这次因为initramfs里没有解压出init程序,ramdisk_execute_command变量在上图中的kernel_init_freeable函数里已经被置为null了,所以不会再尝试执行initramfs里的init程序,这个函数会尝试在一些固定地方,比如/sbin、/etc等目录下,寻找init程序执行。
这些地方寻找到的init程序,就是真正的根文件系统下的,真正的init程序了。
对于我当前机器来说,这个init程序就是systemd:
![](/9-6.2cf2c23d.png)
以上就是通过挂载真正的根文件系统,向linux内核提供init程序的方式。
0x0a 疑问
那有些同学可能会问,既然有了这种内置的挂载根文件系统的方式,为什么还要使用initramfs呢?
这是因为,现实中的根文件系统的挂载过程可能非常复杂,因为根文件系统并不一定存在于本地硬盘上,它可能是在一个网络节点上,甚至它还有可能被压缩被加密等等,如果想在内核里处理所有这些情况,是完全不现实的。
所以,内核的当前状态是,提供了一个内置的挂载简单根文件系统的逻辑,而针对复杂的根文件系统挂载,则可以使用initramfs的方式,由用户自行处理。
0x0b 总结
以上,就是根文件系统初始化和init程序运行的全部内容,下面我们再画一张图来总结下:
![](/b-1.ea819fbb.png)
0x0c 其他
如果有对linux及linux内核感兴趣的,可以扫描右侧二维码添加我的微信。
另外,我开设了一门 linux内核启动流程源码分析 课程,有对内核源码感兴趣的,欢迎报名。