不是我,是编译器
摘要
一位Rust程序员发现了一个编译器错误,其中将'bool as u32'进行类型转换会产生不正确的结果,导致解析器错误。该错误已报告并链接到GitHub issue #158206。
<p><a href="https://lobste.rs/s/v7ghbn/it_s_not_me_it_s_compiler">评论</a></p>
查看缓存全文
缓存时间: 2026/07/01 03:56
# Parsa 的博客 | bool 作为 u32
来源:https://parsa.wtf/cast/
## 不是我的错,是编译器的错!
每个程序员至少有一次会对自己说:“不是我的错,是编译器的错!” 通常我们是错的,但这次的故事里,**我实际上是对的** (https://github.com/rust-lang/rust/issues/158206)。
某个周六的晚上,像往常一样,我在重构我的 JavaScript 引擎的词法分析器。之前我写了这样的代码:
```rust
impl LexerConsumer {
#[inline]
pub fn consume(&mut self) {
self.0 += 1;
}
#[inline]
pub fn consume_test(&mut self, store: &LexStore, expected: TokenKind) -> bool {
if self.peek(store) == expected {
self.consume();
true
} else {
false
}
}
}
```
这在解析器中相当常见,但我不太喜欢这种代码结构——每个分支都在返回比较的结果,为什么不直接自己返回值呢?于是我开始动手,写了这个版本,并且很满意单独看生成的汇编:它少了几个字节,也没有分支。
```rust
#[inline]
pub fn consume_test(&mut self, store: &LexStore, expected: TokenKind) -> bool {
let x = self.peek(store) == expected;
self.0 += x as u32;
x
}
```
生成的汇编(旧版本):
```
mov eax,DWORD PTR [rdi]
mov ecx,eax
and ecx,0x3f
movzx ecx,BYTE PTR [rsi+rcx*1]
cmp cl,dl
jne 233263
inc eax
mov DWORD PTR [rdi],eax
cmp cl,dl
sete al
ret
```
新版本:
```
mov ecx,DWORD PTR [rdi]
mov r8d,ecx
and r8d,0x3f
xor eax,eax
cmp BYTE PTR [rsi+r8*1],dl
sete al
add ecx,eax
mov DWORD PTR [rdi],ecx
ret
```
于是我继续尝试解析一个简单的语句,bash 历史记录顶部正好是一条解析 for 循环的命令。
```
> joe parse - 'for (var lol; false; false) {}'
Error was found
Diagnostic { kind: E079, flag: Flag(11529215046068469773), byte: 5, current_token: Var }
Parse error
```
等等!发生了什么?!难道是我把函数搞砸了?也许 `x as u32` 的语义和我记忆中的不一样……让我写得更明确一些。
```rust
#[inline]
pub fn consume_test(&mut self, store: &LexStore, expected: TokenKind) -> bool {
let x = self.peek(store) == expected;
self.0 += if x { 1 } else { 0 };
x
}
```
然后这个版本竟然可以工作!
```
> joe parse - 'for (var lol; false; false) {}'
Parsed 8 nodes in 14ns
Raw nodes:
[0] POS=0 ScriptStart payload=0
[1] POS=9 VariableDeclaration payload=0
[2] POS=14 FalseLit payload=0
[3] POS=21 FalseLit payload=0
[4] POS=28 BlockStatementStart payload=0
[5] POS=29 BlockStatement payload=0
[6] POS=0 ForStatement payload=0
[7] POS=0 Script payload=0
POS=000 [7] Script
POS=000 [6] ForStatement
POS=009 [1] VariableDeclaration @A0
POS=014 [2] FalseLit
POS=021 [3] FalseLit
POS=029 [5] BlockStatement
```
有那么一瞬间我怀疑了自己的理智,但我非常确定我知道 `bool as u32` 是做什么的——这个转换我写过几百次了。那一刻,我说出了每个绝望又有点困惑的程序员都会说的话:
> 不是我的错,是编译器的错!
我必须查看我的 for 语句解析器到底在做什么。幸运的是,使用我的自建构建系统,查看任何函数的汇编只需要一条命令。注意,这不是一个 `cargo asm` 的包装器,我的项目不使用 `cargo`,因为我自己用 TypeScript 写了一个构建系统,运行在 Deno 上,它甚至支持 `--dry` 模式,所以你可以看到它底层执行了什么,哇,透明性真好!
```
> x -b --fn ForOrInOfStatement 1 --dry
MKDIR ./out
RUN rustc src/main.rs --crate-name=joe --crate-type=staticlib --edition=2024 --out-dir=./out --target=x86_64-unknown-linux-gnu --cfg joe_no_libc --emit=link,obj -Crelocation-model=static -Copt-level=3 -Clto -Ccodegen-units=1 -Cdebuginfo=line-tables-only --diagnostic-width=150 -Cpanic=abort -Ztemps-dir=out/tmp -Zhuman-readable-cgu-names --extern proc=./out/libproc.so
RUN mold -melf_x86_64 -o ./out/joe ./out/joe.o --static --package-metadata="Joe!" -zrelro -znoexecstack --discard-locals --build-id --gc-sections --no-undefined --icf=safe --compress-debug-sections=zlib
RUN bash -c nm out/joe | rustfilt | grep ForOrInOfStatement | grep -oP '^[0-9a-f]+ t\s+\K.+' | sed -n '1p' | xargs -I {} objdump -WK -M intel -d --disassembler-color=on --visualize-jumps=color --demangle=rust ./out/joe --disassemble={} 2>/dev/null | grep -v 'Disassembly of section' | grep -v './out/joe:' | less -R
# 为可读性添加了换行,不客气。
```
总之……回到检查汇编,但也许你应该先看看 Rust 代码,这里是一个精简版,实际函数有几百行长 :D
```rust
fn ForOrInOfStatement(
store: &mut Storage,
mut lex: LexerConsumer,
mut emit: EmitBuffer,
mut stack: StateStack,
) -> Termination {
debug_assert_eq!(lex.peek(store), TokenKind::For);
lex.consume();
if lex.peek_test(store, TokenKind::Await) {
if !stack.has_flag(Flag::AWAIT) {
return raise_diagnostic(store, lex, emit, stack, DiagnosticKind::E056);
}
if !lex.consume_test(store, TokenKind::LParen) {
return raise_diagnostic(store, lex, emit, stack, DiagnosticKind::E057);
}
wip!()
}
if !lex.consume_test(store, TokenKind::LParen) {
return raise_diagnostic(store, lex, emit, stack, DiagnosticKind::E058);
}
match lex.peek(store) {
TokenKind::Var => { }
_ => {
stack.use_flags(store, FlagDiff::clear(Flag::IN));
stack.push(store, State::ForOrInOfStatement_decide);
LeftHandSideExpression(store, lex, emit, stack)
},
}
}
fn PrimaryExpression(
store: &mut Storage,
mut lex: LexerConsumer,
mut emit: EmitBuffer,
mut stack: StateStack,
) -> Termination {
match lex.peek(store) {
_ => raise_diagnostic(store, lex, emit, stack, DiagnosticKind::E079),
}
}
```
现在揭开真相:
```
> x -b --fn ForOrInOfStatement 1
000000000021e290 <_ZN3joe19ForOrInOfStatement17h4c1c6c3b2f2b2b2bE>:
21e290: ff c6 inc esi
21e292: 89 f0 mov eax,esi
21e294: 83 e0 3f and eax,0x3f
21e297: 0f b6 04 07 movzx eax,BYTE PTR [rdi+rax*1]
21e29b: 83 f8 04 cmp eax,0x4 # cmp TokenKind::LParen
21e29e: ,----- 75 70 jne 21e310
21e2a0: | 4d 85 c0 test r8,r8
21e2a3: | ,-- 78 14 js 21e2b9
21e2a5: | | 41 0f b6 c0 movzx eax,r8b
21e2a9: | | c6 84 07 10 56 00 mov BYTE PTR [rdi+rax*1+0x5610],0x20
21e2b0: | | 00 20
21e2b1: | | 49 ff c0 inc r8
21e2b4: | | e9 d7 45 00 00 jmp 222890
21e2b9: | '-> 48 b8 00 ff ff movabs rax,0x7fffffffffffff00
21e2c0: | ff ff ff 7f
21e2c3: | 4c 21 c0 and rax,r8
21e2c6: | 45 0f b6 c8 movzx r9d,r8b
21e2ca: | 42 c6 84 0f 10 56 mov BYTE PTR [rdi+r9*1+0x5610],0x0
21e2d1: | 00 00
21e2d3: | 45 89 c9 mov r9d,r9d
21e2d6: | 4d 89 c2 mov r10,r8
21e2d9: | 49 c1 ea 10 shr r10,0x10
21e2dd: | 49 bb 00 00 00 00 movabs r11,0xffff00000000
21e2e4: | ff ff 00 00
21e2e7: | 4d 21 d3 and r11,r10
21e2ea: | 4e 89 9c cf d0 56 mov QWORD PTR [rdi+r9*8+0x56d0],r11
21e2f1: | 00 00
21e2f2: | 41 ff c0 inc r8d
21e2f5: | 45 0f b6 c0 movzx r8d,r8b
21e2f9: | 49 09 c0 or r8,rax
21e2fc: | 41 0f b6 c0 movzx eax,r8b
21e300: | c6 84 07 10 56 00 mov BYTE PTR [rdi+rax*1+0x5610],0x20
21e307: | 00 20
21e308: | 49 ff c0 inc r8
21e30b: | e9 80 45 00 00 jmp 222890
21e310: '----> 48 89 ca mov rdx,rcx
21e313: 83 f8 6e cmp eax,0x6e # cmp TokenKind::Await
21e316: ,-- 75 15 jne 21e32d
21e318: | 4c 89 c1 mov rcx,r8
21e31b: | 49 0f ba e0 3d bt r8,0x3d
21e320: ,--|-- 72 19 jb 21e33b
21e322: | | 41 b8 37 00 00 mov r8d,0x37
21e328: | | e9 b3 ae 00 00 jmp 2291e0
21e32d: | '-> 4c 89 c1 mov rcx,r8
21e330: | 41 b8 39 00 00 00 mov r8d,0x39
21e336: | e9 a5 ae 00 00 jmp 2291e0
21e33b: '----> 41 b8 38 00 00 00 mov r8d,0x38
21e341: e9 9a ae 00 00 jmp 2291e0
```
哦!就这些?我的 `match` 语句去哪了?!我已经知道在改动之前我的 `ForOrInOfStatement` 是什么样子——那是一个怪物,有几个寄存器到栈的溢出,我之前那一周正试图消除它们。但仔细看汇编,我们可以看到对 `TokenKind::LParen` 的比较,这几乎肯定来自我们的 `consume_test(store, TokenKind::LParen)`。不过首先,关于解析器的 ABI 有一点需要注意,特别是 SysV ABI,目前这是 rust 的默认行为。
```rust
pub struct LexerConsumer(u32 , u32 );
pub struct EmitBuffer(usize);
pub struct StateStack(u64);
fn Foo(
store: &mut Storage,
mut lex: LexerConsumer,
mut emit: EmitBuffer,
mut stack: StateStack,
) -> Termination {...}
```
所以汇编现在读起来就很直截了当了:
```
# lex.consume() // `for`
21e290: ff c6 inc esi
# 词法分析器的环形缓冲索引计算:
21e292: 89 f0 mov eax,esi
21e294: 83 e0 3f and eax,0x3f
# 加载当前位置的 token
21e297: 0f b6 04 07 movzx eax,BYTE PTR [rdi+rax*1]
# 与 TokenKind::LParen 比较
21e29b: 83 f8 04 cmp eax,0x4
# 如果不是 LParen 则跳转到 21e310
21e29e: 75 70 jne 21e310
# 如果执行到这里,token 是 LParen,我们在 `consume_test` 内部。
# 但是…… consume_test 不是应该有 `self.0 += x as u32` 吗?
# 等等……谁吃了我的 `INC ESI`?!
```
所以这基本上证实了编译器有错误。剩下的就是写一个最小可复现示例。由于我在 `1.94.0-nightly` 上,我怀疑也许夜间版已经修复了,但检查后发现并没有!这意味着我必须尝试找出罪魁祸首。我的第一次尝试是查看优化前的 LLVM IR,所以我添加了 `-Cno-prepopulate-passes` 到构建标志,然后看到从未初始化内存加载。为了判断是 rust/MIR 的问题还是 LLVM 的问题,我还使用了 `-Zmir-opt-level=0`,然后问题就修复了。
我提交了一个 issue,一开始有点紧张,“这看起来是这么常见的模式,万一我只是犯傻然后在公众面前丢脸怎么办?”所以 issue 的标题写着“**疑似**误编译”,这个词是我用来掩饰的,以防我真的是一只傻猫(喵)。等我提交 issue 时已经很晚了,我得去睡觉。
第二天醒来,已经有一个修复的 PR 打开 (https://github.com/rust-lang/rust/pull/158214),来自 Hanna Kruppe (https://github.com/hanna-kruppe),这是最快的转机。这个 issue 被标记为 `p-critical` 和 `i-miscompile`。在超过 61K 个 rust issue 中,只有 7 个(包括这个)同时是 `p-critical` 和 `i-miscompile`。对我来说,这是编译器可能拥有的最危险的 bug,因为它们违反了程序员与语言之间的契约。它们表明,并非你写的每一段安全代码都是安全的。另外,总体上只有 247 个 `p-critical` 的 issue。
最棒的部分是看到社区如此迅速地响应,并在 18 小时内实际落地了一个修复。这是这个故事中最令人印象深刻的部分,感谢 tmiasko (https://github.com/tmiasko) 和 hanna-kruppe (https://github.com/hanna-kruppe) 的快速响应。
现在,我可以活着讲述我与 `rustc` 交战并获胜的故事了 :D
相似文章
查询循环:编译器谋杀之谜
一位 Ferrocene/Rust 编译器工程师详细描述了一场为期一周的调试历程,该崩溃由查询循环引起,最终揭示了三个相互作用的错误,导致了OOM和无限循环。
实现FMA并发现C和Rust标准库中的错误
作者为Rust的fearless_simd库实现了一个向量化FMA,从而提高了性能,并发现了Rust和musl libc标准库中的错误。
Bun Rust 重写:"代码库未通过基本 Miri 检查,在安全 Rust 中允许未定义行为"
Bun 的 Rust 重写未通过基本 Miri 检查,在安全 Rust 中导致未定义行为,引发了严重的安全担忧。
Bun 的问题可能在于公开开发
一篇分析 Bun 实验性使用 LLM 将其 Zig 代码库转译到 Rust 所引发的争议的文章,强调公众的强烈反应源于透明的开发实践而非实验本身。
Rust与C/C++在内存安全CVE上的差异
分析Rust与C/C++在内存安全CVE报告方式上的不同,论证即使存在错误,Rust的设计也能降低某些类型漏洞的发生。