L'objectif de cette séance est d'étendre le langage A6000 avec des fonctions définies par l'utilisateur.

Voici un exemple de programme complet, équivalent au programme Circle :

      main(integer x) (
        loop(0, x);
      )

      loop(integer i, integer r) (
        if line(point(i, r), r) then (
          loop(i+1, r);
        ) else ( );
      )
      
      integer point(integer i, integer r) (
        var integer j;
        j := 0;
        while (i*i + j*j < r*r) (
          j := j+1;
        );
        result := j;
      )

      boolean line(integer p, integer r) (
        var integer j;
        result := false;
        j := 0;
        while (j < r+1) (
          if j < p then (
            print(46);
            result := true;
          ) else (
            print(35);
          );
          print(32);
          j := j+1;
        );
        print(10);
      )

1. Description

1.1. Extension du langage source

1.1.1. Lexique

Le langage contient le nouveau symbole terminal , (virgule).

1.1.2. Grammaire

Les grammaires des instructions et des expressions sont étendues chacune d'une production pour l'appel d'une fonction (dans le cas d'une instruction, on parle aussi d'appel de procédure).

      [instruction] ← ...
                    |  [call]
             [expr] ← ...
                    |  [call]

             [call] ← {id} ( [arguments] )
        [arguments] ← ε
                    |  { [expression] , }* [expression]
On a également un nouveau non-terminal [fun_decl] pour la définition d'une fonction (qui généralise [main]), et un nouveau non-terminal [prog] pour un programme entier, constitué de plusieurs définitions de fonctions.
  [fun_decl] ← { [typ] }? {id} ( [parameters] ) ( [var_decls] [instructions] )

[parameters] ← ε
             |  { [typ] {id} , }* [typ] {id}

      [prog] ← { [fun_decl] }*
{ e }? signifie "e ou ε", et { e }* signifie "e répété 0 fois ou plus".

1.1.3. Sémantique

On choisit pour les paramètres des fonctions un mode de passage par valeur.

Lorsqu'une fonction a un type de retour ty, sa table des symboles contient une variable spéciale result de type ty et de sorte Return. Le résultat d'un appel de fonction est la dernière valeur qui a été affectée à la variable result. Le résultat d'une lecture de cette variable n'est pas spécifié.

Lorsqu'un appel de fonction est effectué dans une expressions, on attend que la fonction renvoie un résultat. Lorsque l'appel est une instruction, on attend que la fonction ne renvoie pas de résultat.

Un programme est un ensemble de fonctions, dans lequel on suppose qu'une des fonctions est nommée main. Invariablement, la fonction main attend un unique paramètre de type integer, et ne renvoie pas de résultat. Par défaut, les fonctions sont mutuellement récursives : chaque fonction d'un programme peut faire appel à n'importe quelle autre fonction du même programme. Exécuter le programme sur l'entrée n consiste à exécuter un appel de fonction main(n).

1.2. Extension du compilateur

1.2.1. Syntaxes abstraites

Dans la syntaxe abstraite source SourceAst, on définit un nouveau type désignant un appel de fonction

      call = string * expression list
constitué d'un identifiant (l'identifiant de la fonction appelée) et d'une liste d'expressions (les paramètres effectifs). Ce type est utilisé dans la définition de deux nouveaux constructeurs FunCall of call et ProcCall of call représentant respectivement un appel de fonction en position d'expression et d'instruction.

Les deux constructeurs FunCall et ProcCall sont traduits dans la représentation intermédiaire IrAst par les deux instructions

       FunCall of identifier * string * value list
      ProcCall of              string * value list
L'une et l'autre ont comme deux derniers arguments l'identifiant de la fonction appelée et la liste des paramètres formels. Le cas FunCall a en outre un premier argument indiquant le registre virtuel de destination du résultat renvoyé par la fonction.

Le type principal est maintenant program, qui est une table de symboles associant à chaque identifiant de fonction sa définition. La définition d'une fonction est donnée par le type

      type function_info = { 
        return:  typ option;
        formals: typ list;
        locals:  identifier_info Symb_Tbl.t;
        code:    block
      }
qui généralise le type main de la première séquence. Le champ return contient le type de l'éventuel résultat renvoyé par la fonction, et le champs formals la liste des types des paramètres de la fonction (pour cette séance il ne sera pas nécessaire de tenir compte de ces deux informations, qui n'interviendront que dans le TP8).
Remarquez que, dorénavant, chaque fonction possède sa propre table des symboles.

Dans la table des symboles utilisée pour les variables, le type identifier_kind est enrichi et offre maintenant trois sortes de variables :

1.2.2. Conventions d'appel

La version de base décrite ici n'est pas immédiatement compatible avec l'allocation de registres, mais une extension facile permet de réparer ceci. Voir Sauvegarder les registres.

Dans les grandes lignes.
Les codes des différentes fonctions sont stockés l'un après l'autre en mémoire, chacun étiqueté par l'identifiant de la fonction correspondante, et en supposant que l'une des ces étiquettes est main. Lors de l'appel d'une fonction, l'appelant a la charge de réserver un emplacement sur la pile pour le résultat (si un résultat est attendu) et de placer les paramètres effectifs de l'appel sur la pile, puis de les retirer après le retour de la fonction. L'appelé a la charge de sauvegarder les registres $fp et $ra. Les variables locales sont toujours stockées dans la pile.

En détail.
Lors de l'exécution d'un appel de fonction f (a_0, ..., a_n), la pile a la forme suivante :

    |         |
    |---------|
    |         |    <- adresse pointée par l'ancien $fp (old_$fp)
    |         |
    |   ...   |    <- partie de la pile utilisée par le contexte appelant
    |         |
    |         |
    |---------|
    |   res   |    <- résultat de l'appel, le cas échéant
    |---------|
    |   a_1   |    
    |         |
    |   ...   |    <- arguments passés à f (paramètre effectifs)
    |         |
    |   a_n   |
  --|---------|-- Frontière entre ce qui est géré par l'appelant ou l'appelé
    | old_$fp |    
    |---------|
    |   $ra   |    <- pointeur $fp actuel
    |---------|
    |   x_0   |   
    |         |
    |   ...   |    <- variables locales de f
    |         |
    |   x_k   |    <- position du pointeur $sp au début du calcul de f
    |---------|
    |         |
    |   ...   |    <- partie de la pile utilisée pour les appels de fonction
    |         |	      réalisés par f

Le registre $fp contient une adresse dans la pile, qui sert de point de référence pour accéder aux variables locales. Il est mis à jour à chaque appel ou retour d'une fonction.

Une fonction accède à ses paramètres effectifs sur la pile en comptant un décalage positif par rapport au pointeur $fp, et à ses variables locales par un décalage négatif à partir de ce même point de référence. Par exemple, l'adresse du paramètre effectif a_n est obtenue en ajoutant 8 octets à l'adresse donnée par $fp, et l'adresse de la variable x_0 est obtenue en retirant 4 octets à l'adresse donnée par $fp. Commes pour les paramètres effectifs, l'emplacement de la variable spéciale result est accessible avec un décalage positif.
Tous ces éléments peuvent être traduits avec le constructeur Stack.

Lors de l'appel d'une fonction, l'appelant et l'appelé doivent procéder ainsi, dans l'ordre :

  1. L'appelant réserve au sommet de la pile une place pour le résultat, si un résultat est attendu.
  2. L'appelant place au sommet de la pile tous les arguments passés à la fonction, de gauche à droite.
  3. L'appelant passe la main au code de la fonction appelée avec une instruction jal (jump-and-link, qui a pour effet de placer une adresse de retour dans le registre $ra).
  4. L'appelé sauvegarde sur la pile les valeurs des registres $fp et $ra.
  5. L'appelé définit la nouvelle valeur de $fp comme l'adresse à laquelle a été est stocké $ra sur la pile. Ceci doit aussi correspondre à ce stade à l'adresse indiquée par $sp.
  6. L'appelé décale le pointeur $sp d'autant de cases que nécessaire pour laisser de la place à ses variables locales.
  7. L'appelé exécute le code du corps de la fonction. À la fin de cette étape, l'éventuel résultat est censé être placé à l'emplacement de pile res alloué à la variable result.
  8. L'appelé retire de la pile les valeurs sauvegardées de $fp et $ra et restaure ces registres avec ces valeurs.
  9. L'appelé repasse la main à l'appelant en sautant à l'adresse de retour indiquée par $ra.
  10. L'appelant retire de la pile les arguments qui avaient été donnés à la fonction (ce qui peut se faire par une simple modification de $sp).
  11. Si l'appel devait produire une valeur, l'appelant transfère le résultat contenu dans res vers l'emplacement alloué pour cette valeur.
Ce processus entier ramène la pile dans l'état qui était le sien juste avant l'appel, et place l'éventuel résultat dans son emplacement de destination.

2. Travail attendu

Vous devez étendre chaque étape du compilateur pour intégrer les deux nouveaux aspects introduits dans ce sujet :

3. Extensions

3.1. Uniformisation

Maintenant que nous avons un mécanisme général pour l'appel de fonctions, il n'est plus nécessaire d'avoir la construction spéciale print.

Vous pouvez donc opérer un nettoyage consistant à :

3.2. Variables globales

En l'état actuel, chaque fonction a ses variables locales, et l'exécution d'une fonction n'a aucun effet sur les variables locales d'une autre fonction. On peut en revanche ajouter au langage des variables globales, accessibles et modifiables par toutes les fonctions du programme.

Voici le modèle proposé pour intégrer des variables globales :

3.3. Conventions d'appel, suite

Les conventions d'appel ne se limitent en général pas à la gestion de la pile, mais parlent aussi des registres.

3.3.1. Sauvegarder les registres (RECOMMANDÉ)

L'allocation des registres est faite fonction par fonction. Chaque fonction est donc susceptible d'utiliser les registres $ti pour stocker ses variables locales. Lorsqu'une fonction f appelle une fonction g (éventuellement f elle-même), les valeurs de ces registres doivent donc être sauvegardées pour ne pas être écrasées par l'exécution de g.

Étendre le protocole d'appel avec une étape 0 dans laquelle l'appelant enregistre au sommet de la pile les valeurs contenues dans les registres $t2 à $t9, et une étape 12 dans laquelle l'appelant restaure les valeurs de ces registres.

Une fois ce nouveau protocole en place, l'allocation de registres peut à nouveau être activée.

3.3.2. Paramètres et résultat

Les conventions usuelles de MIPS demandent que les valeurs des quatre premiers paramètres d'une fonction soient passées par les registres $a0 à $a3 plutôt que par la pile. De même, le résultat doit être transmis par le registre $v0.

Adapter le protocole d'appel pour intégrer cette nouvelle version.

De même que dans la section précédente il faut éviter que les valeurs contenues dans ces registres soient écrasées lors d'un appel à une autre fonction. Une stratégie pour cela consiste à créer de nouveaux registres virtuels pour les paramètres d'une fonction (au minimum pour les quatre premiers) et pour son résultat, et ajouter au protocole d'appel une étape 6' qui transfère vers ces registres virtuels les valeurs des paramètres et une étape 7' qui transfère le résultat de son registre virtuel vers $v0.

Pour des fonctions pas trop complexes, on peut s'attendre à ce que ces nouveaux registres virtuels soient affectés à des registres physiques, et dans tous les cas leur sauvegarde sera prise en charge par les mécanismes déjà en place.

Si vous avez implémenté l'optimisation de propagation des copies, elle peut également agir à ce niveau.

3.3.3. Affiner l'utilisation des registres

La version précédente sauvegarde systématiquement tous les registres temporaires à chaque appel de fonction. Cependant, tous ne contiennent pas des informations utiles.
Vous pouvez utiliser les informations de vivacité pour savoir quels registres contiennent des valeurs qui sont encore vivantes après un appel, et ne sauvegarder que celles-ci.

Les registres sont séparés en deux familles, selon que leurs valeurs doivent être sauvegardées par l'appelant (comme les $ti) ou par l'appelé (les $si).
Vous pouvez affiner l'algorithme d'allocation pour qu'il tente d'affecter aux registres $si les registres temporaires dont les valeurs sont vivantes à la fois avant et après une instruction FunCall ou ProcCall. Il faut également ajouter alors au protocole d'appel une étape 5' dans laquelle l'appelé sauvegarde sur la pile les valeurs de tous les registres $si qu'il est susceptible d'utiliser, et une étape 7'' dans laquelle ces valeurs sont restaurées.

Enfin, même les registres $ai ou $vi peuvent être utilisés lors de l'allocation de registres, lorsqu'ils ne sont pas utiles pour réaliser un appel de fonction.
Vous pouvez affiner l'algorithme d'allocation réaliser ceci. Une technique consiste à prévoir dans le graphe d'interférence des nœuds précolorés avec les $ai ou $vi.

3.4. Optimisation des appels terminaux

Un appel terminal n'a pas besoin d'allouer une nouvelle fenêtre d'activation sur la pile, et peut plutôt réutiliser celle de la fonction appelante.

Vous pouvez enfin intégrer à votre compilateur l'optimisation des appels terminaux. Pour éviter les problèmes liés aux fenêtres d'activation de tailles différentes, vous pouvez vous concentrer sur le cas des appels récursifs d'une même fonction.