printf n'affiche rien en C : tampon de stdout et fflush
Ton printf ne s'affiche pas avant un segfault, ou disparaît dans un pipe ? Le tampon de stdout montré en vrai, et trois façons de forcer l'affichage.
Tu ajoutes un printf pour savoir jusqu'où va ton programme avant de planter. Il plante, et ton message n'apparaît pas. Tu en conclus que le crash arrive plus tôt et tu cherches au mauvais endroit. En réalité, le printf a bien été exécuté : son texte attendait dans un tampon, et il est mort avec le processus.
En bref
printf n'écrit pas directement à l'écran : il copie le texte dans un tampon, une zone de mémoire de ton programme, que la bibliothèque C vide plus tard. Quand la sortie standard est un terminal, le tampon est vidé à chaque \n ; quand c'est un pipe ou un fichier, seulement quand il est plein ou quand le programme se termine normalement. Un crash tue le processus avant ce vidage, et le texte en attente est perdu. Pour forcer l'affichage, appelle fflush(stdout) juste après le message, ou écris tes traces sur stderr, qui n'a pas de tampon.
Pourquoi printf n'affiche rien avant un crash ?
Parce que l'affichage se fait en deux temps, et que le crash arrive entre les deux. Ce programme annonce ce qu'il fait, puis lit un entier à travers un pointeur nul :
#include <stdio.h>
int main(void){ int *score = NULL;
printf("Chargement du profil\n"); printf("Lecture du score..."); printf("%d\n", *score); return 0;}$ gcc -Wall -Wextra -g crash.c -o crash$ ./crashChargement du profilSegmentation faultLa ligne 8 s'est exécutée avant la ligne 9, qui a planté. Pourtant « Lecture du score... » n'apparaît nulle part. Si tu te fiais à l'affichage, tu chercherais le bug entre les lignes 7 et 8.
printf(3) met le texte en forme et le range dans le tampon de stdout, un simple tableau d'octets qui appartient à ton processus. Ce n'est qu'au moment du vidage que la bibliothèque C appelle write(2), l'appel système qui transmet réellement les octets au noyau, et donc au terminal.
- printf()met le texte en forme
- tampon de stdoutun tableau dans ton processus
- write(1, ...)appel système
- terminalou un pipe, ou un fichier
Le vidage a lieu quand le tampon est plein, quand tu appelles fflush(3), et quand le programme se termine normalement : un return depuis main ou un appel à exit, qui vide tous les flux ouverts. Un SIGSEGV, lui, arrête le processus sur place. Rien n'est vidé, le tampon disparaît avec le reste de la mémoire.
Pourquoi le \n change tout dans un terminal
La première ligne est passée parce qu'elle se terminait par \n. Quand stdout est relié à un terminal, la bibliothèque C le met en mode « ligne » : chaque \n déclenche un vidage. « Lecture du score... » n'avait pas de \n, il attendait la suite de sa ligne.
Il existe trois modes, et la bibliothèque choisit selon ce qui est branché au bout du flux :
| Flux | Branché sur | Mode | Le texte part |
|---|---|---|---|
stdout | un terminal | par ligne | à chaque \n, à fflush, à la fin normale du programme |
stdout | un pipe ou un fichier | par blocs | quand le tampon est plein, à fflush, à la fin normale du programme |
stderr | n'importe quoi | sans tampon | immédiatement |
La norme C fixe le principe : stdout est entièrement tamponné si et seulement si on peut déterminer qu'il ne désigne pas un périphérique interactif, et stderr ne l'est pas au démarrage. Le mode « par ligne » pour un terminal est la convention Unix, celle de glibc comme de musl.
C'est aussi ce qui s'est passé dans l'exemple du scanf sans & : « Ton âge : » ne s'est jamais affiché parce qu'il n'avait pas de \n et que scanf(3) a planté avant le vidage. Un détail rend le cas plus traître encore. Avant de lire l'entrée standard quand elle vient du clavier, glibc vide stdout ; l'invite aurait donc été visible si l'âge avait été tapé à la main. Dans l'exemple, l'âge arrivait par echo 19 |, l'entrée était un pipe, et glibc n'a rien vidé.
Pourquoi tout disparaît dans un pipe ou un fichier
Envoie la sortie du même programme dans un pipe ou dans un fichier, et cette fois même la première ligne disparaît :
$ ./crash | cat; echo ${PIPESTATUS[0]}139$ ./crash > sortie.txtSegmentation fault$ cat sortie.txtstdout n'est plus un terminal, il passe en mode « par blocs ». Le \n de « Chargement du profil » ne déclenche plus rien, et les deux lignes sont mortes dans le tampon. Bash n'annonce même pas le crash dans le premier cas : le statut d'un pipe est celui de sa dernière commande, cat, qui s'est bien terminée. La variable PIPESTATUS garde le statut de chaque commande, et 139 (128 + 11, le numéro de SIGSEGV) confirme que crash a planté.
Le piège devient vicieux quand le programme écrit beaucoup avant de planter. Celui-ci affiche mille lignes, puis déréférence un pointeur nul :
#include <stdio.h>
int main(void){ int *fin = NULL;
for (int i = 1; i <= 1000; i++) printf("ligne %d\n", i); printf("%d\n", *fin); return 0;}$ ./boucle | tail -n 2ligne 829ligne 830$ ./boucle | wc -c8192Seuls 8 192 octets sont sortis, des blocs complets. Les 170 dernières lignes, celles qui précèdent immédiatement le crash, sont restées dans le tampon. Si tu rediriges la sortie dans un fichier pour retrouver où ton programme s'arrête, tu perds précisément les lignes qui t'intéressent. La taille des blocs dépend du système, ne la prends pas pour une constante.
Sous musl, dans le terminal des exercices Grademe, le résultat diffère sur un point. musl démarre en mode ligne et ne vérifie qu'au premier vidage si stdout est un terminal. La première ligne terminée par \n passe donc toujours, puis musl bascule en mode par blocs, comme glibc :
$ ./crash | cat; echo ${PIPESTATUS[0]}Chargement du profil139Pourquoi printf et write s'affichent dans le désordre
write ne passe pas par le tampon de stdout. Si tu mélanges les deux sur la même sortie, l'ordre à l'écran dépend du mode de tampon :
#include <stdio.h>#include <unistd.h>
int main(void){ printf("1. printf\n"); write(1, "2. write\n", 9); printf("3. printf\n"); return 0;}$ gcc -Wall -Wextra -g ordre.c -o ordre$ ./ordre1. printf2. write3. printf$ ./ordre | cat2. write1. printf3. printfDans le terminal, chaque \n vide le tampon et l'ordre est respecté. Dans le pipe, les deux printf attendent la fin du programme, et le write, parti tout de suite, passe devant. La page de manuel de puts le dit sans détour : mélanger les fonctions de stdio et des appels à write sur le même descripteur donne des résultats indéfinis. Choisis l'un ou l'autre pour une sortie donnée, ou appelle fflush(stdout) avant chaque write.
write_str
Écris write_str, qui affiche une chaîne caractère par caractère avec write sur le descripteur 1, sans rien ajouter. Aucun tampon entre ton code et le terminal : chaque octet sort au moment où tu l'écris.
Comment forcer l'affichage
La solution la plus directe est d'appeler fflush(stdout) juste après le message qui doit sortir, avant le code suspect :
#include <stdio.h>
int main(void){ int *score = NULL;
printf("Chargement du profil\n"); printf("Lecture du score..."); fflush(stdout); printf("%d\n", *score); return 0;}$ ./flushChargement du profilLecture du score...Segmentation fault$ ./flush | catChargement du profilLecture du score...Les deux lignes sortent, dans le terminal comme dans le pipe : fflush a appelé write avant le crash.
Pour des traces de débogage, stderr est souvent un meilleur choix. Il n'a pas de tampon, et | ou > ne le redirigent pas : tes traces restent à l'écran pendant que la vraie sortie part dans le fichier.
#include <stdio.h>
int main(void){ int *score = NULL;
fprintf(stderr, "Chargement du profil\n"); fprintf(stderr, "Lecture du score..."); printf("%d\n", *score); return 0;}$ ./stderr | catChargement du profilLecture du score...Tu peux aussi supprimer le tampon de stdout pour tout le programme avec setvbuf(stdout, NULL, _IONBF, 0);, ou demander le mode ligne même dans un pipe avec _IOLBF. L'appel doit venir avant toute écriture sur stdout. Avec cette ligne ajoutée en tête du main de crash.c :
$ ./setvbuf | catChargement du profilLecture du score...Enfin, il y a la solution qui évite toute la question : pour un crash, ne cherche pas la ligne à coups de printf. Compilé avec -g et lancé dans gdb(1), le programme indique la ligne exacte qui a planté, comme le montre l'article sur le segmentation fault.
puts
Réécris puts(3) avec seulement write : la chaîne, puis exactement un \n, même pour une chaîne vide, et une valeur de retour positive ou nulle. Ta version n'a aucun tampon, rien ne peut y rester coincé quand le programme plante.
Questions fréquentes
Pourquoi mon texte n'apparaît qu'à la fin du programme ?
Parce qu'il n'a pas de \n, ou parce que la sortie va dans un pipe ou un fichier. Le texte attend dans le tampon, et la fin normale du programme (return depuis main ou exit) vide tous les flux d'un coup. Un fflush(stdout) après le message le fait sortir tout de suite.
Pourquoi « Segmentation fault » s'affiche, lui ?
C'est le shell qui l'écrit, après avoir constaté que ton processus est mort d'un signal. Il ne passe donc par aucun tampon de ton programme. Dans un pipe, bash ne l'affiche pas forcément, comme on l'a vu plus haut.
Faut-il écrire fflush(stdin) pour vider le clavier ?
Non. La norme C ne définit fflush que pour les flux de sortie : sur un flux d'entrée, le comportement est indéfini. POSIX l'étend aux fichiers sur disque, pas au terminal ni aux pipes. Pour jeter le reste d'une ligne tapée, lis-la jusqu'au \n.
Pourquoi mon printf s'affiche deux fois après un fork ?
Parce que fork copie toute la mémoire du processus, tampon de stdout compris. Un printf("avant fork ") sans \n se retrouve dans le tampon du parent et dans celui de l'enfant, et chacun le vide en se terminant. Appelle fflush(stdout) juste avant fork. Les processus sont détaillés dans fork, wait, pipe et exec.
Supprimer le tampon avec setvbuf ralentit-il le programme ?
Sans tampon, chaque printf se traduit par au moins un appel système, ce qui coûte cher dans un programme qui écrit beaucoup. Pour déboguer, c'est sans importance. En production, préfère des fflush placés là où l'ordre compte.
Sources
