C - Fuite mémoire en dehors du tas ?

Je crois avoir trouvé un moyen de réaliser cette opération étrange...

Le problème exposé dans ce sujet a été résolu.

Bonjour, voici un petit code de mon cru qui, il me semble soulève un problème. On dit souvent qu’on ne peut être victime de fuite mémoire que dans le tas mais je viens de coder ceci et il me semble que cela engendre une fuite mémoire "pas dans le tas" (je sais pas comment on appelle la zone mémoire réservée à une variable de classe automatique dans une fonction). J’ai rendu ce code le plus court possible promis ! Il est facile à lire globalement. A la compilation il fonctionne et affiche bien ce à quoi on s’attend. Pourriez-vous me dire ce que vous en pensez ? Merci d’avance…

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct temps_t {
	unsigned int heures;
	unsigned int minutes;
	unsigned int secondes;
};

void displayTemps(struct temps_t* p) //sert à afficher une variable de type struct temps_t
{
	printf("heures : %u\nminutes : %u\nsecondes : %u\n\n", p->heures, p->minutes, p->secondes);
}

void displayTab(int *p, size_t len) //affiche un tableau de int de taille "len"
{
	size_t k;
	for (k = 0 ; k < len ; k++)
		printf("[%zu] : %d\n", k, p[k]);

	printf("\n");
}

int main()
{
	struct temps_t *p = &(struct temps_t) {};
	displayTemps(p);
	p = NULL;	//une première "fuite mémoire" pas très importante

	int (*pTab)[100] = &(int [100]) {[10] = 1, 2, 3, 4, 5, 6, 7, 8, 9};
	displayTab(pTab[0], 100);
	pTab = NULL;	//une deuxième fuite mémoire, plus grave cette fois...

	return 0;
}
+0 -0

En quoi ce sont des fuites mémoire ?

Tu références des objets mémoire situés sur la pile puis après tu ne les références plus. Ils persisteront sur la pile jusqu’à que le scope de ces variables entraine leur suppression de la pile (ici en gros quand la fonction main() est finie).

C’est automatique et il n’y a rien de spécial à faire et ce n’est pas grave en soi.

C’est un peu comme ta variable k ligne 18 qui est supprimée de la pile une fois la fonction displayTab() est terminée. Ce n’est pourtant pas une fuite de mémoire non plus, c’est pourtant exactement la même chose.

C’est pourquoi il est recommandée de réduire aussi le scope des variables au maximum en particulier pour les grosses structures de données, pour éviter d’utiliser de la pile pour rien sur une période de temps trop longue (en plus d’améliorer la lisibilité).

Mais rien de grave ici.

+2 -0

Merci Renault, donc il n’y a pas de problème particulier à perdre le pointeur qui va vers l’objet d’une variable dans la pile (variable qui n’a pas d’identificateur en l’occurrence) car la mémoire de la variable sera libérée quoi qu’il arrive au même moment. En effet j’aurais pu y penser… La manipulation de mon code me paraissait trop étrange, il me fallait sans doute une confirmation je n’arrivais pas à voir plus loin que ça ;)

+0 -0

On ne peut avoir des fuites que sur des ressources allouées pour lesquelles on oublie de déclencher la restitution. Quand je parle de ressources, j’inclus aussi bien la mémoire que les handles de fichiers, sockets, mutex, ou autres éléments plus métiers (pot de peinture à ranger après utilisation).

Dans le cas des variables automatiques, "sur la pile" comme on dit généralement, la restitution est garantie à la fin de leur portée. Il n’y a rien à craindre de ce coté là.

Tout ce que tu me sembles avoir fait, c’est perdre le moyen d’accéder à quelque chose qui est toujours là (j’ai un doute ici), mais qui sera bien nettoyé le moment opportun par le compilo (par mise à jour du "registre pointeur" de pile en fin de fonction — dans le cas pas optimisé/machine théorique cible).

PS: tu peux t’amuser à faire tourner ce code avec l’adress sanitizer, tu verras que tout va bien coté fuites

PPS: je sais bien que je ne fais plus de C, mais c’est quand même bien tordu comme pratiques. Ah. Le C++ nous envoie bouler sur la ligne 27 car on veut prendre l’adresse d’une rvalue. Je me disais bien aussi… Je serai bien incapable de garantir que l’on n’a ni UB ni dangling pointer (le contraire des fuites)

Tu peux aussi essayer de compiler ton code en mode debug, et de l’exécuter avec valgrind, et tu dirais bien ce qu’il te dit ! :)

Mais je pense que même en rust ce code reste presque valide sauf que la première occurrence de p devrait être initialisée.

Ce qu’il faut bien comprendre avec les fuites mémoires, c’est qu’elles ne sont pas graves, sauf quand elles le deviennent :p Ce qu’on appelle généralement fuite mémoire c’est le fait de perdre la trace d’un blob (appelons ça comme ça) de mémoire. Or, tu le fais tout le temps.

void foo()
{
  int m = 0;
  print("%d", m);
}

int main(void)
{
  // AVANT 
  foo();
  // APRES
  return 0;
}

Pour l’état de la pile (là où sont stockées les variables temporaires, les adresses pour appeler les fonctions, bref, tout le process normal), il ne s’est rien passé entre AVANT et APRES. Or, dans foo, tu as bel et bien déclaré m … puis en as perdu la trace une fois que la pile d’appel est revenu à l’état AVANT durant le moment APRES.

Bref, en fait, des "fuites mémoires de pile" tu en fais tout le temps. Sauf que comme le temps de vie d’une variable dépend totalement des accolades à l’intérieur desquelles elle a été définie, ce n’est pas grave. En effet, cela signifie qu’une fois ces accolades terminées, ces variables locales disparaîtront.

Donc, ces variables locales ne pourriront pas ad vitam aeternam la RAM, puisqu’elles finissent par disparaître.

Ce qui est différent quand tu alloues sur le tas.

Pour te donner une idée, si on représente la RAM comme une suite de cases mémoires de 4 octets numérotés de 0 à 65536 (par exemple hein), le tas se situe dans la partie basse (à 0 et plus) et la pile dans la partie haute (de 65536 à plus bas).

Quand tu appelles une fonction, l’état local (registres, etc.) de ton programme est pushé sur la pile avant d’entrer dans la nouvelle fonction (il y a des conventions sur comment on le fait mais ici on s’en tape). Et quand une fonction se finit, l’état local est restauré à deux différences possibles :

  • Une valeur retournée par la fonction, qui est alors assignée
  • Les effets de bords : via des pointeurs en argument de la fonction, qui te permettent d’accéder à la mémoire "de l’état local précédent", ou au tas. Par exemple ce code :
void foo(int &m)
{
  *m = 0;
}

int main(void)
{
  // AVANT
  int k = 32;
  foo(&k);
  // APRES
  return 0;
}

Permet de modifier, dans l’appel de foo, l’état local précédant l’appel de foo. Mais est-ce que c’est grave ? Uniquement quand il y a dangling pointers : si &m a une valeur invalide (out of scope ou même null).

Mais, le problème de la pile, c’est qu’on a besoin de savoir, pour compiler (et donc, pour pouvoir générer l’assembleur qui y est liée), combien de place chaque fonction a besoin d’occuper, en octets. En échange de cette contrainte, ça fait qu’à chaque fin de fonction, le code assembleur généré sait exactement ce qu’il doit oublier et réapprendre pour "rollback" à l’état local du moment où la fonction a été appelé (moyennant la valeur de retour). Donc, si il y a des références qui sont paumés, et que ces références pointent sur la pile ce n’est pas grave. Les données qui sont liées à ces références seront oubliées, et ne s’accumuleront pas.

Alors qu’avec le tas, on a un fabuleux espace de travail ! Sauf que là, l’assembleur généré ne sait pas (par design) combien de mémoire va être allouée ou désallouée. Donc il ne peut pas faire le travail à notre place.

Et donc, si on reprend notre exemple, un peu modifié :

void foo()
{
  int k = 32;
  int *m = malloc(sizeof(int));
  // ICI
}

int main(void)
{
  // AVANT
  foo();
  // APRES
  return 0;
}

Au moment AVANT, il n’y a rien. Puis, l’état local est sauvegardé, et on entre dans foo. Sur la pile, un espace d’une taille donnée (sizeof(int)) est créé pour accueillir notre valeur (notre variable kk).

Puis, une variable de la taille d’un pointeur (i.e. comme un pointeur c’est un pointeur sur RAM, cela signifie que la taille sur la pile d’un pointeur est la même et correspond à la taille nécessaire pour stocker, une adresse, je crois que le type utilisée en C pour une adresse est size_t, mais je soupçonne mon C d’être rouillé et pense aussi à ssize_t) est créée sur la pile. Puis vient l’appel à malloc qui fait de la magie noire, demande à l’OS un espace mémoire de la taille sizeof(int) sur le tas, qui nous la donne bien gentiment généralement.

Dans l’état ICI, nous avons alors trois cases mémoires :

  • Celle pour kk (taille sizeof(int)) : un entier, sur la pile
  • Celle pour mm (taille sizeof(ssize_t)) : un pointeur, sur la pile
  • Celle pour m\ast m (taille sizeof(int)) : un entier, sur le tas

Et normalement, la valeur contenue dans mm correspond à l’adresse où est stockée l’entier sur notre tas.

Et là, patatras, on revient à APRES. Y a-t-il une valeur de retour ? Non. Y a-t-il eu des effets de bords (i.e. : de la mémoire créée précédemment qui a été modifiée) ? Non. Donc restauration de l’état local de pile au même état que à AVANT.

Woopsy doopsy. On oublie kk, on oublie mm, mais pas m\ast m, tout simplement car le code assembleur ne gère pas ça. Il gère la pile, pas le tas, et notre tas s’encombre.

Maintenant, imagine avec ce genre de scénario (débile, certes, mais pas si loin de ce qu’il se passe en réalité) :

void foo()
{
  int k = 32;
  int *m = malloc(sizeof(int));
  // ICI
}

int main(void)
{
  for(int k = 0; k < 10; k++)
  {
    foo();
  }
  return 0;
}

Tu imagines le nombre de leaks qu’on peut avoir ? Quand on dit "fuite mémoire", le mot est sans doute mal choisi pour le programmeur. C’est du point de vue du programme qu’il y a une fuite mémoire. En effet, pour le programme, il demande constamment de la mémoire à l’OS, qui lui donne, puis il voit la mémoire partir et il ne peut plus y accéder ! La mémoire s’enfuit !

Du point de vue du programmeur, on devrait peut-être dire : un encombrement problématique de mémoire, mais c’est moins classe :p

Bref, tout ça pour dire que non, ton exemple du début ne pose pas de problèmes.


Je vais même pousser le vice un peu plus loin. On va sortir valgrind pour voir ce qu’il en pense. Uniquement disponible sur Linux (et pour cause), valgrind est le meilleur outil que je connaisse pour traquer les fuites mémoires. Son usage est assez simple gcc -g fu.c -o fu && valgrind fu (tu peux omettre l’option -g du compilateur, elle permet juste à valgrind de t’afficher en langage humain où sont les erreurs, en sauvegardant les noms des fonctions dans l’exécutable (mode debug)). Valgrind me donne ceci sur la sortie d’erreur :

==2254== Memcheck, a memory error detector
==2254== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==2254== Using Valgrind-3.16.1 and LibVEX; rerun with -h for copyright info
==2254== Command: ./a.out
==2254== 
==2254== 
==2254== HEAP SUMMARY:
==2254==     in use at exit: 0 bytes in 0 blocks
==2254==   total heap usage: 1 allocs, 1 frees, 1,024 bytes allocated
==2254== 
==2254== All heap blocks were freed -- no leaks are possible
==2254== 
==2254== For lists of detected and suppressed errors, rerun with: -s
==2254== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Du coup, ton programme ne pose pas de fuites mémoires (tu peux croire valgrind, à moins que tu saches précisément ce que tu fais).

Maintenant, regardons mon dernier exemple … en code assembleur ! :p (qui est rouillé, alors j’espère que vais pas dire trop de bêtises).

Tu l’obtiens en faisant gcc foo.c -c -o foo.o suivi d’un objdump -d foo.o. Explications : Dans la compilation, il y a la production du code assembleur correspondant au programme (où on obtient les .o.o), puis une phase de linkage où un programme fait touuuuuut le travail pour que TON OS puisse faire fonctionner le programme. Cette deuxième partie rend le code assez illisible, et très plateforme-dépendant, donc je demande au compilateur de s’arrêter à la production de code objet avec -c. Puis objdump est un outil très pratique pour avoir des informations sur un fichier que tu utilises, et avec l’option -d il désassemble ton programme pour te permettre de lire l’assembleur de façon humaine (pas uniquement du code héxadécimal). On obtient ça.

Pour lire ce qu’il suit, commence à <main>.

foo.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <foo>:
   0:	55                   	push   %rbp // sauvegarde l'état local précédant l'appel à foo
   1:	48 89 e5             	mov    %rsp,%rbp // idem
   4:	48 83 ec 10          	sub    $0x10,%rsp 
                  // ** augmente la taille de la pile (de 0x10), pour accueillir k
   8:	c7 45 fc 20 00 00 00 	movl   $0x20,-0x4(%rbp) // met 32 (=0x20) dans la place faite
   f:	bf 04 00 00 00       	mov    $0x4,%edi // lié à malloc, on passe 
  14:	e8 00 00 00 00       	callq  19 <foo+0x19> // on appelle malloc
  19:	48 89 45 f0          	mov    %rax,-0x10(%rbp) // osef
  1d:	90                   	nop    // il ne se passe rien
  1e:	c9                   	leaveq 
       // on laisse place nette, et notamment, on remet la pile dans son état initial (opération
       // inverse à la ligne (**)
  1f:	c3                   	retq  // on rend la main

0000000000000020 <main>:
  20:	55                   	push   %rbp  // sauvegarde l'état local précédant l'appel à main
  21:	48 89 e5             	mov    %rsp,%rbp // idem
  24:	b8 00 00 00 00       	mov    $0x0,%eax // osef
  29:	e8 00 00 00 00       	callq  2e <main+0xe> // On appelle foo
  2e:	b8 00 00 00 00       	mov    $0x0,%eax // osef
  33:	5d                   	pop    %rbp
  34:	c3                   	retq

Et maintenant, demandons son avis à valgrind : gcc foo.c -g -o foo && valgrind ./foo

==2266== Memcheck, a memory error detector
==2266== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==2266== Using Valgrind-3.16.1 and LibVEX; rerun with -h for copyright info
==2266== Command: ./foo
==2266== 
==2266== 
==2266== HEAP SUMMARY:
==2266==     in use at exit: 4 bytes in 1 blocks
==2266==   total heap usage: 1 allocs, 0 frees, 4 bytes allocated
==2266== 
==2266== LEAK SUMMARY:
==2266==    definitely lost: 4 bytes in 1 blocks
==2266==    indirectly lost: 0 bytes in 0 blocks
==2266==      possibly lost: 0 bytes in 0 blocks
==2266==    still reachable: 0 bytes in 0 blocks
==2266==         suppressed: 0 bytes in 0 blocks
==2266== Rerun with --leak-check=full to see details of leaked memory
==2266== 
==2266== For lists of detected and suppressed errors, rerun with: -s
==2266== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Woopsy doopsy. Il est pas d’accord. Je vais même suivre son conseil et faire valgrind --leak-check=full ./foo :

==2267== Memcheck, a memory error detector
==2267== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==2267== Using Valgrind-3.16.1 and LibVEX; rerun with -h for copyright info
==2267== Command: ./foo
==2267== 
==2267== 
==2267== HEAP SUMMARY:
==2267==     in use at exit: 4 bytes in 1 blocks
==2267==   total heap usage: 1 allocs, 0 frees, 4 bytes allocated
==2267== 
==2267== 4 bytes in 1 blocks are definitely lost in loss record 1 of 1
==2267==    at 0x483877F: malloc (vg_replace_malloc.c:307)
==2267==    by 0x10914D: foo (foo.c:7)
==2267==    by 0x109162: main (foo.c:12)
==2267== 
==2267== LEAK SUMMARY:
==2267==    definitely lost: 4 bytes in 1 blocks
==2267==    indirectly lost: 0 bytes in 0 blocks
==2267==      possibly lost: 0 bytes in 0 blocks
==2267==    still reachable: 0 bytes in 0 blocks
==2267==         suppressed: 0 bytes in 0 blocks
==2267== 
==2267== For lists of detected and suppressed errors, rerun with: -s
==2267== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

Il te dit que tu as perdu la trace d’un mémoire définie à at 0x483877F: malloc (vg_replace_malloc.c:307) (c’est la lib standard, on ne veut pas y mettre les pieds, et généralement c’est pas elle le problème), qui a été appelé à by 0x10914D: foo (foo.c:7) (dans la fonction foo qui est dans le fichier foo.c et plus précisément sa ligne 7), elle même appelée par by 0x109162: main (foo.c:12) (fonction main dans le fichier foo.c, à la ligne 12).

Elle est pas belle la vie ? :D

Note de bas de pavé : Pas prévu d’écrire un truc aussi gros, j’espère que c’est lisible >.>

Mais je pense que même en rust ce code reste presque valide sauf que la première occurrence de p devrait être initialisée.

À moins de manipuler un pointeur qui vient d’une API externe (typiquement en appelant du code C) ou d’écrire tes propres structures de données, tu manipules jamais des pointeurs nus et en tout cas c’est pas avec eux que tu gères les ressources. T’as besoin d’un owner quelque part et c’est sa durée de vie qui détermine quand la ressource sera relâchée (ce qui ressemble dans beaucoup de cas à ce qui se passe pour la pile en C). L’équivalent idiomatique du code de l’OP (i.e. perdre moyen d’accéder à quelque chose sur la pile) serait via le shadowing (la ressource sera libérée en sortie de scope) ou bien réassigner une variable mutable (la ressource sera libérée lors de la mutation) :

fn some_function() {
    let arr = ['A'; 100];  // array 'A'
    let mut arr = ['B'; 100];  // array 'B'
    // ici, array 'A' n'est plus disponible, array 'B' l'est via arr
    arr = ['C'; 100];  // array 'C'
    // array 'B' est libéré par l'assignement précédent, array 'C' est disponible via arr
    // array 'A' n'est toujours pas disponible mais encore en mémoire
} // array 'C' et 'A' sont libérés car en dehors du scope

C’est pas franchement équivalent cela dit dans le sens où même si ces ressources étaient sur le tas, elles seraient aussi libérées via le mécanisme de Drop (alors que perdre tous les pointeurs vers quelque chose sur le tas en C donne une fuite). Le scénario classique pour créer une fuite de mémoire en Rust est lorsque tu veux passer un pointeur vers quelque chose sur le tas à du code C (typiquement avec Box::into_raw()). C’est alors la responsabilité du code appelant de te refiler le pointeur plus tard pour que tu puisses nettoyer la mémoire.

+0 -0

Mais je pense que même en rust ce code reste presque valide sauf que la première occurrence de p devrait être initialisée.

À moins de manipuler un pointeur qui vient d’une API externe (typiquement en appelant du code C) ou d’écrire tes propres structures de données, tu manipules jamais des pointeurs nus et en tout cas c’est pas avec eux que tu gères les ressources. T’as besoin d’un owner quelque part et c’est sa durée de vie qui détermine quand la ressource sera relâchée (ce qui ressemble dans beaucoup de cas à ce qui se passe pour la pile en C). L’équivalent idiomatique du code de l’OP (i.e. perdre moyen d’accéder à quelque chose sur la pile) serait via le shadowing (la ressource sera libérée en sortie de scope) ou bien réassigner une variable mutable (la ressource sera libérée lors de la mutation) :

adri1

Tu as tout à fait raison. En fait, je pensais vraiment à "écrire le code qui, en rust, fasse la même chose, de manière rustacéenne". Donc ce serait essentiellement des valeurs sur pile (et donc des Owner). Et en remplaçant les valeurs sur pile par d’autres valeurs sur pile, ça fonctionnerait. Mais effectivement, je ne parlais pas de manipuler des pointeurs en Rust. Juste de l’idée de scope et de shadowing, comme tu l’as souligné.

"pas dans le tas" (je sais pas comment on appelle la zone mémoire réservée à une variable de classe automatique dans une fonction)

La norme du langage n’impose pas de zone mémoire précise mais généralement une variable automatique est sur la pile (comme en parle @Renault). Ainsi, une variable est automatiquement dépilée quand on sort de la fonction, d’où l’impossibilité d’avoir une fuite de mémoire sur la pile comme il l’explique.

Pour le reste les autres t’on fait des réponses très complètes. :)

+0 -0

Salut,

Mais je pense que même en rust ce code reste presque valide sauf que la première occurrence de p devrait être initialisée.

Lyph

Si tu parles de la définition à la ligne 27, c’est le cas. Si la valeur des champs ou seulement de certains champs d’un agrégat littéral ne sont pas spécifiées (ce qui est le cas ici avec (struct temps_t) {}), la valeur de ceux-ci est soit zéro (entier ou flottant) soit un pointeur nul (et récursivement si la structure comporte une autre structure, un tableau ou une union).

+2 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte