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

作者:

在

🇬🇧 English

Lume-core 是 Lume 语言的 host-independent 上游树:它把语言本体独立出来——前端(词法 / 解析 / 类型检查)、一个树遍历解释器,以及两套原生代码发射器(手写 LLVM IR 文本 + libLLVM C API)。它只链接 libc(构建时若找到 llvm-config 再额外链 libLLVM),这里没有 HTTP 服务、没有 agent 运行时、没有 Docker 镜像。与宿主树 lume(把语言嵌进 agent-httpd 的那个发行版)是「同一门语言、不同的部署形态」:语言改动先落此树,宿主树再从这里同步走。

下面沿真实源码(src/ 下约 38 个 .c 文件、实测约 2.2 万行 C11)按数据流逐层拆解。

速览(TL;DR)

  • 三层结构:前端(lexer / parser / typecheck)→ 树遍历解释器(interp.c)→ 后端(手写 IR 文本 codegen*.c + libLLVM C API llvm_codegen.c)。
  • 双后端刻意共存并交叉验证:--compile 走 libLLVM(构建时即 LLVMVerifyModule 校验 IR 形状),--compile-text 走手写 IR 文本交给 clang;make native-bench 用同一脚本对比两者。
  • --no-pass 跳过 LLVM 优化管线,便于观察 emitter 产出的 lowering。
  • 与宿主树共享前端文件(手工同步、非自动):lexer.c / token.c / parser_stmt.c / typecheck_stmt.c 目前仍逐字节相同(仅 vdom.c 有差异);两树的 parser 同为 parser.c + parser_expr.c + parser_stmt.c 的拆分结构,core 的 parser.c 是 696 行的主文件。
  • int 是真 i64(two's complement),lexer 对整数走 strtoll、只有带 ./e/E 的才走 strtod 成为 float——这是为避免 2^53 以上静默舍入、以及解释器与两个后端分叉而刻意设计的。

整体架构概览

Lume 的源码组织呈三层结构:前端 → 解释器 → 后端。README 明确指出,这棵树是 host-independent,仅链接 libc(在找到 llvm-config 时额外链接 libLLVM),不带任何 HTTP 服务或 agent 运行时。与宿主树(work/lume/lume)相比,这里的前端文件是手工维护的副本,而非自动跟踪的 fork——它们已经在漂移:lexer.c / token.c / parser_stmt.c / typecheck_stmt.c 仍逐字节相同(仅 vdom.c 有差异),而 interp.c / value.c / typecheck.c / loader.c 等也已不同。

前端的抽象数据结构均声明在 lume.h 中,包括 VM、Node、Type 等核心类型,它们被词法分析器、递归下降解析器、类型检查器以及解释器和两个后端所共享,形成了跨层的统一中间表示。符号表在 loader.c 与 value.c 中维护:类型检查阶段填充绑定信息,解释器在运行时查询同一套符号表。

前端漫游

词法分析器(lexer.c) 词法阶段把源程序转换为 Token 流。一个值得注意的真实设计是整数与浮点的分流——int 是语言真正的 i64,绝不允许经过 double(strtod 会在 2^53 以上静默舍入,曾导致原生后端与解释器对同一个字面量产生分歧)。只有带 ./e/E 的字面量才走 strtod 成为 float:

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(696 行主文件)+ parser_expr.c + parser_stmt.c,内部通过递归下降处理表达式与语句,构建出 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 对应一种语法节点:

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 上下文:

/* 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 的上下文结构体持有模块、构建器与已声明的类型:

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 调用:

case OP_ADD: r = LLVMBuildAdd(g->ab, a.v, b.v, "a"); break;

这样生成的 IR 是内存中的 LLVM 数据结构,省掉了文本解析步骤。backend_llvm.c 负责后端的初始化、目标机选择,并把生成的模块交给 LLVMVerifyModule 在产出点就地校验——一个畸形的 IR 形状会在产生时就被拒绝,而不是延迟到 clang 步骤才暴露。

双后端协同与交叉验证

两个后端共享同一份前端产出的带类型 AST,因此生成的 LLVM IR 在语义上应当完全等价。项目在 docs/NATIVE.md 中说明,故意保留两套后端以互相交叉验证:可以同时对同一脚本启用两个后端生成 IR 并用 diff 比较,捕获任意一套实现的偏差(tests/native_backends.sh 做解释器 / 文本 / libLLVM 的三方对比,scripts/check-backend-parity.sh 检查两发射器的 AST 标签覆盖是否一致)。

一个已更新的结论(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 次内循环)给出实测:

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 所以慢”。

编译流程倒溯(main.c)

从命令行入口 main.c 看,用户执行 lume --compile script.lume 时,程序先读取文件、跑词法 / 解析 / 类型检查得到带类型的 AST,再按所选后端进入对应代码生成。main.c 用一个枚举把「要哪个原生后端」显式建模——默认是 libLLVM,缺 libLLVM 构建时回退到文本:

/* 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 是否可用)。常用目标:

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

Lume 这棵树是否依赖除 libc 之外的其他库?

仅在检测到 llvm-config 时会额外链接 libLLVM,除此之外只依赖标准 C 库。没有 Node、没有 server 运行时、没有 SQLite。

前端的解析器在本树与宿主树之间有何主要区别?

两树的 parser 都是 parser.c + parser_expr.c + parser_stmt.c 的拆分结构(core 的 parser.c 为 696 行的主文件);两树前端是手工同步的副本,非自动跟踪,因此 parser_stmt.c 等文件需随 core 改动手动合并。

解释器如何处理函数调用与返回?

解释器用运行时栈(vm_push / vm_peek)保存返回地址与局部变量帧,遇到 return 时弹出栈帧并把返回值传递给调用者。

双后端的主要区别是什么?

文本 IR 后端通过直接拼接 LLVM IR 字符串生成 .ll 文件再交给 clang;libLLVM 后端调用 LLVM C API 在内存中构建 LLVMValueRef 与基本块,并在产出点用 LLVMVerifyModule 就地校验。

如何验证两个后端生成的 IR 是否等价?

可同时启用两个后端分别生成 IR 后用 diff 比较;tests/native_backends.sh 做解释器 / 文本 / libLLVM 三方对比,scripts/check-backend-parity.sh 检查两发射器的 AST 标签覆盖一致性。

–no-pass 选项的作用是什么?

它会跳过 LLVM 的优化管线,直接输出未经优化的原始 IR,便于检查代码生成的 lowering 过程。

为什么 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 同仓相邻(当前工作区路径 work/lume/lume-core),语言改动先落此树再由宿主树同步。按管线惯例此处保留远端地址 github.com/erishen/lume-core;若项目尚未推送公开远端,该链接会是 404——属模板惯例,非本文杜撰。外部可访问的权威入口是 erishen.cn 的 Lume 系列文章(如 Lume: An Agent DSL Server in a Single C11 Binary)。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

商店 Web Chat Nsbp 关于 隐私政策

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号