web-dev-qa-db-fra.com

Peut-on accéder à la mémoire d'une variable locale en dehors de sa portée?

J'ai le code suivant.

#include <iostream>

int * foo()
{
    int a = 5;
    return &a;
}

int main()
{
    int* p = foo();
    std::cout << *p;
    *p = 8;
    std::cout << *p;
}

Et le code est en cours d'exécution sans exception d'exécution!

La sortie était 58

Comment cela peut-il être? La mémoire d'une variable locale n'est-elle pas inaccessible en dehors de sa fonction?

974
Avi Shukron

Comment cela peut-il être? La mémoire d'une variable locale n'est-elle pas inaccessible en dehors de sa fonction?

Vous louez une chambre d'hôtel. Vous mettez un livre dans le tiroir du haut de la table de chevet et allez vous coucher. Vous vérifiez le lendemain matin, mais "oubliez" de rendre votre clé. Vous volez la clé!

Une semaine plus tard, vous rentrez à l'hôtel, ne vous enregistrez pas, entrez dans votre ancienne chambre avec votre clé volée et regardez dans le tiroir. Votre livre est toujours là. Étonnant!

Comment cela peut-il être? Le contenu d'un tiroir de chambre d'hôtel n'est-il pas inaccessible si vous n'avez pas loué la chambre?

Eh bien, évidemment, ce scénario peut se produire dans le monde réel sans problème. Aucune force mystérieuse ne provoque la disparition de votre livre lorsque vous n'êtes plus autorisé à entrer dans la pièce. Il n'y a pas non plus de force mystérieuse qui vous empêche d'entrer dans une pièce avec une clé volée.

La direction de l'hôtel n'est pas nécessaire pour supprimer votre livre. Vous n'avez pas passé de contrat avec eux qui stipule que si vous laissez des objets derrière vous, ils les déchiqueteront pour vous. Si vous entrez illégalement dans votre chambre avec une clé volée pour la récupérer, le personnel de sécurité de l'hôtel n'est pas pour vous surprendre en train de vous faufiler. Vous n'avez pas passé de contrat avec ceux qui ont dit "si j'essaie de rentrer dans ma chambre plus tard, vous devez m'arrêter." Vous avez plutôt signé un contrat avec eux qui disait "Je promets de ne pas rentrer dans ma chambre plus tard", un contrat que vous avez rompu .

Dans cette situation , tout peut arriver . Le livre peut être là - vous avez eu de la chance. Le livre de quelqu'un d'autre peut être là et le vôtre dans la fournaise de l'hôtel. Quelqu'un pourrait être présent dès votre arrivée, déchirant votre livre. L'hôtel aurait pu retirer entièrement la table et le livre et le remplacer par une armoire. L’ensemble de l’hôtel pourrait être sur le point d’être démoli et remplacé par un stade de football. Vous allez mourir dans une explosion alors que vous vous glissez.

Vous ne savez pas ce qui va se passer lorsque vous avez quitté l'hôtel et volé une clé à utiliser illégalement plus tard, vous avez renoncé au droit de vivre dans un monde sûr et prévisible car vous avez choisi de ne pas respecter les règles du jeu. système.

C++ n'est pas un langage sûr . Cela vous permettra joyeusement d'enfreindre les règles du système. Si vous essayez de faire quelque chose d'illégal et de stupide, comme rentrer dans une pièce dans laquelle vous n'êtes pas autorisé et fouiller dans un bureau qui pourrait même ne plus être là, le C++ ne vous arrêtera pas. Des langages plus sûrs que C++ résolvent ce problème en limitant votre puissance - en exerçant un contrôle beaucoup plus strict sur les clés, par exemple.

MISE À JOUR

Sainte bonté, cette réponse attire beaucoup d'attention. (Je ne sais pas pourquoi - j'ai considéré qu'il s'agissait simplement d'une petite analogie "amusante", mais peu importe.)

J'ai pensé qu'il serait peut-être judicieux de mettre à jour cela avec quelques considérations techniques supplémentaires.

Les compilateurs sont en train de générer du code qui gère le stockage des données manipulées par ce programme. Il existe de nombreuses façons différentes de générer du code pour gérer la mémoire, mais au fil du temps, deux techniques de base se sont enracinées.

La première consiste à avoir une sorte de zone de stockage "longue durée" où la "durée de vie" de chaque octet dans la mémoire, c'est-à-dire la période de temps pendant laquelle il est associé valablement à une variable de programme, ne peut pas être facilement prédite à l'avance. de temps. Le compilateur génère des appels dans un "gestionnaire de tas" qui sait comment allouer de manière dynamique le stockage lorsque cela est nécessaire et le récupérer lorsqu'il n'est plus nécessaire.

La seconde méthode consiste à créer une zone de stockage "de courte durée" dans laquelle la durée de vie de chaque octet est bien connue. Ici, les durées de vie suivent un modèle de "nidification". La plus longue durée de vie de ces variables de courte durée sera allouée avant toute autre variable de courte durée et sera libérée en dernier. Les variables à durée de vie plus courte seront allouées après les plus longues, et seront libérées avant elles. La durée de vie de ces variables à durée de vie plus courte est "imbriquée" dans celle des variables à durée de vie plus longue.

Les variables locales suivent ce dernier modèle; Lorsqu'une méthode est entrée, ses variables locales s'animent. Lorsque cette méthode appelle une autre méthode, les variables locales de la nouvelle méthode s'animent. Ils seront morts avant que les variables locales de la première méthode ne soient mortes. L'ordre relatif des débuts et des fins de durée de vie des stockages associés aux variables locales peut être défini à l'avance.

Pour cette raison, les variables locales sont généralement générées en tant que stockage sur une structure de données "pile", car une pile a la propriété que la première chose appliquée sera la dernière chose extraite.

C'est comme si l'hôtel décidait de ne louer les chambres que de manière séquentielle, et vous ne pouvez pas vérifier si toutes les personnes dont le numéro de chambre est supérieur à celui que vous avez choisi.

Alors pensons à la pile. Dans de nombreux systèmes d'exploitation, vous obtenez une pile par thread et la pile est allouée pour avoir une certaine taille fixe. Lorsque vous appelez une méthode, des éléments sont placés dans la pile. Si vous transmettez ensuite un pointeur à la pile en arrière de votre méthode, comme l'affiche ici, il ne s'agit que d'un pointeur au milieu d'un bloc de mémoire d'un million d'octets entièrement valide. Dans notre analogie, vous quittez l'hôtel; lorsque vous le faites, vous venez de quitter la pièce occupée la plus numérotée. Si personne ne se présente après vous et si vous retournez dans votre chambre illégalement, toutes vos affaires sont assurées d'être toujours là dans cet hôtel particulier .

Nous utilisons des piles pour les magasins temporaires car elles sont vraiment faciles et peu coûteuses. Une implémentation de C++ n'est pas nécessaire pour utiliser une pile pour le stockage de locaux. il pourrait utiliser le tas. Ce n’est pas le cas, car cela ralentirait le programme.

Une implémentation de C++ n'est pas obligée de laisser les ordures que vous avez laissées intactes sur la pile afin que vous puissiez revenir pour les récupérer plus tard illégalement; il est parfaitement légal pour le compilateur de générer du code qui remet à zéro tout ce qui se trouve dans la "pièce" que vous venez de quitter. Ce n'est pas parce que, encore une fois, cela coûterait cher.

Une implémentation de C++ n'est pas nécessaire pour garantir que, lorsque la pile est réduite de manière logique, les adresses précédemment valides soient toujours mappées en mémoire. L’implémentation est autorisée à dire au système d’exploitation "nous avons fini d’utiliser cette page de pile maintenant. Jusqu'à ce que je dise autre chose, émettez une exception qui détruit le processus si quelqu'un touche la page de pile précédemment valide". Encore une fois, les implémentations ne le font pas réellement parce que c'est lent et inutile.

Au lieu de cela, les implémentations vous permettent de faire des erreurs et de vous en sortir. La plupart du temps. Jusqu'au jour où quelque chose de vraiment terrible se passe mal et le processus explose.

C'est problématique. Il y a beaucoup de règles et il est très facile de les enfreindre accidentellement. J'ai certainement plusieurs fois. Et pire encore, le problème ne fait souvent surface que lorsque la mémoire détectée est corrompue, des milliards de nanosecondes après la corruption, lorsqu'il est très difficile de déterminer qui l'a gâché.

Des langues plus sûres pour la mémoire résolvent ce problème en limitant votre puissance. En C # "normal", il n’ya tout simplement aucun moyen de prendre l’adresse d’un local et de la retourner ou de la stocker pour plus tard. Vous pouvez prendre l'adresse d'un local, mais la langue est intelligemment conçue pour qu'il soit impossible de l'utiliser après la fin de la vie des fins locales. Afin de prendre l'adresse d'un local et de la renvoyer, vous devez mettre le compilateur dans un mode spécial "unsafe", et mettre le mot "unsafe" dans votre programme. , pour attirer l'attention sur le fait que vous faites probablement quelque chose de dangereux qui pourrait enfreindre les règles.

Pour en savoir plus:

4724
Eric Lippert

Ce que vous faites ici, c'est simplement lire et écrire dans la mémoire habitué être l'adresse de a. Maintenant que vous êtes en dehors de foo, il ne s'agit que d'un pointeur sur une zone de mémoire aléatoire. Il se trouve que, dans votre exemple, cette zone de mémoire existe et que rien d'autre ne l'utilise pour le moment. Vous ne cassez rien en continuant à l'utiliser, et rien d'autre ne l'a encore écrasé. Par conséquent, le 5 est toujours là. Dans un programme réel, cette mémoire serait réutilisée presque immédiatement et vous casseriez quelque chose en procédant de la sorte (même si les symptômes n'apparaissent que bien plus tard!)

Lorsque vous revenez de foo, vous indiquez au système d'exploitation que vous n'utilisez plus cette mémoire qui peut être réaffectée à autre chose. Si vous avez de la chance et qu'il ne soit jamais réaffecté, et que le système d'exploitation ne vous empêche pas de l'utiliser à nouveau, vous vous en sortirez avec le mensonge. Il y a de fortes chances que vous finissiez par écrire sur tout ce qui se termine avec cette adresse.

Maintenant, si vous vous demandez pourquoi le compilateur ne se plaint pas, c'est probablement parce que foo a été éliminé par optimisation. Il vous avertira généralement de ce genre de chose. C suppose que vous savez ce que vous faites cependant et que techniquement vous n’avez pas violé la portée ici (il n’ya aucune référence à a elle-même en dehors de foo), uniquement des règles d’accès à la mémoire, qui ne déclenchent qu’un avertissement plutôt qu'une erreur.

En bref: cela ne fonctionnera généralement pas, mais le sera parfois par hasard.

271
Rena

Parce que l'espace de stockage n'a pas encore été utilisé. Ne comptez pas sur ce comportement.

148
msw

Un petit ajout à toutes les réponses:

si vous faites quelque chose comme ça:

#include<stdio.h>
#include <stdlib.h>
int * foo(){
    int a = 5;
    return &a;
}
void boo(){
    int a = 7;

}
int main(){
    int * p = foo();
    boo();
    printf("%d\n",*p);
}

la sortie sera probablement: 7

En effet, après le retour de foo (), la pile est libérée puis réutilisée par boo (). Si vous démontez l'exécutable, vous le verrez clairement.

80
Michael

En C++, vous pouvez accéder à n’importe quelle adresse, mais cela ne signifie pas que vous devriez . L'adresse à laquelle vous accédez n'est plus valide. Cela fonctionne car rien d’autre n’a brouillé la mémoire après le retour de foo, mais cela pourrait se bloquer dans de nombreuses circonstances. Essayez d’analyser votre programme avec Valgrind , ou même de le compiler optimisé, et voyez ...

68
Charles Brunet

Vous ne lancez jamais une exception C++ en accédant à une mémoire invalide. Vous ne faites que donner un exemple de l'idée générale de référencer un emplacement mémoire arbitraire. Je pourrais faire la même chose comme ça:

unsigned int q = 123456;

*(double*)(q) = 1.2;

Ici, je traite simplement 123456 comme l'adresse d'un double et j'écris. N'importe quel nombre de choses pourraient arriver:

  1. q pourrait en réalité être une adresse valide d'un double, par ex. double p; q = &p;.
  2. q pourrait pointer quelque part dans la mémoire allouée et je viens d'écraser 8 octets.
  3. q pointe en dehors de la mémoire allouée et le gestionnaire de mémoire du système d'exploitation envoie un signal d'erreur de segmentation à mon programme, ce qui provoque son arrêt par le moteur d'exécution.
  4. Vous gagnez la loterie.

La façon dont vous l'avez configurée est un peu plus raisonnable que l'adresse renvoyée pointe dans une zone de mémoire valide, car elle sera probablement un peu plus loin dans la pile, mais il s'agit toujours d'un emplacement non valide auquel vous ne pouvez pas accéder dans un répertoire. mode déterministe.

Personne ne vérifiera automatiquement la validité sémantique des adresses de mémoire comme cela pour vous lors de l'exécution normale du programme. Cependant, un débogueur de mémoire tel que valgrind le fera avec plaisir, vous devriez donc exécuter votre programme à travers celui-ci et constater les erreurs.

66
Kerrek SB

Avez-vous compilé votre programme avec l'optimiseur activé? La fonction foo() est assez simple et peut avoir été insérée ou remplacée dans le code résultant.

Mais je suis d’accord avec Mark B pour dire que le comportement résultant est indéfini.

28
gastush

Votre problème n'a rien à voir avec scope. Dans le code que vous montrez, la fonction main ne voit pas les noms dans la fonction foo, vous ne pouvez donc pas accéder à a dans foo directement avec this nom extérieur foo.

Le problème que vous rencontrez est la raison pour laquelle le programme ne signale pas d'erreur lors du référencement de mémoire illégale. En effet, les normes C++ ne spécifient pas une limite très claire entre la mémoire illégale et la mémoire légale. Référencer quelque chose dans la pile sautée provoque parfois une erreur et parfois pas. Ça dépend. Ne comptez pas sur ce comportement. Supposons que cela entraîne toujours une erreur lors de la programmation, mais qu’il ne signale jamais une erreur lors du débogage.

22
Chang Peng

Vous ne faites que renvoyer une adresse mémoire, c'est autorisé mais c'est probablement une erreur.

Oui, si vous essayez de déréférencer cette adresse mémoire, vous aurez un comportement indéfini.

int * ref () {

 int tmp = 100;
 return &tmp;
}

int main () {

 int * a = ref();
 //Up until this point there is defined results
 //You can even print the address returned
 // but yes probably a bug

 cout << *a << endl;//Undefined results
}
17
Brian R. Bondy

Faites attention à tous les avertissements. Ne résolvez pas seulement les erreurs.
GCC affiche cet avertissement

warning: adresse de la variable locale 'a' renvoyée

C'est le pouvoir de C++. Vous devriez vous soucier de la mémoire. Avec le drapeau -Werror, cet avertissement est devenu une erreur et vous devez maintenant le déboguer.

17
sam

Cela fonctionne parce que la pile n’a pas (encore) été modifiée depuis que a été placée là-bas. Appelez quelques autres fonctions (qui appellent également d'autres fonctions) avant d'accéder de nouveau à a et vous ne serez probablement plus aussi chanceux ... ;-)

16
Adrian Grigore

C’est classique comportement non défini c’est ce qui a été discuté ici il ya deux jours - recherchez un peu sur le site. En un mot, vous avez eu de la chance, mais tout aurait pu arriver et votre code permet un accès invalide à la mémoire.

16
Kerrek SB

Ce comportement n’est pas défini, comme Alex l’a fait remarquer. En fait, la plupart des compilateurs le déconseillent, car c’est un moyen facile d’obtenir un crash.

Pour un exemple du type de comportement sinistre que vous êtes susceptible d'obtenir, essayez cet exemple:

int *a()
{
   int x = 5;
   return &x;
}

void b( int *c )
{
   int y = 29;
   *c = 123;
   cout << "y=" << y << endl;
}

int main()
{
   b( a() );
   return 0;
}

Ceci affiche "y = 123", mais vos résultats peuvent varier (vraiment!). Votre pointeur écrase d'autres variables locales non liées.

16
AHelps

Vous avez en fait invoqué un comportement indéfini.

Renvoyer l'adresse d'une œuvre temporaire, mais comme les temporaires sont détruits à la fin d'une fonction, les résultats de leur accès seront indéfinis.

Ainsi, vous n'avez pas modifié a, mais plutôt l'emplacement de la mémoire où se trouvait jadis a. Cette différence est très similaire à la différence entre un crash et un non-crash.

15
Alexander Gessler

Dans les implémentations typiques du compilateur, vous pouvez considérer le code comme "affiche la valeur du bloc de mémoire dont l'adresse est anciennement occupée par un". De même, si vous ajoutez un nouvel appel de fonction à une fonction qui constitue une variable locale int, il est fort probable que la valeur de a (ou l'adresse de mémoire à laquelle a désignait). changements. Cela est dû au fait que la pile sera remplacée par une nouvelle image contenant des données différentes.

Cependant, il s’agit d’un comportement non défini et vous ne devez pas compter sur lui pour que cela fonctionne!

13
larsmoa

Cela est possible car a est une variable allouée temporairement pour la durée de vie de son étendue (fonction foo.). Après votre retour de foo, la mémoire est libre et peut être remplacée.

Ce que vous faites est décrit comme comportement non défini. Le résultat ne peut être prédit.

13
littleadv

Les choses avec une sortie de console correcte (?) Peuvent changer radicalement si vous utilisez :: printf mais pas cout. Vous pouvez jouer avec le débogueur dans le code ci-dessous (testé sur x86, 32 bits, MSVisual Studio):

char* foo() 
{
  char buf[10];
  ::strcpy(buf, "TEST”);
  return buf;
}

int main() 
{
  char* s = foo();    //place breakpoint & check 's' varialbe here
  ::printf("%s\n", s); 
}
11
Mykola

Après le retour d'une fonction, tous les identificateurs sont détruits au lieu de conserver les valeurs dans un emplacement de mémoire et nous ne pouvons pas localiser les valeurs sans avoir d'identificateur. Mais cet emplacement contient toujours la valeur stockée par la fonction précédente.

Donc, ici, la fonction foo() renvoie l'adresse de a et a est détruite après le renvoi de son adresse. Et vous pouvez accéder à la valeur modifiée via cette adresse renvoyée.

Prenons un exemple concret:

Supposons qu'un homme cache de l'argent à un endroit et vous indique l'endroit. Après un certain temps, l'homme qui vous avait dit l'emplacement de l'argent meurt. Mais vous avez toujours accès à cet argent caché.

4

C'est une façon "sale" d'utiliser des adresses de mémoire. Lorsque vous renvoyez une adresse (pointeur), vous ne savez pas si elle appartient à la portée locale d'une fonction. C'est juste une adresse. Maintenant que vous avez appelé la fonction 'foo', cette adresse (emplacement mémoire) de 'a' y était déjà allouée dans la mémoire adressable (en toute sécurité, pour le moins au moins) de votre application (processus). Après le retour de la fonction 'foo', l'adresse de 'a' peut être considérée comme 'sale' mais elle est là, pas nettoyée, ni perturbée/modifiée par des expressions dans une autre partie du programme (du moins dans ce cas spécifique). Un compilateur C/C++ ne vous empêche pas d'un tel accès "sale" (vous pouvez toutefois vous en avertir). Vous pouvez utiliser (mettre à jour) en toute sécurité tout emplacement de mémoire situé dans le segment de données de votre instance de programme (processus) à moins de protéger l'adresse de quelque manière que ce soit.

3
Ayub

Votre code est très risqué. Vous créez une variable locale (considérée comme détruite à la fin de la fonction) et vous retournez l’adresse de mémoire de cette variable après sa destruction.

Cela signifie que l'adresse mémoire peut être valide ou non, et votre code sera vulnérable à d'éventuels problèmes d'adresse mémoire (par exemple, erreur de segmentation).

Cela signifie que vous faites une très mauvaise chose, car vous transmettez une adresse mémoire à un pointeur qui n’est pas fiable du tout.

Considérez plutôt cet exemple et testez-le:

int * foo()
{
   int *x = new int;
   *x = 5;
   return x;
}

int main()
{
    int* p = foo();
    std::cout << *p << "\n"; //better to put a new-line in the output, IMO
    *p = 8;
    std::cout << *p;
    delete p;
    return 0;
}

Contrairement à votre exemple, avec cet exemple, vous êtes:

  • allouer de la mémoire pour int dans une fonction locale
  • cette adresse mémoire est toujours valide même lorsque la fonction expire (elle n'est supprimée par personne)
  • l'adresse de mémoire est digne de confiance (ce bloc de mémoire n'est pas considéré comme libre, il ne sera donc pas remplacé jusqu'à ce qu'il soit supprimé)
  • l'adresse mémoire doit être supprimée lorsqu'elle n'est pas utilisée. (voir la suppression à la fin du programme)
0
Nobun