← 文章 / 数据与数据库
Hacker News 2小时前 · 2026-08-25 03:25:09 · 0 阅读

可执行文件其实就是 SQLite 数据库

过去几年,我对两件事近乎痴迷:一是 Nix,它能重建整个系统,是探索创新想法的利器;二是用 SQLite 替代 ELF 作为可执行文件格式。你大概已经发现,这两个想法天然契合。

读博时我就研究过这个方向,但反馈并不积极。颠覆性的想法很难被接受,因为它要对抗既有方案强大的惯性。

Four-panel comic. A crow at a microphone says "Nix is great"; the audience
boos and shouts "get better material"; the crow looks stricken; the last panel
shows its remaining cue cards, which read "SQLite can be an object file format".

那次探索的一个成果是 sqlelf——一个用 SQL 声明式地探查 ELF 文件的工具。11我写过一篇论文 arXiv:2405.03883,没能成功发表,还写了一篇后续博文 GitHub 上,欢迎感兴趣的朋友看看。这个想法衍生出的有趣之处,远超我的预期。

§ELF 是个不肯承认自己是数据库的数据库

读博期间,我意识到一件让我很纠结的事:ELF 本身就已经是一个数据库了。它只是用手工方式实现了很多数据库原语,外加一堆为性能而生的数据结构,比如用于符号查找的布隆过滤器。

ELF 机制 它重复造轮子实现的数据库原语
.strtab / .dynstr 字符串驻留
.hash / .gnu.hash 索引(CREATE INDEX
节头表 sqlite_schema,即表的元数据表
st_name → 在 .strtab 中的偏移量 手工实现的外键
sh_offset / sh_size B-tree 页的记录布局
.gnu.version_r
objcopy --strip-debug DELETE + VACUUM
ldconfig 缓存、debuginfod 在上述内容之上构建的带外索引

无论你是用内核、ld.so、binutils、LIEF、goblin 还是 readelf 去分析或解析 ELF,本质上都是在反复重写同一个解析器。每个生成端也都在重写同一个序列化器。

这个格式本身极为紧凑,是为那个磁盘空间和网络带宽都极其珍贵的时代设计的。修改它很麻烦,由于排布过于紧密,你经常不得不把某些节清零再新增。ELF 也没有自描述的 schema。它本身是一种非常通用的格式,支持存放各类数据节,按惯例以特定方式解释,但格式本身并不强制这一点。

SQLite 是反例:它是一种自描述格式,极其稳定,被设计成可以不断扩展以支持新功能,又不会破坏已有消费者,同时还能高效支持各种查询。

如果我们用 SQLite 取代 ELF,会自然产生什么好处,所需的所有信息又能否在 SQLite 数据库中完整表达?答案是肯定的,而且实现起来出奇地简单。

§哪些东西会随之消失

SELF 文件要运行起来需要两张表:self_meta 把 ELF 头转成键值对,segments 存放加载镜像,每个程序头对应一行,字节数据放在 BLOB 里:

CREATE TABLE segments (
  -- 原始 phdr 索引
  id      INTEGER PRIMARY KEY,
  -- 'load' | 'tls' | 'stack' | 'relro'
  type    TEXT NOT NULL,
  -- 原始文件偏移
  offset  INTEGER NOT NULL,
  vaddr   INTEGER NOT NULL,
  filesz  INTEGER NOT NULL,
  memsz   INTEGER NOT NULL,
  r INTEGER, w INTEGER, x INTEGER,
  align   INTEGER NOT NULL DEFAULT 4096,
  -- 段字节;纯 BSS 时为 NULL
  content BLOB
);

用一张符号表就替代了 ELF 中大量的 section 以及 .gnu.hash 索引,这张表只配一个索引:

CREATE TABLE symbols (
  id      INTEGER PRIMARY KEY,
  name    TEXT NOT NULL,
  -- 'GLIBC_2.2.5'
  version TEXT,
  value   INTEGER,
  size    INTEGER,
  -- 'func' | 'object' | 'tls' | ...
  type    TEXT,
  -- 'global' | 'weak' | 'local'
  bind    TEXT,
  defined  INTEGER NOT NULL,
  exported INTEGER NOT NULL
);
CREATE INDEX idx_symbols_name ON symbols(name, version);

我们建索引的能力相当于 ELF 里的 .gnu.hash.hash,但它是由 SQLite 维护的正经 b-tree 索引,而不是手写的布隆过滤器。2.gnu.hash 本质是布隆过滤器加桶链,布局上让 ld.so 在符号查找时能直接判定未命中,连桶链都不用碰。

更惊喜的是还有不少附带好处:.dynstr 没了,因为 name 本来就是 TEXT,SQLite 自带字符串驻留;符号版本号直接是一列,不用再折腾 .gnu.version_r.gnu.version_d;也不需要单独的 strings 表。

还有一些表用于存放工具链需要的元数据,比如 sectionsnotesdynamic_entries。把它们删掉程序照样能跑,也就是说 strip(1) 就是一个事务:

# ldd(1)
$ sqlite3 hello 'SELECT soname FROM ldd'
libc.so.6

# nm -D --undefined
$ sqlite3 hello 'SELECT name,version FROM imports LIMIT 3'
__libc_start_main|GLIBC_2.34
_ITM_deregisterTMCloneTable|
puts|GLIBC_2.2.5

# readelf -l
$ sqlite3 hello \
    "SELECT type,vaddr,memsz,r,w,x FROM segments WHERE type='load'"
load|0|1744|1|0|0
load|4096|361|1|0|1
load|8192|312|1|0|0
load|15768|640|1|1|0

# strip(1)
$ sqlite3 hello 'DELETE FROM sections; DELETE FROM notes; VACUUM;'
# 57344 -> 49152 bytes

# 仍可运行,因为可选表本来就是可选的
$ ./hello
Hello, world!

所有用于读取 ELF 文件的工具,都可以化简为对数据库的查询。任何修改 ELF 文件的工具(例如 strip),都可以在事务中对数据库操作,而不必做脆弱的偏移量修补:strip 就是一个 DELETE 加上 VACUUMpatchelf 则是一个 UPDATE

schema 中缺失的信息,通过视图就能轻松暴露。例如,ldd 是对 needed 表的查询,该表由 symbols 表与 segments 表连接而成,用于找出程序所需的库的 soname。

CREATE VIEW exports AS SELECT name, version, type, size FROM symbols WHERE exported = 1;
CREATE VIEW imports AS SELECT name, version FROM symbols WHERE defined = 0;
CREATE VIEW ldd     AS SELECT ord, soname FROM needed ORDER BY ord;

§它是如何工作的?

SQLite 在其头部第 68 字节处预留了 4 字节的 application_id,正是为此而设。我们把它标记为 SELF,这样普通的 SQLite 数据库永远不会匹配上:

$ xxd -s 64 -l 8 hello
00000040: 0000 0001 5345 4c46                      ....SELF

现在可以利用 binfmt_misc,这个子系统允许你像调用原生程序一样调用任意二进制文件。我们只需注册要触发的魔数,以及一个能够加载我们这种新文件格式的解释器。

在 NixOS 上,注册只需要几行:在偏移 0 处匹配 SQLite 魔数,在偏移 68 处匹配 SELF

boot.binfmt.registrations.self = {
  recognitionType = "magic";
  offset = 0;
  # bytes 0-15, 68-71
  magicOrExtension = "SQLite format 3\\x00" + ... + "SELF";
  # ignore the middle
  mask = "\\xff..\\x00..\\xff";
  interpreter = "${self-exec}/bin/self-exec";
};

目前我写了一个小工具 elf2self,用来把 ELF 文件转成 SELF 文件。它是一个简单的 postFixup 钩子,在 NixOS 上可以为每个包按需启用。这个工具读取 ELF 文件,提取 program headers 和符号表,然后写入 SQLite 数据库。我们也可以考虑扩展 gccld 让它们直接生成 SELF,不过现阶段这个简单方案足以验证想法。

self-exec 就是解释器本身。它是一个链接了 libsqlite3 的小型 C 程序。其实现方式与 ld.so 惊人地相似,只不过它是从数据库中读取 program headers 和符号表,而不是从 ELF 文件中读取。它会将可加载段映射到内存中,完成重定位,然后跳转到入口点。

注意 self-exec 必须保持 ELF 格式。如果解释器本身也匹配注册条件,就会无限递归触发 -ELOOP

§动态链接

运行静态程序虽然简单轻松,但未免有些无聊且缺乏想象力。真正有意思的是动态链接,而数据库的优势也正在于此。

我探索了两种不同的动态链接方案。第一种是保留 ld.so,通过 glibcrtld-audit 接口把符号查找替换成 SQL 查询,这样能快速迭代设计。第二种则是彻底替换掉 ld.so,写一个新的动态链接器,把查找和绑定全部交给 SQL 来完成。

glibc 的 rtld-audit 接口允许审计库在任何文件系统查找(包括 dlopen)之前拦截每一次共享对象查找(la_objsearch)。审计库随后可以用 SQL 查询来回答"哪个库满足这个符号",而不是遍历 RUNPATHLD_LIBRARY_PATH。标准的 ld.so 负责映射和重定位,因此 glibc 的全部功能都能正常工作:延迟 PLT、IFUNC、TLS 和符号版本控制,而库的存储变成了行,库的查找变成了查询。

# 磁盘上没有任何 ELF 库
$ rm libgreet.so.1
$ ./app
./app: error while loading shared libraries: 
       libgreet.so.1: cannot open ...

$ self scan --db system.db .
$ SELF_SYSTEM_DB=system.db LD_AUDIT=libself-audit.so ./app
Hello, world, from a SQLite library!

我很好奇一个完全用 SQL 实现的动态链接器会是什么样子,所以做了一个原型,叫 self-ld,它是一个小型 C 程序,用 SQL 完整实现了动态链接器。这只是个概念验证,但确实能跑。它映射每个对象的段,发布它们的导出符号,然后为每个重定位修补 GOT 并跳转到起点。

SELECT s.value + o.load_bias
FROM   relocations r
JOIN   symbols s ON r.symbol = s.id
JOIN   objects o ON s.object = o.id
WHERE  r.id = ?
ORDER BY o.load_order
LIMIT  1;

§成本与基准测试

替换一个成熟的格式时,通常要看两个指标:体积和延迟。SELF 文件比 ELF 文件大多少?运行起来又慢多少?

体积。 SELF 文件带有 SQLite 的 b-tree 开销,大小约为 ELF 的两倍。

和 ELF 二进制类似,大部分开销是可以回收的,因为这些开销主要来自用于调试和工具链的可选表。删掉它们只是一次事务的事。剥离后的 coreutils SELF 大小为 1,794,048 字节,而 ELF 为 1,768,632 字节,差距在 1% 以内

不过我们会看到一些更有趣的方式来进一步摊销这些开销,我觉得这些方法非常独特且有意思。

延迟。 我对各种二进制做了基准测试,从 15 KiB 的 hello 到链接了 47 个库的 42 MiB 的 gdb

打开 SQLite 并启动解释器本身需要固定的约 5 毫秒,此外还有一次与镜像大小成正比的拷贝。这层拷贝比看起来更糟糕,因为 b-tree 页并没有被映射进内存。两个跑同一个 SELF 二进制的进程,并不能像普通 mmap 出来的 ELF 那样共享文本页——字节是从 b-tree 里拷出来的,而不是映射进去的。33你可能注意到 curl(274 KiB,27 个动态库)启动比 ELF 的 git(4.6 MiB,5 个动态库)还慢。这是因为 ld.so 干的工作量跟对象个数成正比,而不是跟字节数成正比,这件事我之前已经吐槽过了

§系统是一个闭包

不过 SQLite 数据库并不仅限于作为单个可执行文件,它还可以是一个闭包——一个文件里既装着程序,又装着它所有的传递依赖。ldd 的输出是含糊的:它只列出了需要的 soname,却没有列出满足这些需求的具体文件。Nix 在这一点上做得更好,它通过使用 RUNPATH 把每一条边都显式解析到一个具体的 store path。44我之前写过多篇关于 Nix 和 RUNPATH 的文章,比如让它变得多余,又如让它跑得更快

我们在 SELF 里也可以做到同样的事——把每条边解析后的路径存到数据库里:

CREATE TABLE objects (id INTEGER PRIMARY KEY, path TEXT UNIQUE,
                      soname TEXT, kind TEXT, is_root INTEGER);
CREATE TABLE needs (
  object_id     INTEGER REFERENCES objects(id),
  ord           INTEGER NOT NULL,
  soname        TEXT NOT NULL,
  -- 消除歧义的外键
  resolved_path TEXT REFERENCES objects(path)
);

self closure 把一个二进制和它的全部传递依赖打包成一个数据库,并把上述边信息填好。共享库的解析不再是一种猜测,而变成了一个外键;ldd 则变成了一个 JOIN 🤯:

$ self closure "$(readlink -f $(command -v ls))" coreutils.db
ls + closure -> coreutils.db

$ sqlite3 -column coreutils.db \
    "SELECT n.soname, substr(n.resolved_path, 12, 20)
     FROM needs n JOIN objects o ON o.id = n.object_id
     WHERE o.is_root = 1"
libgmp.so.10          rfabfsmwq02sn94mb3qg
libacl.so.1           x0zgiss9hdzcsll3cswg
libattr.so.1          08nfpyc4qhzdkc37nznv
libc.so.6             8kvxvr3pmsypxiypq4g8

这单个数据库就是 ls 可执行文件及其五个库的闭包:六个对象,连同所有段字节,全部装进一个 4.8 MiB 的文件里。闭包内部不存在 soname 歧义,因为闭包在构造上对每条边只包含唯一的提供方。

§这能走多远?一个文件,整个用户态

希望你能跟上思路,因为接下来才是真正有意思的地方。我们还可以更进一步,把多个闭包装进同一个数据库。

《盗梦空间》五格梗图。Cobb:"你的可执行文件是一个 SQLite 数据库。"
Fischer:"那它链接的库呢?" Cobb:"也是 SQLite,整个用户态也
是,一个文件。" Fischer:"这能嵌套多深?" Cobb 眨眼:"你现在
就在里面。"

我对本系统 PATH 上的每个 ELF 可执行文件都跑了一遍 self closure:共 723 个可执行文件,拉入了 400 个不同的共享库。1,123 个对象、346,386 个符号、3,808 条依赖边,全部装在一个 SQLite 文件里。

实际结果出乎意料地小。

数据库 611.9 MiB,而 ELF 文件原占 644.4 MiB。整个用户态作为一个可查询的文件,比来源文件还小。让单个 hello 体积翻倍的 B-tree 开销,分摊到 1,123 个对象上几乎可以忽略不计,相对于实际的程序字节只多出约 6%。

各个可执行文件之间共享库和闭包的方式,很像 Nix 在多个闭包间共享,只要 store path 相同就能复用。如果每个根程序都自带私有闭包(即 AppImage 的模式),同样这 723 个程序的体积会膨胀到 5.53 GiB;而通过数据库 schema,库和符号的去重自然而然地就完成了。

$ sqlite3 userland.db \
    'SELECT count(DISTINCT soname), count(*)
     FROM objects WHERE soname IS NOT NULL'
345|399

$ sqlite3 -column userland.db \
    'SELECT soname, count(*) FROM objects
     WHERE soname IS NOT NULL
     GROUP BY soname HAVING count(*) > 1
     ORDER BY 2 DESC LIMIT 4'
libsystemd.so.0   3
libpthread.so.0   3
libgcc_s.so.1     3
libc.so.6         3

$ sqlite3 userland.db \
    "SELECT count(*)
    FROM needs
    WHERE resolved_path IS NULL AND soname NOT LIKE 'ld-%'"
4

许多 ELF 中常见的惯用法,自然而然就变成了数据库里的查询。例如,LD_PRELOAD 不再是环境变量,而是表中的一行记录。preload 表列出了最后映射的对象,这样它们的导出符号就会优先生效。也就是说,开启或关闭 LD_PRELOAD 就是一次事务操作。

$ ./app.self; echo $?
13

$ sqlite3 system.db "BEGIN;
    CREATE TABLE preload(ord INTEGER PRIMARY KEY, path TEXT);
    INSERT INTO preload VALUES (0, 'libmul.so.1.self');
  COMMIT;"

# 同一个二进制文件,无需设置环境变量,也无需重新链接
$ ./app.self; echo $?
42

$ sqlite3 system.db 'DELETE FROM preload;'
$ ./app.self; echo $?
13

我们可以在一个文件里,对整个用户态实现原子化的 LD_PRELOAD —— 比如『全局插入一个用于追踪的 malloc,然后 ROLLBACK 撤回来』,这本身就是单一事务。�

§目前的进展

格式已经定稿,ELF 与 SELF 之间可以无损互转。工具链也已完成,支持查询、修改和打包闭包。通过 SQL 查找的方式在未经修改的 glibc 程序上完美运行,原生 SQL 加载器也实现了足够的功能,足以作为一种思路来探索。

整个项目放在 fzakaria/selfdb。运行 nix run .#self-vm 会启动一个 NixOS 虚拟机,里面的 hello 就是一个 SQLite 数据库。🙌

Nix 让我们能够探索这样激进的思路。我们可以从零开始重建整个系统,甚至包括 Linux 内核。我们不必被过去的决策和约束所限制,可以放手尝试新想法,看看会冒出什么新东西。希望这个点子能让你觉得和我一样有趣。

原始来源: Hacker News

评论 (0)