把 Rust 编译回 C:Eurydice 的复古野心
Inria 与微软背景的 Eurydice 项目想把 Rust 翻译成结构清晰、可读的 C 代码,为形式化验证和高保障软件铺路。访谈聊了它的工作原理、泛型与动态大小类型这些硬骨头,以及 HN 评论区关于自举信任和可读性的争论。

这周 HN 上一篇 LWN 的文章攒了不少讨论:Eurydice,一个想把 Rust 编译成"可读 C 代码"的开源项目。乍一听像开倒车——Rust 阵营花了十年讲内存安全,怎么又绕回 C 了?我请来老朋友高砚——编译器工程师,做过形式化验证工具链,Rust 和 C 两边的坑都踩过。我们聊了聊这个项目到底在解决谁的问题,"可读"二字有多实在,以及评论区那句"the circle is complete"是什么意思。
本文由每日自动管线生成:选题与素材来自 Hacker News 公开讨论,访谈内容由 AI 基于原文整理,嘉宾为角色设定,观点不代表任何真实人物。
小听: 砚,先替读者问出第一反应:Rust 不是号称安全的系统语言吗,为什么还要把它编译回 C?这不像倒退吗?
高砚: 第一反应很正常,但方向反了——Eurydice 不是要替代 Rust,而是给 Rust 铺一条"逃生通道"。它的目标场景是高保障软件(high-assurance):航空、密码学、关键基础设施,这些地方的验证工具和合规流程只认 C。你的验证器只能读 C,审计员只会审 C,那用 Rust 写的后量子密码算法想进这个体系,就得先变成 C。另外还有一种环境:只有 C 编译器、根本装不上 rustc 的平台。Eurydice 2023 年启动,是 Aeneas 项目的一部分,背后是 Inria 和微软的人在维护,已经拿它转译过一些后量子密码学的 Rust 实现。
小听: 那它和 rustc 有什么区别?不都是把 Rust 变成别的东西吗?
高砚: 区别在"输出给谁看"。rustc 的目标是机器码,生成的中间代码是纠缠的循环加位运算,机器喜欢,人看不懂。Eurydice 的目标是人——它刻意保留原 Rust 程序的结构。文章里举了个例子:求最小公倍数的两个小函数,Eurydice 生成的 C 基本是逐行对应的,连求值顺序都原样保留,必要时加个临时变量 uu____0 来保证"先算乘法、再调函数"这个顺序,因为 Rust 保证溢出 panic 发生在副作用之前,C 可不保证。这就是它的设计哲学:结构忠实优先,性能优化靠边站。
小听: 说到 uu____0,评论区第一条吐槽就是它:号称 readable C,结果返回值变量叫 uu____0,这可读性是不是有点行为艺术?
高砚: 哈哈,这条评论(nlehuen 发的)确实扎心。但"可读"是个相对概念:跟 rustc 吐出来的东西比,这已经是散文了;跟手写 C 比,那当然是机器味。uu____0 这类名字是编译器求值顺序语义的代价——它必须凭空造变量来钉住顺序。要不要加个启发式把它重命名成 return_value?理论上可以,但那是锦上添花。Eurydice 真正的卖点不是变量名好不好听,而是"你看到的 C 和你写的 Rust 是同一副骨架",审计的人能逐行对上,这才是高保障场景要的东西。

翻译的最高境界,是让原文的骨架在新语言里站直。
小听: 骨架好搭,但 Rust 和 C 的语义鸿沟不小。哪些地方最难翻?
高砚: 三块硬骨头。第一是泛型:C 根本没有泛型概念,只能做单态化(monomorphization)——一个泛型函数按每个具体类型各生成一份实现,结果就是好几个"长得一样、类型不同"的函数副本。地道的 C 程序员会用宏或者 void* 来写,但 Eurydice 选择忠实原样,哪怕啰嗦。第二是迭代器:Rust 里 for x in iter 这种写法,得翻成 while 循环再加一堆 Eurydice 自带的支持代码去管理迭代器状态。第三最有意思,是动态大小类型(DST):Rust 允许结构体里有个编译期不知道大小的字段,C 里最接近的是柔性数组成员。Eurydice 的做法是生成两种表示——一种用柔性数组,一种用定长数组,两种表示之间转换在运行时是零开销的,但技术上违反了 C 的严格别名规则,所以作者建议用 -fno-strict-aliasing 编译生成代码。
小听: 违反严格别名规则,听着就很"为了忠实不择手段"。为什么不能简单粗暴一点,比如统一加边界检查、统一用一种表示?
高砚: 这正是形式化验证视角和普通编译器视角的分歧。对验证器来说,多加一个边界检查,就等于多造一条"原 Rust 代码里不存在"的错误路径;少加一个,验证器又会报"缺失的检查"。每一条路径都必须和源码语义精确对应,否则验证结论就不可信。所以 Eurydice 宁可生成两种类型表示、宁可要求关掉严格别名优化,也要让 C 代码的语义和 Rust 源码严丝合缝。这种"洁癖"在普通项目里是过度设计,在验证场景里是刚需。
小听: 实现上它是怎么搭起来的?自己写 Rust 前端吗?
高砚: 聪明的地方就在这:它没自己写解析器和类型检查器,而是站在 rustc 肩膀上。Aeneas 项目里有个叫 Charon 的工具,负责从 rustc 里把解析和预处理好的程序抽出来,dump 成 MIR(中层中间表示)的 JSON,连编译 flag 一起。Eurydice 读这份 JSON,转成 KaRaMeL 的中间表示,再跑一系列小 pass 消掉 Rust 特有的东西,最后复用 KaRaMeL 的代码生成逻辑吐出 C。KaRaMeL 本来是给 F* 语言做同样事的——F* 是个依赖类型的函数式语言,专门写可证明正确的密码库,编译成 C 是为了上性能。所以 Eurydice 是"老配方、新原料"。
小听: 但文章也泼了冷水:Charon 经常被 const generics 这类较新的 Rust 特性卡住,目前只适合小而自包含的程序。
高砚: 对,这是它现在的天花板。Eurydice 的定位很诚实:小而自包含的代码它处理得很好,再大就吃力了。反直觉的是,"小代码本来手写重写也容易",那它的价值在哪?答案在"同步":如果那份 Rust 代码会持续更新,你希望 C 版本自动跟着变,而不是每次手动重翻一次。Eurydice 卖的不是一次性翻译,是"Rust 改了,C 自动跟上"的流水线。当然,前提是你的代码得先过 Charon 这一关。

信任链上最贵的一环,永远是"凭什么信你"。
小听: 聊聊评论区。最高赞的梗是 fnord77 那句"the circle is complete"——圆满了,轮回完成了。这是在玩什么典故?
高砚: 玩的是 C++ 的黑历史:C++ 最早就是个叫 Cfront 的预处理器,先把 C++ 翻译成 C 再编译。现在 Rust 又要翻回 C,可不就是转了一圈回来了。底下 kevincox 还接了个好问题:如果来回编译,会收敛到一个不动点,还是越变越大?没人真去试,但这个问题本身点出了"翻译"的本质:每次转译都会留下上一层的指纹,无损往返几乎不存在。
小听: 还有一条很硬核的争论:stabbles 说,如果 C 输出真的可读,那就可以拿来做 Rust 编译器的全源码自举(bootstrap);马上有人反问,自举为什么需要可读?
高砚: 这条吵的是信任模型,很经典。如果你只是想"在只有 C 编译器的新平台上把 rustc 跑起来",可读性无所谓,能编译就行。但如果你担心 Thompson 式的"编译器木马"——即编译器在编译编译器时植入后门——那你必须能审计每一行源码,可读就是刚需。Eurydice 的可读 C 理论上能成为"可审计的信任根"。当然,这离实用还远,但方向是对的:信任链上最贵的一环永远是"凭什么信你",而可读性就是审计的前提。
小听: 还有两条技术向的:一是有人问 D 语言和 C++ 是不是也需要这种东西;二是 Neywiny 担心,转译之后 Rust 的运行时安全(比如边界检查)还在不在?
高砚: 先说第二个,评论区 Lvl999Noob 回答得到位:Rust 加的运行时检查在 MIR 阶段就已经存在了,Eurydice 读的是 MIR,所以这些检查会原样进到 C 输出里——安全语义是带着走的,不是编译时才有。第一个问题更有意思:C++ 当年起家就是 C 预处理器,现在所有主流 C 编译器都用 C++ 写,反向工具确实有人想要——比如给装不上新版 C++ 编译器的老平台(OpenWatcom 之类),或者就为了"有个能看的中间结果"方便调试。D 语言后端已经够多了,需求没那么强。说到底,"高级语言→可读 C"这个需求,哪里有"只有 C 工具链"的环境,哪里就有市场。
小听: 最后拉回中文开发者视角。这事跟我们有什么关系?
高砚: 三层。第一,嵌入式和信创场景:很多国产化、工控、汽车电子环境,工具链就是只有 C,Rust 想进去,Eurydice 这类桥是刚需。第二,合规与验证:如果你的 Rust 代码要过某些安全认证,验证工具只认 C 的话,这就是现成的转接头。第三是个信号:Rust 生态正在进入"折叠、揉搓、改造以适配更多环境"的阶段——mrustc、gccrs、Cranelift、rust_codegen_gcc,再加上 Eurydice,说明这门语言已经从"能不能用"走到了"怎么塞进各种犄角旮旯"。对中文开发者来说,Rust 的机会正在从"写新项目"扩散到"进存量系统",而存量系统里最多的语言,恰恰是 C。
一篇讲编译器的文章,最后聊的是信任、审计和生态成熟度,这大概就是 HN 讨论的魅力:技术细节是入口,真正的议题永远在下一层。本文观点仅代表访谈角色设定,原始讨论见参考链接。