Valgrind と Sanitizers の実践

このページの目次

これらのツールは、プログラムの実際の実行を観察することでエラーを検出します。「どこかでクラッシュした」という情報よりも、より早期かつ具体的な証拠を提供できますが、実行されなかったパスには依然として問題がある可能性があります。まずは、検出対象がアドレスの越界アクセス、未初期化データ、言語の未定義動作、あるいはスレッドアクセスにおける同期の欠如のいずれであるかを区別する必要があります。

迅速な選択

問題一般的なツール主要な境界
ヒープ越界、解放後使用ASan、Memcheckカバレッジとオブジェクトレイアウト、アロケーターの統合に関連
スタックまたはグローバルオブジェクトの越界ASanすべてのオブジェクト内部の越界アクセスを検出するわけではない
未初期化値が動作に影響Memcheck、MSanMSan は依存ライブラリのインストルメンテーション連携が必要
符号付きオーバーフロー、アライメントミスなどUBSan有効化された項目のみをチェックし、UB のすべてをカバーするわけではない
データ競合TSan、Helgrind関連する実行と同期メカニズムのカバレッジが必要
ヒープ割り当ての増大、ピークMassif などのプロファイラーメモリ増大が必ずしもリークを意味するわけではない
到達可能性におけるリークLSan、Memcheck到達可能なオブジェクトは、ビジネス上不要な保持である可能性がある

デテクターとプロファイラーの目的は異なります。Cachegrind はシミュレーションまたはインストルメンテーションによる統計を提供しますが、これは実際の CPU の PMU 測定に等しくありません。Massif は実行に伴うメモリの変化を記述しますが、すべてのオブジェクトの所有権エラーを判定する保証はありません。

Valgrind:実行されたマシンコードの分析

Valgrind は動的バイナリトランスレーションにより、実行パスにツールが必要とする分析を追加します。通常、対象の再インストルメンテーションは不要ですが、デバッグシンボルは位置特定に役立ちます。対象アーキテクチャ、システムコール、命令セット、およびランタイムはサポートされている必要があります。

valgrind --tool=memcheck --leak-check=full --show-leak-kinds=all \
  --errors-for-leak-kinds=definite,possible --error-exitcode=99 ./prog
valgrind --tool=memcheck --track-origins=yes ./prog
valgrind --tool=helgrind ./threaded_prog
valgrind --tool=massif ./prog

track-origins は主に未初期化値の起源を追跡するのに役立ち、「有効にすればリークがどこで割り当てられたかがわかる」わけではありません。Memcheck はアドレスのアクセス可能性と値の定義済み状態を別々に追跡します。あるアドレスは合法であっても、その中の値は未初期化である可能性があります。Memcheck マニュアル

Memcheck は、ソースコードレベルの配列境界チェック器ではありません。スタックまたはグローバル配列の越界アクセスが依然としてアクセス可能な領域内にある場合、報告されない可能性があります。静的リンクも「一般的に完全にサポートされない」と一概には書けません:アロケーター関数の置き換えが影響を受ける可能性があり、ツールドキュメントに従って処理する必要があります。Valgrind FAQ

追加のシャドウ状態とトランスレーションキャッシュが必要であるため、「追加のメモリオーバーヘッドなし」という記述は誤りです。時間とメモリのオーバーヘッドは、具体的なツールと負荷に基づいて測定する必要があり、ツールファミリー全体を固定された倍率に固定することはできません。

ASan:アクセスされたアドレスの使用許可をチェック

ASan はコンパイル時にチェックを挿入し、ランタイムのアロケーターなどのメカニズムと連携します。一般的な実装では、8バイトのアプリケーションバイトを記述するためにシャドウバイトを使用し、オブジェクト周囲のレッドゾーンと解放後のポイズニングを組み合わせます。アイソレーションキューはアドレスの再利用を遅らせ、解放後使用の発見に役立ちます。

ASan 概要:有効なアドレス範囲とシャドウ状態による共同チェック 10バイト割り当て:2つの8バイト粒度のシャドウ項目 最初の8バイトはすべてアクセス可能 shadow = 0 2バイト 有効 最後の6バイト アクセス不可 2番目のシャドウ項目 = 2 一般的な ASan マッピング;レッドゾーンと解放後のポイズニングはエラー発見に使用され、すべてのオブジェクト境界が安全であることを証明するものではありません。

これにより、ヒープ、スタック、グローバルの越界アクセスの多くが検出されますが、すべてのプログラムセマンティクスを追跡するわけではありません。例えば、越界アクセスがちょうど別の合法なオブジェクト内に入った場合、オブジェクト内部の特定の越界アクセス、またはインストルメンテーションされていないアクセスは、必ずしも報告されません。ASan は一般的な未初期化値の伝播を担当するものではありません。AddressSanitizer ドキュメント

1つのシャドウバイトが、どのように8つのアプリケーションバイトを記述するか?

オブジェクトの長さが10バイトの場合、2つの粒度はそれぞれ 00 と 02 です。起点が9、幅が1の場合合法ですが、幅を2に変更すると、オフセット10の越界バイトを含んでしまうため、起点が合法であっても拒否されます。解放後、以前有効だった領域はポイズニングされます。これにより、オブジェクトのライフサイクルと現在のアドレスにマッピングが残っているかどうかを混同してはならない理由が説明されます。

図を準備しています
1つのシャドウバイトが、どのように8つのアプリケーションバイトを記述するか?

shadow=0 は8バイトがアクセス可能であることを示し、1–7 はそのプレフィックスがアクセス可能であることを示し、ポイズニングマーカーはアクセス不可を示します。チェックはアクセス全体をカバーする必要があり、起点のみを見てはいけません。

実際のヒープ越界アクセスの例

以下を bounds.c として保存します。これは意図的に添字が長さと等しいことを許可し、個別のアクセス関数を通じてエラーを観察しやすくしています。このコードはローカルのデテクターでのデモ専用です。

#include <stdio.h>
#include <stdlib.h>

__attribute__((noinline))
static void store(unsigned char *p, size_t index) {
    p[index] = 7;
}

int main(int argc, char **argv) {
    size_t index = argc > 1 ? strtoul(argv[1], NULL, 10) : 10;
    unsigned char *p = malloc(10);
    if (p == NULL) return 1;
    store(p, index);
    printf("%u\n", (unsigned)p[index]);
    free(p);
    return 0;
}
clang -g -O1 -fno-omit-frame-pointer -fsanitize=address \
  bounds.c -o bounds-asan
./bounds-asan 9
./bounds-asan 10

9 は最後の有効な添字です。10 は割り当て領域の後ろを指しており、heap-buffer-overflow の報告が期待されます。レポート内のアクセス位置は「どこで不正なアクセスが行われたか」に答え、割り当てスタックは「この領域がどこで作成されたか」に答えます。これら2つを組み合わせて読む必要があります。アドレスと具体的なスタックの形式はビルドと実行によって異なります。

修正では、入力範囲とデータ長の関係を明確にする必要があります。例えば、アクセス前に index < 10 を検証します。単に malloc のサイズを1バイト大きくするだけでは、任意の添字の問題は無視されます。トリガー入力を回帰テストケースとして保持し、有効なパスと無効なパスの両方を再実行します。

割り当てを拡大してエラーを隠すのではなく、入力解析と境界を修正する

以下を bounds-fixed.c として保存します。これは空でない10進数のみを受け付け、マイナス記号、末尾のテキスト、または size_t を超える数値は許可しません。変換が成功した後に、ビジネスロジックの添字範囲をチェックします。不正な入力は終了コード 2 で拒否され、割り当て失敗は 1 です。

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

static int parse_index(const char *text, size_t *out) {
    size_t value = 0;
    if (*text == '\0') return 0;
    for (; *text; ++text) {
        if (*text < '0' || *text > '9') return 0;
        size_t digit = (size_t)(*text - '0');
        if (value > (SIZE_MAX - digit) / 10) return 0;
        value = value * 10 + digit;
    }
    *out = value;
    return 1;
}
int main(int argc, char **argv) {
    const size_t length = 10;
    size_t index = 0;
    if (argc != 2 || !parse_index(argv[1], &index) || index >= length) {
        fputs("expected index in 0..9\n", stderr);
        return 2;
    }
    unsigned char *bytes = malloc(length);
    if (!bytes) return 1;
    bytes[index] = 7;
    printf("%u\n", (unsigned)bytes[index]);
    free(bytes);
    return 0;
}
clang -g -O1 -fno-omit-frame-pointer -fsanitize=address,undefined \
  -fno-sanitize-recover=undefined bounds-fixed.c -o bounds-fixed
./bounds-fixed 0
./bounds-fixed 9
./bounds-fixed 10
./bounds-fixed 9junk
./bounds-fixed -1

最初の2つは成功して 7 を出力し、後の3つはメモリアクセスの前に拒否されるべきです。ASan に再度プロセスを強制終了させるのではなくです。回帰テストには空文字列と超大きな数字も追加する必要があります。解析の成功とアドレスの合法性は2つの条件層であり、その1つだけを検証すればよいわけではありません。元の strtoul は endptr と変換エラーをチェックしておらず、単に index < length を追加しても、予期しないテキストの一部を合法なプレフィックスとして解析してしまう可能性があります。

レポート内の3つの位置はそれぞれ異なる質問に答える

レポート情報追跡方法
WRITE/READ of size N実際のアクセス幅とインデックスを確認し、ポインター値のみを見ない
アクセス呼び出しスタック越界アクセスを実行したパスを特定し、パラメータの起源をさらに追跡
割り当て領域と割り当てスタック割り当て長、要素単位、ライフサイクルを確認
freed by(存在する場合)早期解放と古いポインターを依然として保持している所有関係の特定
シャドウ状態レッドゾーン、解放後領域などを区別するのに役立ち、ビジネス上の原因そのものではない

例えば、「アドレスが10バイトの領域の直後 0 バイトにある」というのは、ちょうど one-past 境界を踏んでいることを意味します。C では、比較やイテレーションの終点として one-past ポインターを構築することが許可されていますが、それを間接参照することは許可されていません。レポートは、10番目の有効な要素も誤ってインターセプトされたことを意味するものではありません。

UBSan:言語ルールに基づくチェック

clang -g -O1 -fsanitize=undefined -fno-sanitize-recover=undefined \
  prog.c -o prog-ubsan

一般的なチェックには、符号付き整数のオーバーフロー、不正なシフト、NULL ポインター、およびアライメントの問題が含まれます。符号なし整数のモジュロ演算によるオーバーフローは、C/C++ では通常定義済み動作であり、「整数オーバーフロー」すべてが UB であると総称すべきではありません。Clang は、定義済みではあるが疑わしい動作の一部をさらにチェックするための追加チェックを提供しますが、これは undefined グループとは異なります。

UBSan の多くのチェックは、デフォルトで報告後に実行を継続します。自動化に使用する場合は、リカバリ、終了、またはトラップのポリシーを明確にする必要があります。-fno-sanitize-recover=undefined は、対応するエラーで停止し、ログを読んで実行失敗を知ることに依存しなくなります。UBSan ドキュメント

符号付きオーバーフローは演算前に判断する必要がある

以下の overflow.c は、意図的に int の範囲を超える加算を実行し、UBSan の最初のエラーを観察するために使用されます。volatile はこの例での読み取りを保持するだけであり、オーバーフローを合法にするものではありません。

#include <limits.h>
#include <stdio.h>
int main(void) {
    volatile int maximum = INT_MAX;
    int result = maximum + 1;
    printf("%d\n", result);
    return 0;
}

前述の UBSan コマンドを使用し、prog.c を overflow.c に置き換えます。リカバリなしモードを有効にすると、ゼロ以外の終了コードと signed integer overflow の報告が期待され、オーバーフロー後の数字に依存して計算を継続してはいけません。

正しい方法は、加算が発生する前に判断を行うことです。以下は独立した checked-add.c です。

#include <assert.h>
#include <limits.h>
static int checked_add(int a, int b, int *out) {
    if ((b > 0 && a > INT_MAX - b) || (b < 0 && a < INT_MIN - b)) return 0;
    *out = a + b;
    return 1;
}
int main(void) {
    int result = 7;
    assert(!checked_add(INT_MAX, 1, &result));
    assert(result == 7);
    assert(!checked_add(INT_MIN, -1, &result));
    assert(result == 7);
    assert(checked_add(INT_MAX, INT_MIN, &result) && result == -1);
    assert(checked_add(INT_MIN, 0, &result) && result == INT_MIN);
    assert(checked_add(20, 22, &result) && result == 42);
}

out はこの例では呼び出し元によって有効なポインターが提供され、失敗時には既存の結果は変更されません。符号付きオーバーフローを検出するために、まず a+b を計算し、結果が小さくなったかどうかを確認しないでください。最初の加算で既に UB が発生している可能性があるためです。また、型を符号なしに変更して要件が修正されたと主張しないでください。モジュロラップアラウンドがビジネス契約に適合するかどうかは、別途決定する必要があります。UBSan のオーバーフローチェック

TSan:スレッド間アクセスの同期関係のチェック

clang -g -O1 -fsanitize=thread -pthread threaded.c -o threaded-tsan

TSan はメモリアクセスと同期イベントを組み合わせて、潜在的なデータ競合を検出します。重要なのは「2つのスレッドがちょうど同じナノ秒に書き込んだかどうか」ではなく、競合するアクセスに適切な happens-before 関係が欠けていないかどうかです。レポートには通常、2つのアクセススタックとスレッド作成情報が含まれます。

通常の変数を volatile に変更しても、スレッド同期は確立されません。データ構造に応じてミューテックス、アトミック操作、または他の正しいプロトコルを使用する必要があります。すべてがアトミックアクセスであっても、ツールが報告しない高レベルの論理競合が存在する可能性があります。

TSan と ASan は通常、別々にビルドする必要があり、2つのパラメータを単に連結して万能デテクターにすることはできません。カスタム同期、インストルメンテーションされていないライブラリ、インラインアセンブリ、およびプラットフォームサポートは結果に影響を与えます。ThreadSanitizer ドキュメント

MSan とリーク検出

MSan は値の初期化状態を追跡し、主に対応するサポートを持つ Clang 環境で使用されます。通常、プログラムと関連する依存関係の一貫したインストルメンテーションを要求します。main のみを再コンパイルして任意のプリコンパイル済みライブラリを混在させると、不完全な結果や誤報の原因になる可能性があります。起源を追跡する必要がある場合は origins を有効にできますが、オーバーヘッドが増加します。MemorySanitizer ドキュメント

LeakSanitizer は、サポートされているプラットフォームでは ASan と統合することも、単独で使用することもでき、デフォルトの有効化状況はプラットフォームによって異なります。到達可能性分析では、依然として参照されているメモリは別の種類のリークですが、グローバルキャッシュの無限増大はビジネス上の問題である可能性があります。メモリ増大曲線、ライフサイクル、および所有権を組み合わせることで判断する必要があり、「0 leaks」のみを見るべきではありません。

アドレスの合法性、値の初期化、並行性の同期は異なるチェック次元である malloc 後未書き込み アドレスはアクセス可能だが、値は未定義 free 後も読み取り 旧値は初期化済みだったが、アドレスは無効 2つのスレッドが同期なしで書き込み アドレス/初期化はどちらも正常である可能性がある Memcheck / MSan ASan / Memcheck TSan / Helgrind 検出される内容は、実行カバレッジ、インストルメンテーション、およびランタイムサポートに依存します。単一のツールだけで完全な証明を行うことはできません。

すべてをアトミック操作に変更しても、アルゴリズムが正しいとは限らない

2つのスレッドがともにアトミック load で 0 を取得し、その後アトミック store で 1 を書き込むことを考えます。これらのアクセスは C/C++ のデータ競合を持たないかもしれませんが、「それぞれが1回ずつインクリメントし、最終的に2になる」というビジネス要件は依然として失敗します。ニーズに一致するアトミック read-modify-write(例:fetch_add)を使用するか、ミューテックスセクションで複合操作全体をカバーする必要があります。

TSan のレポートは、欠落している同期エッジを見つけるのに役立ちますが、残高の保存、タスクの単一実行、またはメッセージ損失なしでの終了などのビジネスロジックを証明するものではありません。並行性の回帰テストでは、ビジネス不変条件を同時にアサートし、バリアや制御されたスケジューリングを使用して重要なインターリーブを作成し、単に sleep を繰り返して運を待つことを避ける必要があります。

デテクターの失敗とプログラムのエラーを別々に記録する

ランタイムでシャドウアドレスを初期化できない、プラットフォームが対応する命令をサポートしていない、またはインストルメンテーションライブラリが一致しない場合、デテクターが有効なチェックを行わなかったことを意味し、「エラーが見つからなかった」として記録してはいけません。環境を修正して再実行します。ツールが実際のアクセスエラーを報告した場合、トリガー入力と一致する成果物を保存してから、プログラムの契約を修正します。

自動化では、少なくとも成功、ビジネス拒否、デテクターによるエラー発見、およびデテクター自体の実行失敗を区別する必要があります。このページの修正プログラムでは、終了コード 2 は予期される入力拒否です。意図的に越界アクセスを行うプログラムでは、ゼロ以外の終了コードに加え、予期される heap-buffer-overflow レポートを伴う必要があり、任意の起動失敗をテスト成功としてカウントしてはいけません。

安定したデバッグフローとして整理する

最小限のトリガー入力と一致するバイナリ、シンボルを優先して保存し、最初の信頼できるレポートを修正します。早期の越界アクセスは後の状態を破壊する可能性があるためです。その後、境界長、失敗分岐、および並行パスを含む入力カバレッジを拡大します。

コンパイルと最終リンクの両方の段階で、sanitizer オプションを正しく渡して一致するランタイムとリンクする必要があります。サードパーティの問題に対する抑制は、正確な理由を説明し、広範なマスクを使用して自コードの問題を隠さないようにします。デテクターのオーバーヘッドはスケジューリングとレイアウトを変更するため、レポートが消えたからといって並行エラーが存在しないことを自動的に証明するものではありません。

パフォーマンス評価は最終的に、納品構成に準拠したビルドに戻るべきです。デテクタービルドはバグ発見用であり、原プログラムのパフォーマンスベースラインとして直接使用することはできません。CPU のホットスポット分析については、perf の深い使用 を参照してください。

キーワード:Valgrind, Memcheck, ASan, UBSan, TSan, MSan, LSan, shadow memory, redzone, data race, uninitialized value