Mam następujący kod.
#include <iostream>
int * foo()
{
int a = 5;
return &a;
}
int main()
{
int* p = foo();
std::cout << *p;
*p = 8;
std::cout << *p;
}
Kod działa tylko bez wyjątków czasu wykonywania!
Wynik był 58
Jak to możliwe? Czy pamięć lokalnej zmiennej nie jest niedostępna poza jej funkcją?
c++
memory-management
local-variables
dangling-pointer
nieznanych
źródło
źródło
address of local variable ‘a’ returned
; pokazy valgrindInvalid write of size 4 [...] Address 0xbefd7114 is just below the stack ptr
Odpowiedzi:
Wynajmujesz pokój hotelowy. Wkładasz książkę do górnej szuflady stolika nocnego i idziesz spać. Sprawdzasz następnego dnia rano, ale „zapominasz” o oddaniu klucza. Kradniesz klucz!
Tydzień później wrócisz do hotelu, nie zameldujesz się, przekradniesz do swojego starego pokoju ze skradzionym kluczem i zajrzysz do szuflady. Twoja książka wciąż tam jest. Zadziwiający!
Jak to możliwe? Czy zawartość szuflady pokoju hotelowego jest niedostępna, jeśli pokój nie został wynajęty?
Oczywiście ten scenariusz może się zdarzyć w prawdziwym świecie, nie ma problemu. Nie ma tajemniczej siły, która powoduje, że książka znika, gdy nie masz już pozwolenia na przebywanie w pokoju. Nie ma też tajemniczej siły, która uniemożliwia ci wejście do pokoju ze skradzionym kluczem.
Kierownictwo hotelu nie jest zobowiązane do usunięcia książki. Nie zawarliście z nimi umowy, która mówi, że jeśli zostawicie coś za sobą, zniszczą to za was. Jeśli nielegalnie ponownie wejdziesz do pokoju ze skradzionym kluczem, aby go odzyskać, personel ochrony hotelowej nie musi cię przyłapać. Nie zawarłeś z nimi umowy, która mówi „jeśli spróbuję wślizgnąć się z powrotem do mojej pokój później, musisz mnie zatrzymać. " Przeciwnie, podpisałeś z nimi umowę, która brzmiała: „Obiecuję, że nie wrócę później do mojego pokoju”, umowę, którą złamałeś .
W tej sytuacji wszystko może się zdarzyć . Książka może tam być - masz szczęście. Może być tam czyjaś książka, a twoja może być w hotelowym piecu. Ktoś może być przy wejściu i rozrywać książkę na strzępy. Hotel mógł całkowicie usunąć stół i zarezerwować i zastąpić go szafą. Cały hotel może zostać właśnie zburzony i zastąpiony stadionem piłkarskim, a umrzesz podczas eksplozji.
Nie wiesz, co się stanie; kiedy wyrejestrowany z hotelu i ukradł klucz do nielegalnego wykorzystania później, zrezygnował z prawa do życia w przewidywalnym, bezpiecznym świecie, ponieważ pan zdecydował się złamać zasady systemu.
C ++ nie jest bezpiecznym językiem . Pozwoli to z radością złamać zasady systemu. Jeśli spróbujesz zrobić coś nielegalnego i głupiego, jak powrót do pokoju, w którym nie masz uprawnień, i szperanie w biurku, którego może już tam nie być, C ++ cię nie powstrzyma. Bezpieczniejsze języki niż C ++ rozwiązują ten problem, ograniczając twoją moc - na przykład poprzez znacznie ściślejszą kontrolę nad klawiszami.
AKTUALIZACJA
O Boże, ta odpowiedź zyskuje wiele uwagi. (Nie jestem pewien, dlaczego - uważałem to za „zabawną” małą analogię, ale cokolwiek.)
Pomyślałem, że może to być niemądre, aby zaktualizować to trochę za pomocą kilku technicznych myśli.
Kompilatory zajmują się generowaniem kodu, który zarządza przechowywaniem danych przetwarzanych przez ten program. Istnieje wiele różnych sposobów generowania kodu do zarządzania pamięcią, ale z czasem zakorzeniły się dwie podstawowe techniki.
Pierwszym jest posiadanie pewnego rodzaju „długowiecznego” obszaru pamięci, w którym „czas życia” każdego bajtu w pamięci - to znaczy okres, w którym jest on prawidłowo powiązany z jakąś zmienną programu - nie może być łatwo przewidzieć z wyprzedzeniem czasu. Kompilator generuje wywołania do „menedżera sterty”, który wie, jak dynamicznie przydzielać pamięć, gdy jest potrzebna, i odzyskiwać ją, gdy nie jest już potrzebna.
Druga metoda polega na utworzeniu „krótkotrwałego” obszaru przechowywania, w którym czas życia każdego bajtu jest dobrze znany. Tutaj wcielenia są wzorowane na „zagnieżdżaniu się”. Najdłużej żyjące z tych zmiennych krótkotrwałych zostaną przydzielone przed innymi zmiennymi krótkotrwałymi i zostaną uwolnione na końcu. Zmienne o krótszym czasie życia zostaną przydzielone po zmiennych najdłużej żyjących i zostaną przed nimi uwolnione. Czas życia tych zmiennych o krótszym czasie życia jest „zagnieżdżony” w czasie życia zmiennych o dłuższym okresie życia.
Zmienne lokalne są zgodne z tym ostatnim wzorcem; po wprowadzeniu metody jej lokalne zmienne ożywają. Kiedy ta metoda wywołuje inną metodę, lokalne zmienne nowej metody ożywają. Będą martwe, zanim zmienne lokalne pierwszej metody staną się martwe. Względną kolejność początków i zakończeń czasów życia magazynów związanych ze zmiennymi lokalnymi można ustalić z wyprzedzeniem.
Z tego powodu zmienne lokalne są zwykle generowane jako pamięć w strukturze danych „stosu”, ponieważ stos ma właściwość polegającą na tym, że pierwszą rzeczą, która zostanie na niego narzucona, będzie ostatnią rzeczą, która odpadnie.
To tak, jakby hotel postanowił wynajmować pokoje tylko sekwencyjnie i nie możesz się wymeldować, dopóki wszyscy z numerem pokoju wyższym niż wymeldowałeś się.
Pomyślmy więc o stosie. W wielu systemach operacyjnych otrzymujesz jeden stos na wątek, a stos ten ma przydzielony określony rozmiar. Kiedy wywołujesz metodę, rzeczy są wypychane na stos. Jeśli następnie przekażesz wskaźnik do stosu z powrotem z metody, tak jak robi to oryginalny plakat, jest to tylko wskaźnik do środka całkowicie poprawnego bloku pamięci o milionach bajtów. W naszej analogii wymeldowujesz się z hotelu; kiedy to zrobisz, właśnie wymeldowałeś się z zajmowanego pokoju o najwyższym numerze. Jeśli nikt inny nie zamelduje się za tobą i wrócisz nielegalnie do pokoju, wszystkie twoje rzeczy będą tam nadal w tym hotelu .
Używamy stosów do tymczasowych sklepów, ponieważ są naprawdę tanie i łatwe. Implementacja C ++ nie jest wymagana do używania stosu do przechowywania lokalnych; przydałby się stos. Nie robi tego, ponieważ spowolniłoby to program.
Implementacja C ++ nie jest wymagana, aby pozostawić śmieci pozostawione na stosie nietknięte, abyś mógł później po nie wrócić; kompilator generuje kod, który wraca do zera wszystko w „pokoju”, który właśnie opuściłeś, jest całkowicie legalne. Nie dzieje się tak, ponieważ znowu byłoby to drogie.
Implementacja C ++ nie jest wymagana, aby zapewnić, że gdy stos logicznie się kurczy, adresy, które były prawidłowe, nadal są mapowane do pamięci. Implementacja może powiedzieć systemowi operacyjnemu, że „skończyliśmy już używać tej strony stosu. Dopóki nie powiem inaczej, wydaj wyjątek, który niszczy proces, jeśli ktoś dotknie poprzednio prawidłowej strony stosu”. Ponownie wdrożenia tego nie robią, ponieważ jest powolne i niepotrzebne.
Zamiast tego implementacje pozwalają popełniać błędy i uciec od tego. Większość czasu. Aż pewnego dnia coś naprawdę okropnego pójdzie nie tak i proces wybuchnie.
To jest problematyczne. Istnieje wiele zasad i bardzo łatwo jest je przypadkowo złamać. Z pewnością mam wiele razy. Co gorsza, problem często pojawia się tylko wtedy, gdy pamięć zostanie wykryta jako zepsuta miliardy nanosekund po zdarzeniu się korupcji, kiedy bardzo trudno jest ustalić, kto go pomieszał.
Bardziej bezpieczne dla pamięci języki rozwiązują ten problem, ograniczając swoją moc. W „normalnym” języku C # po prostu nie ma sposobu, aby wziąć adres lokalny i zwrócić go lub zachować na później. Możesz wziąć adres lokalny, ale język jest sprytnie zaprojektowany, tak że nie można go używać po zakończeniu lokalnych okresów. Aby pobrać adres lokalny i przekazać go z powrotem, musisz ustawić kompilator w specjalnym „niebezpiecznym” trybie i umieścić słowo „niebezpieczne” w swoim programie, aby zwrócić uwagę na fakt, że prawdopodobnie robisz coś niebezpiecznego, co może łamać zasady.
Do dalszego czytania:
Co jeśli C # pozwolił na zwracanie referencji? Przypadkowo jest to temat dzisiejszego postu na blogu:
https://ericlippert.com/2011/06/23/ref-returns-and-ref-locals/
Dlaczego używamy stosów do zarządzania pamięcią? Czy typy wartości w języku C # są zawsze przechowywane na stosie? Jak działa pamięć wirtualna? I wiele innych tematów dotyczących działania menedżera pamięci C #. Wiele z tych artykułów jest również związanych z programistami C ++:
https://ericlippert.com/tag/memory-management/
źródło
To, co tu robisz, to po prostu czytanie i zapisywanie w pamięci, która kiedyś była adresem
a
. Teraz, gdy jesteś pozafoo
, jest to tylko wskaźnik do losowego obszaru pamięci. Tak się składa, że w twoim przykładzie ten obszar pamięci istnieje i nic innego go nie używa w tej chwili. Nie niszczysz niczego, nadal z niego korzystasz i nic innego jeszcze go nie zastąpiło. Dlatego5
nadal tam jest. W prawdziwym programie pamięć ta zostałaby ponownie wykorzystana niemal natychmiast i można by to zepsuć (chociaż objawy mogą pojawić się dopiero później!)Po powrocie z
foo
systemu operacyjnego informujesz system operacyjny, że nie używasz już tej pamięci i że można ją ponownie przypisać do innej funkcji. Jeśli masz szczęście i nigdy nie zostanie on ponownie przypisany, a system operacyjny nie przyłapie Cię na tym, że go użyjesz ponownie, to uciekniesz od kłamstwa. Są jednak szanse, że skończysz pisać na czymkolwiek innym, co skończy się na tym adresie.Jeśli zastanawiasz się, dlaczego kompilator nie narzeka, to prawdopodobnie dlatego, że
foo
został wyeliminowany przez optymalizację. Zazwyczaj ostrzega cię przed tego rodzaju rzeczami. C zakłada, że wiesz, co robisz i technicznie nie naruszyłeś tutaj zakresu (nie ma odniesienia doa
siebie pozafoo
), tylko reguły dostępu do pamięci, które wyzwalają raczej ostrzeżenie niż błąd.Krótko mówiąc: to zwykle nie zadziała, ale czasami będzie przypadkiem.
źródło
Ponieważ przestrzeń dyskowa nie była jeszcze narzucona. Nie licz na to zachowanie.
źródło
Mały dodatek do wszystkich odpowiedzi:
jeśli zrobisz coś takiego:
wyjście prawdopodobnie będzie: 7
Jest tak, ponieważ po powrocie z foo () stos jest zwalniany, a następnie ponownie wykorzystywany przez boo (). Jeśli zdemontujesz plik wykonywalny, zobaczysz go wyraźnie.
źródło
boo
ponownie wykorzystujefoo
stos? nie są stosy funkcji oddzielone od siebie, również mam śmieci, które uruchamiają ten kod w Visual Studio 2015foo()
, istnieje, a następnie schodzi doboo()
.Foo()
iBoo()
oba wchodzą ze wskaźnikiem stosu w tym samym miejscu. Nie jest to jednak zachowanie, na które należy polegać. Inne „rzeczy” (takie jak przerwania lub system operacyjny) mogą używać stosu między wywołaniemboo()
ifoo()
, modyfikując jego zawartość ...W C ++ możesz uzyskać dostęp do dowolnego adresu, ale to nie znaczy, że powinieneś . Adres, do którego uzyskujesz dostęp, jest już nieważny. To działa , bo nic innego nie kodowany pamięć po foo wrócił, ale może upaść w wielu okolicznościach. Spróbuj przeanalizować swój program za pomocą Valgrind lub nawet po prostu skompiluj go zoptymalizowany i zobacz ...
źródło
Nigdy nie rzucasz wyjątku C ++, uzyskując dostęp do niepoprawnej pamięci. Podajesz tylko przykład ogólnej idei odwoływania się do dowolnej lokalizacji pamięci. Mógłbym zrobić to samo:
Tutaj po prostu traktuję 123456 jako adres podwójnego i piszę do niego. Może się zdarzyć dowolna liczba rzeczy:
q
może faktycznie być poprawnym adresem podwójnego adresu, npdouble p; q = &p;
.q
może wskazywać gdzieś wewnątrz przydzielonej pamięci, a ja po prostu nadpisuję tam 8 bajtów.q
wskazuje poza przydzieloną pamięć, a menedżer pamięci systemu operacyjnego wysyła sygnał błędu segmentacji do mojego programu, powodując jego zakończenie.Sposób, w jaki go skonfigurowałeś, jest nieco bardziej rozsądny, jeśli zwracany adres wskazuje prawidłowy obszar pamięci, ponieważ prawdopodobnie będzie on znajdował się nieco dalej na stosie, ale nadal jest to nieprawidłowa lokalizacja, do której nie można uzyskać dostępu w moda deterministyczna.
Nikt nie sprawdzi automatycznie semantycznej ważności takich adresów pamięci podczas normalnego wykonywania programu. Jednak debugger pamięci, taki jak
valgrind
ten, z radością to zrobi, więc powinieneś uruchomić swój program i być świadkiem błędów.źródło
4) I win the lottery
Czy skompilowałeś swój program z włączonym optymalizatorem?
foo()
Funkcja ta jest bardzo prosta i może być inlined lub zastąpione w wynikowym kodzie.Ale zgadzam się z Markiem B, że wynikające z tego zachowanie jest niezdefiniowane.
źródło
5
zostanie zmienione ...Twój problem nie ma nic wspólnego z zakresem . W pokazanym kodzie funkcja
main
nie widzi nazw w funkcjifoo
, więc nie możesz uzyskać dostępua
w foo bezpośrednio z tą nazwą na zewnątrzfoo
.Problemem jest to, że program nie sygnalizuje błędu podczas odwoływania się do nielegalnej pamięci. Wynika to z faktu, że standardy C ++ nie określają bardzo wyraźnej granicy między pamięcią nielegalną a pamięcią legalną. Odwoływanie się do czegoś w wyskakującym stosie czasami powoduje błąd, a czasem nie. To zależy. Nie licz na to zachowanie. Załóż, że zawsze spowoduje błąd podczas programowania, ale załóż, że nigdy nie zasygnalizuje błędu podczas debugowania.
źródło
Zwracasz tylko adres pamięci, jest to dozwolone, ale prawdopodobnie błąd.
Tak, jeśli spróbujesz wyrejestrować ten adres pamięci, będziesz mieć niezdefiniowane zachowanie.
źródło
cout
.*a
wskazuje na nieprzydzieloną (uwolnioną) pamięć. Nawet jeśli go nie odrzucisz, nadal jest niebezpieczny (i prawdopodobnie fałszywy).To klasyczne, nieokreślone zachowanie , o którym dyskutowano tutaj dwa dni temu - poszukaj trochę w witrynie. Krótko mówiąc, miałeś szczęście, ale wszystko mogło się zdarzyć, a Twój kod uniemożliwia dostęp do pamięci.
źródło
To zachowanie jest niezdefiniowane, jak zauważył Alex - w rzeczywistości większość kompilatorów ostrzega przed zrobieniem tego, ponieważ jest to łatwy sposób na awarie.
Na przykład tego rodzaju upiorne zachowanie, które możesz się spodziewać , spróbuj tego przykładu:
Wyświetla to „y = 123”, ale wyniki mogą się różnić (naprawdę!). Twój wskaźnik blokuje inne niepowiązane zmienne lokalne.
źródło
Zwróć uwagę na wszystkie ostrzeżenia. Nie tylko rozwiązuj błędy.
GCC pokazuje to Ostrzeżenie
To jest moc C ++. Powinieneś dbać o pamięć. Z
-Werror
flagą to ostrzeżenie staje się błędem i teraz musisz go debugować.źródło
Działa, ponieważ stos nie został zmieniony (jeszcze) od czasu umieszczenia tam. Wywołaj kilka innych funkcji (które również wywołują inne funkcje) przed
a
ponownym uzyskaniem dostępu i prawdopodobnie nie będziesz już miał tyle szczęścia ... ;-)źródło
W rzeczywistości wywołałeś niezdefiniowane zachowanie.
Zwrócenie adresu tymczasowego dzieła, ale ponieważ tymczasowe zostaną zniszczone na końcu funkcji, wyniki dostępu do nich będą niezdefiniowane.
Więc nie zmodyfikowałeś,
a
ale raczej miejsce w pamięci, w któryma
kiedyś był. Ta różnica jest bardzo podobna do różnicy między awarią a awarią.źródło
W typowych implementacjach kompilatora można myśleć o kodzie jako o „wypisaniu wartości bloku pamięci z adresem, który był wcześniej zajęty przez”. Ponadto, jeśli dodasz nowe wywołanie funkcji do funkcji składającej się z lokalnego
int
, istnieje duża szansa, że wartośća
(lub adres pamięci, którya
zmieni się który wskazywał). Dzieje się tak, ponieważ stos zostanie zastąpiony nową ramką zawierającą różne dane.Jest to jednak niezdefiniowane zachowanie i nie powinieneś na nim polegać!
źródło
a
, wskaźnik posiadał adresa
. Chociaż Standard nie wymaga, aby implementacje definiowały zachowanie adresów po zakończeniu okresu ich istnienia, rozpoznaje również, że na niektórych platformach UB jest przetwarzany w udokumentowany sposób charakterystyczny dla środowiska. Chociaż adres zmiennej lokalnej nie będzie generalnie przydatny po przekroczeniu zakresu, niektóre inne rodzaje adresów mogą nadal mieć znaczenie po upływie czasu ich odpowiednich obiektów docelowych.realloc
porównanie przekazanego wskaźnika z wartością zwracaną, ani nie pozwalały na dostosowywanie wskaźników w starym bloku, aby wskazywał na nowy, niektóre implementacje to robią , a kod wykorzystujący taką funkcję może być bardziej wydajny niż kod, który musi unikać jakichkolwiek działań - nawet porównań - obejmujących wskaźniki przydzielonej alokacjirealloc
.Może, ponieważ
a
jest to zmienna przydzielona tymczasowo na czas życia jej zakresu (foo
funkcji). Po powrocie zfoo
pamięci pamięć jest wolna i może zostać nadpisana.To, co robisz, jest określane jako niezdefiniowane zachowanie . Nie można przewidzieć wyniku.
źródło
Rzeczy z poprawnym (?) Wyjściem konsoli mogą się radykalnie zmienić, jeśli użyjesz :: printf, ale nie cout. Możesz bawić się debuggerem w ramach poniższego kodu (testowane na x86, 32-bit, MSVisual Studio):
źródło
Po powrocie z funkcji wszystkie identyfikatory są niszczone zamiast wartości przechowywanych w pamięci i nie możemy zlokalizować wartości bez identyfikatora, ale ta lokalizacja nadal zawiera wartość przechowywaną przez poprzednią funkcję.
Zatem tutaj funkcja
foo()
zwraca adresa
ia
jest niszczona po zwróceniu adresu. I możesz uzyskać dostęp do zmodyfikowanej wartości za pośrednictwem tego zwróconego adresu.Weźmy przykład z prawdziwego świata:
Załóżmy, że mężczyzna ukrywa pieniądze w miejscu i informuje o miejscu. Po pewnym czasie umiera mężczyzna, który powiedział ci, gdzie są pieniądze. Ale nadal masz dostęp do tych ukrytych pieniędzy.
źródło
Jest to „brudny” sposób używania adresów pamięci. Kiedy zwracasz adres (wskaźnik), nie wiesz, czy należy on do lokalnego zasięgu funkcji. To tylko adres. Teraz, gdy wywołałeś funkcję „foo”, ten adres (lokalizacja pamięci) „a” został już tam przydzielony w (bezpiecznej, przynajmniej na razie) adresowalnej pamięci twojej aplikacji (procesu). Po zwróceniu funkcji „foo” adres „a” można uznać za „brudny”, ale jest tam, nie jest czyszczony ani zaburzony / modyfikowany przez wyrażenia w innej części programu (przynajmniej w tym konkretnym przypadku). Kompilator AC / C ++ nie powstrzymuje cię przed takim „brudnym” dostępem (może cię jednak ostrzec, jeśli ci na tym zależy).
źródło
Twój kod jest bardzo ryzykowny. Tworzysz zmienną lokalną (która jest uważana za zniszczoną po zakończeniu funkcji) i zwracasz adres pamięci tej zmiennej po jej zniszczeniu.
Oznacza to, że adres pamięci może być prawidłowy lub nie, a kod będzie podatny na możliwe problemy z adresem pamięci (na przykład błąd segmentacji).
Oznacza to, że robisz coś bardzo złego, ponieważ przekazujesz adres pamięci do wskaźnika, który wcale nie jest godny zaufania.
Zamiast tego rozważ ten przykład i przetestuj go:
W przeciwieństwie do twojego przykładu, w tym przykładzie jesteś:
źródło
new
.new
. Uczysz ich używanianew
. Ale nie powinieneś używaćnew
.new
w 2019 r. (Chyba że piszesz kod biblioteki) i nie ucz nowych użytkowników, aby to robili! Twoje zdrowie.