万书网 > 文学作品 > 深度探索Linux操作系统 > 4.2 initramfs原理探讨

4.2 initramfs原理探讨



在2.4以及更早版本的内核中,内核使用的是initrd。initrd是基于ramdisk技术的,而ramdisk就是一个基于内存的块设备,因此initrd也具有块设备的一切属性。比如initrd容量是固定的,一旦创建initrd时设定了一个大小,就不能再进行动态调整。而且,如同块设备一样,initrd需要按照一定的文件系统格式进行组织,因此制作initrd时需要使用如mke2fs这样的工具“格式化”initrd,访问initrd时需要通过文件系统驱动。更重要的是,虽然initrd是一个伪块设备,但是从内核的角度看,其与真实的块设备并无区别,因此,内核访问initrd也需使用缓存机制,显然这是多此一举的,因为本身initrd就在内存中。

鉴于ramdisk机制的种种限制,Linus  Torvalds提出了一个想法:能否将cache当作一个文件系统直接挂载使用?基于这个想法,Linus  Torvalds基于已有的缓存机制实现了ramfs。ramfs与ramdisk有着本质的区别,ramdisk本质上是基于内存的一个块设备,而ramfs是基于缓存的一个文件系统。因此,ramfs去除了前述块设备的一些限制。比如,ramfs根据其中包含的文件大小可自由伸缩;增加文件时,自动分配内存;删除文件时,自动释放内存。更重要的是,ramfs是基于已有的缓存机制,因此不必再像ramdisk那样需要和缓存之间进行多余的复制一环。

伴随着ramfs的出现,从2.6开始,内核开发人员基于ramfs开发了initramfs替代initrd。那么initramfs是怎样工作的呢?

当2.6版本的内核引导时,在挂载真正的根文件系统之前,首先将挂载一个名为rootfs的文件系统,并将rootfs的根作为虚拟文件系统目录树的总根。那么为什么要使用rootfs这么一个中间过程呢?原因之一还是为了解决鸡和蛋的问题。内核需要根文件系统上的驱动以及程序来驱动和挂载根文件系统,但是这些驱动和程序有可能没有编译进内核,而在根文件系统上。如果不借助第三方,内核是没有办法挂载真正的根文件系统的。而rootfs虽然名称为rootfs,但是并不是什么新的文件系统,事实上,其就是一个ramfs,只不过换了一个名称。换句话说,rootfs是在内存中的,内核不需要特殊的驱动就可以挂载rootfs,所以内核使用rootfs作为一个过渡的桥梁。

在挂载了rootfs后,内核将Bootloader加载到内存中的initramfs中打包的文件解压到rootfs中,而这些文件中包含了驱动以及挂载真正的根文件系统的工具,内核通过加载这些驱动、使用这些工具,实现了挂载真正的根文件系统。此后,rootfs也完成了历史使命,被真正的根文件系统覆盖(overmount)。但是rootfs作为虚拟文件系统目录树的总根,并不能被卸载。但是这没有关系,前面我们已经谈到了,rootfs基于ramfs,删除其中的文件即可释放其占用的空间。



4.2.1 挂载rootfs


在讨论具体的挂载rootfs时,因为涉及了一些文件系统相关的概念,因此,为了更好地理解文件系统相关的概念,我们有必要先来了解一下文件系统的物理组织结构,以期对这些抽象的概念有个具体的认识。

以ExtX(X=2,3,4)文件系统为例,其在存储介质上按照图4-1所示进行组织。虽然用于不同操作系统的文件系统其物理存储结构是不同的,但是Linux的虚拟文件系统通过为这些文件系统建立中间适配层,模拟这里介绍的概念来实现对这些文件系统的支持。

图 4-1 ExtX文件系统的组织结构

ExtX文件系统使用块(Block)作为基本存储单元。ExtX支持1024、2048和4096字节大小的块,块的大小是在创建文件系统时指定的,如果没有明确指出,mke2fs将使用默认大小。ExtX文件系统将整个分区分成多个块组(Block  Group),除了最后一个块组,其他块组都包含相同数量的块。下面介绍每个块组包含的部分。

(1)超级块(Super  Block)

超级块描述整个文件系统的信息,包括Inode总数,空闲Inode数量,每个块组包含的Inode的数量;块的总数,空闲块的数量,每个块组包含的块的数量,块的大小;挂载的次数、最近一次挂载的时间等。

(2)块组描述符(Group  Descriptors)

块组描述符包含所有块组的描述。每个块组描述符存储一个块组的描述信息,包括块组中块位图所在的块、索引节点位图所在的块、索引节点表所在的块等等。

(3)块位图(Block  Bitmap)

块位图用来描述块组中哪些块已用、哪些块空闲。其中每个位对应本块组中的一个块,这个位为1表示该块已用,为0表示该块空闲可用。

(4)索引节点位图(Inode  Bitmap)

和块位图类似,索引节点位图用来描述索引节点表中哪些Inode已用、哪些Inode空闲。其中每个位对应索引节点位图中的一个Inode,这个位为1表示该Inode已用,为0表示该Inode空闲可用。

(5)索引节点表(Inode  Table)

一个文件除了需要存储数据以外,一些描述信息也需要存储,如文件类型(常规文件、目录等)、权限、文件大小、创建/修改/访问时间等,这些信息存储在Inode中而不是数据块中。每个文件都有一个对应的Inode,一个块组中的所有Inode组成了索引节点表。除了文件属性信息外,Inode中还记录了存储文件数据的数据块。

(6)数据块(Data  Block)

数据块中存储的就是文件的数据。但是对于不同的文件类型,数据块中存储的内容是不同的,以常规文件和目录为例:

◆  对于常规文件,数据块中存储的是文件的数据。

◆  对于目录,数据块中存储的是该目录下的所有文件名和子目录名。

当然了,不是所有的文件都需要数据块,如设备文件、socket等特殊文件将相关信息全部保存在Inode中,就不需要数据块存储数据。

因为超级块和块组描述符是文件系统的关键信息,所以每个块组中都包含一份冗余的备份。ExtX文件系统也允许在某些特殊的情况下,除了第0个块组外,其余的块组可以不包含超级块和块组描述符的备份。用户在创建文件系统时可以通过命令行参数告诉工具mke2fs。

Linux的虚拟文件系统将文件系统组织为树形结构。在初始化阶段,内核挂载rootfs文件系统,虚拟文件系统从无到有,rootfs的根作为虚拟文件系统这棵大树中的第一个节点,自然成为所有后来创建的节点的祖先。也就是说,虚拟文件系统目录树的根就是rootfs的根。

本质上,rootfs就是一个ramfs文件系统,根据下面rootfs的文件系统类型的定义就可看出这一点:



根据rootfs中mount的具体实现——rootfs_mount可见,创建rootfs超级块使用的函数恰恰是创建ramfs文件系统的函数ramfs_fill_super,这从侧面表明了rootfs就是ramfs。

在内核引导过程中,将调用mnt_init挂载rootfs,代码如下所示:



mnt_init首先调用init_rootfs向内核中注册了rootfs文件系统,代码如下所示:



然后,mnt_init调用init_mount_tree挂载rootfs,代码如下所示:



挂载rootfs的过程是由do_kern_mount来完成的,该函数所作的工作主要有以下几个方面。

(1)创建代表rootfs的mount

Linux的文件系统是按照树形组织的,不同的文件系统都可以挂载到这个树中的任何一个目录上来。内核使用数据结构mount记录具体的文件系统与虚拟文件系统这棵大树之间的关系,mount起到一个承上启下的作用,其中的mnt_mountpoint指向文件系统的挂载点,mnt_parent指向挂载点所在文件系统的mount,mnt_root指向要挂载的文件系统的根。所以do_kern_mount需要为rootfs创建一个mount,因为rootfs是整个虚拟文件系统中第一个挂载的文件系统,所以这个mount实例是没有父亲的,其指向父亲mount成员的mnt_parent指向其自身,指向rootfs挂载点的成员mnt_mountpoint指向rootfs自己的根mnt_root。

(2)创建rootfs的超级块

超级块用于描述整个文件系统信息,某种意义上,超级块就代表了整个文件系统,所以挂载文件系统时,需要创建超级块。对于一个常规的文件系统,内核将从存储介质上读入超级块信息。但是ramfs是一个基于内存的文件系统,并不存在所谓的存储介质,但是前面我们讨论的ExtX的文件系统的基本概念依然是适用的,ramfs虽然不能从存储介质上读入超级块信息,但是会模拟出一个超级块。

(3)创建rootfs根节点的Inode

内核也需要从文件系统中读入rootfs文件系统根节点的Inode信息。但是,与创建超级块同样的道理,对于ramfs来说,也是在内存中模拟一个根节点的Inode信息。

(4)创建rootfs根节点的dentry

虽然在文件系统中,每个文件都有一个Inode(对于那些没有的,Linux将模拟Inode,以使这些文件系统能够挂载到虚拟文件系统中),但是这个Inode主要是记录文件的数据块以及属性信息,而并没有记录文件间关系的信息。所以,内核设计了结构体dentry,dentry中记录了该文件的父节点和子节点,从而可以将文件挂载到虚拟文件系统树中。dentry在物理存储介质中并没有对应的实体,而只存在于内存中。为了提高搜索文件的效率,内核会缓存文件系统中最近访问的dentry。

挂载rootfs后,内核初始虚拟文件系统的结构如图4-2所示。

图 4-2 挂载rootfs后内核初始虚拟文件系统的结构

在虚拟文件系统中,通过文件系统的超级块、文件系统的根节点,再加上文件的dentry,就可以在虚拟文件系统中唯一确定文件的位置了。为了程序实现上的方便,内核中设计了结构体vfsmount和path。vfsmount“封装”了文件系统的超级块、文件系统的根节点等。path“封装”了mount和dentry。

事实上,虚拟文件系统这棵代表整个文件系统的大树的根对用户并不可见,我们平时在进程中所见到的根目录,仅是这棵树上的一个分支而已。因此我们看到内核中文件系统中有namespace的概念,就是每个进程都有属于自己的文件系统空间,现实中多数进程的文件系统的namespace都是相同的。进程在任务结构体task_struct中的fs_struct中记录进程的文件系统的根,也就是进程的文件系统的namespace,init_mount_tree调用set_fs_root就是这个目的。当然,此时的current指向的是内核的原始进程,即进程0。

至此,通过挂载rootfs,虚拟文件系统的根目录已经建立起来,根目录已经可以容纳文件了。所以,接下来内核解压initramfs的内容到虚拟文件系统的根中,利用initramfs中的内容挂载并切换到真正的根文件系统。



4.2.2 解压initramfs到rootfs

在挂载了rootfs后,内核将Bootloader加载到内存中的initramfs中的文件解压到rootfs中。而这些文件中包含了驱动以及挂载真正的根文件系统的工具,内核通过加载这些驱动、使用这些工具实现挂载真正的根文件系统。

一旦配置内核支持initramfs,那么内核将编译文件initramfs.c,脚本如下所示:



而在文件initramfs.c中,我们可看到如下代码:



宏rootfs_initcall告诉编译器将函数populate_rootfs链接在段".initcall"部分。在内核初始化时,函数do_basic_setup调用do_initcalls,而do_initcalls执行段".initcall"中包含的函数,所以initramfs在此时被解压到rootfs中。

populate_rootfs解压initramfs的代码如下所示:



根据populate_rootfs的代码可见:

1)populate_rootfs首先调用unpack_to_rootfs将内核内置的initramfs解压到rootfs中;

2)接下来,如果变量initrd_start不为0,那么说明还有一个外部的initramfs通过Bootloader加载了,内核将这个外部的initramfs也释放到rootfs中。其中CONFIG_BLK_DEV_RAM是对应于使用ramdisk机制的情况,我们不关心这种情况。initrd_start是initramfs被加载到内存中的起始位置。initramfs通常作为一个独立的外部文件存在,并通过Bootloader加载到内存。

事实上,内核也允许将initramfs和内核映像构建在一起,统一通过内核加载,使用的方法是:首先将initramfs的内容保存到一个目录;然后将这个目录指定给kbuild,kbuild将使用自带的辅助程序gen_init_cpio将其压缩为initramfs;最后链接到内核映像的段".init.ramfs"中。这种方法的配置方式如图4-3所示。

图 4-3 指定initramfs的源文件所在目录

但是,即使我们没有指定将initramfs包含进内核映像中,内核也会构建一个内置的initramfs。这也是我们看到populate_rootfs代码中,第一个unpack_to_rootfs是无条件执行的原因。

接下来,我们就具体看看这个内置的initramfs的创建过程。

首先,内核的链接脚本告诉链接器将段".init.ramfs"中包含的内容链接到内核映像的"Init  code  and  data"部分,链接脚本如下所示:



在".init.begin"和".init.end"之间的部分是内核初始化时使用的代码,在内核初始化完成后,将再无用处,因此,内核初始化完成后,这部分的代码将被释放。内核内置的initramfs就被包含在这里。我们先来看一下宏INIT_DATA_SECTION以及INIT_RAM_FS的定义:



由上可见,段".init.ramfs"被链接到了内核映像的"Init  code  and  data"部分;函数unpack_to_rootfs中使用的符号__initramfs_start指向段".init.ramfs"的开头。

段".init.ramfs"的具体内容在文件initramfs_data.S中,代码如下:



通过伪指令".section.init.ramfs",链接器将initramfs链接进段".init.ramfs"。其中的INITRAMFS_IMAGE在Makefile中定义:



initramfs可以采用不同压缩方式,suffix_y是对应的后缀。比如,如果使用的是gzip压缩方式,则后缀为".gz";如果使用的是bzip2压缩方式,则后缀是".bz2";等等。当然也可以不必压缩,因为内核最终会被压缩。

显然,initramfs_data.S就是initramfs的内容(initramfs_data.cpio)的封装。另外在这个文件中定义了代表initramfs大小的符号__initramfs_size,这个符号也是函数populate_rootfs解压内置的initramfs时需要的。

接下来,我们就来看看具体的initramfs的内容initramfs_data.cpio,其创建规则的脚本如下所示:



关注创建initramfs_data.cpio规则的命令,其中if_changed这个表达式在讨论内核的构建时我们已见过多次,其核心就是执行命令cmd_$1,这里对应的就是cmd_initfs,该命令定义如下:



其中涉及的三个参数的定义如下:



其中:

◆  initramfs是scripts目录下的脚本gen_initramfs_list.sh。

◆  ramfs-input指定了创建initramfs的输入。如果配置内核时指定了构成initramfs的源所在的目录,则使用这个源目录下的文件创建initramfs;否则,只传递一个参数"-d"给脚本gen_initramfs_list.sh,该脚本则用内核默认的内容创建initramfs。

◆  ramfs-args这个参数只有在用户自己指定了构建initramfs的文件时才有效,默认是"-u  0-g  0",是告诉内核将这些文件的用户ID和组ID都设置为root。

综上所述,当指定了initramfs的源目录时,假设源目录为/vita/initramfs,那么构建initramfs的命令展开后大致如下:



脚本gen_initramfs_list.sh将/vita/initramfs目录下的文件的UID和GID全部设置为0,即root用户和组的ID,然后将目录下的内容打包为initramfs_data.cpio。

当不指定initramfs的源目录时,创建内置的initramfs的命令展开后大致如下:



通过命令行参数"-d",即"default  initramfs",告诉脚本gen_initramfs_list.sh创建一个默认的initramfs,其包含的内容如下:



这个默认的initramfs非常简单,仅包括一个/dev目录,一个/dev/console设备节点以及一个/root目录。

最后还要指出的一点是,事实上,即使配置内核不支持initramfs,内核在内部依然会构建一个最小的initramfs。根据init目录下的Makefile,当配置内核不支持initramfs时,内核链接init下的noinitramfs.c,如下脚本所示:



文件noinitramfs.c的代码如下:



由代码"rootfs_initcall(default_rootfs)"可见,在没有配置内核支持initramfs的情况下,内核初始化时,依然会执行default_rootfs。而该函数将在rootfs中创建/dev、/root目录以及/dev/console节点。

可见,无论在什么情况下,内核都将确保有一个initramfs。那么内核为什么要这么做呢?因为第一个进程如果打不开控制台设备(/dev/console),那么其将异常终止,最终导致内核panic。所以,这个默认的initramfs确保了内核不会因为第一个进程打不开控制台设备而panic。从某种意义上,也可以将其看作内核虚拟文件系统的一个Bootstrap,也就是说,如果没人给内核提供一个最小的文件系统的内容,那么内核就自己创建一个。



4.2.3 挂载并切换到真正的根目录

将initramfs成功解压后,挂载真正的根文件系统所需的驱动、程序等已经全部俱备,可以挂载真正的根文件系统了。假设真正的根文件系统在第一块硬盘的第一个分区,即/dev/sda1,并假设挂载点为/root,那么挂载完成后,文件系统的目录树如图4-4所示。

图 4-4 挂载根文件系统后虚拟文件系统结构

挂载真正的根文件系统,即/dev/sda1时,内核将为其创建一个mount对象,为了行文方便,这里用mount2指代。为了方便查找,mount2会被内核加入到一个Hash表中。mount2中的mnt_parent指向了代表rootfs的mount1。mount2中的mnt_mountpoint指向该文件系统的挂载点,显然,这里是rootfs中的/root目录。mount2中的mnt_root指向代表根文件系统的根节点的dentry。

此时,进程0的任务结构体中的fs的root和pwd均指向rootfs的根节点,当然,这个根节点是由mount和rootfs的根目录的dentry的共同标识的。也就是说,此时进程的文件系统的namespace是以rootfs的根目录作为根的目录树。

挂载真正的根文件系统后,rootfs中的内容已经没有保留的意义,但是并不能将rootfs卸载,因为rootfs是整个虚拟文件系统的根。因此,为了不占用内存空间,将rootfs中的内容(文件)释放掉即可,然后将真正的根文件系统移动到虚拟文件系统的根(即rootfs的根)下,最后再将进程的文件系统的namespace切换到真正的根文件系统。切换后,虚拟文件系统中的相关数据结构间的关系如图4-5所示。

图 4-5 切换根目录后虚拟文件系统结构