Ich habe den folgenden Code.
#include <iostream>
int * foo()
{
int a = 5;
return &a;
}
int main()
{
int* p = foo();
std::cout << *p;
*p = 8;
std::cout << *p;
}
Und der Code läuft nur ohne Laufzeitausnahmen!
Die Ausgabe war 58
Wie kann es sein? Ist der Speicher einer lokalen Variablen außerhalb ihrer Funktion nicht unzugänglich?
address of local variable ‘a’ returned
. Valgrind ShowsInvalid write of size 4 [...] Address 0xbefd7114 is just below the stack ptr
Antworten:
Sie mieten ein Hotelzimmer. Sie legen ein Buch in die oberste Schublade des Nachttisches und schlafen ein. Sie checken am nächsten Morgen aus, aber "vergessen", Ihren Schlüssel zurückzugeben. Du stiehlst den Schlüssel!
Eine Woche später kehren Sie ins Hotel zurück, checken nicht ein, schleichen sich mit Ihrem gestohlenen Schlüssel in Ihr altes Zimmer und schauen in die Schublade. Dein Buch ist noch da. Erstaunlich!
Wie kann das sein? Ist der Inhalt einer Hotelzimmerschublade nicht unzugänglich, wenn Sie das Zimmer nicht gemietet haben?
Nun, offensichtlich kann dieses Szenario in der realen Welt kein Problem sein. Es gibt keine mysteriöse Kraft, die dazu führt, dass Ihr Buch verschwindet, wenn Sie nicht mehr berechtigt sind, im Raum zu sein. Es gibt auch keine mysteriöse Kraft, die Sie daran hindert, einen Raum mit einem gestohlenen Schlüssel zu betreten.
Die Hotelleitung ist nicht verpflichtet , Ihr Buch zu entfernen. Sie haben mit ihnen keinen Vertrag geschlossen, der besagt, dass sie Dinge für Sie zerreißen, wenn Sie sie zurücklassen. Wenn Sie illegal Ihr Zimmer mit einem gestohlenen Schlüssel erneut eingeben es zurück zu bekommen, ist das Hotel Sicherheitspersonal nicht erforderlich fangen Sie schleichen in. Sie haben keinen Vertrag mit ihnen machen, der „sagte , wenn ich versuche , meine zu schleichen zurück in Zimmer später müssen Sie mich aufhalten. " Sie haben vielmehr einen Vertrag mit ihnen unterzeichnet, der besagt: "Ich verspreche, mich später nicht mehr in mein Zimmer zu schleichen", einen Vertrag, den Sie gebrochen haben .
In dieser Situation kann alles passieren . Das Buch kann da sein - du hast Glück gehabt. Das Buch eines anderen kann dort sein und Ihr Buch könnte im Ofen des Hotels sein. Jemand könnte genau dort sein, wenn Sie hereinkommen und Ihr Buch in Stücke reißen. Das Hotel hätte den Tisch und das Buch komplett entfernen und durch einen Kleiderschrank ersetzen können. Das gesamte Hotel könnte gerade abgerissen und durch ein Fußballstadion ersetzt werden, und Sie werden bei einer Explosion sterben, während Sie sich herumschleichen.
Sie wissen nicht, was passieren wird; Als Sie aus dem Hotel auscheckten und einen Schlüssel stahlen, um ihn später illegal zu verwenden, gaben Sie das Recht auf, in einer vorhersehbaren, sicheren Welt zu leben, weil Sie sich entschieden hatten, gegen die Regeln des Systems zu verstoßen.
C ++ ist keine sichere Sprache . Es wird Ihnen fröhlich erlauben, die Regeln des Systems zu brechen. Wenn Sie versuchen, etwas Illegales und Dummes zu tun, wie in einen Raum zurückzukehren, in dem Sie nicht autorisiert sind, und durch einen Schreibtisch zu stöbern, der möglicherweise nicht mehr vorhanden ist, wird C ++ Sie nicht aufhalten. Sicherere Sprachen als C ++ lösen dieses Problem, indem Sie Ihre Leistung einschränken - beispielsweise durch eine viel strengere Kontrolle über Schlüssel.
AKTUALISIEREN
Heilige Güte, diese Antwort bekommt viel Aufmerksamkeit. (Ich bin mir nicht sicher warum - ich hielt es nur für eine "lustige" kleine Analogie, aber was auch immer.)
Ich dachte, es wäre vielleicht sinnvoll, dies mit ein paar weiteren technischen Gedanken ein wenig zu aktualisieren.
Compiler generieren Code, der die Speicherung der von diesem Programm manipulierten Daten verwaltet. Es gibt viele verschiedene Möglichkeiten, Code zum Verwalten des Speichers zu generieren, aber im Laufe der Zeit haben sich zwei grundlegende Techniken festgesetzt.
Die erste besteht darin, eine Art "langlebigen" Speicherbereich zu haben, in dem die "Lebensdauer" jedes Bytes im Speicher - dh der Zeitraum, in dem es einer Programmvariablen gültig zugeordnet ist - nicht einfach vorhergesagt werden kann von Zeit. Der Compiler generiert Aufrufe an einen "Heap-Manager", der weiß, wie Speicher dynamisch zugewiesen wird, wenn er benötigt wird, und ihn zurückfordert, wenn er nicht mehr benötigt wird.
Die zweite Methode besteht darin, einen "kurzlebigen" Speicherbereich zu haben, in dem die Lebensdauer jedes Bytes bekannt ist. Hier folgen die Lebensdauern einem „Verschachtelungsmuster“. Die langlebigsten dieser kurzlebigen Variablen werden vor allen anderen kurzlebigen Variablen zugewiesen und zuletzt freigegeben. Variablen mit kürzerer Lebensdauer werden nach den Variablen mit der längsten Lebensdauer zugewiesen und vor ihnen freigegeben. Die Lebensdauer dieser kurzlebigen Variablen ist innerhalb der Lebensdauer längerlebiger Variablen „verschachtelt“.
Lokale Variablen folgen dem letzteren Muster; Wenn eine Methode eingegeben wird, werden ihre lokalen Variablen lebendig. Wenn diese Methode eine andere Methode aufruft, werden die lokalen Variablen der neuen Methode lebendig. Sie sind tot, bevor die lokalen Variablen der ersten Methode tot sind. Die relative Reihenfolge der Anfänge und Enden der Lebensdauer von Speichern, die mit lokalen Variablen verknüpft sind, kann im Voraus ermittelt werden.
Aus diesem Grund werden lokale Variablen normalerweise als Speicher in einer "Stapel" -Datenstruktur generiert, da ein Stapel die Eigenschaft hat, dass das erste, was darauf gedrückt wird, das letzte ist, das herausspringt.
Es ist, als ob das Hotel beschließt, Zimmer nur nacheinander zu vermieten, und Sie können erst auschecken, wenn alle Zimmer eine höhere Zimmernummer haben als Sie.
Denken wir also über den Stapel nach. In vielen Betriebssystemen erhalten Sie einen Stapel pro Thread und der Stapel wird einer bestimmten festen Größe zugewiesen. Wenn Sie eine Methode aufrufen, werden Inhalte auf den Stapel verschoben. Wenn Sie dann einen Zeiger auf den Stapel aus Ihrer Methode zurückgeben, wie dies hier im Originalposter der Fall ist, ist dies nur ein Zeiger auf die Mitte eines vollständig gültigen Millionen-Byte-Speicherblocks. In unserer Analogie checken Sie aus dem Hotel aus; Wenn Sie dies tun, haben Sie gerade aus dem am höchsten belegten Raum ausgecheckt. Wenn niemand anderes nach Ihnen eincheckt und Sie illegal in Ihr Zimmer zurückkehren, sind alle Ihre Sachen garantiert immer noch in diesem bestimmten Hotel vorhanden .
Wir verwenden Stapel für temporäre Geschäfte, weil sie wirklich billig und einfach sind. Eine Implementierung von C ++ ist nicht erforderlich, um einen Stapel zum Speichern von Einheimischen zu verwenden. es könnte den Haufen gebrauchen. Dies ist nicht der Fall, da dies das Programm verlangsamen würde.
Eine Implementierung von C ++ ist nicht erforderlich, um den Müll, den Sie auf dem Stapel gelassen haben, unberührt zu lassen, damit Sie später illegal darauf zurückkommen können. Es ist völlig legal, dass der Compiler Code generiert, der alles in dem "Raum", den Sie gerade verlassen haben, auf Null zurücksetzt. Es ist nicht so, denn das wäre wieder teuer.
Eine Implementierung von C ++ ist nicht erforderlich, um sicherzustellen, dass beim logischen Verkleinern des Stapels die früher gültigen Adressen weiterhin im Speicher zugeordnet werden. Die Implementierung darf dem Betriebssystem mitteilen, dass "wir diese Stapelseite jetzt nicht mehr verwenden. Bis ich etwas anderes sage, geben Sie eine Ausnahme aus, die den Prozess zerstört, wenn jemand die zuvor gültige Stapelseite berührt." Wiederum tun Implementierungen dies nicht wirklich, weil es langsam und unnötig ist.
Stattdessen können Sie mit Implementierungen Fehler machen und damit durchkommen. Meistens. Bis eines Tages etwas wirklich Schreckliches schief geht und der Prozess explodiert.
Das ist problematisch. Es gibt viele Regeln und es ist sehr leicht, sie versehentlich zu brechen. Ich habe sicherlich viele Male. Und schlimmer noch, das Problem tritt oft nur dann auf, wenn festgestellt wird, dass der Speicher Milliarden von Nanosekunden nach der Korruption beschädigt ist, wenn es sehr schwer ist herauszufinden, wer ihn durcheinander gebracht hat.
Mehr speichersichere Sprachen lösen dieses Problem, indem sie Ihre Leistung einschränken. In "normalem" C # gibt es einfach keine Möglichkeit, die Adresse eines Einheimischen zu übernehmen und zurückzugeben oder für später zu speichern. Sie können die Adresse eines Einheimischen verwenden, aber die Sprache ist so konzipiert, dass es unmöglich ist, sie nach Ablauf der Lebensdauer des Orts zu verwenden. Um die Adresse eines lokalen Objekts zu übernehmen und zurückzugeben, müssen Sie den Compiler in einen speziellen "unsicheren" Modus versetzen und das Wort "unsicher" in Ihr Programm aufnehmen, um auf die Tatsache aufmerksam zu machen, dass Sie dies wahrscheinlich tun etwas Gefährliches, das gegen die Regeln verstoßen könnte.
Zur weiteren Lektüre:
Was wäre, wenn C # die Rückgabe von Referenzen zulässt? Zufälligerweise ist das das Thema des heutigen Blogposts:
https://ericlippert.com/2011/06/23/ref-returns-and-ref-locals/
Warum verwenden wir Stapel, um den Speicher zu verwalten? Werden Werttypen in C # immer auf dem Stapel gespeichert? Wie funktioniert der virtuelle Speicher? Und viele weitere Themen zur Funktionsweise des C # -Speichermanagers. Viele dieser Artikel sind auch für C ++ - Programmierer von Bedeutung:
https://ericlippert.com/tag/memory-management/
quelle
Was Sie hier tun , ist einfach zu lesen und in den Speicher zu schreiben , dass verwendet die Adresse zu sein
a
. Jetzt, wo Sie sich außerhalb befindenfoo
, ist es nur ein Zeiger auf einen zufälligen Speicherbereich. Es kommt einfach so vor, dass in Ihrem Beispiel dieser Speicherbereich vorhanden ist und im Moment von nichts anderem verwendet wird. Sie brechen nichts, indem Sie es weiter verwenden, und nichts anderes hat es noch überschrieben. Daher ist das5
noch da. In einem echten Programm würde dieser Speicher fast sofort wiederverwendet und Sie würden dadurch etwas kaputt machen (obwohl die Symptome möglicherweise erst viel später auftreten!).Wenn Sie von zurückkehren
foo
, teilen Sie dem Betriebssystem mit, dass Sie diesen Speicher nicht mehr verwenden und dass er einem anderen Speicher zugewiesen werden kann. Wenn Sie Glück haben und es nie neu zugewiesen wird und das Betriebssystem Sie nicht wieder beim Verwenden erwischt, werden Sie mit der Lüge davonkommen. Es besteht die Möglichkeit, dass Sie am Ende über alles schreiben, was mit dieser Adresse endet.Wenn Sie sich jetzt fragen, warum sich der Compiler nicht beschwert, liegt dies wahrscheinlich daran,
foo
dass er durch die Optimierung beseitigt wurde. Es wird Sie normalerweise vor solchen Dingen warnen. C setzt jedoch voraus, dass Sie wissen, was Sie tun, und technisch gesehen haben Sie hier nicht gegen den Gültigkeitsbereich verstoßen (es gibt keinen Verweis auf sicha
selbst außerhalb vonfoo
), sondern nur auf Speicherzugriffsregeln, die nur eine Warnung und keinen Fehler auslösen.Kurz gesagt: Dies funktioniert normalerweise nicht, manchmal aber auch zufällig.
quelle
Weil der Speicherplatz noch nicht belegt war. Verlassen Sie sich nicht auf dieses Verhalten.
quelle
Eine kleine Ergänzung zu allen Antworten:
wenn du so etwas machst:
Die Ausgabe wird wahrscheinlich sein: 7
Dies liegt daran, dass nach der Rückkehr von foo () der Stapel freigegeben und dann von boo () wiederverwendet wird. Wenn Sie die ausführbare Datei zerlegen, sehen Sie sie deutlich.
quelle
boo
wird derfoo
Stapel wiederverwendet ? sind keine Funktionsstapel voneinander getrennt, auch ich bekomme Müll, der diesen Code auf Visual Studio 2015foo()
, zu existieren und dann abzusteigenboo()
.Foo()
undBoo()
beide treten mit dem Stapelzeiger an derselben Stelle ein. Dies ist jedoch kein Verhalten, auf das man sich verlassen sollte. Andere 'Sachen' (wie Interrupts oder das Betriebssystem) können den Stapel zwischen dem Aufruf vonboo()
und verwendenfoo()
und dessen Inhalt ändern ...In C ++, Sie können eine beliebige Adresse zugreifen, aber es bedeutet nicht , Sie sollten . Die Adresse, auf die Sie zugreifen, ist nicht mehr gültig. Es funktioniert, weil nichts anderes den Speicher nach der Rückkehr von foo verschlüsselt hat, aber es könnte unter vielen Umständen abstürzen. Versuchen Sie, Ihr Programm mit Valgrind zu analysieren oder es einfach nur optimiert zu kompilieren, und sehen Sie ...
quelle
Sie lösen niemals eine C ++ - Ausnahme aus, indem Sie auf ungültigen Speicher zugreifen. Sie geben nur ein Beispiel für die allgemeine Idee, auf einen beliebigen Speicherort zu verweisen. Ich könnte das auch so machen:
Hier behandle ich 123456 einfach als Adresse eines Doppelpacks und schreibe darauf. Es können beliebig viele Dinge passieren:
q
könnte tatsächlich eine gültige Adresse eines Doppels sein, zdouble p; q = &p;
.q
könnte irgendwo in den zugewiesenen Speicher zeigen und ich überschreibe dort nur 8 Bytes.q
Punkte außerhalb des zugewiesenen Speichers und der Speichermanager des Betriebssystems sendet ein Segmentierungsfehlersignal an mein Programm, wodurch die Laufzeit es beendet.Die Art und Weise, wie Sie es einrichten, ist etwas vernünftiger, dass die zurückgegebene Adresse auf einen gültigen Speicherbereich verweist, da sie sich wahrscheinlich nur ein wenig weiter unten im Stapel befindet, aber immer noch ein ungültiger Speicherort ist, auf den Sie in einem nicht zugreifen können deterministische Mode.
Während der normalen Programmausführung überprüft niemand automatisch die semantische Gültigkeit solcher Speicheradressen für Sie. Ein Speicher-Debugger wie
valgrind
dieser erledigt dies jedoch gerne. Sie sollten Ihr Programm also ausführen und die Fehler beobachten.quelle
4) I win the lottery
Haben Sie Ihr Programm mit aktiviertem Optimierer kompiliert? Die
foo()
Funktion ist recht einfach und wurde möglicherweise im resultierenden Code eingefügt oder ersetzt.Aber ich stimme Mark B zu, dass das resultierende Verhalten undefiniert ist.
quelle
5
wird geändert ...Ihr Problem hat nichts mit dem Umfang zu tun . In dem Code, den Sie anzeigen,
main
sieht die Funktion die Namen in der Funktion nichtfoo
, sodass Siea
in foo nicht direkt mit diesem Namen außerhalb zugreifen könnenfoo
.Das Problem, das Sie haben, ist, warum das Programm beim Verweisen auf unzulässigen Speicher keinen Fehler signalisiert. Dies liegt daran, dass C ++ - Standards keine sehr klare Grenze zwischen illegalem Speicher und legalem Speicher festlegen. Das Verweisen auf etwas im herausgesprungenen Stapel führt manchmal zu Fehlern und manchmal nicht. Es hängt davon ab, ob. Verlassen Sie sich nicht auf dieses Verhalten. Angenommen, es wird beim Programmieren immer zu Fehlern führen, aber beim Debuggen wird niemals ein Fehler angezeigt.
quelle
Sie geben nur eine Speicheradresse zurück, es ist erlaubt, aber wahrscheinlich ein Fehler.
Ja, wenn Sie versuchen, diese Speicheradresse zu dereferenzieren, haben Sie ein undefiniertes Verhalten.
quelle
cout
.*a
zeigt auf nicht zugewiesenen (freigegebenen) Speicher. Selbst wenn Sie es nicht derefenzieren, ist es immer noch gefährlich (und wahrscheinlich falsch).Das ist klassisches undefiniertes Verhalten , das hier vor nicht einmal zwei Tagen diskutiert wurde - suchen Sie ein bisschen auf der Website. Kurz gesagt, Sie hatten Glück, aber alles hätte passieren können, und Ihr Code macht einen ungültigen Zugriff auf den Speicher.
quelle
Dieses Verhalten ist undefiniert, wie Alex betonte - tatsächlich werden die meisten Compiler davor warnen, da dies ein einfacher Weg ist, um Abstürze zu bekommen.
Probieren Sie dieses Beispiel aus, um ein Beispiel für das gruselige Verhalten zu erhalten, das Sie wahrscheinlich bekommen:
Dies gibt "y = 123" aus, aber Ihre Ergebnisse können variieren (wirklich!). Ihr Zeiger blockiert andere, nicht verwandte lokale Variablen.
quelle
Beachten Sie alle Warnungen. Lösen Sie nicht nur Fehler.
GCC zeigt diese Warnung
Das ist die Kraft von C ++. Sie sollten sich um das Gedächtnis kümmern. Mit dem
-Werror
Flag wurde diese Warnung zu einem Fehler und jetzt müssen Sie sie debuggen.quelle
Es funktioniert, weil der Stapel (noch) nicht geändert wurde, seit a dort abgelegt wurde. Rufen Sie einige andere Funktionen auf (die auch andere Funktionen aufrufen), bevor Sie
a
erneut darauf zugreifen , und Sie werden wahrscheinlich nicht mehr so viel Glück haben ... ;-)quelle
Sie haben tatsächlich undefiniertes Verhalten aufgerufen.
Rückgabe der Adresse eines temporären Werks, aber wenn temporäre Werke am Ende einer Funktion zerstört werden, sind die Ergebnisse des Zugriffs auf sie undefiniert.
Sie haben also nicht geändert
a
, sondern den Speicherort, an dem sicha
einmal befand. Dieser Unterschied ist dem Unterschied zwischen Absturz und Nichtabsturz sehr ähnlich.quelle
In typischen Compiler-Implementierungen können Sie sich den Code als "Ausdruck des Werts des Speicherblocks mit der Adresse, die früher von a belegt war" vorstellen . Wenn Sie einer Funktion, die eine lokale Funktion festhält, einen neuen Funktionsaufruf hinzufügen, besteht eine
int
gute Chance, dass sich der Werta
(oder die Speicheradresse, auf diea
früher verwiesen wurde ) ändert. Dies liegt daran, dass der Stapel mit einem neuen Frame überschrieben wird, der andere Daten enthält.Dies ist jedoch ein undefiniertes Verhalten und Sie sollten sich nicht darauf verlassen, dass es funktioniert!
quelle
a
, enthielt der Zeiger die Adresse vona
. Obwohl der Standard nicht verlangt, dass Implementierungen das Verhalten von Adressen nach Ablauf der Lebensdauer ihres Ziels definieren, erkennt er auch an, dass UB auf einigen Plattformen auf eine dokumentierte Weise verarbeitet wird, die für die Umgebung charakteristisch ist. Während die Adresse einer lokalen Variablen nach Ablauf des Gültigkeitsbereichs im Allgemeinen nicht mehr von großem Nutzen ist, können einige andere Arten von Adressen nach der Lebensdauer ihrer jeweiligen Ziele noch von Bedeutung sein.realloc
Vergleich eines übergebenen Zeigers mit dem Rückgabewert zulassen oder Zeiger auf Adressen innerhalb des alten Blocks so anpassen, dass sie auf den neuen verweisen, tun dies einige Implementierungen und Code, der eine solche Funktion ausnutzt, kann effizienter sein als Code, der jegliche Aktion - sogar Vergleiche - vermeiden muss, die Zeiger auf die Zuweisung enthält, die gegeben wurderealloc
.Dies ist möglich, da
a
eine Variable vorübergehend für die Lebensdauer ihres Bereichs (foo
Funktion) zugewiesen wird . Nach Ihrer Rückkehr ausfoo
dem Speicher ist frei und kann überschrieben werden.Was Sie tun, wird als undefiniertes Verhalten beschrieben . Das Ergebnis kann nicht vorhergesagt werden.
quelle
Die Dinge mit korrekter (?) Konsolenausgabe können sich dramatisch ändern, wenn Sie :: printf verwenden, aber nicht cout. Sie können mit dem Debugger im folgenden Code herumspielen (getestet auf x86, 32-Bit, MSVisual Studio):
quelle
Nach der Rückkehr von einer Funktion werden alle Bezeichner zerstört, anstatt Werte an einem Speicherort zu speichern, und wir können die Werte nicht finden, ohne einen Bezeichner zu haben. Dieser Speicherort enthält jedoch weiterhin den von der vorherigen Funktion gespeicherten Wert.
Hier gibt die Funktion
foo()
also die Adresse von zurücka
unda
wird nach der Rückgabe ihrer Adresse zerstört. Über diese zurückgegebene Adresse können Sie auf den geänderten Wert zugreifen.Lassen Sie mich ein Beispiel aus der realen Welt nehmen:
Angenommen, ein Mann versteckt Geld an einem Ort und teilt Ihnen den Ort mit. Nach einiger Zeit stirbt der Mann, der Ihnen den Geldstandort mitgeteilt hat. Aber Sie haben immer noch Zugang zu diesem versteckten Geld.
quelle
Es ist eine "schmutzige" Art, Speicheradressen zu verwenden. Wenn Sie eine Adresse (einen Zeiger) zurückgeben, wissen Sie nicht, ob sie zum lokalen Bereich einer Funktion gehört. Es ist nur eine Adresse. Nachdem Sie die Funktion 'foo' aufgerufen haben, wurde diese Adresse (Speicherort) von 'a' bereits dort im (zumindest vorerst sicher) adressierbaren Speicher Ihrer Anwendung (Prozess) zugewiesen. Nachdem die 'foo'-Funktion zurückgegeben wurde, kann die Adresse von' a 'als' schmutzig 'betrachtet werden, sie ist jedoch vorhanden, wird weder bereinigt noch durch Ausdrücke in einem anderen Teil des Programms gestört / geändert (zumindest in diesem speziellen Fall). Der AC / C ++ - Compiler hindert Sie nicht an einem solchen "schmutzigen" Zugriff (könnte Sie jedoch warnen, wenn Sie sich darum kümmern).
quelle
Ihr Code ist sehr riskant. Sie erstellen eine lokale Variable (die nach Funktionsende als zerstört gilt) und geben die Speicheradresse dieser Variablen zurück, nachdem sie zerstört wurde.
Dies bedeutet, dass die Speicheradresse gültig sein kann oder nicht, und Ihr Code ist anfällig für mögliche Speicheradressprobleme (z. B. Segmentierungsfehler).
Dies bedeutet, dass Sie eine sehr schlechte Sache tun, weil Sie eine Speicheradresse an einen Zeiger übergeben, der überhaupt nicht vertrauenswürdig ist.
Betrachten Sie stattdessen dieses Beispiel und testen Sie es:
Im Gegensatz zu Ihrem Beispiel sind Sie mit diesem Beispiel:
quelle
new
.new
. Du bringst ihnen bei, es zu benutzennew
. Aber du solltest nicht benutzennew
.new
im Jahr 2019 (es sei denn, Sie schreiben Bibliothekscode) und bringen Sie Neulingen dies auch nicht bei! Prost.