La machine virtuelle Ethereum

Bannière Amazon du livre Maîtriser Ethereum

Au cœur du protocole et du fonctionnement Ethereum se trouve la machine virtuelle Ethereum, ou EVM en abrégé. Comme vous pouvez le deviner d’après son nom, il s’agit d’un moteur de calcul, pas très différent des machines virtuelles du cadre de développement .NET de Microsoft, ou des interprètes d’autres langages de programmation compilés par code intermédiaire tels que Java. Dans ce chapitre, nous examinons en détail l’EVM, y compris son jeu d’instructions, sa structure et son fonctionnement, dans le contexte des mises à jour d’état d’Ethereum.

Qu’est-ce que l’EVM ?

L’EVM est la partie d’Ethereum qui gère le déploiement et l’exécution des contrats intelligents. Les transactions simples de transfert de valeur d’un EOA à un autre n’ont pas besoin de l’impliquer, pratiquement, mais tout le reste impliquera une mise à jour d’état calculée par l’EVM. À un niveau élevé, l’EVM fonctionnant sur la chaîne de blocs Ethereum peut être considéré comme un ordinateur mondial décentralisé contenant des millions d’objets exécutables, chacun avec son propre magasin de données permanent.

L’EVM est une machine d’état quasi–Turing-complète ; "quasi" car tous les processus d’exécution sont limités à un nombre fini d’étapes de calcul par la quantité de gaz disponible pour toute exécution de contrat intelligent donnée. En tant que tel, le problème d’arrêt est "résolu" (toutes les exécutions de programme s’arrêteront) et la situation où l’exécution pourrait (accidentellement ou par malveillance) s’exécuter indéfiniment, amenant ainsi la plate-forme Ethereum à s’arrêter dans son intégralité, est évitée.

L’EVM a une architecture basée sur la pile, stockant toutes les valeurs en mémoire sur une pile. Il fonctionne avec une taille de mot de 256 bits (principalement pour faciliter les opérations de hachage natif et de courbe elliptique) et possède plusieurs composants de données adressables :

  • Un code de programme ROM immuable, chargé avec le code intermédiaire du smart contract à exécuter

  • Une mémoire volatile, avec chaque emplacement explicitement initialisé à zéro

  • Un stockage permanent qui fait partie de l’état Ethereum, également initialisé à zéro

Il existe également un ensemble de variables d’environnement et de données disponibles pendant l’exécution. Nous les détaillerons plus loin dans ce chapitre.

L’architecture et le contexte d’exécution de la machine virtuelle Ethereum (EVM) montre l’architecture EVM et le contexte d’exécution.

L’architecture de la machine virtuelle Ethereum (EVM) et son contexte d’exécution
Figure 1. L’architecture et le contexte d’exécution de la machine virtuelle Ethereum (EVM)

Comparaison avec la technologie existante

Le terme "machine virtuelle" est souvent appliqué à la virtualisation d’un ordinateur réel, typiquement par un "hyperviseur" tel que VirtualBox ou QEMU, ou d’une instance entière du système d’exploitation, comme le KVM de Linux. Ceux-ci doivent fournir une abstraction logicielle, respectivement, du matériel réel, des appels système et des autres fonctionnalités du noyau.

L’EVM fonctionne dans un domaine beaucoup plus limité : c’est juste un moteur de calcul, et en tant que tel fournit une abstraction du calcul et du stockage, similaire à la spécification Java Virtual Machine (JVM), par exemple. D’un point de vue de haut niveau, la JVM est conçue pour fournir un environnement d’exécution indépendant du système d’exploitation ou du matériel hôte sous-jacent, permettant la compatibilité entre une grande variété de systèmes. Les langages de programmation de haut niveau tels que Java ou Scala (qui utilisent la JVM) ou C# (qui utilise .NET) sont compilés dans le jeu d’instructions code intermédiaire de leur machine virtuelle respective. De la même manière, l’EVM exécute son propre jeu d’instructions de code intermédiaire (décrit dans la section suivante), dans lequel sont compilés des langages de programmation de contrats intelligents de niveau supérieur tels que LLL, Serpent, Mutan ou Solidity.

L’EVM n’a donc aucune capacité de planification, car l’ordre d’exécution est organisé en externe - les clients Ethereum exécutent des transactions de bloc vérifiées pour déterminer quels contrats intelligents doivent être exécutés et dans quel ordre. En ce sens, l’ordinateur mondial Ethereum est mono-fil (d’exécution), comme JavaScript. L’EVM n’a pas non plus de gestion "d’interface système" ou de "support matériel" - il n’y a pas de machine physique avec laquelle s’interfacer. L’ordinateur mondial Ethereum est entièrement virtuel.

Le jeu d’instructions EVM (opérations de code intermédiaire)

Le jeu d’instructions EVM offre la plupart des opérations que l’on peut s’attendre, y compris :

  • Opérations arithmétiques et logiques au niveau du bit

  • Enquêtes sur le contexte d’exécution

  • Accès à la pile, à la mémoire et au stockage

  • Contrôler les opérations de flux

  • Journalisation, appels et autres opérateurs

En plus des opérations de code intermédiaire typiques, l’EVM a également accès aux informations de compte (par exemple, l’adresse et le solde) et aux informations de bloc (par exemple, le numéro de bloc et le prix actuel du gaz).

Commençons notre exploration de l’EVM plus en détail en examinant les opcodes disponibles et ce qu’ils font. Comme vous pouvez vous y attendre, tous les opérandes sont extraits de la pile et le résultat (le cas échéant) est souvent remis sur le dessus de la pile.

Note

Une liste complète des opcodes et leur coût de gaz correspondant peut être trouvée dans [evm_opcodes].

Les opcodes disponibles peuvent être divisés dans les catégories suivantes :

Opérations arithmétiques

Instructions d’opcode arithmétique :

ADD        // Ajouter les deux premiers éléments de la pile
MUL        // Multiplier les deux premiers éléments de la pile
SUB        // Soustraire les deux premiers éléments de la pile
DIV        // Division entière
SDIV       // Division d'entier signé
MOD        // Opération modulo (reste)
SMOD       // Opération modulo signée
ADDMOD     // Addition modulo n'importe quel nombre
MULMOD     // Multiplication modulo n'importe quel nombre
EXP        // Opération exponentielle
SIGNEXTEND // Étend la longueur d'un entier signé complément à deux
SHA3       // Calcule le hachage Keccak-256 d'un bloc de mémoire

Notez que toute l’arithmétique est effectuée modulo 2256 (sauf indication contraire) et que la puissance zéro de zéro, 00, est considérée comme égale à 1.

Opérations de pile

Instructions de gestion de la pile, de la mémoire et du stockage :

POP        // Supprime l'élément du haut de la pile
MLOAD      // Charger un mot machine de la mémoire
MSTORE     // Enregistre un mot machine en mémoire
MSTORE8    // Enregistre un octet en mémoire
SLOAD      // Charger un mot machine depuis le stockage
SSTORE     // Enregistrer un mot machine dans le stockage
MSIZE      // Récupère la taille de la mémoire active en octets
PUSHx      // Place l'élément x octet sur la pile, où x peut être n'importe quel entier de
           // 1 à 32 (mot machine complet) inclus
DUPx       // Dupliquer le x-ième élément de la pile, où x peut être n'importe quel entier de
           // 1 à 16 inclus
SWAPx      // Échangez les 1er et (x+1)-ème éléments de la pile, où x peut être n'importe quel
           // entier de 1 à 16 inclus
Opérations de flux de processus

Instructions pour le flux de contrôle :

STOP     // Arrête l'exécution
JUMP     // Mettre le compteur de programme à n'importe quelle valeur
JUMPI    // Modifier conditionnellement le compteur de programme
PC       // Récupérer la valeur du compteur de programme (avant l'incrément
         // correspondant à cette instruction)
JUMPDEST // Marquer une destination valide pour les sauts
Opérations système

Opcodes pour le système exécutant le programme :

LOGx         // Ajouter un enregistrement de journal avec x sujets, où x est n'importe quel entier
             // de 0 à 4 inclus
CREATE       // Créer un nouveau compte avec le code associé
CALL         // Message-appel dans un autre compte, c'est-à-dire exécuter un autre
             // code du compte
CALLCODE     // Message-appel dans ce compte avec un autre
             // code du compte
RETURN       // Arrête l'exécution et renvoie les données de sortie
DELEGATECALL // Message-appel dans ce compte avec une alternative
             // code du compte, mais en conservant les valeurs actuelles pour
             // sender et value
STATICCALL   // Message-appel statique dans un compte
REVERT       // Arrête l'exécution, annule les changements d'état mais retourne
             // des données et le gaz restant
INVALID      // L'instruction invalide désignée
SELFDESTRUCT // Arrête l'exécution et enregistre le compte pour suppression
Opérations logiques

Opcodes pour les comparaisons et la logique au niveau du bit :

LT     // Comparaison inférieure à
GT     // Comparaison supérieure à
SLT    // Comparaison inférieure signée
SGT    // Comparaison supérieure signée
EQ     // Comparaison d'égalité
ISZERO // Opérateur NOT simple
AND    // Opération AND au niveau du bit
OR     // Opération OR au niveau du bit
XOR    // Opération XOR au niveau du bit
NOT    // Opération NOT au niveau du bit
BYTE   // Récupérer un seul octet d'un mot pleine largeur de 256 bits
Opérations environnementales

Opcodes traitant des informations sur l’environnement d’exécution :

GAZ            // Obtenir la quantité de gaz disponible (après la réduction pour
               // cette instruction)
ADRESSE        // Obtenir l'adresse du compte en cours d'exécution
SOLDE          // Obtenir le solde du compte d'un compte donné
ORIGIN         // Obtenir l'adresse de l'EOA qui a lancé cette
               // éxecution EVM
APPELANT       // Obtenir l'adresse de l'appelant immédiatement responsable
               // pour cette exécution
CALLVALUE      // Obtenir le montant en ether déposé par l'appelant responsable
               // pour cette exécution
CALLDATALOAD   // Récupère les données d'entrée envoyées par l'appelant responsable de
               // cette exécution
CALLDATASIZE   // Récupère la taille des données d'entrée
CALLDATACOPY   // Copie les données d'entrée dans la mémoire
CODESIZE       // Récupère la taille du code en cours d'exécution dans l'environnement actuel
CODECOPY       // Copiez le code en cours d'exécution dans l'environnement actuel vers
               // la mémoire
GASPRICE       // Obtenir le prix du gaz spécifié par la
               // transaction d'origine
EXTCODESIZE    // Récupère la taille du code de n'importe quel compte
EXTCODECOPY    // Copie le code de n'importe quel compte en mémoire
RETURNDATASIZE // Récupère la taille des données de sortie de l'appel précédent
               // dans l'environnement actuel
RETURNDATACOPY // Copie la sortie des données de l'appel précédent dans la mémoire
Opérations sur les blocs

Opcodes pour accéder aux informations sur le bloc actuel :

BLOCKHASH  // Obtenir le hachage de l'un des 256 derniers
           // blocs complétés
COINBASE   // Obtenir l'adresse du bénéficiaire du bloc pour la récompense du bloc
TIMESTAMP  // Récupère l'horodatage du bloc
NUMBER     // Récupère le numéro du bloc
DIFFICULTY // Obtenir la difficulté du bloc
GASLIMIT   // Récupère la limite de gaz du bloc

État Ethereum

Le travail de l’EVM consiste à mettre à jour l’état Ethereum en calculant des transitions d’état valides à la suite de l’exécution du code de contrat intelligent, tel que défini par le protocole Ethereum. Cet aspect conduit à la description d’Ethereum comme une machine à états basée sur les transactions, qui reflète le fait que des acteurs externes (c’est-à-dire les titulaires de compte et les mineurs) initient des transitions d’état en créant, acceptant et ordonnant des transactions. Il est utile à ce stade de considérer ce qui constitue l’état Ethereum.

Au niveau supérieur, nous avons l’état mondial d’Ethereum. L’état mondial est un mappage des adresses Ethereum (valeurs 160 bits) vers des comptes. Au niveau inférieur, chaque adresse Ethereum représente un compte comprenant un solde d’ether (stocké comme le nombre de wei détenu par le compte), un nonce (représentant le nombre de transactions envoyées avec succès depuis ce compte s’il s’agit d’un EOA, ou le nombre de contrats créés par celui-ci s’il s’agit d’un compte contractuel), le stockage du compte (qui est une donnée permanente store, utilisé uniquement par les contrats intelligents) et le code programme du compte (encore une fois, uniquement si le compte est un compte de contrat intelligent). Un EOA n’aura toujours aucun code et un stockage vide.

Lorsqu’une transaction entraîne l’exécution d’un code de contrat intelligent, une EVM est instanciée avec toutes les informations requises par rapport au bloc en cours de création et à la transaction spécifique en cours de traitement. En particulier, la ROM de code de programme de l’EVM est chargée avec le code du compte de contrat appelé, le compteur de programme est mis à zéro, la mémoire est chargée à partir de la mémoire du compte de contrat, la mémoire est mise à zéro, et tout le bloc et les variables d’environnement sont définies. Une variable clé est l’approvisionnement en gaz pour cette exécution, qui est fixé à la quantité de gaz payée par l’expéditeur au début de la transaction (voir Gaz pour plus de détails). Au fur et à mesure de l’exécution du code, l’alimentation en gaz est réduite en fonction du coût en gaz des opérations exécutées. Si, à un moment quelconque, l’alimentation en gaz est réduite à zéro, nous obtenons une exception "Out of Gas" (OOG) ; l’exécution s’arrête immédiatement et la transaction est abandonnée. Aucune modification de l’état d’Ethereum n’est appliquée, à l’exception du nonce de l’expéditeur qui est incrémenté et de son solde d’ether qui diminue pour payer le bénéficiaire du bloc pour les ressources utilisées pour exécuter le code jusqu’au point d’arrêt. À ce stade, vous pouvez penser à l’EVM s’exécutant sur une copie en bac à sable de l’état mondial d’Ethereum, cette version en bac à sable étant complètement supprimée si l’exécution ne peut pas se terminer pour une raison quelconque. Cependant, si l’exécution réussit, l’état réel est mis à jour pour correspondre à la version en bac à sable, y compris toute modification des données de stockage du contrat appelé, tout nouveau contrat créé et tout transfert de solde d’ether initié.

Notez que, comme un contrat intelligent peut lui-même initier efficacement des transactions, l’exécution de code est un processus récursif. Un contrat peut appeler d’autres contrats, chaque appel entraînant l’instanciation d’un autre EVM autour de la nouvelle cible de l’appel. Chaque instanciation a son état mondial de bac à sable initialisé à partir du bac à sable de l’EVM au niveau supérieur. Chaque instanciation reçoit également une quantité spécifiée de gaz pour son alimentation en gaz (n’excédant pas la quantité de gaz restant dans le niveau supérieur, bien sûr), et peut donc elle-même s’arrêter à une exception près en raison du manque de gaz pour terminer son exécution . Encore une fois, dans de tels cas, l’état du bac à sable est ignoré et l’exécution revient à l’EVM au niveau supérieur.

Compilation de Solidity en code intermédiaire EVM

La compilation un fichier source Solidity en code intermédiaire EVM peut être réalisé via plusieurs méthodes. Dans [intro_chapter] nous avons utilisé le compilateur Remix en ligne. Dans ce chapitre, nous utiliserons l’exécutable solc en ligne de commande. Pour une liste d’options, exécutez la commande suivante :

$ solc --help

La génération du flux d’opcode brut d’un fichier source Solidity est facilement réalisée avec l’option de ligne de commande --opcodes. Ce flux d’opcode laisse de côté certaines informations (l’option --asm produit les informations complètes), mais c’est suffisant pour cette discussion. Par exemple, la compilation d’un exemple de fichier Solidity, Example.sol, et l’envoi de la sortie de l’opcode dans un répertoire nommé BytecodeDir s’effectuent avec la commande suivante :

$ solc -o BytecodeDir --opcodes Example.sol

ou alors:

$ solc -o BytecodeDir --asm Example.sol

La commande suivante produira le code intermédiaire binaire pour notre exemple de programme :

$ solc -o BytecodeDir --bin Example.sol

Les fichiers d’opcode de sortie générés dépendront des contrats spécifiques contenus dans le fichier source Solidity. Notre fichier Solidity simple Example.sol n’a qu’un seul contrat, nommé example :

pragma solidity ^0.4.19;

contract example {

  address contractOwner;

  function example() {
    contractOwner = msg.sender;
  }
}

Comme vous pouvez le voir, ce contrat ne contient qu’une seule variable d’état persistante, qui est définie comme l’adresse du dernier compte pour exécuter ce contrat.

Si vous regardez dans le répertoire BytecodeDir, vous verrez le fichier d’opcode example.opcode, qui contient les instructions d’opcode EVM du contrat example. L’ouverture du fichier example.opcode dans un éditeur de texte affichera ce qui suit :

PUSH1 0x60 PUSH1 0x40 MSTORE CALLVALUE ISZERO PUSH1 0xE JUMPI PUSH1 0x0 DUP1
REVERT JUMPDEST CALLER PUSH1 0x0 DUP1 PUSH2 0x100 EXP DUP2 SLOAD DUP2 PUSH20
0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF MUL NOT AND SWAP1 DUP4 PUSH20
0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF AND MUL OR SWAP1 SSTORE POP PUSH1
0x35 DUP1 PUSH1 0x5B PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN STOP PUSH1 0x60 PUSH1
0x40 MSTORE PUSH1 0x0 DUP1 REVERT STOP LOG1 PUSH6 0x627A7A723058 KECCAK256 JUMP
0xb9 SWAP14 0xcb 0x1e 0xdd RETURNDATACOPY 0xec 0xe0 0x1f 0x27 0xc9 PUSH5
0x9C5ABCC14A NUMBER 0x5e INVALID EXTCODESIZE 0xdb 0xcf EXTCODESIZE 0x27
EXTCODESIZE 0xe2 0xb8 SWAP10 0xed 0x

Compiler l’exemple avec l’option --asm produit un fichier nommé example.evm dans notre répertoire BytecodeDir. Celui-ci contient une description de niveau légèrement supérieur des instructions du code intermédiaire EVM, ainsi que quelques annotations utiles :

/* "Example.sol":26:132  contract example {... */
  mstore(0x40, 0x60)
    /* "Example.sol":74:130  function example() {... */
  jumpi(tag_1, iszero(callvalue))
  0x0
  dup1
  revert
tag_1:
    /* "Example.sol":115:125  msg.sender */
  caller
    /* "Example.sol":99:112  contractOwner */
  0x0
  dup1
    /* "Example.sol":99:125  contractOwner = msg.sender */
  0x100
  exp
  dup2
  sload
  dup2
  0xffffffffffffffffffffffffffffffffffffffff
  mul
  not
  and
  swap1
  dup4
  0xffffffffffffffffffffffffffffffffffffffff
  and
  mul
  or
  swap1
  sstore
  pop
    /* "Example.sol":26:132  contract example {... */
  dataSize(sub_0)
  dup1
  dataOffset(sub_0)
  0x0
  codecopy
  0x0
  return
stop

sub_0: assembly {
        /* "Example.sol":26:132  contract example {... */
      mstore(0x40, 0x60)
      0x0
      dup1
      revert

    auxdata: 0xa165627a7a7230582056b99dcb1edd3eece01f27c9649c5abcc14a435efe3b...
}

The --bin-runtime option produces the machine-readable hexadecimal bytecode:

60606040523415600e57600080fd5b336000806101000a81548173
ffffffffffffffffffffffffffffffffffffffff
021916908373
ffffffffffffffffffffffffffffffffffffffff
160217905550603580605b6000396000f3006060604052600080fd00a165627a7a7230582056b...

Vous pouvez enquêter sur ce qui se passe ici en détail en utilisant la liste des opcodes donnée dans Le jeu d’instructions EVM (opérations de code intermédiaire). Cependant, c’est toute une tâche, alors commençons par examiner les quatre premières instructions :

PUSH1 0x60 PUSH1 0x40 MSTORE CALLVALUE

Ici, nous avons PUSH1 suivi d’un octet brut de valeur 0x60. Cette instruction EVM prend l’octet unique suivant l’opcode dans le code du programme (en tant que valeur littérale) et le pousse sur la pile. Il est possible de pousser des valeurs de taille jusqu’à 32 octets sur la pile, comme dans :

PUSH32 0x436f6e67726174756c6174696f6e732120536f6f6e20746f206d617374657221

Le deuxième opcode PUSH1 de example.opcode stocke 0x40 en haut de la pile (en poussant le 0x60 déjà présent d’un emplacement vers le bas).

Vient ensuite MSTORE, qui est une opération de stockage en mémoire qui enregistre une valeur dans la mémoire de l’EVM. Il prend deux arguments et, comme la plupart des opérations EVM, les obtient de la pile. Pour chaque argument, la pile est “poppée” (popped) c’est-à-dire que la valeur supérieure de la pile est retirée et toutes les autres valeurs de la pile sont décalées d’une position. Le premier argument de MSTORE est l’adresse du mot en mémoire où sera mise la valeur à sauvegarder. Pour ce programme, nous avons 0x40 en haut de la pile, de sorte qu’il est retiré de la pile et utilisé comme adresse mémoire. Le deuxième argument est la valeur à sauvegarder, qui est 0x60 ici. Après l’exécution de l’opération MSTORE, notre pile est à nouveau vide, mais nous avons la valeur 0x60 (96 en décimal) à l’emplacement mémoire 0x40.

L’opcode suivant est CALLVALUE, qui est un opcode environnemental qui pousse vers le haut de la pile la quantité d’ether (mesurée en wei) envoyée avec l’appel de message qui a initié cette exécution.

Nous pourrions continuer à parcourir ce programme de cette manière jusqu’à ce que nous ayons une compréhension complète des changements d’état de bas niveau que ce code effectue, mais cela ne nous aiderait pas à ce stade. Nous y reviendrons plus tard dans le chapitre.

Code de déploiement du contrat

Il existe une différence importante mais subtile entre le code utilisé lors de la création et du déploiement d’un nouveau contrat sur la plateforme Ethereum et le code du contrat lui-même. Afin de créer un nouveau contrat, une transaction spéciale est nécessaire dont le champ to est défini sur l’adresse spéciale 0x0 et son champ data défini sur le code d’initiation du contrat. Lorsqu’une telle transaction de création de contrat est traitée, le code du nouveau compte de contrat n’est pas le code du champ données de la transaction. Au lieu de cela, un EVM est instancié avec le code dans le champ data de la transaction chargée dans sa ROM de code de programme, puis la sortie de l’exécution de ce code de déploiement est prise comme code pour le nouveau compte de contrat. C’est ainsi que de nouveaux contrats peuvent être initialisés par programme en utilisant l’état mondial Ethereum au moment du déploiement, en définissant des valeurs dans le stockage du contrat et même en envoyant de l’ether ou en créant de nouveaux contrats.

Lors de la compilation d’un contrat hors ligne, par exemple, en utilisant solc sur la ligne de commande, vous pouvez soit obtenir le code intermédiaire de déploiement ou le code intermédiaire d’exécution.

Le code intermédiaire de déploiement est utilisé pour chaque aspect de l’initialisation d’un nouveau compte de contrat, y compris le code intermédiaire qui finira par être exécuté lorsque les transactions appelleront ce nouveau contrat (c’est-à-dire le code intermédiaire d’exécution) et le code pour tout initialiser en fonction du constructeur du contrat.

Le code intermédiaire d’exécution, en revanche, est exactement le code intermédiaire qui finit par être exécuté lorsque le nouveau contrat est appelé, et rien de plus ; il n’inclut pas le code intermédiaire nécessaire pour initialiser le contrat lors du déploiement.

Prenons comme exemple le simple contrat Faucet.sol que nous avons créé précédemment :

// Version du compilateur Solidity pour lequel ce programme a été écrit
pragma solidity ^0.4.19;

// Notre premier contrat est un robinet !
contract Faucet {

  // Donnez de l'ether à quiconque demande
  function withdraw(uint withdraw_amount) public {

      // Limiter le montant du retrait
      require(withdraw_amount <= 100000000000000000);

      // Envoie le montant à l'adresse qui l'a demandé
      msg.sender.transfer(withdraw_amount);
    }

  // Accepte tout montant entrant
  function () external payable {}

}

Pour obtenir le code intermédiaire de déploiement, nous exécuterions solc --bin Faucet.sol. Si nous voulions plutôt uniquement le code intermédiaire d’exécution, nous exécuterions solc --bin-runtime Faucet.sol.

Si vous comparez la sortie de ces commandes, vous verrez que le code intermédiaire d’exécution est un sous-ensemble du code intermédiaire de déploiement. En d’autres termes, le code intermédiaire d’exécution est entièrement contenu dans le code intermédiaire de déploiement.

Désassemblage du code intermédiaire

Le désassemblage du code intermédiaire EVM est un excellent moyen de comprendre comment le haut niveau de Solidity agit dans l’EVM. Il existe quelques désassembleurs que vous pouvez utiliser pour cela :

  • Porosity est un décompilateur à source libre populaire.

  • Ethersplay est un plug-in EVM pour Binary Ninja, un désassembleur.

  • IDA-Evm est un plugin EVM pour IDA, un autre désassembleur.

Dans cette section, nous utiliserons le plug-in Ethersplay pour Binary Ninja et pour commencer Démontage du code intermédiaire d’exécution du robinet. Après avoir obtenu le code intermédiaire d’exécution de Faucet.sol, nous pouvons l’introduire dans Binary Ninja (après avoir chargé le plug-in Ethersplay) pour voir à quoi ressemblent les instructions EVM.

Le code intermédiaire d’exécution de Faucet.sol a été désassemblé
Figure 2. Démontage du code intermédiaire d’exécution du robinet

Lorsque vous envoyez une transaction à un contrat intelligent compatible ABI (ce que vous pouvez supposer que tous les contrats le sont), la transaction interagit d’abord avec le répartiteur de ce contrat intelligent. Le répartiteur lit le champ data de la transaction et envoie la partie pertinente à la fonction appropriée. Nous pouvons voir un exemple de répartiteur au début de notre code intermédiaire d’exécution Faucet.sol désassemblé. Après l’instruction familière MSTORE, nous voyons les instructions suivantes :

PUSH1 0x4
CALLDATASIZE
LT
PUSH1 0x3f
JUMPI

Comme nous l’avons vu, PUSH1 0x4 place 0x4 en haut de la pile, qui est autrement vide. CALLDATASIZE obtient la taille en octets des données envoyées avec la transaction (appelée calldata) et pousse ce nombre sur la pile. Une fois ces opérations exécutées, la pile ressemble à ceci :

Pile

<length of calldata from tx>

0x4

Cette instruction suivante est LT, abréviation de "plus petit que". L’instruction LT vérifie si l’élément du haut de la pile est inférieur à l’élément suivant de la pile. Dans notre cas, il vérifie si le résultat de CALLDATASIZE est inférieur à 4 octets.

Pourquoi l’EVM vérifie-t-il que les données d’appel de la transaction font au moins 4 octets ? En raison du fonctionnement des identificateurs de fonction. Chaque fonction est identifiée par les 4 premiers octets de son hachage Keccak-256. En plaçant le nom de la fonction et les arguments qu’elle prend dans une fonction de hachage keccak256, nous pouvons déduire son identifiant de fonction. Dans notre cas, nous avons :

keccak256("withdraw(uint256)") = 0x2e1a7d4d...

Ainsi, l’identifiant de fonction pour la fonction withdraw(uint256) est 0x2e1a7d4d, puisqu’il s’agit des 4 premiers octets du hachage résultant. Un identifiant de fonction a toujours une longueur de 4 octets, donc si le champ entier data de la transaction envoyée au contrat est inférieur à 4 octets, alors il n’y a pas de fonction avec laquelle la transaction pourrait éventuellement communiquer, à moins qu’une fonction de secours ne soit définie. Parce que nous avons implémenté une telle fonction de secours dans Faucet.sol, l’EVM saute à cette fonction lorsque la longueur des données d’appel est inférieure à 4 octets.

LT retire les deux premières valeurs de la pile et, si le champ data de la transaction est inférieur à 4 octets, il y pousse un 1. Sinon, il pousse un 0. Dans notre exemple, supposons que le champ data de la transaction envoyée à notre contrat était inférieur à 4 octets.

L’instruction PUSH1 0x3f pousse l’octet 0x3f sur la pile. Après cette instruction, la pile ressemble à ceci :

Pile

0x3f

1

L’instruction suivante est JUMPI, qui signifie "sauter si". Cela fonctionne comme ceci :

jumpi(label, cond) // Aller à "label" si "cond" est vrai

Dans notre cas, label est 0x3f, où réside notre fonction de secours dans notre contrat intelligent. L’argument cond est 1, qui était le résultat de l’instruction LT précédente. Pour mettre toute cette séquence en mots, le contrat passe à la fonction de repli si les données de transaction sont inférieures à 4 octets.

À 0x3f, seule une instruction STOP suit, car bien que nous ayons déclaré une fonction de repli, nous l’avons laissée vide. Comme vous pouvez le voir dans Instruction JUMPI menant à la fonction de repli, si nous n’avions pas implémenté une fonction de secours, le contrat lèverait une exception à la place.

Instruction JUMPI menant à la fonction de repli
Figure 3. Instruction JUMPI menant à la fonction de repli

Examinons le bloc central du répartiteur. En supposant que nous recevions des données d’appel d’une longueur supérieure à 4 octets, l’instruction JUMPI ne passerait pas à la fonction de repli. Au lieu de cela, l’exécution du code procéderait aux instructions suivantes :

PUSH1 0x0
CALLDATALOAD
PUSH29 0x1000000...
SWAP1
DIV
PUSH4 0xffffffff
AND
DUP1
PUSH4 0x2e1a7d4d
EQ
PUSH1 0x41
JUMPI

PUSH1 0x0 pousse 0 sur la pile, qui est maintenant à nouveau vide. CALLDATALOAD accepte comme argument un index dans les données d’appel envoyées au contrat intelligent et lit 32 octets à partir de cet index, comme ceci :

calldataload(p) //charge 32 octets de données d'appel à partir de la position d'octet p

Puisque 0 était l’index qui lui a été transmis par la commande PUSH1 0x0, CALLDATALOAD lit 32 octets de données d’appel à partir de l’octet 0, puis le pousse vers le haut de la pile (après avoir extrait le 0x0 d’origine). Après l’instruction PUSH29 0x1000000…​, la pile est alors :

Pile

0x1000000…​ (longueur de 29 octets)

<32 bytes of calldata starting at byte 0>

SWAP1 commute l’élément supérieur sur la pile avec le i-ème élément après lui. Dans ce cas, il échange 0x1000000…​ avec le calldata. La nouvelle pile est :

Pile

<32 bytes of calldata starting at byte 0>

0x1000000…​ (longueur de 29 octets)

L’instruction suivante est DIV, qui fonctionne comme suit :

div(x, y) // division entière x / y

Dans ce cas, x = 32 octets de calldata commençant à l’octet 0, et y = 0x100000000…​ (29 octets au total). Pouvez-vous penser à la raison pour laquelle le répartiteur fait la division ? Voici un indice : nous avons lu 32 octets de calldata plus tôt, en commençant à l’index 0. Les 4 premiers octets de cette calldata sont l’identifiant de la fonction.

Le 0x100000000…​ que nous avons poussé plus tôt fait 29 octets de long, composé d’un 1 au début, suivi de tous les 0. Diviser nos 32 octets de données d’appel par cette valeur ne nous laissera que les 4 octets les plus élevés de notre charge de données d’appel, à partir de l’index 0. Ces 4 octets - les 4 premiers octets des données d’appel commençant à l’index 0 - sont l’identifiant de la fonction, et cela est la façon dont l’EVM extrait ce champ.

Si cette partie n’est pas claire pour vous, pensez-y comme ceci : en base 10, 1234000 / 1000 = 1234. En base 16, ce n’est pas différent. Au lieu que chaque lieu soit un multiple de 10, c’est un multiple de 16. Tout comme la division par 103 (1000) dans notre petit exemple ne conservait que les chiffres les plus élevés, divisant notre valeur de base 16 de 32 octets par 1629 fait de même.

Le résultat du DIV (l’identifiant de la fonction) est poussé sur la pile, et notre pile est maintenant :

Pile

<function identifier sent in data>

Étant donné que les instructions PUSH4 0xffffffff et AND sont redondantes, nous pouvons les ignorer complètement, car la pile restera la même après leur exécution. L’instruction DUP1 duplique le premier élément de la pile, qui est l’identificateur de la fonction. L’instruction suivante, PUSH4 0x2e1a7d4d, pousse l’identificateur de fonction précalculé de la fonction withdraw (uint256) sur la pile. La pile est maintenant :

Pile

0x2e1a7d4d

<function identifier sent in data>

<function identifier sent in data>

L’instruction suivante, EQ, extrait les deux premiers éléments de la pile et les compare. C’est là que le répartiteur fait son travail principal : il compare si l’identifiant de la fonction envoyé dans le champ msg.data de la transaction correspond à celui de withdraw (uint256). S’ils sont égaux, EQ pousse 1 sur la pile, qui sera finalement utilisé pour passer à la fonction de retrait. Sinon, EQ pousse 0 sur la pile.

En supposant que la transaction envoyée à notre contrat a bien commencé avec l’identifiant de fonction pour withdraw(uint256), notre pile est devenue :

Pile

1

<function identifier sent in data>(maintenant connu pour être 0x2e1a7d4d)

Ensuite, nous avons PUSH1 0x41, qui est l’adresse à laquelle la fonction withdraw(uint256) vit dans le contrat. Après cette instruction, la pile ressemble à ceci :

Pile

0x41

1

identifiant de la fonction envoyé dans msg.data

L’instruction JUMPI est la suivante, et elle accepte à nouveau les deux premiers éléments de la pile comme arguments. Dans ce cas, nous avons jumpi(0x41, 1), qui indique à l’EVM d’exécuter le saut vers l’emplacement de la fonction withdraw(uint256), et l’exécution du code de cette fonction peut continuer.

Complétude de Turing et Gaz

Comme nous l’avons déjà évoqué, en termes simples, un système ou langage de programmation est Turing complet s’il peut exécuter n’importe quel programme. Cette capacité, cependant, s’accompagne d’une mise en garde très importante : certains programmes prennent une éternité à s’exécuter. Un aspect important de ceci est que nous ne pouvons pas dire, juste en regardant un programme, s’il prendra une éternité ou non à s’exécuter. Nous devons en fait suivre l’exécution du programme et attendre qu’il se termine pour le savoir. Bien sûr, si l’exécution prend une éternité, nous devrons attendre une éternité pour le savoir. C’est ce qu’on appelle le problème d’arrêt et ce serait un énorme problème pour Ethereum s’il n’était pas résolu.

En raison du problème d’arrêt, l’ordinateur du monde Ethereum risque de se voir demander d’exécuter un programme qui ne s’arrête jamais. Cela pourrait être par accident ou par malveillance. Nous avons discuté du fait qu’Ethereum agit comme une machine à un seul fil d’exécution, sans aucun planificateur, et donc s’il était bloqué dans une boucle infinie, cela signifierait qu’il deviendrait inutilisable.

Cependant, avec le gaz, il existe une solution : si après qu’une quantité maximale de calculs a été effectuée, l’exécution n’est pas terminée, l’exécution du programme est stoppée par l’EVM. Cela fait de l’EVM une machine quasi–Turing-complète : elle peut exécuter n’importe quel programme que vous lui introduisez, mais seulement si le programme se termine dans un certain nombre de calculs. Cette limite n’est pas fixée dans Ethereum - vous pouvez payer pour l’augmenter jusqu’à un maximum (appelé "limite de gaz de bloc"), et tout le monde peut accepter d’augmenter ce maximum au fil du temps. Néanmoins, à tout moment, il y a une limite en place, et les transactions qui consomment trop de gaz lors de l’exécution sont stoppées.

Dans les sections suivantes, nous examinerons le gaz et examinerons en détail son fonctionnement.

Gaz

Le gaz est l’unité d’Ethereum pour mesurer les ressources de calcul et de stockage nécessaires pour effectuer des actions sur la chaîne de blocs Ethereum. Contrairement à Bitcoin, dont les frais de transaction ne tiennent compte que de la taille d’une transaction en kilo-octets, Ethereum doit tenir compte de chaque étape de calcul effectuée par les transactions et l’exécution du code de contrat intelligent.

Chaque opération effectuée par une transaction ou un contrat coûte une quantité fixe de gaz. Quelques exemples, tirés du Ethereum Yellow Paper :

  • L’ajout de deux nombres coûte 3 gaz

  • Le calcul d’un hachage Keccak-256 coûte 30 gaz + 6 gaz pour chaque 256 bits de données hachés

  • L’envoi d’une transaction coûte 21 000 gaz

Le gaz est un composant crucial d’Ethereum et joue un double rôle : comme tampon entre le prix (volatile) d’Ethereum et la récompense des mineurs pour le travail qu’ils font, et comme défense contre les attaques par déni de service. Pour éviter les boucles infinies accidentelles ou malveillantes ou tout autre gaspillage de calcul dans le réseau, l’initiateur de chaque transaction est tenu de fixer une limite à la quantité de calcul qu’il est prêt à payer. Le système de gaz décourage ainsi les attaquants d’envoyer des transactions de "spam", car ils doivent payer proportionnellement pour les ressources de calcul, de bande passante et de stockage qu’ils consomment.

Comptabilisation du gaz pendant l’exécution

Lorsqu’une EVM est nécessaire pour effectuer une transaction, en premier lieu il reçoit un approvisionnement en gaz égal au montant spécifié par la limite de gaz dans la transaction. Chaque opcode exécuté a un coût en gaz, et donc l’approvisionnement en gaz de l’EVM est réduit au fur et à mesure que l’EVM avance dans le programme. Avant chaque opération, l’EVM vérifie qu’il y a suffisamment de gaz pour payer l’exécution de l’opération. S’il n’y a pas assez de gaz, l’exécution est interrompue et la transaction est annulée.

Si l’EVM atteint la fin de l’exécution avec succès, sans manquer de gaz, le coût du gaz utilisé est payé au mineur sous forme de frais de transaction, converti en ether sur la base du prix du gaz spécifié dans la transaction :

frais de mineur = coût du gaz * prix du gaz

Le gaz restant dans l’approvisionnement en gaz est remboursé à l’expéditeur, à nouveau converti en ether sur la base du prix du gaz spécifié dans la transaction :

gaz restant = limite de gaz - coût du gaz
ether remboursé = gaz restant * prix du gaz

Si la transaction « manque de gaz » pendant l’exécution, l’opération est immédiatement terminée, ce qui déclenche une exception « à court de gaz » (OOG). La transaction est annulée et toutes les modifications apportées à l’état sont annulées.

Bien que la transaction ait échoué, l’expéditeur se verra facturer des frais de transaction, car les mineurs ont déjà effectué le travail de calcul jusqu’à ce point et doivent être indemnisés pour cela.

Considérations sur la comptabilisation du gaz

Les coûts relatifs du gaz des différentes opérations pouvant être effectuées par l’EVM ont ont été soigneusement choisis pour protéger au mieux la chaîne de blocs Ethereum contre les attaques. Vous pouvez voir un tableau détaillé des coûts de gaz pour différents opcodes EVM dans [evm_opcodes_table].

Les opérations plus intensives en calcul coûtent plus de gaz. Par exemple, l’exécution de la fonction SHA3 est 10 fois plus coûteuse (30 gaz) que l’opération ADD (3 gaz). Plus important encore, certaines opérations, telles que EXP, nécessitent un paiement supplémentaire en fonction de la taille de l’opérande. L’utilisation de la mémoire EVM et le stockage des données dans le stockage en chaîne d’un contrat entraînent également un coût en gaz.

L’importance de faire correspondre le coût du gaz au coût réel des ressources a été démontrée en 2016 lorsqu’un attaquant a découvert et exploité une inadéquation des coûts. L’attaque a généré des transactions très coûteuses en calcul et a presque paralysé le réseau principal Ethereum. Cette inadéquation a été résolue par un hard fork (nom de code "Tangerine Whistle") qui a modifié les coûts relatifs du gaz.

Coût du gaz versus prix du gaz

Alors que le coût du gaz est une mesure de calcul et de stockage utilisée dans l’EVM, le gaz lui-même a également un prix mesuré en ether. Lors d’une transaction, l’expéditeur spécifie le prix du gaz qu’il est prêt à payer (en ether) pour chaque unité de gaz, permettant au marché de décider de la relation entre le prix de l’ether et le coût des opérations informatiques (mesuré en gaz) :

frais de transaction = total de gaz utilisé * prix du gaz payé (en ether)

Lors de la construction d’un nouveau bloc, les mineurs du réseau Ethereum peuvent choisir parmi les transactions en attente en sélectionnant celles qui proposent de payer un prix du gaz plus élevé. Offrir un prix du gaz plus élevé incitera donc les mineurs à inclure votre transaction et à la faire confirmer plus rapidement.

En pratique, l’expéditeur d’une transaction fixera une limite de gaz supérieure ou égale à la quantité de gaz qu’il s’attend à utiliser. Si la limite de gaz est fixée à une valeur supérieure à la quantité de gaz consommée, l’expéditeur recevra un remboursement du montant excédentaire, car les mineurs ne sont indemnisés que pour le travail qu’ils effectuent réellement.

Il est important d’être clair sur la distinction entre le coût du gaz et le prix du gaz. Récapituler:

  • Le coût du gaz est le nombre d’unités de gaz nécessaires pour effectuer une opération particulière.

  • Le prix du gaz est la quantité d’ether que vous êtes prêt à payer par unité de gaz lorsque vous envoyez votre transaction au réseau Ethereum.

Tip

Alors que le gaz a un prix, il ne peut pas être "possédé" ni "dépensé". Le gaz n’existe qu’à l’intérieur de l’EVM, en tant que décompte de la quantité de travail de calcul effectué. L’expéditeur se voit facturer des frais de transaction en ether, qui sont ensuite convertis en gaz pour la comptabilité EVM, puis de nouveau en ether en tant que frais de transaction payés aux mineurs.

Coûts de gaz négatifs

Ethereum encourage la suppression des variables et des comptes de stockage utilisés en remboursant une partie du gaz utilisé lors de l’exécution du contrat.

Il y a deux opérations dans l’EVM avec des coûts de gaz négatifs :

  • La suppression d’un contrat (SELFDESTRUCT)vaut un remboursement de 24 000 gaz.

  • Changer une adresse de stockage d’une valeur différente de zéro à zéro (SSTORE[x] = 0)vaut un remboursement de 15 000 gaz.

Pour éviter l’exploitation du mécanisme de remboursement, le remboursement maximum pour une transaction est fixé à la moitié de la quantité totale de gaz consommée (arrondie à l’inférieur).

Limite de gaz du bloc

La limite de gaz de bloc est la quantité maximale de gaz pouvant être consommée par toutes les transactions d’un bloc et limite le nombre de transactions pouvant tenir dans un bloc.

Par exemple, supposons que nous ayons 5 transactions dont les limites de gaz ont été fixées à 30 000, 30 000, 40 000, 50 000 et 50 000. Si la limite de gaz du bloc est de 180 000, alors quatre de ces transactions peuvent tenir dans un bloc, tandis que la cinquième devra attendre un futur bloc. Comme indiqué précédemment, les mineurs décident des transactions à inclure dans un bloc. Différents mineurs sont susceptibles de sélectionner différentes combinaisons, principalement parce qu’ils reçoivent des transactions du réseau dans un ordre différent.

Si un mineur essaie d’inclure une transaction qui nécessite plus de gaz que la limite actuelle de gaz du bloc, le bloc sera rejeté par le réseau. La plupart des clients Ethereum vous empêcheront d’émettre une telle transaction en donnant un avertissement du type "la transaction dépasse la limite de gaz du bloc". La limite de gaz de bloc sur le réseau principal Ethereum est de 8 millions de gaz au moment de la rédaction selon https://etherscan.io, ce qui signifie qu’environ 380 transactions de base (chacune consommant 21 000 gaz) pourraient tenir dans un bloc.

Qui décide de la limite de gaz du bloc ?

Les mineurs du réseau décident collectivement de la limite de gaz du bloc. Les personnes qui souhaitent exploiter sur le réseau Ethereum utilisent un programme de minage, tel qu’Ethminer, qui se connecte à un client Geth ou Parity Ethereum. Le protocole Ethereum a un mécanisme intégré où les mineurs peuvent voter sur la limite de gaz afin que la capacité puisse être augmentée ou diminuée dans les blocs suivants. Le mineur d’un bloc peut voter pour ajuster la limite de gaz du bloc d’un facteur de 1/1 024 (0,0976 %) dans les deux sens. Le résultat est une taille de bloc ajustable en fonction des besoins du réseau à ce moment-là. Ce mécanisme est couplé à une stratégie minière par défaut où les mineurs votent sur une limite de gaz d’au moins 4,7 millions de gaz, mais qui cible une valeur de 150 % de la moyenne de la consommation totale récente de gaz par bloc (en utilisant un déplacement exponentiel de 1 024 blocs en moyenne).

Conclusion

Dans ce chapitre, nous avons exploré la machine virtuelle Ethereum, en retraçant l’exécution de divers contrats intelligents et en examinant comment l’EVM exécute le code intermédiaire. Nous avons également examiné le gaz, le mécanisme de comptabilité de l’EVM, et avons vu comment il résout le problème d’arrêt et protège Ethereum des attaques par déni de service. Ensuite, dans [consensus], nous examinerons le mécanisme utilisé par Ethereum pour parvenir à un consensus décentralisé.