虚拟文件系统的自举

我们给游戏引擎设计了一个虚拟文件系统,可以挂接不同的文件系统实现,比如本地文件系统模块,内存文件系统模块,网络文件系统模块。比如前几天谈到的资源仓库,就是一个文件系统模块。

这个虚拟文件系统是用 lua 编写的,这就有了一个小问题:lua 代码本身也是放在虚拟文件系统中的,那么就需要解决自举。这些代码很有可能需要从网络更新(网络文件系统模块),而网络模块也是 lua 编写的,代码同样放在这套文件系统内。

这篇 blog 我想谈谈自举是怎样完成的。

首先我不想做的太复杂。我们不需要特别弹性的不同文件系统模块挂接到虚拟文件系统的不同目录上的功能。所以我写死了一个叫 .firmware 的目录,专门存放用来自举所需的基础代码(的备用版本)。这块代码在启动后可以在网络模块加载完毕后,用新的版本覆盖。

其次,除了非常必要的 C 代码(例如调用 os 的文件访问 api)外,我希望全部用 lua 实现。

但是,所有 lua 代码,包括文件系统的实现都是放在文件系统内的。我们需要初始化 lua 虚拟机,加载必要的代码,然后才能建立起最低可运行的环境。也就是说,建立这个环境时,lua 虚拟机还并不存在。这样就需要解决先有鸡还是先有蛋的问题。

好在我们先封装了 lua 虚拟机模块,简化了使用。我基于它,创建出一个最小可用的虚拟机环境,只用来读取虚拟文件系统支持模块(先不加载网络模块),然后封装成 C API ,藏起 lua vm 这个细节,专供自举阶段使用。当自举完成,就可以销毁这个虚拟机,在正式的环境重新加载相关 Lua 模块了。

大概是这样的:

struct vfs * vfs_init(const char *firmware, const char *repo);
const char * vfs_load(struct vfs *V, const char *path);
void vfs_exit(struct vfs *V);

初始化的时候,传入一个 firmware 的路径,把自举所需的最小 lua 代码放进去。再传入 repo 仓库路径,供 vfs lua 代码可以工作后,作为新版本替代。

也就是说,我先把 bootstrap 的 lua 代码以原生文件的形式,放在 firmware 里,(针对 ios 版,就是打包在 app 中)一开始加载出来使用。用这块代码创建出可以访问 repo 的最小环境(但不包括网络功能)。repo 是我们的资源仓库,里面有上次从网络上同步过来的最新代码。然后,我们从 repo 中再次加载新版本的 vfs 支持代码,之后用最新版本的 vfs 支持代码去访问 repo 仓库中的其它部分。

一旦新版本出了问题,也可以直接把 repo 删干净,回退到最老的 firmware 版本上。


vfs 的 C 实现尽量少嵌入写死的 lua 代码,大约是这样的:

struct vfs { struct luavm *L; int handle;
}; static int
linitvfs(lua_State *L) { luaL_checktype(L,1, LUA_TLIGHTUSERDATA); struct vfs ** V = (struct vfs **)lua_touserdata(L, 1); *V = lua_newuserdata(L, sizeof(struct vfs)); return 1;
} extern int luaopen_winfile(lua_State *L); static int
lfs(lua_State *L) { // todo: use lfs return luaopen_winfile(L);
} static int
cfuncs(lua_State *L) { luaL_checkversion(L); luaL_Reg l[] = { { "initvfs", linitvfs }, { "lfs", lfs }, { NULL, NULL }, }; luaL_newlib(L, l); return 1;
} static const char * init_source = "local _, firmware = ... ; loadfile(firmware .. '/bootstrap.lua')(...)"; struct vfs *
vfs_init(const char *firmware, const char *dir) { struct luavm *L = luavm_new(); if (L == NULL) return NULL; struct vfs *V = NULL; const char * err = luavm_init(L, init_source, "ssfp", firmware, dir, cfuncs, &V); if (err) { fprintf(stderr, "Init error: %s\n", err); luavm_close(L); return NULL; } if (V == NULL) { luavm_close(L); return NULL; } V->L = L; err = luavm_register(L, "return _LOAD", "=vfs.load", &V->handle); if (err) { // register failed fprintf(stderr, "Register error: %s\n", err); luavm_close(L); return NULL; } return V;
} void
vfs_exit(struct vfs *V) { if (V) { luavm_close(V->L); }
} const char *
vfs_load(struct vfs *V, const char *path) { const char * ret = NULL; const char * err = luavm_call(V->L, V->handle, "sS", path, &ret); if (err) { fprintf(stderr, "Load error: %s\n", err); return NULL; } return ret;
}

这里在初始化的时候仅仅是用原生的 loadfile 读入了 bootstrap.lua 这个文件而已。所以可以在不动任何 C 代码的基础上做到业务逻辑的更新。

最后来看看 bootstrap.lua 的实现:

local errlog, firmware, dir, cfuncs, V = ... cfuncs = cfuncs() package.preload.lfs = cfuncs.lfs -- init lfs local vfs = assert(loadfile(firmware .. "/vfs.lua"))()
local repo = vfs.new(firmware, dir)
local f = repo:open(".firmware/vfs.lua") -- try load vfs.lua in vfs
if f then local vfs_source = f:read "a" f:close() vfs = assert(load(vfs_source, "@.firmware/vfs.lua"))() repo = vfs.new(firmware, dir)
end local function readfile(f) if f then local content = f:read "a" f:close() return content end
end local bootstrap = readfile(repo:open(".firmware/bootstrap.lua")) if bootstrap then local newboot = load(bootstrap, "@.firmware/bootstrap.lua") local selfchunk = string.dump(debug.getinfo(1, "f").func, true) if string.dump(newboot, true) ~= selfchunk then -- reload bootstrap newboot(...) return end
end function _LOAD(path) local f = repo:open(path) if f then local content = f:read "a" f:close() return content end
end _VFS = cfuncs.initvfs(V) -- init V , store in _G

它会去加载 vfs.lua 建立一个最小环境,然后用新加载出来的 vfs 模块,重加载 bootstrap.lua 自身,看是否有更新,最终保证采用的是仓库中最新的代码来加载文件。

发表我的评论
取消评论
表情

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址