Tout de suite!
D’abord, il vaut mieux se mettre à faire le projet de programmation le
plus tôt possible! J’estime que vous pouvez faire un compilateur
C--, sans les exceptions, en deux à trois semaines, à condition de
ne pas tarder à s’y mettre. La gestion des exceptions
(throw
,
try
…catch
…finally
)
est quelque chose d’un peu compliqué, qu’il vaut mieux aborder avec
méthode.
Un peu de débrouille ne fait pas de mal… Si vous ne voyez pas bien
comment compiler quelque chose que vous sauriez écrire en C,
écrivez-le en C, appelez gcc -S
pour
produire le code assembleur correspondant, et déduisez-en quel code
assembleur gcc
fabrique. Par exemple, si
vous ne savez pas comment calculer un quotient de deux entiers, créez
un ficher /tmp/a.c
, contenant:
et tapez gcc -S a.c
dans le répertoire
/tmp
. Vous obtiendrez un fichier
/tmp/a.s
de la forme:
dont vous pourrez déduire que le quotient se calcule par une
combinaison des instructions cltd
et idivl
: voir la
section 9. (C’est l’instruction la plus compliquée à
compiler, hormis les exceptions.)
On peut programmer en Caml en différents styles. Par exemple, une factorielle en style fonctionnel s’écrira:
La même, en style impératif, pourra s’écrire:
On peut encore l’écrire en style objet, soit grâce aux caractéristiques objet d’OCaml, soit en se fabriquant des classes à la main.
Tous les styles sont bons, et l’on peut coder ce que l’on veut dans chaque style. Chacun a ses préférences. Cependant, il est bon de savoir que le compilateur que vous avez à écrire s’écrit probablement le plus naturellement du monde dans un style fonctionnel.
Par exemple, j’ai défini dans ma version du compilateur une fonction
compile_expr
pour compiler les expressions,
de type loc_expr
, et une fonction
compile_code
pour compiler les instructions
et les programmes, de type loc_code
, les deux
fonctions s’appelant récursivement, et la seconde fonction appelant
naturellement la première.
Au passage, le type de ma fonction
compile_expr
est:
out_channel -> string -> (string * int) list -> int -> loc_expr -> unit
L’idée est que l’appel compile_expr out f env stack e
,
compile le code assembleur qui évalue l’expression e
et retourne sa valeur dans %eax
, où:
out
est le descripteur de fichier sur
lequel imprimer les instructions assembleur;
f
est le nom de la fonction C que le
compilateur est en train de compiler (ceci sert à appeler
genlab
pour fabriquer des labels frais);
env
est une liste de couples (nom de
variable, offset), disant pour chaque variable locale à quel offset
par rapport à %ebp
il faut la chercher;
stack
est un entier n décrivant la
profondeur de la pile courante depuis %ebp
(autrement dit, on a
l’invariant %esp
=%ebp
−n).
Il est quand même pratique d’utiliser quelques références
(ref
, !
,
:=
), sans en abuser. Dans mon compilateur,
je n’ai qu’une seule référence, qui est de plus une variable globale:
let strings = ref ([] : (string * string) list);;
et qui sert à associer à chaque constante chaîne (par exemple,
"r"
dans le fichier
cat.c,
mais aussi les noms d’exception comme Trouve
dans le fichier
exc1.c)
le nom d’un label où cette chaîne sera stockée. Regardez bien, par
exemple, où la chaîne "r"
est décrite dans le
fichier assembleur
cat.s.
Si vous ne comprenez pas tout de suite à quoi peut servir la variable
globale strings
ci-dessus, vous le
comprendrez en écrivant le compilateur…
En ce qui concerne les exceptions, leur sémantique, décrite dans un
cadre plus simple que celui de C--, est donnée en
section 7. C’est un peu compliqué à compiler, et la
clause finally
est particulièrement retorse.
(Vous comprendrez pourquoi en la compilant…) Néanmoins, et même s’il
existe de nombreuses façons de le faire, vous pouvez vous inspirer de
ce que mon compilateur fait. Ma version du compilateur
mcc
a le bon goût d’ajouter plein de
commentaires dans l’assembleur produit aux alentours des
throw
et des
try
…catch
…finally
pour expliquer ce que le code qu’il produit est censé faire, et sur
quelles conventions (usage des registres
%ebx
, %ecx
, ce qu’il y a sur la pile,
comment la pile __exception_handler
des handlers d’exception
courants est gérée et ce qu’elle contient, etc.) Par exemple, le
fichier
exc1.s,
qui est un exemple très simple d’utilisation des exceptions, sans
clause finally
, que voici:
se compile dans l’assembleur suivant, abondamment commenté quant à la gestion des exceptions:
.globl __exception_handler .bss .align 4 .type __exception_handler, @object .size __exception_handler, 4 __exception_handler: .zero 4 .text .globl f .type f,@function f: pushl %ebp movl %esp, %ebp movl $0, %eax pushl %eax movl 12(%ebp), %eax popl %ebx cmpl %ebx, %eax je .f_3 movl $0, %eax jmp .f_4 .f_3: movl $1, %eax .f_4: cmpl $0, %eax je .f_1 movl 8(%ebp), %eax movl %eax, %ecx movl $.f_5, %ebx // On lance l'exception %ebx, avec valeur %ecx. // Depilons __exception_handler. movl __exception_handler, %eax // On doit d'abord verifier que __exception_handler!=NULL. cmpl $0, %eax jne .f_6 // Si __exception_handler==NULL, on est arrive au bout de la pile d'exceptions, // et l'on choisit d'afficher un message informatif et de s'arreter en urgence, // plutot que de laisser le programme se planter salement tout seul. pushl %ebx pushl $.f_7 pushl stderr call fprintf call fflush jmp abort .f_6: // Sinon, on recupere la suite de la pile d'exceptions dans %esi, // et on la met dans __exception_handler. // Il serait plus elegant d'appeler free() sur %esi, aussi (laisse en exercice). movl (%eax), %esi movl %esi, __exception_handler // On recupere l'adresse ou il faudra continuer l'execution dans %esi, movl 4(%eax), %esi // puis le %ebp sauvegarde, movl 12(%eax), %ebp // puis le %esp sauvegarde (ce qui revient a depiler %esp assez brutalement). movl 8(%eax), %esp // Et hop, on saute vers le code qui va traiter l'exception. jmp *%esi jmp .f_2 .f_1: .f_2: movl $1, %eax pushl %eax movl 12(%ebp), %eax popl %ebx cmpl %ebx, %eax je .f_10 movl $0, %eax jmp .f_11 .f_10: movl $1, %eax .f_11: cmpl $0, %eax je .f_8 movl 8(%ebp), %eax movl %eax, %ecx movl $.f_12, %ebx // On lance l'exception %ebx, avec valeur %ecx. // Depilons __exception_handler. movl __exception_handler, %eax // On doit d'abord verifier que __exception_handler!=NULL. cmpl $0, %eax jne .f_13 // Si __exception_handler==NULL, on est arrive au bout de la pile d'exceptions, // et l'on choisit d'afficher un message informatif et de s'arreter en urgence, // plutot que de laisser le programme se planter salement tout seul. pushl %ebx pushl $.f_7 pushl stderr call fprintf call fflush jmp abort .f_13: // Sinon, on recupere la suite de la pile d'exceptions dans %esi, // et on la met dans __exception_handler. // Il serait plus elegant d'appeler free() sur %esi, aussi (laisse en exercice). movl (%eax), %esi movl %esi, __exception_handler // On recupere l'adresse ou il faudra continuer l'execution dans %esi, movl 4(%eax), %esi // puis le %ebp sauvegarde, movl 12(%eax), %ebp // puis le %esp sauvegarde (ce qui revient a depiler %esp assez brutalement). movl 8(%eax), %esp // Et hop, on saute vers le code qui va traiter l'exception. jmp *%esi jmp .f_9 .f_8: .f_9: movl $2, %eax pushl %eax movl 12(%ebp), %eax popl %ebx cltd idivl %ebx movl %edx, %eax cmpl $0, %eax je .f_14 movl $1, %eax pushl %eax movl 12(%ebp), %eax pushl %eax movl $3, %eax popl %ebx imull %ebx, %eax popl %ebx addl %ebx, %eax pushl %eax movl $1, %eax pushl %eax movl 8(%ebp), %eax popl %ebx addl %ebx, %eax pushl %eax call f addl $8, %esp movl %ebp, %esp popl %ebp ret jmp .f_15 .f_14: movl $2, %eax pushl %eax movl 12(%ebp), %eax popl %ebx cltd idivl %ebx pushl %eax movl $1, %eax pushl %eax movl 8(%ebp), %eax popl %ebx addl %ebx, %eax pushl %eax call f addl $8, %esp movl %ebp, %esp popl %ebp ret .f_15: movl %ebp, %esp popl %ebp ret .globl main .type main,@function main: pushl %ebp movl %esp, %ebp subl $4, %esp movl $2, %eax pushl %eax movl 8(%ebp), %eax popl %ebx cmpl %ebx, %eax je .main_20 movl $0, %eax jmp .main_21 .main_20: movl $1, %eax .main_21: cmpl $0, %eax je .main_18 movl $0, %eax jmp .main_19 .main_18: movl $1, %eax .main_19: cmpl $0, %eax je .main_16 movl $.main_22, %eax pushl %eax movl stderr, %eax pushl %eax call fprintf addl $8, %esp movl stderr, %eax pushl %eax call fflush addl $4, %esp movl $10, %eax pushl %eax call exit addl $4, %esp jmp .main_17 .main_16: .main_17: movl $1, %eax pushl %eax movl 12(%ebp), %eax popl %ebx movl (%eax, %ebx, 4), %eax pushl %eax call atoi addl $4, %esp movl %eax, -4(%ebp) // Debut d'une instruction try { ... }. // On va empiler dans la liste __exception_handler suffisamment d'infos // pour pouvoir retrouver nos billes lorsqu'une exception sera lancee par throw. // Pour ceci, on alloue une structure que l'on pourrait ecrire en C: // struct xhandler { struct xhandler *next; char *next_eip, *save_esp, *save_ebp; } // Le champ next sert a retrouver la suite de la liste, // le champ next_eip sera l'adresse du code qui traitera les catch { ... } // et les finally du try { ... } courant, // le champ save_esp est une sauvegarde du pointeur de pile courant, // et save_ebp sauvegarde %ebp. // Cette structure fait 16 octets: on commence par l'allouer. push $16 call malloc addl $4, %esp // %eax->next = __exception_handler; movl __exception_handler, %ebx movl %ebx, (%eax) // exception_handler = %eax; movl %eax, __exception_handler // %eax->next_eip = &.main_23; // c'est la qu'on ira si une exception est lancee! movl $.main_23, %ebx movl %ebx, 4(%eax) // %eax->save_esp = %esp; movl %esp, 8(%eax) // %eax->save_ebp = %ebp; movl %ebp, 12(%eax) // Maintenant que le handler a ete empile, on passe au corps du try { ... }. movl -4(%ebp), %eax pushl %eax movl $0, %eax pushl %eax call f addl $8, %esp movl $.main_27, %eax pushl %eax movl stderr, %eax pushl %eax call fprintf addl $8, %esp // Fin du corps du try { ... }. On doit depiler le handler d'exception, // et traiter le finally { ... } s'il y en a un. Ceci sera fait en .main_24. jmp .main_24 // Ici, on va traiter des catch (...) successifs. // Le nom de l'exception sera dans le registre %ebx, // la valeur de l'exception sera dans %ecx, // et le handler empile par le try { ... } a deja ete depile par le throw responsable du lancement de l'exception. .main_23: // On commence par empiler la valeur %ecx de l'exception. pushl %ecx // Entree de catch(Trouve ...): on compare le nom de l'exception a 'Trouve'. pushl %ebx pushl $.f_12 call strcmp addl $8, %esp // Note: %ebx n'est pas modifie par strcmp(), on dispose donc toujours du nom de l'exception. cmpl $0, %eax jnz .main_28 // Corps du catch (Trouve ...). movl -4(%ebp), %eax pushl %eax movl -8(%ebp), %eax pushl %eax movl $.main_29, %eax pushl %eax movl stderr, %eax pushl %eax call fprintf addl $16, %esp // On depile la valeur de l'exception, et on va traiter le finally { ... } s'il y en a un. addl $4, %esp jmp .main_25 .main_28: // Exception non rattrapee: on la relance. popl %ecx // On lance l'exception %ebx, avec valeur %ecx. // Depilons __exception_handler. movl __exception_handler, %eax // On doit d'abord verifier que __exception_handler!=NULL. cmpl $0, %eax jne .main_30 // Si __exception_handler==NULL, on est arrive au bout de la pile d'exceptions, // et l'on choisit d'afficher un message informatif et de s'arreter en urgence, // plutot que de laisser le programme se planter salement tout seul. pushl %ebx pushl $.f_7 pushl stderr call fprintf call fflush jmp abort .main_30: // Sinon, on recupere la suite de la pile d'exceptions dans %esi, // et on la met dans __exception_handler. // Il serait plus elegant d'appeler free() sur %esi, aussi (laisse en exercice). movl (%eax), %esi movl %esi, __exception_handler // On recupere l'adresse ou il faudra continuer l'execution dans %esi, movl 4(%eax), %esi // puis le %ebp sauvegarde, movl 12(%eax), %ebp // puis le %esp sauvegarde (ce qui revient a depiler %esp assez brutalement). movl 8(%eax), %esp // Et hop, on saute vers le code qui va traiter l'exception. jmp *%esi .main_24: // Aucune exception n'a ete lancee, il faut d'abord // depiler __exception_handler avant de continuer. // Depilons __exception_handler. movl __exception_handler, %eax // On doit d'abord verifier que __exception_handler!=NULL. cmpl $0, %eax jne .main_31 // Si __exception_handler==NULL, on est arrive au bout de la pile d'exceptions, // et l'on choisit d'afficher un message informatif et de s'arreter en urgence, // plutot que de laisser le programme se planter salement tout seul. pushl %ebx pushl $.f_7 pushl stderr call fprintf call fflush jmp abort .main_31: // Sinon, on recupere la suite de la pile d'exceptions dans %esi, // et on la met dans __exception_handler. // Il serait plus elegant d'appeler free() sur %esi, aussi (laisse en exercice). movl (%eax), %esi movl %esi, __exception_handler // Ici, l'exception a ete rattrapee par un catch, et __exception_handler // a ete depile par le throw. On fait le finally et on continue. .main_25: movl stderr, %eax pushl %eax call fflush addl $4, %esp movl $0, %eax movl %ebp, %esp popl %ebp ret addl $4, %esp movl %ebp, %esp popl %ebp ret .main_29: .string "La suite termine apres %d iterations en partant de %d.\n" .align 4 .main_27: .string "Pas trouve...\n" .align 4 .main_22: .string "Usage: ./exc1 <n>\ncalcule a quelle iteration une suite mysterieuse termine, en partant de <n>.\n" .align 4 .f_12: .string "Trouve" .align 4 .f_7: .string "Uncaught exception %s: abort.\n" .align 4 .f_5: .string "Zero" .align 4 |
Dans la distribution mcc
qui vous est
fournie, il ne manque que le fichier
compile.ml
, que vous devrez écrire.
Certains pensent qu’il faut qu’ils lisent les fichiers ML fournis pour
comprendre comment ça marche. En principe, je favorise la curiosité:
si vous pouvez apprendre quelque chose en le faisant, faites-le!
Mais, en l’occurrence, ceci ne vous servira pas à grand-chose. Le
code ML est probablement trop complexe, et utilise probablement des
choses que vous ne connaissez pas (ocamllex
,
ocamlyacc
), pour que vous puissiez en tirer
vraiment profit.
Lire les fichiers ML fournis est donc, en fait, essentiellement une perte de temps dans le cadre du projet.
En revanche, il peut être utile de comprendre quel genre de syntaxe
abstraite est produite par mcc
à partir d’un
fichier source C-- donné. Je rappelle que cette syntaxe abstraite
est de type Cparse.var_declaration list
, le
type du deuxième argument de la fonction
compile
que vous devez écrire.
Une façon de faire est de réserver une variable globale Caml, disons
ast
(pour “abstract syntax tree”), de
stocker l’arbre de syntaxe fourni à compile
dedans, et de demander à afficher le contenu de
ast
après exécution, ce qui se fait bien sous
le toplevel.
Pour utiliser les fonctions du compilateur
mcc
sous le toplevel Caml, lancer
ocaml
, et taper:
#use "top.ml";;
Le fichier top.ml
est en gros le compilateur
mcc
en réduction. Si l’on écrit un fichier
compile.ml
bidon:
puis que l’on tape make
pour recompiler le
tout, enfin que l’on tape #use "top.ml";;
sous le toplevel Caml (ce qui charge tous les fichiers compilés, y
compris compile.ml
), on obtiendra une
fonction teste
de type string -> unit
.
Donnons-lui à traiter le fichier cat.c
, par
exemple. Contrairement au compilateur final
mcc
, le programme que nous venons de charger
sous le toplevel Caml ne sait pas appeler le préprocesseur C tout
seul. Appelons-le donc pour fabriquer le fichier préprocessé
cat1.c
, en tapant la ligne suivante sous le
shell (note: le faire dans une autre fenêtre, par exemple):
cpp -DMCC cat.c >cat1.c
Sous le toplevel Caml avec top.ml
chargé, on
écrit:
teste "cat1.c";;
puis l’on demande la valeur de la variable
ast
, en tapant:
ast;;
ce qui donne en l’occurrence:
En général, le fichier top.ml
permet de
tester son compilateur sous le toplevel Caml. Mais l’insertion de
Printf.printf
judicieux dans le code du
compilateur peut s’avérer être une technique plus efficace de
debugging…