今週公開したv0マングリングの実測記事で、私は検証環境の欄にrustc 1.97.1と書いています。
ただ、なぜ末尾が.1なのか、そのときは気にしていませんでした。
調べると、1.97.1は「安全なRustがNoneを渡しただけでsegfaultする」ミスコンパイルを塞ぐ緊急リリースでした。
自分の記事の検証環境に、意味も知らずに緊急パッチの版番号を書いていたわけです。
1.97.0を使っている人は、この記事の40行で自分の環境の再現を確かめられます。
更新済みの人には、何が直ったのかを機械語のdiffで示します。
この件を見ても、私は「安全なRustはsegfaultしない」という信頼を下方修正しませんでした。
むしろ毎週rustup updateする理由が1つ増えました。
反対する人がいる主張だと思うので、根拠を実測で積みます。
何が起きたか
公式アナウンスの要点は3つです。
LLVMの最適化にミスコンパイルがあり、少なくともRust 1.87から存在していた。
1.97.0がenum判別値の内部表現を変えた結果、それがsegfaultとして顕在化した(rust-lang/rust#159035)。
1.97.1はLLVM修正のバックポートと判別値変更の取り消し、2段構えで塞いだ。
LLVM側の報告はllvm/llvm-project#208611で、x86バックエンドのロード融合の問題とされています。
ただ「判別値の表現を変えたら10リリース前からのバグが顕在化する」という因果は、文章だけでは腑に落ちませんでした。
再現コードはissueに付いています。
3バージョンのコンパイラに同じコードを与えて機械語を並べれば、因果を目で確認できるはずです。
検証環境
- WSL2(kernel 6.6.87.2)/ Ubuntu 24.04.4 / Intel Core i7-14700F
- rustc 1.96.0(ac68faa20)/ 1.97.0(2d8144b78)/ 1.97.1(8bab26f4f)
- GNU binutils 2.42(objdump)/ gcc(実体はclang 18.1.6。SIGSEGVの観測に使用)
同じコードを3つのコンパイラに与える
issue #159035の最小再現に、読むためのコメントを足したものです。
use std::hint::black_box; // 引数を最適化で定数化させないために使う
// 内側のenum。どちらの変種もu32を1つ持つ
enum Inner {
A(u32), // 取り出したい値その1
B(u32), // 取り出したい値その2
}
// 8バイト境界の詰め物の後ろにInnerを置く構造体
struct Big {
_pad: u64, // 先頭8バイトを占有する
inner: Inner, // 値はこの中にある
}
// 関数ポインタを含む小さい方の構造体
struct Small {
a: u16, // 合成する下位16 bit
b: u16, // 合成する上位16 bit
_f: fn(), // 関数ポインタ(ヌルにならない型)
}
// サイズの異なる2つの構造体を持つenum
enum Checksum {
X(Big), // 大きい方の変種
Y(Small), // 小さい方の変種
}
impl Checksum {
// どちらの変種からもu32を1つ取り出す
fn finalize(self) -> u32 {
match self {
// Xなら内側のenumをさらに開いて値を返す
Checksum::X(h) => match h.inner {
Inner::A(s) => s, // Aの中身をそのまま返す
Inner::B(s) => s, // Bの中身をそのまま返す
},
// Yなら2つのu16を1つのu32に合成する
Checksum::Y(s) => (u32::from(s.b) << 16) | u32::from(s.a),
}
}
}
// インライン化を止めて、この関数単体の機械語を観察できるようにする
#[inline(never)]
fn run(c: Option<Checksum>) -> Option<u32> {
c.map(|c| c.finalize()) // Someならfinalize、NoneならそのままNone
}
fn main() {
// Noneを渡す。期待する出力は "None" 一行だけ
println!("{:?}", run(black_box(None)));
}
Noneを渡すのでfinalizeは一度も呼ばれず、期待する出力は「None」の1行だけです。
最適化を有効にしてコンパイルし、各バージョンで3回ずつ実行しました。
rustup run 1.97.0 rustc -O minimal.rs -o minimal-1.97.0
./minimal-1.97.0
1.96.0: Noneを出力して終了コード0(3回とも)
1.97.0: 何も出力せずsegfault、終了コード139(3回とも)
1.97.1: Noneを出力して終了コード0(3回とも)
unsafeもFFIも無い40行が、コンパイラのバージョンだけで落ちたり落ちなかったりします。
再現は決定的で、確率的な挙動ではありませんでした。
分かっていて実行しても、exit 139が3回並ぶと軽くひやりとします。
本番でこれを踏んだ人は、コンパイラを疑う前に自分のコードを何時間も疑ったはずです。
7命令を読む
objdump -d --demangleでrun()を取り出します。
最初のgrepは何も返しませんでした。
シンボルを_ZN前提で絞り込んでいたからで、1.97系のマングリングはv0、つまり_R開頭です。
今週それを記事に書いた本人が、2日後に同じ変更で手を止めました。
1.97.0のrun()は、アドレス列を省くとこの7命令です(注釈は私が付けたものです)。
<minimal::run>:
mov (%rdi),%rcx ; 判別値i64を読む。Noneは-1
xor %eax,%eax
cmp $0xffffffffffffffff,%rcx
setne %al ; al = Someかどうか
mov %ecx,%ecx ; rcxの上位32bitを0にする
mov 0xc(%rdi,%rcx,4),%edx ; rdi + 4×rcx + 12を読む
ret
この7命令には分岐がありません。
SomeかNoneかの判定(setne)はしているのに、最後の読み出しはその結果を待たず、どちらの場合も実行されます。
ただ、ここまでなら投機的なロードとしてよくある形です。
判別値が0(Some(X))か1(Some(Y))なら、読み先rdi + 4×判別値 + 12はオフセット12か16、どちらも24バイトのOptionの内側に収まります。
問題はNoneです。
Noneの判別値は-1。
仮に64bitのまま計算すればrdi + 4×(-1) + 12 = rdi + 8で、これもまだOptionの内側でした。
そうならないのは、直前のmov %ecx,%ecxが-1を32bitに切り詰めて、0xffffffffという正の数に変えるからです。
4×0xffffffff + 12 = 17,179,869,192。
最初にこの数字を出したとき、桁を数え直しました。
Noneを渡したときにこの関数が読むのは、スタック上の24バイトの中ではなく、その16GiBと8バイト先です。
実行時にも確かめます。
この環境にはgdbもstraceも無いので(sudoも使えません)、SIGSEGVを捕まえてフォルト時のレジスタを印字する共有ライブラリを書きました。
// SIGSEGVの瞬間のフォルトアドレスとレジスタを印字するLD_PRELOAD用ライブラリ
#define _GNU_SOURCE
#include <signal.h>
#include <stdio.h>
#include <ucontext.h>
#include <unistd.h>
// segfaultしたらアドレスと関係レジスタをstderrに出して終了する
static void handler(int sig, siginfo_t *info, void *ctx) {
(void)sig;
ucontext_t *uc = ctx; // フォルト時のレジスタはここから読める
unsigned long long rip = uc->uc_mcontext.gregs[REG_RIP]; // 落ちた命令のアドレス
unsigned long long rdi = uc->uc_mcontext.gregs[REG_RDI]; // run()の第1引数(Optionへのポインタ)
unsigned long long rcx = uc->uc_mcontext.gregs[REG_RCX]; // 読み出しに使われたインデックス
char buf[256];
int n = snprintf(buf, sizeof buf,
"SIGSEGV: si_code=%d addr=%p rip=0x%llx rdi=0x%llx rcx=0x%llx rdi+0x400000008=0x%llx\n",
info->si_code, info->si_addr, rip, rdi, rcx,
rdi + 0x400000008ULL); // asmから計算した読み出し先アドレス
write(2, buf, n); // ハンドラ内なのでwrite(2)を直接使う
_exit(139); // segfaultと同じ終了コードで抜ける
}
// プログラム開始前にハンドラを登録する
__attribute__((constructor))
static void setup(void) {
struct sigaction sa = {0};
sa.sa_sigaction = handler; // siginfo付きハンドラを指定
sa.sa_flags = SA_SIGINFO; // si_addrとucontextを受け取る
sigaction(SIGSEGV, &sa, 0);
}
gcc -shared -fPIC -O2 -o segvaddr.so segvaddr.c
LD_PRELOAD=./segvaddr.so ./minimal-1.97.0
SIGSEGV: si_code=128 addr=(nil) rip=0x572854a5efae rdi=0x7ffd53242180 rcx=0xffffffff rdi+0x400000008=0x800153242188
rcxは0xffffffff。
切り詰められた-1が、落ちた瞬間のレジスタに証拠としてそのまま残っていました。
ripの下位12bitも0xfae、逆アセンブル上の最後のmovと一致。
ここまでは筋書き通りです。
止まったのはフォルトアドレスの方でした。
addr=(nil)。
この40行のどこを読んでも、NULLを参照する場所はありません。
30秒ほど自分のasmの読みを疑いましたが、悪いのは読みではなく、アドレスの大きさでした。
rdiはスタック上の0x7ffd…で、そこに16GiBを足すと0x8001…になります。
x86-64(4レベルページング)のユーザー空間アドレスは0x7fff_ffff_ffffまで。
この読み出しはページフォルトですらなく、非正準アドレスへのアクセスとして一般保護例外(#GP)を起こします。
このときカーネルが届けるSIGSEGVにはsi_addrが入らず、si_codeは128(SI_KERNEL)になります。
addr=(nil)はNULL参照の印ではなく、フォルトアドレスを特定できない落ち方をしたという印でした。
壊れていたのはIRではなかった
rustc -O --emit=llvm-irで、LLVMの最適化を通り終えたIRを3バージョン分出力しました。
1.97.0のrun()の要点です(getelementptrを畳むなど、表記は読みやすさのために整えています)。
%disc = load i64, ptr %c, align 8, !range !9 ; !9 = !{i64 -1, i64 2}。値域は {-1, 0, 1}
%some = icmp ne i64 %disc, -1 ; Someかどうか
%x_val = load i32, ptr %c+12 ; Xの値(inbounds)
%y_val = load i32, ptr %c+16 ; Yのa,b(inbounds)
%bit0 = trunc nuw i64 %disc to i1 ; 判別値の最下位ビット
%val = select i1 %bit0, i32 %y_val, i32 %x_val ; 値を選ぶ
正直に言えば、壊れた変換はIRの中に見つかると予想していました。
違いました。
IRのどこにも16GiBはありません。
読んでいるのは+12と+16の2箇所だけ、どちらもinbounds付きで、引数にはdereferenceable(24)が付いています。
2つとも読んでからselectで選ぶ、投機的ではあっても域外に出ない形です。
では3バージョンのIRはどう違うのか。
diffを取ると、実質の差は2箇所しかありませんでした。
| バージョン | Noneの判別値 | 判別値の値域(!range) |
|---|---|---|
| 1.96.0 | 2 | {0, 1, 2} |
| 1.97.0 | -1 | {-1, 0, 1} |
| 1.97.1 | 2 | {0, 1, 2} |
IRは正しく、IRはほぼ同じで、機械語だけが壊れている。
「2つの境界内ロード + select」を「判別値をインデックスに使う1つのロード」に融合したのは、IRの最適化ではなくx86バックエンドだと絞り込めます。
llvm/llvm-project#208611のタイトルもそれを指しています(Incorrect load combining causing OOB reads)。
LLVM内部のどの変換がこの融合を実装しているかまでは、この記事では追いません。
上流のissueが追跡中で、手元で観測できる因果の確認までがこの記事の目的だからです。
この融合は、今回の形では判別値が{0, 1, 2}の間は破綻しません。
Noneの2でも読み先はオフセット20、24バイトの内側だからです(読んだ値は捨てられます)。
1.97.0が判別値を-1にした瞬間、同じ融合の読み先が16GiB先に変わりました。
バグは10リリースの間、この形では観測できる壊れ方をしなかっただけで、生成される機械語には残り続けていたことになります。
1.97.1の修正は2命令で見える
1.97.1のrun()です。
<minimal::run>:
mov (%rdi),%rcx
xor %eax,%eax
cmp $0x2,%rcx ; Noneの判別値が2に戻った(rustc側の取り消し)
setne %al
and $0x1,%ecx ; インデックスを0か1に制限(LLVM側の修正)
mov 0xc(%rdi,%rcx,4),%edx
ret
投機的なロード自体は残っています。
変わったのはand $0x1の3バイトで、読み先が12か16に閉じ込められました。
Noneのときはインデックス0でオフセット12を読み、読んだ値は捨てられます。
リリースノートの言う2段構えの修正が、cmpの定数とandの1命令、機械語のこの2行にそのまま対応していました。
この対応が取れた瞬間が、今回の調査でいちばん気持ちよかった部分です。
なお1.96.0の機械語も融合自体は同じ形で、andも切り詰めも無しに判別値をそのまま使います。
一番損なのは1.97.0に留まり続けることです。
このパターンを踏めば本番でsegfaultし、踏まなくても1.97.0に留まる利点はありません。
上げる作業はrustup update 1回です。
CIでツールチェーンを固定している場合は、1.97.0を飛ばして1.97.1を指定してください。
1.87〜1.96で止めている環境は今回の形では実害がありませんが、同じ投機ロードの融合は残っています。
知ったまま使い続けるのは気持ちが悪いので、私なら上げます。
所感
コンパイラのバグとしては嫌な部類です。
unsafeの無い40行が、コンパイラのバージョンだけで落ちたり落ちなかったりする。
それでも冒頭に書いたとおり、私の信頼は下がりませんでした。
顕在化から修正の配布までは7日でした。
原因と再現コードはissueで公開され、修正の中身は今回のように誰でも手元の機械語で確かめられます。
潜伏中のミスコンパイルは、確率で考えれば今もどこかにあるはずです。
私が頼っているのは「バグが無いこと」ではなく、顕在化してからの修正速度の方ですね。

