This is the second of the three assembly parts. Part 1 took a twelve line contract, compiled it, and read the output. If you have not read it, the useful things to carry forward are that the EVM is a stack machine, that a first storage read costs exactly 700 times what an addition costs, and that the first three instructions of almost every contract are PUSH1 0x80, PUSH1 0x40, MSTORE.
This part is about writing assembly rather than reading it. Every gas figure below is measured, not quoted, and the conditions are the same throughout: solc 0.8.30, optimizer on, 200 runs. Measuring a first call against a second call produces differences of two thousand gas that have nothing to do with the code being compared.
Yul is a language, not a list of opcodes
The block you write is not raw EVM assembly:
assembly {
let x := add(1, 2)
}
That is Yul. It has named variables, if, switch, for, and user defined functions. It manages the stack for you, and because it manages the stack, the opcodes that would fight it are not in the language at all. DUP, SWAP and JUMP are unavailable. You cannot hand place values on the stack.
The shape is small enough to summarize:
assembly {
let a := 1 // declare
a := add(a, 2) // assign, not `=`
let b := sload(0) // opcodes are called like functions
if lt(a, 5) { a := 0 } // no parentheses around the condition
switch b
case 0 { a := 1 }
default { a := 2 }
for { let i := 0 } lt(i, 10) { i := add(i, 1) } {
a := add(a, i)
}
}
There are no operators. add(a, b) rather than a + b, lt(a, b) rather than a < b. There are no types. Everything is a 256 bit word, and it is up to you to remember whether a given word is a number, an address, or a hash.
The memory layout you are required to respect
Solidity reserves the first 128 bytes of memory:
| range | purpose |
|---|---|
0x00 to 0x3f | scratch space, 64 bytes, free for you to use |
0x40 to 0x5f | the free memory pointer |
0x60 to 0x7f | the zero slot, must stay zero |
Allocation begins at 0x80. That is what PUSH1 0x80 PUSH1 0x40 MSTORE was doing at the top of every contract in Part 1: writing the value 0x80 into address 0x40, initializing the pointer.
The free memory pointer is the whole allocation scheme. When Solidity needs memory it reads 0x40, uses the memory starting there, and writes back a higher value. There is no deallocation and no garbage collector. If you write past the pointer without advancing it, the next allocation lands on top of your data. If you advance it without writing, you have leaked memory, which costs gas but breaks nothing.
Scratch space is the exception, and it is the reason the next example works. Those 64 bytes belong to you between statements. Nothing persists there.
Where assembly genuinely wins
Hashing two values is the clearest honest case. The ordinary way:
function hashSol(uint256 a, uint256 b) external pure returns (bytes32) {
return keccak256(abi.encodePacked(a, b));
}
abi.encodePacked returns a bytes memory. It reads the free memory pointer, writes a 32 byte length prefix followed by the 64 bytes of data, advances the pointer, and then hashes that region.
The assembly way uses scratch space and allocates nothing:
function hashYul(uint256 a, uint256 b) external pure returns (bytes32 h) {
assembly ("memory-safe") {
mstore(0x00, a)
mstore(0x20, b)
h := keccak256(0x00, 0x40)
}
}Two words fit in 64 bytes exactly, which is the size of scratch space. Measured:
hashSol 484 gas
hashYul 408 gas76 gas saved, and nothing given up. The two functions return identical hashes. This is the pattern behind most of the assembly you will find in well regarded libraries: not complex arithmetic, but avoiding an allocation the compiler cannot avoid on your behalf.
The second genuine win is not about gas at all. When a call you make reverts, Solidity cannot re-revert with the failure it received. A low level call has handed you the returndata since 0.5.0, but there is no statement in the language that says "fail again with exactly this". The closest you can write throws away what actually happened:
(bool ok, ) = t.call(abi.encodeWithSignature("boom()"));
if (!ok) revert("call failed");
If the callee reverted with Insufficient(3, 10), the caller now reports call failed, and the values 3 and 10 are gone. Assembly can re-revert with the exact bytes:
(bool ok, bytes memory ret) = t.call(abi.encodeWithSignature("boom()"));
if (!ok) {
assembly { revert(add(ret, 0x20), mload(ret)) }
}
ret is a bytes memory, so its first word is the length and the data starts 32 bytes later. That is what mload(ret) and add(ret, 0x20) are reading. Tested against a callee reverting with a custom error, the first version surfaces call failed and the second surfaces Insufficient(3, 10) with both arguments intact.
This one matters for anything that wraps calls: routers, multicalls, proxies, batch executors. Swallowing the callee's revert reason is how a contract becomes difficult to debug in production.
Where it silently loses
Now the same exercise on a storage read. The state variable is uint256 public total, sitting in slot 0:
function readSol() external view returns (uint256) {
return total;
}
function readYul() external view returns (uint256 v) {
assembly { v := sload(total.slot) }
}
Both perform exactly one SLOAD of slot 0. Measured, warm:
readSol 278 gas
readYul 322 gasThe assembly version costs 44 gas more. The compiler already emits a minimal sequence for a plain state variable read, and an assembly block gives the optimizer less room around it rather than more.
This is the part that gets left out when assembly is recommended. It is not automatically cheaper. It is cheaper when it removes work, such as an allocation, and it is more expensive when there was no work to remove. If you are reaching for assembly without measuring both versions, you are guessing.
The memory-safe annotation and what it actually buys
You will see this annotation everywhere:
assembly ("memory-safe") { ... }It is a promise you make to the compiler: that your block only touches scratch space, memory you allocated by advancing the free memory pointer, and memory outside the region the compiler is managing. The compiler does not verify the promise. It takes your word and optimizes on that basis, so an incorrect annotation is a miscompilation you asked for.
What it buys is easiest to show by removing it. Here is a contract with two dozen live local variables spanning an assembly block that writes only to scratch space:
function f(uint256[24] calldata v) external pure returns (uint256 s) {
uint256 a0 = v[0];
// ... a1 through a23 ...
assembly {
mstore(0x00, 1)
s := keccak256(0x00, 0x20)
}
s = s + a0 + a1 + /* ... */ + a23;
}Compiled through the IR pipeline with the optimizer on:
YulException: Cannot swap Variable returnValue_3 with Variable returnValue_20:
too deep in the stack by 1 slots
No memoryguard was present. Consider using memory-safe assembly only and
annotating it via 'assembly ("memory-safe") { ... }'.
Add ("memory-safe") to that same block, change nothing else, and it compiles to 432 bytes.
The reason is that when the compiler runs out of reachable stack slots it moves variables into memory. It cannot do that if an assembly block might be writing anywhere in memory, because it has nowhere safe to put them. The annotation restores the option. The compiler names the fix in its own error message.
Stack too deep, and why it exists
The EVM is a stack machine whose DUP and SWAP instructions reach only 16 slots down. A value sitting seventeenth is not addressable. It is still there, and there is no instruction that will reach it.
So when a function has too many live local variables at once, the compiler genuinely cannot generate code. The same contract above, compiled through the older legacy pipeline, does not even get as far as the IR error:
CompilerError: Stack too deep. Try compiling with --via-ir while enabling
the optimizer. Otherwise, try removing local variables.
Three fixes, in the order worth trying: enable the IR pipeline, mark your assembly blocks memory-safe where they honestly are, and group related locals into a struct so fewer values are live at once.
When not to reach for it
Assembly is worth it when you are removing an allocation, forwarding raw calldata or returndata, or reaching something Solidity does not expose. It is not worth it for arithmetic the compiler already handles or for simple storage reads.
The measurements above came from two pairs of functions written specifically to be compared. One saved 76 gas and one lost 44. That ratio is worth sitting with before optimizing a contract by hand.
What is in Part 3
Part 3, advanced: reading contracts you did not write and that have no published source. Where any value lives given a storage layout, how mappings and dynamic arrays are addressed, and how to determine what a proxy currently points at and who is allowed to change it.
Securing the unseen
The reason to learn this is not to write faster contracts. It is to know which protections you are switching off when you open an assembly block.
An assembly block is a region where arithmetic no longer checks itself, types no longer exist, memory is yours to corrupt, and one annotation silently changes how the rest of your contract is compiled. Every one of those is fine if you know it. Each one is a finding if you do not.
Learn more at 0xhades.io/research
Sources
3 references · 3 primary-
Solidity documentation, Inline Assembly and Yul
assemblyblocks contain Yul; named variables,if,switch,forand functions;dup,swapandjumpunavailable because Yul manages the stack docs.soliditylang.org primary -
Solidity documentation, Layout in Memory
0x00–0x3fscratch space,0x40–0x5ffree memory pointer,0x60–0x7fzero slot, allocation begins at0x80docs.soliditylang.org primary - Solidity documentation, memory-safe assembly the annotation is an unverified assertion by the author; enables the stack-to-memory mover under the IR pipeline docs.soliditylang.org primary