Les erreurs courantes en C

Cette page présente un collection d'erreur de programmation courante en C.

L’objectif n’est pas de couvrir tout le langage, mais de montrer quelques situations typiques où un programme peut compiler, s’exécuter, puis produire un résultat faux, instable ou dangereux.

Les exemples ci-dessous montrent aussi un point important : en dehors d’un sanitizer ou d’un outil d’analyse, beaucoup de ces erreurs restent silencieuses.

Fuite mémoire

Une fuite mémoire vient d'une allocation dynamic de mémoire qui n'est pas libérée avant la fin de l'execution du programme.

Dans un petit programme, l’effet peut sembler négligeable. En revanche, dans un service long vivant, une boucle ou un démon, l’accumulation devient progressivement critique.

// leak.c
#include <stdlib.h>
 
void main() {
    void *a = malloc(10);
}
# gcc
$ gcc leak.c -o leak
$ ./leak
$
# clang
$ clang-20 leak.c -o leak
leak.c:4:1: warning: return type of 'main' is not 'int' [-Wmain-return-type]
    4 | void main() {
      | ^
leak.c:4:1: note: change return type to 'int'
    4 | void main() {
      | ^~~~
      | int
1 warning generated.
$ ./leak
$

Même si clang nous affiche un avertissement relatif au type de retour de la fonction main, on constate qu'à l'éxecution, aucune erreur n'apparait.

C’est précisément ce qui rend cette classe de bug intéressante : le programme semble correct, termine normalement, et ne laisse pourtant aucun signal clair sans instrumentation dédiée.

Dépassement de tampon

Le dépassement de tampon (buffer overflow) consiste à lire ou écrire une valeur en dehors de l'espace attribué à un pointeur.

Il s’agit d’une des erreurs mémoire les plus classiques en C. Le comportement observé dépend alors fortement du compilateur, du contexte mémoire, de l’architecture et des optimisations actives.

#include <stdio.h>
#include <stdlib.h>
 
int main(int argc, char** argv) {
  int index = atoi(argv[1]);
  char a[4] = {0x41,0x42,0x43,0x44};
  putchar(a[index]);
  
  return 0;
}

Si on compile et que l'on teste avec des valeurs attendu, tout ce passe bien. Mais si on ne contrôle pas les entrées utilisateur, on va avoir des résultats inattendu.

Le point important est que le code ne protège pas l’index. À partir de là, le programme peut lire une zone voisine de la pile et retourner une valeur arbitraire, sans forcément planter immédiatement.

# gcc
$ gcc stack_buffer_overflow.c -o stack
$ ./stack 0
A
$ ./stack 1
B
$ ./stack 40
Y
$ ./stack 400
�
# clang
$ clang-20 stack_buffer_overflow.c -o stack
$ ./stack 0
A
$ ./stack 1
B
$ ./stack 40
�
$ ./stack 400
F

Le fait que GCC et Clang ne renvoient pas les mêmes caractères pour les indices invalides illustre bien qu’on est déjà hors d’un comportement maîtrisé.

Appel de mémoire non-initialisée

La fonction d'allocation mémoire (malloc) reserve la mémoire me ne l'initialise pas. Essaie de lire une zone mémoire non initialisé en C donne un comportement inattendu.

Ici, le programme lit directement la première case d’un entier alloué mais jamais écrit. La valeur lue dépend donc de l’état précédent de la mémoire et ne doit jamais être considérée comme fiable.

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

int main(){
    int *mem = malloc(sizeof(int));

    printf("%d\n", mem[0]);

    return 0;
}

Essayons directement de compiler avec les optimisations (-O3) pour voir si le résultat change.

# gcc
$ gcc -O3 uninitialised.c -o uninitialised
$ ./uninitialised 
0
$ ./uninitialised 
0
# clang
$ clang-20 -O3 uninitialised.c -o uninitialised
$ ./uninitialised 
905819544
$ ./uninitialised 
386259608

On remarque que GCC semble conserver un comportement consistant, alors que les optimisations clang n'initialise pas la mémoire et lit donc des valeurs aléatoire.

Il ne faut cependant pas interpréter le 0 observé côté GCC comme une garantie du langage. Cela reste une lecture de mémoire non initialisée et donc une situation incorrecte, même si la sortie paraît stable.

Typage, transtypage et cast

Le transtypage consiste à appeler une zone mémoire depuis un pointeur de type différents de celui avec lequel la zone mémoire a été initialisée.

Cette famille d’erreurs touche au modèle de type du langage C. Même si le programme compile, le fait d’accéder à un objet via un type incompatible peut casser les hypothèses faites par le compilateur.

#include <stdio.h>
int main() {
    float f = 1.f;
    int* i = &f;
    printf("%d\n", *i);

    return 0;
}
# gcc
$ gcc transtypage.c -o transtypage
transtypage.c: In function ‘main’:
transtypage.c:4:14: error: initialization of ‘int *’ from incompatible pointer type ‘float *’ [-Wincompatible-pointer-types]
    4 |     int* i = &f;
      |              ^
$ gcc transtypage.c -o transtypage -Wno-incompatible-pointer-types
$ ./transtypage 
1065353216
# clang
$ clang transtypage.c -o transtypage
transtypage.c:4:10: warning: incompatible pointer types initializing 'int *' with an expression of type 'float *' [-Wincompatible-pointer-types]
    4 |     int* i = &f;
      |          ^   ~~
1 warning generated.
$ ./transtypage 
1065353216

Bien que le résultat soit le même pour les deux compilateur, on remarque que gcc considère cet appel comme une erreur et refuse de compilier si on ne l'y oblige pas avec -Wno-incompatible-pointer-types, alors que clang léve juste un avertissement mais compile du premier coup.

Le résultat numérique affiché correspond ici à l’interprétation binaire des bits du float comme un int. Ce n’est pas une conversion de valeur, mais une réinterprétation brute de la mémoire.

Comportement indéfini

Il existe un certain nombre de cas qui ne sont pas explicitement traiter dans le standard et donc qui ne contraint plus le compilateur à un comportement défini. Plus qu'un crash ou une erreur, un comportement indéfini est un état dans lequel la suite du programme devient instable. Cela peut rester silencieux et indétécter durant le fonctionnement du programme puis tout corrompre.

C’est un point central en C : dès qu’un comportement indéfini est atteint, le compilateur n’a plus l’obligation de préserver une sémantique cohérente pour la suite de l’exécution.

#include <stdio.h>

char deref(const char * p) {
    return *p ;
}

int main(){

    const char *str = "Bonjour";

    printf("%c\n", deref("Salut")); 
    printf("%c\n", deref(str));     
    printf("%c\n", deref(NULL));    

    return 1;
}
# gcc
$ gcc example.c -o example
$ ./example
S
B
Erreur de segmentation
# clang
$ clang-20 example.c -o example
$ ./example
S
B
Erreur de segmentation

Le déréférencement d'un pointeur NULL est un comportement indéfini. Ici les deux compilateur décident de crasher le programme avec un segfault.

Le fait que les deux compilateurs aboutissent ici au même symptôme ne signifie pas que ce résultat soit garanti. Il s’agit seulement du comportement observé sur cette plateforme et dans ce contexte précis.

Division
#include <stdio.h>
#include <limits.h>

int zdiv(int a, int b) {
    return a / b;
}

int main(int argc, char **argv){
    printf("%d\n", zdiv(4, 2));
    printf("%d\n", zdiv(2, 4));  
    printf("%d\n", zdiv(INT_MIN, -1));  // <-- =INT_MAX + 1 <-- overflow
    printf("%d\n", zdiv(5, 0));         // <-- division par 0  
}
# gcc
$ gcc division.c -o division
$ ./division 
2
0
Exception en point flottant
# clang
$ clang-20 division.c -o division
$ ./division 
2
0
Exception en point flottant

Comme expliqué, les deux compilateurs choisissent de faire planté le programme un affichant un message d'erreur sur le processeur en point flottant

Ici encore, le crash n’est qu’un symptôme observé. La division par zéro et le cas INT_MIN / -1 sortent du cadre d’un calcul entier signé sûr et placent le programme dans une zone où le résultat n’est plus maîtrisé.