Changements apportés au Codegen basé sur Solidity IR

Solidity peut générer du bytecode EVM de deux manières différentes : Soit directement de Solidity vers les opcodes EVM (« old codegen »), soit par le biais d’une représentation intermédiaire (« IR ») dans Yul (« new codegen » ou « IR-based codegen »).

Le générateur de code basé sur l’IR a été introduit dans le but non seulement de permettre génération de code plus transparente et plus vérifiable, mais aussi de permettre des passes d’optimisation plus puissantes qui couvrent plusieurs fonctions.

Actuellement, le générateur de code basé sur IR est toujours marqué comme expérimental, mais il supporte toutes les fonctionnalités du langage et a fait l’objet de nombreux tests. Nous considérons donc qu’il est presque prêt à être utilisé en production.

Vous pouvez l’activer sur la ligne de commande en utilisant --experimental-via-ir. ou avec l’option {"viaIR" : true} dans le standard-json et nous encourageons tout le monde à l’essayer !

Pour plusieurs raisons, il existe de minuscules différences sémantiques entre l’ancien générateur de code basé sur l’IR, principalement dans des domaines où nous ne nous attendons pas à ce que les gens se fient à ce comportement de toute façon. Cette section met en évidence les principales différences entre l’ancien et le générateur de code basé sur la RI.

Changements uniquement sémantiques

Cette section énumère les changements qui sont uniquement sémantiques, donc potentiellement cacher un comportement nouveau et différent dans le code existant.

  • Lorsque les structures de stockage sont supprimées, chaque emplacement de stockage qui contient un membre de la structure est entièrement mis à zéro. Auparavant, l’espace de remplissage n’était pas modifié. Par conséquent, si l’espace de remplissage dans une structure est utilisé pour stocker des données (par exemple, dans le contexte d’une mise à jour de contrat), vous devez être conscient que que delete effacera maintenant aussi le membre ajouté (alors qu’il n’aurait pas été effacé dans le passé).

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.7.1;
    
    contract C {
        struct S {
            uint64 y;
            uint64 z;
        }
        S s;
        function f() public {
            // ...
            delete s;
            // s occupe seulement les 16 premiers octets de l'emplacement de 32 octets.
            // delete écrira zéro dans l'emplacement complet
        }
    }
    

    Nous avons le même comportement pour la suppression implicite, par exemple lorsque le tableau de structs est raccourci.

  • Les modificateurs de fonction sont mis en œuvre d’une manière légèrement différente en ce qui concerne les paramètres de fonction et les variables de retour. Cela a notamment un effet si le caractère générique _; est évalué plusieurs fois dans un modificateur. Dans l’ancien générateur de code, chaque paramètre de fonction et variable de retour a un emplacement fixe sur la pile. Si la fonction est exécutée plusieurs fois parce que _; est utilisé plusieurs fois ou utilisé dans une boucle, alors un changement de la valeur du paramètre de fonction ou de la variable de retour est visible lors de la prochaine exécution de la fonction. Le nouveau générateur de code implémente les modificateurs à l’aide de fonctions réelles et transmet les paramètres de fonction. Cela signifie que plusieurs évaluations du corps d’une fonction obtiendront les mêmes valeurs pour les paramètres, et l’effet sur les variables de retour est qu’elles sont réinitialisées à leur valeur par défaut (zéro) à chaque exécution.

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.7.0;
    contract C {
        function f(uint _a) public pure mod() returns (uint _r) {
            _r = _a++;
        }
        modifier mod() { _; _; }
    }
    

    Si vous exécutez f(0) dans l’ancien générateur de code, il retournera 2, alors qu’il retournera 1 en utilisant le nouveau générateur de code.

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.7.1 <0.9.0;
    
    contract C {
        bool active = true;
        modifier mod()
        {
            _;
            active = false;
            _;
        }
        function foo() external mod() returns (uint ret)
        {
            if (active)
                ret = 1; // Same as ``return 1``
        }
    }
    

    La fonction C.foo() renvoie les valeurs suivantes :

    • Ancien générateur de code : 1 comme variable de retour est initialisé à 0 une seule fois avant la première évaluation _; et ensuite écrasée par la variable return 1;. Elle n’est pas initialisée à nouveau pour la seconde évaluation et foo() ne l’assigne pas explicitement non plus (à cause de active == false), il garde donc sa première valeur.

    • Nouveau générateur de code : 0 car tous les paramètres, y compris les paramètres de retour, seront ré-initialisés avant chaque évaluation _;.

  • L’ordre d’initialisation des contrats a changé en cas d’héritage.

    L’ordre était auparavant le suivant :

    • Toutes les variables d’état sont initialisées à zéro au début.

    • Évaluer les arguments du constructeur de base du contrat le plus dérivé au contrat le plus basique.

    • Initialiser toutes les variables d’état dans toute la hiérarchie d’héritage, de la plus basique à la plus dérivée.

    • Exécuter le constructeur, s’il est présent, pour tous les contrats dans la hiérarchie linéarisée du plus bas au plus dérivé.

    Nouvel ordre :

    • Toutes les variables d’état sont initialisées à zéro au début.

    • Évaluer les arguments du constructeur de base du contrat le plus dérivé au contrat le plus basique.

    • Pour chaque contrat dans l’ordre du plus basique au plus dérivé dans la hiérarchie linéarisée, exécuter :

      1. Si elles sont présentes à la déclaration, les valeurs initiales sont assignées aux variables d’état.

      2. Le constructeur, s’il est présent.

Cela entraîne des différences dans certains contrats, par exemple :

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1;

contract A {
    uint x;
    constructor() {
        x = 42;
    }
    function f() public view returns(uint256) {
        return x;
    }
}
contract B is A {
    uint public y = f();
}

Auparavant, y était fixé à 0. Cela est dû au fait que nous initialisions d’abord les variables d’état : D’abord, x est mis à 0, et lors de l’initialisation de y, f() renvoie 0, ce qui fait que y est également 0. Avec les nouvelles règles, y`' sera fixé à 42. Nous commençons par initialiser ``x à 0, puis nous appelons le constructeur de A qui fixe x à 42. Enfin, lors de l’initialisation de y, f() renvoie 42, ce qui fait que y est 42.

  • La copie de tableaux d“« octets » de la mémoire vers le stockage est implémentée d’une manière différente. L’ancien générateur de code copie toujours des mots entiers, alors que le nouveau coupe le tableau d’octets après sa fin. L’ancien comportement peut conduire à ce que des données sales soient copiées après la fin du tableau (mais toujours dans le même emplacement de stockage). Cela entraîne des différences dans certains contrats, par exemple :

      // SPDX-License-Identifier: GPL-3.0
      pragma solidity >=0.8.1;
    
      contract C {
          bytes x;
          function f() public returns (uint _r) {
              bytes memory m = "tmp";
              assembly {
                  mstore(m, 8)
                  mstore(add(m, 32), "deadbeef15dead")
              }
              x = m;
              assembly {
                  _r := sload(x.slot)
              }
          }
      }
    
    Auparavant, ``f()`` retournait ``0x6465616462656566313564656164000000000000000000000000000000000010``
    

    (il a une longueur correcte, et les 8 premiers éléments sont corrects, mais ensuite il contient des données sales qui ont été définies via l’assemblage). Maintenant, il renvoie 0x6465616462656566000000000000000000000000000000000000000000000010 (il a une longueur correcte, et des éléments corrects, mais il ne contient pas de données superflues).

  • Pour l’ancien générateur de code, l’ordre d’évaluation des expressions n’est pas spécifié. Pour le nouveau générateur de code, nous essayons d’évaluer dans l’ordre de la source (de gauche à droite), mais nous ne le garantissons pas. Cela peut conduire à des différences sémantiques.

    Par exemple :

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.8.1;
    contract C {
        function preincr_u8(uint8 _a) public pure returns (uint8) {
            return ++_a + _a;
        }
    }
    

    La fonction preincr_u8(1) retourne les valeurs suivantes :

    • Ancien générateur de code : 3 (1 + 2) mais la valeur de retour n’est pas spécifiée en général.

    • Nouveau générateur de code : 4 (2 + 2) mais la valeur de retour n’est pas garantie

    D’autre part, les expressions des arguments de fonction sont évaluées dans le même ordre par les deux générateurs de code, à l’exception des fonctions globales addmod et mulmod. Par exemple :

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.8.1;
    contract C {
        function add(uint8 _a, uint8 _b) public pure returns (uint8) {
            return _a + _b;
        }
        function g(uint8 _a, uint8 _b) public pure returns (uint8) {
            return add(++_a + ++_b, _a + _b);
        }
    }
    

    La fonction g(1, 2) renvoie les valeurs suivantes :

    • Ancien générateur de code : 10 (add(2 + 3, 2 + 3)) mais la valeur de retour n’est pas spécifiée en général.

    • Nouveau générateur de code : 10 mais la valeur de retour n’est pas garantie

    Les arguments des fonctions globales addmod et mulmod sont évalués de droite à gauche par l’ancien générateur de code et de gauche à droite par le nouveau générateur de code. Par exemple :

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.8.1;
    contract C {
        function f() public pure returns (uint256 aMod, uint256 mMod) {
            uint256 x = 3;
            // Old code gen: add/mulmod(5, 4, 3)
            // New code gen: add/mulmod(4, 5, 5)
            aMod = addmod(++x, ++x, x);
            mMod = mulmod(++x, ++x, x);
        }
    }
    

    La fonction f() renvoie les valeurs suivantes :

    • Ancien générateur de code :  » aMod = 0  » et  » mMod = 2 « .

    • Nouveau générateur de code :  » aMod = 4  » et  » mMod = 0 « .

  • Le nouveau générateur de code impose une limite dure de type(uint64).max (0xffffffffffffffff) pour le pointeur de mémoire libre. Les allocations qui augmenteraient sa valeur au-delà de cette limite. L’ancien générateur de code n’a pas n’a pas cette limite.

    Par exemple :

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >0.8.0;
    contract C {
        function f() public {
            uint[] memory arr;
            // allocation size: 576460752303423481
            // assumes freeMemPtr points to 0x80 initially
            uint solYulMaxAllocationBeforeMemPtrOverflow = (type(uint64).max - 0x80 - 31) / 32;
            // freeMemPtr overflows UINT64_MAX
            arr = new uint[](solYulMaxAllocationBeforeMemPtrOverflow);
        }
    }
    

    La fonction f() se comporte comme suit :

    • Ancien générateur de code : manque de gaz lors de la mise à zéro du contenu du tableau après la grande allocation de mémoire.

    • Nouveau générateur de code : retour en arrière en raison d’un débordement du pointeur de mémoire libre (ne tombe pas en panne sèche).

Internes

Pointeurs de fonctions internes

L’ancien générateur de code utilise des décalages de code ou des balises pour les valeurs des pointeurs de fonctions internes. Ceci est particulièrement compliqué car ces offsets sont différents au moment de la construction et après le déploiement et les valeurs peuvent traverser cette frontière via le stockage. Pour cette raison, les deux offsets sont codés au moment de la construction dans la même valeur (dans différents octets).

Dans le nouveau générateur de code, les pointeurs de fonction utilisent des ID internes qui sont alloués en séquence. Comme les appels via des pointeurs de fonction doivent toujours utiliser une fonction de distribution interne qui utilise l’instruction switch pour sélectionner la bonne fonction.

L’ID 0 est réservé aux pointeurs de fonction non initialisés qui provoquent une panique dans la fonction de répartition lorsqu’ils sont appelés.

Dans l’ancien générateur de code, les pointeurs de fonctions internes sont initialisés avec une fonction spéciale qui provoque toujours une panique. Cela provoque une écriture en mémoire au moment de la construction pour les pointeurs de fonctions internes en mémoire.

Nettoyage

L’ancien générateur de code n’effectue le nettoyage qu’avant une opération dont le résultat pourrait être affecté par les valeurs des bits sales. Le nouveau générateur de code effectue le nettoyage après toute opération qui peut entraîner des bits sales. L’espoir est que l’optimiseur sera suffisamment puissant pour éliminer les opérations de nettoyage redondantes.

Par exemple :

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.1;
contract C {
    function f(uint8 _a) public pure returns (uint _r1, uint _r2)
    {
        _a = ~_a;
        assembly {
            _r1 := _a
        }
        _r2 = _a;
    }
}

La fonction f(1) renvoie les valeurs suivantes :

  • Ancien générateur de code: (fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe, 00000000000000000000000000000000000000000000000000000000000000fe)

  • Nouveau générateur de codes: (00000000000000000000000000000000000000000000000000000000000000fe, 00000000000000000000000000000000000000000000000000000000000000fe)

Notez que, contrairement au nouveau générateur de code, l’ancien générateur de code n’effectue pas de nettoyage après l’affectation bit-non (_a = ~_a). Il en résulte que des valeurs différentes sont assignées (dans le bloc d’assemblage en ligne) à la valeur de retour _r1 entre l’ancien et le nouveau générateur de code. Cependant, les deux générateurs de code effectuent un nettoyage avant que la nouvelle valeur de _a soit assignée à _r2.