可执行文件是SQLite数据库
摘要
文章探讨了用SQLite替换ELF可执行文件格式,介绍了一个名为SELF的原型,使得可执行文件可以成为SQLite数据库,并讨论了其益处和技术影响。
暂无内容
查看缓存全文
缓存时间: 2026/08/24 07:48
# 你的可执行文件是一个SQLite数据库
来源:https://fzakaria.com/2026/08/23/your-executable-is-a-sqlite-database
过去几年,我可能一直痴迷于两件事:Nix作为探索需要重建整个世界能力的创新想法的工具,以及用SQLite替代ELF作为可执行格式。你可能已经注意到这两个想法彼此非常契合。
我在博士论文期间探索过这个想法,但发现他人的反馈并不令人鼓舞。激进的想法很难推销,因为你是在对抗现有解决方案的惯性。
四格漫画。一只乌鸦对着麦克风说"Nix很棒";观众起哄并喊道"换点新东西";乌鸦看起来很沮丧;最后一格显示它剩下的提示卡,上面写着"SQLite可以成为目标文件格式"。
那个探索的最终成果之一就是sqlelf(https://fzakaria.com/2023/03/19/sqlelf-and-20-years-of-nix),一个让你可以使用SQL声明式地探索ELF文件的工具。我写了一篇论文(arXiv:2405.03883,https://arxiv.org/abs/2405.03883),但未能发表,还写了一篇后续关于如何用它进行查询的文章(https://fzakaria.com/2023/09/11/quick-insights-using-sqlelf)。用`SELECT name FROM elf_symbols`而不是摆弄`readelf`和`grep`。通过利用ELF上的*虚拟表*,它变得异常简单:然而我发现这是探索ELF文件格式的一种令人耳目一新的改进。但我当时就知道,还有更重大的事情要做。
我从未放弃这个想法,随着近期大语言模型的改进,我非常想重新审视这些想法并进一步探索。具体来说,我们能用SQLite替代ELF作为可执行格式吗?🤔
不是"一个描述可执行文件的数据库",而是你真正`chmod +x`并运行的那个实际文件。
```
$ file hello
hello: SQLite 3.x database, application id 0x53454c46, user version 1
$ ./hello
Hello, world!
$ sqlite3 hello 'SELECT soname FROM ldd'
libc.so.6
```
我开发了一个相当完善的原型。它被称为**SELF**,即*结构化可执行与可链接格式*,因为我缺乏创意。如果你感兴趣,它在GitHub上(https://github.com/fzakaria/selfdb)。我对这个想法衍生出的所有有趣事物感到惊讶。
## § ELF:一个拒绝承认自己是数据库的数据库
在攻读博士期间,我意识到一件困扰我的事情。ELF*已经*是一个数据库了。它只是手工实现了许多数据库原语,以及为了性能而使用的大量数据结构,比如用于符号查找的布隆过滤器。
如果你曾经需要分析或解析ELF,无论是内核、`ld.so`、binutils、LIEF、goblin还是`readelf`,你都在一遍又一遍地重新实现相同的解析器。每个生成器都重新实现相同的序列化器。
格式本身极其简洁,是为磁盘空间和网络带宽极其宝贵的世界设计的。修改格式很困难,你通常需要将段清零并添加新段,因为它打包得如此紧密。也没有自描述的模式。ELF本身是一个非常通用的格式,支持按惯例被特定方式解释的数据段,但格式本身并不强制执行。
SQLite就是反例。它是一种自描述的格式,极其稳定。它旨在扩展以支持新功能,同时不破坏现有消费者,并高性能地支持广泛的查询。
如果我们用SQLite替代ELF,会衍生出什么,所有必要的信息都能在SQLite数据库中表示吗?答案是肯定的,而且它异常简单。
## § 遗失了什么
一个SELF文件运行只需要两张表:`self_meta`是ELF头,存储为键值对;`segments`是加载映像,每行对应一个程序头,字节存储在`BLOB`中:
```sql
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中的许多段和`.gnu.hash`索引。它是一个带索引的单一表:
```sql
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树索引,而不是手工制作的布隆过滤器。`.gnu.hash`是一个布隆过滤器加上桶链,其布局使得`ld.so`在符号发现期间无需接触链即可拒绝未命中。
令人惊讶的是,还有更多东西消失了:`.dynstr`消失了,因为`name`是`TEXT`,SQLite已经内置了字符串驻留;符号版本控制是一个列,而不是`.gnu.version_r`/`.gnu.version_d`机制;也不需要`strings`表了。
还存在其他用于工具链的元数据表:`sections`、`notes`、`dynamic_entries`。删除它们程序仍然运行,这意味着`strip(1)`是一个事务:
```bash
# 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 字节
# 仍然可以运行,可选的表本来就是可选的
$ ./hello
Hello, world!
```
所有操作ELF文件进行读取的工具,都简化为对数据库的查询。任何修改ELF文件的工具,如`strip`,都可以在数据库内通过事务操作,而不是进行脆弱的偏移手术:`strip`是一个`DELETE`和`VACUUM`。`patchelf`是一个`UPDATE`。
模式中缺少的任何信息都可以通过视图轻松暴露。例如,`ldd`是对`needed`表的查询,这是`symbols`表与`segments`表的连接,用于查找程序所需库的soname。
```sql
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`(https://sqlite.org/pragma.html#pragma_application_id),正是用于此目的。我们将其标记为`SELF`,这样普通的SQLite数据库永远不会匹配:
```bash
$ xxd -s 64 -l 8 hello
00000040: 0000 0001 5345 4c46 ....SELF
```
我们现在可以利用binfmt_misc(https://docs.kernel.org/admin-guide/binfmt-misc.html),这个允许你像调用原生二进制文件一样调用任何二进制文件的子系统。我们只需要注册魔术字节来触发,并注册一个将调用我们新文件格式的解释器。
在NixOS上,注册只需几行代码,匹配偏移量0处的SQLite魔术字节*和*68处的`SELF`:
```nix
boot.binfmt.registrations.self = {
recognitionType = "magic";
offset = 0;
# 字节 0-15, 68-71
magicOrExtension = "SQLite format 3\\x00" + ... + "SELF";
# 忽略中间部分
mask = "\\xff..\\x00..\\xff";
interpreter = "${self-exec}/bin/self-exec";
};
```
目前,我有一个小工具`elf2self`,用于将ELF文件转换为SELF文件。这是NixOS上你可以为每个包选择加入的一个简单的`postFixup`钩子。该工具读取ELF,提取程序头和符号表,并将它们写入SQLite数据库。我们可以考虑扩展`gcc`或`ld`来直接发出SELF,但现在这是探索这个想法的一个简单方法。
```
elfhello(ELF) --elf2self--> convselfhello(SQLite db) --conv-> self
krn execve() --binfmt_misc self--> krn magic SELF@68 interp self-exec(interpreter)
krn --> interp run running process interp --> run
```
`self-exec`是解释器。它是一个链接了`libsqlite3`的小型C程序。其实现与`ld.so`非常相似,但它从数据库获取程序头和符号表,而不是从ELF文件中读取。它将可加载段映射到内存中,重定位它们,然后跳转到入口点。
> **注意** `self-exec`必须仍然是一个ELF文件。一个同时匹配注册的解释器会直接导致`-ELOOP`。
## § 动态链接
运行静态程序快速而简单,但*无聊*且缺乏想象力。有趣的部分是动态链接,这正是数据库大放异彩的地方。
我探索了两种不同的动态链接方式。第一种是保留`ld.so`,只是通过`glibc`的rtld-audit(https://man7.org/linux/man-pages/man7/rtld-audit.7.html)接口将查找替换为SQL查询,以便快速迭代设计。第二种是完全用新的动态链接器替换`ld.so`,该链接器在整个查找和绑定过程中都使用SQL。
glibc的rtld-audit接口允许审计库在任何文件系统搜索(包括`dlopen`)发生之前拦截每个共享对象查找(`la_objsearch`)。审计库随后可以用SQL查询来回答"哪个库满足这个符号?"这个问题,而不是遍历`RUNPATH`和`LD_LIBRARY_PATH`。标准的`ld.so`映射并重定位它,因此glibc的全部功能都有效:惰性PLT、IFUNCs、TLS和符号版本控制,同时库存储是行,库查找是查询。
```bash
# 磁盘上没有任何 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`,是一个完全用SQL实现动态链接器的小型C程序。这是一个概念验证,但它确实有效。它映射每个对象的段,发布它们的导出符号,并为每个重定位修补GOT并跳转到起始位置。
```sql
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树开销,体积大约是ELF的两倍。
(1980-01-01T00:00:00+00:00 image/svg+xml Matplotlib v3.10.5, https://matplotlib.org/ - https://fzakaria.com/assets/plotnine/3eb9b817457887c0.svg)
类似于ELF二进制文件,大部分开销是可以恢复的,因为开销主要来自用于调试和工具链的可选表。剥离和删除它们是一个事务。剥离后的`coreutils` SELF文件为1,794,048字节,而ELF为1,768,632字节,这**在1%以内**。
不过,我们将看到有一些有趣的方法可以进一步分摊这些开销,我发现这些方法非常独特和有趣。
**延迟。** 我对各种二进制文件进行了基准测试,从15 KiB的`hello`到42 MiB的`gdb`(链接了47个库):
(1980-01-01T00:00:00+00:00 image/svg+xml Matplotlib v3.10.5, https://matplotlib.org/ - https://fzakaria.com/assets/plotnine/b6945a8d0ea2d76b.svg)
有一个固定的约5毫秒用于打开SQLite并启动解释器,外加一个与映像大小成比例的复制。这个复制比看起来更糟糕,因为B树页面没有被映射到内存中。两个运行相同SELF二进制文件的进程无法像普通`mmap`的ELF那样共享文本页,因为字节是从B树中复制出来的,而不是映射出来的。你可能注意到`curl`(274 KiB,27个库)比ELF的`git`(4.6 MiB,5个库)启动得更慢。这是因为`ld.so`执行的工作与对象数量成比例,而不是与字节数成比例,我以前对此抱怨过(https://fzakaria.com/2024/05/03/speeding-up-elf-relocations-for-store-based-systems)。
## § 系统是一个闭包
然而,一个SQLite数据库不必仅仅是一个可执行文件。它可以是一个*闭包*,一个包含程序及其所有传递依赖项的单一文件。`ldd`程序的输出是模糊的:它只列出了所需库的soname,而不是满足这些需求的具体文件。Nix通过使用`RUNPATH`明确地将每条边解析到特定的存储路径来改进这一点。我之前写过关于Nix上`RUNPATH`的文章,比如使它变得冗余(https://fzakaria.com/2022/09/12/making-runpath-redundant-for-nix)或加速它(https://fzakaria.com/2022/03/14/shrinkwrap-taming-dynamic-shared-objects)。
我们可以在SELF中做同样的事情,通过在数据库中存储每条边的解析路径:
```sql
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`🤯:
```bash
$ 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歧义,因为根据构造,闭包包含的每个边恰好有一个提供者。
```
ls (is_root) -> libc (libc.so.6)
ls -> gmp (libgmp.so.10)
ls -> acl (libacl.so.1)
ls -> attr (libattr.so.1)
gmp -> libc
acl -> libc
acl -> attr
attr -> libc
```
## § 这能走多远?一个文件,一个用户空间
我希望你一直跟上了,因为这才是真正有趣的地方。我们可以更进一步,将**多个闭包**打包进一个单一的数据库。
五格《盗梦空间》梗图。Cobb:"你的可执行文件是一个SQLite数据库。" Fischer:"它链接的库呢?" Cobb:"也是SQLite,整个用户空间都是,一个文件。"
相似文章
你的可执行文件是一个SQLite数据库
一篇博客文章解释了一种Linux模式,其中SQLite数据库文件被格式化为ELF可执行文件,使用自定义解释器和binfmt_misc实现直接执行。
SQLite:万能数据库解决方案
本文倡导将SQLite视为一种多功能且稳定的数据库解决方案,强调其在不同技术栈中替代多种其他工具的能力。
迷你可执行文件再探
本文重新审视了在 Linux 上创建极小 ELF 可执行文件的技术,探讨如何通过滥用头部字段和重叠结构将大小缩减至 45 字节,同时保持与 ELF 规范的兼容性。
用 10 MB 的 FST(有限状态转换器)二进制文件替换 3 GB 的 SQLite 数据库
作者描述了将 3 GB 的 SQLite 数据库替换为 10 MB 的有限状态转换器(FST)二进制文件,以优化芬兰语-英语词典工具,在保持性能的同时将内存使用量减少了 300 倍。
SQLite: How it works, by Richard Hipp (2024)
本文整理了 Richard Hipp 在 2024 年关于 SQLite 设计原理与哲学演讲的要点,阐述了 SQLite 嵌入式、单文件、低复杂度的特点及其工作原理。