Jaka jest ścisła zasada aliasingu?

803

Pytając o typowe, niezdefiniowane zachowanie w C , ludzie czasami odnoszą się do ścisłej zasady aliasingu.
O czym oni rozmawiają?

Benoit
źródło
12
@Ben Voigt: Zasady aliasingu są różne dla c ++ i c. Dlaczego to pytanie jest oznaczone tagami ci c++faq.
MikeMB
6
@MikeMB: Jeśli sprawdzisz historię, zobaczysz, że zachowałem tagi tak, jak były pierwotnie, pomimo próby innych ekspertów, by odmienić pytanie od istniejących odpowiedzi. Poza tym zależność od języka i wersji jest bardzo ważną częścią odpowiedzi na pytanie „Jaka jest ścisła zasada aliasingu?” a znajomość różnic jest ważna dla zespołów migrujących kod między C i C ++ lub piszących makra do użycia w obu.
Ben Voigt
6
@Ben Voigt: Właściwie - o ile mogę powiedzieć - większość odpowiedzi dotyczy tylko c, a nie c ++, również sformułowanie pytania wskazuje na skupienie się na regułach C (lub OP po prostu nie wiedział, że jest różnica ). W przeważającej części reguły i ogólna Idea są oczywiście takie same, ale zwłaszcza w przypadku związków odpowiedzi nie dotyczą c ++. Trochę się martwię, że niektórzy programiści c ++ będą szukać ścisłej reguły aliasingu i po prostu założą, że wszystko, co tu podano, dotyczy również c ++.
MikeMB
Z drugiej strony zgadzam się, że problematyczne jest zmienianie pytania po opublikowaniu wielu dobrych odpowiedzi, a problem jest i tak niewielki.
MikeMB
1
@MikeMB: Myślę, że zobaczysz, że C skupia się na zaakceptowanej odpowiedzi, co czyni ją niepoprawną dla C ++, została edytowana przez firmę zewnętrzną. Ta część prawdopodobnie powinna zostać ponownie zmieniona.
Ben Voigt

Odpowiedzi:

562

Typową sytuacją, w której napotykasz ścisłe problemy z aliasingiem, jest nakładanie struktury (takiej jak msg urządzenia / sieci) na bufor wielkości słowa twojego systemu (jak wskaźnik do uint32_ts lub uint16_ts). Kiedy nakładasz strukturę na taki bufor lub bufor na taką strukturę za pomocą rzutowania wskaźnika, możesz łatwo złamać surowe reguły aliasingu.

Więc w tego rodzaju konfiguracji, jeśli chcę wysłać wiadomość do czegoś, musiałbym mieć dwa niekompatybilne wskaźniki wskazujące na ten sam fragment pamięci. Mógłbym wtedy naiwnie kodować coś takiego (w systemie z sizeof(int) == 2):

typedef struct Msg
{
    unsigned int a;
    unsigned int b;
} Msg;

void SendWord(uint32_t);

int main(void)
{
    // Get a 32-bit buffer from the system
    uint32_t* buff = malloc(sizeof(Msg));

    // Alias that buffer through message
    Msg* msg = (Msg*)(buff);

    // Send a bunch of messages    
    for (int i =0; i < 10; ++i)
    {
        msg->a = i;
        msg->b = i+1;
        SendWord(buff[0]);
        SendWord(buff[1]);   
    }
}

Surowa zasada aliasingu powoduje, że ta konfiguracja jest nielegalna: dereferencja wskaźnika, który aliuje obiekt, który nie jest zgodnego typu lub jeden z innych typów dozwolonych w C 2011 6.5 akapit 7 1 jest zachowaniem niezdefiniowanym. Niestety, nadal możesz kodować w ten sposób, być może otrzymujesz ostrzeżenia, kompilujesz się dobrze, tylko po to, by mieć dziwne nieoczekiwane zachowanie po uruchomieniu kodu.

(GCC wydaje się nieco niespójna w swojej zdolności do udzielania ostrzeżeń aliasingowych, czasami dając nam przyjazne ostrzeżenie, a czasem nie.)

Aby zobaczyć, dlaczego to zachowanie jest niezdefiniowane, musimy pomyśleć o tym, co ścisła reguła aliasingu kupuje kompilator. Zasadniczo dzięki tej regule nie trzeba myśleć o wstawianiu instrukcji, aby odświeżyć zawartość buffkażdego uruchomienia pętli. Zamiast tego, podczas optymalizacji, z pewnymi irytująco niewymuszonymi założeniami dotyczącymi aliasingu, może pominąć te instrukcje, załadować buff[0]i buff[1] do rejestrów procesora jeden raz przed uruchomieniem pętli i przyspieszyć jej ciało. Przed wprowadzeniem ścisłego aliasingu kompilator musiał żyć w stanie paranoi, w której zawartość buffmoże się zmienić w dowolnym momencie z dowolnego miejsca przez kogokolwiek. Aby uzyskać dodatkową przewagę wydajności i przy założeniu, że większość ludzi nie pisze wskaźników, wprowadzono surową zasadę aliasingu.

Pamiętaj, że jeśli uważasz, że ten przykład jest wymyślony, może się to zdarzyć nawet wtedy, gdy przekażesz bufor innej funkcji wykonującej wysyłanie za ciebie, jeśli zamiast tego masz.

void SendMessage(uint32_t* buff, size_t size32)
{
    for (int i = 0; i < size32; ++i) 
    {
        SendWord(buff[i]);
    }
}

I przepisałem wcześniejszą pętlę, aby skorzystać z tej wygodnej funkcji

for (int i = 0; i < 10; ++i)
{
    msg->a = i;
    msg->b = i+1;
    SendMessage(buff, 2);
}

Kompilator może, ale nie musi, być wystarczająco inteligentny, aby spróbować wstawić SendMessage i może, ale nie musi, ponownie ładować lub nie ładować buffa. Jeśli SendMessagejest częścią innego API skompilowanego osobno, prawdopodobnie zawiera instrukcje ładowania zawartości buffa. Z drugiej strony, być może jesteś w C ++ i jest to jakaś implementacja zawierająca tylko szablony, które według kompilatora mogą być wbudowane. A może jest to po prostu coś, co napisałeś w pliku .c dla Twojej wygody. W każdym razie nadal może wystąpić niezdefiniowane zachowanie. Nawet jeśli wiemy, co dzieje się pod maską, nadal stanowi to naruszenie zasady, więc nie można zagwarantować żadnego dobrze zdefiniowanego zachowania. Zatem samo zawinięcie w funkcję, która bierze nasz bufor rozdzielany słowami, niekoniecznie pomaga.

Jak mam to obejść?

  • Użyj związku. Większość kompilatorów obsługuje to bez narzekania na ścisłe aliasing. Jest to dozwolone w C99 i wyraźnie dozwolone w C11.

    union {
        Msg msg;
        unsigned int asBuffer[sizeof(Msg)/sizeof(unsigned int)];
    };
  • Możesz wyłączyć ścisłe aliasing w swoim kompilatorze ( f [no-] ścisłe aliasing w gcc))

  • Możesz użyć char*do aliasingu zamiast słowa systemu. Reguły dopuszczają wyjątek dla char*(w tym signed chari unsigned char). Zawsze zakłada się, że char*aliasy innych typów. Jednak to nie zadziała w drugą stronę: nie ma założenia, że ​​twoja struktura aliasuje bufor znaków.

Początkujący strzeż się

To tylko jedno potencjalne pole minowe, gdy nakładają się na siebie dwa typy. Powinieneś także dowiedzieć się o endianness , dopasowywaniu słów i jak radzić sobie z problemami z wyrównaniem poprzez prawidłowe pakowanie struktur .

Notatka

1 Rodzaje, do których dostęp C 2011 6.5 7 umożliwia dostęp do wartości, to:

  • typ zgodny z efektywnym typem obiektu,
  • kwalifikowana wersja typu zgodna z efektywnym typem obiektu,
  • typ, który jest typem podpisanym lub niepodpisanym odpowiadającym efektywnemu typowi obiektu,
  • typ, który jest typem podpisanym lub niepodpisanym odpowiadającym kwalifikowanej wersji efektywnego typu obiektu,
  • typ agregatu lub związku, który obejmuje jeden z wyżej wymienionych typów wśród jego członków (w tym, rekurencyjnie, członka podagregatu lub zawartego związku), lub
  • typ postaci.
Doug T.
źródło
16
Wydaje mi się, że nadchodzę po bitwie. Czy zamiast tego unsigned char*można użyć daleko char*? Zwykle używam unsigned charraczej niż charjako podstawowy typ, byteponieważ moje bajty nie są podpisane i nie chcę dziwności podpisanych zachowań (zwłaszcza wrt do przepełnienia)
Matthieu M.,
30
@Matthieu: Signedness nie ma znaczenia dla reguł aliasów, więc używanie unsigned char *jest w porządku.
Thomas Eding
22
Czy nie jest niezdefiniowane zachowanie czytać od członka związku innego niż ten, do którego napisano?
R. Martinho Fernandes,
23
Bollocks, ta odpowiedź jest całkowicie wstecz . Przykład, który pokazuje jako nielegalny, jest w rzeczywistości legalny, a przykład, który pokazuje jako nielegalny, jest w rzeczywistości nielegalny.
R. Martinho Fernandes,
7
Twoje uint32_t* buff = malloc(sizeof(Msg));i kolejne unsigned int asBuffer[sizeof(Msg)];deklaracje buforów związków będą miały różne rozmiary i żadna z nich nie jest poprawna. mallocRozmowa jest poleganie na wyrównanie 4 bajtowy pod maską (nie rób tego) i Unia będzie 4 razy większa niż to musi być ... Rozumiem, że to dla jasności, ale robaki mnie żaden-the- mniej ...
nonsensickle
233

Najlepsze wytłumaczenie, jakie znalazłem, to Mike Acton, Understanding Strict Aliasing . Koncentruje się trochę na rozwoju PS3, ale to po prostu GCC.

Z artykułu:

„Ścisłe aliasing jest założeniem przyjętym przez kompilator C (lub C ++), że dereferencje wskaźników do obiektów różnego typu nigdy nie będą odnosić się do tej samej lokalizacji pamięci (tj. Aliasu).”

Zasadniczo, jeśli masz int*wskazanie na pewną pamięć zawierającą an, inta następnie wskazujesz float*na tę pamięć i używasz jej jako floatzasady łamania reguły. Jeśli Twój kod tego nie przestrzega, optymalizator kompilatora najprawdopodobniej złamie kod.

Wyjątkiem od reguły jest a char*, który może wskazywać na dowolny typ.

Niall
źródło
6
Jaki jest zatem kanoniczny sposób legalnego używania tej samej pamięci ze zmiennymi 2 różnych typów? czy wszyscy po prostu kopiują?
jiggunjer
4
Strona Mike Acton jest wadliwa. Część „Casting through a union (2)” jest przynajmniej całkowicie błędna; kod, który, jak twierdzi, jest legalny, nie jest.
davmac
11
@davmac: Autorzy C89 nigdy nie zamierzali zmusić programistów do skakania przez obręcze. Uważam za całkowicie dziwne pojęcie, że reguła istniejąca wyłącznie w celu optymalizacji powinna być interpretowana w taki sposób, aby wymagać od programistów pisania kodu, który nadmiarowo kopiuje dane w nadziei, że optymalizator usunie zbędny kod.
supercat
1
@curiousguy: „Can't have unions”? Po pierwsze, pierwotny / główny cel związków nie jest w żaden sposób związany z aliasingiem. Po drugie, współczesna specyfikacja języka wyraźnie zezwala na używanie związków do aliasingu. Kompilator musi zauważyć, że używany jest związek i potraktować sytuację w specjalny sposób.
AnT
5
@curiousguy: False. Po pierwsze, pierwotna koncepcja koncepcyjna związków była taka, że ​​w danym momencie jest tylko jeden obiekt członkowski „aktywny” w danym obiekcie związku, podczas gdy inne po prostu nie istnieją. Tak więc, jak się wydaje, nie ma „różnych obiektów pod tym samym adresem”. Po drugie, aliasy naruszeń, o których wszyscy mówią, dotyczą dostępu do jednego obiektu jako innego obiektu, a nie tylko posiadania dwóch obiektów o tym samym adresie. Dopóki nie ma dostępu do wykreślania typów , nie ma problemu. To był oryginalny pomysł. Później dozwolone było pisanie czcionek przez związki.
AnT
133

Jest to reguła ścisłego aliasingu, znaleziona w sekcji 3.10 standardu C ++ 03 (inne odpowiedzi zawierają dobre wyjaśnienie, ale żadna nie podała samej reguły):

Jeśli program próbuje uzyskać dostęp do przechowywanej wartości obiektu przez wartość inną niż jeden z następujących typów, zachowanie jest niezdefiniowane:

  • dynamiczny typ obiektu,
  • wersja dynamiczna typu obiektu kwalifikowana do cv,
  • typ, który jest typem podpisanym lub niepodpisanym odpowiadającym typowi dynamicznemu obiektu,
  • typ, który jest typem podpisanym lub niepodpisanym, odpowiadającym kwalifikowanej do cv wersji typu dynamicznego obiektu,
  • typ agregatu lub związku, który obejmuje jeden z wyżej wymienionych typów wśród jego członków (w tym, rekurencyjnie, członka podagregatu lub zawartego związku),
  • typ, który jest (prawdopodobnie kwalifikowaną do cv) typem klasy bazowej typu dynamicznego obiektu,
  • a charlub unsigned charwpisz.

C ++ 11 i C ++ 14 (podkreślone zmiany):

Jeśli program próbuje uzyskać dostęp do przechowywanej wartości obiektu za pośrednictwem wartości innej niż jeden z następujących typów, zachowanie jest niezdefiniowane:

  • dynamiczny typ obiektu,
  • wersja dynamiczna typu obiektu kwalifikowana do cv,
  • typ podobny (zdefiniowany w 4.4) do dynamicznego typu obiektu,
  • typ, który jest typem podpisanym lub niepodpisanym odpowiadającym typowi dynamicznemu obiektu,
  • typ, który jest typem podpisanym lub niepodpisanym, odpowiadającym kwalifikowanej do cv wersji typu dynamicznego obiektu,
  • typ agregacyjny lub łączący, który obejmuje jeden z wyżej wymienionych typów wśród jego elementów lub niestatycznych elementów danych (w tym rekurencyjnie element lub niestatyczny element danych subagregatu lub zawartego związku),
  • typ, który jest (prawdopodobnie kwalifikowaną do cv) typem klasy bazowej typu dynamicznego obiektu,
  • a charlub unsigned charwpisz.

Dwie zmiany były niewielkie: glvalue zamiast lwartość i wyjaśnienie sprawy agregat / związkowej.

Trzecia zmiana stanowi silniejszą gwarancję (rozluźnia zasadę silnego aliasingu): Nowa koncepcja podobnych typów, które są teraz bezpieczne dla aliasu.


Również sformułowanie C (C99; ISO / IEC 9899: 1999 6.5 / 7; dokładnie to samo sformułowanie zastosowano w ISO / IEC 9899: 2011 § 6.5 ¶7):

Dostęp do przechowywanej wartości obiektu może mieć tylko wyrażenie wartości, które ma jeden z następujących typów 73) lub 88) :

  • typ zgodny z efektywnym typem obiektu,
  • zakwalifikowana wersja typu zgodnego ze skutecznym typem obiektu,
  • typ, który jest typem podpisanym lub niepodpisanym odpowiadającym efektywnemu typowi obiektu,
  • typ, który jest typem podpisanym lub niepodpisanym odpowiadającym zakwalifikowanej wersji efektywnego typu obiektu,
  • typ agregatu lub związku, który obejmuje jeden z wyżej wymienionych typów wśród jego członków (w tym, rekurencyjnie, członka podagregatu lub zawartego związku), lub
  • typ postaci.

73) lub 88) Celem tej listy jest określenie tych okoliczności, w których obiekt może być lub nie być aliasowany.

Ben Voigt
źródło
7
Ben, ponieważ ludzie są tu często kierowani, pozwoliłem sobie dodać odniesienie do standardu C, dla kompletności.
Kos,
1
Spójrz na C89 Rationale cs.technion.ac.il/users/yechiel/CS/C++draft/rationale.pdf sekcja 3.3, która mówi o tym.
phorgan1
2
Jeśli ktoś ma wartość typu struktury, pobiera adres elementu i przekazuje go do funkcji, która używa go jako wskaźnika do typu elementu, byłoby to uważane za dostęp do obiektu typu elementu (legalne), lub obiekt typu konstrukcji (zabroniony)? Dużo kodu zakłada, że to legalne struktury dostępu w taki sposób, i myślę, że wiele osób będzie skrzek w regule, która została rozumianej jako zakaz takich działań, ale jest jasne, jakie są dokładne zasady. Ponadto związki i struktury są traktowane tak samo, ale rozsądne zasady dla każdego powinny być inne.
supercat
2
@ superupat: Sposób, w jaki brzmi reguła dla struktur, faktyczny dostęp jest zawsze do typu pierwotnego. Następnie dostęp przez odwołanie do pierwotnego typu jest legalny, ponieważ typy są zgodne, a dostęp poprzez odwołanie do zawierającego typ struktury jest legalny, ponieważ jest to specjalnie dozwolone.
Ben Voigt
2
@BenVoigt: Nie sądzę, aby wspólna sekwencja początkowa działała, chyba że dostęp jest wykonywany przez związek. Zobacz goo.gl/HGOyoK, aby zobaczyć, co robi gcc. Jeśli uzyskiwanie dostępu do wartości typu unii za pomocą wartości typu członka (nieużywanie operatora dostępu do związku) było legalne, to wow(&u->s1,&u->s2)musiałoby być legalne, nawet gdy wskaźnik jest modyfikowany u, a to negowałoby większość optymalizacji, które zasada aliasingu została zaprojektowana w celu ułatwienia.
supercat
80

Uwaga

Jest to fragment mojej „Co to jest zasada ścisłego aliasowania i dlaczego nas to obchodzi?” napisać

Co to jest ścisłe aliasing?

W C i C ++ aliasing ma związek z typami wyrażeń, przez które mamy dostęp do przechowywanych wartości. Zarówno w C, jak i C ++ standard określa, które typy wyrażeń mogą być używane do aliasu, które typy. Kompilator i optymalizator mogą założyć, że ściśle przestrzegamy zasad aliasingu, stąd termin ścisła zasada aliasingu . Jeśli spróbujemy uzyskać dostęp do wartości przy użyciu niedozwolonego typu, zostanie to zaklasyfikowane jako zachowanie niezdefiniowane ( UB ). Po niezdefiniowanym zachowaniu wszystkie zakłady są wyłączone, wyniki naszego programu nie są już wiarygodne.

Niestety przy surowych naruszeniach aliasingu często uzyskujemy oczekiwane wyniki, pozostawiając możliwość, że przyszła wersja kompilatora z nową optymalizacją zepsuje kod, który naszym zdaniem był prawidłowy. Jest to niepożądane i warto poznać ścisłe zasady aliasingu i unikać ich łamania.

Aby dowiedzieć się więcej o tym, dlaczego nas to obchodzi, omówimy problemy, które pojawiają się w przypadku naruszenia ścisłych reguł aliasingu, pisania na klawiaturze, ponieważ popularne techniki stosowane przy pisaniu na klawiaturze często naruszają ścisłe reguły aliasingu i sposób prawidłowego pisania.

Wstępne przykłady

Spójrzmy na kilka przykładów, a następnie możemy porozmawiać o tym, co mówią standardy, przeanalizować dalsze przykłady, a następnie zobaczyć, jak uniknąć ścisłego aliasingu i złapać naruszenia, które przegapiliśmy. Oto przykład, który nie powinien dziwić ( przykład na żywo ):

int x = 10;
int *ip = &x;

std::cout << *ip << "\n";
*ip = 12;
std::cout << x << "\n";

Mamy int * wskazujące na pamięć zajmowaną przez int i jest to prawidłowe aliasing. Optymalizator musi założyć, że przypisania przez ip mogą zaktualizować wartość zajmowaną przez x .

Następny przykład pokazuje aliasing prowadzący do nieokreślonego zachowania ( przykład na żywo ):

int foo( float *f, int *i ) { 
    *i = 1;               
    *f = 0.f;            

   return *i;
}

int main() {
    int x = 0;

    std::cout << x << "\n";   // Expect 0
    x = foo(reinterpret_cast<float*>(&x), &x);
    std::cout << x << "\n";   // Expect 0?
}

W funkcji foo bierzemy int * i liczbę zmiennoprzecinkową * , w tym przykładzie wywołujemy foo i ustawiamy oba parametry tak, aby wskazywały tę samą lokalizację pamięci, która w tym przykładzie zawiera int . Uwaga: reinterpret_cast mówi kompilatorowi, aby traktował wyrażenie tak, jakby miał typ określony przez parametr szablonu. W tym przypadku mówimy, aby traktował wyrażenie & x tak, jakby miał typ float * . Możemy naiwnie oczekiwać, że wynik drugiego cout wyniesie 0, ale przy włączonej optymalizacji przy użyciu -O2 zarówno gcc, jak i clang dają następujący wynik:

0
1

Tego nie można się spodziewać, ale jest to całkowicie poprawne, ponieważ wywołaliśmy niezdefiniowane zachowanie. Element zmiennoprzecinkowy nie może poprawnie aliasu int obiektu . Dlatego optymalizator może przyjąć stałą 1 przechowywaną, gdy dereferencje i będą wartością zwracaną, ponieważ przechowywanie przez f nie może poprawnie wpływać na obiekt int . Podłączenie kodu w Eksploratorze kompilatorów pokazuje, że dokładnie tak się dzieje ( przykład na żywo ):

foo(float*, int*): # @foo(float*, int*)
mov dword ptr [rsi], 1  
mov dword ptr [rdi], 0
mov eax, 1                       
ret

Optymalizator używa analizę aliasów typu (TBAA) zakłada, że 1 zostanie zwrócony i bezpośrednio przenosi wartość stałą do rejestru eax, który przenosi wartość zwracaną. TBAA korzysta z reguł językowych dotyczących typów dozwolonych dla aliasu w celu optymalizacji obciążeń i sklepów. W tym przypadku TBAA wie, że liczba zmiennoprzecinkowa nie może aliasu i int, i optymalizuje obciążenie i .

Teraz do Zestawu Zasad

Co dokładnie mówi standard, że wolno nam, a nie wolno? Standardowy język nie jest prosty, więc dla każdego elementu postaram się podać przykłady kodu, które demonstrują znaczenie.

Co mówi standard C11?

Standard C11 mówi w sekcji 6.5 Wyrażenia, paragraf 7 :

Dostęp do przechowywanej wartości obiektu może mieć tylko wyrażenie wartości, które ma jeden z następujących typów: 88) - typ zgodny ze skutecznym typem obiektu,

int x = 1;
int *p = &x;   
printf("%d\n", *p); // *p gives us an lvalue expression of type int which is compatible with int

- kwalifikowana wersja typu zgodna z efektywnym typem obiektu,

int x = 1;
const int *p = &x;
printf("%d\n", *p); // *p gives us an lvalue expression of type const int which is compatible with int

- typ, który jest typem podpisanym lub niepodpisanym odpowiadającym efektywnemu typowi obiektu,

int x = 1;
unsigned int *p = (unsigned int*)&x;
printf("%u\n", *p ); // *p gives us an lvalue expression of type unsigned int which corresponds to 
                     // the effective type of the object

gcc / dzyń ma rozszerzenie a także , że pozwala na przypisanie unsigned int * do int * , chociaż nie są one kompatybilne typy.

- typ, który jest typem podpisanym lub niepodpisanym odpowiadającym kwalifikowanej wersji efektywnego typu obiektu,

int x = 1;
const unsigned int *p = (const unsigned int*)&x;
printf("%u\n", *p ); // *p gives us an lvalue expression of type const unsigned int which is a unsigned type 
                     // that corresponds with to a qualified verison of the effective type of the object

- typ agregatu lub związku, który obejmuje jeden z wyżej wymienionych typów wśród jego członków (w tym, rekurencyjnie, członka subagregatu lub zawartego związku), lub

struct foo {
  int x;
};

void foobar( struct foo *fp, int *ip );  // struct foo is an aggregate that includes int among its members so it can
                                         // can alias with *ip

foo f;
foobar( &f, &f.x );

- typ postaci.

int x = 65;
char *p = (char *)&x;
printf("%c\n", *p );  // *p gives us an lvalue expression of type char which is a character type.
                      // The results are not portable due to endianness issues.

Co mówią Craft 17 Draft Standard

Projekt standardu C ++ 17 w sekcji [basic.lval] paragraf 11 mówi:

Jeśli program próbuje uzyskać dostęp do przechowywanej wartości obiektu za pośrednictwem wartości innej niż jeden z następujących typów, zachowanie jest niezdefiniowane: 63 (11.1) - typ dynamiczny obiektu,

void *p = malloc( sizeof(int) ); // We have allocated storage but not started the lifetime of an object
int *ip = new (p) int{0};        // Placement new changes the dynamic type of the object to int
std::cout << *ip << "\n";        // *ip gives us a glvalue expression of type int which matches the dynamic type 
                                  // of the allocated object

(11.2) - kwalifikowana do CV wersja dynamicznego typu obiektu,

int x = 1;
const int *cip = &x;
std::cout << *cip << "\n";  // *cip gives us a glvalue expression of type const int which is a cv-qualified 
                            // version of the dynamic type of x

(11.3) - typ podobny (zgodnie z definicją w 7.5) do typu dynamicznego obiektu,

(11.4) - typ, który jest typem podpisanym lub niepodpisanym odpowiadającym typowi dynamicznemu obiektu,

// Both si and ui are signed or unsigned types corresponding to each others dynamic types
// We can see from this godbolt(https://godbolt.org/g/KowGXB) the optimizer assumes aliasing.
signed int foo( signed int &si, unsigned int &ui ) {
  si = 1;
  ui = 2;

  return si;
}

(11.5) - typ, który jest typem podpisanym lub niepodpisanym, odpowiadającym kwalifikowanej do cv wersji typu dynamicznego obiektu,

signed int foo( const signed int &si1, int &si2); // Hard to show this one assumes aliasing

(11.6) - typ agregatu lub związku, który obejmuje jeden z wyżej wymienionych typów wśród jego elementów lub niestatycznych elementów danych (w tym, rekurencyjnie, element lub element danych niestatycznych subagregatu lub zawartego związku),

struct foo {
 int x;
};

// Compiler Explorer example(https://godbolt.org/g/z2wJTC) shows aliasing assumption
int foobar( foo &fp, int &ip ) {
 fp.x = 1;
 ip = 2;

 return fp.x;
}

foo f; 
foobar( f, f.x ); 

(11.7) - typ, który jest (prawdopodobnie kwalifikowany do cv) typem klasy podstawowej typu dynamicznego obiektu,

struct foo { int x ; };

struct bar : public foo {};

int foobar( foo &f, bar &b ) {
  f.x = 1;
  b.x = 2;

  return f.x;
}

(11.8) - typ char, unsigned char lub std :: byte.

int foo( std::byte &b, uint32_t &ui ) {
  b = static_cast<std::byte>('a');
  ui = 0xFFFFFFFF;                   

  return std::to_integer<int>( b );  // b gives us a glvalue expression of type std::byte which can alias
                                     // an object of type uint32_t
}

Warto zauważyć, że znak char nie jest uwzględniony na powyższej liście, jest to zauważalna różnica w stosunku do C, który mówi o typie postaci .

Co to jest Type Punning

Dotarliśmy do tego punktu i możemy się zastanawiać, po co chcieć alias? Zazwyczaj odpowiedź brzmi: pun , często stosowane metody naruszają surowe reguły aliasingu.

Czasami chcemy obejść system typów i zinterpretować obiekt jako inny typ. Nazywa się to pisaniem na klawiaturze , aby ponownie zinterpretować segment pamięci jako inny typ. Pisanie na czcionkach jest przydatne w przypadku zadań, które chcą uzyskać dostęp do podstawowej reprezentacji obiektu w celu przeglądania, transportu lub manipulowania. Typowe obszary, w których spotykamy się ze znakowaniem, to kompilatory, serializacja, kod sieci itp.

Tradycyjnie zostało to osiągnięte poprzez pobranie adresu obiektu, rzutując go na wskaźnik typu, który chcemy ponownie zinterpretować jako, a następnie dostęp do wartości, lub innymi słowy poprzez aliasing. Na przykład:

int x =  1 ;

// In C
float *fp = (float*)&x ;  // Not a valid aliasing

// In C++
float *fp = reinterpret_cast<float*>(&x) ;  // Not a valid aliasing

printf( "%f\n", *fp ) ;

Jak widzieliśmy wcześniej, nie jest to prawidłowe aliasing, dlatego przywołujemy niezdefiniowane zachowanie. Ale tradycyjnie kompilatory nie korzystały z surowych reguł aliasingu i ten typ kodu zwykle po prostu działał, programiści niestety przyzwyczaili się do robienia tego w ten sposób. Powszechną alternatywną metodą znakowania czcionkami są związki, które są poprawne w C, ale niezdefiniowane zachowanie w C ++ ( patrz przykład na żywo ):

union u1
{
  int n;
  float f;
} ;

union u1 u;
u.f = 1.0f;

printf( "%d\n”, u.n );  // UB in C++ n is not the active member

Nie jest to poprawne w C ++ i niektórzy uważają, że celem związków jest wyłącznie implementacja typów wariantów i uważają, że używanie związków do znakowania czcionkami jest nadużyciem.

Jak poprawnie wpisujemy Pun?

Standardową metodą znakowania czcionkami zarówno w C, jak i C ++ jest memcpy . To może wydawać się trochę trudne, ale optymalizator powinien rozpoznać użycie memcpy do znakowania i zoptymalizować go i wygenerować rejestr rejestrujący ruch. Na przykład, jeśli wiemy, że int64_t ma ten sam rozmiar co double :

static_assert( sizeof( double ) == sizeof( int64_t ) );  // C++17 does not require a message

możemy użyć memcpy :

void func1( double d ) {
  std::int64_t n;
  std::memcpy(&n, &d, sizeof d); 
  //...

Przy wystarczającym poziomie optymalizacji każdy przyzwoity nowoczesny kompilator generuje identyczny kod do wspomnianej wcześniej metody reinterpret_cast lub metody unii dla znakowania typu . Analizując wygenerowany kod, widzimy, że używa tylko rejestracji mov (przykład na żywo Eksploratora kompilatora ).

C ++ 20 i bit_cast

W C ++ 20 możemy uzyskać bit_cast ( implementacja dostępna w linku z propozycji ), co daje prosty i bezpieczny sposób na pisanie na klawiaturze, a także jest możliwe do użycia w kontekście constexpr.

Poniżej znajduje się przykład użycia bit_cast do wpisania pun un unsigned int do float , ( zobacz na żywo ):

std::cout << bit_cast<float>(0x447a0000) << "\n" ; //assuming sizeof(float) == sizeof(unsigned int)

W przypadku, gdy typy Do i Od nie mają tego samego rozmiaru, wymaga od nas użycia struktury pośredniej15. Użyjemy struktury zawierającej tablicę znaków sizeof (unsigned int) (przy założeniu, że 4-bajtowy unsigned int ) jest typu From i unsigned int jako Type To . :

struct uint_chars {
 unsigned char arr[sizeof( unsigned int )] = {} ;  // Assume sizeof( unsigned int ) == 4
};

// Assume len is a multiple of 4 
int bar( unsigned char *p, size_t len ) {
 int result = 0;

 for( size_t index = 0; index < len; index += sizeof(unsigned int) ) {
   uint_chars f;
   std::memcpy( f.arr, &p[index], sizeof(unsigned int));
   unsigned int result = bit_cast<unsigned int>(f);

   result += foo( result );
 }

 return result ;
}

Szkoda, że ​​potrzebujemy tego typu pośredniego, ale takie jest obecne ograniczenie bit_cast .

Łapanie surowych naruszeń aliasingowych

Nie mamy wielu dobrych narzędzi do przechwytywania ścisłego aliasingu w C ++, narzędzia, które mamy, wychwytują niektóre przypadki ścisłego naruszenia aliasingu oraz niektóre przypadki nieprawidłowego ładowania i przechowywania.

gcc za pomocą flag -fstrict-aliasing i -Wstrict-aliasing może przechwycić niektóre przypadki, chociaż nie bez fałszywych trafień / negatywów. Na przykład następujące przypadki wygenerują ostrzeżenie w gcc ( zobacz na żywo ):

int a = 1;
short j;
float f = 1.f; // Originally not initialized but tis-kernel caught 
               // it was being accessed w/ an indeterminate value below

printf("%i\n", j = *(reinterpret_cast<short*>(&a)));
printf("%i\n", j = *(reinterpret_cast<int*>(&f)));

chociaż nie złapie tego dodatkowego przypadku ( zobacz na żywo ):

int *p;

p=&a;
printf("%i\n", j = *(reinterpret_cast<short*>(p)));

Chociaż clang pozwala na te flagi, najwyraźniej tak naprawdę nie implementuje ostrzeżeń.

Kolejnym narzędziem, które mamy do dyspozycji, jest ASan, który może wychwycić niedopasowane ładunki i sklepy. Chociaż nie są to bezpośrednio surowe naruszenia aliasingu, są one powszechnym wynikiem ścisłego naruszenia aliasingu. Na przykład następujące przypadki wygenerują błędy środowiska wykonawczego, gdy zostaną zbudowane z clang przy użyciu opcji -fsanitize = adres

int *x = new int[2];               // 8 bytes: [0,7].
int *u = (int*)((char*)x + 6);     // regardless of alignment of x this will not be an aligned address
*u = 1;                            // Access to range [6-9]
printf( "%d\n", *u );              // Access to range [6-9]

Ostatnie narzędzie, które polecę, jest specyficzne dla C ++ i nie jest wyłącznie narzędziem, ale praktyką kodowania, nie zezwalaj na rzutowania w stylu C. Zarówno gcc, jak i clang wygenerują diagnostykę dla rzutów w stylu C przy użyciu -Wold-style-cast . Zmusi to wszystkie niezdefiniowane kalambury do użycia reinterpret_cast, ogólnie reinterpret_cast powinien być flagą do dokładniejszego przeglądu kodu. Łatwiej jest również przeszukać bazę kodu pod kątem reinterpret_cast w celu przeprowadzenia audytu.

W przypadku C mamy już wszystkie narzędzia, a także mamy interpreter tis, analizator statyczny, który wyczerpująco analizuje program dla dużej części języka C. Biorąc pod uwagę wersje C wcześniejszego przykładu, w którym użycie -fstrict-aliasing pomija jeden przypadek ( zobacz na żywo )

int a = 1;
short j;
float f = 1.0 ;

printf("%i\n", j = *((short*)&a));
printf("%i\n", j = *((int*)&f));

int *p; 

p=&a;
printf("%i\n", j = *((short*)p));

tis-interpeter jest w stanie złapać wszystkie trzy, poniższy przykład wywołuje tis-kernal jako tis-interpreter (dane wyjściowe są edytowane dla zwięzłości):

./bin/tis-kernel -sa example1.c 
...
example1.c:9:[sa] warning: The pointer (short *)(& a) has type short *. It violates strict aliasing
              rules by accessing a cell with effective type int.
...

example1.c:10:[sa] warning: The pointer (int *)(& f) has type int *. It violates strict aliasing rules by
              accessing a cell with effective type float.
              Callstack: main
...

example1.c:15:[sa] warning: The pointer (short *)p has type short *. It violates strict aliasing rules by
              accessing a cell with effective type int.

Wreszcie istnieje TySan, który jest obecnie w fazie rozwoju. Ten środek dezynfekujący dodaje informacje o sprawdzaniu typu w segmencie pamięci cienia i sprawdza dostęp, aby sprawdzić, czy naruszają reguły aliasingu. Narzędzie potencjalnie powinno być w stanie wychwycić wszystkie naruszenia aliasingu, ale może mieć duże obciążenie w czasie wykonywania.

Shafik Yaghmour
źródło
Komentarze nie są przeznaczone do rozszerzonej dyskusji; ta rozmowa została przeniesiona do czatu .
Bhargav Rao
3
Gdybym mógł, +10, dobrze napisany i wyjaśniony, również z obu stron, autorów i programistów kompilatorów ... jedyna krytyka: Byłoby miło mieć powyższe przykłady, aby zobaczyć, co jest zabronione przez standard, nie jest to oczywiste rodzaj :-)
Gabriel
2
Bardzo dobra odpowiedź. Żałuję tylko, że początkowe przykłady są podane w C ++, co sprawia, że ​​trudno jest naśladować osoby takie jak ja, które znają lub dbają tylko o C i nie mają pojęcia, co reinterpret_castmoże zrobić ani co coutmoże znaczyć. (W porządku jest wspominanie o C ++, ale pierwotne pytanie dotyczyło C i IIUC, przykłady te można równie dobrze napisać w C.)
Gro-Tsen
Jeśli chodzi o wykrawanie czcionek: jeśli więc napiszę tablicę jakiegoś typu X do pliku, a następnie odczytam z tego pliku tę tablicę do pamięci oznaczonej void *, to rzuciłem ten wskaźnik na prawdziwy typ danych, aby go użyć - to niezdefiniowane zachowanie?
Michał IV
44

Ścisłe aliasing nie odnosi się tylko do wskaźników, ale także wpływa na referencje, napisałem o tym artykuł na wiki dla deweloperów boost i został tak dobrze przyjęty, że zmieniłem go w stronę mojej witryny konsultingowej. Wyjaśnia całkowicie, co to jest, dlaczego tak bardzo myli ludzi i co z tym zrobić. Biała Księga ścisłego aliasingu . W szczególności wyjaśnia, dlaczego związki są ryzykownym zachowaniem dla C ++ i dlaczego używanie memcpy jest jedyną poprawką przenośną w C i C ++. Mam nadzieję, że to jest pomocne.

phorgan1
źródło
3
Ścisłe aliasing nie odnosi się tylko do wskaźników, ale także wpływa na odniesienia ” W rzeczywistości odnosi się do wartości lv . „ używanie memcpy jest jedynym przenośnym rozwiązaniem ” Słuchaj!
ciekawy,
5
Dobry papier Moje zdanie: (1) ten problem aliasingu jest nadmierną reakcją na złe programowanie - próbuje chronić złego programistę przed jego złymi nawykami. Jeśli programista ma dobre nawyki, to aliasing jest po prostu uciążliwy, a kontrole można bezpiecznie wyłączyć. (2) Optymalizacja po stronie kompilatora powinna być wykonywana tylko w dobrze znanych przypadkach, aw razie wątpliwości należy ściśle przestrzegać kodu źródłowego; zmuszanie programisty do pisania kodu w celu zaspokojenia osobliwości kompilatora jest, po prostu, błędne. Co gorsza, aby stało się częścią standardu.
slashmais
4
@slashmais (1) ” to nadmierna reakcja na złe programowanie „ Bzdury. Jest to odrzucenie złych nawyków. Ty to robisz? Płacisz cenę: bez gwarancji! (2) Dobrze znane przypadki? Które? Surowa zasada aliasingu powinna być „dobrze znana”!
ciekawy,
5
@curiousguy: Po wyjaśnieniu kilku wątpliwości, jasne jest, że język C z regułami aliasingu uniemożliwia programom implementację pul pamięci niezależnych od typu. Niektóre rodzaje programów radzą sobie z malloc / free, ale inne wymagają logiki zarządzania pamięcią lepiej dostosowanej do wykonywanych zadań. Zastanawiam się, dlaczego uzasadnienie C89 posłużyło się tak kiepskim przykładem powodu reguły aliasingu, ponieważ ich przykład wydaje się, że reguła nie będzie stanowić poważnej trudności w wykonaniu jakiegokolwiek rozsądnego zadania.
supercat
5
@ curiousguy, większość dostępnych obecnie kompilatorów zawiera domyślnie--3 aliasing domyślnie na -O3, a ta ukryta umowa jest wymuszana na użytkownikach, którzy nigdy nie słyszeli o TBAA i napisali kod, tak jak może to zrobić programista. Nie chcę brzmieć obraźliwie dla programistów systemu, ale tego rodzaju optymalizację należy pozostawić poza domyślną opcją -O3 i powinna ona być optymalizacją dla tych, którzy wiedzą, czym jest TBAA. Nie jest fajnie patrzeć na „błąd” kompilatora, który okazuje się być kodem użytkownika naruszającym TBAA, zwłaszcza śledzenie naruszenia poziomu kodu źródłowego w kodzie użytkownika.
kchoi
34

Jako dodatek do tego, co już napisał Doug T., oto prosty przypadek testowy, który prawdopodobnie uruchamia go za pomocą gcc:

czek. c

#include <stdio.h>

void check(short *h,long *k)
{
    *h=5;
    *k=6;
    if (*h == 5)
        printf("strict aliasing problem\n");
}

int main(void)
{
    long      k[1];
    check((short *)k,k);
    return 0;
}

Kompiluj z gcc -O2 -o check check.c. Zwykle (z większością wersji gcc, które próbowałem) powoduje to „ścisły problem aliasingu”, ponieważ kompilator zakłada, że ​​„h” nie może być tego samego adresu co „k” w funkcji „sprawdź”. Z tego powodu kompilator optymalizuje if (*h == 5)away i zawsze wywołuje printf.

Dla zainteresowanych jest kod asemblera x64, stworzony przez gcc 4.6.3, działający na Ubuntu 12.04.2 dla x64:

movw    $5, (%rdi)
movq    $6, (%rsi)
movl    $.LC0, %edi
jmp puts

Zatem warunek if całkowicie zniknął z kodu asemblera.

Ingo Blackman
źródło
jeśli dodasz drugi krótki * j do check () i użyjesz go (* j = 7), wówczas optymalizacja zniknie, ponieważ ggc nie zmienia się, jeśli hi j nie wskazują tej samej wartości. tak optymalizacja jest naprawdę sprytna.
philippe lhardy
2
Aby sprawić więcej radości, użyj wskaźników do typów, które nie są kompatybilne, ale mają ten sam rozmiar i reprezentację (w niektórych systemach, np. long long*I int64_t*). Można się spodziewać, że rozsądny kompilator powinien rozpoznać, że long long*i int64_t*może uzyskać dostęp do tej samej pamięci, jeśli są one przechowywane identycznie, ale takie traktowanie nie jest już modne.
supercat
Grr ... x64 to konwencja Microsoft. Zamiast tego użyj amd64 lub x86_64.
SS Anne
Grr ... x64 to konwencja Microsoft. Zamiast tego użyj amd64 lub x86_64.
SS Anne
17

Pisanie na klawiaturze za pomocą rzutów wskaźnikowych (w przeciwieństwie do używania związku) jest głównym przykładem przełamania ścisłego aliasingu.

Chris Jester-Young
źródło
1
Zobacz moją odpowiedź tutaj, aby znaleźć odpowiednie cytaty, zwłaszcza przypisy, ale pisanie przez związki zawsze było dozwolone w C, chociaż na początku było źle sformułowane. Chcesz wyjaśnić swoją odpowiedź.
Shafik Yaghmour,
@ShafikYaghmour: C89 wyraźnie zezwalał programistom na wybieranie przypadków, w których pożytecznie rozpoznaliby wykreślanie typów przez związki. Implementacja może na przykład określać, że dla zapisu do jednego typu, po którym następuje odczyt innego, ma być rozpoznany jako znakowanie typu, jeśli programista zrobił jedną z następujących czynności między zapisem a odczytem : (1) ocenia wartość zawierającą typ związku [przyjmowanie adresu członka kwalifikowałoby się, gdyby zostało wykonane we właściwym punkcie w sekwencji]; (2) przekonwertuj wskaźnik na jeden typ na wskaźnik na drugi i uzyskaj dostęp przez ten ptr.
supercat
@ShafikYaghmour: Implementacja może również określać np., Że znakowanie typu między wartościami całkowitymi i zmiennoprzecinkowymi działałoby niezawodnie tylko wtedy, gdy kod wykonałby fpsync()dyrektywę między zapisem jako fp a odczytem jako int lub vice versa [w implementacjach z oddzielnymi potokami liczb całkowitych i FPU i pamięci podręcznej , taka dyrektywa może być droga, ale nie tak kosztowna, jak to, że kompilator wykonuje taką synchronizację przy każdym dostępie do związku]. Lub implementacja może określić, że wynikowa wartość nigdy nie będzie użyteczna, z wyjątkiem okoliczności wykorzystujących wspólne sekwencje początkowe.
supercat
@ShafikYaghmour: W C89 implementacje mogą zabraniać większości form znakowania czcionkami, w tym przez związki, ale równoważność wskaźników pomiędzy związkami i wskaźników ich członków sugeruje, że znakowanie było dozwolone w implementacjach, które wyraźnie tego nie zabraniały.
supercat
17

Zgodnie z uzasadnieniem C89 autorzy Standardu nie chcieli wymagać, aby kompilatory otrzymywały kod taki jak:

int x;
int test(double *p)
{
  x=5;
  *p = 1.0;
  return x;
}

powinno być wymagane do ponownego załadowania wartości x między poleceniem przypisania a instrukcją return, aby umożliwić możliwość, która pmoże wskazywać x, i przypisanie, które *pmoże w konsekwencji zmienić wartość x. Pojęcie, że kompilator powinien mieć prawo domniemywać, że w takich sytuacjach jak wyżej nie będzie aliasu, nie było kontrowersyjne.

Niestety, autorzy C89 napisali swoją regułę w taki sposób, że jeśli czytany dosłownie, sprawia, że ​​nawet następująca funkcja wywołuje Niezdefiniowane Zachowanie:

void test(void)
{
  struct S {int x;} s;
  s.x = 1;
}

ponieważ używa wartości typu int aby uzyskać dostęp do obiektu typu struct S, orazint nie należy do typów, które mogą być używane do uzyskania dostępu do struct S. Ponieważ absurdem byłoby traktować wszelkie użycie struktur typu i związków innych niż znaki jako Zachowanie nieokreślone, prawie wszyscy zdają sobie sprawę, że istnieją przynajmniej niektóre okoliczności, w których wartość jednego typu może być wykorzystana do uzyskania dostępu do obiektu innego typu . Niestety Komitet ds. Norm C nie zdefiniował, jakie są te okoliczności.

Duża część problemu wynika z Raportu Defektu # 028, który pytał o zachowanie programu takiego jak:

int test(int *ip, double *dp)
{
  *ip = 1;
  *dp = 1.23;
  return *ip;
}
int test2(void)
{
  union U { int i; double d; } u;
  return test(&u.i, &u.d);
}

Raport Defektów # 28 stwierdza, że ​​program wywołuje Nieokreślone Zachowanie, ponieważ czynność napisania członka związku typu „podwójny” i odczytu jednego typu „int” wywołuje zachowanie Zdefiniowane w implementacji. Takie rozumowanie jest bezsensowne, ale stanowi podstawę dla reguł typu efektywnego, które niepotrzebnie komplikują język, nie robiąc nic, aby rozwiązać pierwotny problem.

Najlepszym sposobem na rozwiązanie pierwotnego problemu byłoby prawdopodobnie potraktowanie przypisu dotyczącego celu reguły tak, jakby była ona normatywna, i uczyniła regułę niemożliwą do wyegzekwowania, z wyjątkiem przypadków, które faktycznie wymagają sprzecznych dostępów przy użyciu aliasów. Biorąc pod uwagę coś takiego:

 void inc_int(int *p) { *p = 3; }
 int test(void)
 {
   int *p;
   struct S { int x; } s;
   s.x = 1;
   p = &s.x;
   inc_int(p);
   return s.x;
 }

Wewnątrz nie ma konfliktu, inc_intponieważ wszystkie dostępy do pamięci, do której *puzyskano dostęp, są wykonywane z użyciem wartości typu int, i nie ma konfliktu, testponieważ pjest wyraźnie wywiedziony z struct S, a przy następnym sużyciu, wszystkie dostępu do pamięci, która kiedykolwiek zostanie wykonana przezp już się wydarzyło.

Jeśli kod został nieznacznie zmieniony ...

 void inc_int(int *p) { *p = 3; }
 int test(void)
 {
   int *p;
   struct S { int x; } s;
   p = &s.x;
   s.x = 1;  //  !!*!!
   *p += 1;
   return s.x;
 }

W tym przypadku występuje konflikt aliasingu między pdostępem do s.xoznaczonej linii, ponieważ w tym momencie wykonania istnieje inne odwołanie, które zostanie wykorzystane do uzyskania dostępu do tego samego magazynu .

Gdyby Raport Defektów 028 powiedział, że oryginalny przykład przywołał UB z powodu nakładania się między tworzeniem i użyciem dwóch wskaźników, to uczyniłoby sprawę o wiele bardziej przejrzystą bez konieczności dodawania „Skutecznych typów” lub innej takiej złożoności.

supercat
źródło
Mówiąc dobrze, interesujące byłoby przeczytanie tego rodzaju propozycji, która była mniej więcej „tym, co mógł zrobić komitet normalizacyjny”, który osiągnął swoje cele bez wprowadzania tak dużej złożoności.
jrh
1
@jrh: Myślę, że byłoby to dość proste. Uznanie, że 1. Aby aliasing miał miejsce podczas konkretnego wykonania funkcji lub pętli, podczas tego wykonania należy użyć dwóch różnych wskaźników lub wartości lv , aby zająć się tym samym miejscem przechowywania w konflikcie fashon; 2. Uznają, że w kontekstach, w których jeden wskaźnik lub wartość jest świeżo widoczna z innej, dostęp do drugiej jest dostępem do pierwszej; 3. Uznanie, że reguła nie ma zastosowania w przypadkach, które w rzeczywistości nie wymagają aliasingu.
supercat
1
Dokładne okoliczności, w których kompilator rozpoznaje świeżo wyprowadzoną wartość, może stanowić problem z jakością implementacji, ale każdy zdalnie przyzwoity kompilator powinien być w stanie rozpoznać formy, które gcc i clang celowo ignorują.
supercat
11

Po przeczytaniu wielu odpowiedzi czuję potrzebę dodania czegoś:

Ścisłe aliasing (który opiszę za chwilę) jest ważne, ponieważ :

  1. Dostęp do pamięci może być kosztowny (pod względem wydajności), dlatego dane są rejestrowane w rejestrach procesora, zanim zostaną zapisane z powrotem w pamięci fizycznej.

  2. Jeśli dane w dwóch różnych rejestrach procesora zostaną zapisane w tym samym obszarze pamięci, nie możemy przewidzieć, które dane „przetrwają” gdy kodujemy w C.

    W asemblerze, w którym ręcznie kodujemy ładowanie i rozładowywanie rejestrów procesora, będziemy wiedzieć, które dane pozostają nienaruszone. Ale C (na szczęście) streszcza ten szczegół.

Ponieważ dwa wskaźniki mogą wskazywać to samo miejsce w pamięci, może to skutkować złożonym kodem, który obsługuje możliwe kolizje .

Ten dodatkowy kod jest powolny i obniża wydajność, ponieważ wykonuje dodatkowe operacje odczytu / zapisu w pamięci, które są zarówno wolniejsze, jak i (prawdopodobnie) niepotrzebne.

Reguła aliasing Strict pozwala nam uniknąć nadmiarowego kodu maszynowego w przypadkach, w których powinny być bezpiecznie założyć, że dwa wskaźniki nie wskazują na ten sam blok pamięci (patrz również restrictsłowa kluczowego).

W przypadku ścisłego aliasingu można bezpiecznie założyć, że wskaźniki różnych typów wskazują różne lokalizacje w pamięci.

Jeśli kompilator zauważy, że dwa wskaźniki wskazują różne typy (na przykład an int *i afloat * ), przyjmie, że adres pamięci jest inny i nie ochroni przed kolizjami adresów pamięci, co spowoduje szybszy kod maszynowy.

Na przykład :

Załóżmy następującą funkcję:

void merge_two_ints(int *a, int *b) {
  *b += *a;
  *a += *b;
}

Aby obsłużyć przypadek, w którym a == b (oba wskaźniki wskazują na tę samą pamięć), musimy zamówić i przetestować sposób ładowania danych z pamięci do rejestrów procesora, aby kod mógł wyglądać następująco:

  1. ładuj aib z pamięci.

  2. dodaj ado b.

  3. zapisz b i załaduj ponownie a .

    (zapisz z rejestru procesora do pamięci i załaduj z pamięci do rejestru procesora).

  4. dodaj bdoa.

  5. zapisz a(z rejestru procesora) do pamięci.

Krok 3 jest bardzo powolny, ponieważ musi uzyskać dostęp do pamięci fizycznej. Wymagana jest jednak ochrona przed przypadkami, w którycha i bwskazywanie tego samego adresu pamięci.

Ścisłe aliasing pozwoliłoby nam temu zapobiec, mówiąc kompilatorowi, że te adresy pamięci są wyraźnie różne (co w tym przypadku pozwoli na dalszą optymalizację, której nie można wykonać, jeśli wskaźniki dzielą adres pamięci).

  1. Można to powiedzieć kompilatorowi na dwa sposoby, używając różnych typów wskazań. to znaczy:

    void merge_two_numbers(int *a, long *b) {...}
  2. Za pomocą restrictsłowa kluczowego. to znaczy:

    void merge_two_ints(int * restrict a, int * restrict b) {...}

Teraz, spełniając regułę ścisłego aliasingu, można uniknąć kroku 3, a kod będzie działał znacznie szybciej.

W rzeczywistości przez dodanie restrictsłowa kluczowego można zoptymalizować całą funkcję, aby:

  1. ładowanie aib z pamięci.

  2. dodaj ado b.

  3. zapisz wynik zarówno do, jak ai do b.

Ta optymalizacja nie mogła być wcześniej wykonana z powodu możliwej kolizji (gdzie ai bbyłby potrojony zamiast podwojony).

Mist
źródło
ze słowem kluczowym ograniczającym, w kroku 3, czy nie powinien to być zapis wyników tylko do „b”? Brzmi tak, jakby wynik sumowania został zapisany również w „a”. Czy konieczne jest ponowne załadowanie „b”?
NeilB
1
@NeilB - Tak, masz rację. Zapisujemy tylko b(nie przeładowujemy) i przeładowujemy a. Mam nadzieję, że teraz jest jaśniej.
Myst
Aliasing oparty na typach mógł wcześniej oferować pewne korzyści restrict, ale sądzę, że ten drugi w większości przypadków byłby bardziej skuteczny, a złagodzenie niektórych ograniczeń registerpozwoliłoby mu wypełnić niektóre przypadki, w których restrictnie pomogłoby. Nie jestem pewien, czy kiedykolwiek „ważne” było potraktowanie Standardu jako pełnego opisu wszystkich przypadków, w których programiści powinni oczekiwać, że kompilatory rozpoznają dowody aliasingu, a nie tylko opisywania miejsc, w których kompilatory muszą zakładać aliasing, nawet jeśli nie ma konkretnych dowodów na to .
supercat
Zauważ, że chociaż ładowanie z głównej pamięci RAM jest bardzo wolne (i może zatrzymać procesor na długi czas, jeśli kolejne operacje zależą od wyniku), ładowanie z pamięci podręcznej L1 jest dość szybkie, podobnie jak zapis do ostatnio zapisanej linii pamięci podręcznej do tego samego rdzenia. Tak więc wszystko oprócz pierwszego odczytu lub zapisu na adres zwykle będzie dość szybkie: różnica między dostępem do reg / mem addr jest mniejsza niż różnica między buforowanym / niebuforowanym adresem mem.
ciekawy
@curiousguy - chociaż masz rację, „szybki” w tym przypadku jest względny. Pamięć podręczna L1 jest prawdopodobnie nadal o rząd wielkości wolniejsza niż rejestry procesora (myślę, że ponad 10 razy wolniej). Ponadto restrictsłowo kluczowe minimalizuje nie tylko szybkość operacji, ale także ich liczbę, co może mieć znaczenie ... To znaczy, w końcu najszybsza operacja to w ogóle żadna operacja :)
Myst
6

Ścisłe aliasing nie pozwala różnym typom wskaźników na te same dane.

Ten artykuł powinien pomóc Ci w szczegółowym zrozumieniu problemu.

Jason Dagit
źródło
4
Możesz także alias między odwołaniami oraz między odwołaniem a wskaźnikiem. Zobacz mój samouczek dbp-consulting.com/tutorials/StrictAliasing.html
phorgan1
4
Dozwolone jest stosowanie różnych typów wskaźników do tych samych danych. Pojawia się ścisłe aliasing, gdy ta sama lokalizacja pamięci jest zapisywana przez jeden typ wskaźnika i odczytywana przez inny. Ponadto dozwolone są niektóre różne typy (np. intStruktura zawierająca an int).
MM
-3

Technicznie w C ++ zasada ścisłego aliasingu prawdopodobnie nigdy nie ma zastosowania.

Zwróć uwagę na definicję pośrednictwa ( * operator ):

Jednoargumentowy * operator wykonuje pośrednie: wyrażenie, do którego jest stosowane, powinno być wskaźnikiem do typu obiektu lub wskaźnikiem do typu funkcji, a wynikiem jest wartość odnosząca się do obiektu lub funkcji, na którą wskazuje wyrażenie .

Również z definicji glvalue

Glvalue to wyrażenie, którego ocena określa tożsamość obiektu, (... snip)

Zatem w każdym dobrze zdefiniowanym śladzie programu glvalue odnosi się do obiektu. Tak więc nigdy nie obowiązuje tak zwana zasada ścisłego aliasingu. To może nie być to, czego chcieli projektanci.

ciekawy
źródło
4
Standard C używa terminu „obiekt” w odniesieniu do szeregu różnych pojęć. Wśród nich sekwencja bajtów, które są przypisane wyłącznie do określonego celu, niekoniecznie wyłączne odniesienie do sekwencji bajtów, z których można zapisać lub odczytać wartość określonego typu , lub takie odwołanie, które faktycznie ma był lub będzie dostępny w pewnym kontekście. Nie sądzę, aby istniał jakikolwiek rozsądny sposób zdefiniowania terminu „Obiekt”, który byłby spójny z tym, w jaki sposób Standard go używa.
supercat,
@supercat Nieprawidłowe. Pomimo twojej wyobraźni, jest to właściwie dość konsekwentne. W ISO C jest zdefiniowany jako „region przechowywania danych w środowisku wykonawczym, którego zawartość może reprezentować wartości”. W ISO C ++ istnieje podobna definicja. Twój komentarz jest nawet bardziej nieistotny niż odpowiedź, ponieważ wszystko, co wymieniłeś, to sposoby reprezentacji w celu odniesienia treści obiektów , podczas gdy odpowiedź ilustruje koncepcję C ++ (glvalue) rodzaju wyrażeń ściśle związanych z tożsamością obiektów. Wszystkie reguły aliasingu są w zasadzie istotne dla tożsamości, ale nie dla treści.
FrankHB
1
@FrankHB: Jeśli się deklaruje int foo;, do czego służy wyrażenie lvalue *(char*)&foo? Czy to obiekt typu char? Czy ten przedmiot powstaje w tym samym czasie co foo? Czy pisanie foozmieniłoby wartość przechowywaną wyżej wspomnianego obiektu typu char? Jeśli tak, to czy istnieje jakakolwiek reguła, która pozwala na dostęp do zapisanej wartości obiektu typu charza pomocą wartości typu int?
supercat
@FrankHB: W przypadku braku wersji 6.5p7 można po prostu powiedzieć, że każdy region pamięci jednocześnie zawiera wszystkie obiekty każdego typu, które mogłyby zmieścić się w tym regionie pamięci, i że dostęp do tego regionu pamięci jednocześnie uzyskuje dostęp do wszystkich z nich. Interpretując w ten sposób użycie terminu „obiekt” w 6.5p7, zabraniałoby jednak robienia czegokolwiek z wartościami lvalu innego niż charakter, co byłoby oczywiście absurdalnym rezultatem i całkowicie pokonałoby cel reguły. Co więcej, pojęcie „obiektu” używane wszędzie poza 6.5p6 ma statyczny typ czasu kompilacji, ale ...
supercat
1
sizeof (int) wynosi 4, czy deklaracja int i;tworzy cztery obiekty każdego typu znaków in addition to one of type int ? I see no way to apply a consistent definition of "object" which would allow for operations on both * (char *) i i` oraz i. Wreszcie, w standardzie nie ma nic, co pozwala nawet volatilekwalifikowanemu wskaźnikowi na dostęp do rejestrów sprzętowych, które nie spełniają definicji „obiektu”.
supercat