Was ist striktes Aliasing und warum sollten wir uns darum kümmern? Teil 2

(ODER streiten tippen, vages Verhalten und Ausrichtung, oh mein Gott!)

Freunde, es bleibt nur sehr wenig Zeit, bis ein neuer Thread zum Kurs "C ++ Developer" gestartet wird. Es ist Zeit, eine Übersetzung des zweiten Teils des Materials zu veröffentlichen, die erzählt, was ein Wortspiel tippt.

Was ist eine Wortspiel-Typisierung?

Wir haben den Punkt erreicht, an dem wir uns fragen können, warum wir überhaupt Pseudonyme brauchen könnten. Normalerweise für die Implementierung von Wortspielen, tk. häufig verwendete Methoden verstoßen gegen strenge Aliasing-Regeln.



Manchmal möchten wir das Typsystem umgehen und das Objekt als einen anderen Typ interpretieren. Das Neuinterpretieren eines Speichersegments als ein anderer Typ wird als Wortspiel bezeichnet . Das Eingeben von Wortspielen ist nützlich für Aufgaben, die Zugriff auf die Basisdarstellung eines Objekts erfordern, um die bereitgestellten Daten anzuzeigen, zu transportieren oder zu bearbeiten. Typische Bereiche, in denen wir auf die Verwendung von Wortspielen stoßen können: Compiler, Serialisierung, Netzwerkcode usw.
Traditionell wurde dies erreicht, indem die Adresse des Objekts in einen Zeiger auf den Typ umgewandelt wurde, den wir interpretieren möchten, und dann auf den Wert zugegriffen wurde, oder mit anderen Worten, unter Verwendung von Aliasen. Zum Beispiel:

int x = 1 ; //   C float *fp = (float*)&x ; //   //  C++ float *fp = reinterpret_cast<float*>(&x) ; //   printf( “%f\n”, *fp ) ; 

Wie wir bereits gesehen haben, ist dies ein inakzeptables Aliasing, das zu undefiniertem Verhalten führt. Aber traditionell verwendeten Compiler keine strengen Aliasing-Regeln, und diese Art von Code funktionierte normalerweise nur, und Entwickler sind es leider gewohnt, solche Dinge zuzulassen. Eine übliche alternative Wortspielmethode ist die Vereinigung, die in C gültig ist, in C ++ jedoch undefiniertes Verhalten verursacht ( siehe Beispiel ):

 union u1 { int n; float f; } ; union u1 u; uf = 1.0f; printf( "%d\n”, un ); // UB(undefined behaviour)  C++ “n is not the active member” 

Dies ist in C ++ nicht akzeptabel, und einige glauben, dass Gewerkschaften ausschließlich zur Implementierung von Variantentypen bestimmt sind, und halten die Verwendung von Gewerkschaften zum Eingeben von Wortspielen für einen Missbrauch.

Wie implementiere ich ein Wortspiel?

Die Standardmethode für die Eingabe von Wortspielen in C und C ++ ist memcpy. Dies mag etwas kompliziert erscheinen, aber der Optimierer muss die Verwendung von memcpy für das Wortspiel erkennen, optimieren und ein Register erstellen, um die Bewegung zu registrieren. Wenn wir beispielsweise wissen, dass int64_t dieselbe Größe wie double hat:

 static_assert( sizeof( double ) == sizeof( int64_t ) ); // C++17    

Wir können memcpy :

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

Mit einem ausreichenden Optimierungsgrad generiert jeder anständige moderne Compiler Code, der mit der zuvor erwähnten Methode reinterpret_cast oder der Join-Methode identisch ist, um ein Wortspiel zu erhalten. Wenn wir den generierten Code studieren, sehen wir, dass er nur das mov-Register verwendet ( Beispiel ).

Wortspielertypen und Arrays

Was aber, wenn wir das Wortspiel eines vorzeichenlosen char-Arrays in eine Reihe von vorzeichenlosen int implementieren und dann eine Operation für jeden vorzeichenlosen int-Wert ausführen möchten? Wir können memcpy verwenden, um ein nicht signiertes char-Array in einen temporären nicht gesungenen int-Typ umzuwandeln. Das Optimierungsprogramm kann weiterhin alles über memcpy anzeigen, sowohl das temporäre Objekt als auch die Kopie optimieren und direkt mit den zugrunde liegenden Daten arbeiten ( Beispiel ):

 //  ,    int foo( unsigned int x ) { return x ; } // ,  len  sizeof(unsigned int) int bar( unsigned char *p, size_t len ) { int result = 0; for( size_t index = 0; index < len; index += sizeof(unsigned int) ) { unsigned int ui = 0; std::memcpy( &ui, &p[index], sizeof(unsigned int) ); result += foo( ui ) ; } return result; } 

In diesem Beispiel nehmen wir char*p , nehmen an, dass es auf mehrere Fragmente der sizeof(unsigned int) Daten zeigt, interpretieren jedes Datenfragment als unsigned int , berechnen foo() für jedes Fragment des Wortspiels, fassen es im Ergebnis zusammen und geben den Endwert zurück .

Die Assembly für den Schleifenkörper zeigt, dass der Optimierer den Körper in direkten Zugriff auf das unsigned char base-Array als unsigned int verwandelt und es direkt zu eax hinzufügt:

 add eax, dword ptr [rdi + rcx] 

Derselbe Code, jedoch mit reinterpret_cast , um ein Wortspiel zu implementieren (verstößt gegen striktes Aliasing):

 // ,  len  sizeof(unsigned int) int bar( unsigned char *p, size_t len ) { int result = 0; for( size_t index = 0; index < len; index += sizeof(unsigned int) ) { unsigned int ui = *reinterpret_cast<unsigned int*>(&p[index]); result += foo( ui ); } return result; } 

C ++ 20 und bit_cast

In C ++ 20 haben wir bit_cast , das eine einfache und sichere Möglichkeit zur Interpretation bietet und auch im Kontext von constexpr .

Das folgende Beispiel zeigt, wie mit bit_cast eine vorzeichenlose Ganzzahl in einem float interpretiert wird ( Beispiel ):

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

Für den Fall, dass die Typen An und Von nicht dieselbe Größe haben, müssen wir eine Zwischenstruktur verwenden. Wir werden eine Struktur verwenden, die ein Zeichenarray-Vielfaches von sizeof(unsigned int) (ein 4-Byte-vorzeichenloses int wird angenommen) als From-Typ und unsigned int als To-Typ enthält:

 struct uint_chars { unsigned char arr[sizeof( unsigned int )] = {} ; //  sizeof( unsigned int ) == 4 }; //  len  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 ; } 

Leider benötigen wir diesen Zwischentyp - dies ist die aktuelle bit_cast Einschränkung.

Ausrichtung

In früheren Beispielen haben wir gesehen, dass ein Verstoß gegen strenge Aliasing-Regeln zum Ausschluss von Speicher während der Optimierung führen kann. Verstöße gegen striktes Aliasing können auch zu Verstößen gegen die Ausrichtungsanforderungen führen. Sowohl die C-Standards als auch C ++ besagen, dass Objekte Ausrichtungsanforderungen unterliegen, die den Ort einschränken, an dem Objekte platziert werden können (im Speicher) und daher zugänglich sind. C11 Abschnitt 6.2.8 Ausrichtung von Objektzuständen :

Für vollständige Objekttypen gelten Ausrichtungsanforderungen, die die Adressen einschränken, an denen Objekte dieses Typs platziert werden können. Die Ausrichtung ist ein implementierungsdefinierter ganzzahliger Wert, der die Anzahl der Bytes zwischen aufeinanderfolgenden Adressen darstellt, an denen dieses Objekt platziert werden kann. Der Typ des Objekts stellt für jedes Objekt dieses Typs eine Ausrichtungsanforderung: Mit dem _Alignas kann eine strengere Ausrichtung angefordert werden.

Der C ++ 17-Projektstandard in Abschnitt 1 [basic.align] :

Für Objekttypen gelten Ausrichtungsanforderungen (6.7.1, 6.7.2), die die Adressen einschränken, an denen ein Objekt dieses Typs platziert werden kann. Die Ausrichtung ist ein implementierungsdefinierter ganzzahliger Wert, der die Anzahl der Bytes zwischen aufeinanderfolgenden Adressen darstellt, an denen ein bestimmtes Objekt platziert werden kann. Ein Objekttyp stellt für jedes Objekt dieses Typs eine Ausrichtungsanforderung. Eine strengere Ausrichtung kann mit dem Ausrichtungsspezifizierer (10.6.2) angefordert werden.

Sowohl C99 als auch C11 weisen ausdrücklich darauf hin, dass eine Konvertierung, die zu einem nicht ausgerichteten Zeiger führt, ein undefiniertes Verhalten ist (Abschnitt 6.3.2.3). Zeiger sagt:
Ein Zeiger auf ein Objekt oder einen Teiltyp kann in einen Zeiger auf ein anderes Objekt oder einen Teiltyp konvertiert werden. Wenn der resultierende Zeiger für den Zeigertyp nicht korrekt ausgerichtet ist, ist das Verhalten undefiniert. ...
Obwohl C ++ nicht so offensichtlich ist, glaube ich, dass dieser Satz aus Absatz 1 [basic.align] ausreicht:
... Der Objekttyp stellt für jedes Objekt dieses Typs eine Ausrichtungsanforderung. ...
Beispiel

Nehmen wir also an:

  • alignof (char) und alignof (int) sind 1 bzw. 4
  • sizeof (int) ist 4

Das Interpretieren eines char-Arrays der Größe 4 als int verstößt somit gegen striktes Aliasing und kann auch gegen die Ausrichtungsanforderungen verstoßen, wenn das Array eine Ausrichtung von 1 oder 2 Bytes aufweist.

 char arr[4] = { 0x0F, 0x0, 0x0, 0x00 }; //        1  2  int x = *reinterpret_cast<int*>(arr); // Undefined behavior   

Dies kann in einigen Situationen zu Leistungseinbußen oder Busfehlern führen. Während die Verwendung von Alignas zum Erzwingen der gleichen Ausrichtung für ein Array in int verhindert, dass die Ausrichtungsanforderungen nicht erfüllt werden:

 alignas(alignof(int)) char arr[4] = { 0x0F, 0x0, 0x0, 0x00 }; int x = *reinterpret_cast<int*>(arr); 

Atomizität

Eine weitere unerwartete Strafe für unausgeglichenen Zugriff besteht darin, dass die Atomizität einiger Architekturen verletzt wird. Atomspeicher werden für andere Threads in x86 möglicherweise nicht atomar angezeigt, wenn sie nicht ausgerichtet sind.

Auffangen strenger Aliasing-Verstöße

Wir haben nicht viele gute Tools, um striktes Aliasing in C ++ zu verfolgen. Die Tools, die wir haben, werden einige Fälle von Verstößen und einige Fälle von unsachgemäßem Laden und Speichern auffangen.

gcc mit den -fstrict-aliasing und -Wstrict-aliasing kann einige Fälle abfangen, allerdings nicht ohne Fehlalarme / Probleme. In den folgenden Fällen wird beispielsweise eine Warnung in gcc generiert ( Beispiel ):

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

obwohl er diesen zusätzlichen Fall nicht erfassen wird ( Beispiel ):

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

Obwohl clang diese Flags auflöst, scheint es die Warnung nicht tatsächlich zu implementieren.

Ein weiteres Tool, das wir haben, ist ASan, das falsch ausgerichtete Aufzeichnungen und Speicherungen erkennen kann. Obwohl es sich nicht um direkte Verstöße gegen striktes Aliasing handelt, ist dies ein recht häufiges Ergebnis. In den folgenden Fällen werden beispielsweise Laufzeitfehler während der Assemblierung mithilfe von clang mit -fsanitize=address

 int *x = new int[2]; // 8 : [0,7]. int *u = (int*)((char*)x + 6); //     x    *u = 1; //    [6-9] printf( "%d\n", *u ); //    [6-9] 

Das letzte Tool, das ich empfehle, ist spezifisch für C ++ und in der Tat nicht nur ein Tool, sondern auch eine Codierungspraxis, die kein Casting im C-Stil erlaubt. Sowohl gcc als auch clang führen eine Diagnose für -Wold-style-cast mit -Wold-style-cast . Dadurch werden alle undefinierten Wortspiele gezwungen, reinterpret_cast zu verwenden. Im Allgemeinen sollte reinterpret_cast ein Signal für eine gründlichere Codeanalyse sein.
Es ist auch einfacher, die Codebasis nach reinterpret_cast zu durchsuchen, um eine Prüfung durchzuführen.

Für C verfügen wir über alle bereits beschriebenen Tools und über einen tis-interpreter , einen statischen Analysator, der das Programm ausführlich auf eine große Teilmenge von C analysiert. In den C-Versionen des vorherigen Beispiels wird bei Verwendung von -fstrict-aliasing ein Fall übersprungen ( Beispiel )

 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)); 

Der TIS-Interpreter kann alle drei abfangen. Im folgenden Beispiel wird der TIS-Kernel als TIS-Interpreter bezeichnet (die Ausgabe wird der Kürze halber bearbeitet):

 ./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. 

Und schließlich TySan , das sich in der Entwicklung befindet. Dieses Desinfektionsprogramm fügt dem Schattenspeichersegment Informationen zur Typprüfung hinzu und überprüft die Zugriffe, um festzustellen, ob sie gegen die Aliasing-Regeln verstoßen. Das Tool sollte möglicherweise in der Lage sein, alle Aliasing-Verstöße zu verfolgen, kann jedoch zur Laufzeit einen hohen Overhead haben.

Fazit

Wir haben etwas über Aliasing-Regeln in C und C ++ gelernt, was bedeutet, dass der Compiler von uns erwartet, dass wir diese Regeln strikt befolgen und die Konsequenzen akzeptieren, wenn wir sie nicht erfüllen. Wir haben einige Tools kennengelernt, mit denen wir Pseudonymmissbrauch identifizieren können. Wir haben gesehen, dass die übliche Verwendung von Aliasing ein Wortspiel der Typisierung ist. Wir haben auch gelernt, wie man es richtig implementiert.

Optimierer verbessern schrittweise die typbasierte Alias-Analyse und brechen bereits Code, der auf strengen Aliasing-Verstößen basiert. Wir können erwarten, dass Optimierungen besser werden und noch mehr Code brechen, der gerade funktioniert hat.

Wir haben vorgefertigte kompatible Standardmethoden für die Interpretation von Typen. Manchmal sollten diese Methoden für Debug-Builds freie Abstraktionen sein. Wir haben verschiedene Tools zum Erkennen schwerwiegender Aliasing-Verstöße, aber für C ++ werden sie nur einen kleinen Teil der Fälle erfassen, und für C können wir mit dem tis-Interpreter die meisten Verstöße verfolgen.

Vielen Dank an diejenigen, die diesen Artikel kommentiert haben: JF Bastien, Christopher Di Bella, Pascal Quoc, Matt P. Dziubinski, Patrice Roy und Olafur Vaage
Am Ende gehören natürlich alle Fehler dem Autor.

Damit ist die Übersetzung eines ziemlich großen Materials beendet, dessen erster Teil hier gelesen werden kann . Und wir laden Sie traditionell zum Tag der offenen Tür ein , der am 14. März vom Leiter der Abteilung für Technologieentwicklung bei Rambler & Co - Dmitry Shebordaev abgehalten wird.

Source: https://habr.com/ru/post/de443602/


All Articles