Comment fonctionne l'EVM ? Décoder la Machine Virtuelle Ethereum
1. Aperçu
L’EVM (Ethereum Virtual Machine) est la « CPU » qui exécute tous les smart contracts sur Ethereum. Comprendre comment l’EVM exécute le bytecode vous aide à lire les traces de transaction, à déboguer les erreurs out of gas, et à écrire des contrats Solidity plus économes en gas. Cet article va du modèle de compte, l’architecture basée sur la stack, au calcul du gas, avec des exemples en Solidity et du code Java (Web3j) pour que vous voyiez l’ensemble du flux du bytecode au résultat d’exécution.
2. Qu’est-ce que l’EVM
L’EVM est une machine virtuelle basée sur la stack, déterministe (le même input produit toujours le même output), qui s’exécute sur tous les nœuds Ethereum. Chaque nœud réexécute exactement les transactions d’un bloc pour se mettre d’accord sur l’état final.
La plus grande différence par rapport à la JVM ou aux CPU classiques : l’EVM n’a pas de concept de thread, pas d’I/O, ne peut pas appeler d’API externes. Tout ce dont il a besoin (données, soldes, code d’autres contrats) se trouve dans le world state — un arbre Merkle Patricia Trie qui stocke tous les comptes.
3. Modèle de compte
Ethereum a deux types de comptes :
- EOA (Externally Owned Account) : contrôlé par une clé privée, sans code.
- Contract Account : possède du
code(bytecode EVM) et son proprestorage.
Account {
nonce: nombre de transactions envoyées (EOA) ou contrats créés
balance: solde en ETH (unité wei)
storage: Merkle Trie clé-valeur 256-bit (compte contrat uniquement)
codeHash: hash du bytecode (compte contrat uniquement)
}
Quand vous envoyez une transaction à une adresse qui a du code, l’EVM charge ce code et commence à l’exécuter — c’est à ce moment que la « machine virtuelle » entre en jeu.
4. Architecture de la machine virtuelle : Stack, Memory, Storage, Calldata
L’EVM a quatre régions de données principales, chacune différente en termes de coût gas et de durée de vie.
4.1. Stack
La stack contient des mots 256-bit, maximum 1024 éléments. Toutes les opérations (ADD, MUL, SUB…) prennent les opérandes depuis la stack et ramènent le résultat dessus.
PUSH1 0x05 ; stack: [5]
PUSH1 0x03 ; stack: [5, 3]
ADD ; stack: [8]
Ces trois opcodes exécutent 5 + 3 : deux PUSH1 poussent les nombres 5 et 3 sur la stack, ADD prend les deux éléments du haut, les additionne, et ramène le résultat 8 sur la stack.
4.2. Memory
Memory est une région de tableau d’octets temporaire, qui n’existe que pendant l’exécution d’un appel (call). Elle est utilisée pour les grandes données comme des struct, des array avant de les renvoyer ou de les écrire dans le storage.
4.3. Storage
Storage est une map clé-valeur (uint256 -> uint256) persistante, attachée à chaque contrat, écrite dans l’arbre d’état de la blockchain. C’est la région qui coûte le plus cher en gas puisqu’elle doit être consensuelle et conservée de façon permanente.
4.4. Calldata
Calldata est la donnée d’entrée de la transaction — lecture seule, elle ne coûte pas de gas à « allouer » comme la memory.
Note : la confusion entre
memoryetstorageen Solidity est l’erreur la plus courante des débutants — déclarer une variablestorageinutilement fait exploser le coût en gas.
5. Bytecode et Opcode
Solidity compile en bytecode, une chaîne hex. Chaque octet (ou un octet + opérande) correspond à un opcode — une instruction bas niveau que l’EVM comprend directement.
Exemple d’un contrat qui additionne deux nombres et stocke le résultat :
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Counter {
uint256 public count;
function increment() external {
count += 1;
}
}
Compilez avec solc :
solc --bin --asm Counter.sol
PUSH1 0x01
PUSH1 0x00
DUP2
SLOAD
ADD
SWAP1
SSTORE
Cette séquence d’opcodes lit count du storage (SLOAD), ajoute 1 (ADD), puis le réécrit (SSTORE) — exactement ce que count += 1 fait.
Pour voir comment l’EVM décode le bytecode en opcodes, voici un décodeur Java simple (pas besoin d’un vrai nœud, juste pour illustrer le mécanisme) :
public class OpcodeDecoder {
private static final Map<Integer, String> OPCODES = Map.of(
0x00, "STOP",
0x01, "ADD",
0x54, "SLOAD",
0x55, "SSTORE",
0x60, "PUSH1"
);
public List<String> decode(String hexBytecode) {
byte[] code = HexFormat.of().parseHex(hexBytecode.replace("0x", ""));
List<String> ops = new ArrayList<>();
int i = 0;
while (i < code.length) {
int opcode = code[i] & 0xFF;
String name = OPCODES.getOrDefault(opcode, "UNKNOWN_0x" + Integer.toHexString(opcode));
ops.add(name);
// PUSH1 a 1 octet d'opérande qui suit, il faut sauter par-dessus
i += name.equals("PUSH1") ? 2 : 1;
}
return ops;
}
}
OPCODESmappe le code hex au nom de l’opcode (la vraie table en a 140+).decodelit chaque octet, cherche le nom de l’opcode; si c’estPUSH1alors on saute un octet de plus parce que cet opcode a un opérande qui le suit.- Le résultat est une liste d’opcodes dans l’ordre exact que l’EVM va exécuter.
Test avec JUnit 5 + AssertJ :
@Test
void decode_shouldReturnPushAddSstoreSequence() {
OpcodeDecoder decoder = new OpcodeDecoder();
List<String> ops = decoder.decode("0x600101550054");
assertThat(ops).containsExactly(
"PUSH1", "ADD", "SSTORE", "SLOAD"
);
}
Ce test affirme que le décodeur lit correctement l’ordre des opcodes du bytecode brut — c’est exactement la première étape que l’EVM fait avant d’exécuter chaque instruction.
6. Gas : unité de mesure du coût d’exécution
Chaque opcode a un coût gas fixe (selon l’EIP courant), qui reflète la charge sur la CPU/storage du nœud. La transaction doit inclure un gasLimit; si l’exécution dépasse ce seuil, l’EVM annule tous les changements d’état mais garde quand même le gas consommé (pour éviter le spam).
ADD -> 3 gas
SLOAD -> 2100 gas (cold) / 100 gas (warm)
SSTORE -> 20000 gas (création) / 2900 gas (mise à jour)
PUSH1 -> 3 gas
Un petit utilitaire pour estimer le gas d’une séquence d’opcodes :
public class GasEstimator {
private static final Map<String, Long> COST = Map.of(
"ADD", 3L,
"PUSH1", 3L,
"SLOAD", 2100L,
"SSTORE", 20000L
);
public long estimate(List<String> opcodes) {
return opcodes.stream()
.mapToLong(op -> COST.getOrDefault(op, 0L))
.sum();
}
}
@Test
void estimate_shouldSumGasForCounterIncrement() {
GasEstimator estimator = new GasEstimator();
List<String> ops = List.of("PUSH1", "SLOAD", "ADD", "SSTORE");
long gas = estimator.estimate(ops);
assertThat(gas).isEqualTo(3 + 2100 + 3 + 20000);
}
estimate additionne le gas de chaque opcode selon la table des prix; le résultat 22106 montre que juste une instruction count += 1 consomme la majorité du gas dans SSTORE — c’est pour ça que les développeurs Solidity recommandent toujours de réduire le nombre d’écritures en storage.
7. Cycle de vie du traitement d’une transaction dans l’EVM
Quand une transaction arrive à un contrat, l’EVM l’exécute selon cette séquence :
- Vérifier que le
nonce,balancesuffisent pour payergasLimit * gasPrice. - Charger
calldataetcodedu contrat destination. - Initialiser la stack, memory vide; lire le
storagecourant. - Exécuter chaque opcode selon le program counter (PC), en retranchant le gas correspondant à chaque étape.
- Si vous rencontrez
REVERTou vous manquez de gas → annuler tous les changements d’état, mais le gas consommé est quand même retenu. - Si succès → valider le nouveau storage dans le world state, émettre un
receiptavec leslogs(event).
Transaction
│
▼
Valider nonce & balance
│
▼
Charger code + calldata ──► Exécuter opcodes (PC augmente)
│ │
│ retrancher gas à chaque étape
│ │
▼ ▼
Pas assez de gas / REVERT Succès
│ │
▼ ▼
Annuler state Valider state + logs
8. Exemple pratique : appeler un contrat depuis Spring Boot avec Web3j
Pour interagir réellement avec l’EVM (via un nœud local comme Ganache/Anvil ou un testnet), vous avez besoin de la bibliothèque Web3j :
<dependency>
<groupId>org.web3j</groupId>
<artifactId>core</artifactId>
<version>4.11.1</version>
</dependency>
Un service qui lit la valeur de count d’un contrat déployé :
@Service
public class CounterService {
private final Web3j web3j;
private final String contractAddress;
public CounterService(Web3j web3j,
@Value("${evm.counter.address}") String contractAddress) {
this.web3j = web3j;
this.contractAddress = contractAddress;
}
public BigInteger readCount() throws IOException {
Function function = new Function(
"count",
Collections.emptyList(),
List.of(new TypeReference<Uint256>() {})
);
String encoded = FunctionEncoder.encode(function);
EthCall response = web3j.ethCall(
Transaction.createEthCallTransaction(null, contractAddress, encoded),
DefaultBlockParameterName.LATEST
).send();
List<Type> results = FunctionReturnDecoder.decode(
response.getValue(), function.getOutputParameters()
);
return (BigInteger) results.get(0).getValue();
}
}
Functiondécrit la fonctioncount()à appeler et son type de retour (Uint256).FunctionEncoder.encodela transforme en calldata hex (function selector + params) — c’est exactement la donnée que l’EVM charge dans la stack lors de l’exécution.ethCallenvoie une requêteeth_call— exécute l’EVM en mode lecture, ne crée pas une vrai transaction, ne consomme pas de gas.FunctionReturnDecoder.decodedécode la sortie hex renvoyée enBigInteger.
# application.properties
evm.counter.address=0x5FbDB2315678afecb367f032d93F642f64180aa
Note :
eth_callne modifie pas l’état, donc vous n’avez pas besoin degasLimitréel et il ne retourne pastransactionHash. Si vous avez besoin de modifier des données (appelerincrement()), vous devez utilisereth_sendRawTransactionavec votre clé privée pour signer la transaction — et c’est là que le gas réel est consommé.
9. Erreurs courantes et quand être prudent
- Out of gas : vous avez estimé un
gasLimittrop bas pour une boucle ou plusieurs écritures en storage. Testez toujours aveceth_estimateGasavant d’envoyer une vraie transaction. - Reentrancy : l’EVM permet au contrat A d’appeler le contrat B, et B de rappeler A avant que A ne mette à jour l’état — ce qui peut mener à retirer plusieurs fois des fonds (l’attaque The DAO). Utilisez le pattern checks-effects-interactions ou le modifier
nonReentrant. - Stack too deep : le compilateur Solidity limite le nombre de variables locales à cause de la stack qui n’a que 1024 emplacements; divisez votre fonction en plus petites fonctions quand vous rencontrez cette erreur.
- Ne pas exécuter une logique lourde en calcul (hash complexe, grosse boucle) directement on-chain — chaque itération multiplie le coût en gas, il est mieux de calculer off-chain puis soumettre le résultat avec une preuve si possible.
10. Conclusion
L’EVM est une machine virtuelle basée sur la stack simple par son ensemble d’instructions mais stricte sur le gas et l’état — chaque opcode a un prix, chaque changement de storage est permanent et coûteux. Bien comprendre la stack/memory/storage, comment le gas est calculé, et le cycle de vie d’une transaction vous aide à écrire des smart contracts plus efficaces et à déboguer quand une transaction est annulée. Si vous travaillez régulièrement avec des contrats, entraînez-vous à lire les traces d’opcodes réelles avec evm disasm ou le debugger dans Hardhat/Foundry — c’est bien plus intuitif que de simplement lire le Solidity.