HN 日本語サマリー

← 一覧へ戻る
プログラミング

Rust vs C++におけるポインタの来歴:ポインタは単なる強化された整数ではない

Pointer Provenance in Rust vs. C++: Pointers Are Not Just Glorified Ints (sofiabelen.github.io)

14 pointsby sofiabelenlopez6 コメント

要約

この記事では、RustとC++におけるポインタの「来歴(Provenance)」という概念を比較し、ポインタが単なるメモリ番地以上の意味を持つことを解説しています。コンパイラは、ポインタがどのメモリ領域から派生したかを追跡することで最適化を行いますが、C++では整数へのキャストがこの追跡を困難にする一方、Rustでは`addr()`や`with_addr()`といったAPIを用いて、コンパイラに意図を明示することで、より安全で最適化の余地のあるコード記述が可能になることを説明しています。

全文翻訳

Rust vs C++におけるポインタの来歴

私はRustを探求しており、特にポインタの来歴というトピックに興味をそそられました。なぜなら、それを視覚化するのは容易ではないからです。

vtablesの感触を得るために、実際のメモリを見て、それが実際にどのように見えるかを確認し、それぞれの関数ポインタを通じてこれらの関数を呼び出せることを証明したかったのです。来歴についても同じように分解したいと思いましたが、それはそれほど単純ではないことがわかりました。これがさらに私の興味をそそり、何が見つかるか見てみましょう!

来歴という言葉を初めて聞いたとき、なぜ前に聞いたことがなかったのだろうかと思いました。それは非常に重要であるように思えますが、少し曖昧な概念です。

来歴とは何か:ポインタは単なる強化された整数ではない

[画像: Victoria crowned pigeon: some will say it's just a pigeon. Photo by CHUTTERSNAP on Unsplash]

このスニペットをよく見てみましょう。短いですが、この概念に初めて触れる場合、私たちの直感は私たちを欺く可能性があります。void opaque(int*)はここで前方宣言されているため、私たちにとってはブラックボックスです。私たちもコンパイラも、この関数がpを逆参照してメモリに書き込む可能性を排除できません。

void opaque(int*);

int f(int* p) {
    int x = 1;
    opaque(p);
    return x;
}

これを-O2でコンパイルすると、Clangはこれをreturn 1に最適化します。Godboltで試してみましょう。

問題は、コンパイラはどのようにしてint* pがxの場所と同じメモリアドレスを指していない(つまりopaque(p)がxを変更する可能性がある)と知っているのでしょうか?

コンパイラは、pが&xと等しくなり得ないと仮定します。なぜなら、pはxから派生したものではないからです。

これを初めて聞いたとき、少し奇妙な概念だと思いました。「派生した」とはどういう意味でしょうか?

ポインタが単なる数値(メモリアドレス)であれば、pは理論的にはxのスタック上の割り当てと同じメモリアドレスを持つ可能性があります。それらが単なる数値でないのは幸いです!

白いウサギを追いかけましょう。

[画像: Yes, I've been trying to get more rabbits on my page. Somehow, only cats seemed to make the cut. This one reminds me of my childhood pet, Kaisie (honorary mention). Photo by Sincerely Media on Unsplash]

これが来歴の核心です。「派生した」という関係が来歴です。コンパイラは、各ポインタについて、それがどの割り当てに属しており、したがって触れることができるかを追跡します。

この神秘的な来歴を印刷する方法を見つけようとしましたが、それは純粋にコンパイラの抽象化であることがわかりました。メモリアドレスと一緒に保存される実際のデータフィールドとしては存在しません。(例外もあります。CHERIアーキテクチャを調べて、別のウサギの穴に落ちるのも自由です)。

来歴の検査:LLVM IR

では、それを検査するために何ができるでしょうか?コンパイラがそれを知っているなら、それはどこかに存在するはずです。もしそうなら、それを見る方法があるはずです。

これがLLVM IR(中間表現)について学んだ方法です。LLVM IRは、高水準言語(Rust、C++など)と機械コードの間の橋渡しをする低水準プログラミング言語です。

コンパイラがその部分を最適化しない場合に何が起こるかを見るために、&xをopaqueに渡すこのコードを試してみましょう。

void opaque(int*);

int f(int* p) {
    int x = 1;
    opaque(p);
    opaque(&x);
    return x;
}

Godboltで-emit-llvmフラグを使用して、LLVM IRを確認できます:

define dso_local noundef i32 @f(int*)(ptr noundef %0) local_unnamed_addr #0 !dbg !14 {
  %2 = alloca i32, align 4 // --> root of %2 allocation (x)
    #dbg_assign(i1 poison, !22, !DIExpression(), !23, ptr %2, !DIExpression(), !24)
    #dbg_value(ptr %0, !21, !DIExpression(), !24)
  call void @llvm.lifetime.start.p0(ptr nonnull %2) #3, !dbg !25
  store i32 1, ptr %2, align 4, !dbg !26
    #dbg_assign(i32 1, !22, !DIExpression(), !28, ptr %2, !DIExpression(), !24)
  tail call void @opaque(int*)(ptr noundef %0), !dbg !29
  call void @opaque(int*)(ptr noundef nonnull %2), !dbg !30
  %3 = load i32, ptr %2, align 4, !dbg !31
  call void @llvm.lifetime.end.p0(ptr nonnull %2) #3, !dbg !32
  ret i32 %3, !dbg !33
}

declare void @llvm.lifetime.start.p0(ptr captures(none)) #1

declare !dbg !34 void @opaque(int*)(ptr noundef) local_unnamed_addr #2

declare void @llvm.lifetime.end.p0(ptr captures(none)) #1

attributes #0 = { mustprogress uwtable "min-legal-vector-width"="0" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cmov,+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" }
attributes #1 = { mustprogress nocallback nofree nosync nounwind willreturn memory(argmem: readwrite) }
attributes #2 = { "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cmov,+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" }
attributes #3 = { nounwind }

コンパイラが行うのは、ポインタがどこから来たのか、そして2つのポインタが同じメモリに触れる可能性があるかどうかを判断する質問に答えるために、概念的にDAG(有向非巡回グラフ)を構築することです。

ルートの起源が一致しない場合、LLVMは%0を通じた書き込みが決して%2に格納された値を変更しないことを保証できます。

100%

ここにさらに探求すべきことがたくさんあり、本当に魅力的であるため、熱中しないわけにはいきません。しかし、私の元の目標は、C++とRustがこれをどのように処理するかを比較することだったので、続けましょう。

C++:整数にキャストするとどうなるか?

&xを整数にキャストした場合、LLVMはその整数からxへのポインタを再構築できると誰でも仮定しなければならないと判断します。

#include <cstdint>

void g(std::uintptr_t);
void opaque(int*);

int f(int* p) {
    int x = 1;
    g(reinterpret_cast<std::uintptr_t>(&x)); // address of x is now 'exposed'
    opaque(p);
    return x;
}

結果として、コンパイラは最適化を断念します。

アドレスを読み取りたいだけで、決してポインタに戻すつもりがない場合、これは残念です。この意図をコンパイラに伝える方法があればいいと思いませんか?

Rust:意図を明示する

これはまさにRustが強力な来歴APIで提供するものです。

公開せずに検査する
let p: *const u32 = &x;
let a: usize = p.addr();

p.addr()は生のメモリアドレス整数を抽出しますが、Rustの抽象マシンに次のように伝えます。

この数値アドレスを検査のために読み取りますが、有効なポインタにキャストし直すことは絶対にしないことを保証します。

したがって、来歴は公開されません。

以前の実験を再現するために、最適化が回避されたかどうかを確認してみましょう。

mov eax, dword ptr [rsp + 12]がopaque(p)の後にxをリロードしていることがわかります。これは、少なくとも現時点では、最適化が断念されていることを意味します。C++の場合と同じです。

それでも、公開に依存せず、より表現力豊かなAPIを使用することから利益を得ることができます。これにより、コンパイラは将来的に最適化する余地が与えられ、Miriに意図を明確に伝えることで、バグの検出に役立てることができます。

来歴の維持

[画像: Pointers reproduce like rabbits and someone has to keep track of the family tree. Photo by Michael Myers on Unsplash]

ロックフリーデータ構造では、ポインタの未使用の低位ビットにステータスフラグを格納する必要がある場合があります。これは非常にクールなトリックで、初めて読んだときは数分間目を丸くしました。この投稿で詳しく説明しています。基本的に、アライメントのため、ポインタの低位ビットは「無駄」(常に0)なので、フラグを格納するために使用できます。

中間ポインタ値で作業することができます。これはフラグビットが設定されていますが、元のポインタからの来歴も継承します。これはまさにwith_addrが提供する機能です。完了後、有効な初期ポインタを再作成できます。

タグ付けされた値は、整数は単なる数値であり来歴を運ぶことができないため、usizeではなくポインタのままです。

let tagged: *const u32 = p.with_addr(p.addr() | 1);
// ...
let revived_ptr: *const u32 = tagged.with_addr(tagged.addr() & !1);

p.addr() | 1はu32に対してアライメントがずれているため、taggedは逆参照可能ではないことに注意してください。それを逆参照するとUBになります。

要するに:

  • addr()は来歴を公開せずにアドレスを読み取ります。
  • with_addr()は、元のポインタと同じ来歴を持つ新しいアドレスで新しいポインタを構築します。
レガシーRust、C++と同様

レガシーコードでは、次のようなことを行う必要があります。

let addr = p as usize;
let tagged_addr = addr | 1;
let revived_ptr = (tagged_addr & !1) as *const u32;
println!("*revived_ptr: {}", unsafe { *revived_ptr});

このコードは実行されますが、Miriで実行すると警告が発せられます。

warning: integer-to-pointer cast
 --> src/main.rs:6:22
  |
6 |     let revived_ptr = (tagged_addr & !1) as *const u32;
  |                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ integer-to-pointer cast
  |
  = help: this program is using integer-to-pointer casts or (equivalently) `ptr::with_exposed_provenance`, which means that Miri might miss pointer bugs in this program
  = help: see https://doc.rust-lang.org/nightly/std/ptr/fn.with_exposed_provenance.html for more details on that operation
  = help: to ensure that Miri does not miss bugs in your program, use Strict Provenance APIs (https://doc.rust-lang.org/nightly/std/ptr/index.html#strict-provenance, https://crates.io/crates/sptr) instead
  = help: you can then set `MIRIFLAGS=-Zmiri-strict-provenance` to ensure you are not relying on `with_exposed_provenance` semantics
  = help: alternatively, `MIRIFLAGS=-Zmiri-permissive-provenance` disables this warning

*revived_ptr: 1
warning: 1 warning emitted

実際、実行される理由は興味深いものです。C++の例と同様のことが起こります。p as usizeはpの来歴を公開し、再構築されたポインタが同じ割り当てを指しているため、来歴が回復されます。

ポインタ演算を行って別の割り当てに着地した場合、来歴は失われます。

来歴を公開せずにキャストする

[画像: Photo by Dimitry B on Unsplash]

来歴が公開されない場合に何が起こるかを見るために、ptr::without_provenanceを使用できます。これは、割り当てがない生の С を作成します。(これはダングリングポインタやセンチネルポインタに役立ちますが、逆参照することはできません)。

let bare_ptr = std::ptr::without_provenance::<u32>(p.addr());
println!("*tagged_ptr: {}", unsafe { *bare_ptr});

予想通り、Miriはダングリングポインタについて不平を言い、来歴がないことを明確に伝えます。

error: Undefined Behavior: memory access failed: attempting to access 4 bytes, but got 0x20b28[noalloc] which is a dangling pointer (it has no provenance)
  --> src/main.rs:11:42
   |
11 |     println!("*tagged_ptr: {}", unsafe { *bare_ptr});
   |                                          ^^^^^^^^^ Undefined Behavior occurred here
   |
   = help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
   = help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information

note: some details are omitted, run with `MIRIFLAGS=-Zmiri-backtrace=full` for a verbose backtrace

error: aborting due to 1 previous error

まとめ

要するに、来歴とは、LLVMが最適化に使用する「どの割り当て」からポインタが派生したかを追跡する隠された情報です。

C++では、uintptr_tキャストは、最悪のケースに備えて一部の最適化を無効にすることをコンパイラに強制します。

Rustでは、addr()、with_addr()、without_provenance()を使用すると、意図を明示できます。数値だけが必要なのか、既存のポインタから新しいポインタを派生させているのか。原則として、これによりコンパイラはより積極的に最適化する余地が与えられます。ただし、私たちの実験では、Rustのバージョンも最適化されていませんでした。xはまだスタックからリロードされていました。それでも、より表現力豊かなAPIを使用することは価値があります。Miriはルールを強制し、意図しない使用から生じるバグを検出できるからです。

MiriとASanが何を検出できるかを比較する予定でしたが、それは別の投稿に保存します。すでに十分な新しい情報であり、完全に理解するには時間がかかります。

現在、私は求職中であり、Rustの職を探しています。そのため、これらの投稿は私を動機付け続け、コミュニティの感覚を見つけるのに役立っています。読んでくれてありがとう、そして楽しんでいただけたなら幸いです!