Le langage C pour l’impatient

Un programme écrit en C est constitué d’un ou plusieurs fichiers texte contenant du code source écrit en langage C. Chacun de ces fichiers est compilé séparément vers un fichier binaire contenant des instructions en langage machine illisibles pour nous mais exécutables telles-quelles par un processeur. Tous ces fichiers sont ensuite groupés en un seul fichier exécutable qui constitue ce qu’on appelle le « programme » ou l’ « exécutable ». Pour que le programme final soit exécutable, un des fichiers compilés doit déclarer une fonction nommée « main ». Cet exécutable contient également des métadonnées specifiques au systeme d’exploitation – telles que son nom en multilingue, son icone d’affichage, etc. La tâche d’écrire les fichiers sources revient à nous, celui de les compiler revient à un compilateur; et celui de fabriquer un programme à un éditeur de liens.

Premièrement nous allons écrire quelques lignes de C, puis les compiler, puis les faire exécuter par l’ordinateur.

Sur une machine de type debian/ubuntu le paquet build-essential installe tout le nécessaire pour continuer ce cours. Sur windows il faut installer WSL avec Ubuntu puis également installer build-essential.

Le premier programme que nous allons écrire affichera un simple texte à l’écran : "Bonjour à tous !".

Avec un éditeur de texte écrivez ceci

#include <stdio.h>

main() {
    printf("Bonjour a tous !\n");
}

Sauvegardez votre fichier avec l’extension .c, par exemple bonjour.c.

Depuis la console entrez la commande suivante cc bonjour.c, cela devrait produire un fichier a.out ou a.exe sur windows. Vous devriez voir un avertissement sur un type de retour implicite. Vous pouvez l’ignorer. Le programme cc (C compiler) transforme un fichier code source écrit en C en programme exécutable.

Pour l’exécuter tapez ./a.out : Bonjour à tous ! devrait s’afficher à l’écran.

Fonctions, afficher du texte

La fonction main() automatiquement appellée au démarrage du programme. Une fonction dans le langage C commence optionellement par un type de retour, suivi d’un espace puis le nom de la fonction, une paire de parenthèses contenant éventuellement les arguments de la fonction, séparés par des virgules. Suit une paire d’accolades qui contient les instructions de la fonction. Chaque instruction doit être terminée par un point virgule « ; ». Si la fonction déclare retourner une valeur, la dernière instruction de la fonction doit obligatoirement être une instruction return (ce n’est en fait pas obligatoire mais ne pas le faire constitue un comportement indéfini ).

Notre fonction main contient un appel à une la fonction printf. Cette fonction est écrite par les auteurs de la librairie stdio que nous incluons à la première ligne.

Son rôle est d’afficher la chaine de caractères qu’on lui fournit en paramètre sur l’écran de la console. Le détail du processus qui aboutit à cet affichage est complèxe et hautement variable en fonction du matériel qui sert à un tel affichage. Utiliser une fontion permet d’abstraire cette variablité et cette complexité et nous permet d’arriver à notre fin de manière générique.

Nous écrirons nos propres fonctions dans la suite du cours pour abstraire certaines fonctionalités de nos programmes.

Voyons maintenant un programme plus complèxe

#include <stdio.h>

main() {
    printf("Bonjour a tous ! ");
    printf("Un premier message ");
    printf("Un deuxième message\n");
}

Les instructions d’une fonction, ici main, sont exécutées automatiquement, les unes après les autres du haut vers le bas.

Lorsqu’une fonction, ici main, appelle une autre, ici printf, le fil d’exécution dans la fonction main s’arrête et continue dans la fonction appellée, ici printf et suit un cours qui nous est inconnu mais dont on sait le résultat : afficher du texte sur la console.

Lorsque la fonction printf a fini de s’exécuter, le fil du programme reprend dans la fonction main à l’instruction qui suit l’appel à la fonction. Un programme C est capable se se rappeller l’endroit où il a quitté son fil d’éxécution pour y revenir une fois l’exécution de la fonction appellée terminée. Cette mémoire est limitée toutefois et une imbrication trop profonde d’appels de fonctions peut causer un débordement de mémoire, on parle de stack overflow en anglais. Il existe de nombreuses zones de mémoire de travail liées au fonctionnement d’un programme C, et de nombreuses manières de faire déborder celles-ci, nous en avons abordé une.

Il est possible que les caractères accentués s’affichent différemment sur windows, cette page explique comment régler le probleme.

Controle de l’execution (goto)

Il est possible de peturber le fil du programme autrement que par des appels de fonction, la façon la plus simple est d’utiliser une instruction goto .

#include<stdio.h>

main() {
    printf("Bonjour a tous !");
    goto ici;
    printf("Premier");
    ici:printf("Deuxième");
}

On peut désigner n’importe quelle instruction comme destination de saut en lui attachant une etiquette, en la nommant par exemple ici, la faisant suivre d’un deux-points et de l’instructuon en question.

Une fois qu’une instruction est étiquetée, on peut lui sauter dessus depuis n’importe où dans la fonction avec une instruction goto.

Ce programme n’affichera que la première et la troisième ligne, c’est à dire « Bonjour a tous !Deuxième »

On ne peut pas inverser le sens de lecture d’un programme mais on peut faire des goto en amont et donc répéter en boucle certaines parties du programme, comme on peut esquiver totalement certaines parties du programme avec un goto en aval.

Les goto permettent de faire des sauts dits inconditionels, qui s’executent sans condition. Cela peut etre utile pour desactiver temporairement une ou plusieurs instructions, ou pour avoir un programme qui se répète indéfiniment, l’invite de commandes en est un exemple.

Execution conditionnelle (if, while, …)

Avec l’instruction if (si) est possible d’effectuer des sauts dits conditionnels, qui ne s’effectuent que si le test qui leur est attaché est vérifié. L’instruction if s’écrit de la sorte if (test à effectuer) {instructions à effectuer si test vérifié}.

Le test peut être n’importe quelle expression qui pour être valide doit avoir une valeur différente de zero. Une valeur zero est considérée comme fausse, toute valeur différente de zéro est vraie.

Dans ce programme la première instruction ne s’exécutera jamais, tandis que la seconde s’exécutera toujours.

#include<stdio.h>

main() {

    if(0) {
        printf("Bonjour.\n"); // ne s'exécuteront jamais
        printf("Comment allez-vous.\n");
    } 

    if(42) {
        printf("Au revoir.\n"); // s'exécutera toujours
    }
}

Le if est surtout interessant lorsqu’il doit tester des variants, c’est à dire des paramètres qui peuvent changer à chaque exécution du programme. Ces variants peuvent venir de l’état interne de la machine (son horloge par exemple), d’un capteur ou d’une interface avec un utilisateur, ici un clavier.

Nous avons vu la fonction printf() de la bibliothèque stdio.h qui sert à afficher un texte, la fonction getchar() demande à l’utilisateur d’écrire une lettre sur la console avec son clavier et d’appuyer sur la touche entrée. Cette lettre est ensuite stockée dans la mémoire du programme.

Ce programme demande à l’utilisateur d’entrer une lettre au clavier. Si cette lette est la lettre a, un texte spécial s’affichera.

#include<stdio.h>


main() {
    printf("Entrez une valeur\n> ");

    if(getchar() == 'a') {
        printf("Vous avez entré la valeur 'a'\n");
    }

    printf("Merci d'avoir utilisé notre programme, a bientot\n");
}

Opérateurs arithmétiques

Ce programme teste si l’entrée utilisateur obtenue via getchar() est égale (on note l’égalité avec deux caractères == : notez bien les deux signes) à la valeur 'a'. On utilise des guillemets simples pour représenter des lettres simples et des guillemets doubles pour représenter des chaines de caractères consécutifs. On ne peut pas écrire 'abc', mais on peut écrire "abc", on ne peut pas écrire un caractère vide '' mais on peut écrire une chaine vide "", on ne peut pas utiliser de chaine de caractères dans un test d’égalité à moins d’avoir une bonne notion des pointeurs.

Notez qu’un test d’égalité qui se valide s’évalue arbitrairement à la valeur 1 qui est donc différente de zéro et exécute bien l’instruction qui lui est associée.

Voici les principaux operateurs du C

Symbole nom effet exemple resultat
+ addition somme les deux operandes 1 + 2 3
- soustraction soustrait l’opérande de droite à celui de gauche 1 - 2
1 - 2 - 1
-1 si signé, integer overflow si non signé
-2 (et non 0, les opérations successives sont évaluées de gauche à droite)
* multiplication multiplie des operandes entre eux 3 * 3 9
/ division entière partie entière de la division 9 / 4 2
% modulo reste de la division entière 9 % 4 1
== égalité retourne 1 si les deux operandes sont egaux, 0 sinon 2 == 2 1
!= différence retourne 0 si les deux operandes sont egaux, 1 sinon 2 != 2 0
< strictement inferieur à retourne 1 si le premier operande est strictement inférieur au second 2 < 2 0
<= inferieur ou égal retourne 1 si le premier operande est inférieur ou égal au second 2 <= 2 1
> strictement supérieur à retourne 1 si le premier operande est strictement supérieur au second 2 > 1 1
>= supérieur ou égal retourne 1 si le premier operande est supérieur ou égal au second 1 >= 2 0

Voici deux opérateurs unaires à connaitre, ils ne s’appliquent qu’à un seul opérande.

Symbole nom effet exemple resultat
! négation retourne 0 si l’opérande qui suit est différent de zero, 1 sinon !0
!1
!42
!-1
1
0
0
0
- inversion transforme l’opérande en sa version de signe opposé -(0)
-(1)
-(42)
-(-5)
0
-1
-42
5
++ increment augmente la valeur de un ++5 6
-- decrement diminue la valeur de un --5 4
Exercice

Ecrivez une ébauche de livre dont vous etes le heros. Affichez un texte qui met en place l’histoire et votre lieu (Vous vous touvez actuellement devangt un marécage/chateau/carrefour et trois chemins s’offrent à vous… Voulez vous aller à gauche(g) ou (t)out droit ?), puis en fonction de la saisie de l’utilisateur afficher un texte différent. Après chaque saisie pensez bien à nettoyer le tampon de touches saisies.

Solution :

#include<stdio.h>

main() {
    printf("Vous vous touvez actuellement devant un lieu mystérieux... Voulez vous aller à gauche(g) ou (t)out droit ?\n");

    if(getchar() == 'g') {
        goto gauche;
    }
    
    goto milieu;

    gauche:
    printf("Vous avez pris a gauche");
    goto fin;

    milieu:
    printf("Vous etes alle tout droit");
    goto fin;

    fin:
    printf("Fin de l'histoire.\n");
}

Autres structures conditionelles (else, while)

L’instruction else s’execute si l’instruction if qui la précède est fausse, c’est à dire si elle est évaluée à zero.

#include<stdio.h>

main() {
    printf("Entrez une valeur\n> ");

    if(getchar() == 'a') 
        printf("Vous avez entré 'a'");
    else 
        printf("Merci d'avoir utilisé ce programme, à bientot");
}

Comme exercice exécutez les deux programmes précédents et observez leur comportement en fonction des entrées qui vous leur soumettez, et comment ils diffèrent.

Opérateur ternaire « ?: »

L’opérateur ternaire ?: (il n’en existe qu’un seul) donne une alternative simple aux instructions if/else et s’utilise de la sorte (expression a tester) ? (expression qui s'exécute si test valide) : (expression qui s'execute si test non valide).

Le programme précédent peut être réécrit ainsi

#include<stdio.h>

main() {
    printf("Entrez une valeur\n> ");

    getchar() == 'a' 
        ? printf("Vous avez entré 'a'") 
        : printf("Merci d'avoir utilisé ce programme, à bientot");
}

Attention: seulement des expressions peuvent se trouver comme opérandes de cet opérateur, on ne peut y mettre des instructions comme goto ou if.

#include<stdio.h>

main() {
    printf("Entrez une valeur\n> ");

    getchar() == 'a' 
        ? goto fin; // illegal
        : while(0);; // illegal

    fin:;
}

Un appel de fonction est une expression.

Tampons

Que se passe il si vous entrez plus d’un caractère au clavier avant d’appuyer sur la touche entrée ? Voyons tout de suite.

#include<stdio.h>

main() {
    puts("ecrivez 'abc' puis appuyez sur entrée, evrivez ensuite 'd' et appuyez sur entrée");

    if(getchar() == 'a')
        puts("1");
    if(getchar() == 'b')
        puts("2");
    if(getchar() == 'c')
        puts("3");
    if(getchar() == '\n')
        puts("4");
    if(getchar() == 'd')
        puts("5");
    
    puts("Fin du programme");

}

Tout comme printf() (print formatted), puts() (put string) affiche à l’écran la chaine de caractères qu’on lui fournit en paramètre, mais ajoute également un saut de ligne à la fin.

Le programme précédent illustre l’existence d’un tampon d’entrée (un tampon de sortie existe également) qui garde en mémoire les derniers caractères entrés par l’utilisateur. getchar() ne va mettre en attente d’entrée le programme que si ce tampon est vide. Le caractère de saut de ligne créé par l’appui sur la touche entrée qui valide la saisie se trouve également dans le tampon : il s’agit d’une source de confusion très fréquente pour les programmeurs qui débutent avec la bibliothèque de saisie/affichage du C, il est donc souhaitable que vous intégriez cela au plus tôt.

« While », saut vers le haut

Les inscrtructions if et else permettent donc de “sauter” des instruction, ce saut s’effectue vers le bas du programme. Le while quand à lui permet de sauter vers le haut du programme, pour répéter par exemple en boucle la même instruction tant qu’un variant n’a pas changé.

#include<stdio.h>

main() {
    while(getchar() != 'x')  {
        puts("Je ne ferai plus le pitre en classe");
    }
}

Ce programme est equivalent à écrire,

#include<stdio.h>

main() {
    debut:
    if(getchar() == 'x')  {
        goto fin;
    }
    puts("Je ne ferai plus le pitre en classe");

    goto debut;

    fin:
}

Ce programme affichera le texte tant que vous appuyez sur Entrée, si vous entrez une lettre il affichera le texte deux fois à cause de la touche entrée mise dans le tampon. Si la lettre entrée est « x », le programme sort de la boucle et s’arrête.

Comparée à la version du programme avec des goto, celle avec un while est plus compacte, plus sûre et ne nécessite pas de trouver des noms d’étiquettes.

Mémoire, types, afficher des nombres

A chaque fois que nous modifions notre programme celui ci est recompilé et stocké en mémoire, en incorporant nos changements.

La mémoire du point de vue d’un programme C, est une plage d’octets. Un octets est un multiplet (bytes) de 8 bits. Dans les années 60 et 70 il existait des machines à 6 bits, à 10 bits, et ainsi de suite. Aujourd’hui toutes les architectures que vous allez utiliser sont à 8 bits, et on associe généralement le byte à l’octet – le multiplet de 8 bits.

Un bit ne peut avoir que deux valeurs, zero ou un. Un octet formé de 8 bits a donc deux puissance huit valeurs possibles (deux multipliés par deux huit fois de suite), soit 256 valeurs possibles.

Lorsque le système d’exploitation change un programme en mémoire, il charge d’abord le programme, mais également une zone libre où le programme peut écrire les valeurs qu’il veut. Ces valeurs peuvent changer au cours du programme par l’action de celui-ci ou d’une influence externe (capteur de température, horloge, etc.), on les appelle donc des variables.

Pour utiliser une variable en C il faut d’abord la déclarer, en lui donnant un nom et une taille.

Un nom de variable peut contenir chiffres et lettres ascii, et ne doit pas commencer par un chiffre : 1er n’est pas un nom de variable valide ; premier ou num1 sont des noms valides. Sur les plus anciens compilateurs il faut également veiller à ce que le nombre de lettres de la variable ne dépasse pas 8. Le nom d’une variable est sensible à la casse.

La taille d’une variable est déterminée par son type : elle peut être de type caractère (char) sur un octet, de type entier (int) de un a huit octets, de type nombre à virgule flottante (float, double) de deux à huit octets. Pour les nombres entiers il est possible d’en avoir en version à valeurs négatives (signed) ou non (unsigned), de les faire varier en taille avec les modificateurs short et long. Par defaut les entiers sont signés, et leur taille réelle dépend de la machine.

Il est également possible de faire des tableaux contenants ces types, des stuctures composées de ces types, des pointeurs qui pointent sur ces types ainsi que des fonctions acceptants et retournant ces types : cela sera abordé plus tard.

Une déclaration de variables commence par un type et une liste de variables auxquelles ont peut éventuellement assigner une valeur par défaut.

int toto = 1, tata;
char titi;

A l’usage

#include<stdio.h>

main() {
    char mon_caractere = 'a'; // un octet
    int mon_entier = 62; // 2 ou 4 octets selon machine 16 ou 32 bits 
    short int mon_entier_court = 63; // explicitement deux octets
    long int mon_entier_long = 64; // explicitement 4 octets (8 sur linux)
    long long int mon_entier_etendu = 65; // explicitement 8 octets
    unsigned long long int mon_entier_etendu_non_signe = 65; // explicitement 8 octets, non signés

    printf("Valeur contenue dans la mémoire : ");
    putchar(mon_caractere);
    putchar(mon_entier);
    putchar(mon_entier_court);
    putchar(mon_entier_long);
    putchar(mon_entier_etendu);
    putchar(mon_entier_etendu_non_signe);
}

Un int de 4 octets aura 2 puissance huit fois quatre, soit deux puissance trente-deux, soit 4 294 967 296 valeurs possibles.

Un entier peut etre signé ou non. Un entier non signé ne peut avoir que des valeurs comprise entre 0 et 4 294 967 296. Un entier signé pourra avoir des valeurs négatives et positives, comprises entre -2 147 483 648 et 2 147 483 647. Par défault tous les entiers sont signés, pour les rendre strictement positifs, il faut ajouter unsigned à coté du type de la variable.

A la première ligne de notre fonction nous avons “réservé” une zone de la mémoire suffisamment grande pour contenir une valeur de type char et nous l’avons nommée mon_etiquette. Avec l’opérateur d’assignation qui se note = nous lui avons assigné la veleur 'a', qui est traduite en son équivalent ASCII soit 97. putchar() affiche la lettre qu’on lui donne en paramètre sur la sortie console.

Vous pouvez donner à mon_etiquette plusieurs valeurs et voyez ce qu’il se produit. Essayez également des nombres comme 64 ou 118. Tapez ascii dans votre console pour vous aider à faire correspondre les caractères à leur valeur numérique.

Il existe deux types entiers char et int, et plusieurs modificateurs pour en faire varier la taille.

nom taile en octets taille en bits extrema signés extrema non signés
char 1 8 -128 / 127 0 / 255
short int 2 16 -32 768 / 32 767 0 / 65 536
long int 4 32 -2 147 483 648 / 2 147 483 647 0 / 4 294 967 296
long long int 8 64 -9 223 372 036 854 775 808 / 9 223 372 036 854 775 807 0 / 18 446 744 073 709 551 616

Le C propose également des types de nombres à virgule, en simple, double et quadruple précision.

Ils se notent float, double et long double. Le séparateur décimal en C est le point.

La fonction putchar peut afficher des caractères, pour afficher des nombres sur la console il faut utiliser la fonctionnalité de formattage de printf.

Si on veut afficher un nombre, à virgule ou non sur la console, il faut utiliser le caractère % et un caractère de formattage correspondant au type de valeur voulu.

formattage type
%c char
%s *char (string)
%d int (base decimale)
%o int (base octale)
%x int (base hexadecimale)
%i int (générique)
%hd short int
%u unsigned int
%ld long int
%lld long long int
%llu long long unsgined
%f float
%lf double
%llf long double

Les nombres à virgule ont une représentation mémoire hermétique, si bien que pour les afficher de manière compréhensible, il faut utiliser l’outil printf.

L’exemple précédent peut être réécrit ainsi, et on lui ajoute aussi quelques nombres flottants :

#include<stdio.h>

main() {
    char car = 'a';
    int ent = 62;
    short entc = 63; /// short rend le int implicite, on peut aussi ecrire "short int"
    long entl = 64; ///on peut aussi ecrire "long int"
    long long entll = 65;
    unsigned long long entull = 65; /// unsigned rend le int implicite

    float flot = 10.5f; // le f maraque un litteral simple
    double dou = 21.0; // par defaut les litteraux décimaux sont de typa double précision
    long double doul = 42.; // le marqueur decimal suffit

    printf(
        "Valeur contenue dans la mémoire : %c %d %hd %ld %lld %llu %f %lf %llf \n", 
        car,
        ent,
        entc,
        entl,
        entll,
        entull,
        flot,
        dou,
        doul
    );
}

%c affiche un char, %d affiche un int décimal, %hd affiche un short int, %ld affiche une long int, %lld affiche un long long int, %llu affiche un unsigned long long (si unsigned est present dans le type, le int est implicite).

%f affiche un float, %lf affiche un double, %llf affiche un long double.

Adresse des variabes

Pour connaitre l’adresse d’une variable, on peut utiliser l’operateur d’adressage &.

L’opérateur d’adresse renvoie un nombre non signé qui peut aller de zero jusqu’à la limite de la mémoire vive disponible sur la machine. Pour simplifier si vous avez deux gigaoctets de mémoire vive, les valeurs que peuvent avoir un pointeur iront de 0 à 2 147 483 648. Cette adresse est également appellée un pointeur et on peut la stocker dans une variable qu’on appelle également un pointeur. On peut éagelemt stocker un pointeur dans une variable de type “entier” mais il faut que la variable soit suffisament grande pour éviter la troncature.

int main() {
    int toto = 42;
    int tata = 12;
    char titi = 'a';
    int * p = &toto; /// le type int a gauche de * signifie 
    /// que la variable pointée est un int, pas que la variable elle même 
    /// est un int (même si c'est le cas)
    p = &tata; /// on repoint p sur une autre variable
    // p = &titi; /// interdit : un pointeur ne peut pointer que vers un type identique
}

Voici un programme qui affiche l’adresse de deux pointeurs.

#include<stdio.h>

main() {
    char car = 'a';
    int ent = 62;

    printf(
            "Valeur contenue dans la memoire : \ncaractere a : %c %u\nentier b : %d %u\n",
            car,
            &car,
            ent,
            &ent
    );
}

Réexécutez ce programme plusieurs fois et il est probable que l’adresse des deux variables change à chaque fois.

C’est le système d’exploitation qui choisit pour nous la location de notre mémoire de travail, et par extension l ‘adresse de nos variables.

Cette adresse est un valeur absolue située sur l’entièreté de la mémoire accessible à l’ordinateur : il est tout à fait possible avec un programme C de lire l’entièreté du contenu situé sur la mémoire vive, c’est à dire les variables et le code exécutable de tous les programmes en cours d’exécution ou qui se sont exécutés dans le passé – tant que la mémoire qui leur a été alouée n’a pas été réecrite par d’autres programmes. Bien entendu cela pose de graves soucis de sécurité et le système d’exploitation veille à ce que cela ne se produise pas.

Si vous êtes curieux vous pouvez également connaître l’endroit où le code du programme se trouve.

#include<stdio.h>

main() {
    char car = 'a';
    int ent_a = 62;
    int ent_b = 63;

    printf(
            "Valeur contenue dans la memoire "
            ": \ncaractere a : %c - %u\nentier "
            "b : %d - %u\n\nentier c : %d - %d\n",
            car,
            &car,
            ent_a,
            &ent_a,
            ent_b,
            &ent_b
    );

    printf("Adresse de la fonction main : %u\n", main);
}

// affiche
// a : a - 12 515 803
// b : 62 - 12 515 792
// c : 63 - 12 515 796
// main : 4 526 336

On voit que le système d’exploitation a placé le premier caractère en adresse 12 millions et 803 ; il a ensuite placé le premier entier en 792 puis le deuxieme en 796.

Sachant qu’un entier fait quatre octets on peut voir que nos deux variables sont côte-à-côte, on dit aussi qu’elles sont empilées les unes sur les autres : dans ce cas il faut voir la mémoire comme une tour verticale où chaque étage est une adresse potentiellement disponible et le zéro se trouve au rez de chaussée. On appelle cette mémoire de travail la pile (stack en anglais).

On peut également voir qu’elles sont empillées vers le bas, c’est à dire vers la direction où se trouve le code exécutable du programme et qu’il y a une distance d’environ 5 à 6 mega octets (millions d’octets ou 5Mo) entre la mémoire de travail et le code exécutable d’un programme.

Cette distance est arbitrairement choisie par le compilateur et il est possible de la modifier mais on recommande généralement de ne pas mettre dans la pile des données trop volumineuses, commeune image de plusieurs gigaoctets : il existe d’autres endroits en mémoire où on pourra la stocker. Déclarer sur la pile un tableau de plusieurs millions d’octets est un moyen de faire déborder ce tampon : int grand[10000000000] = { 0 }; // plante à l'exécution.

Déborder cette mémoire de travail constitue un stack overflow et il s’agit d’une technique souvent utilisée par les personnes malicieuses pour modifier à l’exécution le code source des programmes et leur faire exécuter du code arbitraire et malicieux.

Capturer la saisie de l’utilisateur

La fonction scanf() permet à l’utilisateur d’entrer des valeurs au clavier de manière formatée, elle est plus puissante que getchar() qui ne permet la saisie de caractères que un par un. Tout comme printf, scanf prend comme argument une chaine de formattage, puis les adresses des variables où les entrées clavier seront stockées en fonction du format souhaité.

#include<stdio.h>

main() {
    int entier_a;
    char caractere_b;
    
    puts(
        "ecrivez \"toto\" suivi d'un espace, suivi d'un nombre sans virgule, "
        "suivi d'un espace, suivi d'un seul caractere."
    );

    scanf("toto %d %c", &entier_a, &caractere_b);

    printf("entier a : %d\ncaractere b : %c\n", entier_a, caractere_b);
}

En exécutant ce programme, si vous écrivez toto 123 b, il s’affichera

entier a : 123 
caractere b : b

Si vous n’ecrivez pas exactement dans le format attendu il s’affichera des valeurs qui n’ont pas de sens. Voyez le chapitre consacré à scanf si vous voulez contraindre les valeurs saisies et sécuriser celles-ci.

Do/while

Une manière de solidifier le programme est de le faire boucler tant que l’utilisateur n’a pas fourni une entrée qui correspond au format attendu “toto nombre caractere”.

Pour cela on utilisera une variante de la boucle while qui est do/while. Le while teste d’abord la condition de la boucle avant de l’exécuter, le do/while exécute d’abord la boucle, puis teste la condition.

main() {
    int entier_a;
    char caractere_b;
    int nb_reconnus;

    do {
        puts("ecrivez votre entree");

        nb_reconnus = scanf("toto %d %c", &entier_a, &caractere_b);
        while(getchar() != '\n') {} /* On nettoye le tampon d'entrée */

        printf("Le programme a stocke %d variable\n", nb_reconnus);

        if(nb_reconnus != 2) 
            continue;
        else 
            break;

    } while(1);

    printf("entier a : %d\ncaractere b : %c\n", entier_a, caractere_b);
}

Ici la condition est 1, donc elle est toujours vraie et la boucle s’exécuterait à l’infini si il n’y avait pas d’instruction break pour en sortir. Le break fait sauter le programme en bas de la boucle où il se situe. Le continue fait recommencer la boucle.

Si scanf n’a pas réussi à stocher 2 variables on continue la boucle, sinon on sort de la boucle avec break;

Si scanf n’a pas réussi à faire correspondre le tampon d’entrée à ce qu’il attend, il arrête de lire le tampon d’entrée à partir du moment où la correspondance s’arrête.

Les caractères qu’il n’a pas réussi à lire restent dans le tampon et si on ne le nettoye pas, le scanf essayerait de le lire à l’infini et échouerait en boucle. while(getchar() != '\n'); lit chaque caractère du tampon jusqu’à trouver une nouvelle ligne qui correspond à l’appui de la touche Entree. Ce dernier caractère est également consommé.

L’instruction qui est attachée à ce while est un bloc vide qui n’effectue rien, le simple fait d’appeller getchar() en boucle suffit à nettoyer le tampon.

Pointeurs stockés

Jusqu’ici nous avons immédiatement consommé les pointeurs que nous fabriquons avec &, il est possible de les stocker dans une variable en suivant une syntaxe propre à celles-ci. Par métonymie on appelle pointeur une variable contenant un pointeur. La syntaxe de la déclaration d’une telle variable diffère par l’ajout d’un astérisque entre le type et le nom de la variable.

#include<stdio.h>

main() {
    int a = 42;
    int b = 12;

    int * pointeur = &a;

    printf("Le pointeur pointe sur la variable a dont la valeur est %d", *pointeur);

}

Attention ici l’étoile ne signifie pas la multiplication mais le déréférencement. La différence est que l’opérateur de déréférencement est unaire, il faut faire attention si vous multipliez deux pointeur à les mettre entre parenthèses : (*toto) * (*tata).

Déréférencer un pointeur signifie accéder à la valeur sur laquelle il pointe. Sans l’étoile on se contente de référencer celle-ci et si on l’affichait on n’y verrait qu’une adresse.

On peut modifier à la fois la valeur pointée et le pointeur en lui même :

#include<stdio.h>

main() {
    char a = 'm';
    char b = 'n';

    char * pointeur = &a; 
    // on déclare la variable 'pointeur' 
    // de type 'pointeur sur char' et 
    // on lui donne comme valeur l'adrese de 'a'

    printf("Le pointeur pointe sur la variable a dont la valeur est %c\n", *pointeur);
    
    *pointeur = 'o'; 
    // on dereference le pointeur
    // (on référence la variable) 
    // et on y met la valeur 'o'
    
    printf("La variable pointée vaut maintenant %c\n", *pointeur);

    pointeur = &b; 
    // on reference le pointeur et 
    // on y met l'adresse de b
    
    printf("Le pointeur pointe sur la variable b dont la valeur est %c\n", *pointeur);

    *pointeur = 'p';

    printf("La variable b vaut maintenant %c\n", *pointeur);
}

Il s’affiche

Le pointeur pointe sur la variable a dont la valeur est m
La variable point├®e vaut maintenant o                   
Le pointeur pointe sur la variable b dont la valeur est n
La variable b vaut maintenant p                          

Un pointeur peut pointer en réalité sur n’importe quelle adresse, par exemple char * pointeur = 0 ou encore char * pointeur = 42 Avec les pointeurs on peut donc écrire à n’importe quel endroit de la mémoire ce qui les rend puissants mais dangereux.

Les adresses mémoire ont une représentation opaque mais le C demande à ce qu’ils puissent être convertis en nombres entiers strictement positifs et vice-versa. Chaque adresse doit avoir une valeur entière unique. Lorsqu’un pointeur est sous une forme entière on ne peut pas les déréférencer :

#include<stdio.h>

main() {
    char a = 'm';
    char b = 'n';
    char * pointeur = &a;
    // on convertit le pointeur en entier
    long long int memoire = pointeur; 

    printf(
        "Le pointeur pointe sur %d , "
        "la valeur qui s'y trouve "
        "est '%c'\n", pointeur, *pointeur);

    memoire = &b; 
    // on stocke l'adresse dans un entier
    pointeur = memoire; 
    // on convertit l'entier vers un pointeur

    printf(
        "Le pointeur pointe sur %d , "
        "la valeur qui s'y trouve est"
        " '%c'\n", pointeur, *pointeur);

}

affiche

Le pointeur pointe sur 16119703 , la valeur qui s'y trouve est 'm'
Le pointeur pointe sur 16119702 , la valeur qui s'y trouve est 'n'

🧨 On peut theoriquement mettre n’importe quelle valeur dans un pointeur et cela permet au programme de lire et modifier n’importe quelle partie de la mémoire de l’ordinateur, y compris en dehors de notre programme, il faut donc faire attention à ce qu’on met dans nos pointeurs, et se limiter soit à la valeur zero, soit à l’adresse d’une variable qu’on a créé nous même.

Tableaux

Lorsque nous créons des variables sur la pile, elles sont placées dans un ordre qui peut varier en fonction de l’architecture, des options de compilation et ainsi de suite.

Le C permet d’empiler des variables les unes après les autres, de manière prédictive, sans surprise quand à leur location. Un tel groupement de variables contiguës est appellé un tableau, on le note ainsi int tableau[10]; // créé dix variables contiguës assez grandes pour contenir un entier, non initialisées

et on l’initialise ainsi int tableau[10] = {1, 2, 3, 4, 5, 42, 10, 0, 1, 2}; // notez les accolades

ou encore ainsi int tableau[10] = { 0 }; // toutes les valeurs sont mises à zero,

ou encore int tableau[10] = { 3, 3, 3 }; // les trois premières valeurs sont mises à 3, les sept valeurs suivantes sont mises à zero

ou enfin depuis le C99 int tableau[10] = { 3, 3, 3, [5] = 3 }; // les trois premières valeurs et la sixième sont mises à 3, les six autres valeurs sont mises à zero

Voila un exemple qui place les dix premiers carrés dans un tableau et les affiche au moyen d’un pointeur.

#include<stdio.h>

main() {
    int tableau[10] = {0};
    int * p1 = &tableau; 
    
    int i = 1;
    while(i < 11) {
        *p1 = i * i; // dereference le pointeur et y met un carré
        p1 = p1 + 1; // avancer le pointeur
        i = i + 1;  // augmente l'arrete du carré
    }

    /* Affiche le résultat du travail */
    i = 0;
    p1 = &tableau;

    while(i < 10) {
        printf(
            "%d et %d font %d\n", 
            i + 1, i + 1, *p1
        );
        i++; p1++;
    }

}

On créé d’abord un tableau de dix cases et on fait pointer un pointeur dessus. La valeur d’un tableau est égale à l’adresse de son premier élément, donc p pointe sur le premier élément du tableau. Ecrire int* p1 = tableau, int* p1 = &tableau et int* p1 = &tableau[0] aboutit au même résultat.

Avec *p1 = i * i on ecrit le carré de i dans la variable pointée, c’est à dire la premiere case du tableau.

On augmente la valeur de p1 de un, ce qui en arithmétique des pointeurs correspond à 4, qui est le nombre d’octets contenus dans une case d’un tableau de int, p1 pointe sur la case suivante du tableau. On augmente également la valeur de i de un.

On sort de la boucle lorsque toutes les cases du tableau sont remplies. On affiche le contenu de ce tableau. Ici les instructions i++, i = i + 1 et i += 1 aboutissent au même résultat.

Arithmétique de pointeurs

Lorsqu’un pointeur pointe sur un tableau, on peut additionner et soustraire des nombres à ce pointeur, ce qui aura pour effet de le déplacer d’autant de cases mémoire que son type le nécessite.

On peut également soustraire deux pointeurs entre eux. Pour connaitre la taille d’un tableau on peut soustraire un pointeur qui se situe au début d’un tableau à un pointeur situé à une case après la fin de celui-ci. C’est à dire que pour un tableau de 10 entiers de 4 octets chacun, soit 40 octets, si int* p2 = &tableau + 11; et int* p1 = &tableau + 0; , p2 moins p1 vaudra 10, soit p2 - p1 == 10. Bien que le tableau fasse 40 octets, on n’obtient pas 40 mais bien 10, soit le nombre de cases du tableau et non son nombre d’octets.

On ne peut pas additionner deux pointeurs. Plus généralement l’arithmétique des pointeurs se limite à la soustraction entre deux pointeurs et à l’addition et à la soustraction d’un pointeur à une valeur entière.

La notation entre crochets simplifie l’opération d’addition et soustraction avec un entier.

#include<stdio.h>

main() {
    int tab[3] = {1, 2, 3};
    printf("%d\n", *(tab + 2)); // 3
    printf("%d\n", tab[2]); // 3
}

La notation tab[0] référence la première case du tableau, tab[1] la seconde, ainsi de suite. La valeur entre les crochets réfère à la distance du début du tableau de la case qu’on souhaite afficher. On peut y mettre n’importe quelle expression entière : tab[toto() + 10]. *(tab + 3) est équivalent à écrire tab[3]. tab[0] est équivalent à écrire *(tab + 0) qui peut se simplifier en *tab. tab[3][2] est équivalent à écrire *(*(tab + 3) + 2). La notation en crochets est beaucoup plus lisible et devrait toujours être privilégiée dans le cadre de l’arithmétique de pointeurs à la notation en pointeur nu.

#include<stdio.h>

main() {
    int tab[] = {1, 2, 3};

    int i = 0;
    while(i++ < 3) {
        printf(
            "valeur numero %d : %d\n", 
            i , tab[i]);
    }
}

int tab[] = {1, 2, 3}; crée un tableau de 3 cases. Spécifier la taille d’un tableau lors de sa déclaration n’est pas nécessaire si on fournit un initialisateur exhausif. tab[i] affiche la case aux valeurs successives de i (0, 1 puis 2).

Itérateur « for »

Pour travailler avec plusieurs cases successives d’un tableau, on privilégie l’instruction for au lieu de while. Le code précédent peut être écrit ainsi :

#include<stdio.h>

main() {
    int tab[] = {1, 2, 3};

    for(int i = 0 ; i < sizeof tab / sizeof tab[0] ; i++) {
        printf(
            "valeur numero %d : %d\n", 
            i , tab[i]);
    }
}

L’opérateur sizeof donne la taille en octets de son opérande. La taille d’un tableau est sa taille totale en octets (et non sa taille en cases) qui vaut ici 12 (quatre octets fois trois). En divisant cette valeur par la taille occupée par une case on obtient la taille du tableau. Cette valeur est obtenue en référencant une case du tableau, soit un entier de 4 octets. 12 divisés par 4 font 3, qui est la taille du tableau.

L’orsqu’on travaille avec des tableaux, on privilégie l’instruction for au while : elle est composée ainsi

for(clause initiale; clause de test ; clause d'itération).

La clause initiale sert à créer la variable utilisée pour le parcours du tableau ; la clause de test est exécutée avant chaque itération pour déterminer si l’itération doit s’exécuter ou non. La clause d’itération s’exécute à la fin de chaque itération, et on l’utilise généralement pour incrémenter la variable de boucle.

for(int i = 0 ; i < sizeof tab / sizeof *tab ; i++) peut se lire : « De i allant de zero à la taille du tableau moins un, exécuter la boucle et augmenter i de 1 ».

Chaines de caractères

Les chaines de caractères que nous avons vu sont des tableaux d’une forme particulière. Ecrire "toto" est équivalent à écrire un tableau de 5 caractères de la sorte char mot[] = {'t', 'o', 't', 'o', 0};. Les chaines de caractères en C sont terminés par un caractère de valeur zero (on parle également de caractère nul).

Un pointeur sur une chaine de caractères et un tableau initialisé par une chaine de caractères ne sont pas équivalents.

#include<stdio.h>

main() {
    char * toto = "toto";
    char tata[] = "toto";
    char * titi = "toto";

    toto[1] = 'i'; // interdit
    tata[1] = 'i'; // autorisé
    toto == titi; // probablement vrai
    toto == tata; // surement faux
}

Dans un executable windows ( coff 🌍⤴ , pe 🌍⤴ , …) ou linux ( elf 🌍⤴ ), toto pointe sur une zone non modifiable de la mémoire qui contient toutes les chaines de caractères du programme. Tenter de modifier cette zone causera une erreur et un arrêt du programme. tata est une variable créée sur la pile de 5 caractères initialisés avec les lettres t o t o et un caractère nul. titi pointera au même endroit de la mémoire statique que toto : deux chaines de caractères identiques dans le code source par souci d’économie pointeront au même endroit. tata quand a lui pointe sur la pile et non la mémoire statique, sa valeur, évaluée comme telle en tant que pointeur sera différente de toto et titi.

Structures

La structure permet comme le tableau de grouper des valeurs ensembles, mais là où dans un tableau tous les éléments ont le même type, une structure peut contenir des éléments de types différents, et leur agencement en mémoire est moins strict qua dans un tableau.

Une structute doit être déclarée avant de pouvoir être utilisée

#include<stdio.h>

main() {
  struct toto {
      int a;
      char b[2];
      float c;
  }

  struct toto objet = {
      42, {'a', 'b'}, 1.5
  };

  printf(
    "Objet 1 : %d %c, %f\n",
    objet.a, objet.b[0], objet.c
  ); // 42, a, 1.50000
}

Chaque champ d’une structure doit avoir un nom et un type.

On utilise le point . pour accéder aux different champs de la structure.

Fonctions

Il est possible en C d’écrire des fonctions pour encapsuler une partie de la logique du programme, c’est à dire avoir un bloc d’instructions qu’on peut exécuter à loisir à n’importe quel lieu.

Pour créer une fonction on doit éventuellement renseigner son type de retour (par défaut il s’agit de int), son nom et ses éventuels paramètres qu’elle accepte entre parenthèses, séparés par des virgules. A noter qu’omettre le type de retour d’une fonction est considéré comme une fonctionnalité obsolète qui risque d’être enlevée des versions futures du langage.

int function1() {return 42;} // retourn int
function2() {return 42;} // identique a ci-dessus

void fonction_parametree(int toto) {  // routine ou procédure
    printf("%d", toto);

    return; // optionel
}

int fois_deux(int toto) {  // f(x) = x * 2
    return toto * 2; 
}

Une fonction qui retourne void signifie que la fonction ne retourne rien : elle s’exécute uniquement pour son effet de bord (ici afficher un texte à l’écran). Une fonction qui retourne void est parfois appellée routine ou procédure.

Le mot clé return sert à retourner une valeur depuis une fonction, il est optionnel pour une procédure.

#include <stdio.h>

int toto(int val) {
    if (val == 42) {
        return 24;
    }

    return val * 2;
}

int main() {
    printf("%d\n", toto(10)); // 20
    printf("%d\n", toto(15)); // 30
    printf("%d\n", toto(42)); // 24

    for (int i = 40; i < 45 ; i++) {
        printf("%d\n", toto(i)); 
        // 80, 82, 24, 86, 88
    }
}

Une fonction doit être déclarée avant de pouvoir être appellée :

void toto(void) {
    tata(); // impossible, tata n'est pas encore connu
}

void tata(void) {}

La bonne manière est de définir la fonction avant

void tata(void) {}

void toto(void) {
    tata(); // ok
}

Pour qu’une fonction soit connue du compilateur, elle doit être entièrement définie. C’est à dire que le compilateur ne connait une fonction que lorsqu’il arrive à son accolade fermante. Pour permettre à une fonction d’être récursive (qu’elle puisse s’appeller elle-mêmre), le C autorise de séparer déclaration et définition d’une même fonction.

Une déclaration de fonction est identique à sa définition hormis qu’on remplace son corps par un point-virgule :

#include<stdio.h>

void tata(void);  // déclaration
int titi(int val);  // déclaration

void toto(void) {  // déclaration et définition
    tata(); // ok
    printf("%d", titi(21)); // 42

}

void tata(void) {  // définition
    puts("tata");
}

int titi(int val) {  // définition
    return val * 2;
}

// indispensable pour une fonction récursive
int factorielle(int val);

int factorielle(int val) {
    if(val == 0 || val == 1) {
        return val;
    }

    return val + factorielle(val - 1);
}

int main(void) {
    printf("%d\n", factorielle(3)); // 6
    printf("%d", factorielle(5)); // 15
}

Lorsqu’une fonction est appellée, ses parametres sont copiées dans une nouvelle zone de la mémoire : la fonction travaille avec des copies des valeurs qu’on lui passe et il est impossible a une fonction de modifier des valeurs externes à elle-même

#include<stdio.h>

void fun(char val) {
    val = 'o';  // val est une copie interne a la fonction
}

int main(void) {
    char val = 't';
    fun(val);
    putchar(val); // 't', fun() 
}

Pour qu’une fonction modifie une valeur qui ne lui appartient pas, on peut lui passer un pointeur sur cette valeur

#include<stdio.h>

void fun(char *val) {
    *val = 'o';
}

int main(void) {

    char val = 't';

    fun(&val);

    putchar(val); // o
}

Hormis des valeurs simples et des pointeurs, il est possible de passer en argument des tableaux, des structures, des pointeurs sur celles-ci, ainsi que d’autres fonctions. Ces points sont abordés plus en détail dans le chapitre dédié aux fonctions.

Allocation dynamique

Jusqu’ici nous avons alloué notre mémoire sur la pile . Nous avons vu qu’elle fait environ 5Mo, et que dans celle-ci les valeurs sont placées les unes après les autres. Si on y déclarait un tableau, puis une autre valeur, puis qu’on voulait agrandir le tableau, celui-ci écraserait la valeur suivante dans la pile.

L’allocation dynamique permet d’alouer une quantité de mémoire qui peut varier à chaque exécution du programme.

Pour ce faire il faut inclure la librairie stdlib.h et utiliser la fonction malloc(). Cette fonction prend en entrée le nombre d’octets qu’on souhaite allouer et retourne un pointeur de type void sur la mémoire nouvellement allouée. Cette mémoire n’est pas initialisée. On peut utiliser memset() à cette fin.

Si on veut alouer un tableau de 4 entiers on multiplie la taille d’un entier – obtenue au moyen de sizeof – par 4.

#include<stdlib.h>

int main() {
    int * valeurs = malloc(4 * sizeof (int) );
    valeurs[0] = 10;
    valeurs[1] = 12;
    valeurs[2] = 14;
    valeurs[3] = 16;

    free(valeurs);
}

Il faut avec free() libérer manuellement la mémoire que nous avons alloué, sans quoi notre programme pourrait finir par occuper toute la mémoire de l’ordinateur jusqu’à ce que le système d’exploitation force l’arrêt du programme ou que le système devienne inutilisable. A tout appel de malloc() doit correspondre un appel à free(), il faut par conséquent conserver la valeur de retour de malloc() dans un pointeur jusqu’à ce qu’on ait appellé free() dessus. Un pointeur qui a été libéré ne doit plus être utilsé après cela, sauf si on le fait repointer sur une autre valeur « active ».

manipulation chaines manipulation fichiers