Skip to main content
BlockchainMar 28, 2026

Deep EVM #6: Yul Memory Management — mstore, mload, and Free Memory Pointer

OS
Open Soft Team

Engineering Team

Memory Management Without a Garbage Collector

In Yul, you are the memory manager. There is no allocator, no garbage collector, no bounds checking. Memory is a flat byte array that you read from and write to using MLOAD and MSTORE. If you write to the wrong offset, you corrupt your own data. If you read from an uninitialized offset, you get garbage.

This level of control is exactly what makes Yul powerful for gas optimization — and exactly what makes it dangerous.

MSTORE, MLOAD, and MSTORE8

Three opcodes handle memory access:

// MSTORE(offset, value) — write 32 bytes at offset
mstore(0x00, 0xdeadbeef)  // Stores 0x00...00deadbeef at offset 0

// MLOAD(offset) — read 32 bytes from offset
let value := mload(0x00)  // value = 0x00...00deadbeef

// MSTORE8(offset, value) — write 1 byte at offset
mstore8(0x00, 0xff)  // Stores 0xff at byte 0

Critical detail: MSTORE writes big-endian. The value 0xdeadbeef stored at offset 0x00 occupies bytes 28-31 (the least significant 4 bytes of the 32-byte word). The first 28 bytes are zeros.

Memory after mstore(0x00, 0xdeadbeef):
Offset: 00 01 02 03 ... 1b 1c 1d 1e 1f
Value:  00 00 00 00 ... 00 de ad be ef

The Free Memory Pointer (0x40)

Solidity initializes the free memory pointer at offset 0x40 to the value 0x80. This means usable memory starts at offset 0x80, with 0x00-0x7f reserved:

Memory layout (Solidity convention):
0x00-0x1f: Scratch space (temporary, used for hashing)
0x20-0x3f: Scratch space (temporary)
0x40-0x5f: Free memory pointer (initially 0x80)
0x60-0x7f: Zero slot (always zero, used for default values)
0x80+:     Free memory (your allocations start here)

When you mix Yul with Solidity, you must respect this convention. If Solidity code runs after your Yul block, it expects the free memory pointer to accurately track the next free byte.

Allocating Memory in Yul

function allocate(size) -> ptr {
    ptr := mload(0x40)            // Read current free memory pointer
    mstore(0x40, add(ptr, size))  // Advance it by 'size' bytes
}

// Allocate 64 bytes
let buffer := allocate(64)
mstore(buffer, 0x1234)  // Write to allocated memory

When to Ignore the Free Memory Pointer

In pure Yul contracts (no Solidity), you can ignore the free memory pointer entirely and manage memory manually. Many MEV bots do this:

// Pure Yul contract — use memory however you want
// No Solidity, no free memory pointer convention
mstore(0x00, calldataload(0))   // Use 0x00 directly
mstore(0x20, calldataload(32))  // Use 0x20 directly

This saves gas: no MLOAD(0x40) and no pointer update. But you must track your own memory layout.

Building ABI-Encoded Calldata in Yul

One of the most common Yul tasks is building calldata for external calls. Let us construct a transfer(address,uint256) call:

// transfer(address to, uint256 amount)
// Selector: 0xa9059cbb

function encodeTransfer(to, amount) -> ptr, size {
    ptr := mload(0x40)  // allocate from free memory

    // Byte 0-3: function selector
    mstore(ptr, 0xa9059cbb00000000000000000000000000000000000000000000000000000000)

    // Byte 4-35: address (left-padded to 32 bytes)
    mstore(add(ptr, 0x04), to)

    // Byte 36-67: uint256 amount
    mstore(add(ptr, 0x24), amount)

    size := 0x44  // 4 + 32 + 32 = 68 bytes
    mstore(0x40, add(ptr, size))  // update free memory pointer
}

Wait — there is a subtle bug above. When we mstore(ptr, selector), we write 32 bytes starting at ptr. Then mstore(add(ptr, 0x04), to) overwrites bytes 4-35, which partially overlaps the selector write. This is actually fine because the second write overwrites bytes 4-31 (which were zeros from the selector padding) with the address value.

But a cleaner approach uses the scratch space:

function doTransfer(token, to, amount) {
    // Use scratch space at 0x00 — we know it is safe to overwrite
    mstore(0x00, 0xa9059cbb00000000000000000000000000000000000000000000000000000000)
    mstore(0x04, to)
    mstore(0x24, amount)

    let success := call(
        gas(),    // forward all gas
        token,    // target contract
        0,        // no ETH value
        0x00,     // calldata starts at memory offset 0
        0x44,     // calldata length: 68 bytes
        0x00,     // return data offset (overwrite scratch space)
        0x20      // return data length: 32 bytes
    )

    // Check success and return value
    if iszero(and(success, or(
        iszero(returndatasize()),                    // no return data (USDT)
        and(gt(returndatasize(), 31), mload(0x00))   // returned true
    ))) {
        revert(0, 0)
    }
}

This pattern — store calldata at 0x00, call, check return — is the standard for gas-efficient token transfers in MEV bots.

Handling Dynamic Types: bytes and string

Dynamic types in ABI encoding use an offset-pointer structure:

// abi.encode(uint256 x, bytes memory data, uint256 y)
// Layout in memory:
// 0x00: x (value directly)
// 0x20: offset to 'data' (points to 0x60)
// 0x40: y (value directly)
// 0x60: length of 'data'
// 0x80: actual bytes of 'data' (padded to 32-byte boundary)

Encoding this in Yul:

function encodeMixed(x, dataPtr, dataLen, y) -> outPtr, outSize {
    outPtr := mload(0x40)

    // Static part
    mstore(outPtr, x)                           // word 0: x
    mstore(add(outPtr, 0x20), 0x60)            // word 1: offset to data (3 * 32 = 0x60)
    mstore(add(outPtr, 0x40), y)               // word 2: y

    // Dynamic part
    mstore(add(outPtr, 0x60), dataLen)         // word 3: data length

    // Copy data bytes
    let words := div(add(dataLen, 31), 32)     // round up to 32-byte words
    for { let i := 0 } lt(i, words) { i := add(i, 1) } {
        mstore(
            add(add(outPtr, 0x80), mul(i, 0x20)),
            mload(add(dataPtr, mul(i, 0x20)))
        )
    }

    outSize := add(0x80, mul(words, 0x20))
    mstore(0x40, add(outPtr, outSize))
}

Memory-Efficient Hashing

Keccak256 hashing is a frequent operation (storage slot computation, event topics, signatures). In Yul, you control exactly what gets hashed:

// Hash a mapping key: keccak256(abi.encode(key, slot))
function getMappingSlot(key, baseSlot) -> slot {
    mstore(0x00, key)
    mstore(0x20, baseSlot)
    slot := keccak256(0x00, 0x40)  // hash 64 bytes starting at 0x00
}

// Hash a nested mapping: mapping[key1][key2]
function getNestedMappingSlot(key1, key2, baseSlot) -> slot {
    // First level: keccak256(key1 . baseSlot)
    mstore(0x00, key1)
    mstore(0x20, baseSlot)
    let intermediate := keccak256(0x00, 0x40)

    // Second level: keccak256(key2 . intermediate)
    mstore(0x00, key2)
    mstore(0x20, intermediate)
    slot := keccak256(0x00, 0x40)
}

Using scratch space (0x00-0x3f) for hashing is a common pattern because it avoids advancing the free memory pointer.

Copying Data Between Memory Regions

Solidity’s abi.encode and bytes concatenation generate memory copies. In Yul, you can do this manually:

// Copy 'length' bytes from 'src' to 'dst'
function memcpy(dst, src, length) {
    // Copy 32-byte chunks
    for { let i := 0 } lt(i, length) { i := add(i, 0x20) } {
        mstore(add(dst, i), mload(add(src, i)))
    }
}

For calldata-to-memory copies, use CALLDATACOPY which is cheaper than a loop:

// Copy calldata to memory (single opcode, cheaper than loop)
calldatacopy(destOffset, calldataOffset, length)

Similarly, CODECOPY and RETURNDATACOPY are single opcodes that are more gas-efficient than loops.

Memory Expansion Cost Revisited

Recall from the previous article that memory has a quadratic expansion cost. In Yul, you must be aware of the high-water mark:

// This is CHEAP (memory already expanded to 0x80 by Solidity init)
mstore(0x00, 42)

// This is EXPENSIVE (first access beyond current memory size)
mstore(0x10000, 42)  // Expands memory to 64KB, costs ~1600 gas just for expansion

// Reading also triggers expansion!
let x := mload(0x20000)  // Expands memory to 128KB

Always use the lowest memory offsets possible. In pure Yul contracts, start at 0x00 and allocate sequentially.

Pattern: Returndata Forwarding

After an external call, you often need to forward the return data. Here is the gas-optimal pattern:

// Call target and forward return data (or revert data)
let success := call(gas(), target, value, inOffset, inSize, 0, 0)
let size := returndatasize()
returndatacopy(0x00, 0x00, size)

switch success
case 0 { revert(0x00, size) }
default { return(0x00, size) }

This pattern uses zero for retOffset and retSize in the CALL (saving gas by not pre-allocating return buffer), then copies return data afterward. This is the standard pattern in proxy contracts.

Pattern: Compact Error Handling

// Custom error: InsufficientBalance(uint256 available, uint256 required)
// Selector: keccak256("InsufficientBalance(uint256,uint256)") >> 224
function revertInsufficientBalance(available, required) {
    mstore(0x00, 0x0a087903)  // error selector (4 bytes, left-aligned would need shl)
    mstore(0x04, available)
    mstore(0x24, required)
    revert(0x00, 0x44)  // revert with 68 bytes
}

This costs significantly less gas than Solidity’s require(balance >= amount, "Insufficient balance") because there is no string storage or ABI encoding of the string.

Real-World Example: Extracting Uniswap V2 Reserves

function getReserves(pair) -> reserve0, reserve1 {
    // getReserves() selector: 0x0902f1ac
    mstore(0x00, 0x0902f1ac00000000000000000000000000000000000000000000000000000000)

    let success := staticcall(gas(), pair, 0x00, 0x04, 0x00, 0x60)
    if iszero(success) { revert(0, 0) }

    reserve0 := mload(0x00)
    reserve1 := mload(0x20)
    // Third return value (blockTimestampLast) at 0x40 — ignored
}

This is how MEV bots read pool reserves: a single staticcall with minimal memory usage, no ABI decoding overhead, and the result is immediately available as stack variables.

Conclusion

Memory management in Yul is manual, precise, and powerful. The patterns in this article — scratch space usage, calldata construction, hash computation, returndata forwarding — are the building blocks of every gas-optimized smart contract. Master these, and you can build anything the EVM can execute.

In the next article, we tackle control flow: gas-efficient loops, conditionals, and the specific patterns that minimize gas in iteration-heavy code.