一个可以 chmod +x 直接运行的文件,用 file 命令查看类型却显示 SQLite 数据库——这件事在 Linux 上已经可以实现。工程师 Farid Zakaria 发布了名为 SELF(Structured Executable & Linkable Format)的原型项目,把 ELF 可执行格式整体搬进 SQLite,./hello 输出 Hello, world!,sqlite3 hello 'SELECT soname FROM ldd' 则直接查出它依赖 libc.so.6。
$ 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 指的是作者自定义的可执行格式,与 Sun/Oracle 的老牌 SELF 格式(Small Executable and Linkable Format)并无关系。项目代码开源在 GitHub 的 fzakaria/selfdb 仓库,用 C 语言写成,nix run .#self-vm 可以启动一台 hello 以 SQLite 数据库形态存在的 NixOS 虚拟机。
ELF 本来就是一个不肯承认的数据库
作者在博士期间研究 Nix 与可执行格式时得出一个观察:ELF 自身已经在手工实现大量数据库原语。把两边的机制摆在一起对照,对应关系相当直观:
| ELF 机制 | 手工复刻的数据库原语 |
|---|---|
.strtab / .dynstr | 字符串驻留(string interning) |
.hash / .gnu.hash | 索引(等价 CREATE INDEX) |
| section header table | 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 格式本身极为紧凑,诞生于磁盘空间和网络带宽极度昂贵的年代,代价是修改困难(常常需要清零某些 section 再追加新的),并且缺少自描述的 schema——格式约定了数据如何摆放,却不强制任何语义。
SQLite 恰好相反:自描述、格式极其稳定、设计上就支持在不破坏旧消费者的情况下扩展新特性,并且能高效承载多种查询模式。用一个稳定的数据库格式承载可执行文件,是这个项目的核心假设。
SELF 的 schema:两张表跑起来
一个能运行的 SELF 文件最少只需要两张表。self_meta 以键值对形式存放原 ELF 头部字段;segments 存放加载镜像,每个 program header 一行,字节内容放在 BLOB 列里:
CREATE TABLE segments (
id INTEGER PRIMARY KEY, -- 原 phdr 索引
type TEXT NOT NULL, -- 'load' | 'tls' | 'stack' | 'relro'
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,
content BLOB -- 段字节;纯 BSS 为 NULL
);符号表用一张表加一个索引,替代了 ELF 里多个 section 和 .gnu.hash:
CREATE TABLE symbols (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
version TEXT, -- 例如 'GLIBC_2.2.5'
value INTEGER,
size INTEGER,
type TEXT, -- 'func' | 'object' | 'tls' 等
bind TEXT, -- 'global' | 'weak' | 'local'
defined INTEGER NOT NULL,
exported INTEGER NOT NULL
);
CREATE INDEX idx_symbols_name ON symbols(name, version);idx_symbols_name 这个 b-tree 索引在能力上等价于 ELF 的 .gnu.hash 和 .hash,区别在于它由 SQLite 维护,而非手写的 bloom filter 加桶链。.gnu.hash 的设计目标是让 ld.so 在符号查找未命中时无需遍历链表即可快速拒绝;在 SELF 里这层优化直接交给了数据库引擎。
更多结构随之自然消失:name 是 TEXT 且 SQLite 已做字符串驻留,.dynstr 不再需要;符号版本变成一列,.gnu.version_r / .gnu.version_d 这套组合机制也不复存在。为工具链服务的元数据表(sections、notes、dynamic_entries)是可选的——删掉它们程序照常运行。
工具链操作全部退化为 SQL
传统二进制工具在这个模型下获得了一一对应的查询形态:
# 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!读操作变成查询,写操作变成事务。strip 是一次 DELETE 加 VACUUM,patchelf 是一次 UPDATE,都不再需要对文件做脆弱的偏移手术。schema 里没有的信息可以用视图补齐,ldd、imports、exports 三个视图就是按不同条件过滤 symbols 表得到的。
它怎么跑起来:application_id 加 binfmt_misc
SQLite 在文件头偏移 68 的位置保留了 4 字节的 application_id,官方文档明确说明这个字段就是留给此类用途的。SELF 把它写为 SELF 四个字符的 ASCII(0x53454c46),普通 SQLite 数据库不会命中这个标记:
$ xxd -s 64 -l 8 hello
00000040: 0000 0001 5345 4c46 ....SELF剩下的事交给 Linux 内核的 binfmt_misc 子系统:注册一条规则,匹配偏移 0 处的 SQLite magic 加偏移 68 处的 SELF,命中后交给解释器 self-exec 执行。在 NixOS 上这段注册只是几行声明式配置。
self-exec 是一个链接了 libsqlite3 的小型 C 程序,实现逻辑与 ld.so 高度相似,只是程序头和符号表从数据库读取而 ELF 从文件解析。它把可加载段映射进内存、完成重定位、跳转到入口点。有一个必要的妥协:self-exec 自身必须保持 ELF 格式,否则解释器会递归匹配自己的注册规则,直接陷入 -ELOOP。
转换工具 elf2self 负责把现有 ELF 转成 SELF,读取 program headers 和符号表写入 SQLite 数据库。在 NixOS 上它是一个可以按包启用的 postFixup 钩子。作者提到未来可以让 gcc 或 ld 直接输出 SELF,目前先以转换工具的方式探索这个想法。
动态链接的两条路线
静态程序只是热身,动态链接才是这个格式真正发挥数据库优势的地方。作者实现了两条技术路线。
第一条保留 ld.so,通过 glibc 的 rtld-audit 接口介入。审计库可以在任何文件系统查找发生之前拦截每个共享对象的查找(la_objsearch,包括 dlopen),然后用一条 SQL 查询回答"哪个库满足这个符号",替代对 RUNPATH 和 LD_LIBRARY_PATH 的遍历。加载与重定位仍由原生 ld.so 完成,lazy PLT、IFUNC、TLS、符号版本等 glibc 全部特性照常工作。先把库扫描进 system.db,再设置 LD_AUDIT,即使磁盘上没有任何 ELF 库文件,程序也能从数据库里完成链接。
第二条路线更激进:作者写了一个完全用 SQL 实现的动态链接器 self-ld。它映射每个对象的段、发布导出符号、为每条重定位修补 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 文件携带 SQLite b-tree 开销,初始约为 ELF 的两倍。但开销主要来自调试和工具链用的可选表,strip 之后差距急剧收窄:一个 strip 过的 coreutils SELF 为 1,794,048 字节,对应 ELF 为 1,768,632 字节,差距在 1% 以内。而在更大的聚合规模上(下文详述),b-tree 成本还会进一步摊薄。
延迟方面,作者的 benchmark 覆盖从 15 KiB 的 hello 到 42 MiB、链接 47 个库的 gdb。开销结构是固定约 5 ms 的 SQLite 打开与解释器启动成本,加上与镜像大小成正比的拷贝成本。拷贝这部分的代价超出表面所见:b-tree 页没有映射进内存,字节是从 b-tree 拷贝出来的,两个进程运行同一个 SELF 二进制时无法像 mmap 过的 ELF 那样共享代码页。
benchmark 里还有一个反直觉的细节:274 KiB、链接 27 个库的 curl,启动反而比 4.6 MiB、只链接 5 个库的 ELF 版 git 慢。原因在于 ld.so 的工作量与对象数量成正比,与字节数无关——这正好暴露了传统动态链接在库数量上的扩展性短板,也是作者此前研究 ELF 重定位加速时的已知结论。
从闭包到整个 userland
ldd 的输出天然有歧义:它只列出需要的库的 soname,不指出具体由哪个文件满足。Nix 用 RUNPATH 显式解析每条边到特定 store 路径来解决这个问题。SELF 把这个思路搬进了 schema——needs 表的 resolved_path 列是一个指向 objects(path) 的外键,作者称之为"消灭歧义的外键"。
self closure 命令把一个二进制和它的全部传递依赖打进单个数据库。ls 加它的五个库,六个对象连同段字节,装在一个 4.8 MiB 的文件里。闭包内没有 soname 歧义,因为一个闭包在构造上保证每条边恰好有一个提供者。查依赖就是一次 JOIN。
这条路还能走得更远:多个闭包可以合并进同一个数据库。作者把系统 PATH 上全部 723 个 ELF 可执行文件(涉及 400 个不同的共享库,合计 1,123 个对象、346,386 个符号、3,808 条依赖边)打包成了一个 SQLite 文件,结果是 611.9 MiB 的数据库对 644.4 MiB 的原始 ELF 文件——整个 userland 装进一个可查询的文件,体积比来源文件还小。单个 hello 上翻倍的 b-tree 成本,摊到 1,123 个对象上只比程序字节多出约 6%。库和闭包在可执行文件之间的共享方式与 Nix 跨闭包共享 store path 的机制类似;如果每个程序各自携带私有闭包(即 AppImage 模式),同样的 723 个程序要占 5.53 GiB,而数据库 schema 天然完成了库与符号的去重。
连 LD_PRELOAD 也获得了新形态:它变成一张表里的若干行,而不是一个环境变量。往 preload 表插入一行,同一个二进制不做任何重链接,行为就变了;删掉这行,行为复原。作者演示了在事务里对一个 userland 全局原子性地插入追踪用的 malloc,再整体 ROLLBACK。
现状与边界
项目的完成度描述:格式已完成,ELF 与 SELF 之间可以无损往返;工具链已完成,能查询、修改、打包闭包;通过 SQL 查找在未修改的 glibc 程序上完全可用;原生 SQL 加载器足以支撑继续探索。
边界同样清晰。约 5 ms 的固定启动开销对命令行工具敏感;b-tree 页无法映射导致代码页无法跨进程共享,这是与 ELF+mmap 模型的实质差距;self-exec 必须保持 ELF,引导链无法纯粹自举;整个方案依赖 binfmt_misc,属于 Linux 特有的路径。它目前是一个研究原型,距离挑战 ELF 的既有地位很远——作者自己也把这项工作定位为 Nix 支撑下对激进想法的探索:Nix 可以重建整个世界直到 Linux 内核,不必被历史决策约束,可以看看从一个不同的基础出发会掉出什么东西。
对工具链开发者的参考价值在于视角转换:当二进制被当作数据库,符号解析变成索引查询,依赖分析变成外键遍历,补丁变成事务。这个原型证明了这条思路在工程上成立,也为分析类工具(安全扫描、依赖审计、符号统计)提供了一个无需解析 ELF 的替代入口。

参考来源:Farid Zakaria 个人博客原文(fzakaria.com/2026/08/23/your-executable-is-a-sqlite-database)、GitHub 仓库 fzakaria/selfdb、相关论文 arXiv:2405.03883。