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
これはいわば「賢い」ことですが、私の質問は次のとおりです。C++ 標準では、コンパイラが bool が '0' または '1' の内部数値表現のみを持つことができると想定し、そのように使用することが許可されていますか?
それとも、これは実装定義のケースでしょうか。その場合、実装ではすべてのブール値には常に 0 または 1 のみが含まれ、その他の値は未定義の動作領域であると想定されますか?
ベストアンサー1
はい、ISO C++ では実装でこの選択を行うことができます (ただし必須ではありません)。
しかし、ISO C++ では、プログラムが UB に遭遇した場合、たとえばエラーを見つけるのに役立つように、コンパイラが意図的にクラッシュするコード (たとえば、不正な命令で) を生成することを許可していることにも注意してください。 (または、DeathStation 9000 であるため。厳密に準拠しているだけでは、C++ 実装が実際の目的に役立つには不十分です)。したがって、ISO C++ では、同様のコードで初期化されていない を読み取る場合でも、コンパイラがクラッシュする asm (まったく異なる理由) を作成することを許可しますuint32_t
。これは、トラップ表現のない固定レイアウト型である必要があります。 (C には C++ とは異なるルールがあることに注意してください。初期化されていない変数Cでは不定値を持つこれは罠の表現かもしれないが、読むこと自体がC++で完全にUB_Bool
C++ と同じクラッシュ動作を許可する可能性のあるC11 の追加ルールがあるかどうかはわかりません。
これは実際の実装がどのように機能するかについての興味深い質問ですが、答えが異なっていたとしても、現代の C++ はアセンブリ言語の移植可能なバージョンではないため、コードは依然として安全ではないことに注意してください。
コンパイルしているのはx86-64 システム V ABIは、レジスタ内の関数引数としての が、bool
false=0
true=1
レジスタ1の下位 8 ビットのビットパターンと によって表されることを指定します。メモリ内では、bool
は 1 バイト型であり、これも 0 または 1 の整数値を持つ必要があります。
(ABI とは、同じプラットフォームのコンパイラが合意する実装の選択肢のセットであり、型のサイズ、構造体のレイアウト規則、呼び出し規約など、互いの関数を呼び出すコードを作成できます。ISO C++ 標準では、ABI に違反するオブジェクト表現は、CPU 自体がバイトに対して命令を実行するときに直接トラップしないにもかかわらず、トラップ表現と呼ばれます。違反したソフトウェアの前提により、後で障害が発生するだけです。ISO C17、6.2.6.1 #5 では、「特定のオブジェクト表現は、オブジェクト型の値を表す必要はありません。オブジェクトの格納された値がそのような表現を持ち、文字型を持たない左辺値式によって読み取られた場合、動作は未定義です...」と述べており、これはトラップ表現と呼ばれています。ISO C++ に同じ言語があるかどうかはわかりません。)
ISO C++ では指定されていませんが、この ABI の決定は、bool->int 変換を安価にする (ゼロ拡張のみ) ため広く普及しています。任意のアーキテクチャ (x86 だけでなく) で、コンパイラが に対して 0 または 1 を想定できない ABI は知りません。これにより、下位ビットを反転するなどbool
の最適化が可能になります。!mybool
xor eax,1
単一のCPU命令でビット/整数/ブール値を0と1の間で反転できるコードまたは、型a&&b
に対してビットANDにコンパイルするbool
。一部のコンパイラは実際にこれを利用してコンパイラではブール値は 8 ビットとして扱われます。それらの操作は非効率的ですか?。
一般に、as-if ルールにより、コンパイラは、コンパイル対象のターゲット プラットフォームで当てはまるものを利用できます。最終結果は、C++ ソースと同じ外部から見える動作を実装する実行可能コードになるためです。(未定義の動作によって、実際に「外部から見える」ものに対して課されるすべての制限が適用されます。デバッガーではなく、適切に形成された/合法的な C++ プログラム内の別のスレッドから見えるもの)
コンパイラは、コード生成で ABI 保証を最大限に活用し、strlen(whichString)
に最適化されるようなコードを作成することが確実に許可されています
5U - boolValue
。 (ちなみに、この最適化は賢いやり方ですが、即時データ2memcpy
の格納場所としての分岐やインライン化に比べると近視眼的かもしれません。)
または、コンパイラはポインターのテーブルを作成し、 の整数値でインデックス付けすることもできます (この場合bool
も、 は 0 または 1 であると想定します)。この可能性は@Barmarの回答が示唆したものです。
__attribute((noinline))
最適化が有効になっているコンストラクタにより、clang は として使用するためにスタックからバイトをロードするだけになりました。これにより、のuninitializedBool
オブジェクト用のスペースが作成されました(これは よりも小さく、さまざまな理由から とほぼ同じくらい効率的です)。そのため、 に入るときに AL にあったゴミは、に使用される値です。これが、実際には ではない値を取得したのはこのためです。main
push rax
sub rsp, 8
main
uninitializedBool
0
5U - random garbage
大きな符号なし値に簡単にラップされ、memcpy がマップされていないメモリに入る可能性があります。宛先はスタックではなく静的ストレージにあるため、戻りアドレスなどを上書きすることはありません。
他の実装では、異なる選択を行うことができます。たとえば、false=0
と ですtrue=any non-zero value
。その場合、clang はおそらく、この特定の UB インスタンスでクラッシュするコードを作成しないでしょう。 (ただし、必要に応じて許可されます。)に対して x86-64 が行う以外のことを選択する実装は知りませんがbool
、C++ 標準では、現在の CPU のようなハードウェアでは誰も行わない、または行おうとも思わない多くのことが許可されています。
ISO C++ では、 のオブジェクト表現を調べたり変更したりした場合に何が表示されるかは未指定のままです。bool
(たとえば、を に変換するmemcpy
ことによって、何でもエイリアスできるため、これを行うことができます。また、にはパディング ビットがないことが保証されているため、C++ 標準では、UB なしでオブジェクト表現を 16 進ダンプすることが正式に許可されています。オブジェクト表現をコピーするためのポインター キャストは、もちろん を割り当てることとは異なるため、0 または 1 へのブール化は行われず、生のオブジェクト表現が得られます。)bool
unsigned char
char*
unsigned char
char foo = my_bool
を使用すると、この実行パスの UB をコンパイラから部分的に「隠蔽」できますnoinline
。インライン化されない場合でも、プロシージャ間の最適化によって、別の関数の定義に依存する関数のバージョンが作成されることがあります。(まず、clang は実行可能ファイルを作成するのであって、シンボルの介入が発生する可能性がある Unix 共有ライブラリを作成するのではありません。次に、定義は定義内にあるclass{}
ため、すべての翻訳単位は同じ定義を持つ必要があります。inline
キーワードの場合と同様です。)
したがって、の先頭から始まる実行パスは、必然的に未定義の動作に遭遇するため、コンパイラはの定義としてret
またはud2
main
main
(不正な命令) だけを出力する可能性があります。 (コンパイラは、非インライン コンストラクターを介してパスをたどることを決定した場合、コンパイル時にこれを検出できます。)
UB に遭遇したプログラムは、その存在全体にわたって完全に未定義です。しかし、if()
実際には実行されない関数または分岐内の UB は、プログラムの残りの部分を破損しません。実際には、これは、ret
コンパイル時に UB を含むか UB につながることが証明できる基本ブロック全体に対して、コンパイラが不正な命令または を発行するか、何も発行せずに次のブロック/関数に進むかを決定できることを意味します。
GCC と Clang は実際には、意味をなさない実行パスのコード生成を試みることさえなく、UB で出力することがありますud2
。または、関数以外の終了時に落ちるような場合void
、gcc はret
命令を省略することがあります。「関数は RAX 内のゴミをそのまま返す」と考えていたなら、それは大きな間違いです。最新の C++ コンパイラは、もはやこの言語をポータブルなアセンブリ言語として扱いません。プログラムは、関数のスタンドアロンの非インライン バージョンが asm でどのように見えるかについて想定することなく、実際に有効な C++ である必要があります。
もう一つの面白い例はAMD64 で mmap されたメモリへの非整列アクセスがセグメント違反になることがあるのはなぜですか?x86 は、整列されていない整数ではエラーになりませんよね? では、整列されていない整数がなぜuint16_t*
問題になるのでしょうか? なぜならalignof(uint16_t) == 2
、 であり、その仮定に違反すると、SSE2 で自動ベクトル化するときにセグメント違反が発生するからです。
参照 すべての C プログラマーが未定義の動作について知っておくべきこと #1/3、clang 開発者による記事。
重要なポイント: コンパイラがコンパイル時に UB に気付いた場合、任意のビット パターンが有効なオブジェクト表現である ABI をターゲットにしている場合でも、UB を引き起こすコードのパスが「中断」される(予期しない 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
、常に負であるということだけが最適化されますtmp
(この 2 の補数ターゲットではINT_MIN
+a=INT_MAX
が負であり、a
それよりも大きくなることはできないため)。
そのため、gcc/clang は現在、計算の入力の範囲情報を導出するためにバックトラックせず、符号付きオーバーフローがないという仮定に基づく結果のみを導出します。ゴッドボルトの例これは、ユーザーフレンドリーさという名目で意図的に最適化が「省略」されているのか、それとも何か他の理由があるのかはわかりません。
また、実装 (つまりコンパイラ) では、ISO C++ が undefined のままにする動作を定義できることにも注意してください。たとえば、Intel の組み込み関数 (手動 SIMD ベクトル化など) をサポートするすべてのコンパイラでは、アラインメントがずれたポインタの形成を許可する必要があります。これは、デリファレンスしない_mm_add_ps(__m128, __m128)
場合でも C++ では UB になります。は、またはではなく、アラインメントがずれた引数を取ることで、アラインメントされていないロードを実行します。__m128i _mm_loadu_si128(const __m128i *)
__m128i*
void*
char*
ハードウェア SIMD ベクトル ポインターと対応する型の間の `reinterpret_cast` は未定義の動作ですか?
-fwrapv
GNU C/C++では、通常の符号付きオーバーフロー UB ルールとは別に、負の符号付き数値 ( がなくても) を左シフトする動作も定義しています。(これはISO C++のUBです符号付き数値の右シフトは実装定義(論理的か算術的か)であるが、良質な実装では算術右シフトを持つハードウェアでは算術を選択するが、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, dil
を出力します。(整数引数の場合、clang はこの ABI ルールに違反し、代わりに gcc と clang の文書化されていない動作に依存して、狭い整数を 32 ビットにゼロ拡張または符号拡張します。Serialize
sub eax, edi
x86-64 ABI のポインターに 32 ビット オフセットを追加する場合、符号またはゼロ拡張は必要ですか?だから、 では同じことが行われないことに興味がありましたbool
。
脚注 2:mov
分岐後は、4 バイトの -immediate、または 4 バイト + 1 バイトのストアになります。長さは、ストアの幅 + オフセットに暗黙的に含まれています。
一方、glibc memcpyは長さに応じて重複する2つの4バイトのロード/ストアを実行するため、ブール値の条件分岐がまったく発生しなくなります。L(between_4_7):
ブロックglibc の memcpy/memmove で。または、少なくとも、チャンク サイズを選択するために、memcpy の分岐でどちらのブール値でも同じようにします。
インライン化する場合は、2x mov
-immediate +cmov
と条件付きオフセットを使用することも、文字列データをメモリ内に残しておくこともできます。
あるいは、Intel Ice Lake向けにチューニングする場合(高速ショートREP MOV機能付き) の場合、実際のサイズrep movsb
は最適になる可能性があります。 glibc は、その機能を備えた CPU で小さなサイズをmemcpy
使用するようになりrep movsb
、分岐を大幅に節約できます。
UB と初期化されていない値の使用を検出するためのツール
gcc と clang では、実行時に発生する UB に対して警告またはエラーを出力する実行時インストルメンテーションを追加するためにコンパイルできます-fsanitize=undefined
。ただし、初期化されていない変数はキャッチされません (「初期化されていない」ビットのためのスペースを確保するために型のサイズが拡大されないため)。
見る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初期化されていないメモリ読み取りを検出する例を示します。最適化なしでコンパイルすると、変数のすべての読み取りが実際には asm のメモリからロードされるので、clang -fsanitize=memory -fPIE -pie
最も効果的です。ロードが最適化されない場合に使用されていることを示しています。自分では試していません。(場合によっては、たとえば配列を合計する前にアキュムレータを初期化しない場合、clang -O3 は初期化されていないベクトル レジスタに合計するコードを生成します。したがって、最適化により、UB に関連付けられたメモリ読み取りがないケースが発生する可能性があります。ただし、生成された asm が変更され、これがチェックされる可能性があります。)-O2
-fsanitize=memory
初期化されていないメモリのコピー、およびそれを使用した単純なロジックと算術演算を許容します。通常、MemorySanitizer はメモリ内の初期化されていないデータの拡散を静かに追跡し、初期化されていない値に応じてコード分岐が行われる (または行われない) 場合に警告を報告します。
MemorySanitizer は、Valgrind (Memcheck ツール) にある機能のサブセットを実装します。
この場合は、初期化されていないメモリから計算された を使用した glibc の呼び出しmemcpy
によりlength
(ライブラリ内で) に基づく分岐が発生するため、動作するはずです。 、インデックス、および 2 つのストアlength
のみを使用する完全に分岐のないバージョンをインライン化していた場合はcmov
、動作しなかった可能性があります。
ヴァルグリンドのmemcheck
この種の問題も探しますが、プログラムが単に初期化されていないデータをコピーするだけの場合は、やはり文句を言いません。しかし、初期化されていないデータに依存する外部から見える動作をキャッチするために、「条件付きジャンプまたは移動が初期化されていない値に依存する」場合は検出すると書かれています。
おそらく、ロードだけにフラグを立てないのは、構造体にパディングがある場合があり、個々のメンバーが一度に 1 つしか書き込まれていない場合でも、構造体全体 (パディングを含む) をワイド ベクター ロード/ストアでコピーするとエラーにならないためです。asm レベルでは、何がパディングで、何が実際に値の一部であるかに関する情報が失われています。