Lume C11编译器内部架构:从树形解释器到双LLVM后端的设计之旅
Lume-core 是 Lume 语言的 host-independent 上游树:它把语言本体独立出来------前端(词法 / 解析 / 类型检查)、一个树遍历解释器,以及两套原生代码发射器(手写 LLVM IR 文本 + libLLVM C API)。它只链接 libc (构建时若找到 llvm-config 再额外链 libLLVM),这里没有 HTTP 服务、没有 agent 运行时、没有 Docker 镜像。需要区分的是:这里的「只依赖 libc」指的是编译器自身 的链接依赖(加上可选的 libLLVM);而把一个 Lume 脚本编译成原生可执行文件,还要依赖系统工具链(cc / clang 与链接器)------那是生成环节 的需求,不是编译器这个二进制本身的依赖。与宿主树 lume(把语言嵌进 agent-httpd 的那个发行版)是「同一门语言、不同的部署形态」:语言改动先落此树,宿主树再从这里同步走。
下面沿真实源码(src/ 下的 C11 实现)按数据流逐层拆解。
速览(TL;DR)
- 一个前端 + 三条互不串行的执行/代码生成路径 :前端(lexer / parser / typecheck)产出带类型 AST 之后,并行分叉为树遍历解释器(
interp.c)、手写 LLVM IR 文本后端(codegen*.c)、libLLVM C API 后端(llvm_codegen.c)三条路径------解释器与两个后端是共享同一份 AST 的不同执行分支,而非「前端 → 解释器 → 后端」的串行管线。 - 双后端刻意共存并交叉验证 :
--compile走 libLLVM(构建时即LLVMVerifyModule校验 IR 形状),--compile-text走手写 IR 文本交给clang;make native-bench用同一脚本对比两者。 --no-pass跳过 LLVM 优化管线,便于观察 emitter 产出的 lowering。- 与宿主树共享前端文件 (由
make sync-lang显式触发、make check-sync审核、以lang/PIN锁定上游版本,属「工具辅助的受控同步」而非全自动跟踪):lexer.c/token.c/parser_stmt.c/typecheck_stmt.c目前仍逐字节相同(仅vdom.c有差异);两树的 parser 同为parser.c+parser_expr.c+parser_stmt.c+parser_internal.h的拆分结构。 int是真 i64 (two's complement),lexer 对整数走strtoll、只有带./e/E的才走strtod成为float------这是为避免 2^53 以上静默舍入、以及解释器与两个后端分叉而刻意设计的。
整体架构概览
Lume 的源码组织是一个共享前端 + 三条互不串行的执行/代码生成路径 ,而不是「前端 → 解释器 → 后端」的串行管线。README 明确指出,这棵树是 host-independent ,仅链接 libc(在找到 llvm-config 时额外链接 libLLVM),不带任何 HTTP 服务或 agent 运行时。带类型信息的 AST 是分叉点:之后树遍历解释器、手写 IR 文本后端、libLLVM 后端各自独立消费同一份 AST。与宿主树(lume)相比,这里的前端文件是受控同步的副本 :宿主通过 make sync-lang 从上游拉取、并以 lang/PIN 锁定 core 的具体提交,并非自动跟踪的 fork;当前它们已经在漂移:lexer.c / token.c / parser_stmt.c / typecheck_stmt.c 仍逐字节相同(仅 vdom.c 有差异),而 interp.c / value.c / typecheck.c / loader.c 等也已不同。当前宿主已用 make sync-lang + lang/PIN 实现受控同步(显式触发、需人工审核漂移),理想状态是进一步自动化------核心语言只有一份权威实现,宿主通过依赖 / 子模块 / 明确的同步机制复用它,HTTP、Agent 与部署能力留在宿主层;重点不是现在就拆成独立动态库,而是避免「语言语义的修复只进入一棵树、另一棵树继续跑旧实现」。这是全文最值得持续关注的架构债。
前端的抽象数据结构均声明在 lume.h 中,包括 VM、Node、Type 等核心类型,它们被词法分析器、递归下降解析器、类型检查器以及解释器和两个后端所共享,形成了跨层的统一中间表示。符号表在 loader.c 与 value.c 中维护:类型检查阶段填充绑定信息,解释器在运行时查询同一套符号表。
下面这张图是理解全文架构的关键------解释器与两个后端共享 AST、互不串行:
text
Lume 源代码
│
▼
Lexer(词法分析)
│
▼
Parser(语法解析)
│
▼
Type Checker(类型检查)
│
▼
带类型信息的 AST (lume.h: Node / Type)
│
┌─────────────┼─────────────┐
▼ ▼ ▼
树遍历解释器 手写 LLVM IR libLLVM C API
(interp.c) 文本后端 后端
(codegen*.c) (llvm_codegen.c)
│ │ │
▼ ▼ ▼
直接执行 .ll → Clang/ LLVM IR 模块
LLVM 工具链 (内存中构建)
→ 原生可执行文件 │
▼
验证(LLVMVerifyModule)
+ 优化(-O2)
→ 目标文件 (.o)
│
▼
cc / clang 链接
│
▼
原生可执行文件
注:LLVMVerifyModule 只是对 IR 模块做结构合法性校验 ,并不是生成二进制的一步;libLLVM 后端最终仍需经目标代码生成为目标文件、再由系统 cc 链接为原生可执行文件(某些构建配置下也可能走 libLLVM 的 JIT 直接执行,而非产出独立二进制)。
前端漫游
词法分析器(lexer.c) 词法阶段把源程序转换为 Token 流。一个值得注意的真实设计是整数与浮点的分流 ------int 是语言真正的 i64,绝不允许经过 double(strtod 会在 2^53 以上静默舍入,曾导致原生后端与解释器对同一个字面量产生分歧)。只有带 ./e/E 的字面量才走 strtod 成为 float:
c
bool is_float = false;
for (int i = 0; i < len; i++)
if (buf[i] == '.' || buf[i] == 'e' || buf[i] == 'E') { is_float = true; break; }
Token *t = &o->toks[o->count - 1];
t->is_int = is_float ? 0 : 1;
超出 i64 范围的整数直接报错,而不是悄悄回绕------因为一个源程序无法表示的值属于源码 bug,静默改动正是两个后端当初分叉的根源。
递归下降解析器(parser.c、parser_expr.c、parser_stmt.c) 本树把解析拆到 parser.c + parser_expr.c + parser_stmt.c + parser_internal.h,内部通过递归下降处理表达式与语句,构建出 lume.h 中定义的 Node 抽象语法树。宿主树采用同一套拆分(parser.c + parser_expr.c + parser_stmt.c + parser_internal.h),正因如此,两树前端的「手工同步」最易在这些文件间漂移。
类型检查器(typecheck.c、typecheck_expr.c、typecheck_stmt.c) 解析完成后,类型检查器遍历 AST,依据 lume.h 中的 Type 体系做静态推断与一致性检查,并把每个节点的推断类型附加到 AST 上,为后端代码生成提供类型信息。
中端漫游:树遍历解释器(interp.c)
解释器采用经典的树遍历方式直接执行 AST。eval_expr() 用一个大 switch 按节点类型分派------这正是「树遍历」的骨架,每个 case 对应一种语法节点:
c
static void eval_expr(VM *vm, Node *n, Env *env) {
if (vm->error) return;
switch (n->type) {
case N_LITERAL:
eval_expr_literal(vm, n);
return;
case N_VAR: {
int found = 0;
Value v = env_get(env, n->as.var.name, &found);
if (!found) {
vm_set_error(vm, "line %zu: undefined variable '%s'", n->line, n->as.var.name);
return;
}
vm_push(vm, v);
return;
}
case N_ASSIGN: {
eval_expr(vm, n->as.assign.value, env);
if (vm->error) return;
Value v = vm_peek(vm, 0); /* keep rooted on the stack */
env_set(vm, env, n->as.assign.name, v);
return; /* result: assigned value, already on stack */
}
/* ...N_ASSIGN_MEMBER / N_BINARY / N_CALL / N_IF / N_WHILE / N_FOR ... */
}
}
执行过程只依赖解释器自身的运行时栈(vm_push / vm_peek),无需生成任何中间代码;遇到 return 时弹出栈帧并把返回值传递给调用者。这套栈式执行也是后续原生后端要对齐的语义基准。
后端一:手写 LLVM IR 文本发射(codegen.c 及 codegen_*.c 族)
一套后端直接拼接 LLVM IR 的文本 形式来生成中间表示。codegen.c 只是入口------真正的发射器按职责拆到相邻文件,通过 codegen_internal.h 共享同一个 CG 上下文:
c
/* codegen.c --- the text backend's entry point.
* codegen_types.c Type -> LLVM spelling
* codegen_expr.c every expression form
* codegen_scan.c static types of expressions, block scanning
* codegen_sig.c signature inference
* codegen_stmt.c statements, loops, function bodies
*/
它遍历带类型信息的 AST,为每种表达式或语句发射对应的 LLVM 指令序列,写入一个 .ll 文件,随后交给 clang -S / llvm-as 汇编与链接。因为是纯文本拼接,这条路径便于人工检查生成的 IR 是否正确。
后端二:libLLVM C API 发射(llvm_codegen.c、backend_llvm.c)
另一套后端直接用 LLVM 的 C 接口,在内存中构建 IR。llvm_codegen.c 的上下文结构体持有模块、构建器与已声明的类型:
c
typedef struct {
LLVMContextRef ctx;
LLVMModuleRef mod;
LLVMBuilderRef ab; /* the moving builder */
LLVMValueRef fn; /* function whose body is being emitted */
LLVMBasicBlockRef cur; /* block the builder points into */
LLVMTypeRef i1, i64, dbl, i8ptr, void_ty, i32;
/* ...locals / gvars / sigs ... */
} Cg;
每个 AST 节点调用对应的 LLVMBuild* API 来构造值(LLVMValueRef)与基本块。例如二元加法的发射就一行直白的 builder 调用:
c
case OP_ADD: r = LLVMBuildAdd(g->ab, a.v, b.v, "a"); break;
这样生成的 IR 是内存中的 LLVM 数据结构,省掉了文本解析步骤。backend_llvm.c 负责后端的初始化、目标机选择,并把生成的模块交给 LLVMVerifyModule 在产出点 就地校验------一个畸形的 IR 形状会在产生时就被拒绝,而不是延迟到 clang 步骤才暴露。注意 :LLVMVerifyModule 验证的是 LLVM IR 模块自身的结构合法性(类型、基本块、调用签名的自洽),并不证明生成的程序符合 Lume 的语言语义------整数溢出、作用域、控制流等语义正确性,仍需由解释器与原生后端的一致性测试来兜底。
双后端协同与交叉验证
两个后端共享同一份前端产出的带类型 AST,但不要求生成逐字节相同的 IR ------只要各自符合 Lume 的语言语义,指令组织、临时变量命名、优化结果都可以不同。项目在 docs/NATIVE.md 中说明,验证手段分两层:其一是 IR 差异比较(诊断用) ------对同一脚本同时启用两后端生成 IR 后 diff,适合发现生成指令、基本块与 lowering 策略的偏差;其二是 运行结果差分测试(语义证据) ------tests/native_backends.sh 跑解释器 / 文本 / libLLVM 三方对比、对同一程序核对输出,才是语义一致性的主要证据(scripts/check-backend-parity.sh 仅检查两发射器的 AST 标签覆盖是否一致,是另一层结构校验)。
保留两套后端是有意为之的取舍,而非过度工程。三条理由支撑它:可移植构建 ------没有 LLVM 开发包的环境里,文本后端仅凭系统 clang / 系统工具链就能产出原生二进制,部署门槛更低;可观测性 ------手写 IR 文本人眼可读,生成阶段的问题(某条指令拼错、某块基本块没闭合)直接 cat 就能定位,而 libLLVM 的内存 IR 需额外 dump 才能看;一致性验证 ------两套发射器共用同一前端,对同一程序产出的执行结果 / 测试覆盖一旦分歧,diff 与 native_backends.sh 会立刻报警。代价同样真实:维护两套代码生成逻辑意味着两倍的发射器 bug 面、两份 lowering 实现要对齐,开发与测试成本明显高于单一后端------这是项目当前主动承担的负债,不是没有代价。
一个已更新的结论(2026-10-03):libLLVM 这条路现在跑优化 pipeline 了 ------LLVMRunPasses 的入口在 llvm-c/Transforms/PassBuilder.h,而 backend_llvm.c 本来就 include 了它,所以不再是「纯 C 翻译单元里调 crash」的问题。实测上文本路和 libLLVM 路的运行侧落在同一档(default<O2> / clang -O2),而且libLLVM 路反而编译更快 ;make native-bench 对同一嵌套循环(1e8 次内循环)给出实测:
arduino
backend compile-ms ir-KiB obj-KiB bin-KiB run-ms nopass-ms
text 446 3 1 35 9 -
llvm 184 2 1 35 9 215
运行侧打平以后,真正的哨兵是最后一列 nopass-ms:--no-pass(等价环境变量 LUME_NO_PASS=1)跳过 pipeline 后,libLLVM 腿从 9ms 变 215ms------哪天 pipeline 悄悄失效,这一列会先跳起来。不要直接引用旧数(如 220ms / 7ms) :那是 pipeline 接通前的表,文库已明确标注为过期。吞吐敏感时仍可改用 --compile-text,但现在的理由不再是"libLLVM 不跑 pass 所以慢"。
测试环境说明 :本基准在作者开发机测得 ------ Apple M3、macOS 26.6.2(Apple clang 21.0.0、LLVM 23.1),优化档统一为
-O2。make native-bench的统计方式:compile-ms是单次 编译耗时(只测一次、不取平均),run-ms与nopass-ms是3 次运行取中位数 (消抖,非均值)。表格中compile-ms列为单次测量值(编译耗时未做多次采样),run-ms/nopass-ms列为 3 次运行的中位数。可下结论的仅是「同一嵌套循环(1e8 次内循环)这一特定负载下,libLLVM 后端编译更快、运行耗时与文本后端相当」------不能据此推断所有程序都呈现相同的性能关系。
设计结论:为什么既保留解释器,又支持原生编译?
把三条路径的价值分层看,本文的设计主线就清楚了:
| 执行方式 | 核心价值 |
|---|---|
| 树遍历解释器 | 快速反馈、调试与语义参照------在一条相对直接的执行路径上先把语言行为跑通 |
| 手写 LLVM IR 文本后端 | 易观察、易诊断、工具链兼容(仅需系统 clang) |
| libLLVM C API 后端 | 直接在内存构建 IR、集成 LLVM 的验证(LLVMVerifyModule)与优化能力 |
需要澄清一点:解释器并不天然 比原生编译更容易调试,也不会自动成为正确的语义标准。它的真正价值在于------你可以在一条相对直接的执行路径上先把语言行为跑通、当作参照,再用「解释器 + 两个原生后端」的差分测试去发现原生实现的偏差。当三条路径都稳定后,这套结构本身就为 Lume 的语言演进提供了现成的回归验证基底。
一个端到端的编译实例
用一个最小脚本串起全文各环节,便于不熟悉 LLVM 的读者沿一条具体路径建立心智模型。
① 源文件 fib.lume(递归求斐波那契第 n 项):
lume
fn fib(n) {
if n < 2 { return n }
return fib(n - 1) + fib(n - 2)
}
print(fib(10))
② 前端产出带类型 AST :lume --check fib.lume 跑词法 / 解析 / 类型检查,对 fib 推断签名 (int) -> int,不产出任何 IR。
③ 文本后端 IR (代表性 .ll 片段,执行 lume --compile-text --dump fib.lume 可见):
llvm
define i64 @fib(i64 %n) {
entry:
%lt = icmp slt i64 %n, 2
br i1 %lt, label %then, label %else
; ...then/else 分支、对 fib 的递归调用、phi 汇合结果...
}
④ 优化 :--compile(libLLVM,默认)与 --compile-text 都在 -O2 档跑优化管线;--no-pass 可关掉管线观察原始 lowering。
⑤ 原生二进制与运行 :lume --compile fib.lume 调系统 cc 把 IR 链成可执行文件并运行,print(fib(10)) 输出 55。
编译流程倒溯(main.c)
从命令行入口 main.c 看,用户执行 lume --compile script.lume 时,程序先读取文件、跑词法 / 解析 / 类型检查得到带类型的 AST,再按所选后端进入对应代码生成。main.c 用一个枚举把「要哪个原生后端」显式建模------默认是 libLLVM,缺 libLLVM 构建时回退到文本:
c
/* Native backends the CLI can ask for. --compile picks the default, which is
* the libLLVM one when the binary was built with libLLVM (LLVM then verifies
* the IR shape while it is built); --compile-text is the opt-out. */
enum { NAT_DEFAULT = 0, NAT_LLVM = 1, NAT_TEXT = 2 };
CLI 暴露的标志与 README 一致:--check(仅类型检查)、--dump(打印文本后端产出的 IR)、--compile / --compile-llvm / --compile-text、--no-pass(跳过优化管线)、--no-fs / --no-net(收窄脚本能力边界)。随后 main.c 临时调用系统的 cc(或 libLLVM 的 JIT)把 IR 转成可执行二进制返回给用户。
构建、测试与平台支持
依赖极简:一个 C11 编译器(cc),以及可选的 llvm-config(决定 --compile-llvm 是否可用)。常用目标:
bash
make # 构建 bin/lume-core(需 cc;有 llvm-config 才链 libLLVM)
make check # 对内置 examples 做类型检查,不产生产物
make test # parity + 单测 + crypto + 两套原生后端 + 一致性
make asan # 以 ASan/UBSan 重建并跑单测
make native-bench # 同一脚本对比两套后端
Windows(MSYS2 / mingw-w64)已完整支持:解释器、类型检查器、两套原生后端、模块导入都在 mingw 下工作。文件/目录/锁/时间内建(mkdir / 读写文件 / files / lock_file / strftime)也是可用的,但它是通过 os_win32.c 里的 shim(lume_mkdir / lume_flock)走的,而不是 builtins_fs.c 直接写 Win API。不支持的只有 --watch(依赖 fork/exec/kqueue)和出网 HTTP:关键是 http_get / http_post / ...------构建时排除 src/builtins_http.c 并以 LUME_HAS_HTTP=0 编译,所以这些内建在这种构建里能过类型检查、但调用时会报清晰的错误(而不是直接链接失败)。
源码导航
| 层 | 文件 |
|---|---|
| 核心类型 | lume.h |
| 前端 | lexer.c · token.c · parser.c · parser_expr.c · parser_stmt.c · parser_internal.h · typecheck.c · typecheck_expr.c · typecheck_stmt.c · vdom.c |
| 解释器 | interp.c · value.c · loader.c · rt.c |
| 文本后端 | codegen.c · codegen_types.c · codegen_expr.c · codegen_stmt.c · codegen_scan.c · codegen_sig.c · irbuf.c · backend.c |
| libLLVM 后端 | llvm_codegen.c · backend_llvm.c · backend_llvm.h |
| 内建 | builtins.c · builtins_str.c · builtins_math.c · builtins_fs.c · builtins_http.c · builtins_crypt.c · builtins_hof.c · builtins_catalog.c |
| 桥接 / 入口 | bridge_stub.c · bridge_native.c · bridge_mcp.c · bridge_lsp.c · bridge_serve.c · main.c |
| 文档 | docs/LUME.md(用户指南)· docs/DEVELOPMENT.md · docs/ARCHITECTURE.md · docs/NATIVE.md · docs/SPEC.md · docs/STYLE.md · docs/PITFALLS.md |
FAQ
常见问题(FAQ)
Q:Lume 这棵树是否依赖除 libc 之外的其他库?要怎么理解「只依赖 libc」? 需要区分四层依赖:① 编译器自身的基础链接依赖 ------仅 libc,检测到 llvm-config 时额外链 libLLVM;② 可选的 LLVM 开发依赖 ------libLLVM,缺失时回退到文本后端;③ 生成原生程序时依赖的系统工具链 ------cc / clang 与系统链接器(文本后端经 .ll → clang,libLLVM 后端经目标代码生成后同样由 cc 链接);④ 程序运行时使用的操作系统能力(文件 / 网络 / 进程等系统调用)。「只依赖 libc」指的是第 ① 层,而非「整个开发---编译---部署链路都无需其他工具」。没有 Node、没有 server 运行时、没有 SQLite。
Q:前端的解析器在本树与宿主树之间有何主要区别? 两树的 parser 都是 parser.c + parser_expr.c + parser_stmt.c + parser_internal.h 的拆分结构;两树前端是手工同步的副本,非自动跟踪,因此 parser_stmt.c 等文件需随 core 改动手动合并。
Q:解释器如何处理函数调用与返回? 解释器用运行时栈(vm_push / vm_peek)保存返回地址与局部变量帧,遇到 return 时弹出栈帧并把返回值传递给调用者。
Q:双后端的主要区别是什么? 文本 IR 后端通过直接拼接 LLVM IR 字符串生成 .ll 文件再交给 clang;libLLVM 后端调用 LLVM C API 在内存中构建 LLVMValueRef 与基本块,并在产出点用 LLVMVerifyModule 就地校验。
Q:如何验证两个后端与解释器的语义一致? 见「双后端协同与交叉验证」一节。IR 文本 diff 只是诊断手段 (发现指令 / 基本块 / lowering 偏差),真正的主证据是 tests/native_backends.sh 跑的解释器 / 文本 / libLLVM 三方运行结果差分测试------只要三者对同一程序输出相同,即认为语义一致;两后端并不要求生成逐字节相同的 IR。
Q:--no-pass 选项的作用是什么? 跳过 LLVM 优化管线,输出原始 IR 便于观察 lowering;它更是项目自身的诊断哨兵(见「双后端协同与交叉验证」里的性能哨兵说明)。
Q:为什么 libLLVM 后端跑出来的程序反而更慢? 这是旧 conclusion(2026-10-03 之前)的说法。现在 libLLVM 这条路已经跑优化 pipeline 了------LLVMRunPasses 的入口在 llvm-c/Transforms/PassBuilder.h,而 backend_llvm.c 本来就 include 了它,所以不再是「纯 C 翻译单元里调 crash」的问题;运行侧实测已经打平(default<O2> / clang -O2 同一档)。现在的真实哨兵是 --no-pass(或 LUME_NO_PASS=1)关了 pipeline 时的那一列:libLLVM 腿会从约 9ms 变成约 215ms,哪天 pipeline 悄悄失效它会先跳起来。吞吐敏感时仍可改用 --compile-text,但理由不再是"libLLVM 不跑 pass 所以慢",而是装不上 LLVM 开发包时的回退路。
项目地址与相关阅读
lume-core 是 Lume 语言的 host-independent 上游树,与宿主发行版 lume 同仓相邻。源码已公开,可直接访问 GitHub 仓库:github.com/erishen/lum...。语言改动先落此树再由宿主树同步。外部可访问的权威入口是 erishen.cn 的 Lume 系列文章(如 Lume: An Agent DSL Server in a Single C11 Binary)。
📌 本文首发于 erishen.cn,持续更新与更多技术文章请访问站点。