wong2

Posted on Oct 20, 2021Read on Mirror.xyz

Emscripten的文件系统

Intro

我们知道Emscripten可以将C/C++代码编译到WebAssembly,从而在浏览器中运行。为了实现这个目标,除了代码层面的编译外,Emscripten还需要在Web环境下提供大量对native runtime的模拟,如文件系统、底层图形库、网络等。今天重点看一下文件系统部分。

Overview

由于浏览器中的JavaScript无法直接访问操作系统的文件系统,Emscripten提供了一套虚拟文件系统来模拟一个POSIX FS。

FileSystemArchitecture

如上面的架构图所示,原生代码编写的应用使用libc/libxx中API进行文件系统操作,经Emscripten编译后,调用JavaScript编写的虚拟文件系统API。

默认的虚拟文件系统实现为MEMFS,顾名思义它是在内存里实现的,页面刷新后数据就会丢失。如果需要持久化,在浏览器里可以使用基于IndexedDB的IDBFS

使用示例

下面我们通过一个例子来展示文件系统的使用,并为后续实验提供一个基础。

C程序

首先我们编写一个如下含有文件操作的C程序:

#include <stdio.h>

void append_line(char *filename, char *line) {
  FILE *fp = fopen(filename, "a");
  fprintf(fp, "%s\n", line);
  fclose(fp);
}

int main() {
  return 0;
}

这段程序中定义了 append_line 函数,它的作用就是往一个文件里添加一行新内容。我们在main中并没有调用它,而是准备暴露给JS代码调用。

编译

接下来我们用下面的命令编译这段C代码到WebAssembly,在选项中指定export append_line 函数。

emcc fs.c \
  -o fs.js \
  -s EXPORTED_RUNTIME_METHODS="[cwrap, FS]" \
  -s EXPORTED_FUNCTIONS="[_append_line]"

编译后会得到 fs.jsfs.wasm 两个文件,其中 fs.js 是 Emscripten提供的 wrapper 文件,它会自动处理 wasm 文件的加载等等。我们写一段极其简单的HTML来使用它:

<body>
    <script src="fs.js"></script>
    <script>
    	const appendLine = Module.cwrap('append_line', null, ['string', 'string'])
    </script>
</body>

除了引入 fs.js 外,我们还通过 cwrapappend_line 转成了可以在JS中调用的函数。(C和JS代码如何互操作不是本文的重点,想了解更多可以阅读相关文档

使用

我们在浏览器console里做些实验:

首先执行 appendLine('/tmp/test.txt', 'line1') 向一个文件里写入内容。

然后我们通过FS API 把内容读出来看看:

> Module.FS.readFile('/tmp/test.txt', { encoding: 'utf8' })
"line1\n"

成功!

Emscripten文件系统的实现

对概念和使用有了基本认识后,我们来看看Emscripten文件系统的实现。

WebAssembly调用JavaScript代码

在前文架构图处提到,Emscripten的文件系统是在JavaScript层面实现的,然后由C/C++调用。更准确地说,是由C/C++编译后的WebAssembly调用。

我们以文本格式打开编译出的 fs.wasm,可以在头部看到一些 import 语句:

(import "env" "__sys_open" (func $env.__sys_open (type $t1)))
(import "wasi_snapshot_preview1" "fd_close" (func $wasi_snapshot_preview1.fd_close (type $t0)))
(import "env" "__sys_fcntl64" (func $env.__sys_fcntl64 (type $t1)))
(import "env" "__sys_ioctl" (func $env.__sys_ioctl (type $t1)))
(import "wasi_snapshot_preview1" "fd_write" (func $wasi_snapshot_preview1.fd_write (type $t9)))
(import "wasi_snapshot_preview1" "fd_read" (func $wasi_snapshot_preview1.fd_read (type $t9)))
(import "env" "__sys_mkdir" (func $env.__sys_mkdir (type $t2)))
(import "env" "emscripten_resize_heap" (func $env.emscripten_resize_heap (type $t0)))
(import "env" "emscripten_memcpy_big" (func $env.emscripten_memcpy_big (type $t1)))
(import "env" "setTempRet0" (func $env.setTempRet0 (type $t4)))
(import "wasi_snapshot_preview1" "fd_seek" (func $wasi_snapshot_preview1.fd_seek (type $t7)))
...

不难看出,其中的 fd_read, fd_write 等是与文件操作相关的。它们的实现又在哪里呢?

要在WebAssembly中调用JavaScript代码,需要在实例化WebAssembly时传入一个 importObject。下面就是 fs.js中实例化的地方:

return fetch(wasmBinaryFile, { credentials: 'same-origin' }).then(function (response) {
  var result = WebAssembly.instantiateStreaming(response, info);
  ...
})

其中 info 就包含了要被导入到WebAssembly实例的对象,它的定义也在 fs.js 里:

var asmLibraryArg = {
  "__sys_fcntl64": ___sys_fcntl64,
  "__sys_ioctl": ___sys_ioctl,
  "__sys_open": ___sys_open,
  "emscripten_memcpy_big": _emscripten_memcpy_big,
  "emscripten_resize_heap": _emscripten_resize_heap,
  "fd_close": _fd_close,
  "fd_read": _fd_read,
  "fd_seek": _fd_seek,
  "fd_write": _fd_write,
  "setTempRet0": _setTempRet0
};

var info = {
  'env': asmLibraryArg,
  'wasi_snapshot_preview1': asmLibraryArg,
};

可以看到这里的定义和 fs.wasm 中的 import 是一一对应的。

搞懂了Emscripten编译出的WebAssembly如何调用JavaScript代码后,我们可以去看看具体的实现了。

JavaScript实现

Emscripten虚拟文件系统的实现位于源码的 src 目录下,主体是 library_fs.js,各个具体实现分别位于 library_memfs.js, library_idbfs.js 等。

打开 library_fs.js 可以看到里面定义了一个大的 FS 对象,上面定义了各种文件操作的方法,如创建文件、删除文件、查看目录等等,比如这是 readdir 的代码(选择它是因为比较短):

readdir: function(path) {
  var lookup = FS.lookupPath(path, { follow: true });
  var node = lookup.node;
  if (!node.node_ops.readdir) {
    throw new FS.ErrnoError({{{ cDefine('ENOTDIR') }}});
  }
  return node.node_ops.readdir(node);
}

这里node对应的数据结构是 FSNode,它表示的是文件系统树中的一个节点,有这样一些重要的属性:

FSNode {
  id         // 一个自增的数字
  parent     // 指向节点的父节点(root节点的父节点是自己)
  mode       // 节点的类型及读写模式
  name       // 文件名或目录名
  contents   // 文件内容,如果节点是目录则是目录下文件名到子节点的map
  node_ops   // 节点操作
}

其中 node_ops 的实现位于各个具体文件系统,比如我们可以在 library_memfs.js 里找到其 readdir 实现:

readdir: function(node) {
  var entries = ['.', '..'];
  for (var key in node.contents) {
    if (!node.contents.hasOwnProperty(key)) {
      continue;
    }
    entries.push(key);
  }
  return entries;
}

用于Node.js环境的library_nodefs.js 的实现则是基于Node.js的 fs 模块的:

readdir: function(node) {
  var path = NODEFS.realPath(node);
  try {
    return fs.readdirSync(path);
  } catch (e) {
    if (!e.code) throw e;
    throw new FS.ErrnoError(NODEFS.convertNodeCode(e));
  }
}

就这样,FS 模块对外提供了统一的文件操作接口,且可以方便的切换到不同的底层实现。

现在我们回头看看前面提到的传给WebAssembly实例的asmLibraryArg,里面并没有直接使用FS模块的方法,而是出现了一个叫 _fd_write 的函数,它定义在 library_wasi.js 文件里。进而会发现它又通过系统调用 SYSCALLS.doWritev 才最终调用了 FS.write

限于篇幅,关于 WASI 和 Emscripten 中对系统调用的实现这里就不作展开了。