web-dev-qa-db-ja.com

Cの未定義の振る舞い。厳密なエイリアシングルール、または不適切な配置?

このプログラムの実行動作を説明することはできません:

#include <string> 
#include <cstdlib> 
#include <stdio.h>

typedef char u8;
typedef unsigned short u16;

size_t f(u8 *keyc, size_t len)
{
    u16 *key2 = (u16 *) (keyc + 1);
    size_t hash = len;
    len = len / 2;

    for (size_t i = 0; i < len; ++i)
        hash += key2[i];
    return hash;
}

int main()
{
    srand(time(NULL));
    size_t len;
    scanf("%lu", &len);
    u8 x[len];
    for (size_t i = 0; i < len; i++)
        x[i] = Rand();

    printf("out %lu\n", f(x, len));
}

したがって、gccを指定して-O3でコンパイルし、引数25で実行すると、セグメンテーション違反が発生します。最適化がなければ、正常に機能します。私はそれを分解しました:それはベクトル化されており、コンパイラはkey2配列は16バイトで整列されるため、movdqaを使用します。説明はできませんが、明らかにUBです。厳密なエイリアシングルールについては知っていますが、そうではありません(私が知る限り、厳密なエイリアシングルールはcharsでは機能しないため)。 gccがこのポインターが整列していると想定するのはなぜですか?最適化を行っても、Clangは正常に機能します。

編集

私が変更され unsigned charからcharに、constを削除しても、セグメンテーション違反が発生します。

EDIT2

このコードは適切ではないことはわかっていますが、厳密なエイリアシングルールについて知っている限り、問題なく機能するはずです。違反はどこにありますか?

14
Nikita Vorobyev

コードは確かに厳密なエイリアシングルールに違反しています。ただし、だけでなくエイリアシング違反があり、エイリアシング違反のためにクラッシュは発生しません。これは、unsigned shortポインタが正しく配置されていないために発生します。結果が適切に調整されていない場合、ポインタ変換自体も未定義です。

C11(ドラフトn1570)付録J.2

1次の状況では、動作は定義されていません。

..。

  • 2つのポインタータイプ間の変換は、誤って整列された結果を生成します(6.3.2.3)。

6.3.2.3p7 と言って

[...]結果のポインタが参照されたタイプに対して正しく整列されていない場合[68]、動作は未定義です。 [...]

unsigned shortには、実装(x86-32およびx86-64)で2のアライメント要件があり、これを使用してテストできます。

_Static_assert(_Alignof(unsigned short) == 2, "alignof(unsigned short) == 2");

ただし、u16 *key2が整列されていないアドレスを指すように強制しています。

u16 *key2 = (u16 *) (keyc + 1);  // we've already got undefined behaviour *here*!

アラインされていないアクセスはどこでもx86-32とx86-64で実際に機能することが保証されていると主張するプログラマーは無数にあり、実際には問題はありません-まあ、それらはすべて間違っています。

基本的に何が起こるかというと、コンパイラはそれに気づきます

for (size_t i = 0; i < len; ++i)
     hash += key2[i];

適切に調整されていれば、 SIMD命令 を使用してより効率的に実行できます。値はSSEレジスタに MOVDQA を使用してロードされます。これには、引数が16バイトに整列されている必要があります。

ソースオペランドまたはデスティネーションオペランドがメモリオペランドの場合、オペランドは16バイト境界に揃える必要があります。そうしないと、一般保護例外(#GP)が生成されます。

ポインターが開始時に適切に整列されていない場合、コンパイラーは、ポインターが16バイトに整列されるまで、最初の1〜7個の符号なしショートを1つずつ合計するコードを生成します。

もちろん、oddアドレスを指すポインタから始める場合、2を7回追加しなくても、16バイトにアラインされたアドレスに1が到達します。もちろん、コンパイラはこのケースを検出するコードを生成しません。「2つのポインタタイプ間の変換によって誤って整列された結果が生成された場合、動作は未定義です」-無視します 予測できない結果を伴う状況 、これは、MOVDQAへのオペランドが適切に整列されないことを意味し、プログラムをクラッシュさせます。


厳密なエイリアシングルールに違反しなくても、これが発生する可能性があることは簡単に証明できます。 2変換ユニットで構成される次のプログラムについて考えてみます(fとその呼び出し元の両方がone変換ユニットに配置されている場合、私のGCCは、私たちが- ここでパック構造を使用、およびMOVDQA)でコードを生成しません:

翻訳ユニット1

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

size_t f(uint16_t *keyc, size_t len)
{
    size_t hash = len;
    len = len / 2;

    for (size_t i = 0; i < len; ++i)
        hash += keyc[i];
    return hash;
}

翻訳ユニット2

#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include <inttypes.h>

size_t f(uint16_t *keyc, size_t len);

struct mystruct {
    uint8_t padding;
    uint16_t contents[100];
} __attribute__ ((packed));

int main(void)
{
    struct mystruct s;
    size_t len;

    srand(time(NULL));
    scanf("%zu", &len);

    char *initializer = (char *)s.contents;
    for (size_t i = 0; i < len; i++)
       initializer[i] = Rand();

    printf("out %zu\n", f(s.contents, len));
}

次に、それらをコンパイルしてリンクします。

% gcc -O3 unit1.c unit2.c
% ./a.out
25
zsh: segmentation fault (core dumped)  ./a.out

エイリアシング違反がないことに注意してください。唯一の問題は、位置合わせされていないuint16_t *keycです。

-fsanitize=undefinedを使用すると、次のエラーが生成されます。

unit1.c:10:21: runtime error: load of misaligned address 0x7ffefc2d54f1 for type 'uint16_t', which requires 2 byte alignment
0x7ffefc2d54f1: note: pointer points here
 00 00 00  01 4e 02 c4 e9 dd b9 00  83 d9 1f 35 0e 46 0f 59  85 9b a4 d7 26 95 94 06  15 bb ca b3 c7
              ^ 
34
Antti Haapala

オブジェクトへのポインタをcharへのポインタにエイリアスしてから、元のオブジェクトからすべてのバイトを繰り返すことは合法です。

Charへのポインタが実際にオブジェクトを指している場合(前の操作で取得されている場合)、元の型へのポインタに戻すことは合法であり、標準では元の値を取り戻す必要があります。

ただし、任意のポインタを文字に変換し、取得したポインタを逆参照すると、厳密なエイリアシング規則に違反し、未定義の動作が発生します。

したがって、コードでは、次の行はUBです。

const u16 *key2 = (const u16 *) (keyc + 1); 
// keyc + 1 did not originally pointed to a u16: UB
6
Serge Ballesta

@Antti Haapalaからの優れた回答に対するいくつかの詳細情報と一般的な落とし穴を提供するには:

TLDR:アラインされていないデータへのアクセスはC/C++の未定義の振る舞い(UB)です。アラインされていないデータとは、アラインメント(通常はサイズ)で均等に割り切れないアドレス(別名ポインター値)のデータです。 (擬似)コードの場合:bool isAligned(T* ptr){ return (ptr % alignof(T)) == 0; }

この問題は、ネットワーク経由で送信されたファイル形式またはデータを解析するときによく発生します。さまざまなデータ型の密集した構造体があります。例は次のようなプロトコルです:struct Packet{ uint16_t len; int32_t data[]; };(読み取り:16ビット長の後にlenに値として32ビットintを掛けたもの)。あなたは今することができます:

char* raw = receiveData();
int32_t sum = 0;
uint16_t len = *((uint16_t*)raw);
int32_t* data = (int32_t*)(raw2 + 2);
for(size_t i=0; i<len; ++i) sum += data[i];

これは機能しませんrawが整列していると仮定した場合(すべてのnに対してraw = 0として任意のサイズに整列されている0 % n == 0を設定できます)、dataはおそらく整列できません(整列==タイプサイズと仮定):lenはアドレス0にあるため、dataはアドレス2にあり2 % 4 != 0です。しかし、キャストはコンパイラーに「このデータは適切に整列されている」と伝えます(「...それ以外の場合はUBであり、UBに遭遇することはないため」)。そのため、最適化中にコンパイラは合計の計算を高速化するためにSIMD/SSE命令を使用し、整列されていないデータが与えられるとそれらはクラッシュします。
補足:整列されていないSSE命令がありますが、それらは遅く、コンパイラーは、ここでは使用されないと約束した整列を想定しているためです。

これは、@ Anti Haapalaの例で確認できます。これは、短縮してgodboltに配置し、遊んでみてください: https://godbolt.org/z/KOfi6V 。 「プログラムが返されました:255」、別名「クラッシュ」をご覧ください。

この問題は、次のような逆シリアル化ルーチンでもよく見られます。

char* raw = receiveData();
int32_t foo = readInt(raw); raw+=4;
bool foo = readBool(raw); raw+=1;
int16_t foo = readShort(raw); raw+=2;
...

read*はエンディアンを処理し、多くの場合、次のように実装されます。

int32_t readInt(char* ptr){
  int32_t result = *((int32_t*) ptr);
  #if BIG_ENDIAN
  result = byteswap(result);
  #endif
}

このコードが、異なる配置を持つ可能性のある小さな型を指すポインターを逆参照する方法に注意してください。正確な問題が発生します。

この問題は非常に一般的であるため、Boostでさえ多くのバージョンでこれに苦しんでいました。簡単なエンディアンタイプを提供するBoost.Endianがあります。 godboltのCコードは次のように簡単に書くことができます this

#include <cstdint>
#include <boost/endian/arithmetic.hpp>


__attribute__ ((noinline)) size_t f(boost::endian::little_uint16_t *keyc, size_t len)
{
    size_t hash = 0;
    for (size_t i = 0; i < len; ++i)
        hash += keyc[i];
    return hash;
}

struct mystruct {
    uint8_t padding;
    boost::endian::little_uint16_t contents[100];
};

int main(int argc, char** argv)
{
    mystruct s;
    size_t len = argc*25;

    for (size_t i = 0; i < len; i++)
       s.contents[i] = i * argc;

    return f(s.contents, len) != 300;
}

タイプlittle_uint16_tは基本的に、現在のマシンのエンディアンがuint16_tの場合、byteswapとの間でBIG_ENDIANとの間で暗黙的に変換されるいくつかの文字です。内部的には、Boost:endianで使用されるコードは次のようになっています。

class little_uint16_t{
  char buffer[2];
  uint16_t value(){
    #if IS_x86
      uint16_t value = *reinterpret_cast<uint16_t*>(buffer);
    #else
    ...
    #endif
    #if BIG_ENDIAN
    swapbytes(value);
    #endif
    return value;
};

X86アーキテクチャでは、アラインされていないアクセスが可能であるという知識を使用しました。アラインされていないアドレスからのロードは少し遅くなりましたが、アセンブラレベルでもアラインされたアドレスからのロードと同じでした。

ただし、「可能」とは有効という意味ではありません。コンパイラが「標準」ロードをSSE命令に置き換えた場合、 godbolt に見られるように、これは失敗します。これらのSSE命令は、同じ操作でデータの大きなチャンクを処理するときに使用されるため、これは長い間見過ごされていました。この例で行ったことである値の配列を追加します。これは Boost 1.69 で修正されました。memcopyを使用すると、x86で整列および非整列データをサポートするASMの「標準」ロード命令に変換できるため、キャストバージョン。ただし、さらにチェックしない限り、整列されたSSE命令に変換することはできません。

Takeaway:キャストでショートカットを使用しないでください。特に小さいタイプからキャストする場合は、すべてのキャストを疑って、配置が間違っていないことを確認するか、安全なmemcpyを使用してください。

2
Flamefire

コードが文字タイプの配列が整列されていることを確認するために何かをしない限り、それが整列されることを特に期待するべきではありません。

アラインメントが処理され、コードがそのアドレスを1回取得し、それを別のタイプのポインターに変換し、後者のポインターから派生していない方法でストレージにアクセスしない場合、低レベルプログラミング用に設計された実装には特別なものはありません。ストレージを抽象バッファーとして扱うのが難しい。そのような処理は難しくなく、ある種の低レベルプログラミング(たとえば、malloc()が利用できないコンテキストでのメモリプールの実装)に必要になるため、そのような構造をサポートしない実装は適切であると主張すべきではありません。低レベルプログラミング用。

したがって、低レベルのプログラミング用に設計された実装では、説明したような構造により、適切に配置された配列を型なしストレージとして扱うことができます。残念ながら、そのような実装を認識する簡単な方法はありません。主に低レベルのプログラミング用に設計された実装は、そのような実装が環境に特徴的な方法で動作することが明らかであると著者が考えるすべてのケースをリストできないことが多いためです(その結果、正確にそれを行う場合)、他の目的に焦点を合わせた設計の人は、その目的に対して不適切に動作する場合でも、低レベルのプログラミングに適していると主張する場合があります。

標準の作成者は、Cが移植性のないプログラムに役立つ言語であることを認識しており、「高水準アセンブラ」としての使用を排除したくないと具体的に述べています。しかし、彼らは、さまざまな目的を目的とした実装が、標準で要求されているかどうかに関係なく、それらの目的を容易にするための一般的な拡張機能をサポートすることを期待していたため、標準でそのようなことに対処する必要はありませんでした。ただし、そのような意図は標準ではなく理論的根拠に追いやられていたため、一部のコンパイラ作成者は、標準を、プログラマが実装に期待するすべての完全な説明と見なし、静的な使用などの低レベルの概念をサポートしない場合があります。 -または、効果的に型指定されていないバッファとしての自動期間オブジェクト。

0
supercat