Pytając o typowe, niezdefiniowane zachowanie w C , ludzie czasami odnoszą się do ścisłej zasady aliasingu.
O czym oni rozmawiają?
Pytając o typowe, niezdefiniowane zachowanie w C , ludzie czasami odnoszą się do ścisłej zasady aliasingu.
O czym oni rozmawiają?
Odpowiedzi:
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_t
s lub uint16_t
s). 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ść buff
każ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ść buff
moż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 SendMessage
jest 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 char
i 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 .
1 Rodzaje, do których dostęp C 2011 6.5 7 umożliwia dostęp do wartości, to:
unsigned char*
można użyć daleko char*
? Zwykle używam unsigned char
raczej niż char
jako podstawowy typ, byte
ponieważ moje bajty nie są podpisane i nie chcę dziwności podpisanych zachowań (zwłaszcza wrt do przepełnienia)
unsigned char *
jest w porządku.
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. malloc
Rozmowa 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 ...
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, int
a następnie wskazujesz float*
na tę pamięć i używasz jej jako float
zasady ł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.
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
char
lubunsigned char
wpisz.
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
char
lubunsigned char
wpisz.
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.
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.
Jest to fragment mojej „Co to jest zasada ścisłego aliasowania i dlaczego nas to obchodzi?” napisać
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.
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 .
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.
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.
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 .
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.
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 ).
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 .
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.
reinterpret_cast
może zrobić ani co cout
moż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.)
Ś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.
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.
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.
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.
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.
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 p
może wskazywać x
, i przypisanie, które *p
moż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_int
ponieważ wszystkie dostępy do pamięci, do której *p
uzyskano dostęp, są wykonywane z użyciem wartości typu int
, i nie ma konfliktu, test
ponieważ p
jest wyraźnie wywiedziony z struct S
, a przy następnym s
uż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 p
dostępem do s.x
oznaczonej 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.
Po przeczytaniu wielu odpowiedzi czuję potrzebę dodania czegoś:
Ścisłe aliasing (który opiszę za chwilę) jest ważne, ponieważ :
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.
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ż restrict
sł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:
ładuj a
ib
z pamięci.
dodaj a
do b
.
zapisz b
i załaduj ponownie a
.
(zapisz z rejestru procesora do pamięci i załaduj z pamięci do rejestru procesora).
dodaj b
doa
.
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 b
wskazywanie 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).
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) {...}
Za pomocą restrict
sł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 restrict
słowa kluczowego można zoptymalizować całą funkcję, aby:
ładowanie a
ib
z pamięci.
dodaj a
do b
.
zapisz wynik zarówno do, jak a
i do b
.
Ta optymalizacja nie mogła być wcześniej wykonana z powodu możliwej kolizji (gdzie a
i b
byłby potrojony zamiast podwojony).
b
(nie przeładowujemy) i przeładowujemy a
. Mam nadzieję, że teraz jest jaśniej.
restrict
, ale sądzę, że ten drugi w większości przypadków byłby bardziej skuteczny, a złagodzenie niektórych ograniczeń register
pozwoliłoby mu wypełnić niektóre przypadki, w których restrict
nie 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 .
restrict
sł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 :)
Ś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.
int
Struktura zawierająca an int
).
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.
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 foo
zmienił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 char
za pomocą wartości typu int
?
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 volatile
kwalifikowanemu wskaźnikowi na dostęp do rejestrów sprzętowych, które nie spełniają definicji „obiektu”.
c
ic++faq
.