Lume C11编译器内部架构:从树形解释器到双LLVM后端的设计之旅

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,持续更新与更多技术文章请访问站点。

相关推荐
冬奇Lab1 小时前
一天一个开源项目(第234篇):CC Switch —— 一个 App 管理十款 AI 编程工具的 Provider、MCP、Skills 和 Prompts
人工智能·开源·claude
小蠢驴打代码1 小时前
给 Codex Plan 模式加一本决策账本:问过什么、选了什么,都进仓库
开源·openai·ai编程
沪上企服通2 小时前
历史账套科目重映射与规则版本重算:旧账梳理的“口径复位”工程
笔记·架构
亥时无人机管理系统3 小时前
无人机管理系统源码 | 私有化部署平台 航线规划飞行审批实时监控功能介绍
架构·私有化部署·无人机·低空经济·无人机巡检·无人机管理系统·一网统飞
这个DBA有点耶3 小时前
时序数据库InfluxDB高基数问题深度拆解:内存索引膨胀机制与OOM完整链路
数据库·架构·dba
木子n14 小时前
软件架构设计实战-架构15.ISO 26262 架构设计要求:安全机制在架构里的落点
架构·autosar·功能安全·iso 26262
这个DBA有点耶5 小时前
为什么UPDATE比SELECT危险得多?锁范围等于扫描行数
数据库·架构·代码规范
程序员老赵6 小时前
Docker 部署 Portainer CE:轻松搭建容器管理平台
docker·容器·开源
我不是阵雨6 小时前
分布式事务难题与实战解法全解析
分布式·微服务·架构