Services Approach Projects Research About Request Engagement
EVM & Solidity

Solidity Assembly, Part 1: What Your Code Actually Becomes

The first assembly piece in a four part series, and the second article to read. Your contract is not deployed as Solidity, it is deployed as a few hundred bytes of opcodes, and almost everything that matters about cost and safety happens at that level. What a stack machine is, the four places data can live and what each one costs, and a real twelve-line contract compiled and read line by line, including the moment where a single plus sign turns out to be four instructions.

Beginner Solidity 9 min read Aug 12, 2026

This is the first part of a four part series that actually reads compiled code. Part 0 covered the vocabulary everything here depends on: what a smart contract is, why Ethereum cannot read Solidity, what the compiler produces instead, and what gas is counting. If any of those are unfamiliar, start there and come back.

This part assumes you have never looked at an opcode, only that you know what one is. It should still be worth reading if you write Solidity every day and want to firm up what is happening underneath it.

What follows is the foundation: what actually gets deployed, how the machine executing it works, and what your code looks like on the other side of the compiler. Everything is done against a real contract, compiled here, with the output printed as it came out.

Your Solidity is not what runs

When you deploy a contract, the Solidity does not go on chain. It is compiled to a string of bytes, and those bytes are what the network stores and executes.

The source you see on a block explorer is there because somebody uploaded it afterwards and the explorer checked that compiling it reproduces the deployed bytes. That is a useful service. It is also optional, and plenty of contracts never do it.

So the honest description of any contract is: a few hundred to a few thousand bytes, plus a pile of 32-byte numbers it has written down. Everything else is a convenience layer we put on top for our own benefit.

The EVM is a stack machine

Most programmers learn on machines with registers, named places you put values while you work on them. The EVM has none.

Instead it has a stack. You push values onto the top, and operations take their arguments off the top and push results back. To add two numbers you push both and then issue one instruction that consumes them and leaves the sum.

output
PUSH1 0x03 stack: [3] PUSH1 0x05 stack: [5, 3] ADD stack: [8]

Each of those lines is an opcode: a single byte the EVM knows how to execute. ADD is the byte 0x01. PUSH1 is 0x60 and is followed by the one byte it should push. That is the whole format. A contract is just a long run of these.

Two consequences fall out of this immediately, and they explain most of what looks strange in compiled output.

There are no variable names. A local variable in your Solidity is a position on the stack that the compiler is keeping track of for you. In the compiled output it is gone.

The stack is only reachable near the top. You can duplicate one of the top sixteen items with DUP1 through DUP16, and swap the top item with one of the next sixteen using SWAP1 through SWAP16. Anything deeper is unreachable. This is where the "stack too deep" error you may have hit actually comes from.

Four places data can live, and what each costs

This is very useful so it's worth slowing down.

Stack. Free-ish scratch space for computation. Most operations here cost 3 gas.

Memory. A byte array that exists for the duration of one call and is then discarded. Reading or writing a word costs 3 gas, plus a charge for growing it that rises quadratically as you reach further out.

Calldata. The read-only input to the call. Cheap to read.

Storage. The contract's permanent state, the only one of the four that survives the transaction.

Here is the cost table under the gas schedule set by the Fusaka upgrade in December 2025, which is still the one in force:

operationgas
ADD, SUB, LT, EQ, ISZERO3
MUL, DIV, MOD5
MLOAD, MSTORE (memory)3 + growth
SLOAD, first read of a slot2,100
SLOAD, same slot again in the same transaction100
SSTORE, first write to a slot holding zero22,100

One first-time storage read costs exactly 700 additions.

One note on that last row, because it is the most commonly misquoted number in the EVM. There is no single price for SSTORE. It is two charges added together: 20,000 for writing a slot that currently holds zero, plus a 2,100 surcharge the first time you touch that slot in a transaction. Touch it again later in the same transaction and the surcharge is gone. Overwrite a slot that already held a nonzero value and the write charge drops to 2,900. The 20,000 you see quoted everywhere is only one of the two parts.

That ratio is why experienced Solidity looks the way it does. Reading a state variable once into a local and using the local is not a style preference, it is a 21-fold saving on every use after the first. Everything you have been told about caching state variables in loops comes from this one number.

Two figures you will find in older tutorials are simply wrong now, and they are worth naming so you do not learn them: SLOAD is not 200 gas and CALL is not a flat 700 gas. Both were changed in 2021 and both still circulate.

Let us compile something

Here is the entire contract. Twelve lines.

solidity
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract Adder { uint256 public total; function add(uint256 a, uint256 b) public pure returns (uint256) { return a + b; } function store(uint256 x) public { total = x; } }

Compiled with solc 0.8.30 and no optimizer, that becomes 558 bytes. Here are the first twenty-two instructions, exactly as they came out:

output
0000 PUSH1 0x80 0002 PUSH1 0x40 0004 MSTORE 0005 CALLVALUE 0006 DUP1 0007 ISZERO 0008 PUSH2 0x000f 000b JUMPI 000c PUSH0 000d PUSH0 000e REVERT 000f JUMPDEST 0010 POP 0011 PUSH1 0x04 0013 CALLDATASIZE 0014 LT 0015 PUSH2 0x003f 0018 JUMPI 0019 PUSH0 001a CALLDATALOAD 001b PUSH1 0xe0 001d SHR

That looks like noise. It is four separate jobs, and none of them is your code.

Bytes 0000 to 0004 write the number 0x80 to memory position 0x40. Solidity reserves the first four words of memory for its own use, and the word at 0x40 holds a pointer to where free memory starts. Setting it to 0x80 says: the first 128 bytes are reserved, allocate above that.

Bytes 0005 to 0010 check CALLVALUE, the amount of ether sent. If it is not zero, the code falls through to REVERT. Neither function in this contract is payable, so the compiler hoisted a single rejection to the top rather than repeating it in each function.

Bytes 0011 to 0018 check whether the incoming calldata is at least 4 bytes long. Shorter than that and there is no function selector to read, so it jumps away to the fallback path.

Bytes 0019 to 001d are the interesting ones. CALLDATALOAD reads the first 32 bytes of the call input, and SHR shifts it right by 0xe0 bits, which is 224. That leaves the top four bytes sitting alone at the bottom of the word.

Those four bytes are the function selector.

How a contract finds your function

There is no name lookup at runtime. A function is identified by the first four bytes of the Keccak-256 hash of its signature, written with no spaces and with uint spelled out as uint256.

For our contract:

output
add(uint256,uint256) -> 0x771602f7 store(uint256) -> 0x6057361d total() -> 0x2ddbd13a

And immediately after the selector extraction above, the compiled output contains this, repeated once per function:

output
DUP1 PUSH4 0x2ddbd13a EQ PUSH2 0x0043 JUMPI

Duplicate the selector so the comparison does not consume it, push the one we are looking for, compare, and jump to that function's code if they match. Three of these in a row and then the fallback.

That is the entire dispatch mechanism. When you call a contract you are sending four bytes that the contract compares against a hardcoded list.

Where did a + b go?

You would expect return a + b to be one ADD. Here is what the compiler actually emitted for it:

output
01dd DUP3 01de DUP3 01df ADD 01e0 SWAP1 01e1 POP 01e2 DUP1 01e3 DUP3 01e4 GT 01e5 ISZERO 01e6 PUSH2 0x01f2 01e9 JUMPI 01ea PUSH2 0x01f1 01ed PUSH2 0x0198 01f0 JUMP

The ADD is there, at 01df. Everything after it is an overflow check.

GT asks whether one of the operands is greater than the result. If you add two numbers and the answer comes out _smaller_ than what you started with, the arithmetic wrapped around, and ISZERO plus JUMPI route that case to a revert.

Since Solidity 0.8, every arithmetic operation you write carries this check. Before 0.8 it did not, which is why older contracts pulled in SafeMath and why overflow was a live exploit class for years. The compiler now does it for you, and the price is that a single + costs several instructions instead of one.

This is the first genuinely useful thing assembly tells you: a line of Solidity is not a unit of work. One plus sign is four instructions and a conditional jump.

Most of your contract is not your contract

Of the 558 bytes, the arithmetic we care about is a handful. The rest is dispatch, calldata decoding, the payable guard, bounds checks, and revert helpers.

Turning the optimizer on:

output
unoptimized 558 bytes optimized 270 bytes

Less than half. The optimizer removes redundant stack shuffling, folds constants, and deduplicates the helper routines the compiler generated. Nothing about your source changed.

This is worth knowing before you start optimizing anything by hand. The compiler is already removing a large amount of what you would be tempted to remove yourself, and the version in front of you when you read unoptimized output is not what ships.

So what is inline assembly?

You can drop into this level from inside Solidity:

solidity
assembly { let x := add(1, 2) }

One correction that will save you confusion later: what goes inside that block is not raw EVM assembly. It is a language called Yul, which keeps named variables, if, switch, for, and functions, and manages the stack for you.

Because Yul manages the stack, the opcodes that would fight it are not available inside an assembly block at all. DUP, SWAP, and JUMP are not part of the language. You get the arithmetic, memory, storage and call opcodes, with names and variables layered on top.

There are real reasons to reach for it, and real things you give up. Two you should know about now:

You lose the overflow check. Yul's add is the raw ADD opcode, which wraps around silently. The guard we just walked through does not exist inside an assembly block.

You may lose compiler optimizations across your whole contract. An assembly block that touches memory without being marked memory-safe tells the compiler it can no longer reason about memory anywhere, and that disables optimizations for the entire compilation, not just that block.

Part 2 covers all of this properly.

What is in the rest of the series

Part 2, intermediate: Yul and inline assembly in earnest. The memory layout Solidity expects you to respect, when assembly genuinely wins and when it silently loses, and the memory-safe annotation and what it does and does not promise.

Part 3, advanced: reading contracts you did not write and that have no published source. Storage layout, how to compute where any value lives, and how to determine what a proxy currently points at and who is allowed to change it.

Securing the unseen

You do not need to write assembly to benefit from being able to read it. The value is in knowing what your source becomes.

A single + is an add and a conditional revert. A state variable read is 700 additions worth of gas the first time and 33 the second. A function call is four bytes compared against a list. The optimizer may be deleting half of what you are looking at.

None of that is visible in Solidity, and all of it decides what your contract costs and how it fails.

Learn more at 0xhades.io/research