Powiem to jasno: w naszych programach nie odwołujemy się do nieokreślonego zachowania . To nigdy nie jest dobry pomysł, kropka. Istnieją rzadkie wyjątki od tej zasady; na przykład, jeśli jesteś implementatorem biblioteki implementującym offsetof . Jeśli Twoja sprawa objęta jest takim wyjątkiem, prawdopodobnie już o tym wiesz. W tym przypadku wiemy, że użycie niezainicjowanych zmiennych automatycznych jest zachowaniem niezdefiniowanym .
Kompilatory stały się bardzo agresywne dzięki optymalizacjom dotyczącym nieokreślonego zachowania i możemy znaleźć wiele przypadków, w których nieokreślone zachowanie doprowadziło do wad bezpieczeństwa. Najbardziej niesławnym przypadkiem jest prawdopodobnie usunięcie sprawdzania pustego wskaźnika jądra systemu Linux, o którym wspomniałem w mojej odpowiedzi na błąd kompilacji C ++? gdzie optymalizacja kompilatora wokół niezdefiniowanego zachowania zamieniła skończoną pętlę w nieskończoną.
Możemy przeczytać Niebezpieczne optymalizacje CERT i utratę przyczynowości ( wideo ), które mówią między innymi:
W coraz większym stopniu twórcy kompilatorów wykorzystują niezdefiniowane zachowania w językach programowania C i C ++ w celu poprawy optymalizacji.
Często te optymalizacje zakłócają zdolność programistów do przeprowadzania analizy przyczynowo-skutkowej na ich kodzie źródłowym, to znaczy analizy zależności wyników końcowych od wcześniejszych wyników.
W związku z tym te optymalizacje eliminują przyczynowość w oprogramowaniu i zwiększają prawdopodobieństwo błędów oprogramowania, usterek i luk w zabezpieczeniach.
W szczególności w odniesieniu do nieokreślonych wartości, raport defektu standardowego C 451: Niestabilność niezainicjowanych zmiennych automatycznych stanowi ciekawy odczyt. Nie został jeszcze rozwiązany, ale wprowadza pojęcie niestabilnych wartości, co oznacza, że nieokreśloność wartości może rozprzestrzeniać się w programie i może mieć różne nieokreślone wartości w różnych punktach programu.
Nie znam żadnych przykładów, w których tak się dzieje, ale w tym momencie nie możemy tego wykluczyć.
Prawdziwe przykłady, a nie oczekiwany wynik
Jest mało prawdopodobne, aby uzyskać losowe wartości. Kompilator może całkowicie zoptymalizować pętlę odejścia. Na przykład w tym uproszczonym przypadku:
void updateEffect(int arr[20]){
for(int i=0;i<20;i++){
int r ;
arr[i] = r ;
}
}
clang optymalizuje go ( zobacz na żywo ):
updateEffect(int*): # @updateEffect(int*)
retq
lub może wszystkie zera, jak w tym zmodyfikowanym przypadku:
void updateEffect(int arr[20]){
for(int i=0;i<20;i++){
int r ;
arr[i] = r%255 ;
}
}
zobacz na żywo :
updateEffect(int*): # @updateEffect(int*)
xorps %xmm0, %xmm0
movups %xmm0, 64(%rdi)
movups %xmm0, 48(%rdi)
movups %xmm0, 32(%rdi)
movups %xmm0, 16(%rdi)
movups %xmm0, (%rdi)
retq
Oba te przypadki są całkowicie akceptowalnymi formami niezdefiniowanego zachowania.
Uwaga: jeśli jesteśmy na Itanium, możemy otrzymać wartość pułapki :
[...] jeśli rejestr zawiera specjalną nieistotną wartość, odczytuje pułapki rejestrów, z wyjątkiem kilku instrukcji [...]
Inne ważne uwagi
Interesujące jest odnotowanie różnicy między gcc i clang, odnotowanej w projekcie Kanarek UB, nad tym, jak chętnie wykorzystują niezdefiniowane zachowanie w odniesieniu do niezainicjowanej pamięci. Artykuł zauważa ( moje podkreślenie ):
Oczywiście musimy mieć całkowitą jasność wobec siebie, że wszelkie takie oczekiwania nie mają nic wspólnego ze standardem językowym i wszystko, co ma związek z tym, co dzieje się z konkretnym kompilatorem, albo dlatego, że dostawcy tego kompilatora nie chcą wykorzystywać tego UB, ani po prostu ponieważ jeszcze nie udało im się go wykorzystać . Kiedy nie ma żadnej prawdziwej gwarancji od dostawcy kompilatora, chcemy powiedzieć, że jeszcze niewykorzystane UB to bomby zegarowe : czekają na start w przyszłym miesiącu lub w przyszłym roku, kiedy kompilator stanie się nieco bardziej agresywny.
Jak zauważa Matthieu M. Co każdy programista C powinien wiedzieć o nieokreślonym zachowaniu # 2/3, jest również istotny dla tego pytania. Mówi między innymi ( moje podkreślenie ):
Ważną i przerażającą rzeczą jest uświadomienie sobie, że prawie jakakolwiek
optymalizacja oparta na niezdefiniowanym zachowaniu może zostać uruchomiona na błędnym kodzie w dowolnym momencie w przyszłości . Inlining, rozwijanie pętli, promocja pamięci i inne optymalizacje będą się poprawiać, a znaczną część ich powodów jest ujawnianie wtórnych optymalizacji, takich jak te powyżej.
Dla mnie jest to głęboko niezadowalające, częściowo dlatego, że kompilator nieuchronnie kończy się winą, ale także dlatego, że oznacza to, że ogromne części kodu C to miny lądowe, które tylko czekają na wybuch.
Dla kompletności powinienem chyba wspomnieć, że implementacje mogą zdecydować o tym, aby niezdefiniowane zachowanie było dobrze zdefiniowane, na przykład gcc pozwala na pisanie przez związki, podczas gdy w C ++ wydaje się to niezdefiniowanym zachowaniem . W takim przypadku wdrożenie powinno to udokumentować i zwykle nie będzie to przenośne.