Mobile wallpaper 1Mobile wallpaper 2Mobile wallpaper 3
1158 words
6 minutes
Geth(7) QA

Q1: How does the interpreter loop (Run) work?#

Run() is the heart of the EVM — an infinite for loop that executes bytecode instructions one at a time.

Before entering Run#

evm.Call() sets up the execution context before handing off to the interpreter:

evm.Call(sender, to, input, gas, value)
1. depth > 1024? → prevent infinite recursion
2. sufficient balance? → check caller can afford value transfer
3. snapshot state → for rollback on error
4. is precompile? → call Go function directly, skip interpreter
5. load target bytecode → resolveCode(addr)
6. create Contract object → packages caller, address, gas, code
7. enter Run()

On error, RevertToSnapshot rolls back state. If the error is REVERT, remaining gas is returned; for other errors (e.g., OOG), all gas is consumed.

Inside Run#

Upon entering Run(), per-call-frame resources are allocated:

evm.depth++ // call depth +1
stack = newstack() // from sync.Pool
mem = NewMemory() // fresh memory buffer
scope = &ScopeContext{Memory: mem, Stack: stack, Contract: contract}
pc = 0 // program counter starts at 0

Then the main loop runs, with 7 steps per iteration:

for {
① op = contract.GetOp(pc) // fetch bytecode[pc] as OpCode
② operation = jumpTable[op] // lookup: get execute func, gas, stack bounds
③ validate stack: enough elements? would overflow?
④ deduct constantGas (fixed cost, e.g., ADD=3)
⑤ if dynamicGas: compute + deduct (e.g., SLOAD cold/warm cost)
⑥ if memorySize: expand memory + charge memory gas
⑦ operation.execute(&pc, evm, scope) // execute!
pc++
}

Walking through an ADD instruction:

① pc=5, bytecode[5] = 0x01 (ADD)
② jumpTable[0x01] = {execute: opAdd, constantGas: 3, minStack: 2, maxStack: 1025}
③ stack has 4 elements ≥ 2 ✓, 4-2+1=3 ≤ 1024 ✓
④ contract.Gas -= 3
⑤ ADD has no dynamicGas — skip
⑥ ADD doesn't touch memory — skip
⑦ opAdd: pop two numbers, add them, leave result on stack top
pc++ → pc=6

How the loop exits#

  • STOP / RETURN — return sentinel errStopToken, Run() clears it to nil, normal exit
  • REVERT — return error + return data, caller rolls back state but refunds remaining gas
  • ErrOutOfGas — not enough gas to deduct, loop breaks
  • ErrStackUnderflow/Overflow — stack validation fails
  • JUMP to invalid positionErrInvalidJump

On frame exit, the stack is returned to the pool, memory is freed, and evm.depth--.

Special handling for JUMP#

Most opcodes just let pc++ advance to the next instruction. But JUMP and JUMPI modify *pc directly inside their execute function:

func opJump(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) {
pos := scope.Stack.pop()
if !scope.Contract.validJumpdest(&pos) {
return nil, ErrInvalidJump // can't jump into PUSH data
}
*pc = pos.Uint64() - 1 // -1 because the loop does pc++ after
return nil, nil
}

validJumpdest uses a bitmap to mark which byte positions are real opcodes (vs. embedded data inside PUSH instructions), preventing jumps into the middle of data.


Q2: How do Call, StaticCall, and DelegateCall differ?#

All three are ways for one contract to invoke another, but they differ in context and permissions.

Call — the standard invocation#

Contract A calls Contract B
├─ Executes B's code
├─ Runs in B's context (msg.sender = A, address = B)
├─ Can transfer ETH
└─ Can modify B's storage

A straightforward “I call you.” B sees msg.sender as A, address(this) as B.

StaticCall — read-only invocation#

Contract A static-calls Contract B
├─ Executes B's code
├─ readOnly = true
├─ Cannot transfer ETH (no value parameter)
├─ Cannot modify any state (SSTORE, LOG, CREATE, SELFDESTRUCT all forbidden)
└─ Violation → ErrWriteProtection

Used for view / pure function calls. Guarantees the call produces no side effects.

DelegateCall — “borrow the code”#

Contract A delegate-calls Contract B
├─ Executes B's code
├─ But runs in A's context!
│ ├─ msg.sender = A's caller (not A)
│ ├─ address(this) = A (not B)
│ └─ storage reads/writes target A's storage (not B's)
├─ Cannot transfer ETH
└─ B's code operates on A's state

This is the foundation of the proxy pattern. Proxy contract A holds no business logic — it delegate-calls logic contract B, but all state changes happen on A.

The key difference in code:

// Call: caller=A, address=B
contract := NewContract(A, B, value, gas, ...)
// DelegateCall: caller=A's caller, address=A
contract := NewContract(originCaller, A, value, gas, ...)

Comparison table#

CallStaticCallDelegateCall
Whose code executesBBB
msg.senderAAA’s caller
address(this)BBA
Storage operationsB’sB’s (read-only)A’s
Can transfer ETHYesNoNo
Can modify stateYesNoYes (modifies A)

Create / Create2#

Contract creation takes a different path but follows a similar structure:

  1. Compute new address — CREATE: keccak256(rlp([sender, nonce])). CREATE2: keccak256(0xff ++ sender ++ salt ++ keccak256(initCode)).
  2. Collision check — target address must not have non-zero nonce, non-empty code, or non-empty storage.
  3. Create account, transfer value, set nonce to 1.
  4. Execute init code (constructor). The returned bytes become the deployed code.
  5. Verify code size ≤ 24,576 bytes.
  6. Charge code storage gas: len(code) × 200.

Q3: How does SSTORE gas work and why is it so complex?#

SSTORE has the most complex gas rules in the EVM. The complexity stems from a core principle: the cost of writing storage should reflect the actual change you cause, not how many times you call SSTORE.

Three values#

Gas calculation is based on comparing three values:

original — the slot's value at the start of the transaction (read from disk)
current — the present value (may have been modified earlier in this transaction)
new — the value you're writing now

Scenario breakdown#

Scenario 1: current == new (writing the same value)

Slot is currently 42, you write 42
→ 100 gas (warm read price)

Nothing changes, minimal charge.

Scenario 2: original == current, original == 0 (zero → non-zero)

Slot has never been used, you write 42
→ 20,000 gas

Creates a brand-new node in the trie. Most expensive.

Scenario 3: original == current, original != 0 (non-zero → different non-zero)

Slot is 42, you change it to 100
→ 2,900 gas

Node already exists, just updating the value.

Scenario 4: original == current, new == 0 (non-zero → zero)

Slot is 42, you clear it to 0
→ 2,900 gas + refund

Deletes a trie node, rewarded with a refund.

Scenario 5: original != current (dirty slot — already modified in this transaction)

Transaction start: slot = 42 (original)
First write: slot = 100 (current, cost 2,900 gas)
Second write: slot = 200 (new)
→ Second write costs only 100 gas (warm read)

You already paid the big price for this slot’s change on the first write. Subsequent modifications are charged the minimum.

Warm / Cold stacking#

All scenarios above also stack with EIP-2929 warm/cold rules:

First access to this slot (cold) → additional +2,100 gas
Subsequent access (warm) → no extra charge

So a completely cold slot written from zero to non-zero: 2,100 (cold) + 20,000 (set) = 22,100 gas.

Why this design#

The core goal is net gas metering. If a transaction writes the same slot 5 times, you only pay for “the difference between the final result and the original value,” not for 5 separate writes. This encourages contract developers to freely use temporary variables (e.g., updating a slot in a loop) without worrying about gas explosion.

The refund mechanism follows the same philosophy: clearing a slot frees a trie node, so you get a refund. But if you clear a slot and then write it back to non-zero in the same transaction, the refund is canceled — because the net effect is no node was freed.

Geth(7) QA
https://kehaozheng.vercel.app/posts/chainethgeth/07_qa/
Author
Kehao Zheng
Published at
2026-04-16
License
CC BY-NC-SA 4.0

Some information may be outdated