宏内核架构中,通过虚拟文件系统(VFS)管理所有文件语义以及全局信息,例如文件描述符(file descriptor)以及目录树结构等等。在微内核中,内核并不会拥有文件这种抽象,文件系统作为一种服务(独立的进程)运行在系统中,内核仅仅只是提供消息传递机制[1]

这样做的好处是,可以很方便的重新部署文件系统,而不需要重新编译整个内核。同时每个文件系统之间也有较好的隔离(进程级别的隔离)。

在开发微内核中开发文件系统就会面临几个问题:

  1. 如何兼容 POSIX 接口,显然微内核中 open / read / write 等宏内核中的文件接口 syscall 将不复存在,而上层应用又需要保持这些接口的使用
  2. 如何划分资源,文件系统的使用者(client)与文件系统的逻辑处理者(server)之间除了通信协议以外,还需要分别管理各自的资源,例如文件描述符信息等。那么这些资源应该放在 Client-Side 还是 Server-Side 就变得比较棘手。

本文调研 Google 开发的 Fuchsia 微内核的开源代码和文档,对其文件系统设计进行整理。

文件系统是“服务”

在宏内核中的内核模块到微内核中变成了“服务”,用户和“服务”之间使用 IPC 通信,在 fuchsia 使用的 zircon 架构中被称为 channel。文件系统在 fuchsia 中也变成了“服务”,用户通过 IPC 接口与文件系统交互。

命名空间(Namespace)

在 fuchsia 中有一个叫做 namespace 的东西,通过 lib 形式完全实现在 client 侧的地址空间中,namespace 会维护一个 client 需要的文件系统结构。namespace 本质上是绝对路径(absolute path)-> 句柄(handle)的一个映射表。句柄(handle)中保存着所有文件操作的 IPC 逻辑。

值得注意的是,fuchsia 通过这个 namespace 限制了 client 可以访问的路径。例如最开始的时候一个 client 的 namespace 中有一个根句柄(root handle,或’/’ handle),用户不可以通过点点(dotdot,或’..’)操作回到上级目录。

对于某个路径的访问,本质上是对在 client 内部的 handle 进行访问,而不会落实到真正的文件系统,只有需要真实文件系统或者其他 server 协同的时候才通过 IPC 转发给对应的 server。

Client 与 FS 沟通

用户和文件系统之间会建立一系列连接(connection),这些连接可能是一个文件、一个目录等等,此后连接双方的通信就基于连接上的 IPC 消息来进行。显然消息的信息将会是连续二进制流的形式,那么势必需要连接双方先达成一个协议来打包和解析这些数据,在 fuchsia 中,这种协议叫做 FIDL(Fuchsia Interface Definition Language)。

以文件 Seek 操作为例,当 client 提出一个 Seek 请求后,由于需要知道对应文件在真实文件系统上的大小,所以需要和真实文件系统交互,也就需要通过 IPC 来沟通。这时候 Seek 的参数 position 和 whence 就会被编码成 FIDL 的一个 field 然后被发送给对应的 fs server,server 处理消息之后返回对应的结果。

Memory Mapping

对于微内核来说,mmap 将会是一个比较大的难点,估计在后续调研微内核文件系统的文章中会成为一个必谈话题。

先照着 linux 的实现思路看看 mmap 是否适用于微内核,mmap 的实现首先要求有把文件内容缓存成内存的功能,也就是页缓存机制(page cache),页缓存显然是一个全局设计,不能放在每个 client 自己的地址空间中,否则的话不同 client 的缓存一致性会带来更大的开销,同时按照访问层次,我们可以知道页缓存应当是在 Disk I/O 之上完成的,那么自然也就会放在用户态。

值得注意的是,宏内核中 mmap 调用其实只是在虚拟内存中分配了地址空间,实际访问的时候通过 page fault 来引入物理内存映射,而 page fault 是由内核处理的,微内核中内核并不知道文件相关的语义,同样也没有机会很简单的从 fs server 的进程中找到对应的页地址传给内核,那么在 page fault handler 中应该以什么逻辑去填写页表呢。

可能读者会有疑问,为什么不直接将所有涉及到的虚存页表都在 mmap 的时候填好呢。这个其实很直观,首先 mmap 的区域并不一定被 page cache 缓存了,那如果在 mmap 的时候就填好所有的映射,势必需要将相关的文件内容读进 page cache,开销巨大;其次,就算 page cache 中确实缓存了所有的文件内容,mmap 区域过大还是会导致填页表的开销变大,进而导致 mmap 成为一个很重的操作。

因而宏内核的 mmap 设计并不适用于微内核,那么 fuchsia 是怎么实现 mmap 这件事情的呢?官方文档给出的解释其实也很直观,就是不提供传统意义上的 mmap,只对只读的文件系统提供实现,在 fuchsia 中其实就是blobfs,一种专门用于存放只读二进制文件的 fs。

挂载(Mounting)

文件系统一个很重要的功能就是挂载,这个行为通常来说需要操作系统配合完成,比如在全局存放一个记录了所有挂载节点信息的数组等等。

对于微内核而言,如果需要保存所有文件系统公用的全局信息,势必会单独增加一个进程来维护这些状态,事实上这件事情是比较危险的,微内核的一大优势就在于每项服务,下至驱动程序和文件系统之间都有隔离,不会因为一者 panic 而全局宕机,保存全局信息的节点会成为一个单点故障,显然不是一个很好的设计。

Fuchsia 中采用了一个比较折中的设计,通过牺牲一些性能来保持文件系统之间的崩溃隔离。实现方法其实也比较容易理解,它在 vfs 层的每一个 vnode 中保存了一个 handle 列表,其中每个 handle 都对应于一个挂载在该节点的一个文件系统 channel,当 client 在进行 path walk 的过程中需要先检查 mount handle 是否为空,如果不为空,就将剩下路径的遍历转交给这个节点最后一个 mount handle 所对应的 fs server 来处理。

很明显,这个设计将挂载点信息分散到了各个 fs server 中,因而不能通过很简单的方法来获取全局的挂载信息,同时在 path resolution 阶段也会引入额外的开销,但是很好的保证了不同文件系统之间的崩溃隔离。

源码分析

百闻不如一见,归根到底文件系统还是要为上层服务的,看看 fuchsia 这套文件系统的编程模型以及调用栈是怎么样的。[2]

先简单浏览一下两个测试用例文件:

文件位置:fuchsia/sdk/lib/fdio/tests/fdio_fdio.cc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
TEST(FileDescriptorTest, CreateVMO) {
 const size_t kSize = 4096;
 zx::vmo vmo;
 ASSERT_OK(zx::vmo::create(kSize, 0, &vmo));

 const char* message = "hello, vmo.";
 ASSERT_OK(vmo.write(message, 0, strlen(message)));

 int fd = -1;
 ASSERT_OK(fdio_fd_create(vmo.release(), &fd));
 ASSERT_LE(0, fd);

 struct stat info = {};
 ASSERT_EQ(0, fstat(fd, &info));
 EXPECT_EQ(4096, info.st_size);

 char buffer[1024];
 memset(buffer, 0, sizeof(buffer));
 ASSERT_EQ(sizeof(buffer), read(fd, buffer, sizeof(buffer)));
 ASSERT_EQ(7, lseek(fd, 7, SEEK_SET));
 EXPECT_EQ(0, strcmp(message, buffer));

 const char* updated = "fd.";
 ASSERT_EQ(4, write(fd, updated, strlen(updated) + 1));

 memset(buffer, 0, sizeof(buffer));
 ASSERT_EQ(sizeof(buffer), pread(fd, buffer, sizeof(buffer), 0u));
 EXPECT_EQ(0, strcmp("hello, fd.", buffer));

 ASSERT_EQ(1, pwrite(fd, "!", 1, 9));
 memset(buffer, 0, sizeof(buffer));
 ASSERT_EQ(sizeof(buffer), pread(fd, buffer, sizeof(buffer), 0u));
 EXPECT_EQ(0, strcmp("hello, fd!", buffer));

 ASSERT_EQ(4096, lseek(fd, 4096, SEEK_SET));
 memset(buffer, 0, sizeof(buffer));
 ASSERT_EQ(0, read(fd, buffer, sizeof(buffer)));

 close(fd);
}

文件位置:fuchsia/sdk/lib/fdio/tests/fdio_handle_fd.c

1
2
3
4
5
6
7
8
9
10
11
12
13
TEST(HandleFDTest, TransferDevice) {
  int fd = open("/dev/zero", O_RDONLY);
  ASSERT_GE(fd, 0, "Failed to open /dev/zero");

  // fd --> handle
  zx_handle_t handle = ZX_HANDLE_INVALID;
  zx_status_t status = fdio_fd_transfer(fd, &handle);
  ASSERT_OK(status, "failed to transfer fds to handles");

  // handle --> fd
  ASSERT_EQ(fdio_fd_create(handle, &fd), ZX_OK, "failed to transfer handles to fds");
  ASSERT_EQ(close(fd), 0, "Failed to close fd");
}

乍一看大部分用户代码还是需要和 fdio 模块耦合的,但是感觉还是保留了原来文件描述符(fd)的功能和使用方法,甚至 open 接口也是支持打开设备的,不过翻看了所有 fdio 的测试文件,并没有一个完全使用 POSIX 接口的 open~close 调用结构,但是我相信应该是可以照常用的,也就是说可以兼容现有的 linux 应用程序。

很显然如今的 open 已经不是原来的 open了,看看底下 open 具体是怎么调用的。

文件位置:fuchsia/fuchsia/sdk/lib/fdio/unistd.cc

1
2
3
4
5
6
7
static int vopenat(int dirfd, const char* path, int flags, va_list args) {
  ...
  zx::status io = fdio_internal::open_at(dirfd, path, flags, mode);
  ...
  std::optional fd = bind_to_fd(io.value());
  ...
}

这个 vopenat 是 open 直接调用的函数,里面有两个比较关键的调用,open_at 和 bind_to_fd。

open_at 函数直接调用 open_at_impl

1
2
3
4
5
6
7
8
9
10
11
zx::status<fdio_ptr> open_at_impl(int dirfd, const char* path, int flags, uint32_t mode,
                  bool enforce_eisdir) {
  ...
  fdio_ptr iodir = fdio_iodir(&path, dirfd);
  ...
  zx_status_t status = __fdio_cleanpath(path, clean, &outlen, &has_ending_slash);
  ...
  uint32_t zx_flags = fdio_flags_to_zxio(static_cast<uint32_t>(flags));
  ...
  return iodir->open(clean, zx_flags, mode);
}

这里 iodir 是一个 fdio 结构体,里面含有几乎所有的 fd 操作的函数指针,其中 open 函数指针会通过 zxio 来发 IPC 给 server。这里比较有趣的是 fdio_iodir 这个操作,一起看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static fdio_ptr fdio_iodir(const char** path, int dirfd) {
  bool root = *path[0] == '/';
  if (root) {
   // Since we are sending a request to the root handle, the
   // rest of the path should be canonicalized as a relative
   // path (relative to this root handle).
   while (**path == '/') {
    (*path)++;
    if (**path == 0) {
     *path = ".";
    }
   }
  }
  fbl::AutoLock lock(&fdio_lock);
  if (root) {
   return fdio_root_handle.get();
  }
  if (dirfd == AT_FDCWD) {
   return fdio_cwd_handle.get();
  }
  return fd_to_io_locked(dirfd);
}

fdio_iodir 函数返回一个 fdio 实例,根据 dirfd 的类型以及 path 的类型,会返回不同的 fdio 实例,其实也就是抽象意义上的 handle。从官方文档中可以看出一个进程一开始的时候有两个预设的 handle,一个叫做 root,一个叫做 cwd。root 对应这个进程根节点的 fs server handle,而 cwd 则是对应当前工作目录(current work directory)的 handle。不论如何,根据参数 path 和 dirfd 的具体内容,都可以找到一个 handle,用于之后与 fs server 交互部分的 open 的逻辑。此外也可以注意到,open 这个操作无论如何都是要过一遍 fs server 的,因为真实的储存状态只有 fs server 才有。

那问题来了,之前一直提的 fidl 到哪儿去了呢,其实 open 这个函数指针中有包含 encode 到 fidl 的逻辑,但是显然函数指针我们的 IDE 甚至 grep 或者 vim 也并不是很好定位,不过我们可以通过文件系统的其他接口来窥探这件事情。

再看一个 lseek 的例子:

1
2
3
4
5
6
7
8
__EXPORT
 off_t lseek(int fd, off_t offset, int whence) {
  fdio_ptr io = fd_to_io(fd);
  ...
  zx_status_t status = zxio_seek(&io->zxio_storage().io, whence, offset, &result);
  ...
  return status != ZX_OK ? ERROR(status) : static_cast<off_t>(result);
}

这个例子就直观多了,我们都知道文件描述符一般来说是一个 int 类型的参数,但是事实上 fuchsia 使用handle 也就是 fdio 实例来执行文件的具体操作,那就意味着在 client 的地址空间中有一个 fd 到 fdio 的映射,之后再通过 handle 的类型以及 handle 中初始化的操作指针来与具体的文件系统交互。再来看看 zxio_seek 接口:

1
2
3
4
5
6
7
zx_status_t zxio_seek(zxio_t* io, zxio_seek_origin_t start, int64_t offset, size_t* out_offset) {
  if (!zxio_is_valid(io)) {
   return ZX_ERR_BAD_HANDLE;
  }
  zxio_internal_t* zio = to_internal(io);
  return zio->ops->seek(io, start, offset, out_offset);
}

这里很明显是与 Zircon 的 IPC 逻辑耦合的一个模块,而 zio->ops 中储存的函数和之前 open 类似,是一个包含 fidl 逻辑的 IPC 函数列表,在 ops.h 文件中的 zxio_ops_t 定义,其中就包括了一个 fd 所有可能的操作。

还是没有解决问题,那么指针内容具体是什么。其实在 fuchsia/zircon/system/ulib/zxio 中就有这些函数列表的具体定义,例如 pipe.cc 中定义了zxio_pipe_ops,而我们要找的存储设备的接口则是在 remote.cc 中定义。

最终我们找到了 client 侧的末端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
zx_status_t zxio_remote_seek(zxio_t* io, zxio_seek_origin_t start, int64_t offset,
               size_t* out_offset) {
  Remote rio(io);
  if (rio.stream()->is_valid()) {
   return rio.stream()->seek(start, offset, out_offset);
  }

  auto result =
    fio::File::Call::Seek(rio.control(), offset, static_cast<fio::wire::SeekOrigin>(start));
  if (result.status() != ZX_OK) {
   return result.status();
  }
  if (auto status = result.Unwrap()->s; status != ZX_OK) {
   return status;
  }
  *out_offset = result.Unwrap()->offset;
  return ZX_OK;
}

从 fio::File::Call::Seek 函数开始,client 侧完成了需要发送 IPC 的所有准备工作,并通过 zircon IPC 与 fs server 通信。

参考文献