La compilation séparée en C

Jessee Michaël C. Edouard

La compilation séparée désigne le fait de compiler plusieurs fichiers sources séparément puis de les lier ensuite pour générer le produit final qui peut être un exécutable par exemple. Elle comprend plusieurs techniques que nous allons explorer tout au long de ce tutoriel.

Commentez cet article : Commentez ♪

- Titre : La compilation séparée en C

- Auteur : Jessee Michaël C. Edouard

- Parution : 14 avril 2008

- Mise à jour : 8 décembre 2009

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2008 Melem. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Généralités

- I-A. Compilation d'un projet

- I-A-1. Introduction

- I-A-2. Exemple avec un projet constitué de deux fichiers sources

- I-A-2-a. Le projet

- I-A-2-b. Compilation sous Code::Blocks

- I-B. Le mot-clé extern

- I-C. Le mot-clé static

- I-D. Les fichiers d'en-tête

- I-E. Les structures opaques

- II. Les bibliothèques

- II-A. Introduction

- II-B. Les bibliothèques statiques

- II-C. Les bibliothèques dynamiques

- II-D. Applications. Exemples

- II-D-1. La bibliothèque standard du langage C

- II-D-2. Le concept d'API

- III. Conclusion

I - Généralités

I-A - Compilation d'un projet

I-A-1 - Introduction

Un projet d'application en langage C est au moins constitué d'un fichier source qui sera compilé, ce qui donnera un fichier objet en sortie, puis lié à d'autres fichiers objets pour générer la sortie finale qui peut être un exécutable par exemple. Dans le cas général, un projet est constitué de plusieurs fichiers sources.

I-A-2 - Exemple avec un projet constitué de deux fichiers sources

I-A-2-a - Le projet

Nous allons créer un programme qui affiche la somme et le produit de deux nombres entiers en séparant le programme et les fonctions (somme et produit) dans deux fichiers différents.

Fichier : exemple.c

#include <stdio.h>

int somme(int a, int b);
int produit(int a, int b);

int main()
{
int a = 2, b = 5;

printf("%d + %d = %d\n", a, b, somme(a, b));
printf("%d * %d = %d\n", a, b, produit(a, b));

return 0;
}

Fichier : somme.c

int somme(int a, int b)
{
return a + b;
}

int produit(int a, int b)
{
int prod = 0;

while (b-- > 0)
prod += a;

return prod;
}

I-A-2-b - Compilation sous Code::Blocks

Pour compiler le projet, sélectionnez la commande Build > Build (Ctrl + F9). Cette compilation se fait en deux phases : compilation des fichiers sources (exemple.c et fonctions.c) puis édition des liens. On peut aussi compiler les fichiers sources individuellement : Build > Compile Current File (Ctrl + Shift + F9). Dans ce cas la commande Build (Ctrl + F9) ne fera plus que l'édition des liens.

I-B - Le mot-clé extern

En langage C, tout objet (variable ou fonction) doit toujours avoir été déclaré avant d'être utilisé. Nous avons déjà résolu le problème pour les fonctions, ne reste donc plus que les variables. Supposons donc que l'on souhaite, depuis un fichier donné, accéder à une variable globale définie dans un autre fichier. On ne peut pas tout simplement déclarer une deuxième fois la variable, car on aurait alors deux variables de même nom au sein d'un même projet ce qui conduirait à une erreur lors de l'édition des liens. Le mot-clé extern permet de résoudre le problème. Placé devant une déclaration, il permet d'indiquer que la variable ou fonction est définie (plus précisément : peut être définie) dans un autre fichier (source ou objet). Placé devant une définition, il permet d'indiquer que la variable ou la fonction est visible dans tout le projet, ce qui est déjà le comportement par défaut.

I-C - Le mot-clé static

Le mot-clé static permet de restreindre la visibilité de la variable ou de la fonction au fichier source courant. On a alors ce qu'on appelle une variable ou fonction privée. Par exemple :

#include <stdio.h>

static int id(int x); /* Declaration de la fonction "privee" id() (utilisee dans main()). */

int main()
{
int x = 0;

printf("id(%d) = %d\n", x, id(x));

return 0;
}

int id(int x)
{
return x;
}

I-D - Les fichiers d'en-tête

Les fichiers d'en-tête (*.h) permettent de rassembler des « en-têtes » (c'est-à-dire des déclarations de fonctions, des définitions de macros, de types, etc.) communs à plusieurs fichiers sources et/ou fichiers d'en-tête. Mais puisqu'ils peuvent justement être inclus par un grand nombre de fichiers, le risque d'être inclus plus d'une fois dans un même fichier est très élevé. C'est pour cette raison que les fichiers d'en-têtes doivent impérativement être protégés contre les inclusions multiples. La technique fait appel au préprocesseur : une macro indique si le fichier est déjà inclus. Il suffit donc de tester dès le début du fichier si la macro est définie ou non. Le schéma est donc le suivant :

#ifndef DRAPEAU

#define DRAPEAU

/* --------- */
/*           */
/* --------- */

#endif

Dans l'exemple de projet précédent, on aurait pu par exemple créé un fichier somme.h contenant la déclaration des fonctions somme et produit du fichier somme.c.

Fichier : somme.h

#ifndef H_SOMME_H

#define H_SOMME_H

int somme(int a, int b);
int produit(int a, int b);

#endif

Le fait d'avoir choisi H_SOMME_H plutôt que SOMME_H ou __SOMME_H__ comme drapeau n'est pas du tout le fruit du hasard. Il permet de ne pas entrer en conflit avec les identifiants réservés du langage. Par exemple, les identifiants commençant par E, LC_, SIG, etc. sont réservés (E pour les numéros d'erreur de errno.h, LC_ pour les constantes définies par locale.h et SIG pour le les signaux de signal.h). H_ en début d'un identifiant est pour l'heure encore libre, alors en profiter.

Il suffit maintenant d'inclure ce fichier partout où on a besoin des fonctions somme et produit. Par exemple :

Fichier : exemple.c

#include <stdio.h>
#include "somme.h"

int main()
{
int a = 2, b = 5;

printf("%d + %d = %d\n", a, b, somme(a, b));
printf("%d * %d = %d\n", a, b, produit(a, b));

return 0;
}

Lorsque le nom d'un fichier est mis entre guillemets comme dans cet exemple, le préprocesseur va d'abord chercher le fichier dans le même répertoire que celui du fichier source puis s'il ne le trouve pas, va le chercher dans le ou les répertoires par défaut (spécifiques du compilateur). On peut également spécifier un chemin absolu.

I-E - Les structures opaques

Une structure est dite opaque lorsque son implémentation (c'est-à-dire sa définition) est cachée. L'accès aux champs de la structure se fait alors par l'intermédiaire de fonctions dont l'implémentation évidemment est également cachée. En particulier, l'interface devra au moins fournir une fonction permettant de créer l'objet (constructeur) et une fonction permettant de le détruire (destructeur). Par exemple, nous allons encapsuler le type int dans une structure de type integer_s.

Implémentons la structure dans un fichier integer.c

Fichier : integer.c

#include <stdlib.h>

struct integer_s {
int value;
};

struct integer_s * integer_create_object()
{
return malloc(sizeof(struct integer_s));
}

void integer_set_value(struct integer_s * p_object, int value)
{
p_object->value = value;
}

int integer_get_value(struct integer_s * p_object)
{
return p_object->value;
}

void integer_delete_object(struct integer_s * p_object)
{
free(p_object);
}

Fournissons maintenant l'interface via un fichier d'en-tête : integer.h

integer.h

#ifndef H_INTEGER

#define H_INTEGER

struct integer_s;

struct integer_s * integer_create_object(void);
void integer_set_value(struct integer_s * p_object, int value);
int integer_get_value(struct integer_s * p_object);
void integer_delete_object(struct integer_s * p_object);

#endif

La ligne :

struct integer_s;

déclare la structure (sans la définir). C'est ce qu'on appelle une déclaration incomplète. Comme la définition de la structure n'apparaît pas plus bas, La structure est alors opaque.

Voici un exemple d'utilisation de integer.h :

exemple.c

#include <stdio.h>
#include "integer.h"

int main()
{
struct integer_s * i = integer_create_object();

if (i != NULL)
{
integer_set_value(i, 100);
printf("The value of i is : %d\n", integer_get_value(i));
integer_delete_object(i);
}

return 0;
}

II - Les bibliothèques

II-A - Introduction

Une bibliothèque (library en anglais) est en première approximation un bouquet de fonctions. Chez certains langages elles sont appelées unités ou paquetages, mais le principe reste le même. En langage C, il faut en fait également fournir le ou les fichiers d'en-tête correspondants (contenant la déclaration des fonctions de la bibliothèque, des macros et/ou types supplémentaires, etc.) avant de réellement en constituer une.

La manière de créer et d'utiliser une bibliothèque est très dépendante de l'environnement avec lequel on travaille. Dans ce tutoriel nous allons expliquer essentiellement la procédure pour MS Visual Studio .NET donc évidemment sous Windows.

Comme nous le savons déjà, la compilation d'un fichier source ne produit pas un exécutable, mais un module objet (.o ou .obj), que l'on peut considérer comme la version machine du fichier source original. Il faut ensuite lier différents modules objets pour produire un exécutable.

Un module objet est réutilisable (on peut donc voir un tel fichier comme une véritable « boîte à outils »). C'est là d'ailleurs toute l'importance de la compilation séparée. Reprenons par exemple le fichier somme.c contenant le code des fonctions somme et produit. En compilant ce fichier, on obtient un fichier somme.obj.

Maintenant, créons un nouveau projet dans lequel nous allons utiliser les fonctions somme et produit. Voici le programme :

Fichier : exemple.c

#include <stdio.h>

int somme(int a, int b);
int produit(int a, int b);

int main()
{
int a = 2, b = 5;

printf("%d + %d = %d\n", a, b, somme(a, b));
printf("%d * %d = %d\n", a, b, produit(a, b));

return 0;
}

Si on compile ce fichier, il n'y a aucune erreur puisque tout est syntaxiquement correct, l'équivalent en langage machine peut donc être généré. Par contre si on tente de générer l'exécutable, on aura un message d'erreur indiquant que l'édition des liens a échoué, car les fonctions somme et produit n'ont pu être trouvées. Il faut donc dire au linkeur qu'il doit également chercher dans somme.obj lors de l'édition des liens. La procédure est évidemment dépendante de l'environnement de développement. Sous Code::Blocks, c'est dans Project > Build Options > Linker > Link Libraries. Sous Visual Studio .NET c'est Project > Properties > Configuration Properties > Linker > Input > Additional Dependencies. Il suffit ensuite d'ajouter somme.obj. Ce n'est pas plus différent non plus avec les autres EDI.

Évidemment si vous ne spécifiez pas de chemin complet, le linkeur va supposer que le fichier se trouve dans le répertoire par défaut pour les libs (généralement un dossier nommé LIB dans le répertoire d'installation du compilateur) qui est bien entendu spécifique du linkeur.

II-B - Les bibliothèques statiques

Une bibliothèque statique (.lib ou .a) est un fichier qui regroupe un ou plusieurs modules objets. Elles s'utilisent donc de la même manière que ces derniers. Pour créer une bibliothèque statique avec Visual Studio .NET, créez un nouveau projet Win32 (Win32 Project) puis dans Paramètres de l'application (Application settings), choisissez Bibliothèque statique (Static library). Cochez l'option Projet vide (Empty project) afin qu'aucun fichier source ne soit automatiquement ajouté au projet. À la fin, compilez le projet à l'aide du menu Générer (Build).

II-C - Les bibliothèques dynamiques

Une bibliothèque dynamique est un fichier qui ne sera effectivement lié à l'exécutable que pendant l'exécution. Cela présente plusieurs avantages. Supposez par exemple que vous avez créé une bibliothèque statique et que vous l'avez ensuite utilisé dans de nombreuses applications. Si un jour vous la modifiez et que vous voulez également mettre à jour toutes vos applications, vous devrez les recompiler une par une ! Pourtant si vous avez utilisé une bibliothèque dynamique, la modification seule de ce fichier aura des répercussions sur toutes les applications l'utilisant puisque la liaison avec le fichier ne se fait que pendant l'exécution. De plus, si vous avez bien compris, l'utilisation des bibliothèques dynamiques rend les exécutables plus petits (en terme de taille) puisque ce dernier même est incomplet. En effet, il a besoin du code contenu dans la bibliothèque pour fonctionner.

Sous Windows, les bibliothèques dynamiques sont appelées DLL (.dll) pour Dynamic-Link Library. Sous UNIX on les appelle Shared Objects (.so). Dans ce tutoriel, nous nous intéresserons aux DLL.

Nous allons donc créer une DLL (dsomme.dll) exportant deux fonctions : somme et produit. Que signifie exporter ? Ben c'est très simple : lorsqu'on développe une bibliothèque, on peut spécifier quelles fonctions (ou variables) seront « publiques » (ou exportées), c'est-à-dire accessibles depuis l'extérieur, et lesquelles seront « privées », c'est-à-dire réservées à usage interne. Ce qui ne sont pas exportées sont privées.

La question est donc maintenant : comment exporter une fonction. Et ben il y a plusieurs manières de le faire, par exemple à l'aide du modificateur __declspec(dllexport). Bien entendu, il s'agit bien d'une extension Microsoft aux langages C et C++ (en ce qui nous concerne : le langage C) et qui fut ensuite reprise par la plupart des implémentations pour Windows. Donc pas de problème que vous compilez avec MingW ou Borland C++ …

Sous Visual Studio .NET, créez un nouveau projet Win32 (Win32 Project) puis dans Paramètres de l'application choisissez DLL. Cochez l'option Projet vide afin qu'aucun fichier ne soit automatiquement ajouté au projet. Ajoutez ensuite un fichier dsomme.c puis saisissez le code suivant :

Fichier : dsomme.c

__declspec(dllexport) int somme(int a, int b)
{
return a + b;
}

__declspec(dllexport) int produit(int a, int b)
{
int prod = 0;

while (b-- > 0)
prod += a;

return prod;
}

Compilez ensuite le projet avec la commande Build du menu Build. Vous obtiendrez entre autres en sortie deux fichiers : dsomme.dll et dsomme.lib. Ce dernier, bien que portant l'extension .lib, n'est pas une bibliothèque statique, mais une bibliothèque d'importation. C'est lui qu'il faut passer au linkeur lors de l'édition des liens pour pouvoir compiler du code dépendant d'une DLL. À l'exécution, le programme doit pouvoir localiser la DLL. Cette dernière doit donc se trouver soit dans le même répertoire que le programme, soit dans le répertoire courant du programme, ou encore dans un répertoire « connu » du système par exemple le répertoire system32.

La déclaration de fonctions à importer depuis une DLL (via la bibliothèque d'importation) peut se faire comme la déclaration d'une fonction « normale », cependant le modificateur __declspec(dllimport) permet d'indiquer au compilateur (je dis bien le compilateur, pas le linkeur) que la fonction en question se trouve dans une bibliothèque dynamique, ce qui lui permettra de générer du code plus efficace (plus « direct »). Sans cela, le compilateur va tout simplement convertir chaque appel de la fonction en appel de celle qui se trouve dans le .lib, qui ne fait rien de plus qu'un appel à la fonction dans la DLL ce qui fait donc finalement deux appels, ce qui est évidemment plus long qu'un appel direct. Notre programme sera donc :

Fichier : exemple.c

#include <stdio.h>

__declspec(dllimport) int somme(int a, int b);
__declspec(dllimport) int produit(int a, int b);

int main()
{
int a = 2, b = 5;

printf("%d + %d = %d\n", a, b, somme(a, b));
printf("%d * %d = %d\n", a, b, produit(a, b));

return 0;
}

Et n'oubliez pas : nous devons nous lier avec dsomme.lib.

II-D - Applications. Exemples

II-D-1 - La bibliothèque standard du langage C

Sous Windows, la bibliothèque « standard » du langage C, c'est-à-dire celle qui contient entre autres le code des fonctions standard du C est implémentée en tant que bibliothèque dynamique connue sous le nom de Microsoft C Run-Time Library (CRT), et qui correspond au fichier msvcrt.dll (ce fichier se trouve dans le répertoire système). Les compilateurs utilisent généralement cette bibliothèque comme corps de la bibliothèque standard, mais rien n'empêche un compilateur particulier de disposer de sa propre bibliothèque d'exécution. Visual C++ 2005 (Visual C++ 8.0) par exemple utilise msvcr80.dll (msvcr80d.dll en mode Debug) au lieu de msvcrt.dll.

II-D-2 - Le concept d'API

Une API ou Application Programming Interface (Interface de Programmation d'Applications) est un ensemble de fonctions exposées par un système ou un logiciel pour permettre à d'autres logiciels d'interagir (c'est-à-dire de communiquer) avec lui. Par extension, toute fonction d'une API donnée est également appelée : une API. Sous UNIX, les API du système sont appelés appels système.

Les DLL sont très utilisées sous Windows et le système lui-même expose son API via de nombreuses DLL. Les programmes conçus spécifiquement pour Windows se lient donc à un ou plusieurs DLL de l'API Windows.

III - Conclusion

La compilation séparée permet de mieux organiser ses projets et de développer des bibliothèques de fonctions. C'est donc une technique qu'il faut absolument maîtriser si on veut développer sérieusement en C.