教程·阅读约 4 分钟·
把可执行文件改成 SQLite 数据库:用 SQL 查询 ELF 的一切,程序照常运行

把可执行文件改成 SQLite 数据库:用 SQL 查询 ELF 的一切,程序照常运行

SELF 格式用 SQLite 数据库替代 ELF 作为可执行文件:file 命令显示它是 SQLite 数据库,./hello 照样输出 Hello world,而 ldd、nm、readelf、strip 全部退化成一条 SQL 查询。附完整原理和实操命令。

原文来源:Your Executable Is a SQLite Database — 用 SQLite 数据库替代 ELF 作为可执行格式,ldd、nm、strip 全部变成 SQL 查询,作者还拿 723 个可执行文件做了全用户态打包实验。

先说结论:一位叫 Farid Zakaria 的工程师,在博士论文里琢磨了多年的一个疯狂想法——把 Linux 可执行文件本身做成 SQLite 数据库——已经跑通了,原型叫 SELF(Structured Executable & Linkable Format)。他的 hello 程序现在长这样:

code
$ 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

看懂了吗?file 说它是一个 SQLite 数据库,./hello 能直接运行输出 "Hello, world!",而你想知道它依赖什么库,不用再 ldd hello 或者 readelf -d hello | grep NEEDED,一条 SQL 就完了。符号表、段信息、导入导出,全部变成表,随便查。

这不是什么教学玩具——文章展示了他把整个 Linux 用户态打包进一个数据库的完整实验。往下看,这个想法比听起来靠谱得多。

起点:ELF 本来就是个数据库

作者在博士期间就做过一个工具叫 sqlelf,让你用 SQL 查询 ELF 文件——SELECT name FROM elf_symbols 替代 readelf | grep。他发现 ELF 格式本质上就是一个数据库:它有 section、有符号表、有各种查找索引,只是全部用手工实现的数据结构堆出来的,包括一个为符号查找特调的 bloom filter。

但 ELF 有个致命问题:它是为"磁盘和带宽极度昂贵"的年代设计的,格式极其紧凑,几乎不自描述。你想修改它?经常得先把 section 清零再新增,因为布局挤得太满。更烦的是,解析 ELF 的代码被重复实现了无数遍——内核、ld.so、binutils、LIEF、goblin、readelf,每个工具都重写一遍 parser。

SQLite 正好是反面:自描述的格式、极度稳定、天生支持扩展而不破坏已有消费者。于是作者问了一个问题:如果我们用 SQLite 替代 ELF,会发生什么?

—— 广告 ——

SELF 的极简设计

一个 SELF 可执行文件要能跑起来,只需要两个表:

self_meta:ELF header 的 key-value 形式(magic、版本、入口点等)。

segments:加载映像,每个 program header 一行,段字节放在 BLOB 里:

code
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 里散落在 .dynsym.gnu.hash.dynstr 等多个 section 的东西,在 SELF 里就是一个表加一个索引。.dynstr 直接消失——SQLite 自己会做字符串驻留;符号版本从 .gnu.version_r / .gnu.version_d 那套复杂机制变成一个普通列。

code
# ldd 等价
$ 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

views 可以完美模拟原来的工具语义:

code
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;

最妙的是修改操作。ELF 世界里 strip 是"小心翼翼的偏移手术",在 SELF 里它就是一个事务:

code
# strip(1) 等价
$ sqlite3 hello 'DELETE FROM sections; DELETE FROM notes; VACUUM;'
# 57344 -> 49152 bytes,程序照样跑

# patchelf 等价:UPDATE 而已

它是怎么跑起来的

关键机制有两个。第一,SQLite 在文件头偏移 68 处预留了 4 字节的 application_id,专门给应用打标记用。SELF 在这里盖上 "SELF" 四个字节,普通的 SQLite 数据库永远不会撞上这个魔数:

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

第二,binfmt_misc。这是 Linux 内核的机制,允许你注册任意魔数,让内核把匹配的文件交给指定解释器执行。在 NixOS 上注册 SELF 只要几行配置(匹配偏移 0 的 SQLite 魔数和偏移 68 的 SELF 魔数),解释器是一个叫 self-exec 的小 C 程序——它链接 libsqlite3,行为很像 ld.so:从数据库里读 program header 和符号表,把可加载段映射进内存,做重定位,然后跳到入口点。

ELF 到 SELF 的转换靠一个叫 elf2self 的小工具,在 NixOS 里可以作为每个包可选的 postFixup hook 挂上。作者还留了个彩蛋:nix run .#self-vm 能启动一个 NixOS 虚拟机,里面连 hello 都是 SQLite 数据库。

动态链接:数据库真正的用武之地

静态程序的 SELF 化"又快又简单但无聊",动态链接才是数据库大放异彩的地方。作者探索了两条路线。

路线一:保留 ld.so,用 SQL 接管符号查找。通过 glibc 的 rtld-audit 接口(LD_AUDIT=libself-audit.so),在每次共享库查找时拦截,用 SQL 查询替代 RUNPATH/LD_LIBRARY_PATH 的文件系统搜索。共享库的存储是"行",库查找是"查询"。你可以把磁盘上的 .so 文件删光,程序照样从数据库里找到库并运行。

路线二:完全用 SQL 写一个动态链接器(self-ld)。它把每个对象映射进内存、发布导出符号,然后对每条重定位执行这样的查询:

code
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;

一个动态链接器,核心逻辑就是一条 SQL JOIN。这是 proof-of-concept,但它真的能跑。

成本与收益:有惊喜

体积:SELF 文件因为 SQLite 的 b-tree 开销,单文件约为 ELF 的两倍。但大部分开销来自那些可选工具表,strip 掉之后,coreutils 的 SELF 版本是 1,794,048 字节,ELF 是 1,768,632 字节——差距在 1% 以内

启动延迟:打开 SQLite 加启动解释器有约 5ms 固定开销,加上与映像大小成比例的拷贝。两个进程跑同一个 SELF 二进制不会像 ELF 那样共享 text pages(字节是从 b-tree 拷出来的,不是 mmap 的)——这是当前实现的主要弱点。

但真正的惊喜在"closure 打包":SQLite 数据库不必只是一个可执行文件,它可以是一个闭包——程序和它的全部传递依赖装进一个文件。ldd 有个著名缺陷:它只列出 soname,不告诉你具体哪个文件满足依赖。SELF 的做法是把每条依赖边解析后的路径直接存进数据库,resolved_path 字段就是外键:

code
$ 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
libc.so.6             8kvxvr3pmsypxiypq4g8

ls 加它的 5 个库,全部塞进一个 4.8 MiB 的文件。闭包内部不存在 soname 歧义——因为构造时就保证每条边恰好一个提供者。

作者最后玩了个大的:把系统 PATH 上全部 723 个可执行文件、400 个不同的共享库——1,123 个对象、346,386 个符号、3,808 条依赖边——打包成一个 SQLite 文件。结果:611.9 MiB 的数据库,比原始 644.4 MiB 的 ELF 文件还小。单文件翻倍的开销在 1,123 个对象之间摊销到几乎为零(约 6%),而库和符号的去重是数据库 schema 自然长出来的。如果每个程序都按 AppImage 模式各自打包闭包,同样的 723 个程序要占 5.53 GiB——差距就在这里。

甚至 LD_PRELOAD 都变成了表操作:往 preload 表里 INSERT 一行再 COMMIT,同一个二进制下次运行就多加载了一个库;再 DELETE 就还原。对一个文件进行"预加载-运行-回滚"的原子操作,这在传统 ELF 世界是不可想象的。

怎么看这个项目

坦率说,SELF 短期内不会取代 ELF——5ms 启动开销、无页共享、需要 binfmt_misc 注册,这些在普通场景下都是劣势。但它打开了几个非常有价值的思考方向:

  • 可执行文件的"可查询性":安全审计、供应链分析、逆向工程,如果能用 SQL 直接查二进制,工具链的复杂度会断崖式下降。sqlelf 那个方向其实已经可以用了。
  • 单文件分发:closure 打包解决了"一个程序 + 一堆依赖怎么分发给别人"的经典难题,比 AppImage 更优雅(去重是自动的)。
  • ELF 的自我反思:作者那句"ELF 已经是数据库了,只是用手工实现了数据库原语"——下次你对着 readelf 输出发愁的时候,想想这句话。

项目在 fzakaria/selfdb,作者也写了配套论文(arXiv:2405.03883)。对格式设计、系统编程、或者单纯喜欢"疯狂但跑通了"的实验的人,非常值得读原文。

分享到
微博Twitter

© 2026 四月

原文链接:https://www.aprilzz.com/tutorials/sqlite-executable-self