C Propre

Le but de ce document est, à partir de connaissances de base en C, d'expliquer comment faire un projet "propre" c'est à dire organisé clairement et dont le code est lisible.

Ce petit tutoriel est divisé en 3 parties:

  1. la première partie concerne la Compilation. Il est nécessaire pour faire du code propre de savoir ce qui va lui être fait au moment de la Compilation. Nous expliquerons donc les différentes étapes de la Compilation, et leur rôle.
  2. La deuxième partie s'occupe de l'Organisation du Projet: tout mettre dans un seul gros fichier nuit à la lisibilité et ralentit la Compilation.
  3. La troisième partie C Avancé traite de petits "Tips and tricks" en C, pour une utilisation encore plus claire, et plus optimisée.

Commentaires de Duke : il pourrait être intéressant de rajouter un paragraphe/chapitre
sur les options de gcc utiles comme de -Wall. Et eventuellement un petit rappele
des options de base que sont -o et -l -L -I etc.

Compilation

La compilation est une succession d'étapes qui transforment votre code en C en un fichier exécutable par la machine. Voici ces différentes étapes:

le Preprocessing

Le préprocesseur est un élément du compilateur qui va transformer le code en fonction de directives qui lui sont propres afin de préparer le code à être compilé.

En C, les directives du préprocesseur commencent toutes par #. Les plus connues sont #include et #define, mais dans la partie [[C Avancé]] on verra qu'il existe aussi #ifdef et #ifndef, #else, #endif, et bien d'autres.
C'est notament au niveau du préprocesseur que les fichiers .h sont inclus avec les fichiers C.

Une fois que le fichier a été préparer, le compilateur analyse le code pour passer à la génération du code assembleur correspondant.

La compilation

La compilation transforme le code C en assembleur propre au processeur: à partir de cet instant les fichiers compilés deviennent donc spécifiques à la machine.
Le code assembleur est déduit du code C et c'est à ce moment la qu'intervienne les optimisations éventuelles: déroulage de boucles, rotation de registre, etc.

La génération des fichiers objets - Assemblage

Une fois que la compilation est finie, il faut transformer ce code assembleur en code objet (code machine) utilisable par le microprocesseur. Le fichier devient alors complètement illisible. Il y a un fichier objet par fichier source en C. Ces fichiers portent l'extension .o.

Attention, c'est du code objet, mais pas du code exécutable! En effet, il ne doit y avoir qu'un seul exécutable, mais il peut y avoir plein de fichiers objets. La création de ce fichier "binaire" exécutable est dédiée au linker.

La liaison des objets pour la création du binaire

Lorsqu'un projet est divisé en plusieurs fichiers sources, il y a plusieurs fichiers objets: le but de cette étape est de lier les objets entre eux, ainsi que les bibliothèques (''librairies'' en anglais) statiques et dynamiques, afin de faire un fichier exécutable.

Le fichier créé par le linker est un fichier exécutable (binaire).

Intérêt de comprendre ces étapes

Mais pourquoi doit-on connaitre ces étapes? tout simplement parce que dans un projet dont le résultat doit être propre, il faut maitriser chacune des étapes: le bon programmeur:
* Ecrit des directives de preprocessing intelligente pour limiter le travail du compilateur
* Vérifie son code assembleur pour l'optimiser encore plus si besoin est (notamment en systèmes embarqués)
* Débuggue son programme en se servant d'un debugger qui agit sur les fichiers objets
* Fait de la programmation multifichier afin d'avoir des morceaux séparés, remplaçables, et si possibles liés dynamiquement afin de limiter la taille du binaire

Commentaire de Duke
Eventuellement ajouter le fait que l'on peut aussi créer des librairies.

Comprendre les erreurs de compilation

Comme la compilation se déroule en plusieurs phases, des erreurs peuvent intervenir dans chacune des phases. Pour les débugger correctement, il est important de savoir les identifier. En effet, ce n'est pas la peine de chercher dans les directives de preprocessing des erreurs de liens. Nous allons voir quelques erreurs classiques, apprendre à les identifier, et surtout à les corriger.

Les erreurs de préprocesseur

Les erreurs de préprocesseur arrivent vite: avant même que le code soit transformé en assembleur. En voici un exemple de programme qui va échouer au moment du preprocessing, avant meme la compilation (l'erreur du printf n'est même pas signalée):

 #include <stdio.h>
 #ifdef WIN32
 #include <conio.h>
 int main()
 {       
         printf(5);
 
         return 0;
 }

On voit que le #ifdef n'a pas été fermé par un #endif. Voici l'erreur correspondante:

f1.c:2:1: unterminated #ifdef

Les erreurs du préprocesseur sont donc très claires et faciles à corriger.

Les erreurs de code C

La première erreur est la plus fréquente:

f1.c: In function `main':
f1.c:6: error: syntax error before "return"

Cette erreur est provoquée à la compilation, par une erreur de syntaxe dans le code: un point virgule ou une parenthèse oubliée. Mais attention, l'erreur n'est pas à l'endroit indiqué, mais avant! Voici le code qui a provoquée l'erreur:

 int main()
 {
     printf("Hello World ! \n")
     return 0;
 }

On voit qu'il manque un ";" après le printf. On aurait l'erreur suivante si par exemple nous oubliions la ")" et pas le ";"

f1.c: In function `main':
f1.c:5: error: syntax error before ';' token

Une autre erreur classique est de se tromper de type lors d'un passage de paramètre: voici l'erreur correspondante:

f1.c: In function `main':
f1.c:5: warning: passing arg 1 of `printf' makes pointer from integer without a cast

provoquée par exemple par un
printf(4);

4 est un entier, et printf attend un pointeur. L'erreur inverse peut aussi se produire, lorsqu'on passe un pointeur à la place d'un entier. Il faut donc, dans une erreur de ce type, faire attention aux types signalés par le message d'erreur.

Pour la correction de cette erreur, il faut revérifier les prototypes des fonctions et les types des arguments passés.

Les erreurs de liens

Voila l'erreur de lien la plus courante:

/tmp/cconpIvx.o(.text+0x11): In function `main':
: undefined reference to `print_toto'
collect2: ld returned 1 exit status

Et le code correspondant:

 int main (){
         print_toto();
         return 0;
 }

La raison est que la fonction print_toto() est définie ailleurs (ou pas), mais déclarée nulle part dans le fichier de test. Au moment du lien, le linker ne trouve pas la définition du symbole, donc il n'est pas content.
En réalité, cette erreur est précédée d'un warning, souvent masqué (à moins d'utiliser l'option -Wall du compilateur), qui indique que la fonction est déclarée implicitement:

f1.c: In function `main':
f1.c:2: warning: implicit declaration of function `print_toto'

Voici l'autre erreur très courante que l'on rencontre au moment du link:

/tmp/ccr2MjbC.o(.text+0x0): In function `print_toto':
: multiple definition of `print_toto'
/tmp/ccYyznn1.o(.text+0x0): first defined here
/usr/lib/gcc-lib/i686-pc-linux-gnu/3.3.6/../../../../i686-pc-linux-gnu/bin/ld: Warning: size of symbol `print_toto' changed from 20 in /tmp/ccYyznn1.o to 25 in /tmp/ccr2MjbC.o
collect2: ld returned 1 exit status

Le problème ici est que la fonction print_toto() est définie à deux endroits différents: c'est uniquement au moment du link que le problème apparait car les 2 fichiers sont compilés séparément, et c'est au moment de la construction de la table des symboles générale, que le linker voit que le même symbole a été défini à deux endroits différents.

Le dénominateur commun de ces erreurs de link est qu'elles commencent toutes par:
/tmp/ccr2MjbC.o(.text+0x0):
ou quelque chose de similaire, et qu'elles finissent par:
collect2: ld returned 1 exit status

Comprendre les erreurs d'exécution

C'est l'heure de la légendaire...
Segmentation fault

La raison de cette erreur est que le programme tente d'accéder à une zone de mémoire non autorisée. Voila le code correspondant:

 char *s1, *s2;
 strcat(s1,s2);

Les deux chaines ont été déclarées, il n'y a donc aucune erreur à la compilation. En revanche, Ni l'une ni l'autre ne se sont vues allouées de mémoire. Dès lors, quand strcat() tente de les concatener, elle se heurte au fait que les pointeurs s1 et s2 sont NULL. C'est pourquoi au moment de l'exécution, la concaténation échoue.

D'une manière générale, les segmentation faults (segfaults pour les intimes) sont dues a une allocation mémoire oubliée: il faut donc revoir les pointeurs utilisés dans la zone du segfault, et vérifier que la mémoire vers laquelle ils pointent a été correctement initialisée.

La prochaine étape de ce cours (Organisation du Projet]) sera justement de voir comment améliorer son code et ses interventions dans ces différentes parties.

Commentaire de Duke :
Il pourrait être intéressant de rajouter les commandes gcc permettant de decouper la compilation :
par exemple gcc -E pour avoir le code apres le preprocesseur
Ceci juste comme une addition pour les "curieux" qui voudrait appronfondir d'eux meme
ce qu'il se passe exactement pendant la comilation

Sinon attention il y a des textes qui depassent dans les boites en pointillés
car elles ne retourne pas à la ligne toutes seules

Organisation du Projet

un bon projet, c'est quoi?

* Un projet qui marche (normal)
* Un projet qui est débuggué (ah ben oui, quand meme)
* Un projet qui est débuggable (ne vous faites pas d'illusion il reste toujours des bugs)
* Un projet dont le code peut être compris par quelqu'un d'autre que le concepteur (et c'est la que ca se corse...)

Mais que faire?

Alors comment faire pour qu'un projet satisfasse ces exigences?
* Réfléchir avant de coder, et tester ses résultats
* Ecrire des tests pour l'utilisation du projet, et pas pour le code du projet (la nuance est de taille...): par exemple, on peut écrire les tests avant d'écrire le code du projet afin d'être sur de ne pas être influencé par la façon dont le code a été fait.
* Utiliser des outils de monitoring pour vérifier les fuites de mémoires, etc
* Et bien sur, écrire du code propre, et le ranger correctement.

A titre d'exemple, les projets de plusieurs milliers de lignes de code de C sont légions. Imaginez un fichier de 10000 lignes de code: déjà c'est très lourd à utiliser et ensuite, pour comprendre le code, ca se corse: si le main est tout en bas, et la fonction qu'on veut voir tout en haut....

Il est donc très rapidement nécessaire de séparer son code en morceaux.

Séparer son code

''' Les Headers '''

Déjà il faut séparer les déclarations globales du reste du code: ces déclarations globales sont:
* Les macros
* Les constantes
* Les déclarations de fonctions
* Les déclaration de types (struct, enum, typedef ...)
* Les déclaration de variables globales (beurk!)

Il existe un type de fichier particulier pour accueillir ces déclarations: les fichiers "headers", dont l'extension est ".h" (ca doit vous rappeler des choses)

Donc pour chaque fichier .c, il faut un fichier .h qui contienne la déclaration des fonctions, des macros, des constantes, etc qui y sont utilisées.

Petit rappel...

Ca c'est une '''déclaration''':

 void fonction(int arg1, char arg2);

Ca c'est une '''définition''':

 void fonction(int arg1, char arg2) {
     int i;
     for(i=0; i<arg1; i++)
         printf("%c\n", arg2);
 }

On parle bien ici des déclarations, et pas des définitions. Ca ne compilera pas si vous avez vos définitions dans vos headers. En effet vos headers risquent d'être inclus par plusieurs fichiers c, et donc à chaque fois les fonctions redéfinies...

''' Utiliser plusieurs fichiers '''

Il est aussi bon de séparer son code en blocs logiques: par exemple, un fichier s'occupera des fonctions d'affichage, pendant qu'un autre s'occupera de l'interface avec le système, pendant qu'un autre contiendra les algorithmes de calcul.
Attention! il ne faut pas se tromper d'objectifs: on fait ca pour pouvoir facilement retrouver le bout de code qui nous intéresse: pas pour avoir des petits fichiers!! Ca ne sert à rien d'avoir "fichier1.c fichier2.c fichier3.c" et des contenus tous liés entre eux: au contraire, ca risque d'être encore moins clair qu'un seul gros fichier.
C'est quand on utilise plusieurs fichiers que les headers deviennent important: en effet, peut etre que les fonctions du fichier d'algorithmes se serviront de l'affichage ou de l'interface systeme: il faut donc qu'ils connaissent les déclarations de ces fonctions: il faudra donc include les headers des fichiers d'affichage et systeme dans le fichier C des algorithmes...

Votre projet commence à ressembler à quelque chose

''' Ecrire des tests '''

Tester le programme à la main, c'est bien, mais pas efficace quand le programme devient complexe: on ne peut pas imaginer tous les cas et les taper à la main. En conséquence de quoi il est utile d'écrire des scripts de tests, qui appeleront par exemple 1000 fois le binaire avec des arguments différents et qui renverront des erreurs si le programme bug ou renvoie des résultats incohérents.
Un script de test bien fait, c'est tout un tas de mauvaises surprises en moins lors de la correction ou de la démo du projet :)

On a parlé des headers, des définitions, déclarations, etc. Il y a des outils pour optimiser la compilation de toutes ces choses: c'est ce qu'on verra dans la troisième partie du cours, [[C Avancé]].

C Avancé

Du C Avancé, c'est du C compliqué. Avant de faire compliquer il faut faire propre.

Du code propre

Du code propre c'est quoi?
Ca se lit facilement

  1. Donc c'est indenté, c'est net, ca bave pas: si y'a trop d'indentation c'est qu'il faut probablement revoir le bout de code...
  2. Pas de lignes de 3 km
  3. C'est pas du C syntaxe C++ ou un truc moche du style
  4. Des fonctions pas trop longues: si ca fait plus d'une page, c'est que ca doit être divisé

Ca se comprend vite

  1. Ca se lit comme une histoire
  2. C'est commenté quand ca se corse MAIS...
  3. C'est commenté uniquement quand c'est nécessaire

Ca fait ce qu'on veut et pas plus

  1. Pas la peine de faire 3 fois la même chose
  2. Pas la peine de tester ces choses qui ne peuvent pas arriver
  3. Pas la peine de mettre des options dont on ne va pas se servir
  4. On ne se branle pas au nombre de lignes

C'est du code réfléchi:

  1. Pas de fuite de mémoire (on détruit ce qu'on alloue)
  2. Pas de boucles inutiles (ca prend du temps)
  3. Des variables dont la portée est controlée
  4. Des fonctions qui ne font qu'une seule chose à la fois
  5. Ca gère les situations d'erreurs

Les ptites feintes du préprocesseur

'''#define'''

permet de définir des constantes et des macros. Lorsque des valeurs constantes vont êtres utilisées au cours du programme, il est bon de les définir avec des '''#define''': le préprocesseur remplacera les constantes dans le code par leur valeur au moment de la compilation. C'est une utilisation à privilégier par rapport à définir des variables globales constantes.

'''#if #ifdef, #ifndef, #else, #endif'''

Sont des structures conditionnelles du préprocesseur. Cela permet notamment de "condamner" du code. Par exemple, si vous avez un morceau de code de debug que vous ne voulez pas exécuter au final, vous pouvez procéder de la sorte:

 #define DEBUG 1
 ...
 #if DEBUG
 printf("debug: %d\n", toto);
 #endif

Au moment de la compilation si DEBUG est strictement supérieur à 0 alors le code dans le '''#if''' sera compilé et vous aurez votre affichage de debug. Au moment de la compilation finale quand vous ne débuggerez plus, il suffira de mettre DEBUG à 0 pour que le code soit supprimé de la compilation.

Il est aussi possible de définir des constantes du préprocesseur directement dans la ligne de commande de gcc en utilisant l'option '''-D='''

L'autre façon de faire est d'utiliser #ifdef comme le montre l'example suivant. Dans ce cas, l'option de compilation serra simplement '''-D'''

 #define DEBUG
 ...
 #ifdef DEBUG
 printf("debug: %d\n", toto);
 #endif

Les petits mots clefs qui changent tout

'''static'''

Ce mot clef s'utilise lors de la déclaration d'une variable, avant d'en spécifier le type: par exemple:

 static int i;

Une variable déclarée statique voit sa portée étendue (ou limitée) à tout le fichier. L'utilité est surtout de limiter la portée d'une variable globale dans un projet multifichier, ou alors d'utiliser 2 fonctions ayant le même nom dans des fichiers différents.

'''inline'''

Ce mot clef s'utilise avec les procédures et fonctions dont le code au moment de l'assemblage du fichier doit être inclus directement dans la fonction appelante. L'utilité est d'augmenter la lisibilité du code en séparant les fonctions, mais de limiter les branchements et donc l'overhead des sauvegardes et restaurations de contexte au moment de l'exécution.

Il ne faut pas confondre l'utilisation d''''inline''' avec l'utilisation de macros: en effet, le code d'une macro est adapté dans le code appelant par le préprocesseur, alors que le code d'une procédure "'''inline'''" est adapté au moment de la construction du code assembleur. Dans le premier cas, la transformation de la macro en code assembleur est donc effectuée autant de fois que le code est recopié. Ce n'est pas le cas de la fonction '''inline'''.

Exemple:

 inline int max(int i1, int i2);

'''extern'''

Ce mot clef est utilisé pour éviter de déclarer deux fois une variable dans deux fichiers différents: il indique que la déclaration a déjà été effectuée, et qu'il n'est pas nécessaire d'allouer a nouveau de la mémoire et un espace dans la table des symboles, la variable y sera placée par le linker à partir d'un autre fichier.

Exemple:

extern int i;

'''const'''

Ce mot clef, placé comme les autres devant le type des variables, permet d'indiquer au compilateur que la variable ne doit pas être modifiée, autrement dit, elle doit être considérée comme une constante. Si, lors de la compilation, la valeur de la variable est modifiée, une erreur sera générée.

On peut par exemple l'utiliser avec les paramètres d'une fonction pour éviter qu'ils soient modifiés dans la fonction. C'est surtout utilisé pour les pointeurs, afin de s'assurer que la mémoire sur laquelle ils pointent n'est pas modifiée.

Exemple:

 void print_string(const char *s);

La gestion d'erreurs

En utilisant un header spécial du système, '''errno.h''', une variable de type entier dont le nom est '''errno''' est déclarée. Cette variable est utilisée pour contenir les codes de retour des appels systèmes, si ceux-ci plantent. Par exemple, si le mode d'ouverture de fichier passé à fopen est erroné, fopen placera la valeur '''-EINVAL''' dans errno.

Il existe un appel système dont le nom est '''perror''' qui prend en argument une chaine de caractère. Il consultera la valeur de errno, affichera l'erreur correspondante et enrichira le message d'erreur de la chaine de caractère passée en argument.

D'une manière générale, il est bon pour contrôler le déroulement du programme, particulièrement les parties sensibles, d'utiliser la gestion d'erreur afin d'éviter des failles. Par exemple, si le programme n'arrive pas à ouvrir le fichier de configuration, ce n'est pas la peine qu'il continue à tourner.

Voici un exemple très simple d'utilisation de '''perror()''':

 FILE *fd;
 if((fd = fopen("toto.conf","r")) == NULL) {
     perror("Erreur d'ouverture du fichier de configuration");
     exit(-1);
 }

Ce code permet de sortir du programme en affichant une erreur qui, au moment de l'exécution, est explicite.