Niezdefiniowane zachowanie - Undefined behavior

W programowaniu komputerowym , niezdefiniowane zachowanie ( UB ) jest wynikiem wykonania programu, którego zachowanie jest przepisywany być nieprzewidywalne, w specyfikacji języka do którego kod komputerowy przylega. Różni się to od nieokreślonego zachowania , dla którego specyfikacja języka nie określa wyniku, oraz od zachowania zdefiniowanego przez implementację, które odnosi się do dokumentacji innego komponentu platformy (takiego jak ABI lub dokumentacja tłumacza ).

W społeczności C niezdefiniowane zachowanie może być żartobliwie nazywane „ nosowymi demonami ”, po poście na comp.std.c wyjaśniającym niezdefiniowane zachowanie jako pozwolenie kompilatorowi na zrobienie wszystkiego, co zechce, nawet „wyrzucenie demonów z nosa”. ”.

Przegląd

Niektóre języki programowania pozwalają programowi działać inaczej lub nawet mieć inny przepływ sterowania niż kod źródłowy , o ile wykazuje te same widoczne dla użytkownika skutki uboczne , jeśli niezdefiniowane zachowanie nigdy nie występuje podczas wykonywania programu . Niezdefiniowane zachowanie to nazwa listy warunków, których program nie może spełnić.

We wczesnych wersjach C , główną zaletą niezdefiniowanego zachowania było tworzenie wydajnych kompilatorów dla szerokiej gamy maszyn: konkretną konstrukcję można było mapować do funkcji specyficznej dla maszyny, a kompilator nie musiał generować dodatkowego kodu dla środowiska wykonawczego dostosować efekty uboczne do semantyki narzuconej przez język. Kod źródłowy programu został napisany z uprzednią wiedzą o konkretnym kompilatorze i platformach, które będzie obsługiwał.

Jednak postępująca standaryzacja platform sprawiła, że ​​jest to mniej korzystne, zwłaszcza w nowszych wersjach C. Obecnie przypadki niezdefiniowanego zachowania zazwyczaj reprezentują jednoznaczne błędy w kodzie, na przykład indeksowanie tablicy poza jej granicami. Z definicji środowisko wykonawcze może zakładać, że niezdefiniowane zachowanie nigdy się nie dzieje; dlatego niektóre nieprawidłowe warunki nie muszą być sprawdzane. Dla kompilatora oznacza to również, że różne przekształcenia programu stają się poprawne lub ich dowody poprawności są uproszczone; pozwala to na różnego rodzaju przedwczesną optymalizację i mikrooptymalizację , które prowadzą do nieprawidłowego zachowania, jeśli stan programu spełnia którykolwiek z tych warunków. Kompilator może również usunąć jawne kontrole, które mogły znajdować się w kodzie źródłowym, bez powiadamiania programisty; na przykład wykrycie niezdefiniowanego zachowania poprzez sprawdzenie, czy tak się stało, z definicji nie ma gwarancji, że zadziała. Utrudnia to lub uniemożliwia zaprogramowanie przenośnej opcji odpornej na awarie (nieprzenośne rozwiązania są możliwe dla niektórych konstrukcji).

Obecny rozwój kompilatora zwykle ocenia i porównuje wydajność kompilatora z testami porównawczymi zaprojektowanymi wokół mikrooptymalizacji, nawet na platformach, które są najczęściej używane na rynku komputerów stacjonarnych i laptopów ogólnego przeznaczenia (takich jak amd64). Dlatego niezdefiniowane zachowanie zapewnia dużo miejsca na poprawę wydajności kompilatora, ponieważ kod źródłowy dla określonej instrukcji kodu źródłowego może być mapowany na dowolny element w czasie wykonywania.

W przypadku C i C++ kompilator może w takich przypadkach podać diagnostykę w czasie kompilacji, ale nie jest to wymagane: implementacja zostanie uznana za poprawną niezależnie od tego, co robi w takich przypadkach, analogicznie do terminów „ nieważne” w logice cyfrowej . Obowiązkiem programisty jest napisanie kodu, który nigdy nie wywołuje niezdefiniowanego zachowania, chociaż implementacje kompilatora mogą w takim przypadku wystawiać diagnostykę. Obecnie kompilatory mają flagi, które umożliwiają taką diagnostykę, na przykład włączają -fsanitize„niezdefiniowane zachowanie sanitizera” ( UBSan ) w gcc 4.9 i clang . Jednak ta flaga nie jest domyślna i włączenie jej to wybór, kto buduje kod.

W pewnych okolicznościach mogą istnieć określone ograniczenia dotyczące niezdefiniowanego zachowania. Na przykład specyfikacje zestawu instrukcji procesora mogą pozostawić zachowanie niektórych form instrukcji niezdefiniowane, ale jeśli procesor obsługuje ochronę pamięci, specyfikacja prawdopodobnie będzie zawierać ogólną regułę stwierdzającą, że żadna instrukcja dostępna dla użytkownika nie może spowodować dziury w w systemie operacyjnym bezpieczeństwo „s; tak więc rzeczywisty procesor mógłby uszkadzać rejestry użytkownika w odpowiedzi na taką instrukcję, ale nie mógłby, na przykład, przełączyć się w tryb nadzorcy .

Platforma środowiska wykonawczego może również zapewnić pewne ograniczenia lub gwarancje dotyczące niezdefiniowanego zachowania, jeśli łańcuch narzędzi lub środowisko wykonawcze wyraźnie dokumentuje, że określone konstrukcje znalezione w kodzie źródłowym są mapowane na określone, dobrze zdefiniowane mechanizmy dostępne w czasie wykonywania. Na przykład interpreter może udokumentować pewne zachowanie dla niektórych operacji, które są niezdefiniowane w specyfikacji języka, podczas gdy inne interpretery lub kompilatory dla tego samego języka mogą nie. Kompilator generuje kod wykonywalny dla konkretnego ABI , wypełniając semantycznej lukę w sposób, które są zależne od wersji kompilatora: dokumentację dla tej wersji kompilatora i specyfikacji ABI może stanowić ograniczenia na zachowanie niezdefiniowane. Poleganie na tych szczegółach implementacji sprawia, że ​​oprogramowanie jest nieprzenośne , jednak przenośność może nie stanowić problemu, jeśli oprogramowanie nie ma być używane poza określonym środowiskiem wykonawczym.

Niezdefiniowane zachowanie może spowodować awarię programu, a nawet awarie, które są trudniejsze do wykrycia i sprawiają, że program wygląda, jakby działał normalnie, na przykład cicha utrata danych i generowanie błędnych wyników.

Korzyści

Dokumentowanie operacji jako niezdefiniowanego zachowania pozwala kompilatorom założyć, że ta operacja nigdy nie wystąpi w zgodnym programie. Daje to kompilatorowi więcej informacji o kodzie, a te informacje mogą prowadzić do większych możliwości optymalizacji.

Przykład dla języka C:

int foo(unsigned char x)
{
     int value = 2147483600; /* assuming 32-bit int and 8-bit char */
     value += x;
     if (value < 2147483600)
        bar();
     return value;
}

Wartość xnie może być ujemna i biorąc pod uwagę, że przepełnienie liczby całkowitej ze znakiem jest niezdefiniowanym zachowaniem w języku C, kompilator może założyć, że value < 2147483600zawsze będzie fałszywe. W ten sposób ifinstrukcja, w tym wywołanie funkcji bar, może zostać zignorowana przez kompilator, ponieważ wyrażenie testowe w ifnie ma skutków ubocznych, a jego warunek nigdy nie zostanie spełniony. Kod jest zatem semantycznie równoważny z:

int foo(unsigned char x)
{
     int value = 2147483600;
     value += x;
     return value;
}

Gdyby kompilator został zmuszony do założenia, że ​​przepełnienie liczby całkowitej ze znakiem ma zachowanie owijające , to powyższa transformacja nie byłaby legalna.

Takie optymalizacje stają się trudne do wykrycia przez ludzi, gdy kod jest bardziej złożony i mają miejsce inne optymalizacje, takie jak inlining . Na przykład inna funkcja może wywołać powyższą funkcję:

void run_tasks(unsigned char *ptrx) {
    int z;
    z = foo(*ptrx);
    while (*ptrx > 60) {
        run_one_task(ptrx, z);
    }
}

Kompilator może tutaj zoptymalizować whilepętlę - poprzez zastosowanie analizy zakresu wartości : sprawdzając foo(), wie, że wartość początkowa wskazywana przez ptrxnie może prawdopodobnie przekroczyć 47 (ponieważ więcej wywołałoby niezdefiniowane zachowanie w foo()), dlatego wstępne sprawdzenie *ptrx > 60woli zawsze być fałszywym w zgodnym programie. Idąc dalej, ponieważ wynik znigdy nie jest używany i foo()nie ma skutków ubocznych, kompilator może zoptymalizować run_tasks()się tak, aby był pustą funkcją, która zwraca natychmiast. Zniknięcie whilepętli -loop może być szczególnie zaskakujące, jeśli foo()jest zdefiniowane w oddzielnie skompilowanym pliku obiektowym .

Inną korzyścią wynikającą z zezwolenia na niezdefiniowanie przepełnienia liczby całkowitej ze znakiem jest to, że umożliwia przechowywanie i manipulowanie wartością zmiennej w rejestrze procesora, który jest większy niż rozmiar zmiennej w kodzie źródłowym. Na przykład, jeśli typ zmiennej określony w kodzie źródłowym jest węższy niż natywna szerokość rejestru (np. „ int ” na maszynie 64-bitowej , typowy scenariusz), wówczas kompilator może bezpiecznie użyć podpisanego 64- bitowa liczba całkowita dla zmiennej w wytwarzanym przez nią kodzie maszynowym , bez zmiany zdefiniowanego zachowania kodu. Jeśli program zależałby od zachowania 32-bitowego przepełnienia liczb całkowitych, kompilator musiałby wstawić dodatkową logikę podczas kompilacji dla maszyny 64-bitowej, ponieważ zachowanie przepełnienia większości instrukcji maszyny zależy od szerokości rejestru.

Niezdefiniowane zachowanie umożliwia również więcej kontroli w czasie kompilacji przez kompilatory i analizę programów statycznych .

Zagrożenia

Standardy C i C++ mają kilka form niezdefiniowanego zachowania, które zapewniają większą swobodę w implementacjach kompilatora i sprawdzaniu czasu kompilacji kosztem niezdefiniowanego zachowania w czasie wykonywania, jeśli występuje. W szczególności norma ISO dla języka C zawiera załącznik wymieniający typowe źródła niezdefiniowanego zachowania. Co więcej, kompilatory nie są wymagane do diagnozowania kodu, który opiera się na niezdefiniowanym zachowaniu. Dlatego często zdarza się, że programiści, nawet doświadczeni, polegają na nieokreślonym zachowaniu przez pomyłkę lub po prostu dlatego, że nie są dobrze zorientowani w regułach języka, który może obejmować setki stron. Może to spowodować błędy, które są ujawniane, gdy używany jest inny kompilator lub inne ustawienia. Testowanie lub fuzzing z włączonymi dynamicznymi kontrolami niezdefiniowanego zachowania, np. środkami odkażającymi Clang , może pomóc w wychwyceniu niezdefiniowanego zachowania, które nie zostało zdiagnozowane przez kompilator lub analizatory statyczne.

Niezdefiniowane zachowanie może prowadzić do zabezpieczenia luk w oprogramowaniu. Na przykład przepełnienia bufora i inne luki w zabezpieczeniach w głównych przeglądarkach internetowych są spowodowane niezdefiniowanym zachowaniem. Problem roku 2038 to kolejny przykład ze względu na podpisaną całkowitej przepełnienie . Kiedy programiści GCC zmienili swój kompilator w 2008 roku tak, że pomijał on pewne przepełnienia sprawdzające, które opierały się na niezdefiniowanym zachowaniu, CERT wydał ostrzeżenie przed nowszymi wersjami kompilatora. Linux Weekly News wskazał, że to samo zachowanie zaobserwowano w PathScale C , Microsoft Visual C++ 2005 i kilku innych kompilatorach; ostrzeżenie zostało później zmienione, aby ostrzegać o różnych kompilatorach.

Przykłady w C i C++

Główne formy niezdefiniowanego zachowania w języku C można ogólnie sklasyfikować jako: naruszenia bezpieczeństwa pamięci przestrzennej, naruszenia bezpieczeństwa pamięci czasowej, przepełnienie liczby całkowitej , naruszenia ścisłego aliasingu, naruszenia wyrównania, niesekwencyjne modyfikacje, wyścigi danych i pętle, które nie wykonują operacji we/wy ani nie kończą .

W C użycie dowolnej zmiennej automatycznej przed jej zainicjowaniem daje niezdefiniowane zachowanie, podobnie jak dzielenie liczb całkowitych przez zero , przepełnienie liczby całkowitej ze znakiem, indeksowanie tablicy poza jej zdefiniowanymi granicami (patrz przepełnienie bufora ) lub wyłuskiwanie wskaźnika zerowego . Ogólnie rzecz biorąc, każda instancja niezdefiniowanego zachowania pozostawia abstrakcyjną maszynę wykonawczą w nieznanym stanie i powoduje, że zachowanie całego programu jest niezdefiniowane.

Próba modyfikacji literału ciągu powoduje niezdefiniowane zachowanie:

char *p = "wikipedia"; // valid C, deprecated in C++98/C++03, ill-formed as of C++11
p[0] = 'W'; // undefined behavior

Dzielenie liczb całkowitych przez zero skutkuje niezdefiniowanym zachowaniem:

int x = 1;
return x / 0; // undefined behavior

Niektóre operacje na wskaźnikach mogą skutkować niezdefiniowanym zachowaniem:

int arr[4] = {0, 1, 2, 3};
int *p = arr + 5;  // undefined behavior for indexing out of bounds
p = 0;
int a = *p;        // undefined behavior for dereferencing a null pointer

W C i C++ relacyjne porównanie wskaźników do obiektów (dla porównania mniejsze lub większe) jest ściśle zdefiniowane tylko wtedy, gdy wskaźniki wskazują na członków tego samego obiektu lub elementy tej samej tablicy . Przykład:

int main(void)
{
  int a = 0;
  int b = 0;
  return &a < &b; /* undefined behavior */
}

Osiągnięcie końca funkcji zwracającej wartość (innego niż main()) bez instrukcji return skutkuje niezdefiniowanym zachowaniem, jeśli wartość wywołania funkcji jest używana przez wywołującego:

int f()
{
}  /* undefined behavior if the value of the function call is used*/

Modyfikowanie obiektu między dwoma punktami sekwencji więcej niż raz powoduje niezdefiniowane zachowanie. Istnieją znaczne zmiany w tym, co powoduje niezdefiniowane zachowanie w odniesieniu do punktów sekwencji od C++11. Nowoczesne kompilatory mogą emitować ostrzeżenia, gdy napotkają wiele niesekwencyjnych modyfikacji tego samego obiektu. Poniższy przykład spowoduje niezdefiniowane zachowanie zarówno w C, jak i C++.

int f(int i) {
  return i++ + i++; /* undefined behavior: two unsequenced modifications to i */
}

Podczas modyfikowania obiektu między dwoma punktami sekwencji odczytywanie wartości obiektu w jakimkolwiek innym celu niż określenie wartości, która ma być przechowywana, również jest zachowaniem niezdefiniowanym.

a[i] = i++; // undefined behavior
printf("%d %d\n", ++n, power(2, n)); // also undefined behavior

W C/C++ bitowe przesunięcie wartości o liczbę bitów, która jest albo liczbą ujemną, albo większą lub równą całkowitej liczbie bitów w tej wartości, skutkuje niezdefiniowanym zachowaniem. Najbezpieczniejszym sposobem (niezależnie od dostawcy kompilatora) jest zawsze trzymanie liczby bitów do przesunięcia (prawy operand operatorów<< i >> bitowych ) w zakresie: < > (gdzie jest lewym operandem). 0, sizeof(value)*CHAR_BIT - 1value

int num = -1;
unsigned int val = 1 << num; //shifting by a negative number - undefined behavior

num = 32; //or whatever number greater than 31
val = 1 << num; //the literal '1' is typed as a 32-bit integer - in this case shifting by more than 31 bits is undefined behavior

num = 64; //or whatever number greater than 63
unsigned long long val2 = 1ULL << num; //the literal '1ULL' is typed as a 64-bit integer - in this case shifting by more than 63 bits is undefined behavior

Zobacz też

Bibliografia

Dalsza lektura

Linki zewnętrzne