Deep EVM #5: Introduction to Yul — Solidity's Secret Assembly Language
Engineering Team
What Is Yul and Why Does It Exist
Yul is an intermediate language designed by the Solidity team that compiles to EVM bytecode (and, in theory, to eWASM). It sits between Solidity and raw opcodes: you get direct access to every EVM opcode through a readable, structured syntax with variables, functions, if/switch/for constructs, and scoped blocks.
Yul exists because Solidity’s compiler generates conservative bytecode. Overflow checks, ABI encoding, memory management — all useful for application contracts but unacceptable overhead for gas-critical code like MEV bots, DEX routers, or on-chain math libraries.
When you write assembly { ... } inside Solidity, you are writing Yul.
Yul Syntax Fundamentals
Yul’s syntax is minimal. There are no types — everything is a 256-bit word. There are no arrays, no structs, no inheritance. You have:
Variables
let x := 42
let y := add(x, 1) // y = 43
let z := mul(x, y) // z = 42 * 43 = 1806
Variables are scoped to the enclosing block { }. They live on the stack. The Yul compiler manages stack layout for you (unlike Huff, where you manage the stack manually).
Functions
function safeAdd(a, b) -> result {
result := add(a, b)
if lt(result, a) {
revert(0, 0) // overflow
}
}
let sum := safeAdd(100, 200)
Functions can have multiple return values:
function divmod(a, b) -> quotient, remainder {
quotient := div(a, b)
remainder := mod(a, b)
}
let q, r := divmod(17, 5) // q = 3, r = 2
Control Flow
If (no else in Yul!):
if iszero(calldatasize()) {
revert(0, 0)
}
Switch (the else equivalent):
switch selector
case 0xa9059cbb { // transfer(address,uint256)
// handle transfer
}
case 0x70a08231 { // balanceOf(address)
// handle balanceOf
}
default {
revert(0, 0)
}
For loops:
for { let i := 0 } lt(i, 10) { i := add(i, 1) } {
// loop body
}
Inline Assembly in Solidity
The most common way to use Yul is inside Solidity’s assembly { } blocks. Solidity variables are accessible within the block:
function getBalance(address account) external view returns (uint256 bal) {
bytes32 slot;
assembly {
// balances mapping is at storage slot 0
// balances[account] = keccak256(account . slot_0)
mstore(0x00, account)
mstore(0x20, 0) // slot number
slot := keccak256(0x00, 0x40)
bal := sload(slot)
}
}
This bypasses Solidity’s mapping accessor, saving gas from ABI encoding and bounds checks.
Reading and Writing Storage Directly
contract DirectStorage {
// slot 0: owner (address, 20 bytes)
// slot 1: totalSupply (uint256)
// slot 2: balances mapping base
function getOwner() external view returns (address o) {
assembly {
o := sload(0) // Read slot 0 directly
}
}
function unsafeSetOwner(address newOwner) external {
assembly {
// No access control! For demonstration only.
sstore(0, newOwner) // Write slot 0 directly
}
}
}
Emitting Events in Yul
Events in Yul are emitted using LOG opcodes (LOG0 through LOG4, where the number is the count of indexed topics):
event Transfer(address indexed from, address indexed to, uint256 value);
function emitTransfer(address from, address to, uint256 value) internal {
assembly {
// Transfer(address,address,uint256) topic:
let sig := 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
// Store non-indexed data in memory
mstore(0x00, value)
// LOG3: 3 topics (event sig + 2 indexed params) + data
log3(
0x00, // memory offset of data
0x20, // data length (32 bytes)
sig, // topic 0: event signature
from, // topic 1: indexed from
to // topic 2: indexed to
)
}
}
This saves gas compared to Solidity’s emit Transfer(from, to, value) because we skip ABI encoding overhead.
When to Use Yul (and When Not To)
Use Yul When:
- Gas is critical — MEV bots, DEX aggregator routers, high-frequency on-chain operations
- You need bitwise operations — Packing/unpacking data, custom encoding
- Memory layout matters — Building calldata for external calls, custom ABI encoding
- Solidity generates suboptimal code — Certain patterns (like copying between memory regions) compile poorly
- You need transient storage — TLOAD/TSTORE before Solidity adds native support
- Proxy contracts — The minimal proxy (EIP-1167) is 45 bytes of hand-optimized Yul
Do Not Use Yul When:
- Readability matters more than gas — Most application contracts
- You are not confident in your EVM knowledge — Yul has no safety nets
- The gas savings are negligible — If saving 200 gas on a 500,000 gas function, it is not worth the audit risk
- You need complex data structures — Yul has no arrays, structs, or mappings at the language level
Practical Example: Efficient Multicall
Here is a real-world example — a gas-efficient multicall function that executes multiple calls in a single transaction:
function multicall(bytes[] calldata calls) external returns (bytes[] memory results) {
results = new bytes[](calls.length);
for (uint256 i = 0; i < calls.length;) {
(bool success, bytes memory result) = address(this).delegatecall(calls[i]);
require(success);
results[i] = result;
unchecked { ++i; }
}
}
// Yul-optimized version:
function multicallOptimized(bytes[] calldata calls) external {
assembly {
let len := calls.length
let dataOffset := calls.offset
for { let i := 0 } lt(i, len) { i := add(i, 1) } {
// Get the offset and length of calls[i]
let callOffset := add(dataOffset, calldataload(add(dataOffset, mul(i, 0x20))))
let callLen := calldataload(callOffset)
let callData := add(callOffset, 0x20)
// Copy calldata to memory
calldatacopy(0x00, callData, callLen)
// Execute delegatecall
let success := delegatecall(gas(), address(), 0x00, callLen, 0x00, 0x00)
if iszero(success) {
returndatacopy(0x00, 0x00, returndatasize())
revert(0x00, returndatasize())
}
}
}
}
Practical Example: Reading Uniswap V3 Slot0
For MEV bots, reading pool state quickly is essential:
function getSlot0(address pool) external view returns (
uint160 sqrtPriceX96,
int24 tick,
uint16 observationIndex,
uint16 observationCardinality,
uint16 observationCardinalityNext,
uint8 feeProtocol,
bool unlocked
) {
assembly {
// Prepare staticcall to pool.slot0()
mstore(0x00, 0x3850c7bd00000000000000000000000000000000000000000000000000000000)
// Execute staticcall
let success := staticcall(gas(), pool, 0x00, 0x04, 0x00, 0xe0)
if iszero(success) { revert(0, 0) }
// Read return data directly from memory
sqrtPriceX96 := mload(0x00)
tick := mload(0x20)
observationIndex := mload(0x40)
observationCardinality := mload(0x60)
observationCardinalityNext := mload(0x80)
feeProtocol := mload(0xa0)
unlocked := mload(0xc0)
}
}
Yul vs Huff: Where Yul Falls Short
Yul is a significant step toward the metal, but it is not the final step. Compared to Huff:
| Feature | Yul | Huff |
|---|---|---|
| Stack management | Automatic (compiler) | Manual |
| Variable names | Yes | No (stack positions) |
| Functions | Yes | Macros |
| Control flow | if/switch/for | JUMPI/JUMPDEST |
| Code size control | Limited | Full |
| Gas overhead | Small (variable management) | Zero |
| Learning curve | Moderate | Steep |
For most gas-optimization work, Yul provides 80-90% of the savings with significantly better readability. Huff is reserved for the most extreme cases: ERC-20 implementations, MEV bot cores, and mathematical libraries where every single gas unit matters.
Common Pitfalls
1. Forgetting Memory Is Not Zeroed
Yul does not zero-initialize memory. If you read from a memory offset before writing to it, you get whatever was there before:
// BUG: memory at 0x100 may contain garbage
let value := mload(0x100) // Could be anything!
2. Misunderstanding Return Data
After a CALL, return data is only valid until the next CALL. If you need the data, copy it to memory immediately:
let success := call(gas(), target, 0, 0, 0, 0, 0)
// returndata is available NOW
let size := returndatasize()
returndatacopy(0x00, 0x00, size)
// After the NEXT call, this returndata is gone
3. Stack Too Deep in Large Functions
Yul manages the stack for you, but the EVM stack is only 1024 elements deep with DUP/SWAP limited to 16. Large functions with many variables will fail to compile.
The fix: break large functions into smaller ones, or use memory to store intermediate values.
4. Not Handling Dirty Upper Bits
When you read an address from calldata in Yul, the upper 96 bits may be dirty (nonzero). Always mask:
let addr := calldataload(4) // 32 bytes, but address is only 20 bytes
addr := and(addr, 0xffffffffffffffffffffffffffffffffffffffff) // Mask to 20 bytes
Conclusion
Yul is the bridge between Solidity’s safety and the EVM’s raw power. It gives you opcode-level control with variable names, functions, and structured control flow. For MEV developers, mastering Yul is the difference between a profitable bot and one that loses to competitors on gas efficiency.
In the next article, we dive deep into Yul’s memory management: the free memory pointer, manual ABI encoding, and building external call data from scratch.