Rust 到 C 编译器:95.9% 测试通过率,兼容特殊平台
Rust to C compiler - 95.9% 测试通过率,兼容特殊平台,以及 Rust Week 演讲
发布于 2025年4月11日 7 - 12 分钟阅读 这是关于我的 Rust to C 编译器进展的更新。我正在尝试一种新的文章格式:与其使用一个总括性的主题,不如将一些较小的片段缝合在一起。
大新闻
首先从最大的新闻开始:我将在 Rust Week(在荷兰乌得勒支)期间就该项目发表演讲。 准备这次演讲是一个有趣的挑战:我试图在对初学者友好和谈论一个非常高级的主题之间取得良好的平衡。 因此,如果您要参加 Rust Week,并且对我要说的内容感兴趣,您可以亲自来听!如果您在会议期间看到我并想交谈,请不要害羞,打个招呼。 现在,让我们言归正传...
通过更多测试
我还在慢慢地修复尽可能多的测试,我已经可以自豪地宣布核心测试通过率达到了 95.9%。与两个月前的 92% 通过率相比,这是一个不错的提升。 仍然有大约 65 个测试需要修复,但它们似乎都有非常相似的原因。因此,修复它们应该不会太困难。 该项目的 .NET 方面也从我实施的修复中受益匪浅:现在,96.3% 的 Rust 核心测试可以在 .NET 中运行。
Bug 修复
128 位整数
当前的大部分改进来自对 128 位内在函数、检查算术运算和子切片的修复。
C 的 popcount
内在函数有 3 种变体:__builtin_popcount
(int)、__builtin_popcountl
(long) 和 __builtin_popcountll
(long long)。
似乎很合理地认为 C 内在函数 __builtin_popcountll
可以处理 128 位整数 - 但事实并非如此。
它适用于 long long
类型,这与 __int128_t
不同。至少在 Linux 上,long
和 long long
的大小都是 64 位。这是我早就知道的事情,但我没有考虑到 2 个不同命名的内在函数最终会是同一个东西。
int pop_count64(long num) {
return __builtin_popcountl(num);
}
int pop_count128(__int128_t num) {
return __builtin_popcountll(num);
}
pop_count64:
xor eax, eax
popcnt rax, rdi
ret
pop_count128:
xor eax, eax
popcnt rax, rdi
ret
事实证明,我对大多数位计数内在函数(计算前导/尾随零)的实现一直在默默地将 128 位整数截断为 64 位整数,然后才执行所需的计算。这显然会产生不正确的结果。
但是,模拟这些 128 位内在函数并不太难。popcount
内在函数只是检查一个整数中设置了多少位。因此,我可以将该整数的低半部分和高半部分中设置的位数相加,并获得正确的结果。
static inline __uint128_t pop_count128(__uint128_t val) {
return __builtin_popcountl((uint64_t)val) + __builtin_popcountl((uint64_t)(val>>64));
}
我还终于完全实现了最后一次检查的算术运算。检查 128 位整数乘法期间的溢出非常困难。在很长一段时间内,我一直在尝试想出一些巧妙的想法来进行快速溢出检查。可悲的是,它们都没有最终用于 128 位乘法。
经过深思熟虑,我决定简单地采用简单但效率低下的检查。基本上,只要 (a * b) / b == a
且 b 不为零,就不会发生溢出。
bool u128_mul_ovf_check(__uint128_t A0 ,__uint128_t A1 ){
bb0:
if((A1) != (0)) goto bb1;
return false;
bb1:
return (((A0) * (A1)) / (A1)) == (A1);
}
这没有什么突破性的,但至少它可以工作,并且可以通过更多测试。
子切片
子切片错误非常令人尴尬:我忘记了 sizeof
,并且以字节而不是元素为单位偏移了切片的数据指针。不难看出为什么这是错误的。
鉴于这个错误是多么简单,您可能会想知道它如何能够如此长时间未被发现。好吧,该代码仅在从切片的末尾进行子切片时才被破坏,而不是从其开头进行子切片。据我所知,该子切片模式主要用于模式匹配。
let ok = slice[2..5];
let still_ok = slice[5..];
// broken
if let [start, reminder] = slice{
panic!();
};
因此,子切片仅在此特定模式下被破坏,并且始终适用于字节/字符串切片(字节和 UTF8 代码 单元 的大小均为 1 字节)。这使得它能够偷偷通过我自己的测试,并且仅在运行整个 Rust 编译器测试套件时才会显示出来。
回退内在函数
事实证明,我不必手动实现 某些 内在函数 - Rust 编译器已经支持模拟它们。对于某些内在函数,这是一个天赐之物 - 因为手动实现它们非常痛苦。 例如,carrying_mul_add 要求您对大于输入整数 2 倍的整数执行乘法。这对于 64 位来说是可以的,但是...哪个整数大于 128 位? LLVM 支持 256 位整数,但 C(和 .NET)不支持。
define void @carrying_mul_add(ptr dead_on_unwind noalias nocapture noundef writable writeonly sret([32 x i8]) align 16 dereferenceable(32) initializes((0, 32)) %_0, i128 noundef %a, i128 noundef %b, i128 noundef %c, i128 noundef %d) unnamed_addr #0 !dbg !7 {
%0 = zext i128 %a to i256, !dbg !25
%1 = zext i128 %b to i256, !dbg !25
%2 = zext i128 %c to i256, !dbg !25
%3 = zext i128 %d to i256, !dbg !25
%4 = mul nuw i256 %1, %0, !dbg !25
%5 = add nuw i256 %4, %2, !dbg !25
%6 = add nuw i256 %5, %3, !dbg !25
%7 = trunc i256 %6 to i128, !dbg !25
%8 = lshr i256 %6, 128, !dbg !25
%9 = trunc nuw i256 %8 to i128, !dbg !25
store i128 %7, ptr %_0, align 16, !dbg !25
%10 = getelementptr inbounds nuw i8, ptr %_0, i64 16, !dbg !25
store i128 %9, ptr %10, align 16, !dbg !25
ret void, !dbg !26
}
因此,仅仅 使用此内在函数的内置模拟版本的能力非常棒:这意味着我不需要到处乱搞并找到自己解决此问题的方法。
出于另一个原因,这也非常有趣:由于 carrying_mul_add
使用 128 位整数执行 256 位乘法和加法,这意味着它能够使用 64 位整数执行 128 位运算。
我目前正在研究更好地理解该回退实现,以便将我自己的 128 位整数模拟基于该实现。
虽然许多现代 C 编译器和平台都支持 128 位整数而没有太大的麻烦,但我希望支持尽可能多的平台。
支持更多 C 编译器。
除此之外,我一直在努力提高 C 编译器的兼容性。您可能已经看到 Rust 代码在 Game boy 上运行, 编译成 mov 指令, 或者在愚人节的特别节目中 Rust 在 Temple OS 上运行。 我支持的 C 编译器(在任何程度上)越不常见,Rust 代码就有越高的机会运行在我无法直接访问的专有 C 编译器上。 最近,这已经成为该项目的一个更大的问题。事实证明,由于一个很好的理由(缺乏文档 + 缺乏访问),许多平台不受支持。不支持这些平台对该项目来说有点阻碍。 举个例子:已经有关于用 Rust 编写 Git 的一些新部分 的讨论。 可悲的是,这样做意味着降低/放弃对专有平台 NonStop 的 Git 支持 - 因为它根本不支持 Rust(或 LLVM 甚至 GCC)。 最初,我对这种情况有点乐观:如果我的项目将 Rust 编译为 C,则可以完全消除此问题。 在 理论上,Rust 将能够在 C 可以运行的任何地方运行。对此有一些很大的星号(我仍然不确定我是否可以解决 所有平台上 的某些问题),但是嘿 - 这可能是支持 Rust 的最佳方式,除了公司介入并添加 LLVM 支持,我觉得这...不太可能。 最近,我想检查在这种情况 “通过将其编译为 C 来支持 Rust” 是否是一种可行的策略。 但是,我立刻碰壁了。我找不到任何合法的方式来获得该平台的编译器,而无需购买服务器,这绝对 远远超出我的预算。 所以,我不相信 Rust 会很快在这种平台上运行。
目前的计划
目前,计划是尽可能接近符合标准的 C99(甚至可能是 ANSI C),并且仅使用标准的 POSIX API(我需要一些线程支持来正确初始化线程本地变量)。 这意味着我有自己对某些内在函数的回退,并且我正在慢慢地但肯定地扩展该列表。我已经在 ANSI C 编译器上成功运行了 非常,非常简单 的 Rust 程序,所以肯定有一些希望。 祈祷一下,当需要时,这将意味着添加对当前不可行平台的支持足够容易。
微小的性能改进
我还致力于各种性能改进。最小的更改与整数文字有关。我意识到,对于小于 2^32 的整数,它们的十六进制形式总是大于等于十进制形式,因为它们带有 0x
前缀。例如,255 比 0xFF 短一个字节,65536 (0xFFFF) 也是如此。只有对于 2^32,事情才会开始变得平局。这似乎是一个可以忽略不计的改变。但是,我生成 大量 C 代码。在某些更极端的情况下(将整个 Rust 编译器转移到 C),我已经生成了高达 1GB 的 C 源文件。在这一点上,即使削减总文件大小的一小部分也会产生影响。
我嵌入调试信息的方式(使用 #line
指令)也变得更加智能 - 源文件名不会重复,并且仅在发生更改时才会包含。
所以这个:
#line 1 great.rs
L0 = A0 + A0;
#line 2 great.rs
L1 = L0 * 5.5;
#line 1 amazing.rs
L2 = L1 * L1 * L1;
#line 4 great.rs
L3 = L2 - A0
可以这样写:
#line 1 great.rs
L0 = A0 + A0;
#line 2
L1 = L0 * 5.5;
#line 1 amazing.rs
L2 = L1 * L1 * L1;
#line 4 great.rs
L3 = L2 - A0
这看起来像是一个微小的改变,但是它可以大大减少文件大小(当使用调试信息时)。
重构
rustc_codegen_clr
已经进行了一些重大的内部重构。我设法将其中的一些部分拆分为单独的 crates,这加快了增量构建。这使得开发更容易一些。
我还朝着更节省内存的实习 IR 迈进。在此过程中,我还慢慢地从旧的 IR 中删除了一些垃圾。
主要问题是存在一些相当奇异的 r/lvalue,它们与 C 的映射不太好。如果不深入了解 Rust 的一些更晦涩的功能(如动态大小类型),则很难展示它们。您可以安全地跳过此部分。
考虑这段 Rust 代码:
/// Custom DST.
struct MyStr{
sized:u8,
s:str
}
impl MyStr{
fn inner_str(&self)->&str{
&self.s
}
}
这行 &self.s
似乎 简单,但事实并非如此。由于 MyStr
是一种动态大小类型,因此指向它的指针是“胖”的 - 它包含元数据。
让我们考虑一下此函数将产生什么样的 C 代码。
FatPtr_str inner_str(FatPtr_MyStr self){
// What goes here?
}
在这里,我们需要做两件事:将我们的 self
胖指针的“数据”指针偏移 1
(固定大小字段的大小),从该数据指针和一些元数据创建一个新的切片。这在现代 C 中很容易做到。
struct FatPtr_str inner_str(struct FatPtr_MyStr self){
return (struct FatPtr_str){self.data + 1, self.meta};
}
但是,复合文字直到 C99 才成为语言的一部分,而且许多旧/晦涩的编译器都不支持它。 相反,我们需要这样做:
struct FatPtr_str inner_str(struct FatPtr_MyStr self){
struct FatPtr_str tmp;
tmp.data = self.data;
tmp.meta = self.meta;
return tmp;
}
这是一种符合 ANSI-C 标准的方式。但是 您可能会注意到,1 行 Rust(和 MIR)现在对应于多行 C。这在 IR 级别上很难管理。旧的 IR 有一种奇怪的方式来处理这个问题:它本质上允许您创建一个内部范围,其中包含一个临时局部变量和一些“子语句”。 这非常混乱,坦率地说,这是一种愚蠢的处理此问题的方式。好吧,至少我现在知道我不会再犯这个确切的错误了。新的做事方式在设置阶段有点复杂,但这使整个 IR 更加简单。 在其他情况下,此“临时范围”很有用,但是现在,仅剩下此类最令人讨厌的案例之一。一旦我解决了这个问题,我将能够完全摆脱这个令人憎恶的功能。 这将使我能够完全迁移到新的 IR,这将非常整洁。
结论
在过去的几个月中,我取得了一些进展。修复 bug 肯定有一些收益递减的结果:bug 越少,我需要花费更多的时间来跟踪它们。不过,每天都有关于 C 和 Rust 的新知识要学习。我已经从事 rustc_codegen_clr
已经 1.5 年了 - 感觉有点...奇怪。在那段时间里发生了很多事情:无论是在我的个人生活中,还是在更广阔的世界中。
说实话,有时候感觉就像是上辈子的事情了。
在这个奇怪的新世界中,工作的单调带来了一丝安慰 - 每一天,我都朝着一个更宏伟的目标前进。在此过程中我学到了很多东西,但是随着时间的流逝,我发现还有很多东西需要知道。这令人平静。
但是,我离题了 - 您来到这里是为了听取有关 Rust 和编译器的信息。
我有一些有趣的东西即将推出:我正在努力完成“Rust panics under the hood”的第 2 部分 - 对 Rust 崩溃过程的逐步说明。我正在考虑将本文分为两部分:它已经有 10 分钟长了,而我才刚刚完成解释 panic 消息是如何创建的。
除此之外,我一直在研究一些奇怪的事情,包括一个微小(2K LOC)但非常准确的 Rust 内存分析器。我的时间安排非常紧张,但我希望在未来几周内写一些关于这方面的东西。
如果您喜欢这个项目(rustc_codegen_clr
),并且认为其他人可能会对我的工作感兴趣,请随时在 Bluesky 和 Linkedin 上分享我的帖子。