Introduction à la manipulation mémoire en C et C++
Dans le développement logiciel avec C et C++, la compréhension des pointeurs et de la mémoire est fondamentale. Ces langages de bas niveau permettent une manipulation directe de la mémoire, offrant une grande flexibilité mais aussi un risque accru de bugs. Une maîtrise claire des concepts mémoire est donc essenteille.
L'allocation mémoire dans les systèmes 32 bits
Les systèmes d'exploitation 32 bits gèrent un espace d'adressage de 4 Go, généralement séparé en deux plages de 2 Go. Chaque processus peut accéder à 2 Go de mémoire privée (0x00000000 à 0x7FFFFFFF). Théoriquement, cela permet de déclarer des tableaux de grande taille, comme char stockage[2*1024*1024*1024];, mais en pratique, d'autres sections mémoire (code, variables tempérées) limitent cette utilisation.
La plage supérieure (0x80000000 à 0xFFFFFFFF) est réservée au noyau du système d'exploitation et aux bibliothèques partagées (comme les DLL sous Windows ou les SO sous Linux), qui opèrent dans cet espace pour des services inter-processus. Chaque processus voit sa propre mémoire privée et celle du système, sans accès direct à la mémoire des autres processus. Le système gère cela en coulisses, notamment via la mémoire virtuelle et le mappage dynamique des blocs.
Le concept de mémoire virtuelle
La mémoire virtuelle étend la mémoire physique rapide mais coûteuse en utilisant le disque dur, plus lent et économique. À un instant donné, les données nécessaires au programme sont chargées en RAM. Les données inactives peuvent être transférées sur disque pour libérer de la place. Le système d'exploitation crée l'illusion que chaque processus a un accès exclusif à l'espace d'adressage complet. Le matériel de gestion mémoire (MMU) traduit les adresses virtuelles en adresses physiques, et les processus ne sont pas conscients des éventuels échanges disque-mémoire.
Lorsqu'un processus tente d'accéder à une page non présente en mémoire physique, une erreur de page se produit. Si l'accès est invalide, le noyau envoie un signal de violation de segment ; sinon, il charge la page depuis le disque. Le processus est ensuite repris sans interruption perçue.
Les zones mémoire pour les programmes
La mémoire privée d'un processus est divisée en trois zones principales : la pile statique, la pile dynamique et le tas. La pile statique (ou segment de données statiques) contient les élémants fixes comme le code du programme, les variables globales et les constantes. La pile dynamique (souvent appelée pile) gère les variables locales et les membres d'objets pendant l'exécution des fonctions, avec une durée de vie liée à leur contexte. Le tas est utilisé pour les allocations dynamiques via malloc ou new, situé en haut de l'espace mémoire pour éviter les conflits avec la pile.
Considérons un exemple :
const int limite = 100;
void Procedure(void)
{
char caractere = 0;
char* tampon = (char*)malloc(10);
// ...
}
Ici, limite est dans la pile statique, caractere dans la pile dynamique, et la zone pointée par tampon est dans le tas.
Un exemple classique concerne le passage de paramètres aux threads. Étant donné que les threads s'exécutant de manière asynchrone, il ne faut jamais utiliser des variables locales d'une fonction pour leur passer des paramètres, car la pile dynamique est libérée à la fin de la fonction. À la place, on alloue dynamiquement une structure dans le tas, on la remplit, et on passe son pointeur au thread, qui le libère à la fin.
typedef struct _ParametresEcoute
{
int identifiantSocket;
// Autres paramètres...
} ParamsEcoute;
const taille_t TailleParamsEcoute = sizeof(ParamsEcoute);
bool Ecoute::CallbackTacheEcoute(void* pParamAppel, int& statut)
{
// Logique métier...
// Supposons que 'socket' soit la socket acceptée
ParamsEcoute* parametres = (ParamsEcoute*) malloc(TailleParamsEcoute);
parametres->identifiantSocket = socket;
// Démarrer le thread en passant parametres...
}
bool Ecoute::TacheAcceptation(void* pParamAppel, int& statut)
{
ParamsEcoute* parametres = (ParamsEcoute*)pParamAppel;
// Logique métier...
fermer(parametres->identifiantSocket); // Fermer la socket
free(pParamAppel); // Libérer la zone de paramètres
// ...
}
Les erreurs courantes liées à la mémoire
L'utilisation non rigoureuse de la mémoire et des pointeurs génère de nombreux bugs. Les erreurs fréquentes incluent :
Pointeurs invalides : Utiliser un pointeur non initialisé, après sa libération, ou transmettre un pointeur incorrect à une fonction. Pour atténuer cela, on peut mettre le pointeur à NULL après libération :
free(pointeur); pointeur = NULL;
Écritures hors limites : Écrire au-delà des frontières d'un tableau ou d'une zone allouée, corrompant les données adjacentes ou les structures de gestion du tas. Par exemple :
p = malloc(256); p[-1] = 0; p[256] = 0;
Erreurs de libération : Libérer deux fois le même bloc, libérer une zone non allouée dynamiquement, ou libérer un pointeur encore utilisé. Une erreur courante est de libérer des nœuds dans une boucle de liste chaînée sans précaution :
struct noeud *courant, *suivant;
for (courant = debut; courant; courant = suivant)
{
suivant = courant->suivant;
free(courant);
}
Ces pratiques aident à éviter les corruptions mémoire et les plantages d'application.