Fil-Cの単純化モデル
(corsix.org)- C/C++のポインタに AllocationRecord メタデータ を付随させて追跡し、逆参照時に メモリ境界検査 を行う構造
- ポインタ代入、算術、関数引数の受け渡し、戻り値、
malloc・free呼び出しまで、元のポインタ値と対応するメタデータを一緒に移動するか、Fil-C 専用呼び出しに変換する方式 - ヒープメモリ内のポインタメタデータは invisible_bytes に別途保存し、ポインタのロード・ストア時に値とメタデータを一緒に読み書きし、アラインメント検査 も追加で適用
filc_freeはvisible_bytesとinvisible_bytesだけを解放し、AllocationRecord 自体は維持し、その後の整理はガベージコレクタが担当し、アドレスがエスケープする可能性のあるローカル変数は ヒープ昇格 で処理- スレッド、関数ポインタ、メモリ・性能最適化など実装上の複雑さは残るが、大規模な C/C++ コードの メモリ安全性検証 や pointer provenance の 具体的なシステム例 として活用できる可能性がある
単純化した Fil-C モデル
- Fil-C は C/C++ コードをメモリ安全に扱うため、ポインタと一緒に AllocationRecord* メタデータを追跡する構造を使う
- 実際の実装は LLVM IR の書き換え方式だが、単純モデルでは C/C++ ソースコードを自動変換する形
- 各関数のポインタ型ローカル変数ごとに対応する
AllocationRecord*ローカル変数を追加 - 例として
T1* p1にはAllocationRecord* p1ar = NULLを追加する形
- ポインタのローカル変数に対する単純な代入や計算では、元のポインタ値と一緒に AllocationRecord* も同時に移動する方式
p1 = p2はp1 = p2, p1ar = p2arに変換p1 = p2 + 10でもp1ar = p2arを伴う- 整数からポインタへのキャストはメタデータを
NULLに設定 - ポインタを整数に変換するキャストはそのまま維持
- 関数引数の受け渡しと戻り値でも、ポインタと一緒に AllocationRecord* を追加で渡し、特定の標準ライブラリ呼び出しは Fil-C 専用関数 に置き換える
mallocとfreeの呼び出しはそれぞれfilc_malloc、filc_freeの形に変換- 例として
p1 = malloc(x); free(p1);は{p1, p1ar} = filc_malloc(x); filc_free(p1, p1ar);の形
filc_mallocは要求されたメモリを 1 つだけ確保するのではなく、3 つの割り当てを行うAllocationRecordオブジェクトの割り当て- 実データ用
visible_bytesの割り当て - 見えないメタデータ保存用の
invisible_bytesをcallocで割り当て AllocationRecordはvisible_bytes、invisible_bytes、lengthフィールドを持つ
逆参照と境界検査
- ポインタの逆参照時には付随する AllocationRecord* を使って 境界検査 を行う
- ポインタメタデータが
NULLでないことを確認 - 現在のポインタ位置と
visible_bytesの開始アドレスとの差を計算 - オフセットが全体の長さより小さいことを確認
- 残りの長さが逆参照対象のサイズ以上あることを確認
- ポインタメタデータが
- 読み込みと書き込みの両方に同じ検査手順を適用
x = *p1の前にも検査を実行*p2 = xの前にも同じ形の検査を実行
- この構造により、ポインタが指す対象へのアクセスが割り当て範囲を外れるのを防ぐ
ヒープ内のポインタと invisible_bytes
- ヒープメモリに保存されたポインタは、ローカル変数のようにコンパイラが直接別変数として管理できないため、invisible_bytes を使う
visible_bytes + iの位置にポインタがあるなら、対応するAllocationRecord*はinvisible_bytes + iの位置に保存- つまり
invisible_bytesは要素型がAllocationRecord*の配列のように動作する
- ポインタ値をメモリから読み書きする際は、通常の境界検査に加えて アラインメント検査 を追加
- オフセット
iがsizeof(AllocationRecord*)の倍数であることを確認 - この条件を満たして初めて
invisible_bytesをAllocationRecord**配列のように安全にアクセスできる
- オフセット
- ポインタのロード時にはデータポインタと一緒にメタデータも読み込む
p2 = *p1はp2 = *p1の後にp2ar = *(AllocationRecord**)(p1ar->invisible_bytes + i)を追加
- ポインタのストア時にはポインタ値だけでなく、対応するメタデータも一緒に保存
*p1 = p2は実データ保存後に*(AllocationRecord**)(p1ar->invisible_bytes + i) = p2arを実行
filc_free とガベージコレクタ
filc_freeはポインタがNULLでないとき、AllocationRecord との整合性を検査したうえで 2 つのメモリだけを解放するpar != NULLを確認p == par->visible_bytesを確認visible_bytesとinvisible_bytesを解放- その後
visible_bytes、invisible_bytesをNULLに、lengthを 0 に変更
filc_mallocは 3 つを割り当てるが、filc_freeは AllocationRecord オブジェクト自体は解放しない- この差分はガベージコレクタが処理する
- 単純モデルでは stop-the-world GC で十分で、実際の Fil-C は並列・並行・インクリメンタル収集器を使う
- GC は
AllocationRecordオブジェクトをたどって追跡 - 到達不能な
AllocationRecordを解放対象として扱う
- GC は
- GC は追加で 2 つの作業を行う
- 到達不能な
AllocationRecordを解放するときにfilc_freeを呼び出す lengthが 0 のAllocationRecordを指すすべてのポインタを、長さ 0 の単一の正規AllocationRecordに変更する
- 到達不能な
- この動作により、
freeを呼ばなくてもメモリリークにはつながらない- GC が自動で解放を実行
- ただし
free呼び出しは GC より早い時点でのメモリ解放を可能にする
free後、そのAllocationRecordは最終的に到達不能になり、後で整理できる
ローカル変数アドレスのエスケープとヒープ昇格
- GC が存在すると、ローカル変数のアドレスを安全に扱える範囲が広がる
- ローカル変数のアドレスが取得され、そのアドレスが変数寿命の外へ エスケープしないことの証明 をコンパイラができない場合は、ヒープ割り当てに昇格する
- こうしたローカル変数はスタックではなく
mallocで割り当てる- 対応する
freeを別途挿入する必要はない - GC が回収を担当する
- 対応する
Fil-C 版 memmove
- C 標準ライブラリの
memmoveは任意のメモリを扱うため、その中にポインタがあるかどうかをコンパイラが把握できないという問題がある - これに対して ヒューリスティック を適用
- 任意メモリ内のポインタは、そのメモリ範囲内に 完全に含まれている 必要がある
- ポインタは正しくアラインされていなければならない
- この規則により、同じ 8 バイトの移動でも動作に差が出る
- アラインされた 8 バイトを 1 回で
memmoveすると、対応する範囲のinvisible_bytesも一緒に移動する - 1 バイトずつ 8 回に分けて
memmoveすると、invisible_bytesは移動しない
- アラインされた 8 バイトを 1 回で
実装で追加される複雑さ
-
スレッド
- 並行性は GC の複雑さ を高める要因
filc_freeはメモリを即座には解放できない- 解放中のスレッドと、他のスレッドによる同一メモリへのアクセスが競合状態になる可能性があるため
- ポインタに対するアトミック操作も追加の処理が必要
- 基本的な書き換えではポインタのロード/ストアを 2 回のロード/ストアに変えるため、アトミック性が壊れる
-
関数ポインタ
AllocationRecordの追加メタデータとして、visible_bytesが通常データではなく 実行コードポインタ であることを示す- 関数ポインタ
p1を通じた呼び出しでは、p1 == p1ar->visible_bytesの確認とともに、p1arが関数ポインタを表すかどうかを検査 - 関数ポインタに対する型混同攻撃を防ぐため、呼び出し ABI でも 型シグネチャ検証 が必要
- 1 つの方法は、すべての関数が同一の型シグネチャを持つようにする方式
- すべての引数を構造体に詰めてメモリ経由で渡すように扱う
- ABI 境界では各関数が、その構造体に対応する単一の
AllocationRecord1 つだけを受け取る形
-
メモリ使用量の最適化
filc_mallocがinvisible_bytesを即時に割り当てず、必要時に遅延割り当てする方式を検討できるAllocationRecordとvisible_bytesを 1 回の割り当てで一緒に配置する方式も検討できる- 基盤の
mallocが各割り当ての先頭部分にメタデータを付けるなら、そのメタデータをAllocationRecordに取り込む方式も候補になる
-
性能最適化
- Fil-C のメモリ安全性には 性能コスト が伴う
- 失われた性能を一部取り戻すためのさまざまな手法を適用できる余地がある
Fil-C の利用場面
- 大規模な C/C++ コードが一見動作していても メモリ安全性の検証 がなく、メモリ安全性のために GC 導入と大きな性能低下を受け入れられる場合に利用できる
- Java、Go、Rust への書き換えまでの暫定措置としての可能性に言及
- ASan のようにメモリバグ検出を目的として Fil-C を実行することもできる
- C/C++ コードを Fil-C の下で実行してメモリバグを確認できる
- コンパイル時言語とランタイム言語が同じで、コンパイル時安全性が強い言語では、安全なコンパイル時計算 の用途もありうる
- 例として Zig に言及
- ランタイム評価が安全でなくても、コンパイル時評価には Fil-C 構成を使える
- pointer provenance を扱う具体的なシステム事例としての意味もある
p1とp2の型が同じとき、if (p1 == p2) { f(p1); }をif (p1 == p2) { f(p2); }に変える最適化が可能か、という問いを提示- Fil-C では
fに渡されるAllocationRecord*が異なるため、答えは明確に不可能だと述べる - この点で Fil-C は pointer provenance を持つ具体的システムの一例となる
1件のコメント
Hacker News のコメント
Upon freeing an unreachable AllocationRecord, call filc_free on it.私の理解では、言いたいのは到達不能な AR を解放する前に、visible_bytesとinvisible_bytesフィールドが指しているメモリを先に解放しろ、ということだと思う