La compilation en OCaml avec ocamlc et ocamlopt

Premier billet d'une suite de billets consacrés à la compilation en OCaml et le tooling qui va avec

Dans le cadre de mon travail, j’ai dû réaliser une librairie OCaml. L’outil sur lequel je travaille avait déjà un système de build présent et automatisé. Cependant, en faire une librairie fut un travail laborieux que je n’avais pas imaginé au départ malgré mon expérience avec ce langage. Si j’ai rencontré des difficultés à créer une librairie, c’est en partie lié au fait qu’il manque de la documentation sur le sujet mais aussi les messages d’erreurs d’OCaml ne sont pas toujours très explicites comme on va le voir. Ce billet est le premier d’une suite de billets qui vise à expliquer la compilation en OCaml ainsi que les outils qui existent pour automatiser ce processus. L’objectif final étant de présenter le processus de compilation et d’installation d’une librairie OCaml avec les outils Ocamlbuild, Ocamlfind, Oasis et Opam.

Dans ce premier billet, on va s’intéresser aux commandes ocamlc et ocamlopt qui sont les outils de base qui permettent de compiler n’importe quel projet OCaml.

Ce billet demande de connaître un minimum le langage OCaml mais aussi d’avoir quelques bases sur le processus de compilation.

Compiler un programme OCaml

Hello world

OCaml est un langage compilé. Il propose deux modes de compilation :

  • On peut compiler du code OCaml vers un bytecode qui sera ensuite interprété
  • On peut compiler du code OCaml vers du code machine (un langage d’assemblage)

Pour ce que l’on souhaite faire, il n’y a pas vraiment de différence. Cependant, il est intéressant de savoir que ces deux modes de compilation existent, car les outils que l’on va utiliser et les extensions des fichiers diffèrent en fonction du mode voulu.

Prenons le programme OCaml suivant (que j’enregistre dans un fichier hello.ml) :

1
2
3
let hello = "hello world!\n"

let _ = print_string hello

On peut le compiler vers du bytecode grâce à la commande ocamlc :

1
ocamlc hello.ml -o hello

que l’on peut ensuite exécuter gràce ocamlrun

1
ocamlrun hello

ou bien que l’on peut compiler vers du code natif grâce à la commande ocamlopt :

1
ocamlopt hello.ml -o hello

que l’on peut ensuite exécuter en faisant :

1
./hello

Vous avez pu remarquer que les programme générés étaient directement exécutables. Les outils ont aussi fait le linking pour nous. On peut leur demander de ne pas faire cette dernière étape en utilisant l’option -c :

1
ocamlopt -c hello.ml -o hello

Ceci revient seulement à compiler le fichier et il ne peut pas être exécuté directement par la machine. La compilation produit deux fichiers hello.cmx et hello.cmi. Si on avait utilisé ocamlc à la place du hello.cmx on aurait eu un fichier hello.cmo. La convention de nommage veut qu’en général les fichiers contenant du code natif aient la lettre x dans l’extension. Pour en savoir plus, vous pouvez aller voir ici. Dans le cadre de ce billet, compiler vers du bytecode ou vers du code machine revient au même, j’ai fait le choix arbitraire d’utiliser par la suite seulement ocamlc.

Pour comprendre pourquoi la compilation génère deux fichiers, il faut comprendre que pour OCaml, chaque fichier dont l’extension est .ml déclare un nouveau module. Un module est définit par une implémentation que l’on retrouve dans un fichier .ml et par une interface dans un fichier .mli qui doit porter le même nom. Ainsi, pour définir un module foo, on crée deux fichiers foo.ml et foo.mli. Cependant, il arrive assez fréquement comme pour notre exemple ci-dessus que l’interface puisse être générée directement à partir du fichier .ml et donc on laisse le soin au compilateur de générer automatiquement ce fichier à notre place. Les fichiers .ml/.mli jouent un rôle analogue aux fichiers .c/.h que l’on peut trouver pour le C.

Ainsi, pour chaque module OCaml, le compilateur gènère deux fichiers :

  • un fichier .cmo qui contient le code du module compilé
  • un fichier .cmi qui contient le code de l’interface compilée.

Dans le cas où l’utilisateur crée un fichier foo.mli, il faut toujours le préciser à ocamlc avant de compiler foo.ml :

1
ocamlc foo.mli foo.ml

Compiler plusieurs fichiers

Notre exemple contenait qu’un seul fichier. Mais en général, on va chercher à compiler des projets découpés en plusieurs fichiers. Dans ce cas, il faudra donner dans l’ordre les fichiers à compiler. Une restriction d’OCaml fait qu’on ne peut pas définir un module A qui dépend d’un module B et vice-versa. Cela implique que le graphe généré par les dépendances des fichiers ne doit pas contenir de cycle.

Supposons que l’on a défini un module A :

1
let a = "je suis dans le module A"

et un module B :

1
2
3
4
5
let b = " je suis dans le module B"

let _ =
    print_endline A.a;
    print_endline b

on peut compiler ces modules en faisant :

1
ocamlc A.ml B.ml

car le module B dépend du module A. Si on cherche à compiler les fichiers dans l’autre sens ou bien seulement le module B on obtient l’erreur

1
2
File "B.ml", line 4, characters 16-19:
Error: Unbound module A

ce qui est logique car il ne connait pas l’existence du module A 1. Imaginons maintenant qu’une personne a écrit le module A avant nous et qu’on a seulement accès à sa version compilée ce que l’on obtient avec la commande :

1
ocamlc -c A.ml

Si on lance

1
ocamlc B.ml

on obtient un message d’erreur un poil différent de celui qu’on a eu précédemment :

1
2
File "B.ml", line 1:
Error: Required module `A' is unavailable

Par contre si on lance la commande

1
ocamlc -c B.ml

on n’a plus de soucis. Mais que s’est-il passé ? L’explication réside dans le fait qu’ocamlc fait à la fois le travail du compilateur et du linker, et dans ces messages d’erreurs il ne nous dit pas vraiment s’il a échoué à la compilation ou bien lors du linking.

Lorsque le compilateur rencontre le symbole A.a dans le module B, ce dernier va chercher le fichier A.cmi et vérifier si ce dernier déclare bien un symbole a. Si c’est le cas il est content et il va pouvoir continuer son travail. Le point important est qu’il n’a pas besoin de connaître l’implémentation de A.a, mais il a juste besoin de savoir qu’il est défini. Il peut donc compiler le fichier. Par contre, ce fichier compilé a un trou et donc on ne peut pas l’exécuter directement. Ce sera le travail du linker de compléter ce trou en fournissant une implémentation du symbole A.a, implémentation qui se trouve dans le fichier compilé A.cmo.

C’est ce qui nous est dit dans le second message d’erreur que l’on peut interpréter par : le module B a besoin du module A, mais je n’ai pas trouvé d’implémentation de ce module.

Pour corriger cette erreur, il faut donc lui fournir le module compilé (fichier .cmo) en faisant

1
ocamlc A.cmo B.ml

En résumé :

  • Le compilateur se sert des fichiers .cmi pour gérer les dépendances. Lorsqu’il repère une dépendance vers un module Foo, il va chercher implicitement le fichier foo.cmi. S’il ne le trouve pas il échoue avec une erreur du style Unbound module Foo
  • Le linker se sert des fichiers .cmo pour générer l´exécutable. Il faut préciser explicitement et dans le bon ordre les fichiers .cmo.

J’ai longtemps parcouru le chemin…

Ce qu’on a vu plus haut fonctionne plutôt bien si tous les fichiers se situent dans le même répertoire, mais bien souvent ce n’est pas le cas. Que se passe-t-il si par exemple le module A se situe dans un répertoire différent ? Pour la suite de l’exemple on va supposer qu’on a l’arborescence suivante :

1
2
3
4
.
..
A/A.ml
B.ml

Contrairement à l’exemple précédent, cette fois la commande

1
ocamlc -c B.ml

nous renvoie qu’il n’a pas réussi a trouvé une interface pour le module A. Il faut donc préciser à ocamlc qu’il doit aller regarder dans le dossier A. On peut le faire en utilisant l’option -I :

1
ocamlc -c -I A B.ml

De facon générale Pour chaque répertoire, il faut utiliser l’option -I :

1
ocamlc -c -I A1 -I A2 ... B.ml

sinon, il est possible d’utiliser l’option -Is en séparant les répertoires par une virgule :

1
ocamlc -c -Is A1,A2,A3 B.ml

Il existe toutefois une subtilité ici. Supposons qu’on souhaite compiler et linker en même temps les modules A et B comme au tout début. La commande :

1
ocamlc -I A A.ml B.ml

ne va pas fonctionner car il ne trouve pas le fichier A.ml. Essayons alors la commande

1
ocamlc A/A.ml B.ml

où on indique explicitement le chemin du fichier A.ml. On se retrouve alors encore avec une erreur comme quoi il ne trouve pas le module A. C’est normal car il a besoin du fichier A.cmi qui n’est pas dans le répertoire courant. Il faut donc lui indiquer où le trouver en utilisant l’option -I :

1
ocamlc -I A A/A.ml B.ml

En résumé :

  • les fichiers .ml{,i} doivent être référencés par leur chemin relatif/absolu
  • Il faut préciser avec l’option -I les répertoires qui peuvent contenir tous les fichiers objets (.cmi, .cmo) qui seront éventuellement compilés et/ou qui seront utiles pour la compilation et le linking.

Graphics, Unix, Str…

Si vous utilisez la librairie standard d’OCaml, il vous est certainement arrivé d’utiliser des fonctions du module Hashtbl par exemple :

1
2
3
4
let _ =
  let h = Hashtbl.create 81 in
  Hashtbl.add h 5 10;
  print_int (Hashtbl.length h)

Vous compilez votre programme :

1
ocamlc test.ml -o

et tout se passe comme prévu. Mais si on cherche à utiliser d’autres modules, comme le module Unix les choses se compliquent :

1
let _ = print_int (Unix.getpid ())

et on obtient l’erreur :

1
2
File "test2.ml", line 1:
Error: Required module `Unix' is unavailable

donc cela veut dire qu’il a bien trouvé le fichier unix.cmi mais il n’a pas trouvé d’implémentation. C’est d’autant plus problématique que la documentation n’indique rien à ce sujet2. Pour corriger ce problème il faut donc indiquer explicitement au linker qu’il doit linker le module Unix. Il se trouve que ce module est en fait une librairie, et donc que son extension n’est pas .cmo .cma mais on y reviendra plus tard. De plus, comme il se trouve dans le même répertoire que le .cmi qui est inclu par défaut, on peut donc juste écrire :

1
ocamlc unix.cma test2.ml

  1. Pensez à supprimer les fichiers cmo/cmi , sinon un autre message d’erreur risque d’apparaitre 

  2. L’explication est la suivante : le compilateur est ecrit en OCaml. Tous les modules qui ne sont pas necessaire au compilateur ne sont pas linkes par defaut. 


À l’issu de ce premier billet, je pense qu’il est maintenant à peu près clair comment la compilation fonctionne en OCaml. Cependant, ce qu’on a vu jusqu’ici n’est pas forcément très pratique en général car on aimerait que le processus de compilation soit un peu plus automatique. En particulier on verra dans le prochain billet comment :

  • ne pas avoir à écrire toutes ces commandes à la main
  • gérer automatiquement les dépendances internes de notre projet entre les différents fichiers
  • gérer automatiquement les dépendances externes du projet

Même si un Makefile peut adresser le premier problème, ce n’est pas le cas pour les deux suivants. Pour cela, on utilisera Ocamlbuild et Ocamlfind qui permettent respectivement de régler les second et troisième problèmes.

2 commentaires

Merci pour ce billet, c’est sympa ! Ce modèle par fichier du compilateur OCaml était très naturel au moment où il était conçu (il y a 25 ans), puisque c’est aussi la façon dont fonctionnent les compilateurs C avec lequel les gens étaient familiers. Aujourd’hui la norme est en fait à des systèmes à la fois plus compliqués mais plus clé-en-main, qui font beaucoup de la magie (et parfois complètement tout) pour les gens, et ce modèle peut malheureusement devenir un obstacle à l’adoption du langage.

+2 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte