Chaînes de caractères en C : '\0', strcpy, strncpy, strcmp

Une chaîne C est un tableau de char terminé par '\0'. Ce que ça change pour strlen, strcpy, strncpy, strcmp, strdup et fgets, avec les avertissements de GCC 15.

Par
Publié le

En C, il n'y a pas de type « chaîne ». Pas de string, pas de longueur rangée quelque part : seulement des char à la suite, et un octet nul pour dire où ça s'arrête. Presque tous les pièges des chaînes viennent de là. strlen doit parcourir le texte pour le mesurer, strcpy ne connaît pas la taille de ta destination, et == compare des adresses.

En bref

Une chaîne de caractères en C est un tableau de char terminé par le caractère nul '\0', l'octet de valeur 0. Aucune longueur n'est stockée : strlen compte les caractères jusqu'au '\0', sans le compter, donc "Rabat" occupe 6 octets pour une longueur de 5. Les fonctions de <string.h> font confiance à ce '\0' et à la place que tu leur donnes : strcpy ne vérifie pas la taille de la destination, strncpy peut laisser une chaîne sans terminateur, et deux chaînes se comparent avec strcmp, jamais avec ==.

Qu'est-ce qu'une chaîne de caractères en C ?

Un tableau de char dont le dernier élément utile est '\0'. Quand tu écris char ville[] = "Rabat";, le compilateur réserve six octets, les cinq lettres et le terminateur :

taille.cVisualiser
#include <stdio.h>#include <string.h>
int main(void){    char ville[] = "Rabat";
    printf("strlen : %zu\n", strlen(ville));    printf("sizeof : %zu\n", sizeof ville);    printf("ville[5] vaut %d\n", ville[5]);    return 0;}
Terminal
$ gcc -Wall -Wextra -g taille.c -o taille$ ./taillestrlen : 5sizeof : 6ville[5] vaut 0
char ville[] = "Rabat";

Pile (stack)

main()
char[6]ville
'R''a''b''a''t''\0'

sizeof mesure le tableau, 6 octets. strlen(3) mesure le texte, 5 caractères. Le sixième octet est bien là et vaut 0. Ne le confonds pas avec le caractère '0', qui vaut 48 en ASCII, ni avec NULL, qui est un pointeur.

Ce '\0' est le seul repère que les fonctions de chaînes possèdent. strlen avance octet par octet jusqu'à lui ; printf("%s") affiche tout jusqu'à lui. S'il manque, elles continuent dans la mémoire qui suit, jusqu'au premier octet nul qu'elles croisent. C'est le point commun de tous les bugs de cet article.

strlen ne compte pas le '\0'

strlen renvoie le nombre d'octets avant le terminateur, dans un size_t qu'on affiche avec %zu. D'où le + 1 qu'on retrouve dans tout code qui alloue une copie : malloc(strlen(s) + 1). L'oublier, c'est écrire le '\0' un octet trop loin, une erreur qui ne plante presque jamais tout de suite.

Si tu veux voir de près ce que fait strlen, strlen te le fait réécrire : une boucle, une condition d'arrêt, et le cas de la chaîne vide.

Pourquoi strcpy est dangereux

strcpy(3) copie la source jusqu'à son '\0' inclus, sans connaître la taille de la destination. La page de manuel le dit en toutes lettres : c'est au programmeur de prévoir strlen(src) + 1 octets, sinon le comportement est indéfini. Quand la source est écrite en dur, GCC 15 voit le problème à la compilation :

Terminal
$ gcc -Wall -Wextra -g debord.c -o deborddebord.c: In function 'main':debord.c:8:5: warning: '__builtin_memcpy' writing 11 bytes into a region of size 8 overflows the destination [-Wstringop-overflow=]    8 |     strcpy(ville, "Casablanca");      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~~debord.c:6:10: note: destination object 'ville' of size 8    6 |     char ville[8];      |          ^~~~~

Quand la source vient de l'utilisateur, il ne voit plus rien :

copie.cÀ éviter
#include <stdio.h>#include <string.h>
int main(int argc, char **argv){    char ville[8];
    if (argc < 2)        return 1;    strcpy(ville, argv[1]);    printf("%s\n", ville);    return 0;}
Terminal
$ gcc -Wall -Wextra -g copie.c -o copie$ ./copie RabatRabat$ ./copie CasablancaCasablanca

Aucun warning, aucun crash, et pourtant 11 octets ont été écrits dans un tableau de 8. Les trois derniers ont écrasé ce qui suivait ville sur la pile. AddressSanitizer le confirme :

Terminal
$ gcc -g -fsanitize=address copie.c -o copie-asan$ ./copie-asan Casablanca===================================================================24==ERROR: AddressSanitizer: stack-buffer-overflow on address 0xfbffab100028 at pc 0xffffad900f94 bp 0xffffd4ecbb40 sp 0xffffd4ecbb90WRITE of size 11 at 0xfbffab100028 thread T0    #0 0xffffad900f90  (/usr/local/lib64/libasan.so.8+0x90f90)    #1 0x0000004009dc in main /home/student/lab/copie.c:10...  This frame has 1 object(s):    [32, 40) 'ville' (line 6) <== Memory access at offset 40 overflows this variable...SUMMARY: AddressSanitizer: stack-buffer-overflow /home/student/lab/copie.c:10 in main

Si tu as déjà vu *** buffer overflow detected ***, c'est le même bug attrapé par une autre protection. Compilé avec -O2 -D_FORTIFY_SOURCE=2, les en-têtes de glibc remplacent strcpy par une version vérifiée, qui connaît la taille de ville et arrête le programme :

Terminal
$ gcc -O2 -D_FORTIFY_SOURCE=2 copie.c -o copie-fort$ ./copie-fort Casablanca*** buffer overflow detected ***: terminatedAborted

Pour copier sans risque, mesure avant de copier, ou laisse snprintf faire le travail : il n'écrit jamais plus que la taille donnée, termine la chaîne dès que cette taille n'est pas nulle, et renvoie la longueur qu'il aurait voulu écrire, ce qui permet de détecter la troncature.

snp.c
#include <stdio.h>
int main(int argc, char **argv){    char ville[8];    int n;
    if (argc < 2)        return 1;    n = snprintf(ville, sizeof ville, "%s", argv[1]);    printf("%s\n", ville);    if (n >= (int)sizeof ville)        printf("tronqué : il fallait %d octets\n", n + 1);    return 0;}
Terminal
$ ./snp CasablancaCasablatronqué : il fallait 11 octets

strncpy ne termine pas toujours la chaîne

On présente souvent strncpy(3) comme la version sûre de strcpy. Elle évite bien de déborder, mais elle a son propre piège : si la source fait n octets ou plus, elle en copie exactement n et n'ajoute pas de '\0'. Le résultat n'est alors plus une chaîne.

tronque.cÀ éviter
#include <stdio.h>#include <string.h>
struct fiche {    char nom[4];    char ville[8];};
int main(void){    struct fiche f = { .ville = "Rabat" };
    strncpy(f.nom, "Yasmine", sizeof f.nom);    printf("nom : %s\n", f.nom);    return 0;}
Terminal
$ gcc -Wall -Wextra -g tronque.c -o tronquetronque.c: In function 'main':tronque.c:13:5: warning: 'strncpy' output truncated copying 4 bytes from a string of length 7 [-Wstringop-truncation]   13 |     strncpy(f.nom, "Yasmine", sizeof f.nom);      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~$ ./tronquenom : YasmRabat
Après strncpy(f.nom, "Yasmine", 4)

Pile (stack)

main()
char[4]f.nom
'Y''a''s''m'
aucun '\0'
char[8]f.ville
'R''a''b''a''t''\0''\0''\0'

printf a lu les quatre octets de nom, n'a pas trouvé de terminateur, et a continué dans ville, le champ qui suit dans la structure, jusqu'à son '\0'. Lire au-delà d'un tableau est un comportement indéfini : ici le résultat est prévisible parce que les deux champs sont collés, mais avec une variable ordinaire, printf afficherait ce qui traîne sur la pile.

L'inverse est vrai aussi : quand la source est plus courte que n, strncpy remplit tout le reste de la destination avec des '\0'. Copier trois lettres dans un tampon de 4 096 octets en écrit 4 096.

Peut laisser nom sans terminateurÀ éviter
strncpy(f.nom, "Yasmine", sizeof f.nom);
Termine toujours, quitte à tronquerCorrect
strncpy(f.nom, "Yasmine", sizeof f.nom - 1);f.nom[sizeof f.nom - 1] = '\0';

Avec la seconde version, le programme affiche nom : Yas et GCC ne signale plus rien. Tronquer reste une décision : un nom coupé est parfois pire qu'une erreur franche, et c'est à toi de le choisir.

C

strncpy

Écris gm_strncpy avec le vrai contrat de strncpy : au plus n octets copiés, le reste rempli de '\0' quand la source est plus courte, et aucun terminateur quand elle est plus longue. Les tests affichent chaque octet de la destination, y compris ceux que tu n'aurais pas dû toucher.

Ouvrir l'exercice

Comparer deux chaînes : strcmp, pas ==

== entre deux chaînes compare deux adresses, pas deux textes. GCC le signale avec -Wall :

compare.cÀ éviter
#include <stdio.h>#include <string.h>
int main(void){    char reponse[] = "oui";
    if (reponse == "oui")        printf("== : égales\n");    else        printf("== : différentes\n");    if (strcmp(reponse, "oui") == 0)        printf("strcmp : égales\n");    return 0;}
Terminal
$ gcc -Wall -Wextra -g compare.c -o comparecompare.c: In function 'main':compare.c:8:17: warning: comparison with string literal results in unspecified behavior [-Waddress]    8 |     if (reponse == "oui")      |                 ^~$ ./compare== : différentesstrcmp : égales

reponse est un tableau sur la pile, "oui" une chaîne littérale rangée ailleurs : mêmes octets, adresses différentes. strcmp(3) compare les octets un par un, interprétés comme des unsigned char, et renvoie 0 si les deux chaînes sont identiques, un nombre négatif si la première vient avant dans l'ordre des octets, positif sinon. Seul le signe compte, pas la valeur. Attention au test if (strcmp(a, b)) : il est vrai quand les chaînes sont différentes. Écris == 0 en entier, c'est plus lisible. L'exercice strcmp te fait écrire cette comparaison à la main.

Concaténer avec strcat

strcat ajoute une chaîne à la fin d'une autre, en écrasant le '\0' de la première. La destination doit déjà être une chaîne et avoir la place pour le tout, soit strlen(dst) + strlen(src) + 1 octets :

concat.c
#include <stdio.h>#include <string.h>
int main(void){    char chemin[32] = "/home/";
    strcat(chemin, "yasmine");    strcat(chemin, "/notes.txt");    printf("%s (%zu caractères)\n", chemin, strlen(chemin));    return 0;}
Terminal
$ ./concat/home/yasmine/notes.txt (23 caractères)

strcat ne vérifie pas plus la place que strcpy. Pour assembler un texte à partir de morceaux variables, snprintf(chemin, sizeof chemin, "/home/%s/notes.txt", nom) fait la même chose en une ligne, avec la taille en garde-fou.

Dupliquer avec strdup

strdup(3) alloue avec malloc exactement strlen(s) + 1 octets, y copie la chaîne et renvoie le pointeur. La copie t'appartient : tu peux la modifier, et tu dois la libérer avec free.

dup.c
#include <stdio.h>#include <stdlib.h>#include <string.h>
int main(void){    char *copie = strdup("Marrakech");
    if (copie == NULL)        return 1;    copie[0] = 'm';    printf("%s\n", copie);    free(copie);    return 0;}
Terminal
$ gcc -Wall -Wextra -g dup.c -o dup$ valgrind -q ./dupmarrakech

Valgrind ne signale rien : le bloc alloué par strdup a bien été rendu. Longtemps, strdup n'a existé que dans POSIX ; C23 l'a fait entrer dans la norme. Avec les options par défaut de GCC, ou avec -std=c23, <string.h> la déclare. Si tu imposes une norme stricte plus ancienne, elle disparaît :

Terminal
$ gcc -std=c17 -Wall -Wextra -g dup.c -o dup17dup.c: In function 'main':dup.c:7:19: error: implicit declaration of function 'strdup'; did you mean 'strcmp'? [-Wimplicit-function-declaration]    7 |     char *copie = strdup("Marrakech");      |                   ^~~~~~      |                   strcmpdup.c:7:19: error: initialization of 'char *' from 'int' makes pointer from integer without a cast [-Wint-conversion]

En -std=c17, ajoute #define _POSIX_C_SOURCE 200809L avant le premier #include, ou compile en C23. Le message lui-même est expliqué dans implicit declaration of function.

Lire une ligne avec fgets : le \n gardé

fgets(3) lit une ligne dans un tableau, sans jamais dépasser la taille donnée, et termine toujours la chaîne. C'est la bonne façon de lire du texte tapé par l'utilisateur. Sa particularité surprend la première fois : elle garde le \n de fin de ligne.

prenom.c
#include <stdio.h>
int main(void){    char prenom[32];
    printf("Prénom : ");    if (fgets(prenom, sizeof prenom, stdin) == NULL)        return 1;    printf("Bonjour %s !\n", prenom);    return 0;}
Terminal
$ echo Yasmine | ./prenomPrénom : Bonjour Yasmine !

Le \n lu fait partie de prenom, juste avant le '\0', et le point d'exclamation passe à la ligne. Pour l'enlever, remplace-le par un terminateur. strcspn(prenom, "\n") donne la position du premier \n, ou la longueur de la chaîne s'il n'y en a pas, ce qui rend la ligne sûre dans les deux cas :

C
prenom[strcspn(prenom, "\n")] = '\0';
Terminal
$ echo Yasmine | ./prenom2Prénom : Bonjour Yasmine !
C

bounded_copy_audit

La fonction de départ copy_frame fait confiance à la longueur annoncée par l'appelant et écrit au-delà de sa destination. Répare-la pour qu'elle s'arrête à la première de trois limites (la longueur annoncée, la fin de la source, la capacité moins un) et termine toujours la chaîne. Chaque test repasse sous un vérificateur mémoire : un seul octet de trop suffit à échouer.

Ouvrir l'exercice

Le parcours C consacre un module entier à ces copies bornées, Writing text without overflowing : strcpy, strcat, strncpy et strncat à réécrire toi-même.

Questions fréquentes

Quelle différence entre '\0', '0' et NULL ?

'\0' est le caractère nul, l'octet de valeur 0 qui termine une chaîne. '0' est le chiffre zéro, qui vaut 48 en ASCII. NULL est un pointeur qui ne désigne rien : on le compare à un pointeur, jamais à un caractère.

Pourquoi strlen("été") renvoie 5 ?

Parce que strlen compte des octets, pas des lettres. Dans un fichier source en UTF-8, chaque « é » occupe deux octets. Pour compter les caractères d'un texte accentué, il faut décoder l'UTF-8, ce que <string.h> ne fait pas.

Pourquoi sizeof ne donne pas la longueur de ma chaîne ?

sizeof mesure la mémoire, pas le texte. Sur un tableau, il donne sa capacité, terminateur compris ; sur un char *, la taille du pointeur, 8 octets sur une machine 64 bits. La différence est détaillée dans Tableau ou pointeur en C.

Peut-on modifier une chaîne déclarée avec char *s = "..." ?

Non. s pointe alors sur une chaîne littérale, que le compilateur range en lecture seule, et l'écriture plante sous Linux avec un segmentation fault. Déclare un tableau, char s[] = "...", ou fais une copie avec strdup.

Comment savoir si strncpy a tronqué ma chaîne ?

Pas avec son résultat : la page de manuel précise qu'on ne peut pas distinguer une troncature à partir de ce que renvoie strncpy. Compare strlen(src) à la taille de la destination avant de copier, ou utilise snprintf, dont la valeur de retour indique la longueur complète.

Sources

À toi d'écrire le code.