1. ホーム
  2. c++

[解決済み] C++の規格では、初期化されていないboolがプログラムをクラッシュさせることは可能ですか?

2022-03-19 21:30:41

質問

があることは知っています。 "未定義の動作" C++では、コンパイラが望むことを何でもできるようにすることができます。しかし、私はコードが十分に安全であると仮定していたので、私を驚かせるクラッシュが発生しました。

この場合、本当の問題は、特定のコンパイラを使用した特定のプラットフォーム上で、最適化が有効な場合にのみ発生しました。

この問題を再現し、最大限に単純化するために、いくつかのことを試してみました。という関数の抜粋です。 Serialize これは、boolパラメータを受け取って、文字列をコピーします。 true または false を既存のデスティネーションバッファにコピーします。

この関数はコードレビューで、boolパラメータが初期化されていない値だった場合、実際にクラッシュする可能性があることを伝える方法はないのでしょうか?

// Zero-filled global buffer of 16 characters
char destBuffer[16];

void Serialize(bool boolValue) {
    // Determine which string to print based on boolValue
    const char* whichString = boolValue ? "true" : "false";

    // Compute the length of the string we selected
    const size_t len = strlen(whichString);

    // Copy string into destination buffer, which is zero-filled (thus already null-terminated)
    memcpy(destBuffer, whichString, len);
}

このコードを clang 5.0.0 + 最適化で実行すると、クラッシュする/する可能性があります。

期待される三項演算子 boolValue ? "true" : "false" は十分に安全なように見えたので、私は次のように仮定していました: "どんなゴミ値が boolValue は、どうせtrueかfalseに評価されるのだから、どうでもいいことだ。

を設定しました。 コンパイラエクスプローラの例 分解した場合の問題を示す、完全な例を示します。 注:この問題を再現するために、私が見つけた組み合わせは、Clang 5.0.0 で -O2 最適化を使用することです。

#include <iostream>
#include <cstring>

// Simple struct, with an empty constructor that doesn't initialize anything
struct FStruct {
    bool uninitializedBool;

   __attribute__ ((noinline))  // Note: the constructor must be declared noinline to trigger the problem
   FStruct() {};
};

char destBuffer[16];

// Small utility function that allocates and returns a string "true" or "false" depending on the value of the parameter
void Serialize(bool boolValue) {
    // Determine which string to print depending if 'boolValue' is evaluated as true or false
    const char* whichString = boolValue ? "true" : "false";

    // Compute the length of the string we selected
    size_t len = strlen(whichString);

    memcpy(destBuffer, whichString, len);
}

int main()
{
    // Locally construct an instance of our struct here on the stack. The bool member uninitializedBool is uninitialized.
    FStruct structInstance;

    // Output "true" or "false" to stdout
    Serialize(structInstance.uninitializedBool);
    return 0;
}

問題は、オプティマイザにある。文字列 "true"と "false"は長さが1だけ違うことを推論するほど賢いので、長さを実際に計算する代わりに、bool自体の値を使っているのだ。 技術的には0か1のどちらかで、次のようになります。

const size_t len = strlen(whichString); // original code
const size_t len = 5 - boolValue;       // clang clever optimization

これはいわば "clever" ですが、私の疑問は。 C++の標準では、コンパイラはboolが'0'か'1'の内部数値表現しかできないと仮定して、そのように使用することができるのでしょうか?

それとも、これは実装定義のケースで、その場合、実装はそのすべてのboolsが0または1だけを含むと仮定し、他の値は未定義の動作領域であるのでしょうか?

解決方法は?

はい、ISO C++では、実装がこの選択をすることを認めています(ただし、必須ではありません)。

しかし、ISO C++では、プログラムがUBに遭遇した場合、コンパイラが、例えばエラーを発見しやすくする方法として、わざとクラッシュするコード(例えば、不正な命令で)を出すことを認めていることにも注意してください。 (あるいは、DeathStation 9000だから。 厳密に適合しているだけでは、C++の実装が実際の目的に役立つには十分ではありません)。 つまり、ISO C++では、コンパイラが、未初期化を読み込む同様のコードでも(全く異なる理由で)クラッシュするasmを作ることができるのです。 uint32_t . それがトラップ表現のない固定レイアウト型であることが要求されているのに、です。

実際の実装がどうなっているかは興味深い問題ですが、たとえ答えが違っていたとしても、現代のC++はアセンブリ言語の移植版ではないので、あなたのコードは安全でないことに変わりはないことを覚えておいてください。


に向けてコンパイルしているのでしょう。 x86-64 System V ABI と指定されています。 bool をレジスタの関数 arg として、ビットパターン false=0true=1 レジスタの下位8ビットに 1 . メモリに bool は1バイトの型であり、これも0か1の整数値でなければならない。

(ABIとは、同じプラットフォームのコンパイラが、互いの関数を呼び出すコードを作成できるように合意する実装選択のセットで、型サイズ、構造体レイアウト規則、呼び出し規則などが含まれます)。

ISO C++では規定されていませんが、このABIの決定によりbool->int変換が安くなる(ゼロ拡張で済む)ので広まっています。 . に対してコンパイラに0または1を仮定させないABIを私は知らない。 bool x86に限らず、あらゆるアーキテクチャに対応します。 これにより、以下のような最適化が可能になります。 !myboolxor eax,1 でLowビットを反転させます。 CPUの1命令でビット/整数/ブールを0と1の間で反転させることができる任意のコード . またはコンパイル a&&b をビット単位のANDに変換して bool の型があります。 一部のコンパイラは、実際に ブール値をコンパイラで8ビットとする。これに対する演算は非効率的なのでしょうか? .

一般に、as-if 規則では、コンパイラが真であるものを利用することができます。 に対してコンパイルされるターゲットプラットフォームで なぜなら、最終的にはC++のソースと同じ外から見える動作を実装した実行可能なコードになるからです。 (未定義の振る舞いが実際に外部から見えるものに対して課しているすべての制限を考慮すると、デバッガではなく、整形された/合法なC++プログラム内の別のスレッドからです)。

コンパイラは、そのコード生成においてABI保証をフルに活用し、あなたが見つけたような最適化するコードを作ることは間違いなく許されています。 strlen(whichString) になります。
5U - boolValue .
(ところで、この最適化はちょっと賢いですが、分岐やインライン化に対して近視眼的かもしれません。 memcpy 即時データの保存先として 2 .)

あるいは、コンパイラはポインタのテーブルを作成し、そのテーブルの整数値でインデックスを作成し bool この場合も0か1だと仮定しています。 この可能性は、@Barmar さんの回答が示唆したものです。 .)


あなたの __attribute((noinline)) コンストラクタで最適化を有効にすると、clang はスタックからバイトをロードして uninitializedBool . その結果、オブジェクトのためのスペースが mainpush rax (これは、より小さく、様々な理由で sub rsp, 8 に入るとき、AL にあったゴミはすべて取り除かれます。 main に使用される値です。 uninitializedBool . このため、実際には単なる 0 .

5U - random garbage は大きな符号なし値にラップされやすく、memcpy がマッピングされていないメモリに移動することになります。 行き先はスタックではなくスタティックストレージなので、リターンアドレスなどを上書きしているわけではありません。


他の実装では、例えば以下のように異なる選択をすることができます。 false=0true=any non-zero value . そうすると、clangはおそらく これ の特定のインスタンスであるUB。(しかし、そうしたいと思えば、それはまだ許されるでしょう)。 に対してx86-64が行っていること以外を選択する実装を知りません。 bool しかし、C++の規格は、現在のCPUのようなハードウェアでは誰もやらない、あるいはやりたくもないようなことをたくさん許容しています。

ISO C++では、オブジェクトの表現を調べたり変更したりしたときに何が見つかるかは未指定です。 bool . (例: by memcpy をイングする。 boolunsigned char という理由で許されます。 char* は何でもエイリアスにすることができます。 そして unsigned char はパディングビットを持たないことが保証されているので、C++標準では、UBを持たないオブジェクト表現のHexdumpを正式に許可しています。 オブジェクト表現をコピーするためのポインタキャストと char foo = my_bool もちろん、0や1へのブーリアン化は起こらないので、生のオブジェクト表現が得られます)。

あなたは 部分的に でこの実行パスのUBをコンパイラから隠蔽しています。 noinline . しかし、インラインでなくても、手続き間最適化によって、他の関数の定義に依存するバージョンの関数が作られる可能性があります。 (第一に、clangは実行ファイルを作っているのであって、シンボルの挿入が起こりうるUnixの共有ライブラリではありません。 第二に、この定義は class{} の定義があるので、すべての翻訳ユニットには同じ定義が必要です。 例えば inline というキーワードがあります)。

そのため、コンパイラは ret または ud2 (不正な命令)の定義として main の先頭で実行されるからです。 main 未定義の動作に遭遇するのは避けられない。 (コンパイラは、非インラインコンストラクタを通過する経路を辿ることを決定した場合、コンパイル時にそれを確認することができます)。

UBに遭遇したプログラムは、その存在全体が完全に未定義となります。 しかし、関数または if() ブランチが実際に実行されることはありませんが、プログラムの残りの部分を破損することはありません。 実際には、コンパイラは不正な命令、あるいは ret UBを含む、あるいはUBにつながることがコンパイル時に証明できる基本ブロック全体に対して、何も発せず次のブロック/関数に落ちます。

GCCとClangの実際 する を出力することがあります。 ud2 をUB上で実行し、意味のない実行パスのコードを生成しようとさえしない。 あるいは、非の端から落ちていくようなケースには void 関数を使用する場合、gccは時々 ret という命令があります。 もしあなたが、「私の関数は RAX にあるどんなゴミでも返してくれるだろう」と思っていたら、それは大間違いです。 最近のC++コンパイラーは、もはやこの言語を移植可能なアセンブリ言語のようには扱っていない。 あなたのプログラムは、インライン化されていないスタンドアロン版の関数がasmでどのように見えるかを仮定することなく、本当に有効なC++でなければならないのです。

もう一つの面白い例は AMD64でmmapされたメモリにunalignedでアクセスするとsegfaultになることがあるのはなぜですか? .x86は整列されていない整数ではフォールトを起こしませんよね? ではなぜ不整列の uint16_t* が問題になるのでしょうか? なぜなら alignof(uint16_t) == 2 で、その仮定に違反すると、SSE2 で自動ベクトル化したときにセグメンテーションフォールトが発生しました。

以下もご参照ください。 C言語プログラマーが知っておくべき未定義動作のこと #1/3 Clangの開発者による記事です。

キーポイント:コンパイラがコンパイル時にUBに気づいたら かもしれない の有効なオブジェクト表現であるABIをターゲットにしていても、UBを引き起こすコードのパスを "break" (驚くべきasmを生成する)。 bool .

プログラマが犯した多くのミス、特に最近のコンパイラが警告するようなミスに対しては、全面的に敵対することを期待します。 このため -Wall と警告を修正します。 C++はユーザーフレンドリーな言語ではありませんし、コンパイルするターゲットのasmでは安全であっても、C++では安全でないことがあり得ます。 (例えば、符号付きオーバーフローは C++ では UB であり、コンパイラは 2 の補数 x86 用にコンパイルする場合でも clang/gcc -fwrapv .)

コンパイル時に見えるUBは常に危険で、(リンク時の最適化によって)本当にUBをコンパイラから隠しているかどうかを確認するのは本当に難しく、したがって、どのようなasmが生成されるかを推論することができます。

大げさでなく、コンパイラはあることを許すと、何かがUBであっても期待通りのコードを出力してくれることがよくあるのです。 しかし、コンパイラの開発者が、値の範囲に関するより多くの情報(例えば、変数が非負であること、x86-64でゼロ拡張を解放するために符号拡張を最適化することを可能にする)を得る何らかの最適化を実装した場合、将来的に問題になるかもしれない。 例えば、現在のgccとclangでは、以下のようにします。 tmp = a+INT_MIN は最適化されません。 a<0 をalways-falseとすることのみです。 tmp は常に負である。 (なぜなら INT_MIN + a=INT_MAX はこの 2 の補数ターゲットで負であり a はそれ以上高くできない)

つまり、gcc/clangは現在、計算の入力に対して範囲情報を導き出すためにバックトラックを行うことはなく、符号付きオーバーフローがないという仮定に基づいた結果に対してのみ行うのです。 ゴッドボルトの例 . この最適化が、ユーザーの利便性のために意図的に見過ごされているのか、それとも何なのかは分かりませんが。

また、以下の点にも注意してください。 ISO C++が未定義のままにしている振る舞いを、実装(コンパイラ)が定義することが許されています。 . 例えば、Intel の intrinsics をサポートするすべてのコンパイラ (例えば _mm_add_ps(__m128, __m128) は手動SIMDベクトル化用)は、不整列ポインタの形成を許可しなければなりませんが、これはたとえ しない を参照する。 __m128i _mm_loadu_si128(const __m128i *) は、不整列なロードを行うために、不整列な __m128i* argではなく void* または char* . ハードウェアベクターポインタと対応する型との間の `reinterpret_cast` は未定義の動作ですか?

GNU C/C++では、負の符号付き数値を左シフトした場合の動作も定義されています( -fwrapv ) を、通常の符号付きオーバーフローのUBルールとは別に用意しました。 ( これはISO C++におけるUBである 一方、符号付き数値の右シフトは実装で定義されます(論理か算術か)。良質な実装は算術的右シフトを持つHW上の演算を選択しますが、ISO C++では指定されていません)。 このことは GCCマニュアルの整数のセクション また、C言語規格が実装に要求している、実装で定義された動作も定義しています。

コンパイラの開発者が気にする実装品質の問題は確かに存在します。 試みる しかし、C++のすべてのUBポットホール(彼らが定義したものを除く)を利用して、より良い最適化を行うことは、時にはほとんど区別がつかないことがあります。


脚注1 : 上位56ビットは、レジスタより狭い型では通常通り、着呼側が無視しなければならないゴミになることがあります。

( その他のABI する ここでは異なる選択をする . MIPS64 や PowerPC64 のように、関数に渡すときや関数から返すときに、レジスタを埋めるために狭い整数型をゼロまたは符号拡張する必要があるものもあります。 の最後のセクションを参照してください。 このx86-64の回答では、これらの以前のISAとの比較をしています。 .)

例えば、呼び出し元が計算した a & 0x01010101 を呼び出す前に、RDI でそれを別のことに使っています。 bool_func(a&1) . 呼び出し側は &1 の一部として下位バイトにすでに行っているからです。 and edi, 0x01010101 そして、着呼側が上位バイトを無視することが要求されていることも知っている。

また、3番目の引数にboolが渡された場合、コードサイズを最適化する呼び出し側では mov dl, [mem] ではなく movzx edx, [mem] その代償として、RDX の古い値への誤った依存性 (または CPU モデルによっては、その他の部分レジスタの効果) が生じますが、1 バイトを節約することができます。 あるいは、最初のアーギュメントの場合。 mov dil, byte [r10] の代わりに movzx edi, byte [r10] というのも、どちらも REX プレフィックスが必要だからです。

このため、clang は movzx eax, dilSerialize の代わりに sub eax, edi . (整数の引数については、clang はこの ABI ルールに違反し、代わりに gcc と clang の文書化されていない動作に依存して、狭い整数をゼロまたは符号拡張して 32 ビットにします。 x86-64のABIでポインタに32ビットオフセットを追加する場合、符号またはゼロ拡張は必要ですか? で、同じことをしないことに興味を持ちました。 bool .)


脚注2。 分岐した後は、ちょうど4バイトの mov -即ち、4バイト+1バイトのストアです。 長さは、ストアの幅とオフセットで暗黙的に決定されます。

一方、glibc memcpyは長さに依存したオーバーラップで2つの4バイトのロード/ストアを行うので、これは本当にbooleanの条件分岐から全体を解放することになります。 詳細は L(between_4_7): ブロック をglibcのmemcpy/memmoveに追加しました。 少なくとも、memcpyのチャンクサイズを選択するための分岐で、どちらのブール値に対しても同じように行ってください。

インライン化する場合は、2x mov -即値+α cmov と条件付きでオフセットすることもできますし、文字列データをメモリ上に残しておくこともできます。

また、Intel Ice Lake向けにチューニングした場合( Fast Short REP MOV機能で を使用すると、実際の rep movsb が最適かもしれません。 memcpy を使い始めるかもしれません。 rep movsb を搭載しているCPUでは、小さなサイズであれば、多くの分岐を省くことができます。


初期化されていない値のUBと使用法を検出するためのツール

gccやclangでは、コンパイル時に -fsanitize=undefined を追加すると、実行時に発生するUBに対して警告やエラーを出すランタイムインスツルメンテーションを追加することができます。 しかし、これはユニット化された変数を捕らえることはできない。 (なぜなら、quot;uninitialized"ビットのために型サイズを増やさないからです)。

参照 https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/

初期化されていないデータの使い方を見つけるために、clang/LLVMにはAddress SanitizerとMemory Sanitizerがあります。 https://github.com/google/sanitizers/wiki/MemorySanitizer の例を示しています。 clang -fsanitize=memory -fPIE -pie 初期化されていないメモリの読み込みを検出します。 をコンパイルするとうまくいくかもしれません。 を使用せずに そのため、変数の読み込みはすべて、asmのメモリから実際に読み込まれることになります。 この機能は -O2 ロードが最適化されない場合。 私自身は試していない。 (場合によっては、例えば配列の和をとる前にアキュムレータを初期化しない場合、clang -O3は初期化しなかったベクトルレジスタに和をとるコードを出します。 つまり、最適化によって、UBに関連するメモリ読み出しがないケースもあり得るということです。 しかし -fsanitize=memory は生成されたasmを変更するので、結果的にこれをチェックすることになるかもしれません(笑)。

初期化されていないメモリのコピーや、それを使った簡単な論理演算や算術演算を許容する。一般に、MemorySanitizerはメモリ内の未初期化データの広がりを静かに追跡し、未初期化値によってコードの分岐が行われる(または行われない)ときに警告を報告します。

MemorySanitizerは、Valgrind(Memcheckツール)に見られる機能のサブセットを実装しています。

この場合、glibcの呼び出しがあるため、動作するはずです。 memcpy を付けて length が初期化されていないメモリから計算された場合、(ライブラリ内部では) length . を使用した完全なブランチレスバージョンがインライン化されていた場合、そのバージョンでは cmov インデックスを作成し、2つのストアを作成しても、うまくいかなかったかもしれません。

Valgrindの memcheck もこの種の問題を探しますが、プログラムが単に初期化されていないデータをコピーしているだけなら、やはり文句は言いません。 しかし、quot;Conditional jump or move depends on uninitialised value(s)" の場合は検出し、未初期化データに依存する外から見える動作をキャッチしようとする、と書いてあります。

構造体はパディングを持つことができ、ワイド ベクトル ロード/ストアで構造体全体を(パディングを含めて)コピーしても、個々のメンバが 1 つずつしか書かれていなくてもエラーにならないというのが、ロードだけにフラグを立てない理由なのかもしれません。 asmレベルでは、何がパディングで何が実際の値の一部であるかという情報は失われています。