Skip to main content

EVM and bytecode

Architecture

The EVM is a stack machine with three data areas:

AreaLifetimeCostAddressing
StackOne execution frameCheapest1024 slots of 32 bytes; only the top 16 reachable
MemoryOne execution frameCheap, grows quadraticallyByte-addressed, linear
StoragePersistent on-chainVery expensive32-byte keys to 32-byte values

Storage writes dominate the cost of almost every contract. Reading a storage slot once into memory and working from there is the first optimisation to reach for — see Gas optimization.

Opcodes are one byte, so there are at most 256 of them. The authoritative list is in the Ethereum yellow paper; evm.codes is the practical reference, with current gas costs.

Common opcodes

0x15 ISZERO ; Logical NOT: pushes 1 if the top of stack is 0
0x34 CALLVALUE ; Wei sent with the call that triggered this execution
0x39 CODECOPY ; Copy code running in the current environment into memory
0x50 POP ; Discard the top stack item
0x52 MSTORE ; Write a 32-byte word to memory
0x54 SLOAD ; Read a word from storage
0x55 SSTORE ; Write a word to storage
0x56 JUMP ; Set the program counter
0x57 JUMPI ; Set the program counter if the condition is non-zero
0x5b JUMPDEST ; Mark a valid jump target
0x60 PUSH1 ; Push a 1-byte immediate onto the stack
0x80 DUP1 ; Duplicate the top stack item
0xf3 RETURN ; Halt and return output data

Note JUMPI, not "JUMP1" — the I is for if. Jumps may only land on a JUMPDEST; any other target aborts execution, which is what stops an attacker jumping into the middle of an instruction.

The program counter indexes into the bytecode, and every instruction is one byte plus any immediate data. PUSH1 0x80 occupies two bytes, so the next instruction sits at PC + 2.

Bytecode and deployed bytecode

Two distinct artefacts, and mixing them up wastes a lot of time:

ArtefactWhat it isWhere it appears
bytecode (creation code)Constructor plus the runtime code it returnsThe data field of the contract-creation transaction
deployedBytecode (runtime code)What lives at the contract address afterwardsReturned by eth_getCode
// Runtime code at a deployed address
await web3.eth.getCode("0xCONTRACT_ADDRESS")

The creation code contains the runtime code — it ends by copying it into memory with CODECOPY and returning it with RETURN. That return value is what the network stores. So the runtime code is a subsequence of the creation code, not the other way round; the constructor logic runs once and is then discarded.

This is why a constructor cannot be called again, and why constructor arguments are appended to the creation code rather than stored.

Storage layout

Storage is a mapping of 32-byte slots. The compiler assigns them in declaration order, packing smaller types together where they fit:

contract Layout {
uint128 a; // slot 0, bytes 0–15
uint128 b; // slot 0, bytes 16–31 — packed with a
uint256 c; // slot 1 — needs a full slot
uint8 d; // slot 2, byte 0
}

Packing saves storage but costs gas when only one packed variable is written, because the EVM reads the whole slot, modifies part of it and writes it back. Group variables that are read and written together.

Dynamic types

uint[] public cc; // length at slot p
// elements at keccak256(p) + index

mapping(address => uint) public balances; // value at keccak256(key . p)

For an array declared at slot p, the length lives in p and element i lives at keccak256(p) + i. For a mapping, the value for key lives at keccak256(key . p) — the key concatenated with the slot number, each padded to 32 bytes, then hashed.

Mappings have no length and cannot be enumerated, because the slots are scattered across the whole 2²⁵⁶ space by the hash.

// Read a raw slot, bypassing any getter
await web3.eth.getStorageAt(address, slot)

getStorageAt reads everything, including variables marked private. Nothing in contract storage is confidential — private restricts access from other contracts, not from observers.

Memory layout

Solidity reserves the first four 32-byte words:

RangePurpose
0x000x3fScratch space for hashing
0x400x5fFree memory pointer
0x600x7fZero slot — the initial value for empty dynamic arrays
0x80Where allocation begins

The free memory pointer at 0x40 always holds the address of the next unused byte. Inline assembly that allocates memory must read it, use the space and advance it; overwriting it corrupts every later allocation.

assembly {
let ptr := mload(0x40) // read the free memory pointer
mstore(ptr, someValue) // use the space
mstore(0x40, add(ptr, 0x20)) // advance it
}

mload(0xAB) loads the 32-byte word starting at byte 0xAB — note that memory is byte-addressed, so loads may straddle word boundaries.

Memory is charged for the highest byte touched, and the cost grows quadratically past 724 bytes. Repeatedly expanding memory in a loop is a real gas trap.