Segmentation fault en C : causes et ligne exacte
Ton programme C s'arrête sur « Segmentation fault » sans dire où ? Les causes les plus fréquentes, et comment trouver la ligne exacte avec gdb ou Valgrind.
Le programme compile sans une erreur. Tu le lances, il affiche une ou deux lignes, puis s'arrête net sur Segmentation fault. Pas de numéro de ligne, pas de nom de variable. Bonne nouvelle : la ligne fautive se retrouve en une seule commande, et la cause est presque toujours dans une liste courte.
En bref
Un segmentation fault, c'est le système qui arrête ton programme parce qu'il a touché une adresse mémoire qui ne lui appartient pas : un pointeur NULL, une chaîne en lecture seule, une zone déjà rendue, une pile épuisée. Le compilateur ne peut pas toujours le prévoir. Pour trouver la ligne, recompile avec -g puis lance gdb -q -batch -ex run -ex bt ./main ou valgrind ./main : les deux affichent le fichier et le numéro de la ligne qui a planté.
Que veut dire « Segmentation fault » ?
C'est le nom du signal SIGSEGV, que le noyau envoie à un processus qui lit ou écrit à une adresse interdite. Chaque programme a son espace d'adresses, découpé en zones : le code, les données, le tas, la pile. Une adresse en dehors de ces zones, ou une écriture dans une zone en lecture seule, déclenche le signal, et le programme s'arrête sur place.
Prends ce programme, qui lit un entier à travers un pointeur qui ne pointe sur rien :
#include <stdio.h>
int main(void){ int *score = NULL;
printf("Lecture du score...\n"); printf("%d\n", *score); return 0;}$ gcc -Wall -Wextra -g null.c -o null$ ./nullLecture du score...Segmentation fault$ echo $?139Le code de sortie 139 vaut 128 + 11, et 11 est le numéro de SIGSEGV sous Linux. Sur beaucoup de machines, le message se termine par (core dumped). Ça dépend seulement de la limite de taille des fichiers « core » :
$ ulimit -c unlimited$ ./nullLecture du score...Segmentation fault (core dumped)Le système a écrit dans un fichier core une image de la mémoire du processus au moment du crash, qu'un débogueur peut relire après coup. Le crash, lui, est exactement le même.
Trouver la ligne exacte en une commande
La méthode tient en trois étapes, et elle marche pour tous les cas de cet article.
Recompile avec
-g. Le binaire garde alors la correspondance entre les instructions et les lignes de ton fichier source.Relance le programme dans gdb(1) ou sous valgrind(1).
Lis la première ligne qui pointe dans ton propre fichier. Les lignes au-dessus appartiennent souvent à la bibliothèque C, ce n'est pas là que se trouve l'erreur.
Avec gdb, en mode non interactif :
$ gdb -q -batch -ex run -ex bt ./nullLecture du score...
Program received signal SIGSEGV, Segmentation fault.0x00000000004006a0 in main () at null.c:88 printf("%d\n", *score);#0 0x00000000004006a0 in main () at null.c:8-ex run lance le programme, -ex bt affiche la pile d'appels (backtrace) au moment du crash. La réponse est sur la deuxième ligne : null.c:8, et gdb recopie même la ligne fautive.
Valgrind donne la même ligne, avec en plus la nature de l'accès :
$ valgrind -q ./nullLecture du score...==21== Invalid read of size 4==21== at 0x4006A0: main (null.c:8)==21== Address 0x0 is not stack'd, malloc'd or (recently) free'd==21== ==21== ==21== Process terminating with default action of signal 11 (SIGSEGV)==21== Access not within mapped region at address 0x0==21== at 0x4006A0: main (null.c:8)...Segmentation faultInvalid read of size 4 : le programme a voulu lire 4 octets, la taille d'un int. Address 0x0 : à l'adresse zéro, c'est-à-dire NULL. Quand l'adresse vaut 0x0 ou une très petite valeur, cherche un pointeur nul. L'article Lire un rapport Valgrind détaille chaque message.
Les causes les plus fréquentes
Voici les cinq qu'on croise le plus souvent en écrivant du C. Tous les programmes ci-dessous ont été compilés avec -Wall -Wextra : regarde combien d'entre eux étaient annoncés par un warning.
Déréférencer un pointeur NULL
C'est le cas de null.c. Un pointeur vaut NULL parce qu'on l'a initialisé ainsi, parce qu'une fonction a échoué (malloc renvoie NULL quand il n'y a plus de mémoire, fopen quand le fichier n'existe pas) ou parce qu'on a oublié de le faire pointer quelque part.
Pile (stack)
- int *score
- NULL
Le pointeur existe, mais il ne désigne aucun int. Écrire *score, c'est demander l'entier stocké à l'adresse zéro, que le système réserve justement pour que ce genre d'accès plante tout de suite. Le réflexe : teste le pointeur avant de t'en servir dès qu'il vient d'une fonction qui peut échouer.
Oublier le & dans scanf
scanf(3) a besoin de savoir où écrire la valeur lue, donc il attend une adresse. Lui passer la variable elle-même, c'est lui donner une valeur quelconque qu'il va traiter comme une adresse.
#include <stdio.h>
int main(void){ int age;
printf("Ton âge : "); scanf("%d", age); printf("Tu as %d ans\n", age); return 0;}$ gcc -Wall -Wextra -g scanf.c -o scanfscanf.c: In function 'main':scanf.c:8:13: warning: format '%d' expects argument of type 'int *', but argument 2 has type 'int' [-Wformat=] 8 | scanf("%d", age); | ~^ ~~~ | | | | | int | int *scanf.c:8:5: warning: 'age' is used uninitialized [-Wuninitialized] 8 | scanf("%d", age); | ^~~~~~~~~~~~~~~~scanf.c:5:9: note: 'age' was declared here 5 | int age; | ^~~$ echo 19 | ./scanfSegmentation faultGCC l'a dit deux fois avant même l'exécution : expects argument of type 'int *'. La correction est scanf("%d", &age);.
Remarque un détail : « Ton âge : » ne s'est jamais affiché, alors que le printf est exécuté avant le crash. Le texte attendait dans un tampon de sortie qui n'a pas eu le temps d'être vidé. Ce piège mérite son propre article : pourquoi printf n'affiche rien avant un crash.
Modifier une chaîne littérale
#include <stdio.h>
int main(void){ char *nom = "grademe";
nom[0] = 'G'; printf("%s\n", nom); return 0;}$ gcc -Wall -Wextra -g literal.c -o literal$ ./literalSegmentation faultCette fois, aucun warning. Le texte "grademe" est une chaîne littérale : le compilateur la range dans une zone en lecture seule du programme, et nom ne fait que pointer dessus. La norme dit que la modifier est un comportement indéfini ; sous Linux, ça se traduit par un segfault.
Pile (stack)
- char *nom
- litteral
Données statiques
Pour obtenir une chaîne modifiable, déclare un tableau : il reçoit sa propre copie du texte, sur la pile.
char *nom = "grademe";nom[0] = 'G'; /* écrit en lecture seule */char nom[] = "grademe";nom[0] = 'G'; /* nom est une copie à toi */Le détail complet de la différence entre les deux écritures est dans Tableau ou pointeur en C.
Renvoyer l'adresse d'une variable locale
#include <stdio.h>#include <stdlib.h>#include <string.h>
char *copie(const char *source){ char tampon[32];
strcpy(tampon, source); return tampon;}
int main(void){ char *s = copie("bonjour");
printf("%s\n", s); return 0;}$ gcc -Wall -Wextra -g dangling.c -o danglingdangling.c: In function 'copie':dangling.c:10:12: warning: function returns address of local variable [-Wreturn-local-addr] 10 | return tampon; | ^~~~~~$ ./danglingSegmentation faulttampon vit dans le cadre de pile de copie, qui disparaît au moment du return. L'adresse renvoyée ne désigne plus rien de valide. La trace de gdb révèle un détail instructif :
$ gdb -q -batch -ex run -ex bt ./dangling
Program received signal SIGSEGV, Segmentation fault.__strlen_generic () at ../sysdeps/aarch64/multiarch/../strlen.S:54#0 __strlen_generic () at ../sysdeps/aarch64/multiarch/../strlen.S:54#1 0x0000ffffae2a1ce8 in __GI__IO_puts (str=0x0) at ./libio/ioputs.c:35#2 0x00000000004006c8 [PAC] in main () at dangling.c:17Deux choses à lire. D'abord, le crash a lieu dans strlen, une fonction de la bibliothèque C : il faut descendre jusqu'à la ligne #2 pour retrouver ton code, dangling.c:17. Ensuite, str=0x0 : GCC a détecté qu'on renvoyait l'adresse d'une variable locale et l'a remplacée par NULL, et il a transformé le printf("%s\n", s) en un puts(s) plus rapide. Un autre compilateur aurait pu renvoyer l'ancienne adresse et afficher n'importe quoi. Dans les deux cas, la correction est d'allouer le résultat avec malloc et de laisser l'appelant le libérer.
Une récursion qui ne s'arrête jamais
#include <stdio.h>
int compte(int n){ return compte(n + 1) + 1;}
int main(void){ printf("%d\n", compte(0)); return 0;}$ gcc -Wall -Wextra -g recursion.c -o recursionrecursion.c: In function 'compte':recursion.c:3:5: warning: infinite recursion detected [-Winfinite-recursion] 3 | int compte(int n) | ^~~~~~recursion.c:5:12: note: recursive call 5 | return compte(n + 1) + 1; | ^~~~~~~~~~~~~$ ./recursionSegmentation faultChaque appel empile un nouveau cadre. Sans cas d'arrêt, la pile grandit jusqu'à sa limite (ulimit -s affiche 8192, soit 8 Mo, sur la plupart des distributions Linux) et le premier accès au-delà déclenche le signal. Ici, GCC a repéré la récursion infinie. Il ne le verra pas toujours, par exemple quand le cas d'arrêt existe mais n'est jamais atteint.
Pas de segfault ne veut pas dire pas de bug
Le cas le plus traître ne plante pas. Ce programme lit une case après la fin d'un tableau de cinq notes :
#include <stdio.h>
int main(void){ int notes[5] = {12, 15, 9, 18, 11};
for (int i = 0; i <= 5; i++) printf("notes[%d] = %d\n", i, notes[i]); return 0;}$ gcc -Wall -Wextra -g bounds.c -o bounds$ ./boundsnotes[0] = 12notes[1] = 15notes[2] = 9notes[3] = 18notes[4] = 11notes[5] = 5Aucun warning, aucun crash, et une sixième note qui n'existe pas. notes[5] est juste après le tableau, dans une zone qui appartient toujours au programme : le système n'a aucune raison d'intervenir, on lit simplement ce qui traîne là. Un segfault n'arrive que quand l'adresse sort des zones du processus, et un dépassement de quelques octets n'en sort presque jamais.
Pour attraper ce genre d'erreur, il faut un outil qui surveille chaque accès. AddressSanitizer, intégré à GCC et Clang, s'active à la compilation :
$ gcc -g -fsanitize=address bounds.c -o bounds-asan$ ./bounds-asannotes[0] = 12notes[1] = 15notes[2] = 9notes[3] = 18notes[4] = 11===================================================================29==ERROR: AddressSanitizer: stack-buffer-overflow on address 0xfbff8f000034 at pc 0x000000400b94 bp 0xfffff85af990 sp 0xfffff85af9a8READ of size 4 at 0xfbff8f000034 thread T0 #0 0x000000400b90 in main /home/student/lab/bounds.c:8... This frame has 1 object(s): [32, 52) 'notes' (line 5) <== Memory access at offset 52 overflows this variable...SUMMARY: AddressSanitizer: stack-buffer-overflow /home/student/lab/bounds.c:8 in mainLe rapport nomme la variable (notes), sa taille (les octets 32 à 52, soit 20 octets pour 5 int) et l'accès fautif, juste après. C'est pour ça que les bugs de mémoire en C sont si difficiles : le même programme peut sembler marcher chez toi et planter ailleurs. On en parle dans Comportement indéfini en C.
index_guard
Le programme de départ lit a[i] avant de vérifier que i est un indice valide. La plupart des tests passent, puis un seul fait planter le processus. Remets les deux instructions dans le bon ordre.
Les réflexes qui évitent la plupart des segfaults
Sur les cinq crashs de cet article, trois étaient annoncés par GCC avant la première exécution. Le premier réflexe est donc de compiler avec -Wall -Wextra et de lire chaque warning comme une erreur probable. Les autres :
initialise chaque pointeur, à
NULLs'il n'a pas encore de cible, et teste-le avant de le déréférencer quand il vient demalloc,fopenou d'une fonction qui peut échouer ;après
free(p), écrisp = NULL;: un second accès plantera franchement au lieu de lire une zone recyclée ;ne renvoie jamais l'adresse d'une variable locale, alloue ou fais passer un tampon par l'appelant ;
pendant que tu développes, compile de temps en temps avec
-fsanitize=addressou lance tes tests sous Valgrind.
dangling_repair
build_label renvoie une chaîne construite dans une mémoire qui n'existe plus au moment où l'appelant la lit. Les tests de texte passent, la vérification mémoire échoue : répare la fonction pour qu'elle rende un bloc qui lui appartient vraiment.
Questions fréquentes
Que veut dire « core dumped » ?
Le système a écrit sur le disque une image de la mémoire du processus au moment du crash, un fichier « core ». Un débogueur peut l'ouvrir après coup (gdb ./main core) pour examiner l'état du programme. La mention n'apparaît que si les core dumps sont activés sur ta machine ; sans elle, le crash est le même.
Pourquoi mon programme plante sur une machine et pas sur une autre ?
Parce qu'un accès mémoire invalide est un comportement indéfini : le résultat dépend de ce qui se trouve à cette adresse, du compilateur, des options d'optimisation et du système. Un programme qui « marche chez moi » avec un dépassement de tableau peut planter ailleurs. L'absence de crash ne prouve rien, il faut un outil comme Valgrind ou AddressSanitizer.
Un segfault peut-il abîmer mon ordinateur ?
Non. C'est justement le système d'exploitation qui protège la mémoire des autres programmes en arrêtant le tien. Rien n'est écrit en dehors de l'espace de ton processus.
Pourquoi le printf juste avant le crash ne s'affiche pas ?
Parce que la sortie standard est mise en mémoire tampon. Quand elle n'est pas reliée à un terminal, ou que la ligne ne se termine pas par \n, le texte attend dans le tampon et disparaît avec le processus. fflush(stdout) force l'affichage. Les détails sont dans printf n'affiche rien avant un crash.
Sources
