Menu

Erreur de segmentation en C : causes et corrections

Un segfault signifie que votre programme a touché de la mémoire qui ne lui appartient pas. Voici les cinq causes qui expliquent presque toutes, chacune avec un exemple minimal et sa correction, plus comment trouver la ligne exacte avec gdb et AddressSanitizer.

Cette page contient des éditeurs exécutables - modifiez, exécutez et voyez la sortie instantanément.

Segmentation fault (core dumped)

Cette seule ligne est l'erreur la plus recherchée en C, et elle est moins mystérieuse qu'elle n'en a l'air. Votre programme a demandé une adresse mémoire au processeur, le système d'exploitation a vérifié si votre processus a le droit de toucher cette adresse, et la réponse était non. Le noyau a alors tué le processus avec un signal SIGSEGV.

L'idée clé : le plantage est un symptôme, et son emplacement n'est souvent pas le bug. Le mauvais pointeur a généralement été créé ailleurs, plus tôt, et voici simplement le premier endroit où il a été utilisé. Cette page couvre les cinq causes qui expliquent presque tous les segfaults, puis les deux outils qui trouvent la vraie ligne en quelques secondes.

Les exemples plantants ci-dessous ne sont délibérément pas des blocs exécutables - ils plantent par conception. Lisez-les, puis lisez la version corrigée qui suit.

Ce que signifie « de la mémoire qui n'est pas à vous »

Au démarrage de votre programme, le système d'exploitation mappe plusieurs régions dans son espace d'adressage : le code, les globales, la pile, et ce à quoi le tas a grandi. Tout le reste de l'espace d'adressage - y compris l'adresse 0 - n'est pas mappé. Touchez une adresse non mappée, ou écrivez dans une adresse en lecture seule, et le matériel l'intercepte.

Un segfault n'est donc pas le compilateur qui vous attrape. C'est un garde-fou d'exécution, et il ne se déclenche que si l'adresse invalide se trouve hors de vos pages mappées. C'est pourquoi le même bug peut planter sur une machine et sembler fonctionner sur une autre : la disposition mémoire diffère.

Cause 1 : déréférencer un pointeur NULL

La cause la plus courante, et la plus facile à corriger. L'adresse 0 n'est jamais mappée, donc lire ou écrire via un pointeur nul provoque toujours une faute.

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

int main(void) {
    int *p = NULL;
    *p = 42;              /* PLANTAGE : ecriture a l'adresse 0 */
    printf("%d\n", *p);
    return 0;
}

La version réaliste est une allocation non vérifiée :

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

int main(void) {
    int *data = malloc(1000000000000UL * sizeof(int));  /* echoue, renvoie NULL */
    data[0] = 1;                                        /* PLANTAGE */
    free(data);
    return 0;
}

malloc renvoie NULL quand il ne peut pas satisfaire la demande ; fopen aussi quand le fichier n'existe pas, et strchr quand le caractère est absent. Vérifiez chaque fonction pouvant renvoyer NULL avant d'utiliser son résultat.

Initialisez les pointeurs à NULL plutôt que de les laisser non initialisés. Un pointeur nul plante immédiatement et clairement ; un pointeur aberrant peut corrompre quelque chose et planter bien plus tard. Plus de détails sur le motif dans les pointeurs nuls.

Cause 2 : écrire au-delà de la fin d'un tableau

Le C ne vérifie pas les bornes des tableaux. L'indice 10 d'un tableau de 10 éléments est simplement la mémoire après le tableau, et le compilateur calculera cette adresse pour vous sans broncher.

#include <stdio.h>

int main(void) {
    int arr[10];

    for (int i = 0; i <= 10; i++) {   /* <= au lieu de < : un de trop */
        arr[i] = i;
    }

    printf("termine\n");
    return 0;
}

Que cela plante ou non est une question de chance. Écrire quatre octets après un tableau local atterrit généralement sur d'autres données de pile - un registre sauvegardé, une autre variable, l'adresse de retour - donc le programme se corrompt lui-même et plante plus tard, ailleurs. Un gros débordement quitte la page mappée et provoque un segfault immédiat.

La version plus sauvage plante toujours :

#include <stdio.h>

int main(void) {
    int arr[10];
    arr[1000000] = 42;      /* bien hors de tout ce qui est mappe : PLANTAGE */
    return 0;
}

La solution est l'habitude i < n, et le calcul de n plutôt que de le taper deux fois :

Les chaînes ont leur propre version de ceci : un tampon sans place pour le '\0' terminal.

#include <string.h>

int main(void) {
    char name[5];
    strcpy(name, "Alexander");   /* 9 caracteres + terminateur dans 5 octets */
    return 0;
}

strcpy n'a aucune idée de la taille de name. Utilisez snprintf, qui la connaît parce que vous la lui dites :

Cause 3 : utiliser un pointeur après free (pointeurs pendouillants)

Après free(p), la mémoire est rendue à l'allocateur. Le pointeur contient toujours l'ancienne adresse, mais cette adresse n'est plus à vous.

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

int main(void) {
    int *p = malloc(sizeof *p);
    *p = 42;
    free(p);

    printf("%d\n", *p);   /* usage apres liberation : peut afficher du garbage, peut planter */
    free(p);              /* double liberation : abandonne ou corrompt le tas */
    return 0;
}

Le piège voisin est de renvoyer l'adresse d'une variable locale. Sa trame de pile a disparu à l'instant du retour :

#include <stdio.h>

int *make_number(void) {
    int value = 42;
    return &value;      /* la trame meurt ici ; le pointeur pendouille */
}

int main(void) {
    int *p = make_number();
    printf("%d\n", *p);   /* indefini : garbage, ou plantage */
    return 0;
}

Deux corrections, selon ce que vous vouliez. Renvoyer la valeur plutôt qu'un pointeur, ou allouer sur le tas et laisser l'appelant libérer :

Mettre le pointeur à NULL juste après free est l'habitude défensive bon marché : elle transforme un usage-après-libération silencieux en plantage immédiat et évident par déréférencement nul, et elle rend un second free(p) inoffensif, puisque free(NULL) est défini comme ne faisant rien. Le versant allocation de cette histoire est dans mémoire dynamique et fuites de mémoire.

Cause 4 : débordement de pile par récursion emballée

Chaque appel de fonction place une trame sur la pile, et la pile est une région de taille fixe (couramment 8 Mo). Une récursion sans cas de base - ou avec un cas jamais atteint - en sort par le bout.

#include <stdio.h>

int countdown(int n) {
    printf("%d\n", n);
    return countdown(n - 1);    /* pas de cas de base : ne s'arrete jamais */
}

int main(void) {
    return countdown(5);
}

La même chose arrive avec un cas de base que la récursion enjambe :

int f(int n) {
    if (n == 0) return 1;
    return n * f(n - 2);    /* depuis un n impair, ne vaut jamais 0 */
}

Toute fonction récursive a besoin d'un cas de base atteignable depuis toute entrée :

Un énorme tableau local le fait aussi - int buffer[10000000]; dans une fonction demande 40 Mo de pile et provoque une faute à la première écriture. Allouez les gros tampons sur le tas avec malloc. Voir pile et tas pour les tailles en jeu, et la récursion pour la conception du cas de base.

Cause 5 : écrire dans un littéral de chaîne

Celle-ci surprend parce que le code semble inoffensif.

#include <stdio.h>

int main(void) {
    char *s = "hello";
    s[0] = 'H';          /* PLANTAGE : les litteraux de chaine sont en lecture seule */
    printf("%s\n", s);
    return 0;
}

Un littéral de chaîne vit dans une section en lecture seule de l'exécutable. char *s = "hello" pointe dedans ; écrire via ce pointeur est une faute de protection, que le système rapporte comme un segfault tout comme un accès non mappé.

La solution est d'en faire un tableau, qui obtient sa propre copie modifiable :

Déclarer les pointeurs vers des littéraux const char * transforme ce plantage d'exécution en erreur de compilation, ce qui est strictement mieux. Faites-en une habitude.

Trouver la vraie ligne : gdb

Compilez avec -g pour que l'exécutable porte les symboles de débogage, puis exécutez sous le débogueur :

gcc -g program.c -o program
gdb ./program

Dans gdb :

(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x0000555555555151 in process_item (item=0x0) at program.c:14
14          return item->count * 2;

(gdb) backtrace
#0  process_item (item=0x0) at program.c:14
#1  0x000055555555518a in main () at program.c:23

(gdb) print item
$1 = (struct Item *) 0x0

Trois commandes font l'essentiel du travail. run démarre le programme et s'arrête au point de faute. backtrace (ou bt) montre la chaîne d'appels qui y a mené - la trame #1 est généralement l'endroit où le mauvais pointeur a été réellement produit. print inspecte une variable, et item = 0x0 nomme le problème sans détour.

Sous macOS, l'équivalent est lldb ./program, puis run et bt.

Le trouver plus vite : AddressSanitizer

Mieux encore, laissez le compilateur instrumenter le programme. AddressSanitizer attrape l'accès invalide au moment où il se produit - y compris ceux qui n'auraient pas planté :

gcc -g -fsanitize=address program.c -o program
./program
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
WRITE of size 4 at 0x602000000010 thread T0
    #0 0x4011f6 in main program.c:11

0x602000000010 is located 0 bytes inside of 4-byte region
freed by thread T0 here:
    #1 0x4011c9 in main program.c:10
previously allocated by thread T0 here:
    #2 0x4011a6 in main program.c:8

Ce rapport nomme la classe du bug, la ligne qui l'a commis, la ligne qui a libéré la mémoire, et la ligne qui l'a allouée. C'est l'outil de débogage le plus efficace pour les bugs mémoire en C, il fonctionne avec GCC et clang sous Linux et macOS, et il coûte environ 2x en temps d'exécution - ce qui n'a aucune importance pendant le développement.

Associez-le à -fsanitize=undefined pour attraper en même temps le débordement signé et d'autres comportements indéfinis :

gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program

valgrind ./program est l'alternative qui ne demande aucune recompilation, et signale les mêmes classes d'erreurs plus les fuites.

Un aide-mémoire quand vous en rencontrez un

  1. Recompilez avec -g -Wall -Wextra -fsanitize=address,undefined et relancez. La plupart du temps le rapport nomme la ligne et c'est fini.
  2. Si le sanitizer n'est pas disponible, lancez sous gdb et prenez un backtrace. Regardez la trame 1, pas seulement la trame 0.
  3. Vérifiez chaque pointeur de la ligne du plantage. Affichez chacun ; 0x0 identifie un nul, et une valeur sauvage comme 0x7fff5fc01000 signifie généralement non initialisé ou libéré.
  4. Demandez-vous d'où vient ce pointeur. Un malloc ou un fopen non vérifié ? L'adresse d'une locale dont la fonction a retourné ? Un pointeur utilisé après free ?
  5. Vérifiez chaque borne de boucle près du plantage pour un <= là où un < était voulu.
  6. Si la trace de pile fait des milliers de trames, c'est une récursion emballée, pas un bug de pointeur.

Les prévenir

Les habitudes qui rendent les segfaults rares :

  • Compilez toujours avec -Wall -Wextra, et traitez les avertissements comme des bugs.
  • Initialisez chaque pointeur, à NULL si rien de mieux.
  • Vérifiez le retour de malloc, calloc, realloc et fopen.
  • Mettez les pointeurs à NULL immédiatement après libération.
  • Utilisez snprintf et fgets plutôt que sprintf et gets.
  • Déclarez les pointeurs vers des littéraux const char *.
  • Préférez sizeof arr / sizeof arr[0] à une longueur écrite en dur.
  • Faites tourner la suite de tests sous AddressSanitizer en intégration continue.

Un segfault est le mode d'échec aimable - il vous dit que quelque chose ne va pas. La même classe de bug qui corrompt silencieusement une variable voisine et produit de mauvaises réponses trois fonctions plus loin est bien pire, et les outils ci-dessus attrapent les deux.

Questions fréquentes

Qu'est-ce qu'une erreur de segmentation en C ?

Un plantage déclenché par le système d'exploitation quand votre programme accède à de la mémoire qu'il n'a pas le droit de toucher - lecture ou écriture via un pointeur invalide, dépassement de la fin d'un tableau vers une page non mappée, ou débordement de la pile. Le noyau envoie au processus un signal SIGSEGV, qui le termine et affiche « Segmentation fault (core dumped) ».

Comment trouver où se produit une erreur de segmentation ?

Compilez avec les symboles de débogage et exécutez sous un débogueur : gcc -g program.c -o program puis gdb ./program, run, et au plantage, backtrace. Cela affiche le fichier et la ligne exacts. Encore plus rapide pour les bugs mémoire : gcc -g -fsanitize=address program.c -o program - le simple fait d'exécuter le programme affiche alors un rapport complet de ce qui a mal tourné et où.

Pourquoi mon programme C ne plante-t-il que parfois ?

Parce que l'accès invalide est un comportement indéfini, pas un plantage garanti. Écrire un élément après un tableau atterrit souvent dans de la mémoire que votre processus possède, donc rien ne vous arrête - cela corrompt plutôt une variable voisine. Vous ne plantez que si la mauvaise adresse tombe hors d'une page mappée, ce qui dépend de la disposition de cette compilation et de cette exécution précises.

Une erreur de segmentation signifie-t-elle que j'ai une fuite de mémoire ?

Non - ce sont des problèmes opposés. Une fuite est de la mémoire allouée et jamais libérée : le programme continue et grossit lentement. Un segfault, c'est toucher de la mémoire qui n'est pas à vous. Libérer deux fois, ou utiliser un pointeur après l'avoir libéré, cause des segfaults ; oublier de libérer cause des fuites.

Coddy programming languages illustration

Apprendre à coder avec Coddy

COMMENCER