Uzyskanie dostępu do tablicy poza granicami nie powoduje błędu, dlaczego?

177

Przypisuję wartości w programie C ++ poza granicami w następujący sposób:

#include <iostream>
using namespace std;
int main()
{
    int array[2];
    array[0] = 1;
    array[1] = 2;
    array[3] = 3;
    array[4] = 4;
    cout << array[3] << endl;
    cout << array[4] << endl;
    return 0;
}

Program drukuje 3i 4. To nie powinno być możliwe. Używam g ++ 4.3.3

Oto polecenie kompilacji i uruchomienia

$ g++ -W -Wall errorRange.cpp -o errorRange
$ ./errorRange
3
4

Dopiero przy przypisywaniu array[3000]=3000daje mi to błąd segmentacji.

Jeśli gcc nie sprawdza granic tablicy, jak mogę się upewnić, że mój program jest poprawny, ponieważ może to później doprowadzić do poważnych problemów?

Powyższy kod zamieniłem na

vector<int> vint(2);
vint[0] = 0;
vint[1] = 1;
vint[2] = 2;
vint[5] = 5;
cout << vint[2] << endl;
cout << vint[5] << endl;

i ten również nie powoduje błędu.

seg.server.fault
źródło
3
Powiązane pytanie: stackoverflow.com/questions/671703/…
TSomKes
16
Kod jest oczywiście błędny, ale generuje niezdefiniowane zachowanie. Niezdefiniowany oznacza, że ​​może, ale nie musi, działać do końca. Nie ma gwarancji awarii.
dmckee --- kociak byłego moderatora
4
Możesz być pewien, że twój program jest poprawny, nie majstrując przy surowych tablicach. Programiści C ++ powinni zamiast tego używać klas kontenerów, z wyjątkiem programowania wbudowanego / OS. Przeczytaj to z powodów do kontenerów użytkowników. parashift.com/c++-faq-lite/containers.html
jkeys
8
Pamiętaj, że wektory niekoniecznie sprawdzają zakres za pomocą []. Użycie .at () robi to samo co [], ale sprawdza zakres.
David Thornley
4
A vector nie zmienia rozmiaru automatycznie podczas uzyskiwania dostępu do elementów spoza zakresu! To tylko UB!
Pavel Minaev

Odpowiedzi:

364

Witaj u najlepszego przyjaciela programisty C / C ++: Undefined Behavior .

Jest wiele rzeczy, które nie są określone w standardzie językowym z różnych powodów. To jest jeden z nich.

Ogólnie rzecz biorąc, za każdym razem, gdy napotkasz niezdefiniowane zachowanie, wszystko może się zdarzyć. Aplikacja może się zawiesić, zawiesić, wysunąć napęd CD-ROM lub sprawić, że demony wyjdą Ci z nosa. Może sformatować dysk twardy lub wysłać wszystkie filmy porno do babci.

Może nawet wydawać się, że działa poprawnie , jeśli masz naprawdę pecha .

Język po prostu mówi, co powinno się stać, jeśli uzyskasz dostęp do elementów w granicach tablicy. Nieokreślone, co się stanie, jeśli wyjdziesz poza granice. Może się wydawać, że działa dzisiaj na twoim kompilatorze, ale nie jest to legalne C ani C ++ i nie ma gwarancji, że będzie nadal działać przy następnym uruchomieniu programu. Albo, że nie ma istotnych danych nadpisanych nawet teraz, i po prostu nie napotkały problemy, że ma zamiar przyczyny - jeszcze.

Jeśli chodzi o to, dlaczego nie ma sprawdzania granic, odpowiedź ma kilka aspektów:

  • Tablica jest pozostałością po C. Tablice C są tak prymitywne, jak to tylko możliwe. Tylko sekwencja elementów z ciągłymi adresami. Nie ma sprawdzania granic, ponieważ po prostu eksponuje pamięć surową. Wdrożenie solidnego mechanizmu sprawdzania granic byłoby prawie niemożliwe w C.
  • W C ++ sprawdzanie granic jest możliwe dla typów klas. Ale tablica jest nadal zwykłą starą zgodną z C. To nie jest klasa. Co więcej, C ++ jest również zbudowany na innej regule, która sprawia, że ​​sprawdzanie granic nie jest idealne. Główną zasadą C ++ jest „nie płacisz za to, czego nie używasz”. Jeśli twój kod jest poprawny, nie potrzebujesz sprawdzania granic i nie powinieneś być zmuszany do płacenia za narzut związany ze sprawdzaniem granic w czasie wykonywania.
  • Dlatego C ++ oferuje std::vectorszablon klasy, który pozwala na oba te rozwiązania. operator[]ma być wydajny. Standard języka nie wymaga sprawdzania granic (chociaż tego też nie zabrania). Wektor ma również at()funkcję składową, która gwarantuje sprawdzanie granic. W C ++ uzyskujesz to, co najlepsze z obu światów, jeśli używasz wektora. Otrzymujesz wydajność podobną do tablicowej bez sprawdzania granic i masz możliwość korzystania z dostępu z kontrolą granic, kiedy tego chcesz.
jalf
źródło
5
@Jaif: używamy tej tablicy od tak dawna, ale nadal dlaczego nie ma testu sprawdzającego tak prosty błąd?
seg.server.fault
7
Zasada projektowania C ++ była taka, że ​​nie powinien być wolniejszy niż równoważny kod w C, a C nie wykonuje sprawdzania powiązanego z tablicą. Zasadą projektowania C była w zasadzie szybkość, ponieważ była przeznaczona do programowania systemu. Sprawdzanie powiązań tablicy zajmuje trochę czasu, więc nie jest wykonywane. W przypadku większości zastosowań w C ++, i tak powinieneś używać kontenera, a nie tablicy, i możesz wybrać opcję sprawdzania związanego lub bez sprawdzania związanego, uzyskując dostęp do elementu odpowiednio za pośrednictwem .at () lub [].
KTC
4
@seg Taki czek coś kosztuje. Jeśli napiszesz poprawny kod, nie chcesz płacić tej ceny. Powiedziawszy to, stałem się kompletną konwersją do metody at () std :: vector, która JEST sprawdzana. Użycie go ujawniło sporo błędów w tym, co uważałem za „poprawny” kod.
10
Uważam, że stare wersje GCC faktycznie uruchomiły Emacsa i symulację Wież Hanoi, gdy napotkały pewne typy niezdefiniowanych zachowań. Jak powiedziałem, wszystko może się zdarzyć. ;)
jalf
4
Wszystko już zostało powiedziane, więc to tylko uzasadnia mały dodatek. W takich okolicznościach kompilacje debugowania mogą być bardzo wyrozumiałe w porównaniu z kompilacjami wydań. Ze względu na to, że informacje debugowania są zawarte w plikach binarnych debugowania, istnieje mniejsze prawdopodobieństwo, że coś istotnego zostanie nadpisane. Dlatego czasami wydaje się, że kompilacje debugowania działają dobrze, podczas gdy kompilacja wydania ulega awarii.
Rich
31

Korzystanie z g ++, można dodać opcję wiersza polecenia: -fstack-protector-all.

Na twoim przykładzie skutkowało to:

> g++ -o t -fstack-protector-all t.cc
> ./t
3
4
/bin/bash: line 1: 15450 Segmentation fault      ./t

To naprawdę nie pomaga znaleźć ani rozwiązać problemu, ale przynajmniej segfault poinformuje Cię, że coś jest nie tak.

Richard Corden
źródło
10
Właśnie znalazłem jeszcze lepszą opcję: -fmudflap
Hi-Angel
1
@ Hi-Angel: Nowoczesny odpowiednik -fsanitize=addresswyłapuje ten błąd zarówno w czasie kompilacji (w przypadku optymalizacji), jak iw czasie wykonywania.
Nate Eldredge
@NateEldredge +1, obecnie nawet używam -fsanitize=undefined,address. Ale warto zauważyć, że istnieją rzadkie przypadki narożne z biblioteką std, gdy dostęp poza granicami nie jest wykrywany przez środek dezynfekujący . Z tego powodu polecam dodatkowo skorzystać z -D_GLIBCXX_DEBUGopcji, która dodaje jeszcze więcej sprawdzeń.
Hi-Angel,
12

g ++ nie sprawdza granic tablicy i być może nadpisujesz coś wartością 3,4, ale nic naprawdę ważnego, jeśli spróbujesz z wyższymi liczbami, nastąpi awaria.

Po prostu nadpisujesz części stosu, które nie są używane, możesz kontynuować, aż dotrzesz do końca przydzielonego miejsca dla stosu i w końcu ulegnie awarii

EDYCJA: Nie masz sposobu, aby sobie z tym poradzić, może statyczny analizator kodu mógłby ujawnić te awarie, ale to zbyt proste, możesz mieć podobne (ale bardziej złożone) awarie niewykryte nawet dla analizatorów statycznych

Arkaitz Jimenez
źródło
6
Skąd weźmiesz, jeśli z tego pod adresem tablicy [3] i tablicy [4] nie ma "nic naprawdę ważnego"?
namezero
7

O ile wiem, jest to nieokreślone zachowanie. Uruchom z tym większy program, a gdzieś się zawiesi. Sprawdzanie granic nie jest częścią surowych tablic (ani nawet std :: vector).

std::vector::iteratorZamiast tego użyj std :: vector z 's, więc nie musisz się tym martwić.

Edytować:

Dla zabawy, uruchom to i zobacz, ile czasu minie do awarii:

int main()
{
   int array[1];

   for (int i = 0; i != 100000; i++)
   {
       array[i] = i;
   }

   return 0; //will be lucky to ever reach this
}

Edit2:

Nie uruchamiaj tego.

Edit3:

OK, oto krótka lekcja na temat tablic i ich relacji ze wskaźnikami:

Kiedy używasz indeksowania tablic, naprawdę używasz wskaźnika w przebraniu (zwanego „odniesieniem”), który jest automatycznie wyłuskiwany. Dlatego zamiast * (tablica [1]) tablica [1] automatycznie zwraca wartość o tej wartości.

Gdy masz wskaźnik do tablicy, na przykład:

int array[5];
int *ptr = array;

Wtedy "tablica" w drugiej deklaracji jest naprawdę rozpadana na wskaźnik do pierwszej tablicy. Jest to zachowanie równoważne z następującym:

int *ptr = &array[0];

Kiedy próbujesz uzyskać dostęp poza to, co przydzieliłeś, tak naprawdę używasz po prostu wskaźnika do innej pamięci (na co C ++ nie będzie narzekać). Biorąc mój przykładowy program powyżej, jest to równoważne z tym:

int main()
{
   int array[1];
   int *ptr = array;

   for (int i = 0; i != 100000; i++, ptr++)
   {
       *ptr++ = i;
   }

   return 0; //will be lucky to ever reach this
}

Kompilator nie będzie narzekał, ponieważ w programowaniu często trzeba komunikować się z innymi programami, zwłaszcza z systemem operacyjnym. Odbywa się to dość często za pomocą wskaźników.

jkeys
źródło
3
Myślę, że w ostatnim przykładzie zapomniałeś o inkrementacji „ptr”. Przypadkowo utworzyłeś dobrze zdefiniowany kod.
Jeff Lake
1
Haha, widzisz, dlaczego nie powinieneś używać surowych tablic?
jkeys
„Dlatego zamiast * (tablica [1]) tablica [1] automatycznie zwraca wartość o tej wartości”. Czy na pewno * (tablica [1]) będzie działać poprawnie? Myślę, że powinno to być * (tablica + 1). ps: Lol, to jest jak wysłanie wiadomości do przeszłości. Ale w każdym razie:
muyustan
5

Wskazówka

Jeśli chcesz mieć szybkie tablice rozmiaru ograniczeń ze sprawdzaniem błędów zakresu, spróbuj użyć boost :: array (również std :: tr1 :: array<tr1/array> będzie z niego standardowym kontenerem w następnej specyfikacji C ++). Jest znacznie szybszy niż std :: vector. Rezerwuje pamięć na stercie lub wewnątrz instancji klasy, tak jak int array [].
Oto prosty przykładowy kod:

#include <iostream>
#include <boost/array.hpp>
int main()
{
    boost::array<int,2> array;
    array.at(0) = 1; // checking index is inside range
    array[1] = 2;    // no error check, as fast as int array[2];
    try
    {
       // index is inside range
       std::cout << "array.at(0) = " << array.at(0) << std::endl;

       // index is outside range, throwing exception
       std::cout << "array.at(2) = " << array.at(2) << std::endl; 

       // never comes here
       std::cout << "array.at(1) = " << array.at(1) << std::endl;  
    }
    catch(const std::out_of_range& r)
    {
        std::cout << "Something goes wrong: " << r.what() << std::endl;
    }
    return 0;
}

Ten program wydrukuje:

array.at(0) = 1
Something goes wrong: array<>: index out of range
Arpegius
źródło
4

C lub C ++ nie będzie sprawdzać granic dostępu do tablicy.

Alokujesz tablicę na stosie. Indeksowanie tablicy za pomocą array[3]jest równoważne z * (array + 3), gdzie tablica jest wskaźnikiem do & array [0]. Spowoduje to niezdefiniowane zachowanie.

Jednym ze sposobów na złapanie tego czasami w C jest użycie statycznego kontrolera, takiego jak szyna . Jeśli biegasz:

splint +bounds array.c

na,

int main(void)
{
    int array[1];

    array[1] = 1;

    return 0;
}

wtedy otrzymasz ostrzeżenie:

array.c: (w funkcji main) tablica.c: 5: 9: Prawdopodobnie poza zakresem magazyn: tablica [1] Nie można rozwiązać ograniczenia: wymaga 0> = 1 potrzebne do spełnienia warunku wstępnego: wymaga maxSet (tablica @ tablica .c: 5: 9)> = 1 Zapis do pamięci może zapisywać na adres poza przydzielonym buforem.

Karl Voigtland
źródło
Poprawka: został już przydzielony przez system operacyjny lub inny program. Nadpisuje inne wspomnienia.
jkeys
1
Stwierdzenie, że „C / C ++ nie będzie sprawdzać granic” nie jest całkowicie poprawne - nic nie wyklucza tego, czy konkretna zgodna implementacja może to zrobić, czy to domyślnie, czy z niektórymi flagami kompilacji. Po prostu żadnemu z nich nie przeszkadza.
Pavel Minaev
3

Z pewnością nadpisujesz swój stos, ale program jest na tyle prosty, że efekty tego pozostają niezauważone.

Paul Dixon
źródło
2
To, czy stos zostanie nadpisany, czy nie, zależy od platformy.
Chris Cleeland,
3

Przeprowadź to przez Valgrind, a możesz zobaczyć błąd.

Jak zauważyła Falaina, valgrind nie wykrywa wielu przypadków uszkodzenia stosu. Właśnie wypróbowałem próbkę pod valgrind i rzeczywiście zgłasza zero błędów. Jednak Valgrind może odegrać kluczową rolę w znalezieniu wielu innych typów problemów z pamięcią, po prostu nie jest szczególnie przydatny w tym przypadku, chyba że zmodyfikujesz swój bulid tak, aby zawierał opcję --stack-check. Jeśli skompilujesz i uruchomisz przykład jako

g++ --stack-check -W -Wall errorRange.cpp -o errorRange
valgrind ./errorRange

valgrind będzie zgłosić błąd.

Todd Stout
źródło
2
W rzeczywistości Valgrind jest dość słaby w określaniu nieprawidłowych dostępów do tablicy na stosie. (i słusznie, najlepsze, co może zrobić, to oznaczyć cały stos jako prawidłową lokalizację zapisu)
Falaina
@Falaina - dobra uwaga, ale Valgrind może wykryć przynajmniej niektóre błędy stosu.
Todd Stout
Valgrind nie zobaczy nic złego w kodzie, ponieważ kompilator jest wystarczająco inteligentny, aby zoptymalizować tablicę i po prostu wyprowadzić literał 3 i 4. Ta optymalizacja ma miejsce zanim gcc sprawdzi granice tablicy, dlatego też ostrzeżenie o przekroczeniu granic tak robi gcc nie jest pokazany.
Goswin von Brederlow
2

Nieokreślone zachowanie działa na Twoją korzyść. Bez względu na wspomnienie, które omijasz, najwyraźniej nie zawiera niczego ważnego. Zauważ, że C i C ++ nie sprawdzają granic tablic, więc takie rzeczy nie zostaną przechwycone podczas kompilacji lub wykonywania.

John Bode
źródło
5
Nie, niezdefiniowane zachowanie "działa na twoją korzyść", gdy wyraźnie się zawiesza. Kiedy wydaje się, że działa, jest to najgorszy możliwy scenariusz.
jalf
@JohnBode: W takim razie byłoby lepiej, gdybyś poprawił sformułowanie zgodnie z komentarzem Jalfa
Destructor
1

Podczas inicjalizacji tablicy przy pomocy int array[2]przydzielana jest przestrzeń na 2 liczby całkowite; ale identyfikator arraywskazuje po prostu początek tej przestrzeni. Kiedy następnie uzyskujesz dostęp do array[3]i array[4], kompilator po prostu zwiększa ten adres, aby wskazać, gdzie te wartości byłyby, gdyby tablica była wystarczająco długa; spróbuj uzyskać dostęp do czegoś takiego jakarray[42] bez inicjalizacji go najpierw, w końcu uzyskasz jakąkolwiek wartość, która już znajduje się w pamięci w tym miejscu.

Edytować:

Więcej informacji o wskaźnikach / tablicach: http://home.netcom.com/~tjensen/ptr/pointers.htm

Nathan Clark
źródło
0

kiedy deklarujesz int array [2]; rezerwujesz 2 przestrzenie pamięci po 4 bajty każda (program 32-bitowy). jeśli wpiszesz array [4] w swoim kodzie, nadal odpowiada to poprawnemu wywołaniu, ale tylko w czasie wykonywania zgłosi nieobsługiwany wyjątek. C ++ używa ręcznego zarządzania pamięcią. W rzeczywistości jest to luka w zabezpieczeniach, która została wykorzystana do hakowania programów

może to pomóc w zrozumieniu:

int * somepointer;

somepointer [0] = somepointer [5];

yan bellavance
źródło
0

Jak rozumiem, zmienne lokalne są przydzielane na stosie, więc wyjście poza granice własnego stosu może nadpisać tylko inną zmienną lokalną, chyba że zrobisz zbyt dużo i przekroczysz rozmiar stosu. Ponieważ nie masz zadeklarowanych innych zmiennych w swojej funkcji - nie powoduje to żadnych skutków ubocznych. Spróbuj zadeklarować inną zmienną / tablicę zaraz po pierwszej i zobacz, co się z nią stanie.

Vorber
źródło
0

Kiedy piszesz „tablica [indeks]” w C, tłumaczy to na instrukcje maszynowe.

Tłumaczenie brzmi mniej więcej tak:

  1. „pobierz adres tablicy”
  2. 'pobierz rozmiar typu tablicy obiektów, z którego składa się'
  3. „pomnóż rozmiar typu przez indeks”
  4. „dodaj wynik do adresu tablicy”
  5. „przeczytaj, co jest pod adresem wynikowym”

Wynik odnosi się do czegoś, co może, ale nie musi, być częścią tablicy. W zamian za niesamowitą prędkość instrukcji maszyny tracisz siatkę bezpieczeństwa komputera sprawdzającego rzeczy za Ciebie. Jeśli jesteś skrupulatny i ostrożny, nie stanowi to problemu. Jeśli jesteś niechlujny lub popełnisz błąd, zostaniesz poparzony. Czasami może generować niepoprawną instrukcję, która powoduje wyjątek, czasami nie.

Sójka
źródło
0

Przyjemnym podejściem, które często widziałem i faktycznie byłem używany, jest wstrzyknięcie elementu typu NULL (lub utworzonego, jak np. uint THIS_IS_INFINITY = 82862863263;) Na końcu tablicy.

Następnie przy sprawdzaniu warunku pętli TYPE *pagesWordsjest jakiś rodzaj tablicy wskaźników:

int pagesWordsLength = sizeof(pagesWords) / sizeof(pagesWords[0]);

realloc (pagesWords, sizeof(pagesWords[0]) * (pagesWordsLength + 1);

pagesWords[pagesWordsLength] = MY_NULL;

for (uint i = 0; i < 1000; i++)
{
  if (pagesWords[i] == MY_NULL)
  {
    break;
  }
}

To rozwiązanie nie powiedzie się, jeśli tablica jest wypełniona structtypami.

xudre
źródło
0

Jak już wspomniano w pytaniu, użycie std :: vector :: at rozwiąże problem i sprawi, że przed uzyskaniem dostępu nastąpi sprawdzenie związane.

Jeśli potrzebujesz tablicy o stałym rozmiarze, która znajduje się na stosie jako pierwszy kod, użyj nowego kontenera C ++ 11 std :: array; jako wektor jest funkcja std :: array :: at. W rzeczywistości funkcja istnieje we wszystkich standardowych kontenerach, w których ma znaczenie, tj. Tam, gdzie zdefiniowano operator [] :( deque, map, unordered_map) z wyjątkiem std :: bitset, w którym nazywa się std :: bitset: :test.

Mohamed El-Nakib
źródło
0

libstdc ++, które jest częścią gcc, ma specjalny tryb debugowania do sprawdzania błędów. Jest włączana flagą kompilatora -D_GLIBCXX_DEBUG. Między innymi sprawdza granice std::vectorkosztem wydajności. Oto demo online z najnowszą wersją gcc.

Więc faktycznie możesz sprawdzać granice w trybie debugowania libstdc ++, ale powinieneś to robić tylko podczas testowania, ponieważ kosztuje to znaczną wydajność w porównaniu z normalnym trybem libstdc ++.

ks1322
źródło
0

Jeśli nieznacznie zmienisz program:

#include <iostream>
using namespace std;
int main()
{
    int array[2];
    INT NOTHING;
    CHAR FOO[4];
    STRCPY(FOO, "BAR");
    array[0] = 1;
    array[1] = 2;
    array[3] = 3;
    array[4] = 4;
    cout << array[3] << endl;
    cout << array[4] << endl;
    COUT << FOO << ENDL;
    return 0;
}

(Zmiany w wielkich literach - wpisz je małymi literami, jeśli masz zamiar tego spróbować.)

Zobaczysz, że zmienna foo została skasowana. Twój kod będzie przechowywał wartości w nieistniejącej tablicy [3] i tablicy [4], i będzie mógł je poprawnie pobrać, ale faktycznie używane miejsce będzie pochodziło z foo .

Możesz więc „uciec” z przekroczeniem granic tablicy w oryginalnym przykładzie, ale kosztem spowodowania szkody w innym miejscu - uszkodzenia, które mogą okazać się bardzo trudne do zdiagnozowania.

Dlaczego nie ma automatycznego sprawdzania granic - poprawnie napisany program tego nie potrzebuje. Gdy to zrobisz, nie ma powodu, aby sprawdzać granice czasu wykonywania, a zrobienie tego po prostu spowolniłoby program. Najlepiej, aby to wszystko zostało ustalone podczas projektowania i kodowania.

C ++ jest oparty na C, który został zaprojektowany tak, aby był jak najbardziej zbliżony do języka asemblera.

Jennifer
źródło