Projet de programmation 1: compilateur de C-- avec exceptions

Jean Goubault-Larrecq
LSV/UMR 8643, CNRS, ENS Cachan & INRIA Futurs projet SECSI
61 avenue du président-Wilson, F-94235 Cachan Cedex
goubault@lsv.ens-cachan.fr
Phone: +33-1 47 40 75 68   Fax: +33-1 47 40 75 21

1  Introduction

Le cours de programmation s’étend tout au long de la 1ère année à Cachan. Vous aurez à y faire plusieurs projets. Le tout premier d’entre eux commence le lundi 16 octobre 2006, et est à rendre le vendredi 24 novembre 2006.

Il s’agit d’écrire un compilateur pour un langage ressemblant à C, mais suffisamment réaliste. Il s’agit essentiellement d’un sous-ensemble strict du langage C, mais avec un mécanisme d’exceptions (throw, trycatchfinally) ressemblant à celui de Java. Nous appellerons ce langage C--. Le langage cible sera l’assembleur x86. Votre compilateur sera écrit en OCaml.

Voici un exemple de programme que votre compilateur devrait compiler:

Ce programme, qui a été un de nos exemples lors des premiers cours code la commande Unix cat.

Au passage, j’ai évidemment fait le projet avant vous. Voici ce que mon compilateur produit:

.globl __exception_handler .bss .align 4 .type __exception_handler, @object .size __exception_handler, 4 __exception_handler: .zero 4 .text .globl main .type main,@function main: pushl %ebp movl %esp, %ebp subl $8, %esp movl $1, %eax movl %eax, -8(%ebp) jmp .main_1 .main_2: subl $4, %esp movl $.main_3, %eax pushl %eax movl -8(%ebp), %eax pushl %eax movl 12(%ebp), %eax popl %ebx movl (%eax, %ebx, 4), %eax pushl %eax call fopen addl $8, %esp movl %eax, -12(%ebp) jmp .main_4 .main_5: movl stdout, %eax pushl %eax movl -4(%ebp), %eax pushl %eax call fputc addl $8, %esp .main_4: movl $1, %eax negl %eax pushl %eax movl -12(%ebp), %eax pushl %eax call fgetc addl $4, %esp movl %eax, -4(%ebp) popl %ebx cmpl %ebx, %eax je .main_8 movl $0, %eax jmp .main_9 .main_8: movl $1, %eax .main_9: cmpl $0, %eax je .main_6 movl $0, %eax jmp .main_7 .main_6: movl $1, %eax .main_7: cmpl $0, %eax jnz .main_5 movl -12(%ebp), %eax pushl %eax call fclose addl $4, %esp addl $4, %esp movl -8(%ebp), %eax addl $1, -8(%ebp) .main_1: movl 8(%ebp), %eax pushl %eax movl -8(%ebp), %eax popl %ebx cmpl %ebx, %eax jl .main_10 movl $0, %eax jmp .main_11 .main_10: movl $1, %eax .main_11: cmpl $0, %eax jnz .main_2 movl stdout, %eax pushl %eax call fflush addl $4, %esp movl $0, %eax pushl %eax call exit addl $4, %esp addl $8, %esp movl %ebp, %esp popl %ebp ret .main_3: .string "r" .align 4

Vous avez déjà eu une bonne dose d’assembleur, et vous devriez donc être capables, avec un peu d’effort, de comprendre globalement ce que ça fait (surtout sachant que ceci vient du source C montré plus haut).

2  Comment ça se passe

D’abord, je vous recommande de travailler sous Linux. N’importe quelle version devrait faire l’affaire. Toutes les versions de Caml ou OCaml ≥ 2.04 devraient aussi faire l’affaire. Si vous voulez travailler sur un autre système d’exploitation (Windows par exemple), c’est votre choix. Sachez cependant que, même avec Cygwin sous Windows, vous vous exposez à des complications. Allez-y si vous avez une âme de hacker.

Ensuite, vous devez récupérer l’archive du projet. Décompressez-là avec gunzip et désarchivez le tout avec tar. Vous pouvez faire le tout en une seule étape en tapant tar -xvzf projet.tgz.

Vous devriez obtenir un répertoire MiniC et les fichiers suivants:

drwxr-xr-x goubault/lsv 0 2006-10-16 19:20:49 ProjetMiniC2/ -rw-r--r-- goubault/lsv 63297 2006-10-16 19:20:49 ProjetMiniC2/ctab.ml -rw-r--r-- goubault/lsv 13163 2006-10-16 19:20:49 ProjetMiniC2/clex.mll -rw-r--r-- goubault/lsv 2752 2006-10-16 19:20:49 ProjetMiniC2/error.ml -rw-r--r-- goubault/lsv 2078 2006-10-16 19:20:49 ProjetMiniC2/Makefile drwxr-xr-x goubault/lsv 0 2006-10-16 19:20:49 ProjetMiniC2/Exemples/ -rwxr-xr-x goubault/lsv 7593 2006-10-16 19:20:49 ProjetMiniC2/Exemples/exc1 -rw-r--r-- goubault/lsv 839 2006-10-16 19:20:49 ProjetMiniC2/Exemples/exc2.c -rw-r--r-- goubault/lsv 173 2006-10-16 19:20:49 ProjetMiniC2/Exemples/ordre.c -rw-r--r-- goubault/lsv 1454 2006-10-16 19:20:49 ProjetMiniC2/Exemples/cat.s -rw-r--r-- goubault/lsv 9391 2006-10-16 19:20:49 ProjetMiniC2/Exemples/exc1.s -rw-r--r-- goubault/lsv 459 2006-10-16 19:20:49 ProjetMiniC2/Exemples/cat.c -rw-r--r-- goubault/lsv 2554 2006-10-16 19:20:49 ProjetMiniC2/Exemples/fact.s -rwxr-xr-x goubault/lsv 8077 2006-10-16 19:20:49 ProjetMiniC2/Exemples/exc3 -rw-r--r-- goubault/lsv 10504 2006-10-16 19:20:49 ProjetMiniC2/Exemples/exc2.s -rw-r--r-- goubault/lsv 2064 2006-10-16 19:20:49 ProjetMiniC2/Exemples/sieve.c -rwxr-xr-x goubault/lsv 7935 2006-10-16 19:20:49 ProjetMiniC2/Exemples/exc2 -rw-r--r-- goubault/lsv 7388 2006-10-16 19:20:49 ProjetMiniC2/Exemples/sieve.s -rw-r--r-- goubault/lsv 917 2006-10-16 19:20:49 ProjetMiniC2/Exemples/exc3.c -rw-r--r-- goubault/lsv 739 2006-10-16 19:20:49 ProjetMiniC2/Exemples/fact.c -rw-r--r-- goubault/lsv 727 2006-10-16 19:20:49 ProjetMiniC2/Exemples/ordre.s -rw-r--r-- goubault/lsv 725 2006-10-16 19:20:49 ProjetMiniC2/Exemples/exc1.c -rw-r--r-- goubault/lsv 11072 2006-10-16 19:20:49 ProjetMiniC2/Exemples/exc3.s -rw-r--r-- goubault/lsv 246214 2006-10-16 19:20:49 ProjetMiniC2/clex.ml -rw-r--r-- goubault/lsv 1041 2006-10-16 19:20:49 ProjetMiniC2/genlab.ml -rw-r--r-- goubault/lsv 1254 2006-10-16 19:20:49 ProjetMiniC2/ctab.mli -rw-r--r-- goubault/lsv 14023 2006-10-16 19:20:49 ProjetMiniC2/ctab.mly -rw-r--r-- goubault/lsv 1859 2006-10-16 19:20:49 ProjetMiniC2/top.ml -rw-r--r-- goubault/lsv 2198 2006-10-16 19:20:49 ProjetMiniC2/main.ml -rw-r--r-- goubault/lsv 936 2006-10-16 19:20:49 ProjetMiniC2/verbose.ml -rw-r--r-- goubault/lsv 9464 2006-10-16 19:20:49 ProjetMiniC2/cparse.ml -rw-r--r-- goubault/lsv 643 2006-10-16 19:20:49 ProjetMiniC2/depend -rw-r--r-- goubault/lsv 981 2006-10-16 19:20:49 ProjetMiniC2/compile.mli

Votre travail consiste à écrire le fichier manquant compile.ml. Vous pouvez y écrire ce que vous voulez, l’important est de définir la fonction dont le type est spécifié dans le fichier d’interface compile.mli:

val compile : out_channel -> Cparse.var_declaration list -> unit;;

Cette fonction prend un canal de sortie en premier argument, appelons-le out, et un programme C--, appelons-le π, et imprime sur out un texte en assembleur x86 qui code le programme π. Ce texte en assembleur ressemblera donc au texte de l’introduction (voir le fichier cat.s).

Lorsque vous aurez écrit le fichier compile.mli, compilez le tout en invoquant make. Comme Emmanuel et moi vous l’avons expliqué, ceci va appliquer un certain nombre de commandes dans un ordre défini par les dépendances exprimées dans le fichier Makefile.

Le résultat sera un programme, qui s’appellera mcc et sera dans le répertoire ProjetMiniC2. Vous pourrez tester ce programme en tapant ./mcc suivi du nom d’un fichier C-- à compiler.

Par exemple, si vous tapez (en partant du répertoire ProjetMiniC2):

cd Exemples ../mcc cat.c

Ceci devrait produire un fichier assembleur cat.s, que vous pourrez consulter, et un fichier exécutable cat, que vous pourrez tester en tapant (sous le répertoire ProjetMiniC2/Exemples):

./cat cat.s

Il est évidemment plus que probable que le code assembleur que vous produisiez, au moins au début, ne soit pas de la meilleure qualité. Notamment, mcc appelle le compilateur gcc pour compiler le code assembleur que vous aurez produit vers un vrai exécutable. Si votre code assembleur n’est pas syntaxiquement correct, ceci échouera. Il se peut aussi que vous obteniez un exécutable, mais qu’il plante (message Segmentation fault, typiquement).

Dans tous les cas, vous pouvez:

3  Quelques règles du jeu

Vous pouvez allez regarder les fichiers que vous voulez. Le seul fichier que vous n’aurez pas est celui que vous avez à écrire, compile.ml.

Vous n’avez le droit de modifier aucun des fichiers de l’archive du projet. En fait, vous devrez vous limiter à écrire vos fonctions dans le fichier compile.ml. L’idée, c’est que si je prends votre fichier compile.ml et je l’ajoute à l’archive, que je tape make, j’obtiens un compilateur fonctionnel pour C--.

Une autre façon de le dire: vous pouvez aller bidouiller les autres fichiers du projet si ça vous chante, par exemple, pour ajouter des appels à Printf.printf, et voir comment ça marche, mais vous ne devez pas faire des modifications dans ces fichiers qui soit essentielles à la bonne marche de votre compilateur.

4  L’architecture de mcc

D’abord, quand on exécute mcc, il va commencer par exécuter le code qui se trouve dans le fichier main.ml.

Celui-ci lit les options de commande (-S par exemple), puis appelle l’analyseur syntaxique Ctab.translation_unit, auquel on passe l’analyseur lexical Clex.ctoken.

Le source de l’analyseur syntaxique, qui servira aussi de documentation pour la grammaire du langage C--, est dans le fichier ctab.mly. La description des tokens lexicaux est dans clex.mll.

L’analyseur syntaxique fournit un programme sous forme d’une liste de Cparse.var_declaration. Les d’une liste de var_declaration sont déclarées au début du fichier cparse.ml, comme suit:

type mon_op = M_MINUS | M_NOT | M_POST_INC | M_POST_DEC | M_PRE_INC | M_PRE_DEC (* Les operations unaires: M_MINUS: calcule l'oppose -e de e; M_NOT: calcule la negation logique ~e de e; M_POST_INC: post-incrementation e++; M_POST_DEC: post-decrementation e--; M_PRE_INC: pre-incrementation ++e; M_PRE_DEC: pre-decrementation --e. *) type bin_op = S_MUL | S_DIV | S_MOD | S_ADD | S_SUB | S_INDEX (* Les operations binaires: S_MUL: multiplication entiere; S_DIV: division entiere (quotient); S_MOD: division entiere (reste); S_ADD: addition entiere; S_SUB: soustraction entiere; S_INDEX: acces \`a un element de tableau a[i]. *) type cmp_op = C_LT | C_LE | C_EQ (* Les operations de comparaison: C_LT (less than): <; C_LE (less than or equal to): <=; C_EQ (equal): ==. *) type loc_expr = locator * expr and expr = VAR of string (* une variable --- toujours de type int. *) | CST of int (* une constante entiere. *) | STRING of string (* une constante chaine. *) | SET_VAR of string * loc_expr (* affectation x=e. *) | SET_ARRAY of string * loc_expr * loc_expr (* affectation x[e]=e'. *) | CALL of string * loc_expr list (* appel de fonction f(e1,...,en) *) (* operations arithmetiques: *) | OP1 of mon_op * loc_expr (* OP1(mop, e) denote -e, ~e, e++, e--, ++e, ou --e. *) | OP2 of bin_op * loc_expr * loc_expr (* OP2(bop,e,e') denote e*e', e/e', e%e', e+e', e-e', ou e[e']. *) | CMP of cmp_op * loc_expr * loc_expr (* CMP(cop,e,e') vaut e<e', e<=e', ou e==e' *) | EIF of loc_expr * loc_expr * loc_expr (* EIF(e1,e2,e3) est e1?e2:e3 *) | ESEQ of loc_expr list (* e1, ..., en [sequence, analogue a e1;e2 au niveau code]; si n=0, represente skip. *) type var_declaration = CDECL of locator * string (* declaration de variable de type int. *) | CFUN of locator * string * var_declaration list * loc_code (* fonction avec ses arguments, et son code. *) and loc_code = locator * code and code = CBLOCK of var_declaration list * loc_code list (* { declarations; code; } *) | CEXPR of loc_expr (* une expression e; vue comme instruction. *) | CIF of loc_expr * loc_code * loc_code (* if (e) c1; else c2; *) | CWHILE of loc_expr * loc_code (* while (e) c1; *) | CRETURN of loc_expr option (* return; ou return (e); *) | CTHROW of string * loc_expr (* throw nom_exception (e); *) | CTRY of (* try code [catch (nom_exception var) code]* [finally code]? *) loc_code * (* le code juste apr\`es "try" *) (string * string * loc_code) list * (* la liste des (nom_exception, var, code) pour chaque clause catch, dans l'ordre. *) loc_code option (* la clause finally, si presente. *)

Le type locator est juste un pointeur sur des informations permettant aux fonctions Error.error et Error.warning d’afficher des message d’erreur sensés.

Le fichier cparse.ml contient aussi des fonctions utiles, comme string_of_loc_expr, qui vous permettent d’obtenir une version imprimable d’une expression C--, ce que vous pouvez utiliser en phase de débogage.

Le fichier genlab.ml contient une fonction genlab qui vous permettra de fabriquer des labels uniques, ce qui devrait être bien pratique lorsque vous émettrez des instructions de saut dans votre code assembleur.

Le fichier verbose.ml déclare une variable verbose que vous pourrez tester pour régler le niveau d’informations de débogage que vous voudrez afficher (elle vaut 1 si vous appelez mcc avec l’option -v1, 2 si avec l’option -v2, et 0 par défaut).

5  Le langage C--

C-- est, si l’on excepte la notion d’exception, un sous-ensemble extrêmement réduit du langage C. Ont été supprimés de C: tous les types struct, union, enum, ainsi que les tableaux, et la plupart des types scalaires. En fait, il ne reste que les types int, char *, et les pointeurs sur les types C-- (récursivement).

L’avantage est que tous ces types tiennent sur 32 bits. Vous n’aurez donc pas à vous casser la tête pour savoir combien de place allouer à chaque variable: ce sera toujours 4 octets.

Ensuite, de nombreuses constructions de C ont été supprimées, mais pas suffisamment pour ne pas pouvoir compiler des programmes intéressants.

En principe, C-- est un sous-ensemble de C, ce qui signifie que vous pourrez tester mcc en comparant les programmes qu’il produit avec ceux produits par gcc.

Il n’y a que deux exceptions à ce principe. La première est l’ajout à C--, par rapport à C, des exceptions, c’est-à-dire des constructions throw et trycatchfinally. La seconde est l’arithmétique sur les pointeurs, qui a une sémantique différente en C-- et en C. Si vous déclarez int *p et écrivez p++ en C, ceci ajoute sizeof(int), soit 4, à l’adresse p. En C–, ça n’est censé qu’ajouter un. Je doute que vous ayez à considérer ce genre de subtilités, mais qui sait.

6  La sémantique opérationnelle à grands pas de C--

Définissons, petit à petit, la sémantique de C--, hors exceptions. Les exceptions seront décrites en section 7.

Soit Z32 l’ensemble de tous les entiers signés 32 bits: Z32 = [−231, 231−1]. Soit Addr le sous-ensemble de toutes les adresses.

Nous allons présenter la sémantique de C-- en deux étapes. En section 6.1, nous présenterons une sémantique pour un sous-ensemble extrêmement réduit de C--. L’avantage est que cette sémantique sera très simple. Certaines particularités de C-- nous forceront à décrire une sémantique plus compliquée en section 6.2.

6.1  Une sémantique simple pour un sous-ensemble de C--

Considérons un langage où les expressions sont juste celles de la forme:

  e::=xvariable 
 |e  plus  eaddition 
 |call  f (e, …, e)appel de fonction

et où les instructions sont de la forme:

  c::=x=eaffectation 
 |skipne rien faire 
 |c;cséquence 
 |if  (e)  then  c  else  ctest 
 |while  (e)  cboucle 

Pour pouvoir donner une sémantique aux appels de fonction (call), il nous faut nous donner la description d’un programme π. Pour nous, un programme π sera une fonction mathématique, de domaine fini, qui à des noms de fonction f associe un triplet (l, c, e), où l est une liste de variables distinctes deux à deux (les paramètres formels de la fonction f), c est une instruction (le corps de la fonction), et e est une expression (dénotant la valeur retournée par la fonction).

Les expressions et les instructions seront évaluées dans un environnement ρ, qui est une fonction de domaine fini associant une valeur dans Z32 à des variables.

Définissons des règles permettant de déduire des jugements de la forme

signifiant “dans l’environnement ρ, l’expression e a pour valeur V”.

On a d’abord une règle pour la variable:

      (1)

Cette règle s’applique dès que x est dans le domaine de ρ. Elle n’a aucune (autre) prémisse.

On a ensuite une règle pour l’addition:

      (2)

(La raison pour laquelle nous écrivons l’addition e1 plus  e2 plutôt que e1+e2 par exemple est pour bien distinguer l’opération syntaxique plus de l’opération mathématique d’addition, notée +.)

Nous traiterons de l’appel de fonctions plus loin.

On peut maintenant définir des jugements de la forme

pour chaque instruction c, signifiant “en partant de l’environnement ρ, l’instruction c termine sur un nouvel environnement ρ′, le programme courant étant π”. De nouveau, les jugements dérivables sont définis par des règles de déduction.

La première est pour l’affectation:

      (3)

La notation

désigne la fonction de domaine dom  ρ union {x}, qui à x associe V et à tout y in dom  ρ ∖ {x} associe ρ (y).

L’instruction skip ne fait strictement rien de remarquable:

      (4)

La séquence évalue ses arguments… en séquence:

      (5)

Elle introduit donc un environnement intermédiaire ρ′ représentant l’environnement après l’exécution de e1 et avant l’exécution de e2.

Jusqu’ici, nous avions une règle de déduction par construction syntaxique. Ceci change pour le test. Nous avons une règle lorsque la condition testée est fausse, et une lorsqu’elle est vraie:

      (6)

Comme en C, 0 est faux, et tout autre nombre est considéré comme vrai.

      (7)

La boucle while  (e)  c est équivalente à if  (ethen  c;while  (e)  c  else skip. Ceci donne lieu, ici aussi, à deux règles pour l’évaluation des boucles.

      (8)
      (9)

La seconde règle a la particularité que sa seconde prémisse ne porte pas sur une instruction plus petite que celle en conclusion, contrairement aux règles présentées jusqu’ici. Ceci mène à des comportements non terminants. Disons que c termine dans π, à partir de ρ, si et seulement s’il existe ρ′ tel que soit déductible.

Exercice 1   Si ρ (x) n’est pas défini, while (xskip termine-t-il?
Exercice 2   Montrer que while (xskip termine à partir de ρ si et seulement si x in dom ρ et ρ (x) = 0.

L’appel de fonction fait aussi ce qu’on attend de lui:

      (10)

est l’environnement qui à x1 associe V1, …, à xn associe Vn.

Exercice 3   Que se passe-t-il si on appelle une fonction f sur le mauvais nombre d’arguments, i.e., si on cherche à évaluer call  f (e1, …, en) alors que π (f) = ((x1, …, xm), c) et mn? Que se passe-t-il si f n’est pas dans dom  π?
Exercice 4   Montrer que cette sémantique est déterministe: si et sont déductibles, alors ρ′ = ρ″. Dites précisément sur quoi vous effectuez une récurrence, et décrivez quelques-uns des cas que vous traitez, notamment ceux utilisés lorsque c est une boucle while.
Exercice 5   En Caml, on n’a pas besoin de la construction while. Au lieu d’écrire:
while e do c;;
on peut en effet écrire
let rec f () = 
        if e
           then begin
                  c;
                  f ()
                end
        else ()
in f ();;
f est un nom frais (non utilisé dans e ou c). Peut-on recoder les boucles while par un codage similaire dans le langage de cette section? Et en C? Et en C--?

Il manque à cette sémantique, notamment, l’allocation mémoire et les pointeurs, les variables globales, la possibilité d’effectuer des effets de bord à l’intérieur des expressions. Ceci sera réparé dans la sémantique de C--, que nous décrivons tout de suite.

De plus, la sémantique de l’appel de fonctions est en réalité un peu bricolée, autrement dit pas très élégante. On verra à la section 7.2 qu’un style de sémantique dit par continuations est plus élégant, tout en étant plus proche du code assembleur que l’on produit classiquement.

6.2  La sémantique de C--

Un programme π est pour nous un couple (ρπ, funπ):

On supposera pour simplifier que funπ contient aussi des entrées pour toutes les fonctions de la libc, comme printf, strcpy, malloc, free, etc. Et que, de même, ρπ contient aussi des entrées pour toutes les variables globales de la libc, comme errno par exemple.

Exercice 6   La définition de π permet-elle d’allouer deux variables à la même adresse? Ceci est-il souhaitable? Si oui, pourquoi? Si non, comment réparer la définition?
Exercice 7   La définition de funπ n’autorise pas à avoir deux fonctions de même nom. Mais il peut y avoir deux CFUN (loc, f, params, code) avec le même f fourni à la fonction compile. Comment traduisez-vous ces dernières var_declaration en entrées pour funπ, pour tenir compte de cette différence conceptuelle?

On définit une série de jugements de sémantique opérationnelle de la forme

qui dit qu’en partant d’une mémoire , et d’un environnement qui dit à quelles adresses sont stockées les variables, l’expression C-- e peut s’évaluer en la valeur v in Z32, et modifier la mémoire en µ′.

Il y a deux sortes de variables. Les variables locales (ou automatiques dans le jargon du C) sont données dans l’environnement ρ:

      (11)

Les variables globales sont décrites par l’environnement global ρπ.

      (12)
Exercice 8   Quelle règle s’applique-t-elle dans le cas d’une variable locale (x in dom  ρ) qui a le même nom qu’une variable globale (x in dom  ρπ)?
Exercice 9   Que se passe-t-il si x est une variable globale pour laquelle aucune mémoire n’est allouée (ρπ(x) n’est pas dans dom  µ?)
      (13)

L’évaluation des chaînes est un peu plus étrange. Elle retourne une adresse mémoire quelconque où se trouve déjà la représentation de s. Disons que s est stockée à l’adresse a si et seulement si s ne contient pas l’octet 0, il existe une adresse a+k dans la mémoire µ contenant un octet nul, avec k minimal, et la suite de tous les octets aux adresses a, a+1, a+2, …, a+k−1 vaut s.

      (14)

En pratique, le compilateur devra allouer de la place pour la chaîne s à la compilation. Ceci est fait pratiquement automatiquement par l’assembleur. Par exemple,

.main_3: .string "r" .align 4

réserve de la place pour les octets ‘r’ et 0, les place à une adresse nommée .main_3. L’instruction mystérieuse .align 4 sert à assurer que ce qui suit commencera à une adresse multiple de 4, ce qui facilite la tâche du processeur pour certaines opérations.

Exercice 10   La sémantique ci-dessus n’oblige pas à stocker toute chaîne présente dans le programme. Que se passe-t-il si s n’est stockée nulle part?

Pour toute mémoire µ, pour toute adresse a in Addr, pour toute valeur v in Z32, notons la mémoire qui à a associe v, et à toute autre adresse a′ ≠ a associe µ (a′).

On a encore deux règles pour SET_VAR, selon que la variable est locale ou globale.

      (15)
      (16)
Exercice 11   Que se passe-t-il si on écrit x=1 en C-- mais que x n’est pas dans dom  ρ? … ni dans dom  ρ ni dans dom  ρπ? Ceci arrive lorsque x n’aura pas été déclarée, typiquement par int x. Comment réagit mcc dans ce cas? Comment réagit gcc dans ce cas?
Exercice 12   Pourquoi demande-t-on ρ (x) in dom  µ′, resp. ρπ(x) in dom  µ′ dans ces règles? Autrement dit, quel genre de comportement étrange l’omission de ces conditions autoriserait-il?

Passons aux instructions de la forme x[i]=e. On comprend l’addition a+v comme étant effectuée modulo 232.

      (17)
      (18)
Exercice 13   Dans quel ordre cette règle évalue-t-elle e et e? Que spécifie la norme ANSI C?
Exercice 14   Pourquoi demandons-nous que v in Z32 et a+v in Addr dans ces règles?

Disons qu’une mémoire µ′ étend µ, ce que nous noterons , si et seulement si et µ′|dom  µ = µ. On note la fonction qui à chaque xi associe ai, 1≤ in. Ceci est bien défini lorsque les xi sont distinctes deux à deux.

      (19)
Exercice 15   Dans quel ordre sont évalués les arguments d’un appel de fonction CALL comme ci-dessus? Que dit la norme ANSI C à ce sujet?
Exercice 16   Pourquoi demande-t-on , …, , dans (19)? Pour répondre à cette question, imaginez que µ′nn, …, µ′22 dans (19), et demandez-vous si votre compilateur produit effectivement du code correspondant à la spécification.
Exercice 17   Pourquoi, sur le même principe, ne demanderions-nous pas des mémoires auxiliaires qui étendent µ′, plutôt que de réutiliser directement µ′ dans l’évaluation de e, dans les règles (17) et (18)?
Exercice 18   Que se passe-t-il si la fonction f n’est pas définie? Si f est définie avec un nombre d’arguments différent de n? Si la liste des paramètres formels de f n’est pas de la forme CDECL (l1, x1), …, CDECL (ln, xn) mais contient un élément de la forme CFUN (…)?
Exercice 19   Pourquoi demande-t-on , …, dans les prémisses de (19)?
Exercice 20   Rien n’empêche les variables x1, …, xn d’être non distinctes. Si c’est le cas, la notation n’a aucun sens. Réparez la règle (19) pour tenir compte de ce fait.

Passons aux opérations unaires. La négation effectue son calcul modulo 232.

      (20)
Exercice 21   Dans quel cas le modulo 232 est-il nécessaire?
      (21)
Exercice 22   On aurait pu s’attendre que la négation bit à bit soit décrite en termes des bits de v. Montrer que la définition ci-dessus est équivalente, autrement dit que v−1 est bit la négation bit à bit de v.
      (22)
      (23)
Exercice 23   Que se passe-t-il si on applique M_POST_INC à autre chose qu’une variable?
Exercice 24   Écrire les règles sémantiques pour M_PRE_INC, M_POST_DEC, M_PRE_DEC.
      (24)
Exercice 25   Pourquoi ?
Exercice 26   Dans quel ordre sont évalués les arguments?
Exercice 27   Écrire les règles sémantiques pour S_DIV, S_MOD, S_ADD, S_SUB.
      (25)
Exercice 28   Écrire le reste de la sémantique!

7  Quelle est la sémantique des exceptions?

Donnons d’abord une description intuitive de ce que font les instructions throw et trycatchfinally.

D’abord, try c évalue la commande c, dans la portée des handlers d’exception décrits par les clauses catch et la clause finally éventuelle. Si, à l’intérieur de cette portée, une exception est lancée, par une instruction throw exc (val), l’exécution du code se poursuit dans le handler correspondant: soit le code qui suit une des clauses catch (exc x) suivant le bloc c (avec le même nom d’exception exc; on dit que l’exception est rattrapée par cette clause), en stockant la valeur val de l’exception dans la variable locale x, soit une des clauses venant d’un handler d’exception antérieur.

À noter que si C--, comme C, est un langage à liaison lexicale, c’est-à-dire que la valeur de toute variable est déterminée par l’endroit, textuel, où on l’utilise, les portées des handlers d’exception est dynamique. Il est impossible de savoir vers quel handler d’exception (quelle clause catch, quel(s) code(s) de clause finally) l’exécution va se brancher lorsqu’une instruction throw exc (val) est exécutée. Ceci est déterminé par la liste active à ce moment-là de l’exécution du programme. C’est bien entendu voulu.

S’il existe une clause finally c′, alors le code c′ sera exécuté chaque fois que l’on sort de la portée du handler d’exception. C’est le cas lorsque c ne lance aucune exception (on dit que c termine normalement), lorsque c lance une exception rattrapée, ou bien lorsque c lance une exception non rattrapée par le handler courant. Le flot d’exécution du programme n’est pas modifié (sauf si c′ lance lui-même une exception!): si l’on passe par le bloc finally c′ à cause d’une exception non rattrapée, cette exception sera relancée à la fin de l’exécution de c′; aucune exception n’est relancée dans les autres cas. Pour mieux comprendre, vous pouvez exécuter les programmes exc1, exc2, exc3 dans le répertoire d’exemples, avec différents arguments en entrée (essayez 0, 1, 2, −1 notamment).

Il existe dans la littérature essentiellement deux styles de sémantiques pour décrire l’effet des exceptions.

Le premier consiste à faire comme si une exception lancée était juste une autre forme de résultat. Partant d’un environnement ρ, une commande peut donc s’évaluer soit en un environnement ρ′ après terminaison normale, soit en un paquet d’exception, à savoir un couple formé d’un nom et d’une valeur d’exception, qui a été lancée mais (pas encore) rattrapée. On développe une telle sémantique, dite directe, en section 7.1. Ceci a l’avantage de la simplicité (apparente). C’est le style employé par exemple dans la sémantique de Standard ML, par Harper, Tofte, et Milner.

Le second style consiste à évaluer toute commande c non seulement relativement à un environnement ρ avant son exécution, mais aussi relativement au code κ qu’il faudra exécuter une fois l’exécution de c terminée. Le code κ est la continuation. Un tel style a été inventée dans les années 1960, précisément pour donner une sémantique aux exceptions, aux goto, et autres joyeusetés du même style, dans le cadre de la sémantique du langage Algol 60. Cette sémantique, décrite en section 7.2, et qui part d’une idée a priori plus exotique, a l’avantage d’être plus souple, au sens où ce style permet de décrire uniformément plusieurs constructions de flôt de contrôle. De plus, si l’on identifie κ à l’adresse du code compilé qu’il faudra exécuter une fois c exécuté, on pourra observer que ce style de sémantique est en fait plus proche de ce que l’on implémente in fine en assembleur.

Au lieu de traiter de tout le langage C--, par souci de simplicité et de lisibilité, on traitera d’un langage réduit qui ne dispose que des constructions d’exception, de la séquence, et de l’appel de procédure. On laissera en exercice au lecteur le soin d’enrichir la sémantique des autres constructions du langage, en s’inspirant par exemple de la section 6. On ne considérera d’autre part aucun effet de bord, ce qui nous permettra de nous passer de la mémoire, et de revenir à une sémantique à environnement, proche de celle de la section 6.1.

Voici donc la syntaxe des commandes que nous considérons. On suppose donnée une grammaire d’expressions fixée, aucune n’ayant d’effet de bord (x+y OK, x=e non, call f (x) non plus). On peut toujours s’y ramener, par exemple en remplaçant l’expression call f (x) + y par z+y, juste après un appel de fonction z=call f (x), où z est une variable fraîche. Une autre différence est que les retours de fonction return x peuvent être situés au milieu du corps des fonctions. Exc parcourt l’ensemble des noms d’exceptions — en C--, ce sont juste des chaînes de caractères.

  c::=x=eaffectation simple 
 |x=call  f (x1, …, xn)appel de fonction 
 |c;cséquence 
 |return xretour de fonction 
 |throw Exc (x)lancement d’exception 
 |try c  [catch (Exci xici]1≤ i≤ n finally chandler d’exception

Ici, un programme π sera une fonction de domaine fini qui à des noms de fonction f associe un couple (l, c), où l est une liste de variables distinctes deux à deux (les paramètres formels de la fonction f), et c est une instruction (le corps de la fonction). On n’aura plus besoin de la troisième composante e, puisque return est maintenant une instruction comme les autres.

7.1  Sémantique des exceptions en style direct

Un paquet d’exception est un triplet (ρ, exc, V) formé d’un environnement ρ, d’un nom exc d’exception et d’une valeur V. Un paquet de retour est un triplet (ρ, *, V), où ρ est un environnement, * est un symbole différent de tous les noms d’exception, et V est une valeur. (Ceci sert à représenter l’effet d’une instruction return, qui en un sens est un lancement d’exception.) Un paquet est soit un paquet d’exception soit un paquet de retour. Un état st est soit un environnement ρ, soit un paquet.

L’affectation simple réussit toujours:

où ici comme dans la suite, ρ désigne n’importe quel environnement (mais pas un paquet!). C’est juste la règle (3), inchangée. Contrairement à ce qui se passait en section 6, ce ne sera pas la seule règle qui s’applique sur une affectation (l’autre est (29), plus bas).

Passons directement aux return et aux throw. Ces instructions retournent directement le paquet correspondant.

      (26)
      (27)

Pour la séquence, on aimerait écrire quelque chose de très similaire à (5):

      (28)

La différence est qu’ici, les états intermédiaire st′ et final st″ ne sont pas garantis être des environnements, comme dans (5). Ils pourraient être des paquets, signifiant qu’une exception a été lancée lors de l’exécution de c1 ou de c2. Cependant, la règle ne s’applique que si l’on évalue c1;c2 en partant d’un environnement ρ, pas d’un paquet.

Lorsque l’on cherche à évaluer une commande c en partant d’un paquet, pas d’un environnement, c’est-à-dire que l’on a auparavant lancé une exception, ou déclenché une instruction return, alors c ne sera jamais exécutée. Par exemple, return  yc n’exécutera jamais c. On a besoin pour ceci d’une règle exprimant qu’un paquet passe inchangé à travers toute commande:

      (29)

La notation E ci-dessus dénote soit un nom d’exception Exc, soit *.

On s’attend alors que le corps d’une fonction appelée retourne soit un paquet de retour (ρ, *, V), dénotant un retour normal, soit un paquet d’exception (ρ, Exc, V). Le premier cas est traité par la règle:

      (30)

À noter que l’environnement ρ′ présent dans le paquet de retour (en prémisse) est tout simplement jeté: on n’en voit plus trace dans la conclusion de la règle ci-dessus. C’est parce que ρ′ représente les valeurs des variables locales à la fonction appelée f, qui n’ont plus lieu d’être considérées lors du retour à l’appelant.

Le second cas est traité par la deuxième règle:

      (31)

De même, ici, ρ′ est jeté, et l’on forme un nouveau paquet d’exception avec l’environnement ρ de l’appelant.

Le cœur de la nouvelle sémantique est la définition de throw. On laisse le plaisir au lecteur de comprendre par lui-même ce que signifient ces règles intuitivement.

      (32)
      (33)
      (34)

Ici E′ est soit une exception soit *.

      (35)

On utilise ici un jugement auxiliaire , où [catch (Exci xici]1≤ in finally c′ n’est, à proprement parler, pas une commande. De plus, on trouve à gauche du symbole un paquet d’exception ρ′, Exc, V et non un environnement.

      (36)
      (37)
      (38)
      (39)
Exercice 29   On a dit plus haut que la clause finally serait toujours exécutée. Ce n’est pas le cas si le corps c du try ne termine pas (entre dans un calcul infini), par exemple. Cependant, en ignorant les problèmes de terminaison, montrer qu’il existe quand même des cas où la clause finally ne sera jamais exécutée. Donner des contre-exemples explicites.
Exercice 30   Comment peut-on réparer ceci?

7.2  Sémantique à continuations

L’idée de la sémantique à continuations est de dériver des jugements de la forme exprimant que c peut s’exécuter de sorte à ce que, lorsque son exécution termine, il ne restera plus qu’à exécuter la suite d’instructions décrites dans la continuation κ, laquelle retourne une réponse A. Cette réponse peut être un environnement final, par exemple. En réalité, une réponse peut être n’importe quel élémént d’un ensemble fixé a priori, qui importe peu.

Formellement, une continuation κ sera juste une fonction qui à chaque état st associe une réponse A: dans le jugement ci-dessus, on trouve la réponse finale A en appliquant κ à l’environnement ρ′ obtenu à la fin de l’exécution de c. (Si c ne lance pas d’exception.) En général, nous ne supposerons pas que la continuation est déterministe, et considérerons κ comme une relation binaire entre états de fin st′ et réponses A. On notera st′  κ  A si (st′, A) est dans κ.

L’affectation simple est donc décrite par la règle:

      (40)

Les instructions return et throw ont aussi une description simple:

      (41)
      (42)

Pour la séquence, l’idée est qu’évaluer c1;c2 dans la continuation κ, c’est évaluer juste c1 dans la continuation qui calcule d’abord c2 puis appelle κ. Notons, pour toute commande c, et toute continuation κ, la relation reliant st à A si et seulement si:

On écrit donc:

      (43)

Un petit truc: la prémisse se lit, intuititivement, “partant de c1, faire c1 puis, partant de l’environnement ainsi obtenu, faire c2, puis appliquer κ, obtenant la réponse A”. Une continuation comme , en pratique, est juste une façon de représenter un saut (jmp en assembleur) à la fin de l’exécution de c1 vers un morceau de code qui exécute c2 (puis saute vers le morceau de code dont κ est une abstraction mathématique).

Exercice 31   Il n’y a pas de règle similaire à (29), laquelle servait à propager les paquets à travers les commandes. Pourquoi, intuitivement?

Là où en section 7.1 nous avions besoin de deux règles pour décrire la sémantique de l’appel de fonctions, nous n’avons plus besoin que d’une règle. On note la relation reliant f (x1, …, xn) à g (x1, …, xn) lorsque x1, …, xn varient dans leur domaine naturel, et P (x1, …, xn) est vrai.

      (44)

L’idée est que, pour exécuter l’appel de fonction, il suffit d’exécuter son corps c. Si l’on finit par exécuter une instruction return, on continuera le calcul en invoquant la continuation κ sur l’environnement ρ [x:=V], poursuivant ainsi le calcul. Si c finit par lancer une exception, c’est κexc qui prendra le relais pour la suite du calcul, laquelle remplace l’environnement ρ′ de la fonction appelée par l’environnement ρ de la fonction appelante.

Exercice 32   Que se passe-t-il si l’exécution de c termine sans passer ni par un return ni par un throw, c’est-à-dire si l’on cherche à appliquer à un environnement? Comparer avec les règles (30) et (31) d’appel de fonction en style direct.

Les sémantiques à continuations ont été inventées dans les années 1960 pour donner une définition des mécanismes de goto et d’exceptions dans le langage Algol 60. Voyons comment ça se passe. Notons la continuation κ restreinte aux états qui sont des environnements ρ, et κ|Paq la restriction de κ aux paquets, c’est-à-dire la relation reliant tout paquet (E, V) à tout A tel que (E, V)  κ  A, mais ne reliant aucun environnement ρ à aucune valeur. Finalement, on note la relation κ′ union tous les couples st, A tels que st  κ  A et st n’est pas dans le domaine de κ′.

      (45)
Exercice 33   Démontrer que les deux sémantiques, directe et par continuations, sont équivalents, au sens où: En particulier, si l’on prend comme domaine de réponses l’ensemble des états, et κ la relation identité, en déduire que est dérivable dans la sémantique directe si et seulement si est dérivable dans la sémantique à continuations.

Si l’on prend comme domaine de réponses l’ensemble des booléens (vrai, faux), et si l’on contraint les continuations κ à être des fonctions partielles (pour tout st, il existe au plus une réponse A telle que st κ A) alors on peut penser aux continuations comme à des tests, ou bien des prédicats. Le jugement se lit “partant de ρ, l’exécution de c peut terminer dans un état vérifiant le prédicat κ”. Les règles de la sémantique à continuations s’interprètent alors comme un calcul connu sous le nom de calcul des “weakest preconditions”, dû à E.W. Dijkstra. Les notations traditionnelles pour ce dernier calcul sont un peu différentes. La “weakest precondition” wp (c) (κ) est par définition le prédicat qui envoie tout état st vers κ (st) si st est un paquet, et si st est un environnement ρ, l’envoie vers vrai si et seulement si est dérivable dans la sémantique à continuations.

Exercice 34   Démontrer que, si κ est une fonction partielle, alors la relation qui à ρ associe A si et seulement si est déricable dans la sémantique à continuations est elle aussi une fonction partielle.
Exercice 35   Écrire la sémantique des wp de Dijkstra explicitement. Autrement dit, pour chaque forme possible de commande c du langage, donner une définition explicite de wp (c) (κ).
Exercice 36   Démontrer que la sémantique par continuations est monotone en la continuation: si est dérivable, alors pour toute continuation κ+ telle que , alors est aussi dérivable.
Exercice 37   Notre construction de la sémantique par continuations est incomplètement spécifiée. Par exemple, la règle (43) utilise dans sa prémisse une continuation , laquelle n’est définie que lorsque la relation est déjà définie. Or c’est précisément la relation que l’on cherche à définir. Une solution classique dans la littérature est de considérer que de telles règles sont des règles à une infinité de prémisses. Ceci se voit mieux si on réécrit la règle (43) sous la forme:

Mais ceci n’a aucun sens. En revanche, la règle

      (46)

qui est paramétrée par une continuation κ′, a juste une infinité de prémisses . En utilisant l’exercice 36, justifier pourquoi (46) et (43) sont en fait équivalentes.

En utilisant des règles du style de (46), que vous écrirez, on appellera ensemble des jugements dérivables le plus petit ensemble de jugements stable par les règles de déduction, c’est-à-dire tel que pour toute règle, si toutes ses prémisses sont dans l’ensemble, alors sa conclusion aussi.

8  Quelques conseils utiles

8.1  Quand commencer

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, trycatchfinally) est quelque chose d’un peu compliqué, qu’il vaut mieux aborder avec méthode.

8.2  La débrouille

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.)

8.3  Le style de programmation

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ù:

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…

8.4  Quelques trucs sur les exceptions

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 trycatchfinally 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

8.5  Les fichiers ML fournis

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:

- : Cparse.var_declaration list ref = {contents = [CFUN (("cat.c", 12, 4, 12, 8), "main", [CDECL (("cat.c", 12, 14, 12, 18), "argc"); CDECL (("cat.c", 12, 27, 12, 31), "argv")], (("cat.c", 13, 0, 27, 1), CBLOCK ([CDECL (("cat.c", 14, 6, 14, 7), "i"); CDECL (("cat.c", 14, 9, 14, 10), "c")], [(("cat.c", 16, 2, 24, 5), CBLOCK ([], [(("cat.c", 16, 7, 16, 10), CEXPR (("cat.c", 16, 7, 16, 10), SET_VAR ("i", (("cat.c", 16, 9, 16, 10), CST 1)))); (("cat.c", 16, 2, 24, 5), CWHILE ((("cat.c", 16, 12, 16, 18), CMP (C_LT, (("cat.c", 16, 12, 16, 13), VAR "i"), (("cat.c", 16, 14, 16, 18), VAR "argc"))), (("cat.c", 16, 20, 24, 5), CBLOCK ([], [(("cat.c", 17, 4, 24, 5), CBLOCK ([CDECL (("cat.c", 18, 12, 18, 13), "f")], [(("cat.c", 20, 6, 20, 28), CEXPR (("cat.c", 20, 6, 20, 28), SET_VAR ("f", (("cat.c", 20, 10, 20, 28), CALL ("fopen", [(("cat.c", 20, 17, 20, 24), OP2 (S_INDEX, (("cat.c", 20, 17, 20, 21), VAR "argv"), (("cat.c", 20, 22, 20, 23), VAR "i"))); (("cat.c", 20, 27, 20, 28), STRING "r")]))))); (("cat.c", 21, 6, 22, 18), CWHILE ((("cat.c", 21, 14, 21, 33), EIF ((("cat.c", 21, 14, 21, 33), CMP (C_EQ, (("cat.c", 21, 14, 21, 27), SET_VAR ("c", (("cat.c", 21, 18, 21, 27), CALL ("fgetc", [(("cat.c", 21, 25, 21, 26), VAR "f")])))), (("cat.c", 21, 31, 21, 33), OP1 (M_MINUS, (("cat.c", 21, 32, 21, 33), CST 1))))), (("cat.c", 21, 14, 21, 33), CST 0), (("cat.c", 21, 14, 21, 33), CST 1))), (("cat.c", 22, 1, 22, 18), CEXPR (("cat.c", 22, 1, 22, 18), CALL ("fputc", [(("cat.c", 22, 8, 22, 9), VAR "c"); (("cat.c", 22, 11, 22, 17), VAR "stdout")]))))); (("cat.c", 23, 6, 23, 16), CEXPR (("cat.c", 23, 6, 23, 16), CALL ("fclose", [(("cat.c", 23, 14, 23, 15), VAR "f")])))])); (("cat.c", 16, 20, 16, 23), CEXPR (("cat.c", 16, 20, 16, 23), OP1 (M_POST_INC, (("cat.c", 16, 20, 16, 21), VAR "i"))))]))))])); (("cat.c", 25, 2, 25, 17), CEXPR (("cat.c", 25, 2, 25, 17), CALL ("fflush", [(("cat.c", 25, 10, 25, 16), VAR "stdout")]))); (("cat.c", 26, 2, 26, 10), CEXPR (("cat.c", 26, 2, 26, 10), CALL ("exit", [(("cat.c", 26, 8, 26, 9), CST 0)])))])))]}

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…

9  Guide de référence rapide de l’assembleur x86

Les registres: %eax %ebx %ecx %edx %esi %edi %ebp %esp.
Ils contiennent tous un entier de 32 bits (4 octets), qui peut aussi être vu comme une adresse. Le registre %esp est spécial, et pointe sur le sommet de pile; il est modifié par les instructions pushl, popl, call, ret notamment.

Il y a aussi d’autres registres que l’on ne peut pas manipuler directement. (L’instruction info registers sous gdb ou ddd vous les montrera.) Le plus important est eip, le compteur de programme: il contient en permanence l’adresse de la prochaine instruction à exécuter. 1em

10  Un dernier mot

Bon courage, et travaillez bien! N’hésitez pas à nous contacter, goubault@lsv.ens-cachan.fr ou bardin@lsv.ens-cachan.fr


This document was translated from LATEX by HEVEA.