Hotdry.

Article

Dotcl:将 Common Lisp 编译至 .NET CLR 的技术路径与设计选择

解析 Dotcl 将 Common Lisp 源码编译至 CIL 字节码的实现路径,涵盖 S 表达式到 IL 的转换机制、CLR 运行时互操作模型,以及与 Racket、Clojure 等托管 Lisp 实现的设计差异。

2026-05-02systems

当一门诞生于 1958 年的语言试图运行在 2020 年代成熟的托管运行时上,会碰撞出怎样的工程实现?Dotcl 回答了这个问题 —— 这是一个将 ANSI Common Lisp 完整编译至 .NET CLR(CIL / Common Intermediate Language)的开源实现,其核心思路是将 Lisp 源码翻译为中间语言字节码,交给 .NET JIT 统一执行。这种做法避开了传统 SBCL 移植中繁琐的寄存器分配、调用约定和 GC 后端编写工作,同时保持了跨平台能力。更关键的是,由于生成的仍是标准 .NET 程序集,Lisp 代码可以直接继承 MAUI、ASP.NET Core、MonoGame 等框架的互操作特性,形成独特的生态位。

S 表达式到 CIL 的编译管道

Dotcl 采用「A2 方式」的编译架构,整个管道分为四个阶段:Reader 解析、编译器转换、CIL 汇编、运行时执行。值得注意的是,编译器本身是用 Lisp 编写的,这使得 eval、load 和 compile-file 三条路径共享同一套转换逻辑,天然支持自我引导(self-hosting)。

第一阶段:Reader(读取器)。Dotcl 的 Reader 完全由 C# 实现(runtime/Reader.cs,约 1985 行),严格遵循 CLHS 2.2 的 token 组装规则。它将文本流解析为 S 表达式,同时处理宏字符、共享结构(#n=# / #n#)、条件读取(#+ / #-)等特性。Reader 的核心数据结构是 LispReadtable,其中维护了字符到调度函数的映射表 —— 这与 SBCL 的 Reader 架构基本一致,但实现语言不同。

第二阶段:Lisp 製编译器。compiler/cil-compiler.lisp(约 1286 行)是整个系统的核心,它接收 S 表达式,输出一种名为 SIL 的中间表示 —— 本质上是一系列 CIL 指令的 S 表达式描述,例如 ((:ldc-i8 1) (:call "Fixnum.Make") (:ret))。编译器是完全纯函数的,不调用任何 .NET API,这意味着它可以在任何 Lisp 环境中运行。cil-forms.lisp(约 4698 行)以哈希表 O (1) 调度的方式处理约 250 种特殊形式(special form),包括 quote、if、let、lambda、block、tagbody、handler-case、unwind-protect 等。自由变量分析(closure 捕获哪些变量)和变异分析(哪些 let 绑定会被后续 setq 修改)决定了哪些变量需要装箱为闭包单元。

第三阶段:CIL 汇编器。runtime/Emitter/CilAssembler.cs(约 2695 行)将 SIL 指令列表转换为真正的 CIL 字节码。它支持约 74 种 opcode 和 directive,包括基础的加载存储、函数调用、分支跳转、异常处理帧等。汇编器同时驱动两条输出路径:DynamicMethod(eval/load 场景,即时编译执行)和 PersistedAssemblyBuilder(compile-file 场景,输出 .fasl 文件)。.fasl 在这里并非 SBCL 的机器码 FASL,而是纯 IL 的 .NET 程序集 —— 这正是 Dotcl 跨平台运行的关键:Windows 编译的 .fasl 可以直接在 Linux 上加载运行,无需重新编译。

运行时初始化。首次启动时,Dotcl 需要通过 SBCL + Roswell 进行交叉编译:运行 make cross-compile,SBCL 加载 compiler/cil-compile.lisp,输出 compiler/cil-out.sil(文本化的 SIL),.NET 运行时读取该文件后即可启动 Lisp 环境。启动完成后,设定 DOTCL_LISP=dotcl make cross-compile 即可实现自我编译。

CLR 运行时互操作模型

Dotcl 对 .NET 的互操作不是简单的 FFI 调用,而是深度集成。dotnet: 包提供了直接操作 .NET 类型的语法扩展:

;; 创建 .NET 对象
(dotnet:new "System.Text.StringBuilder")

;; 调用实例方法
(dotnet:invoke sb "Append" "x")

;; 调用静态方法
(dotnet:static "System.Math" "Sin" 1.0)

;; 定义继承 .NET 类的 Lisp 子类
(dotnet:define-class MyPage (ContentPage)
  (dotnet:define-method (OnAppearing ())))

dotnet:define-class 的实现尤为精妙:每次调用时,Dotcl 通过 AssemblyBuilder 动态创建一个真实的 CLR 类型,其方法通过全局字典(key 为 typeFullName + methodName)分发到对应的 Lisp lambda。这意味着 MAUI 的数据绑定、ASP.NET Core 的路由发现、JSON 序列化器的自动探测,都会将这些 Lisp 类视为「普通的 .NET 类」—— 无需额外的适配层。

NuGet 集成通过 dotnet:require 实现,它从 nuget.org 下载包到本地缓存,然后通过 Assembly.LoadFrom 加载,框架兼容性由 .NET SDK 负责校验。这种设计让 Lisp 程序可以直接依赖业界标准的 HTTP/2、gRPC、JSON、ML.NET、Entity Framework 等库,而无需在 Lisp 侧重新实现。

与 Racket、Clojure 的设计差异

将 Lisp 方言编译到托管运行时并非 Dotcl 独有,Racket(通过 Chez Scheme 或 Racket VM)和 Clojure(JVM)已经走了很远。但三者的设计选择存在显著差异:

编译单位与运行时绑定。Racket 默认将程序编译为 Racket VM 字节码,虽然也能生成原生可执行文件,但与 .NET/ JVM 的托管运行时本质不同。Clojure 编译为 JVM 字节码,但它更强调与 Java 库的互操作而非语言本身的完整实现 ——Clojure 本身是 Lisp-1,没有完整的 CL 风格的条件系统、restarts、multiple values 等特性。Dotcl 的目标是完整 ANSI Common Lisp 兼容性(ansi-test 99.99% 通过),包括 CLOS、MOP、conditions、restarts、loop、format 等 —— 这是一条比 Clojure 更重的实现路径。

值表示。Clojure 全部使用堆分配对象(boxed),数值运算有专门的 bigint、bigdec、ratio 类型。Dotcl 在数值塔上也采用类似策略(Fixnum 包装 long、Bignum 使用 BigInteger),但做了 Fixnum 缓存(-128~65535 范围)和快速路径内联。SBCL 的 unboxed fixnum 表示目前尚未在 Dotcl 中实现 —— 这是 Step 7 优化路线图中的未来课题。

互操作深度。Clojure 通过 gen-class 和 proxy 与 Java 互操作,需要显式声明接口实现。Dotcl 的 dotnet:define-class 生成的类型在 Reflection 层面与原生 C# 类无异 —— 这让它更适合需要 deep framework integration 的场景(如 MAUI UI 定义、ASP.NET Core 控制器、MonoGame 游戏逻辑)。ClojureCLR 在 .NET 上也有类似尝试,但实现成熟度和 ANSI 覆盖度不如 Dotcl。

跨平台模型。SBCL 的 FASL 包含机器码,因此需要为每个目标平台(x86-64、ARM64、Windows、Linux、macOS)单独编译。Dotcl 的 .fasl 是纯 IL,天然跨平台 —— 这与 Clojure 的 .class 文件类似,但更接近 CL 的 compile-file 语义而非 JVM 的类文件加载。

可落地的工程参数

如果需要在项目中评估或使用 Dotcl,以下参数值得关注:

环境要求。.NET SDK 10 是硬性依赖 —— 这是因为 PersistedAssemblyBuilder(在 .NET 9 恢复、10 稳定)是 compile-file 输出 .fasl 的前提条件。macOS 可通过 Homebrew brew install --cask dotnet-sdk 安装,Ubuntu 24.04+ 通过 apt install dotnet-sdk-10.0,Windows 通过 winget 或 Scoop。初始交叉编译阶段需要 Roswell(仅此一次),之后 dotcl 可以自我重建。

性能基准。根据 DESIGN.md 中 D675 的测量数据:.fasl 加载耗时 0.73s,.sil(文本化的 SIL)1.77s,原始 .lisp 源码 3.38s。类型声明生效的数值计算可达到 SBCL 的 1~1.5 倍 —— 这在大多数业务场景下属于实用范围。动态路径(eval/load)使用 DynamicMethod,静态路径(compile-file)使用 PersistedAssemblyBuilder,前者适合 REPL 迭代,后者适合发布分发。

测试覆盖。ansi-test 通过 21,928 / 21,929(99.99%),剩余一个失败的用例是 ANSI 标准中描述模糊的边界情况。ASDF、alexandria、bordeaux-threads 等常用库可以直接通过 Quicklisp 加载(只要不依赖 SBCL 特有的内部实现)。项目包含约 880 个设计决策文档(docs/decisions/DNNN-...md),每个决策都记录了背景、选项和依据 —— 这对理解实现细节极有价值。

生态集成示例。samples/ 目录下的 MauiLispDemo 展示了在 .NET MAUI(Windows + Android)中用 Lisp 定义 Application、ContentPage 和 ViewModel;AspNetLispDemo 展示了带属性路由的 ASP.NET Core 控制器;MonoGameLispDemo 展示了在游戏帧循环中执行 Lisp 代码;McpServerDemo 展示了将 Lisp REPL 暴露给 MCP 客户端(Claude Desktop 等)。每个示例都有独立的 README.md 引导启动流程。

小结

Dotcl 的核心价值在于:它不是简单的「在 .NET 上跑 Lisp」,而是用 Lisp 的方式生成标准 .NET 程序集,从而继承了整个 .NET 生态的工具链和框架能力。从 S 表达式到 CIL 的编译管道设计(编译器 itself is Lisp)、ANSI 完整兼容的追求(99.99% ansi-test)、以及深度集成的 dotnet: 互操作模型,共同构成了一条有别于 ClojureCLR、IronScheme 等既有实现的工程路径。其 .fasl 跨平台特性对于需要统一交付物的多系统场景具有实际吸引力,而 Self-hosting 能力则保证了工具链的完整性。


资料来源

systems