不是我,是编译器

Lobsters Hottest 新闻

摘要

一位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

相似文章

查询循环:编译器谋杀之谜

Lobsters Hottest

一位 Ferrocene/Rust 编译器工程师详细描述了一场为期一周的调试历程,该崩溃由查询循环引起,最终揭示了三个相互作用的错误,导致了OOM和无限循环。

Bun 的问题可能在于公开开发

Lobsters Hottest

一篇分析 Bun 实验性使用 LLM 将其 Zig 代码库转译到 Rust 所引发的争议的文章,强调公众的强烈反应源于透明的开发实践而非实验本身。