Sous Linux, le développement en C expose fréquemment à des plantages ou des blocages. Le plantage se manifeste souvent par un arrêt brutal avec le message Segmentation fault (core dumped). Le blocage, quant à lui, est généralement dû à des défauts logiques comme des boucles infinies ou des interblocages. Ces anomalies se regroupent principalement en deux catégories : les accès mémoire invalides et les conflits d'accès aux ressources.
- Accès mémoire invalides (lecture/écriture) : pointeurs invalides, conflits d'accès aux données partagées dans un contexte multithread, dépassement de bornes mémoire, débordement de tampon, etc.
- Conflits d'accès aux ressources : débordement de pile, débordement de tas, interblocage, etc.
- Accès mémoire invalides
Les accès mémoire invalides constituent la cause la plus fréquente des anomalies de programme. Bien que les symptômes apparents puissent varier, ils découlent souvent de ce type de problème.
1.1. Pointeurs invalides
Un pointeur invalide est l'exemple le plus classique. Un pointeur nul ou pointant vers une adresse invalide sont des erreurs courantes.
long *p;
*p = 0; // pointeur nul
p = (long *)0x12345678;
*p = 100; // accès à une adresse invalide
Que l'on accède à l'adresse nulle ou à une adresse invalide dans l'espace utilisateur, cela provoque une erreur de pointeur invalide. Les conversions de type forcées sont une source fréquente de pointeurs invalides. Il est donc crucial de vérifier le type avant toute conversion forcée pour éviter ce problème.
1.2. Conflits d'accès aux données partagées en multithread
Dans un programme multithread, l'apparition d'un pointeur invalide peut être plus subtile. Si plusieurs threads écrivent ou lisent et écrivent simultanément sur des données partagées sans verrouillage approprié, des conflits d'accès surviennent, conduisant à des pointeurs invalides, des données erronées, voire des altérations de la logique d'exécution.
// Variable globale
long *p = (long *)malloc(sizeof(long));
// Thread 1
if (p) {
*p = 100; // accès potentiellement invalide
}
// Thread 2
free(p);
p = NULL;
Dans cet exemple, le thread 1 vérifie que p n'est pas nul avant d'écrire, tandis que le thread 2 libère la mémoire et met p à NULL. Cependant, une commutation de contexte peut survenir juste après free(p) mais avant p = NULL, permettant au thread 1 d'accéder à une mémoire libérée, provoquant une erreur.
La solution standard pour ces conflits est d'utiliser des verrous (mutex, verrous de lecture/écriture) pour synchroniser l'accès aux données partagées. De nombreuses fonctions de la bibliothèque standard ou de GCC ne sont pas thread-safe ; il est impératif de consulter leur documentation et d'utiliser des verrous si nécessaire.
1.3. Dépassement de bornes mémoire
Le dépassement de bornes mémoire survient souvent lors de la manipulation de tableaux. Le C ne vérifie pas les limites des tableaux, donc un accès hors bornes peut ne pas produire d'erreur immédiate, mais peut entraîner des conséquences en chaîne.
void depassement() {
long *p;
long tableau[] = {0};
p = tableau;
tableau[1] = 0; // accès hors bornes : p est écrasé
p[0]++;
}
Cette fonction déclare un pointeur p et un tableau tableau de taille 1. p pointe sur le début du tableau. L'écriture dans tableau[1] (hors bornes) écrase la valeur de p (qui est stockée juste après tableau dans la pile), le mettant à 0. p devient alors un pointeur nul.
Cela s'explique par l'organisation de la pile : elle croît vers les adresses basses, et les variables locales définies en premier ont des adresses plus élevées. tableau et p font chacun 8 octets, donc tableau[1] correspond à l'adresse de p. La vérification des limites est essentielle pour les tableaux, tout comme pour les chaînes de caractères, où des fonctions de la bibliothèque standard peuvent aussi être sujettes à ce problème.
À ce propos, voici un cas piégeux lors du parcours inversé d'un tableau :
void parcoursInverse(long tableau[]) {
for(unsigned int index = sizeof(tableau) / sizeof(long) - 1;
index > 0; index--) {
printf("%ld\n", tableau[index]);
}
}
Si tableau a une longueur nulle, index devient un très grand nombre (sous-dépassement d'un unsigned), et la boucle devient infinie. Il faut être particulièrement vigilant lors de la vérification des limites.
1.4. Débordement de tampon
Le débordement de tampon est un cas particulier de dépassement de bornes, célèbre dans le domaine de la sécurité. Voici un exemple sur la pile (le même principe s'applique au tas) :
void debordement_pile() {
long *p;
long tableau[] = {0};
p = tableau;
tableau[3] = 0; // débordement de tampon
p[0]++;
}
La seule différence est l'indice 3 au lieu de 1. Ici, on écrase non seulement le pointeur p, mais aussi d'autres informations de la pile, comme l'adresse de retour de la fonction. En mettant tableau[3] à 0, on écrase l'adresse de retour par une adresse nulle, ce qui provoque un plantage au retour de la fonction. Un attaquant pourrait remplacer cette adresse par l'adresse d'un code malveillant. Cet exemple illustre le concept de base ; les attaques réelles sont plus complexes.
- Conflits d'accès aux ressources
2.1. Débordement de pile
Le débordement de pile (stack overflow) est différent du débordement de tampon dans la pile. Le système d'exploitation limite la taille de la pile allouée à chaque processus. Si la somme des variables locales d'une fonction dépasse cette limite (ou si la pile est déjà partiellement utilisée), un débordement se produit.
Pour connaître la taille maximale de la pile (en Ko) :
$ ulimit -s
8192
Si un tableau déclaré dans une fonction dépasse cette valeur (en pratique, un peu moins), il y aura débordement.
Une autre cause classique est la récursion infinie (ou trop profonde) :
void recursive_gauche() {
recursive_gauche();
}
Chaque appel consomme de la pile pour le cadre de pile ; une récursion sans condition finit par épuiser la mémoire de la pile.
2.2. Débordement de tas
Le débordement de tas (heap overflow) est conceptuellement similaire, mais l'espace tas est théoriquement limité par l'espace d'adressage (machine 32 ou 64 bits). En pratique, avec la mémoire virtuelle et le swap, il est rare de le rencontrer, sauf dans le cas de fuites mémoire (memory leaks). Un serveur qui alloue constamment de la mémoire sans la libérer finira par remplir le tas, et le processus sera tué par le système.
En C, les fonctions malloc et free gèrent le tas. L'équilibre entre les allocations et les libérations est crucial, mais difficile à maintenir manuellement. Heureusement, des outils d'analyse existent.
2.3. Interblocage (deadlock)
Si les verrous résolvent les conflits d'accès, leur utilisation incorrecte peut mener à un interblocage. Un interblocage ne provoque pas de plantage immédiat, mais bloque des threads, rendant le programme indisponible.
L'exemple classique est le verrouillage AB-BA : deux threads doivent acquérir deux verrous (A et B), mais chacun les acquiert dans un ordre différent. Le thread 1 prend A, le thread 2 prend B. Ensuite, le thread 1 ne peut pas prendre B (détenu par 2), et le thread 2 ne peut pas prendre A (détenu par 1). Les deux threads sont bloqués indéfiniment.
Pour éviter cela, on peut exiger que les threads acquièrent tous les verrous en une seule opération atomique, ou bien forcer un ordre d'acquisition commun pour tous les threads. L'analyse manuelle est fastidieuse, mais des outils existent.
- Résolution des anomalies
Sauf pour les boucles infinies et les interblocages (qui bloquent le processus), les autres anomalies provoquent un plantage avec Segmentation fault (core dumped). Linux permet de générer un fichier core dump, une "photographie" de la mémoire du processus au moment du crash. GDB permet d'analyser ce fichier. Pour les fuites mémoire et les interblocages, l'outil open-source Valgrind est très utile.
3.1. Analyse d'un fichier core dump
Étape 1 : Configuration
$ sudo ulimit -c unlimited
$ sudo echo core > /proc/sys/kernel/core_pattern
La première commande active la génération des fichiers core. La seconde définit leur emplacement (répertoire courant) et leur préfixe (core).
Étape 2 : Code d'exemple
Créer un fichier exemple.c :
void depassement() {
long *p;
long tableau[] = {0};
p = tableau;
tableau[1] = 0; // accès hors bornes
p[0]++;
}
int main() {
depassement();
return 0;
}
Étape 3 : Compilation et exécution
Compiler avec l'option -g pour les symboles de débogage :
$ gcc exemple.c -o exemple -g
$ ./exemple
Segmentation fault (core dumped)
$ ls core.*
core.12345
Étape 4 : Analyse avec GDB
$ gdb exemple core.12345
Reading symbols from exemple...done.
[New LWP 12345]
Core was generated by `./exemple'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0 0x00000000004004d6 in depassement () at exemple.c:6
6 p[0]++;
(gdb) backtrace
#0 0x00000000004004d6 in depassement () at exemple.c:6
#1 0x00000000004004f5 in main () at exemple.c:10
(gdb) print p
$1 = (long *) 0x0
(gdb)
GDB montre que le crash a lieu à la ligne 6, dans depassement. La commande backtrace affiche la pile d'appels. La commande print p confirme que p est nul. Ceci n'est qu'un exemple simple ; dans la réalité, l'erreur peut être plus difficile à localiser.
3.2. Utilisation de Valgrind
Valgrind est un outil puissant pour la détection de fuites mémoire, d'erreurs mémoire et d'interblocages. Il exécute le programme dans un environnement simulé.
Étape 1 : Installation
$ wget http://valgrind.org/downloads/valgrind-3.12.0.tar.bz2
$ tar xf valgrind-3.12.0.tar.bz2
$ cd valgrind-3.12.0
$ ./configure --prefix=/usr/local/
$ make && sudo make install
$ valgrind --version
valgrind-3.12.0
Étape 2 : Détection de fuite mémoire
Créer un fichier fuite.c :
#include <stdlib.h>
int main() {
malloc(4);
return 0;
}
Compiler et exécuter avec Valgrind :
$ gcc fuite.c -o fuite -g
$ valgrind --tool=memcheck --leak-check=full ./fuite
==12345== Memcheck, a memory error detector
...
==12345== LEAK SUMMARY:
==12345== definitely lost: 0 bytes in 0 blocks
==12345== indirectly lost: 0 bytes in 0 blocks
==12345== possibly lost: 0 bytes in 0 blocks
==12345== still reachable: 4 bytes in 1 blocks
==12345== suppressed: 0 bytes in 0 blocks
...
Valgrind signale que 4 octets sont encore accessibles à la fin du programme, ce qui indique une fuite mémoire (la mémoire allouée n'a pas été libérée).
Étape 3 : Détection d'interblocage
Créer un fichier blocage.c qui reproduit l'interblocage AB-BA :
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t verrou_A = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t verrou_B = PTHREAD_MUTEX_INITIALIZER;
void *thread1(void *args)
{
pthread_mutex_lock(&verrou_A);
sleep(1);
pthread_mutex_lock(&verrou_B);
pthread_mutex_unlock(&verrou_B);
pthread_mutex_unlock(&verrou_A);
return NULL;
}
void *thread2(void *args)
{
pthread_mutex_lock(&verrou_B);
sleep(1);
pthread_mutex_lock(&verrou_A);
pthread_mutex_unlock(&verrou_A);
pthread_mutex_unlock(&verrou_B);
return NULL;
}
int main()
{
pthread_t tids[2];
if (pthread_create(&tids[0], NULL, thread1, NULL) != 0)
exit(1);
if (pthread_create(&tids[1], NULL, thread2, NULL) != 0)
exit(1);
pthread_join(tids[0], NULL);
pthread_join(tids[1], NULL);
pthread_mutex_destroy(&verrou_A);
pthread_mutex_destroy(&verrou_B);
return 0;
}
Compiler avec la bibliothèque pthread et exécuter avec Valgrind :
$ gcc blocage.c -o blocage -g -lpthread
$ valgrind --tool=helgrind ./blocage
...
==24652== Thread #2: Exiting thread still holds 1 lock
==24652== Thread #3: Exiting thread still holds 1 lock
...
Helgrind (l'outil de Valgrind pour la détection de conflits dans les threads) détecte que les deux threads détiennent chacun un verrou et sont bloqués. Il faut interrompre le programme avec Ctrl+C. Les messages confirment l'interblocage.