2

系列目录

准备

前面几篇中我们已经建立起了 process 和系统调用的框架,并且已经实现了第一个 fork 系统调用。到目前为止,所有的 process 和它们的 threads 都是我们在 kernel 里手动创建,thread 的工作函数也是提前准备好的固定函数,这只是纯粹给测试用的。一个真正的 OS 当然需要有能力加载用户提供的程序到 process 中运行,这会用到我们要实现的第二个系统调用 exec

然而在此之前,还有一项准备工作要做。既然要加载用户程序,那么当然需要从磁盘加载。目前我们的 kernel 尚不具备和磁盘交互的能力,本篇就来实现一个非常简单的文件系统。

文件系统

文件系统(file system)这个词往往带有二义性,在不同的语境里有不同的含义,所以初学者往往混淆。例如我们经常听到的 windows 系统的 FATNTFS 文件系统,Linux 系统的 EXT 文件系统,有时候你又会听到 Linux 的虚拟文件系统 VFS (Virtual File System)等。

计算机世界里有句话,任何技术问题都可以通过加一个中间层来解决,Linux 的 file system 架构正是完美地体现了这种哲学。你听到的上面的各种术语,都只是分属于整个大 file system 概念下的不同的分层中。

下面我们分别来看这三层的具体职责。

Virtual File System

从顶向下,顶层的 Virtual File System 是 Linux 内核构建出来的一个抽象的文件系统,它实际上可以大致地对应我们平时看到的系统中的文件和目录等:

bash> ls -l /
drwxr-xr-x   2 root root  4096 Jan 13  2019 bin
drwxr-xr-x   4 root root  4096 Jan 11  2019 boot
drwxr-xr-x   3 root root  4096 Feb  3  2020 data
/usr/bin/cat
/home/foo/hello.txt

这一层最接近我们用户心理概念上的文件系统,但它其实是抽象的,因为你并不知道这些文件底下的设备和存储格式,作为用户也并不需要关心,VFS 屏蔽了这些底层的细节,所以这一层叫 Virtual 文件系统。VFS 从逻辑上看是一个树状结构,顶端是根目录 /,每个节点可能是目录(灰色)或者普通文件(绿色)。

存储文件系统

VFS 中每一个节点的文件或者目录都是抽象的,它们都要对应到具体的存储设备(如磁盘)上的文件实体,这就是 VFS 之下的那一层所管理的。例如我们常听到的 EXT2NTFS 等,它们尽管在术语上也叫 file system,但它们描述的是文件在硬件上的存储和组织方式,所以它的名字更应该叫“存储文件系统“(storage system)。磁盘,和内存一样,上面的数据不是杂乱无章的,它们必然是以某种结构组织起来的,这样上层才能根据它的规范去解析并正确地索引到想要的数据。

例如 EXT2 文件系统格式:

EXT2 的整个存储空间会被分为若干个 block group,然后每个 group 内部再组织文件的存储,包含了各种 meta 信息,以及最重要的 inode,它对应于每一个文件,用于存储每个文件的基本 meta 信息,以及指针指向文件具体的数据块(蓝色部分)。

这个存储系统的内部事实上也会组织起目录层级的概念,例如有些 inode 是普通文件,有些则是目录,目录会指引你去寻找到它下层的 inode。整个磁盘文件系统就像是一本书的索引,告诉你如何去找到一个文件的数据在那里。

存储文件系统,一般是建立在我们平时所说的磁盘分区(partition)上的,例如 windows 里到的 C 盘 D 盘,Linux 里的 dev/hda1/dev/sda1 等。我们平时所说的磁盘格式化,就是指将磁盘某个分区,按某种存储文件系统的格式给初始化,类似于在磁盘分区上张起一张逻辑上的结构网。

存储文件系统有很多种类型,EXT2 只是其中一种。我们甚至可以自己定制一种文件系统。在本项目中,我们会实现一个最简单的文件系统并且使用它来制作用户磁盘镜像。

硬件驱动层

再往下一层是硬件 IO 层,也就是硬件驱动,它直接和硬件交互。它这里已经没有数据组织和存储逻辑上的任何概念了,纯粹是一个呆板的 IO,例如你告诉它,我需要读取硬盘上从位置 x 到位置 y 的数据,或者我需要在硬盘上位置 w 到位置 z 的范围内写入什么什么数据。

访问一个文件

一个存储文件系统,或者说磁盘分区,是如何被放进 VFS 组织的这个树状结构里去的呢?在 Linux 里这叫挂载(mount),例如一开始对 VFS 而言,整棵树都是空的,只有一个根节点 /,但是我们一般肯定会有一个系统分区,例如 /dev/sda1,也就是通常你 Linux 装机用的分区,这个分区是一个 EXT2 文件系统,它会被 mount 到 VFS 的根目录 / 上去,这样 VFS 就可以开始查询 / 里的目录和文件了。

例如用户需要读取一个文件:

/home/hello.txt

系统会把这个目录从前往后,一级一级地查询:

  • / 是根目录,它现在被 mount 了 /dev/sda1 这个分区,并且该分区是 EXT2 存储格式的,于是系统就按照 EXT2 系统的格式,去查询这个分区顶层里名字为 home 的节点;注意这里 VFS 有树状结构,EXT2 其实里也有树状结构,它也是可以从顶向下查询的;
  • 在 EXT2 顶层找到了 home 这个节点,发现它确实是一个目录类型的节点,没问题;然后查找 home 目录下的 hello.txt 文件,如果能找到,那么读取之;

这里始终是在按照 EXT2 系统的格式,一级一级地在 /dev/sda1 这个分区上查找;虽然 VFS 里的路径是一个抽象的概念,但在真正访问文件时,这个路径会被投射到它所 mount 的磁盘分区的文件系统里去查询。

以上的例子只是挂载了单一的磁盘分区,事实上 Linux 下可以在 VFS 上找一个目录节点挂载新的磁盘分区,甚至这个分区都不必是 EXT 格式的,只要内核能支持解析这个格式。例如我们有个磁盘分区 /dev/hda2,它是 NTFS 格式的(例如你的双系统 windows 上的 D:\ 盘),我们将它 mount 到 VFS 的 /mnt 这个节点上:

这个新的磁盘分区 mount 上去后,从 VFS 的视角来看,它就能从 mnt 开始以 NTFS 文件系统的格式向下访问,例如读取这个文件:

/mnt/bar

当 VFS 访问到 mnt 节点的时候,发现这是一个 mount 点,并且挂载的磁盘分区是一个 NTFS 文件系统,接下来就会以 NTFS 的格式去解析接下来的路径 - 它会去尝试查找并读取这个磁盘分区上的 /bar 路径。

file system 接口

上面讲到了,VFS 在访问不同节点上的文件时,会跟踪它是属于哪一个磁盘分区以及该分区是什么存储文件系统(如 EXT,NTFS),然后使用对应的文件系统格式去读取磁盘分区数据。这里 VFS 为了兼容各种不同的文件系统,在实现上会首先定义一系列统一的文件操作的接口,然后各种具体的不同种类的文件系统再各自去实现这些接口,这是典型的面向对象编程的范式,例如:

class FileSystem {
  public:
    int32 read_file(const char* filename,
                    char* buffer,
                    uint32 start,
                    uint32 length) = 0;
    
    int32 write_file(const char* filename,
                     const char* buffer,
                     uint32 start,
                     uint32 length) = 0;
    
    int32 stat_file(const char* filename,
                    file_stat_t* stat) = 0;
    
    // ...
}

以上用 C++ 代码做一个演示(当然内核是用 C 语言写的,这里只是为了演示它面向对象编程的模式),定义了抽象类 FileSystem,里面定义了各种文件操作接口,都是纯虚函数。各种具体的文件系统只需要继承并实现这些接口,例如:

class Ext2FileSystem : public FileSystem {
  public:
    int32 read_file(const char* filename,
                    char* buffer,
                    uint32 start,
                    uint32 length) ;
    // ...
}

再次声明一下,以上只是为了演示用,真正的 Linux 的 VFS 里的接口和实现当然没这么简单,但结构是类似的。

代码实现

这个项目里不会使用 EXT 那样复杂的文件系统,也不会实现完整的 VFS 功能,只会将它基本的框架搭建起来,并嵌入一个我们自己定制的非常简单的存储文件系统。

首先定义 file system 的接口,类似上面的抽象类那样,在 src/fs/vfs.h 文件中:

struct file_system {
  enum fs_type type;
  disk_partition_t partition;

  // functions
  stat_file_func stat_file;
  list_dir_func list_dir;
  read_data_func read_data;
  write_data_func write_data;
};

typedef struct file_system fs_t;

可以看到上面定义了各种文件操作的函数指针作为接口,它们 原型是:

typedef int32 (*stat_file_func)(const char* filename,
                                file_stat_t* stat);

typedef int32 (*list_dir_func)(char* dir);

typedef int32 (*read_data_func)(const char* filename,
                                char* buffer,
                                uint32 start,
                                uint32 length);

typedef int32 (*write_data_func)(const char* filename,
                                 const char* buffer,
                                 uint32 start,
                                 uint32 length);

naive_fs 实现

我们不必实现一个 EXT 那样复杂的存储文件系统,在这个项目里我们只实现一个非常简单的文件系统,它的功能非常有限:

  • 磁盘镜像数据提前刻好,只能读,不能写;
  • 只有一层根目录,没有下级目录;

我们定制这个文件系统的目的一是为了演示,二是为了给项目使用,我们需要用它来保存用户程序以供加载并运行,所以只需要能读就可以了,也不需要复杂的目录结构,一层就足够了,所有的文件全放在这层。尽管非常低级,但它仍然不失为一个文件系统,我们不妨将它命名为 naive_fs,因为它真的非常 naive,非常 simple。

naive_fs 的存储结构是这样的:

  • 头部绿色部分是一个整数,记录总文件数量,这也是固定的;
  • 后面灰色部分是每个文件的 meta 信息;
  • 最后蓝色部分是具体的文件数据,用每个文件的 meta 信息(file offsetfile size)可以定位到它的数据存储在什么位置;

你会发现这个其实和我们之前实现的 heap 有异曲同工之处,都是非常简单直白的 meta + data 结构。

我写了一个工具,在 user/disk_image_writer.c,它会读取 user/progs 目录下的所有文件(这个目录目前还不存在,下一篇我们会编译链接用户程序放在这里),然后将它们按照上面 naive_fs 文件系统的格式,将它们写入磁盘镜像文件 user_disk_image,再将镜像文件一并刻写进我们的 kernel 磁盘镜像 srcoll.img 里就可以了。

dd if=user/user_disk_image of=scroll.img bs=512 count=2048 seek=2057 conv=notrunc

写入位置从磁盘的第 2057 个 sector 开始,因为前面是 boot loader 和 kernel 镜像。

接着我们来实现 naive_fs 的代码,实际上就是上面的各个函数指针的实现,代码在 src/fs/naive_fs.c 里:

static fs_t naive_fs;

void init_naive_fs() {
  naive_fs.type = NAIVE;

  naive_fs.stat_file = naive_fs_stat_file;
  naive_fs.read_data = naive_fs_read_file;
  naive_fs.write_data = naive_fs_write_file;
  naive_fs.list_dir = naive_fs_list_dir;
  
  // load file metas to memory.
  // ...
}

init_naive_fs 函数里,将所有文件的 meta 部分都读入并保存在内存,类似一份文件名单,然后 read write stat 等各种函数就依据这些文件的 meta 信息实现对文件的操作,非常简单。

例如读文件,先根据文件名找到 meta,得到文件在磁盘上的偏移量和大小,再调用底层驱动去读取数据:

static int32 naive_fs_read_file(char* filename,
                                char* buffer,
                                uint32 start,
                                uint32 length) {
  // Find file meta by name.
  naive_file_meta_t* file_meta = nullptr;
  for (int i = 0; i < file_num; i++) {
    naive_file_meta_t* meta = file_metas + i;
    if (strcmp(meta->filename, filename) == 0) {
      file_meta = meta;
      break;
    }
  }
  if (file_meta == nullptr) {
    return -1;
  }

  uint32 offset = file_meta->offset;
  uint32 size = file_meta->size;
  if (length > size) {
    length = size;
  }

  // Read file data from disk.
  read_hard_disk((char*)buffer, naive_fs.partition.offset + offset + start, length);
  return length;  
}

磁盘驱动

我们还要实现最底层的磁盘 IO 驱动,这是上层的 naive_fs 需要调用的,主要就一个函数 read_hard_disk,因为我们只需要读磁盘的功能就可以了。为了简单起见,这里的底层 IO 我们仍然沿用了类似 boot loader 里的读磁盘函数 read_disk,通过操作磁盘管理设备的各个端口实现,这是一个同步的实现方式。真正的操作系统对磁盘的 IO 的处理肯定是异步的,因为磁盘的速度非常慢,系统不可能阻塞等待它,而是发出读写命令后就继续处理其它事情,然后磁盘管理设备通过中断的方式来通知系统数据 IO 完毕,数据已经 ready。

总结

以上我们实现了一个简单的 VFS 和文件系统 naive_fs,下面看下 kernel 是如何使用它来读取一个文件的,例如:

char* buffer = (char*)kmalloc(1024);
read_file("hello.txt", buffer, 0, 100);

它调用的是顶层 VFS 的接口,在 vfs.c 中:

int32 read_file(char* filename, char* buffer, uint32 start, uint32 length) {
  fs_t* fs = get_fs(filename);
  return fs->read_data(filename, buffer, start, length);
}

VFS 会根据给定的文件路径 filename,定位它是属于哪一个文件系统,底下对应哪一个磁盘分区。当然我们这里只挂载了一个唯一的分区,文件系统类型就是 naive_fs,因为 get_fs 直接返回 naive_fs 的实体:

fs_t* get_fs(char* path) {
  return get_naive_fs();
}

接下来就是用这个 fs 的读文件函数接口 read_data,读取文件。

本篇是对文件系统 File System 的一个整体架构的分层拆解和样例实现,非常简单初级,仅供演示使用,希望它能帮助你对操作系统是如何管理文件和底层存储有个全面的认知。


navi
612 声望191 粉丝

naive