Skip to main content

Type a word from a question or an article title.

3 · Under the hood

The EVM under the hood: stack, memory, storage, calldata and the cost of gas

How the Ethereum virtual machine executes a smart contract - the stack architecture, data areas, opcodes, operation prices and why storage packing saves money.

5 min readUpdated:
In this article

When you call a contract function, no "program on a server" starts. Every network node independently executes the contract's bytecode on the same virtual machine - the EVM (Ethereum Virtual Machine) - and gets a bit-identical result. That is what makes a blockchain "a computer everyone believes". Let us see how this computer is built.

A contract is bytecode

Solidity code compiles to bytecode: a sequence of bytes, each an instruction (opcode) or data for one. For example 0x01 is ADD, 0x60 is PUSH1, 0xFD is REVERT. A deployed contract is just bytes lying at its address. You can see the bytecode in a block explorer's Contract tab (see how to read Etherscan).

A stack machine

The EVM is a stack virtual machine: there are no registers like in an ordinary processor. Instructions take operands from the top of the stack and put the result back. Stack items are 256-bit words (32 bytes), maximum depth 1024.

The expression a + b executes like this:

text
PUSH1 0x02    // stack: [2]
PUSH1 0x03    // stack: [3, 2]
ADD           // stack: [5]

256 bits is no accident: it is the size of a keccak256 hash and a convenient unit for cryptography, so Solidity works with uint256 by default.

Four data areas

The EVM has four places to keep data. The differences between them are the basis of understanding gas.

AreaLifetimeAccessCost
Stackwithin one functionread and writenearly free
Memoryfor the duration of the callread and writecheap but grows quadratically with size
Calldatafor the duration of the callread onlycheap: the transaction's input data
Storageforever, while the contract livesread and writevery expensive

Storage is the contract's persistent storage, a table "slot (256 bits) → value (256 bits)". Everything the contract "remembers" between calls - balances, deals, statuses - lies here. Writing a new non-zero value costs on the order of 20,000 gas, reading a "cold" slot about 2,100, a "warm" one about 100.

Memory is a temporary buffer living until the call ends. The first bytes are cheap, but the cost of expansion grows: each word is 3 gas plus a quadratic add-on.

Calldata is the transaction's input data, which can only be read. For external functions calldata arguments are cheaper than memory: no copying.

Gas: every instruction has its price

Each opcode spends gas. An approximate order of prices:

  • arithmetic (ADD, MUL) - units of gas;
  • reading storage (SLOAD) - 100–2,100;
  • writing storage (SSTORE) - from 2,900 to 20,000 and more;
  • calling another contract (CALL) - hundreds of gas plus the callee's work;
  • emitting an event (LOG) - depends on the number of topics and data size; cheaper than storage;
  • creating a contract - tens of thousands.

Hence a practical optimisation rule: the main money goes on storage. Saving on addition is hard, while reducing the number of storage writes is easy.

Storage packing

A slot is 32 bytes. Small types can be packed into one slot if they go in a row. This is how the deal structure in our contract is arranged:

solidity
struct Deal {
    // slot 0
    address buyer;           // 20 bytes
    uint96 amount;           // 12 bytes - together exactly 32
    // slot 1
    address seller;          // 20 bytes
    uint16 arbiterBaseFeeBps;    // 2
    uint16 arbiterDisputeFeeBps; // 2
    uint64 deadline;             // 8 - together exactly 32
    // ... 5 slots in total
}

An address takes 20 bytes, uint96 12. Together they fill a slot exactly. If the amount were stored as uint256 an extra slot would be needed, and so an extra 20,000+ gas when creating a deal and an extra 2,100 on each read.

The price of packing is a limit: uint96 holds numbers up to ~7.9 × 10²⁸, which for a token amount with 18 decimals is about 79 billion units. The contract checks for overflow and rejects amounts that are too large (the AmountTooLarge error), so there is no "silent" truncation. The order of fields in the structure is chosen for packing and must not be changed "for beauty".

Calls between contracts

Contracts call each other in three main ways:

  • CALL - an ordinary call: the callee has its own state, its own storage. This is how escrow calls a token's transfer.
  • STATICCALL - a read-only call: any attempt to write reverts the transaction. This is how view functions are called.
  • DELEGATECALL - executing someone else's code in the context of your own storage. Proxy contracts (upgradable contracts) are built on it. A powerful and dangerous tool: a mistake here lets the logic be swapped.

Every external call is a point where control passes to other code. On the consequences - the article on reentrancy.

Revert and state rollback

If an error occurs during execution - revert fires, gas runs out - all state changes are rolled back as if the transaction never happened. Only the spent gas remains. That is why checks in a contract go at the start of a function: an error quickly and cheaply cancels everything.

Custom errors (error NotAuthorized();) are more compact than strings and cost less. How they are encoded is in the article on ABI.

EVM versions and hard forks

The set of opcodes grows with hard forks: PUSH0 appeared in Shanghai, TSTORE and MCOPY in Cancun. A contract compiled for a new version may not run on a network where the fork is not yet active. Our contract is built for the paris version and deliberately does not use these opcodes: the bytecode runs on any EVM network and is byte-identical - a condition for the address being the same everywhere (see CREATE2).

When you call a function, the arguments turn into bytes by strict rules. Exactly how - ABI: call encoding and selectors. And to learn to read the code of contracts themselves, see the Solidity tour.

Mentioned in

Ready to try it?

Create your first deal in a couple of minutes. The money is locked in a smart contract and paid out by rules you can see in advance.